feat: add issuance application workflow
parent
4739757d61
commit
34c3d01a65
@ -0,0 +1,33 @@
|
|||||||
|
# Task 4 Report: Issuance Application Workflow
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Implemented Task 4 application commands, query views, commercial-bank orchestration, and the read-only central-bank query. No REST controller or interface DTO was added.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Added create/update commands and commercial-bank inventory/request views under `issuance/application`.
|
||||||
|
- Added transactional commercial-bank operations for create, update, message preparation, SM3 digest recording, SM2 signing with `sm2-key-02`, JSON packaging, and idempotent sending.
|
||||||
|
- Generated request numbers as `ISSUE_REQ_` followed by an upper-case UUID suffix.
|
||||||
|
- Added request/inventory error mapping to `RESOURCE_NOT_FOUND` and domain argument/state error mapping to `VALIDATION_ERROR`.
|
||||||
|
- Added a central-bank read-only query that exposes persisted request receipt state, receipt time, and payload data.
|
||||||
|
- Registered the message composer and SM3/SM2 signing service as Spring beans.
|
||||||
|
- Added focused application tests for end-to-end send visibility, repeated sends, invalid post-prepare update, central-bank payload/receipt lookup, and missing request mapping.
|
||||||
|
|
||||||
|
## Test evidence
|
||||||
|
|
||||||
|
- RED: `mvn '-Dtest=CommercialBankIssuanceApplicationServiceTest,CentralBankIssuanceQueryServiceTest' test -B` failed at test compilation before implementation because the application command, query, and service classes did not exist.
|
||||||
|
- GREEN: the same focused command passed with 5 tests and 0 failures/errors.
|
||||||
|
- JDK 8: `mvn test -B` using Temurin `1.8.0_502` passed with 45 tests and 0 failures/errors.
|
||||||
|
- During full-suite verification, Spring reported an ambiguous service constructor. The application-context failure reproduced the problem; annotating the intended three-dependency constructor with `@Autowired` restored application-context startup. The focused and full JDK 8 suites were then rerun successfully.
|
||||||
|
- `git diff --check` completed without whitespace errors.
|
||||||
|
|
||||||
|
## Commit
|
||||||
|
|
||||||
|
`feat: add issuance application workflow`
|
||||||
|
|
||||||
|
## Review and concerns
|
||||||
|
|
||||||
|
- The repository's existing MyBatis audit handler derives `created_by` and `updated_by` from the authenticated Spring Security account; application operation signatures retain the authenticated account name expected by the next REST layer.
|
||||||
|
- JSON payload includes the required request identity, bank/organization fields, denomination rows, currency, timestamp, digest, and signature. `orgCode` and `bankCode` both carry the aggregate's commercial-bank code to support either naming convention at the integration boundary.
|
||||||
|
- No known Task 4 scope issues remain.
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
package com.yau.digitalrmb.issuance.application;
|
||||||
|
|
||||||
|
import com.yau.digitalrmb.issuance.domain.service.IssuanceMessageComposer;
|
||||||
|
import com.yau.digitalrmb.issuance.domain.service.IssuanceSignatureService;
|
||||||
|
import com.yau.digitalrmb.issuance.infrastructure.crypto.BouncyCastleIssuanceSignatureService;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class IssuanceApplicationConfiguration {
|
||||||
|
@Bean
|
||||||
|
public IssuanceMessageComposer issuanceMessageComposer() {
|
||||||
|
return new IssuanceMessageComposer();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public IssuanceSignatureService issuanceSignatureService() {
|
||||||
|
return new BouncyCastleIssuanceSignatureService();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
package com.yau.digitalrmb.issuance.application.command;
|
||||||
|
|
||||||
|
import com.yau.digitalrmb.issuance.domain.model.DenominationItem;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class CreateIssuanceRequestCommand {
|
||||||
|
private final String bankCode;
|
||||||
|
private final String organizationId;
|
||||||
|
private final BigDecimal totalAmount;
|
||||||
|
private final String currency;
|
||||||
|
private final List<DenominationItem> denominations;
|
||||||
|
|
||||||
|
public CreateIssuanceRequestCommand(String bankCode, String organizationId, BigDecimal totalAmount, String currency,
|
||||||
|
List<DenominationItem> denominations) {
|
||||||
|
this.bankCode = bankCode;
|
||||||
|
this.organizationId = organizationId;
|
||||||
|
this.totalAmount = totalAmount;
|
||||||
|
this.currency = currency;
|
||||||
|
this.denominations = denominations == null ? null
|
||||||
|
: Collections.unmodifiableList(new ArrayList<DenominationItem>(denominations));
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getBankCode() { return bankCode; }
|
||||||
|
public String getOrganizationId() { return organizationId; }
|
||||||
|
public BigDecimal getTotalAmount() { return totalAmount; }
|
||||||
|
public String getCurrency() { return currency; }
|
||||||
|
public List<DenominationItem> getDenominations() { return denominations; }
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
package com.yau.digitalrmb.issuance.application.command;
|
||||||
|
|
||||||
|
import com.yau.digitalrmb.issuance.domain.model.DenominationItem;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class UpdateIssuanceRequestCommand {
|
||||||
|
private final BigDecimal totalAmount;
|
||||||
|
private final String currency;
|
||||||
|
private final List<DenominationItem> denominations;
|
||||||
|
|
||||||
|
public UpdateIssuanceRequestCommand(BigDecimal totalAmount, String currency, List<DenominationItem> denominations) {
|
||||||
|
this.totalAmount = totalAmount;
|
||||||
|
this.currency = currency;
|
||||||
|
this.denominations = denominations == null ? null
|
||||||
|
: Collections.unmodifiableList(new ArrayList<DenominationItem>(denominations));
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getTotalAmount() { return totalAmount; }
|
||||||
|
public String getCurrency() { return currency; }
|
||||||
|
public List<DenominationItem> getDenominations() { return denominations; }
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package com.yau.digitalrmb.issuance.application.query;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
public final class CommercialBankInventoryView {
|
||||||
|
private final String bankCode;
|
||||||
|
private final BigDecimal currentBalance;
|
||||||
|
private final BigDecimal warningThreshold;
|
||||||
|
private final BigDecimal suggestedSupplementAmount;
|
||||||
|
|
||||||
|
public CommercialBankInventoryView(String bankCode, BigDecimal currentBalance, BigDecimal warningThreshold,
|
||||||
|
BigDecimal suggestedSupplementAmount) {
|
||||||
|
this.bankCode = bankCode;
|
||||||
|
this.currentBalance = currentBalance;
|
||||||
|
this.warningThreshold = warningThreshold;
|
||||||
|
this.suggestedSupplementAmount = suggestedSupplementAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getBankCode() { return bankCode; }
|
||||||
|
public BigDecimal getCurrentBalance() { return currentBalance; }
|
||||||
|
public BigDecimal getWarningThreshold() { return warningThreshold; }
|
||||||
|
public BigDecimal getSuggestedSupplementAmount() { return suggestedSupplementAmount; }
|
||||||
|
}
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
package com.yau.digitalrmb.issuance.application.query;
|
||||||
|
|
||||||
|
import com.yau.digitalrmb.issuance.domain.model.DenominationItem;
|
||||||
|
import com.yau.digitalrmb.issuance.domain.model.IssuanceRequest;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public final class IssuanceRequestView {
|
||||||
|
private final UUID id;
|
||||||
|
private final String requestNo;
|
||||||
|
private final String bankCode;
|
||||||
|
private final String organizationId;
|
||||||
|
private final BigDecimal totalAmount;
|
||||||
|
private final String currency;
|
||||||
|
private final List<DenominationItem> denominations;
|
||||||
|
private final String requestTimestamp;
|
||||||
|
private final String messageText;
|
||||||
|
private final String digest;
|
||||||
|
private final String signature;
|
||||||
|
private final String signingKeyRef;
|
||||||
|
private final String payloadJson;
|
||||||
|
private final String status;
|
||||||
|
private final String centralBankReceiveStatus;
|
||||||
|
private final Instant centralBankReceivedAt;
|
||||||
|
|
||||||
|
private IssuanceRequestView(IssuanceRequest request) {
|
||||||
|
this.id = request.getId().value();
|
||||||
|
this.requestNo = request.getRequestNo();
|
||||||
|
this.bankCode = request.getBankCode();
|
||||||
|
this.organizationId = request.getOrganizationId();
|
||||||
|
this.totalAmount = request.getTotalAmount();
|
||||||
|
this.currency = request.getCurrency();
|
||||||
|
this.denominations = Collections.unmodifiableList(new ArrayList<DenominationItem>(request.getDenominations()));
|
||||||
|
this.requestTimestamp = request.getRequestTimestamp();
|
||||||
|
this.messageText = request.getMessageText();
|
||||||
|
this.digest = request.getDigest();
|
||||||
|
this.signature = request.getSignature();
|
||||||
|
this.signingKeyRef = request.getSigningKeyRef();
|
||||||
|
this.payloadJson = request.getPayloadJson();
|
||||||
|
this.status = request.getStatus().name();
|
||||||
|
this.centralBankReceiveStatus = request.getCentralBankReceiveStatus().name();
|
||||||
|
this.centralBankReceivedAt = request.getCentralBankReceivedAt();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IssuanceRequestView from(IssuanceRequest request) { return new IssuanceRequestView(request); }
|
||||||
|
|
||||||
|
public UUID getId() { return id; }
|
||||||
|
public String getRequestNo() { return requestNo; }
|
||||||
|
public String getBankCode() { return bankCode; }
|
||||||
|
public String getOrganizationId() { return organizationId; }
|
||||||
|
public BigDecimal getTotalAmount() { return totalAmount; }
|
||||||
|
public String getCurrency() { return currency; }
|
||||||
|
public List<DenominationItem> getDenominations() { return denominations; }
|
||||||
|
public String getRequestTimestamp() { return requestTimestamp; }
|
||||||
|
public String getMessageText() { return messageText; }
|
||||||
|
public String getDigest() { return digest; }
|
||||||
|
public String getSignature() { return signature; }
|
||||||
|
public String getSigningKeyRef() { return signingKeyRef; }
|
||||||
|
public String getPayloadJson() { return payloadJson; }
|
||||||
|
public String getStatus() { return status; }
|
||||||
|
public String getCentralBankReceiveStatus() { return centralBankReceiveStatus; }
|
||||||
|
public Instant getCentralBankReceivedAt() { return centralBankReceivedAt; }
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
package com.yau.digitalrmb.issuance.application.service;
|
||||||
|
|
||||||
|
import com.yau.digitalrmb.issuance.application.query.IssuanceRequestView;
|
||||||
|
import com.yau.digitalrmb.issuance.domain.model.IssuanceApplicationId;
|
||||||
|
import com.yau.digitalrmb.issuance.domain.model.IssuanceRequest;
|
||||||
|
import com.yau.digitalrmb.issuance.domain.repository.IssuanceRequestRepository;
|
||||||
|
import com.yau.digitalrmb.shared.api.ErrorCode;
|
||||||
|
import com.yau.digitalrmb.shared.exception.BusinessException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class CentralBankIssuanceQueryService {
|
||||||
|
private final IssuanceRequestRepository repository;
|
||||||
|
|
||||||
|
public CentralBankIssuanceQueryService(IssuanceRequestRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public IssuanceRequestView getCentralBankView(UUID id) {
|
||||||
|
return IssuanceRequestView.from(requireRequest(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private IssuanceRequest requireRequest(UUID id) {
|
||||||
|
if (id == null) {
|
||||||
|
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "request id must not be null");
|
||||||
|
}
|
||||||
|
return repository.findById(new IssuanceApplicationId(id)).orElseThrow(() ->
|
||||||
|
new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "issuance request was not found: " + id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,213 @@
|
|||||||
|
package com.yau.digitalrmb.issuance.application.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.yau.digitalrmb.issuance.application.command.CreateIssuanceRequestCommand;
|
||||||
|
import com.yau.digitalrmb.issuance.application.command.UpdateIssuanceRequestCommand;
|
||||||
|
import com.yau.digitalrmb.issuance.application.query.CommercialBankInventoryView;
|
||||||
|
import com.yau.digitalrmb.issuance.application.query.IssuanceRequestView;
|
||||||
|
import com.yau.digitalrmb.issuance.domain.model.DenominationItem;
|
||||||
|
import com.yau.digitalrmb.issuance.domain.model.IssuanceApplicationId;
|
||||||
|
import com.yau.digitalrmb.issuance.domain.model.IssuanceBankInventory;
|
||||||
|
import com.yau.digitalrmb.issuance.domain.model.IssuanceRequest;
|
||||||
|
import com.yau.digitalrmb.issuance.domain.repository.IssuanceRequestRepository;
|
||||||
|
import com.yau.digitalrmb.issuance.domain.service.IssuanceMessageComposer;
|
||||||
|
import com.yau.digitalrmb.issuance.domain.service.IssuanceSignatureService;
|
||||||
|
import com.yau.digitalrmb.issuance.domain.service.SignedIssuancePayload;
|
||||||
|
import com.yau.digitalrmb.shared.api.ErrorCode;
|
||||||
|
import com.yau.digitalrmb.shared.exception.BusinessException;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class CommercialBankIssuanceApplicationService {
|
||||||
|
private static final String SIGNING_KEY_REF = "sm2-key-02";
|
||||||
|
private static final DateTimeFormatter TIMESTAMP_FORMAT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||||
|
|
||||||
|
private final IssuanceRequestRepository repository;
|
||||||
|
private final IssuanceMessageComposer messageComposer;
|
||||||
|
private final IssuanceSignatureService signatureService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public CommercialBankIssuanceApplicationService(IssuanceRequestRepository repository,
|
||||||
|
IssuanceMessageComposer messageComposer,
|
||||||
|
IssuanceSignatureService signatureService) {
|
||||||
|
this(repository, messageComposer, signatureService, new ObjectMapper());
|
||||||
|
}
|
||||||
|
|
||||||
|
CommercialBankIssuanceApplicationService(IssuanceRequestRepository repository,
|
||||||
|
IssuanceMessageComposer messageComposer,
|
||||||
|
IssuanceSignatureService signatureService, ObjectMapper objectMapper) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.messageComposer = messageComposer;
|
||||||
|
this.signatureService = signatureService;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public IssuanceRequestView create(CreateIssuanceRequestCommand command, String authenticatedAccountName) {
|
||||||
|
try {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
IssuanceRequest request = IssuanceRequest.create(new IssuanceApplicationId(id), requestNo(id),
|
||||||
|
command.getBankCode(), command.getOrganizationId(), command.getTotalAmount(), command.getCurrency(),
|
||||||
|
command.getDenominations());
|
||||||
|
repository.save(request);
|
||||||
|
return IssuanceRequestView.from(request);
|
||||||
|
} catch (IllegalArgumentException | IllegalStateException | NullPointerException exception) {
|
||||||
|
throw validationError(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public IssuanceRequestView update(UUID id, UpdateIssuanceRequestCommand command, String authenticatedAccountName) {
|
||||||
|
try {
|
||||||
|
IssuanceRequest request = requireRequest(id);
|
||||||
|
request.updateDraft(command.getTotalAmount(), command.getCurrency(), command.getDenominations());
|
||||||
|
repository.save(request);
|
||||||
|
return IssuanceRequestView.from(request);
|
||||||
|
} catch (IllegalArgumentException | IllegalStateException | NullPointerException exception) {
|
||||||
|
throw validationError(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public IssuanceRequestView prepareMessage(UUID id, String authenticatedAccountName) {
|
||||||
|
try {
|
||||||
|
IssuanceRequest request = requireRequest(id);
|
||||||
|
String timestamp = LocalDateTime.now().format(TIMESTAMP_FORMAT);
|
||||||
|
String message = messageComposer.compose(request.getBankCode(), request.getOrganizationId(),
|
||||||
|
request.getTotalAmount(), request.getDenominations(), request.getCurrency(), timestamp);
|
||||||
|
request.prepareMessage(timestamp, message);
|
||||||
|
repository.save(request);
|
||||||
|
return IssuanceRequestView.from(request);
|
||||||
|
} catch (IllegalArgumentException | IllegalStateException | NullPointerException exception) {
|
||||||
|
throw validationError(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public IssuanceRequestView digest(UUID id, String authenticatedAccountName) {
|
||||||
|
try {
|
||||||
|
IssuanceRequest request = requireRequest(id);
|
||||||
|
SignedIssuancePayload signedPayload = signatureService.sign(SIGNING_KEY_REF, request.getMessageText());
|
||||||
|
request.recordDigest(signedPayload.getDigest());
|
||||||
|
repository.save(request);
|
||||||
|
return IssuanceRequestView.from(request);
|
||||||
|
} catch (IllegalArgumentException | IllegalStateException | NullPointerException exception) {
|
||||||
|
throw validationError(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public IssuanceRequestView sign(UUID id, String authenticatedAccountName) {
|
||||||
|
try {
|
||||||
|
IssuanceRequest request = requireRequest(id);
|
||||||
|
SignedIssuancePayload signedPayload = signatureService.sign(SIGNING_KEY_REF, request.getMessageText());
|
||||||
|
request.recordSignature(signedPayload.getSigningKeyRef(), signedPayload.getSignature());
|
||||||
|
repository.save(request);
|
||||||
|
return IssuanceRequestView.from(request);
|
||||||
|
} catch (IllegalArgumentException | IllegalStateException | NullPointerException exception) {
|
||||||
|
throw validationError(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public IssuanceRequestView packagePayload(UUID id, String authenticatedAccountName) {
|
||||||
|
try {
|
||||||
|
IssuanceRequest request = requireRequest(id);
|
||||||
|
request.packagePayload(payloadFor(request));
|
||||||
|
repository.save(request);
|
||||||
|
return IssuanceRequestView.from(request);
|
||||||
|
} catch (IllegalArgumentException | IllegalStateException | NullPointerException exception) {
|
||||||
|
throw validationError(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public IssuanceRequestView send(UUID id, String authenticatedAccountName) {
|
||||||
|
try {
|
||||||
|
IssuanceRequest request = requireRequest(id);
|
||||||
|
request.sendToCentralBank(Instant.now());
|
||||||
|
repository.save(request);
|
||||||
|
return IssuanceRequestView.from(request);
|
||||||
|
} catch (IllegalArgumentException | IllegalStateException | NullPointerException exception) {
|
||||||
|
throw validationError(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public IssuanceRequestView getCommercialBankView(UUID id) {
|
||||||
|
return IssuanceRequestView.from(requireRequest(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public CommercialBankInventoryView getInventory(String bankCode) {
|
||||||
|
if (bankCode == null) {
|
||||||
|
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "bank code must not be null");
|
||||||
|
}
|
||||||
|
IssuanceBankInventory inventory = repository.findInventoryByBankCode(bankCode).orElseThrow(() ->
|
||||||
|
new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "issuance bank inventory was not found: " + bankCode));
|
||||||
|
return new CommercialBankInventoryView(inventory.getBankCode(), inventory.getCurrentBalance(),
|
||||||
|
inventory.getWarningThreshold(), inventory.getSuggestedSupplementAmount());
|
||||||
|
}
|
||||||
|
|
||||||
|
private IssuanceRequest requireRequest(UUID id) {
|
||||||
|
if (id == null) {
|
||||||
|
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "request id must not be null");
|
||||||
|
}
|
||||||
|
return repository.findById(new IssuanceApplicationId(id)).orElseThrow(() ->
|
||||||
|
new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "issuance request was not found: " + id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String payloadFor(IssuanceRequest request) {
|
||||||
|
Map<String, Object> payload = new LinkedHashMap<String, Object>();
|
||||||
|
payload.put("requestId", request.getId().value().toString());
|
||||||
|
payload.put("requestNo", request.getRequestNo());
|
||||||
|
payload.put("orgCode", request.getBankCode());
|
||||||
|
payload.put("bankCode", request.getBankCode());
|
||||||
|
payload.put("orgId", request.getOrganizationId());
|
||||||
|
payload.put("totalAmount", request.getTotalAmount());
|
||||||
|
payload.put("denominations", denominationPayload(request.getDenominations()));
|
||||||
|
payload.put("currency", request.getCurrency());
|
||||||
|
payload.put("timestamp", request.getRequestTimestamp());
|
||||||
|
payload.put("digest", request.getDigest());
|
||||||
|
payload.put("signature", request.getSignature());
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(payload);
|
||||||
|
} catch (JsonProcessingException exception) {
|
||||||
|
throw new IllegalStateException("unable to package issuance payload", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Map<String, Integer>> denominationPayload(List<DenominationItem> denominations) {
|
||||||
|
List<Map<String, Integer>> payload = new ArrayList<Map<String, Integer>>();
|
||||||
|
for (DenominationItem denomination : denominations) {
|
||||||
|
Map<String, Integer> item = new LinkedHashMap<String, Integer>();
|
||||||
|
item.put("denomination", denomination.getDenomination());
|
||||||
|
item.put("quantity", denomination.getQuantity());
|
||||||
|
payload.add(item);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String requestNo(UUID id) {
|
||||||
|
return "ISSUE_REQ_" + id.toString().replace("-", "").toUpperCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BusinessException validationError(RuntimeException exception) {
|
||||||
|
return new BusinessException(ErrorCode.VALIDATION_ERROR, exception.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
package com.yau.digitalrmb.issuance.application.service;
|
||||||
|
|
||||||
|
import com.yau.digitalrmb.issuance.application.query.IssuanceRequestView;
|
||||||
|
import com.yau.digitalrmb.issuance.domain.model.DenominationItem;
|
||||||
|
import com.yau.digitalrmb.issuance.domain.model.IssuanceApplicationId;
|
||||||
|
import com.yau.digitalrmb.issuance.domain.model.IssuanceBankInventory;
|
||||||
|
import com.yau.digitalrmb.issuance.domain.model.IssuanceRequest;
|
||||||
|
import com.yau.digitalrmb.issuance.domain.repository.IssuanceRequestRepository;
|
||||||
|
import com.yau.digitalrmb.shared.api.ErrorCode;
|
||||||
|
import com.yau.digitalrmb.shared.exception.BusinessException;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class CentralBankIssuanceQueryServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exposesReceiveStatusTimeAndPayloadForSentRequest() {
|
||||||
|
UUID requestId = UUID.randomUUID();
|
||||||
|
IssuanceRequest request = IssuanceRequest.create(new IssuanceApplicationId(requestId), "ISSUE_REQ_ABCDEF",
|
||||||
|
"BKCHCNBJ00001", "ORG_3A4B5C6D7E8F", new BigDecimal("100.00"), "DC",
|
||||||
|
Arrays.asList(new DenominationItem(100, 1)));
|
||||||
|
request.prepareMessage("20260803120000", "ISSUE|...");
|
||||||
|
request.recordDigest("ABC123");
|
||||||
|
request.recordSignature("sm2-key-02", "signature");
|
||||||
|
request.packagePayload("{\"requestId\":\"" + requestId + "\"}");
|
||||||
|
Instant receivedAt = Instant.parse("2026-08-03T12:00:00Z");
|
||||||
|
request.sendToCentralBank(receivedAt);
|
||||||
|
CentralBankIssuanceQueryService service = new CentralBankIssuanceQueryService(new SingleRequestRepository(request));
|
||||||
|
|
||||||
|
IssuanceRequestView view = service.getCentralBankView(requestId);
|
||||||
|
|
||||||
|
assertThat(view.getCentralBankReceiveStatus()).isEqualTo("RECEIVED");
|
||||||
|
assertThat(view.getCentralBankReceivedAt()).isEqualTo(receivedAt);
|
||||||
|
assertThat(view.getPayloadJson()).contains(requestId.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mapsUnknownRequestToResourceNotFound() {
|
||||||
|
CentralBankIssuanceQueryService service = new CentralBankIssuanceQueryService(new SingleRequestRepository(null));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.getCentralBankView(UUID.randomUUID()))
|
||||||
|
.isInstanceOf(BusinessException.class)
|
||||||
|
.extracting(exception -> ((BusinessException) exception).getErrorCode())
|
||||||
|
.isEqualTo(ErrorCode.RESOURCE_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class SingleRequestRepository implements IssuanceRequestRepository {
|
||||||
|
private final IssuanceRequest request;
|
||||||
|
|
||||||
|
private SingleRequestRepository(IssuanceRequest request) {
|
||||||
|
this.request = request;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void save(IssuanceRequest request) { }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<IssuanceRequest> findById(IssuanceApplicationId id) {
|
||||||
|
return request == null || !request.getId().equals(id) ? Optional.<IssuanceRequest>empty() : Optional.of(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<IssuanceBankInventory> findInventoryByBankCode(String bankCode) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,108 @@
|
|||||||
|
package com.yau.digitalrmb.issuance.application.service;
|
||||||
|
|
||||||
|
import com.yau.digitalrmb.issuance.application.command.CreateIssuanceRequestCommand;
|
||||||
|
import com.yau.digitalrmb.issuance.application.command.UpdateIssuanceRequestCommand;
|
||||||
|
import com.yau.digitalrmb.issuance.application.query.IssuanceRequestView;
|
||||||
|
import com.yau.digitalrmb.issuance.domain.model.DenominationItem;
|
||||||
|
import com.yau.digitalrmb.issuance.domain.model.IssuanceApplicationId;
|
||||||
|
import com.yau.digitalrmb.issuance.domain.model.IssuanceBankInventory;
|
||||||
|
import com.yau.digitalrmb.issuance.domain.model.IssuanceRequest;
|
||||||
|
import com.yau.digitalrmb.issuance.domain.repository.IssuanceRequestRepository;
|
||||||
|
import com.yau.digitalrmb.issuance.domain.service.IssuanceMessageComposer;
|
||||||
|
import com.yau.digitalrmb.issuance.infrastructure.crypto.BouncyCastleIssuanceSignatureService;
|
||||||
|
import com.yau.digitalrmb.shared.api.ErrorCode;
|
||||||
|
import com.yau.digitalrmb.shared.exception.BusinessException;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class CommercialBankIssuanceApplicationServiceTest {
|
||||||
|
|
||||||
|
private final InMemoryIssuanceRequestRepository repository = new InMemoryIssuanceRequestRepository();
|
||||||
|
private final CommercialBankIssuanceApplicationService service = new CommercialBankIssuanceApplicationService(
|
||||||
|
repository, new IssuanceMessageComposer(), new BouncyCastleIssuanceSignatureService());
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendsCommercialBankRequestAndMakesCentralBankViewReceived() {
|
||||||
|
UUID requestId = service.create(createCommand(), "tzs001").getId();
|
||||||
|
|
||||||
|
service.prepareMessage(requestId, "tzs001");
|
||||||
|
service.digest(requestId, "tzs001");
|
||||||
|
service.sign(requestId, "tzs001");
|
||||||
|
service.packagePayload(requestId, "tzs001");
|
||||||
|
IssuanceRequestView sent = service.send(requestId, "tzs001");
|
||||||
|
IssuanceRequestView centralBankView = new CentralBankIssuanceQueryService(repository)
|
||||||
|
.getCentralBankView(requestId);
|
||||||
|
|
||||||
|
assertThat(sent.getStatus()).isEqualTo("SENT");
|
||||||
|
assertThat(sent.getCentralBankReceiveStatus()).isEqualTo("RECEIVED");
|
||||||
|
assertThat(centralBankView.getCentralBankReceiveStatus()).isEqualTo("RECEIVED");
|
||||||
|
assertThat(sent.getRequestNo()).matches("ISSUE_REQ_[0-9A-F]{32}");
|
||||||
|
assertThat(sent.getRequestTimestamp()).matches("\\d{14}");
|
||||||
|
assertThat(sent.getPayloadJson()).contains("\"requestId\":\"" + requestId + "\"");
|
||||||
|
assertThat(sent.getPayloadJson()).contains("\"orgCode\":\"BKCHCNBJ00001\"")
|
||||||
|
.contains("\"orgId\":\"ORG_3A4B5C6D7E8F\"")
|
||||||
|
.contains("\"denominations\"").contains("\"digest\"").contains("\"signature\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void retainsReceivedRequestWhenSendIsRepeated() {
|
||||||
|
UUID requestId = service.create(createCommand(), "tzs001").getId();
|
||||||
|
service.prepareMessage(requestId, "tzs001");
|
||||||
|
service.digest(requestId, "tzs001");
|
||||||
|
service.sign(requestId, "tzs001");
|
||||||
|
service.packagePayload(requestId, "tzs001");
|
||||||
|
IssuanceRequestView firstSend = service.send(requestId, "tzs001");
|
||||||
|
|
||||||
|
IssuanceRequestView repeatedSend = service.send(requestId, "tzs001");
|
||||||
|
|
||||||
|
assertThat(repeatedSend.getStatus()).isEqualTo("SENT");
|
||||||
|
assertThat(repeatedSend.getCentralBankReceiveStatus()).isEqualTo("RECEIVED");
|
||||||
|
assertThat(repeatedSend.getCentralBankReceivedAt()).isEqualTo(firstSend.getCentralBankReceivedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsUpdateAfterMessagePreparation() {
|
||||||
|
UUID requestId = service.create(createCommand(), "tzs001").getId();
|
||||||
|
service.prepareMessage(requestId, "tzs001");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.update(requestId, new UpdateIssuanceRequestCommand(
|
||||||
|
new BigDecimal("200.00"), "DC", Arrays.asList(new DenominationItem(100, 2))), "tzs001"))
|
||||||
|
.isInstanceOf(BusinessException.class)
|
||||||
|
.extracting(exception -> ((BusinessException) exception).getErrorCode())
|
||||||
|
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CreateIssuanceRequestCommand createCommand() {
|
||||||
|
return new CreateIssuanceRequestCommand("BKCHCNBJ00001", "ORG_3A4B5C6D7E8F", new BigDecimal("50000.00"),
|
||||||
|
"DC", Arrays.asList(new DenominationItem(100, 400), new DenominationItem(50, 200)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class InMemoryIssuanceRequestRepository implements IssuanceRequestRepository {
|
||||||
|
private final Map<UUID, IssuanceRequest> requests = new HashMap<UUID, IssuanceRequest>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void save(IssuanceRequest request) {
|
||||||
|
requests.put(request.getId().value(), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<IssuanceRequest> findById(IssuanceApplicationId id) {
|
||||||
|
return Optional.ofNullable(requests.get(id.value()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<IssuanceBankInventory> findInventoryByBankCode(String bankCode) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue