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