diff --git a/.superpowers/sdd/2026-08-03-issuance-request-implementation/task-4-report.md b/.superpowers/sdd/2026-08-03-issuance-request-implementation/task-4-report.md new file mode 100644 index 0000000..b0a4b77 --- /dev/null +++ b/.superpowers/sdd/2026-08-03-issuance-request-implementation/task-4-report.md @@ -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. diff --git a/src/main/java/com/yau/digitalrmb/issuance/application/IssuanceApplicationConfiguration.java b/src/main/java/com/yau/digitalrmb/issuance/application/IssuanceApplicationConfiguration.java new file mode 100644 index 0000000..87459ac --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/issuance/application/IssuanceApplicationConfiguration.java @@ -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(); + } +} diff --git a/src/main/java/com/yau/digitalrmb/issuance/application/command/CreateIssuanceRequestCommand.java b/src/main/java/com/yau/digitalrmb/issuance/application/command/CreateIssuanceRequestCommand.java new file mode 100644 index 0000000..80ea3c2 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/issuance/application/command/CreateIssuanceRequestCommand.java @@ -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 denominations; + + public CreateIssuanceRequestCommand(String bankCode, String organizationId, BigDecimal totalAmount, String currency, + List denominations) { + this.bankCode = bankCode; + this.organizationId = organizationId; + this.totalAmount = totalAmount; + this.currency = currency; + this.denominations = denominations == null ? null + : Collections.unmodifiableList(new ArrayList(denominations)); + } + + public String getBankCode() { return bankCode; } + public String getOrganizationId() { return organizationId; } + public BigDecimal getTotalAmount() { return totalAmount; } + public String getCurrency() { return currency; } + public List getDenominations() { return denominations; } +} diff --git a/src/main/java/com/yau/digitalrmb/issuance/application/command/UpdateIssuanceRequestCommand.java b/src/main/java/com/yau/digitalrmb/issuance/application/command/UpdateIssuanceRequestCommand.java new file mode 100644 index 0000000..d1377af --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/issuance/application/command/UpdateIssuanceRequestCommand.java @@ -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 denominations; + + public UpdateIssuanceRequestCommand(BigDecimal totalAmount, String currency, List denominations) { + this.totalAmount = totalAmount; + this.currency = currency; + this.denominations = denominations == null ? null + : Collections.unmodifiableList(new ArrayList(denominations)); + } + + public BigDecimal getTotalAmount() { return totalAmount; } + public String getCurrency() { return currency; } + public List getDenominations() { return denominations; } +} diff --git a/src/main/java/com/yau/digitalrmb/issuance/application/query/CommercialBankInventoryView.java b/src/main/java/com/yau/digitalrmb/issuance/application/query/CommercialBankInventoryView.java new file mode 100644 index 0000000..7137655 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/issuance/application/query/CommercialBankInventoryView.java @@ -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; } +} diff --git a/src/main/java/com/yau/digitalrmb/issuance/application/query/IssuanceRequestView.java b/src/main/java/com/yau/digitalrmb/issuance/application/query/IssuanceRequestView.java new file mode 100644 index 0000000..edbc18f --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/issuance/application/query/IssuanceRequestView.java @@ -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 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(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 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; } +} diff --git a/src/main/java/com/yau/digitalrmb/issuance/application/service/CentralBankIssuanceQueryService.java b/src/main/java/com/yau/digitalrmb/issuance/application/service/CentralBankIssuanceQueryService.java new file mode 100644 index 0000000..46113a3 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/issuance/application/service/CentralBankIssuanceQueryService.java @@ -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)); + } +} diff --git a/src/main/java/com/yau/digitalrmb/issuance/application/service/CommercialBankIssuanceApplicationService.java b/src/main/java/com/yau/digitalrmb/issuance/application/service/CommercialBankIssuanceApplicationService.java new file mode 100644 index 0000000..de20709 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/issuance/application/service/CommercialBankIssuanceApplicationService.java @@ -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 payload = new LinkedHashMap(); + 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> denominationPayload(List denominations) { + List> payload = new ArrayList>(); + for (DenominationItem denomination : denominations) { + Map item = new LinkedHashMap(); + 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()); + } +} diff --git a/src/test/java/com/yau/digitalrmb/issuance/application/service/CentralBankIssuanceQueryServiceTest.java b/src/test/java/com/yau/digitalrmb/issuance/application/service/CentralBankIssuanceQueryServiceTest.java new file mode 100644 index 0000000..0d70fc0 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/issuance/application/service/CentralBankIssuanceQueryServiceTest.java @@ -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 findById(IssuanceApplicationId id) { + return request == null || !request.getId().equals(id) ? Optional.empty() : Optional.of(request); + } + + @Override + public Optional findInventoryByBankCode(String bankCode) { + return Optional.empty(); + } + } +} diff --git a/src/test/java/com/yau/digitalrmb/issuance/application/service/CommercialBankIssuanceApplicationServiceTest.java b/src/test/java/com/yau/digitalrmb/issuance/application/service/CommercialBankIssuanceApplicationServiceTest.java new file mode 100644 index 0000000..6aa1d88 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/issuance/application/service/CommercialBankIssuanceApplicationServiceTest.java @@ -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 requests = new HashMap(); + + @Override + public void save(IssuanceRequest request) { + requests.put(request.getId().value(), request); + } + + @Override + public Optional findById(IssuanceApplicationId id) { + return Optional.ofNullable(requests.get(id.value())); + } + + @Override + public Optional findInventoryByBankCode(String bankCode) { + return Optional.empty(); + } + } +}