feat: add issuance request aggregate
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…
Reference in New Issue