From 878c8901713eb27f91a371ea6229bcd8a539cae0 Mon Sep 17 00:00:00 2001 From: chenyuan Date: Mon, 3 Aug 2026 21:04:06 +0800 Subject: [PATCH] feat: add issuance request aggregate --- .../model/CentralBankReceiveStatus.java | 6 + .../domain/model/DenominationItem.java | 45 ++++ .../domain/model/IssuanceRequest.java | 194 ++++++++++++++++++ .../domain/model/IssuanceRequestStatus.java | 10 + .../domain/model/IssuanceRequestTest.java | 117 +++++++++++ 5 files changed, 372 insertions(+) create mode 100644 src/main/java/com/yau/digitalrmb/issuance/domain/model/CentralBankReceiveStatus.java create mode 100644 src/main/java/com/yau/digitalrmb/issuance/domain/model/DenominationItem.java create mode 100644 src/main/java/com/yau/digitalrmb/issuance/domain/model/IssuanceRequest.java create mode 100644 src/main/java/com/yau/digitalrmb/issuance/domain/model/IssuanceRequestStatus.java create mode 100644 src/test/java/com/yau/digitalrmb/issuance/domain/model/IssuanceRequestTest.java diff --git a/src/main/java/com/yau/digitalrmb/issuance/domain/model/CentralBankReceiveStatus.java b/src/main/java/com/yau/digitalrmb/issuance/domain/model/CentralBankReceiveStatus.java new file mode 100644 index 0000000..aa645bc --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/issuance/domain/model/CentralBankReceiveStatus.java @@ -0,0 +1,6 @@ +package com.yau.digitalrmb.issuance.domain.model; + +public enum CentralBankReceiveStatus { + NOT_RECEIVED, + RECEIVED +} diff --git a/src/main/java/com/yau/digitalrmb/issuance/domain/model/DenominationItem.java b/src/main/java/com/yau/digitalrmb/issuance/domain/model/DenominationItem.java new file mode 100644 index 0000000..1e18436 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/issuance/domain/model/DenominationItem.java @@ -0,0 +1,45 @@ +package com.yau.digitalrmb.issuance.domain.model; + +import java.util.Objects; + +public final class DenominationItem { + + private final int denomination; + private final int quantity; + + public DenominationItem(int denomination, int quantity) { + if (denomination <= 0) { + throw new IllegalArgumentException("denomination must be positive"); + } + if (quantity <= 0) { + throw new IllegalArgumentException("quantity must be positive"); + } + this.denomination = denomination; + this.quantity = quantity; + } + + public int getDenomination() { + return denomination; + } + + public int getQuantity() { + return quantity; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof DenominationItem)) { + return false; + } + DenominationItem that = (DenominationItem) other; + return denomination == that.denomination && quantity == that.quantity; + } + + @Override + public int hashCode() { + return Objects.hash(denomination, quantity); + } +} diff --git a/src/main/java/com/yau/digitalrmb/issuance/domain/model/IssuanceRequest.java b/src/main/java/com/yau/digitalrmb/issuance/domain/model/IssuanceRequest.java new file mode 100644 index 0000000..43b357b --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/issuance/domain/model/IssuanceRequest.java @@ -0,0 +1,194 @@ +package com.yau.digitalrmb.issuance.domain.model; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +public class IssuanceRequest { + + private final IssuanceApplicationId id; + private final String bankCode; + private final String organizationId; + private BigDecimal totalAmount; + private String currency; + private List denominations; + private String requestTimestamp; + private String messageText; + private String digest; + private String signature; + private String signingKeyRef; + private String payloadJson; + private IssuanceRequestStatus status; + private CentralBankReceiveStatus centralBankReceiveStatus; + private Instant centralBankReceivedAt; + + private IssuanceRequest(IssuanceApplicationId id, String bankCode, String organizationId, + BigDecimal totalAmount, String currency, List denominations) { + this.id = Objects.requireNonNull(id, "id must not be null"); + this.bankCode = Objects.requireNonNull(bankCode, "bankCode must not be null"); + this.organizationId = Objects.requireNonNull(organizationId, "organizationId must not be null"); + applyDraftValues(totalAmount, currency, denominations); + this.status = IssuanceRequestStatus.DRAFT; + this.centralBankReceiveStatus = CentralBankReceiveStatus.NOT_RECEIVED; + } + + public static IssuanceRequest create(IssuanceApplicationId id, String bankCode, String organizationId, + BigDecimal totalAmount, String currency, + List denominations) { + return new IssuanceRequest(id, bankCode, organizationId, totalAmount, currency, denominations); + } + + public void updateDraft(BigDecimal totalAmount, String currency, List denominations) { + requireStatus(IssuanceRequestStatus.DRAFT); + applyDraftValues(totalAmount, currency, denominations); + } + + public void prepareMessage(String requestTimestamp, String messageText) { + requireStatus(IssuanceRequestStatus.DRAFT); + this.requestTimestamp = Objects.requireNonNull(requestTimestamp, "requestTimestamp must not be null"); + this.messageText = Objects.requireNonNull(messageText, "messageText must not be null"); + this.status = IssuanceRequestStatus.MESSAGE_PREPARED; + } + + public void recordDigest(String digest) { + requireStatus(IssuanceRequestStatus.MESSAGE_PREPARED); + this.digest = Objects.requireNonNull(digest, "digest must not be null"); + this.status = IssuanceRequestStatus.DIGESTED; + } + + public void recordSignature(String signingKeyRef, String signature) { + requireStatus(IssuanceRequestStatus.DIGESTED); + this.signingKeyRef = Objects.requireNonNull(signingKeyRef, "signingKeyRef must not be null"); + this.signature = Objects.requireNonNull(signature, "signature must not be null"); + this.status = IssuanceRequestStatus.SIGNED; + } + + public void packagePayload(String payloadJson) { + requireStatus(IssuanceRequestStatus.SIGNED); + this.payloadJson = Objects.requireNonNull(payloadJson, "payloadJson must not be null"); + this.status = IssuanceRequestStatus.PACKAGED; + } + + public void sendToCentralBank(Instant receivedAt) { + if (status == IssuanceRequestStatus.SENT) { + return; + } + requireStatus(IssuanceRequestStatus.PACKAGED); + Instant validatedReceivedAt = Objects.requireNonNull(receivedAt, "receivedAt must not be null"); + this.status = IssuanceRequestStatus.SENT; + this.centralBankReceiveStatus = CentralBankReceiveStatus.RECEIVED; + this.centralBankReceivedAt = validatedReceivedAt; + } + + public IssuanceApplicationId getId() { + return id; + } + + 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 IssuanceRequestStatus getStatus() { + return status; + } + + public CentralBankReceiveStatus getCentralBankReceiveStatus() { + return centralBankReceiveStatus; + } + + public Instant getCentralBankReceivedAt() { + return centralBankReceivedAt; + } + + private void applyDraftValues(BigDecimal totalAmount, String currency, List denominations) { + BigDecimal normalizedAmount = normalizeAmount(totalAmount); + List copiedDenominations = copyDenominations(denominations); + BigDecimal denominationTotal = calculateDenominationTotal(copiedDenominations); + if (denominationTotal.compareTo(normalizedAmount) != 0) { + throw new IllegalArgumentException("denomination total must equal totalAmount"); + } + this.totalAmount = normalizedAmount; + this.currency = Objects.requireNonNull(currency, "currency must not be null"); + this.denominations = Collections.unmodifiableList(copiedDenominations); + } + + private BigDecimal normalizeAmount(BigDecimal totalAmount) { + BigDecimal amount = Objects.requireNonNull(totalAmount, "totalAmount must not be null") + .setScale(2, RoundingMode.UNNECESSARY); + if (amount.signum() <= 0) { + throw new IllegalArgumentException("totalAmount must be positive"); + } + return amount; + } + + private List copyDenominations(List denominations) { + List copiedDenominations = new ArrayList( + Objects.requireNonNull(denominations, "denominations must not be null")); + if (copiedDenominations.isEmpty()) { + throw new IllegalArgumentException("denominations must not be empty"); + } + for (DenominationItem denomination : copiedDenominations) { + Objects.requireNonNull(denomination, "denomination item must not be null"); + } + return copiedDenominations; + } + + private BigDecimal calculateDenominationTotal(List denominations) { + BigDecimal total = BigDecimal.ZERO; + for (DenominationItem denomination : denominations) { + total = total.add(BigDecimal.valueOf(denomination.getDenomination()) + .multiply(BigDecimal.valueOf(denomination.getQuantity()))); + } + return total.setScale(2); + } + + private void requireStatus(IssuanceRequestStatus expectedStatus) { + if (status != expectedStatus) { + throw new IllegalStateException("expected status " + expectedStatus + " but was " + status); + } + } +} diff --git a/src/main/java/com/yau/digitalrmb/issuance/domain/model/IssuanceRequestStatus.java b/src/main/java/com/yau/digitalrmb/issuance/domain/model/IssuanceRequestStatus.java new file mode 100644 index 0000000..40248da --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/issuance/domain/model/IssuanceRequestStatus.java @@ -0,0 +1,10 @@ +package com.yau.digitalrmb.issuance.domain.model; + +public enum IssuanceRequestStatus { + DRAFT, + MESSAGE_PREPARED, + DIGESTED, + SIGNED, + PACKAGED, + SENT +} diff --git a/src/test/java/com/yau/digitalrmb/issuance/domain/model/IssuanceRequestTest.java b/src/test/java/com/yau/digitalrmb/issuance/domain/model/IssuanceRequestTest.java new file mode 100644 index 0000000..7275fc3 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/issuance/domain/model/IssuanceRequestTest.java @@ -0,0 +1,117 @@ +package com.yau.digitalrmb.issuance.domain.model; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Arrays; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class IssuanceRequestTest { + + private static final IssuanceApplicationId REQUEST_ID = + new IssuanceApplicationId(UUID.fromString("b7f572a0-1e8d-4e3c-8e42-44b5994f0429")); + + @Test + void rejectsDenominationsWhoseTotalDoesNotMatchRequestAmount() { + assertThatThrownBy(() -> IssuanceRequest.create( + REQUEST_ID, + "BKCHCNBJ00001", + "ORG_3A4B5C6D7E8F", + new BigDecimal("50000.00"), + "DC", + Arrays.asList(new DenominationItem(100, 400)))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsNonPositiveDenominationOrQuantity() { + assertThatThrownBy(() -> new DenominationItem(0, 1)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new DenominationItem(100, 0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void sendsOnlyPackagedRequestAndMarksCentralBankAsReceived() { + IssuanceRequest request = preparedSignedAndPackagedRequest(); + Instant receivedAt = Instant.parse("2026-08-03T12:00:00Z"); + + request.sendToCentralBank(receivedAt); + + assertThat(request.getStatus()).isEqualTo(IssuanceRequestStatus.SENT); + assertThat(request.getCentralBankReceiveStatus()).isEqualTo(CentralBankReceiveStatus.RECEIVED); + assertThat(request.getCentralBankReceivedAt()).isEqualTo(receivedAt); + } + + @Test + void rejectsTransitionsThatDoNotFollowTheWorkflow() { + IssuanceRequest request = draftRequest(); + + assertThatThrownBy(() -> request.recordDigest("A1B2")) + .isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> request.sendToCentralBank(Instant.parse("2026-08-03T12:00:00Z"))) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void repeatedSendRetainsOriginalCentralBankReceipt() { + IssuanceRequest request = preparedSignedAndPackagedRequest(); + Instant firstReceivedAt = Instant.parse("2026-08-03T12:00:00Z"); + + request.sendToCentralBank(firstReceivedAt); + request.sendToCentralBank(Instant.parse("2026-08-03T12:01:00Z")); + + assertThat(request.getStatus()).isEqualTo(IssuanceRequestStatus.SENT); + assertThat(request.getCentralBankReceiveStatus()).isEqualTo(CentralBankReceiveStatus.RECEIVED); + assertThat(request.getCentralBankReceivedAt()).isEqualTo(firstReceivedAt); + } + + @Test + void rejectsMissingReceiveTimeWithoutChangingThePackagedRequest() { + IssuanceRequest request = preparedSignedAndPackagedRequest(); + + assertThatThrownBy(() -> request.sendToCentralBank(null)) + .isInstanceOf(NullPointerException.class); + + assertThat(request.getStatus()).isEqualTo(IssuanceRequestStatus.PACKAGED); + assertThat(request.getCentralBankReceiveStatus()).isEqualTo(CentralBankReceiveStatus.NOT_RECEIVED); + assertThat(request.getCentralBankReceivedAt()).isNull(); + } + + @Test + void updatesOnlyDraftAndNormalizesTheRequestAmount() { + IssuanceRequest request = draftRequest(); + + request.updateDraft(new BigDecimal("200.0"), "DC", Arrays.asList(new DenominationItem(100, 2))); + + assertThat(request.getTotalAmount()).isEqualByComparingTo("200.00"); + assertThat(request.getTotalAmount().scale()).isEqualTo(2); + request.prepareMessage("20260803120000", "ISSUE|..."); + assertThatThrownBy(() -> request.updateDraft( + new BigDecimal("200.00"), "DC", Arrays.asList(new DenominationItem(100, 2)))) + .isInstanceOf(IllegalStateException.class); + } + + private IssuanceRequest preparedSignedAndPackagedRequest() { + IssuanceRequest request = draftRequest(); + request.prepareMessage("20260803120000", "ISSUE|BKCHCNBJ00001|ORG_3A4B5C6D7E8F|50000.00|100:400,50:200|DC|20260803120000"); + request.recordDigest("0123456789ABCDEF"); + request.recordSignature("sm2-key-02", "signature"); + request.packagePayload("{\"requestId\":\"b7f572a0-1e8d-4e3c-8e42-44b5994f0429\"}"); + return request; + } + + private IssuanceRequest draftRequest() { + return IssuanceRequest.create( + REQUEST_ID, + "BKCHCNBJ00001", + "ORG_3A4B5C6D7E8F", + new BigDecimal("50000.00"), + "DC", + Arrays.asList(new DenominationItem(100, 400), new DenominationItem(50, 200))); + } +}