From e32d479a12719148fbe713dd37e939886d23622d Mon Sep 17 00:00:00 2001 From: Puneethkumar CK Date: Sun, 8 Mar 2026 15:11:01 +0100 Subject: [PATCH] =?UTF-8?q?feat(s3):=20domain=20model=20=E2=80=94=20Collec?= =?UTF-8?q?tionOrder=20aggregate,=20state=20machine,=20events,=20ports=20(?= =?UTF-8?q?STA-120)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes STA-120 Co-Authored-By: Claude Opus 4.6 --- .../event/CollectionCompletedEvent.java | 20 ++ .../domain/event/CollectionFailedEvent.java | 16 + .../event/CollectionInitiatedEvent.java | 19 ++ .../domain/event/RefundCompletedEvent.java | 18 ++ .../domain/event/RefundInitiatedEvent.java | 18 ++ .../onramp/domain/model/AccountType.java | 8 + .../onramp/domain/model/BankAccount.java | 24 ++ .../onramp/domain/model/CollectionOrder.java | 276 ++++++++++++++++++ .../onramp/domain/model/CollectionStatus.java | 14 + .../domain/model/CollectionTrigger.java | 14 + .../payments/onramp/domain/model/Money.java | 15 + .../onramp/domain/model/PaymentRail.java | 16 + .../onramp/domain/model/PaymentRailType.java | 11 + .../onramp/domain/model/PspIdentifier.java | 13 + .../onramp/domain/model/PspTransaction.java | 73 +++++ .../domain/model/PspTransactionDirection.java | 6 + .../payments/onramp/domain/model/Refund.java | 114 ++++++++ .../onramp/domain/model/RefundStatus.java | 8 + .../domain/port/CollectionEventPublisher.java | 6 + .../port/CollectionOrderRepository.java | 15 + .../onramp/domain/port/PspGateway.java | 8 + .../onramp/domain/port/PspPaymentRequest.java | 16 + .../onramp/domain/port/PspPaymentResult.java | 4 + .../onramp/domain/port/PspRefundRequest.java | 14 + .../onramp/domain/port/PspRefundResult.java | 4 + .../domain/port/PspTransactionRepository.java | 13 + .../onramp/domain/port/RefundRepository.java | 16 + .../domain/statemachine/StateMachine.java | 25 ++ .../statemachine/StateMachineException.java | 13 + .../domain/statemachine/StateTransition.java | 4 + 30 files changed, 821 insertions(+) create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/event/CollectionCompletedEvent.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/event/CollectionFailedEvent.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/event/CollectionInitiatedEvent.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/event/RefundCompletedEvent.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/event/RefundInitiatedEvent.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/AccountType.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/BankAccount.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/CollectionOrder.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/CollectionStatus.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/CollectionTrigger.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/Money.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/PaymentRail.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/PaymentRailType.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/PspIdentifier.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/PspTransaction.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/PspTransactionDirection.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/Refund.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/RefundStatus.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/CollectionEventPublisher.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/CollectionOrderRepository.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/PspGateway.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/PspPaymentRequest.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/PspPaymentResult.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/PspRefundRequest.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/PspRefundResult.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/PspTransactionRepository.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/RefundRepository.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/statemachine/StateMachine.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/statemachine/StateMachineException.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/statemachine/StateTransition.java diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/event/CollectionCompletedEvent.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/event/CollectionCompletedEvent.java new file mode 100644 index 00000000..463791d7 --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/event/CollectionCompletedEvent.java @@ -0,0 +1,20 @@ +package com.stablecoin.payments.onramp.domain.event; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +public record CollectionCompletedEvent( + UUID collectionId, + UUID paymentId, + UUID correlationId, + BigDecimal collectedAmount, + String currency, + String paymentRail, + String psp, + String pspReference, + Instant collectedAt +) { + + public static final String TOPIC = "fiat.collected"; +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/event/CollectionFailedEvent.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/event/CollectionFailedEvent.java new file mode 100644 index 00000000..df5aab07 --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/event/CollectionFailedEvent.java @@ -0,0 +1,16 @@ +package com.stablecoin.payments.onramp.domain.event; + +import java.time.Instant; +import java.util.UUID; + +public record CollectionFailedEvent( + UUID collectionId, + UUID paymentId, + UUID correlationId, + String reason, + String errorCode, + Instant failedAt +) { + + public static final String TOPIC = "fiat.collection.failed"; +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/event/CollectionInitiatedEvent.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/event/CollectionInitiatedEvent.java new file mode 100644 index 00000000..912a136e --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/event/CollectionInitiatedEvent.java @@ -0,0 +1,19 @@ +package com.stablecoin.payments.onramp.domain.event; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +public record CollectionInitiatedEvent( + UUID collectionId, + UUID paymentId, + UUID correlationId, + BigDecimal amount, + String currency, + String paymentRail, + String psp, + Instant initiatedAt +) { + + public static final String TOPIC = "fiat.collection.initiated"; +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/event/RefundCompletedEvent.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/event/RefundCompletedEvent.java new file mode 100644 index 00000000..d4a3c44a --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/event/RefundCompletedEvent.java @@ -0,0 +1,18 @@ +package com.stablecoin.payments.onramp.domain.event; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +public record RefundCompletedEvent( + UUID refundId, + UUID collectionId, + UUID paymentId, + BigDecimal refundAmount, + String currency, + String pspRefundRef, + Instant completedAt +) { + + public static final String TOPIC = "fiat.refund.completed"; +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/event/RefundInitiatedEvent.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/event/RefundInitiatedEvent.java new file mode 100644 index 00000000..4cc28580 --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/event/RefundInitiatedEvent.java @@ -0,0 +1,18 @@ +package com.stablecoin.payments.onramp.domain.event; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +public record RefundInitiatedEvent( + UUID refundId, + UUID collectionId, + UUID paymentId, + BigDecimal refundAmount, + String currency, + String reason, + Instant initiatedAt +) { + + public static final String TOPIC = "fiat.refund.initiated"; +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/AccountType.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/AccountType.java new file mode 100644 index 00000000..32a52d1b --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/AccountType.java @@ -0,0 +1,8 @@ +package com.stablecoin.payments.onramp.domain.model; + +public enum AccountType { + IBAN, + ACH_ROUTING, + SORT_CODE, + PIX_KEY +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/BankAccount.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/BankAccount.java new file mode 100644 index 00000000..bf0d6d5e --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/BankAccount.java @@ -0,0 +1,24 @@ +package com.stablecoin.payments.onramp.domain.model; + +public record BankAccount( + String accountNumberHash, + String bankCode, + AccountType accountType, + String country +) { + + public BankAccount { + if (accountNumberHash == null || accountNumberHash.isBlank()) { + throw new IllegalArgumentException("Account number hash is required"); + } + if (bankCode == null || bankCode.isBlank()) { + throw new IllegalArgumentException("Bank code is required"); + } + if (accountType == null) { + throw new IllegalArgumentException("Account type is required"); + } + if (country == null || country.isBlank()) { + throw new IllegalArgumentException("Country is required"); + } + } +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/CollectionOrder.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/CollectionOrder.java new file mode 100644 index 00000000..b04097fd --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/CollectionOrder.java @@ -0,0 +1,276 @@ +package com.stablecoin.payments.onramp.domain.model; + +import com.stablecoin.payments.onramp.domain.statemachine.StateMachine; +import com.stablecoin.payments.onramp.domain.statemachine.StateTransition; +import lombok.AccessLevel; +import lombok.Builder; + +import java.time.Instant; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static com.stablecoin.payments.onramp.domain.model.CollectionStatus.AMOUNT_MISMATCH; +import static com.stablecoin.payments.onramp.domain.model.CollectionStatus.AWAITING_CONFIRMATION; +import static com.stablecoin.payments.onramp.domain.model.CollectionStatus.COLLECTED; +import static com.stablecoin.payments.onramp.domain.model.CollectionStatus.COLLECTION_FAILED; +import static com.stablecoin.payments.onramp.domain.model.CollectionStatus.MANUAL_REVIEW; +import static com.stablecoin.payments.onramp.domain.model.CollectionStatus.PAYMENT_INITIATED; +import static com.stablecoin.payments.onramp.domain.model.CollectionStatus.PENDING; +import static com.stablecoin.payments.onramp.domain.model.CollectionStatus.REFUNDED; +import static com.stablecoin.payments.onramp.domain.model.CollectionStatus.REFUND_INITIATED; +import static com.stablecoin.payments.onramp.domain.model.CollectionStatus.REFUND_PROCESSING; +import static com.stablecoin.payments.onramp.domain.model.CollectionTrigger.AMOUNT_MISMATCH_DETECTED; +import static com.stablecoin.payments.onramp.domain.model.CollectionTrigger.ESCALATE_MANUAL_REVIEW; +import static com.stablecoin.payments.onramp.domain.model.CollectionTrigger.FAIL; +import static com.stablecoin.payments.onramp.domain.model.CollectionTrigger.INITIATE_PAYMENT; +import static com.stablecoin.payments.onramp.domain.model.CollectionTrigger.PAYMENT_CONFIRMED; +import static com.stablecoin.payments.onramp.domain.model.CollectionTrigger.PAYMENT_TIMEOUT; +import static com.stablecoin.payments.onramp.domain.model.CollectionTrigger.PSP_SESSION_CREATED; +import static com.stablecoin.payments.onramp.domain.model.CollectionTrigger.REFUND_COMPLETED; +import static com.stablecoin.payments.onramp.domain.model.CollectionTrigger.REFUND_PROCESSING_STARTED; +import static com.stablecoin.payments.onramp.domain.model.CollectionTrigger.START_REFUND; + +/** + * Aggregate root for a fiat collection order. + *

+ * Enforces the collection lifecycle via an internal state machine: + * {@code PENDING -> PAYMENT_INITIATED -> AWAITING_CONFIRMATION -> COLLECTED}. + *

+ * Handles edge cases: amount mismatches, manual review escalation, + * and refund flows for compensation. + *

+ * Immutable record — all state transitions return new instances via {@code toBuilder()}. + */ +@Builder(toBuilder = true, access = AccessLevel.PACKAGE) +public record CollectionOrder( + UUID collectionId, + UUID paymentId, + UUID correlationId, + Money amount, + PaymentRail paymentRail, + PspIdentifier psp, + BankAccount senderAccount, + CollectionStatus status, + Money collectedAmount, + String pspReference, + Instant pspSettledAt, + String failureReason, + String errorCode, + Instant createdAt, + Instant updatedAt, + Instant expiresAt +) { + + private static final Set TERMINAL_STATES = + Set.of(COLLECTION_FAILED, REFUNDED, MANUAL_REVIEW); + + private static final StateMachine STATE_MACHINE = + new StateMachine<>(List.of( + // -- Happy path ----------------------------------------------- + new StateTransition<>(PENDING, INITIATE_PAYMENT, PAYMENT_INITIATED), + new StateTransition<>(PAYMENT_INITIATED, PSP_SESSION_CREATED, AWAITING_CONFIRMATION), + new StateTransition<>(AWAITING_CONFIRMATION, PAYMENT_CONFIRMED, COLLECTED), + + // -- Failure paths -------------------------------------------- + new StateTransition<>(AWAITING_CONFIRMATION, PAYMENT_TIMEOUT, COLLECTION_FAILED), + new StateTransition<>(AWAITING_CONFIRMATION, AMOUNT_MISMATCH_DETECTED, AMOUNT_MISMATCH), + new StateTransition<>(AMOUNT_MISMATCH, ESCALATE_MANUAL_REVIEW, MANUAL_REVIEW), + new StateTransition<>(PENDING, FAIL, COLLECTION_FAILED), + new StateTransition<>(PAYMENT_INITIATED, FAIL, COLLECTION_FAILED), + + // -- Refund paths (compensation) ------------------------------ + new StateTransition<>(COLLECTED, START_REFUND, REFUND_INITIATED), + new StateTransition<>(REFUND_INITIATED, REFUND_PROCESSING_STARTED, REFUND_PROCESSING), + new StateTransition<>(REFUND_PROCESSING, REFUND_COMPLETED, REFUNDED) + )); + + // -- Factory Method --------------------------------------------------- + + /** + * Creates a new collection order in PENDING state. + */ + public static CollectionOrder initiate(UUID paymentId, UUID correlationId, + Money amount, PaymentRail paymentRail, + PspIdentifier psp, BankAccount senderAccount) { + if (paymentId == null) { + throw new IllegalArgumentException("paymentId is required"); + } + if (correlationId == null) { + throw new IllegalArgumentException("correlationId is required"); + } + if (amount == null) { + throw new IllegalArgumentException("amount is required"); + } + if (paymentRail == null) { + throw new IllegalArgumentException("paymentRail is required"); + } + if (psp == null) { + throw new IllegalArgumentException("psp is required"); + } + if (senderAccount == null) { + throw new IllegalArgumentException("senderAccount is required"); + } + + var now = Instant.now(); + return CollectionOrder.builder() + .collectionId(UUID.randomUUID()) + .paymentId(paymentId) + .correlationId(correlationId) + .amount(amount) + .paymentRail(paymentRail) + .psp(psp) + .senderAccount(senderAccount) + .status(PENDING) + .createdAt(now) + .updatedAt(now) + .expiresAt(now.plusSeconds(1800)) + .build(); + } + + // -- State Transition Methods ----------------------------------------- + + /** + * Initiates payment with PSP. Transitions PENDING -> PAYMENT_INITIATED. + */ + public CollectionOrder initiatePayment() { + assertNotTerminal(); + var nextState = STATE_MACHINE.transition(status, INITIATE_PAYMENT); + return toBuilder() + .status(nextState) + .updatedAt(Instant.now()) + .build(); + } + + /** + * Records PSP session creation. Transitions PAYMENT_INITIATED -> AWAITING_CONFIRMATION. + */ + public CollectionOrder awaitConfirmation(String pspReference) { + assertNotTerminal(); + if (pspReference == null || pspReference.isBlank()) { + throw new IllegalArgumentException("PSP reference is required"); + } + var nextState = STATE_MACHINE.transition(status, PSP_SESSION_CREATED); + return toBuilder() + .status(nextState) + .pspReference(pspReference) + .updatedAt(Instant.now()) + .build(); + } + + /** + * Confirms collection. Transitions AWAITING_CONFIRMATION -> COLLECTED. + */ + public CollectionOrder confirmCollection(Money collectedAmount) { + assertNotTerminal(); + if (collectedAmount == null) { + throw new IllegalArgumentException("Collected amount is required"); + } + var nextState = STATE_MACHINE.transition(status, PAYMENT_CONFIRMED); + return toBuilder() + .status(nextState) + .collectedAmount(collectedAmount) + .pspSettledAt(Instant.now()) + .updatedAt(Instant.now()) + .build(); + } + + /** + * Fails the collection. Can be triggered from PENDING or PAYMENT_INITIATED. + */ + public CollectionOrder failCollection(String reason, String errorCode) { + var nextState = STATE_MACHINE.transition(status, FAIL); + return toBuilder() + .status(nextState) + .failureReason(reason) + .errorCode(errorCode) + .updatedAt(Instant.now()) + .build(); + } + + /** + * Detects amount mismatch. Transitions AWAITING_CONFIRMATION -> AMOUNT_MISMATCH. + */ + public CollectionOrder detectAmountMismatch() { + assertNotTerminal(); + var nextState = STATE_MACHINE.transition(status, AMOUNT_MISMATCH_DETECTED); + return toBuilder() + .status(nextState) + .updatedAt(Instant.now()) + .build(); + } + + /** + * Escalates to manual review. Transitions AMOUNT_MISMATCH -> MANUAL_REVIEW. + */ + public CollectionOrder escalateToManualReview() { + assertNotTerminal(); + var nextState = STATE_MACHINE.transition(status, ESCALATE_MANUAL_REVIEW); + return toBuilder() + .status(nextState) + .updatedAt(Instant.now()) + .build(); + } + + /** + * Initiates refund (compensation). Transitions COLLECTED -> REFUND_INITIATED. + */ + public CollectionOrder initiateRefund() { + assertNotTerminal(); + var nextState = STATE_MACHINE.transition(status, START_REFUND); + return toBuilder() + .status(nextState) + .updatedAt(Instant.now()) + .build(); + } + + /** + * Starts refund processing. Transitions REFUND_INITIATED -> REFUND_PROCESSING. + */ + public CollectionOrder startRefundProcessing() { + assertNotTerminal(); + var nextState = STATE_MACHINE.transition(status, REFUND_PROCESSING_STARTED); + return toBuilder() + .status(nextState) + .updatedAt(Instant.now()) + .build(); + } + + /** + * Completes refund. Transitions REFUND_PROCESSING -> REFUNDED. + */ + public CollectionOrder completeRefund() { + assertNotTerminal(); + var nextState = STATE_MACHINE.transition(status, REFUND_COMPLETED); + return toBuilder() + .status(nextState) + .updatedAt(Instant.now()) + .build(); + } + + // -- Query Methods ---------------------------------------------------- + + /** + * Returns true if this collection order is in a terminal state. + */ + public boolean isTerminal() { + return TERMINAL_STATES.contains(status); + } + + /** + * Returns true if a given trigger can be applied from the current state. + */ + public boolean canApply(CollectionTrigger trigger) { + return STATE_MACHINE.canTransition(status, trigger); + } + + // -- Invariant Guards ------------------------------------------------- + + private void assertNotTerminal() { + if (isTerminal()) { + throw new IllegalStateException( + "CollectionOrder %s is in terminal state %s and cannot be modified" + .formatted(collectionId, status)); + } + } +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/CollectionStatus.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/CollectionStatus.java new file mode 100644 index 00000000..e31763be --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/CollectionStatus.java @@ -0,0 +1,14 @@ +package com.stablecoin.payments.onramp.domain.model; + +public enum CollectionStatus { + PENDING, + PAYMENT_INITIATED, + AWAITING_CONFIRMATION, + COLLECTED, + COLLECTION_FAILED, + AMOUNT_MISMATCH, + MANUAL_REVIEW, + REFUND_INITIATED, + REFUND_PROCESSING, + REFUNDED +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/CollectionTrigger.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/CollectionTrigger.java new file mode 100644 index 00000000..6b9df8e9 --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/CollectionTrigger.java @@ -0,0 +1,14 @@ +package com.stablecoin.payments.onramp.domain.model; + +public enum CollectionTrigger { + INITIATE_PAYMENT, + PSP_SESSION_CREATED, + PAYMENT_CONFIRMED, + PAYMENT_TIMEOUT, + AMOUNT_MISMATCH_DETECTED, + ESCALATE_MANUAL_REVIEW, + START_REFUND, + REFUND_PROCESSING_STARTED, + REFUND_COMPLETED, + FAIL +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/Money.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/Money.java new file mode 100644 index 00000000..b660b4ca --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/Money.java @@ -0,0 +1,15 @@ +package com.stablecoin.payments.onramp.domain.model; + +import java.math.BigDecimal; + +public record Money(BigDecimal amount, String currency) { + + public Money { + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("Amount must be positive"); + } + if (currency == null || currency.isBlank()) { + throw new IllegalArgumentException("Currency must not be blank"); + } + } +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/PaymentRail.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/PaymentRail.java new file mode 100644 index 00000000..352353d4 --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/PaymentRail.java @@ -0,0 +1,16 @@ +package com.stablecoin.payments.onramp.domain.model; + +public record PaymentRail(PaymentRailType rail, String country, String currency) { + + public PaymentRail { + if (rail == null) { + throw new IllegalArgumentException("Rail is required"); + } + if (country == null || country.isBlank()) { + throw new IllegalArgumentException("Country is required"); + } + if (currency == null || currency.isBlank()) { + throw new IllegalArgumentException("Currency is required"); + } + } +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/PaymentRailType.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/PaymentRailType.java new file mode 100644 index 00000000..6b9e72ee --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/PaymentRailType.java @@ -0,0 +1,11 @@ +package com.stablecoin.payments.onramp.domain.model; + +public enum PaymentRailType { + SEPA, + ACH, + ACH_WIRE, + FASTER_PAYMENTS, + PIX, + UPI, + SWIFT +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/PspIdentifier.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/PspIdentifier.java new file mode 100644 index 00000000..2247db39 --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/PspIdentifier.java @@ -0,0 +1,13 @@ +package com.stablecoin.payments.onramp.domain.model; + +public record PspIdentifier(String pspId, String pspName) { + + public PspIdentifier { + if (pspId == null || pspId.isBlank()) { + throw new IllegalArgumentException("PSP ID is required"); + } + if (pspName == null || pspName.isBlank()) { + throw new IllegalArgumentException("PSP name is required"); + } + } +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/PspTransaction.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/PspTransaction.java new file mode 100644 index 00000000..5b5b1883 --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/PspTransaction.java @@ -0,0 +1,73 @@ +package com.stablecoin.payments.onramp.domain.model; + +import lombok.AccessLevel; +import lombok.Builder; + +import java.time.Instant; +import java.util.UUID; + +/** + * Immutable event log recording PSP interactions. + *

+ * Each PSP webhook or API call response is captured as a {@code PspTransaction}. + * No state transitions — this is an append-only audit record. + */ +@Builder(toBuilder = true, access = AccessLevel.PACKAGE) +public record PspTransaction( + UUID pspTxnId, + UUID collectionId, + String pspName, + String pspReference, + PspTransactionDirection direction, + String eventType, + Money amount, + String status, + String rawResponse, + Instant receivedAt +) { + + // -- Factory Method --------------------------------------------------- + + /** + * Creates a new PSP transaction record. + */ + public static PspTransaction create(UUID collectionId, String pspName, + String pspReference, PspTransactionDirection direction, + String eventType, Money amount, + String status, String rawResponse) { + if (collectionId == null) { + throw new IllegalArgumentException("collectionId is required"); + } + if (pspName == null || pspName.isBlank()) { + throw new IllegalArgumentException("pspName is required"); + } + if (pspReference == null || pspReference.isBlank()) { + throw new IllegalArgumentException("pspReference is required"); + } + if (direction == null) { + throw new IllegalArgumentException("direction is required"); + } + if (eventType == null || eventType.isBlank()) { + throw new IllegalArgumentException("eventType is required"); + } + if (amount == null) { + throw new IllegalArgumentException("amount is required"); + } + if (status == null || status.isBlank()) { + throw new IllegalArgumentException("status is required"); + } + + return PspTransaction.builder() + .pspTxnId(UUID.randomUUID()) + .collectionId(collectionId) + .pspName(pspName) + .pspReference(pspReference) + .direction(direction) + .eventType(eventType) + .amount(amount) + .status(status) + .rawResponse(rawResponse) + .receivedAt(Instant.now()) + .build(); + } +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/PspTransactionDirection.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/PspTransactionDirection.java new file mode 100644 index 00000000..b78ea034 --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/PspTransactionDirection.java @@ -0,0 +1,6 @@ +package com.stablecoin.payments.onramp.domain.model; + +public enum PspTransactionDirection { + DEBIT, + CREDIT +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/Refund.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/Refund.java new file mode 100644 index 00000000..6222336b --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/Refund.java @@ -0,0 +1,114 @@ +package com.stablecoin.payments.onramp.domain.model; + +import lombok.AccessLevel; +import lombok.Builder; + +import java.time.Instant; +import java.util.UUID; + +import static com.stablecoin.payments.onramp.domain.model.RefundStatus.COMPLETED; +import static com.stablecoin.payments.onramp.domain.model.RefundStatus.FAILED; +import static com.stablecoin.payments.onramp.domain.model.RefundStatus.PENDING; +import static com.stablecoin.payments.onramp.domain.model.RefundStatus.PROCESSING; + +/** + * Child entity representing a refund for a collected payment. + *

+ * Tracks the refund lifecycle: {@code PENDING -> PROCESSING -> COMPLETED/FAILED}. + * Immutable record — all state transitions return new instances via {@code toBuilder()}. + */ +@Builder(toBuilder = true, access = AccessLevel.PACKAGE) +public record Refund( + UUID refundId, + UUID collectionId, + UUID paymentId, + Money refundAmount, + String reason, + RefundStatus status, + String pspRefundRef, + Instant initiatedAt, + Instant completedAt, + String failureReason +) { + + // -- Factory Method --------------------------------------------------- + + /** + * Creates a new refund in PENDING state. + */ + public static Refund initiate(UUID collectionId, UUID paymentId, + Money refundAmount, String reason) { + if (collectionId == null) { + throw new IllegalArgumentException("collectionId is required"); + } + if (paymentId == null) { + throw new IllegalArgumentException("paymentId is required"); + } + if (refundAmount == null) { + throw new IllegalArgumentException("refundAmount is required"); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("reason is required"); + } + + return Refund.builder() + .refundId(UUID.randomUUID()) + .collectionId(collectionId) + .paymentId(paymentId) + .refundAmount(refundAmount) + .reason(reason) + .status(PENDING) + .initiatedAt(Instant.now()) + .build(); + } + + // -- State Transition Methods ----------------------------------------- + + /** + * Starts processing the refund. Transitions PENDING -> PROCESSING. + */ + public Refund startProcessing() { + if (status != PENDING) { + throw new IllegalStateException( + "Refund %s cannot start processing from state %s".formatted(refundId, status)); + } + return toBuilder() + .status(PROCESSING) + .build(); + } + + /** + * Completes the refund. Transitions PROCESSING -> COMPLETED. + */ + public Refund complete(String pspRefundRef) { + if (status != PROCESSING) { + throw new IllegalStateException( + "Refund %s cannot complete from state %s".formatted(refundId, status)); + } + if (pspRefundRef == null || pspRefundRef.isBlank()) { + throw new IllegalArgumentException("PSP refund reference is required"); + } + return toBuilder() + .status(COMPLETED) + .pspRefundRef(pspRefundRef) + .completedAt(Instant.now()) + .build(); + } + + /** + * Fails the refund. Transitions PROCESSING -> FAILED. + */ + public Refund fail(String failureReason) { + if (status != PROCESSING) { + throw new IllegalStateException( + "Refund %s cannot fail from state %s".formatted(refundId, status)); + } + if (failureReason == null || failureReason.isBlank()) { + throw new IllegalArgumentException("Failure reason is required"); + } + return toBuilder() + .status(FAILED) + .failureReason(failureReason) + .build(); + } +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/RefundStatus.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/RefundStatus.java new file mode 100644 index 00000000..58cac035 --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/model/RefundStatus.java @@ -0,0 +1,8 @@ +package com.stablecoin.payments.onramp.domain.model; + +public enum RefundStatus { + PENDING, + PROCESSING, + COMPLETED, + FAILED +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/CollectionEventPublisher.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/CollectionEventPublisher.java new file mode 100644 index 00000000..99906d51 --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/CollectionEventPublisher.java @@ -0,0 +1,6 @@ +package com.stablecoin.payments.onramp.domain.port; + +public interface CollectionEventPublisher { + + void publish(Object event); +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/CollectionOrderRepository.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/CollectionOrderRepository.java new file mode 100644 index 00000000..8940cef9 --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/CollectionOrderRepository.java @@ -0,0 +1,15 @@ +package com.stablecoin.payments.onramp.domain.port; + +import com.stablecoin.payments.onramp.domain.model.CollectionOrder; + +import java.util.Optional; +import java.util.UUID; + +public interface CollectionOrderRepository { + + CollectionOrder save(CollectionOrder order); + + Optional findById(UUID collectionId); + + Optional findByPaymentId(UUID paymentId); +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/PspGateway.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/PspGateway.java new file mode 100644 index 00000000..b24d16b6 --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/PspGateway.java @@ -0,0 +1,8 @@ +package com.stablecoin.payments.onramp.domain.port; + +public interface PspGateway { + + PspPaymentResult initiatePayment(PspPaymentRequest request); + + PspRefundResult initiateRefund(PspRefundRequest request); +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/PspPaymentRequest.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/PspPaymentRequest.java new file mode 100644 index 00000000..253117a1 --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/PspPaymentRequest.java @@ -0,0 +1,16 @@ +package com.stablecoin.payments.onramp.domain.port; + +import com.stablecoin.payments.onramp.domain.model.BankAccount; +import com.stablecoin.payments.onramp.domain.model.Money; +import com.stablecoin.payments.onramp.domain.model.PaymentRail; + +import java.util.UUID; + +public record PspPaymentRequest( + UUID collectionId, + Money amount, + PaymentRail paymentRail, + BankAccount senderAccount, + String pspName +) { +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/PspPaymentResult.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/PspPaymentResult.java new file mode 100644 index 00000000..564f7f22 --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/PspPaymentResult.java @@ -0,0 +1,4 @@ +package com.stablecoin.payments.onramp.domain.port; + +public record PspPaymentResult(String pspReference, String status) { +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/PspRefundRequest.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/PspRefundRequest.java new file mode 100644 index 00000000..e1101118 --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/PspRefundRequest.java @@ -0,0 +1,14 @@ +package com.stablecoin.payments.onramp.domain.port; + +import com.stablecoin.payments.onramp.domain.model.Money; + +import java.util.UUID; + +public record PspRefundRequest( + UUID collectionId, + String pspReference, + Money refundAmount, + String pspName, + String reason +) { +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/PspRefundResult.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/PspRefundResult.java new file mode 100644 index 00000000..08ac592f --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/PspRefundResult.java @@ -0,0 +1,4 @@ +package com.stablecoin.payments.onramp.domain.port; + +public record PspRefundResult(String pspRefundRef, String status) { +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/PspTransactionRepository.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/PspTransactionRepository.java new file mode 100644 index 00000000..1c02cb5d --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/PspTransactionRepository.java @@ -0,0 +1,13 @@ +package com.stablecoin.payments.onramp.domain.port; + +import com.stablecoin.payments.onramp.domain.model.PspTransaction; + +import java.util.List; +import java.util.UUID; + +public interface PspTransactionRepository { + + PspTransaction save(PspTransaction transaction); + + List findByCollectionId(UUID collectionId); +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/RefundRepository.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/RefundRepository.java new file mode 100644 index 00000000..4279e424 --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/port/RefundRepository.java @@ -0,0 +1,16 @@ +package com.stablecoin.payments.onramp.domain.port; + +import com.stablecoin.payments.onramp.domain.model.Refund; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface RefundRepository { + + Refund save(Refund refund); + + Optional findById(UUID refundId); + + List findByCollectionId(UUID collectionId); +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/statemachine/StateMachine.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/statemachine/StateMachine.java new file mode 100644 index 00000000..85f9ff4a --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/statemachine/StateMachine.java @@ -0,0 +1,25 @@ +package com.stablecoin.payments.onramp.domain.statemachine; + +import java.util.List; + +public class StateMachine { + + private final List> transitions; + + public StateMachine(List> transitions) { + this.transitions = List.copyOf(transitions); + } + + public S transition(S currentState, T trigger) { + return transitions.stream() + .filter(t -> t.from().equals(currentState) && t.trigger().equals(trigger)) + .map(StateTransition::to) + .findFirst() + .orElseThrow(() -> StateMachineException.invalidTransition(currentState, trigger)); + } + + public boolean canTransition(S currentState, T trigger) { + return transitions.stream() + .anyMatch(t -> t.from().equals(currentState) && t.trigger().equals(trigger)); + } +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/statemachine/StateMachineException.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/statemachine/StateMachineException.java new file mode 100644 index 00000000..55b31b96 --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/statemachine/StateMachineException.java @@ -0,0 +1,13 @@ +package com.stablecoin.payments.onramp.domain.statemachine; + +public class StateMachineException extends RuntimeException { + + public StateMachineException(String message) { + super(message); + } + + public static StateMachineException invalidTransition(S state, T trigger) { + return new StateMachineException( + "Invalid transition: state=%s trigger=%s".formatted(state, trigger)); + } +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/statemachine/StateTransition.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/statemachine/StateTransition.java new file mode 100644 index 00000000..2349972c --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/statemachine/StateTransition.java @@ -0,0 +1,4 @@ +package com.stablecoin.payments.onramp.domain.statemachine; + +public record StateTransition(S from, T trigger, S to) { +}