feat: add issuance request aggregate

chore/issuance-comments-zh
chenyuan 2 months ago
parent 430eb528b6
commit 878c890171

@ -0,0 +1,6 @@
package com.yau.digitalrmb.issuance.domain.model;
public enum CentralBankReceiveStatus {
NOT_RECEIVED,
RECEIVED
}

@ -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);
}
}

@ -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<DenominationItem> 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<DenominationItem> 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<DenominationItem> denominations) {
return new IssuanceRequest(id, bankCode, organizationId, totalAmount, currency, denominations);
}
public void updateDraft(BigDecimal totalAmount, String currency, List<DenominationItem> 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<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 IssuanceRequestStatus getStatus() {
return status;
}
public CentralBankReceiveStatus getCentralBankReceiveStatus() {
return centralBankReceiveStatus;
}
public Instant getCentralBankReceivedAt() {
return centralBankReceivedAt;
}
private void applyDraftValues(BigDecimal totalAmount, String currency, List<DenominationItem> denominations) {
BigDecimal normalizedAmount = normalizeAmount(totalAmount);
List<DenominationItem> 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<DenominationItem> copyDenominations(List<DenominationItem> denominations) {
List<DenominationItem> copiedDenominations = new ArrayList<DenominationItem>(
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<DenominationItem> 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);
}
}
}

@ -0,0 +1,10 @@
package com.yau.digitalrmb.issuance.domain.model;
public enum IssuanceRequestStatus {
DRAFT,
MESSAGE_PREPARED,
DIGESTED,
SIGNED,
PACKAGED,
SENT
}

@ -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)));
}
}
Loading…
Cancel
Save