diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/WalletApp.java b/payguard-wallet/src/main/java/dev/amg/payguard/WalletApp.java new file mode 100644 index 0000000..de780f3 --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/WalletApp.java @@ -0,0 +1,12 @@ +package dev.amg.payguard; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class WalletApp { + + public static void main(String[] args) { + SpringApplication.run(WalletApp.class, args); + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/FinancialAccountType.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/FinancialAccountType.java new file mode 100644 index 0000000..55b6947 --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/FinancialAccountType.java @@ -0,0 +1,9 @@ +package dev.amg.payguard.domain.enums; + +public enum FinancialAccountType { + USER_WALLET, + BANK_CLEARING, + COMPANY_REVENUE, + PROMOTIONAL_EXPENSE, + SETTLEMENT_SUSPENSE +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/HoldStatus.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/HoldStatus.java new file mode 100644 index 0000000..03fe121 --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/HoldStatus.java @@ -0,0 +1,8 @@ +package dev.amg.payguard.domain.enums; + +public enum HoldStatus { + PENDING, + CAPTURED, + RELEASED, + EXPIRED +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/LedgerEntryType.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/LedgerEntryType.java new file mode 100644 index 0000000..8e5fb86 --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/LedgerEntryType.java @@ -0,0 +1,6 @@ +package dev.amg.payguard.domain.enums; + +public enum LedgerEntryType { + DEBIT, + CREDIT +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/TransactionStatus.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/TransactionStatus.java new file mode 100644 index 0000000..5f6785c --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/TransactionStatus.java @@ -0,0 +1,9 @@ +package dev.amg.payguard.domain.enums; + +public enum TransactionStatus { + INITIATED, + PENDING_SETTLEMENT, + SUCCESSFUL, + FAILED, + REVERSED +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/TransactionType.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/TransactionType.java new file mode 100644 index 0000000..82e6883 --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/TransactionType.java @@ -0,0 +1,10 @@ +package dev.amg.payguard.domain.enums; + +public enum TransactionType { + DEPOSIT, + WITHDRAWAL, + P2P_TRANSFER, + PURCHASE, + REFUND, + FEE +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/WalletStatus.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/WalletStatus.java new file mode 100644 index 0000000..aa1a64d --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/WalletStatus.java @@ -0,0 +1,9 @@ +package dev.amg.payguard.domain.enums; + +public enum WalletStatus { + ACTIVE, + SUSPENDED_DEBIT, + SUSPENDED_CREDIT, + FROZEN, + CLOSED +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldCapturedEvent.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldCapturedEvent.java new file mode 100644 index 0000000..1f825cf --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldCapturedEvent.java @@ -0,0 +1,22 @@ +package dev.amg.payguard.domain.event; + +import dev.amg.payguard.domain.value_object.HoldId; +import dev.amg.payguard.domain.value_object.Money; +import dev.amg.payguard.domain.value_object.WalletId; +import domain.DomainEvent; +import java.time.Instant; +import java.util.UUID; + +public record FundsHoldCapturedEvent( + UUID eventId, Instant occurredAt, HoldId holdId, WalletId walletId, Money amount) + implements DomainEvent { + @Override + public Instant occurredOn() { + return occurredAt; + } + + public static FundsHoldCapturedEvent now( + HoldId holdId, WalletId walletId, Money amount, Instant now) { + return new FundsHoldCapturedEvent(UUID.randomUUID(), now, holdId, walletId, amount); + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldCreatedEvent.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldCreatedEvent.java new file mode 100644 index 0000000..874471f --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldCreatedEvent.java @@ -0,0 +1,27 @@ +package dev.amg.payguard.domain.event; + +import dev.amg.payguard.domain.value_object.HoldId; +import dev.amg.payguard.domain.value_object.Money; +import dev.amg.payguard.domain.value_object.WalletId; +import domain.DomainEvent; +import java.time.Instant; +import java.util.UUID; + +public record FundsHoldCreatedEvent( + UUID eventId, + Instant occurredAt, + HoldId holdId, + WalletId walletId, + Money amount, + Instant expiresAt) + implements DomainEvent { + @Override + public Instant occurredOn() { + return occurredAt; + } + + public static FundsHoldCreatedEvent now( + HoldId holdId, WalletId walletId, Money amount, Instant expiresAt, Instant now) { + return new FundsHoldCreatedEvent(UUID.randomUUID(), now, holdId, walletId, amount, expiresAt); + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldExpiredEvent.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldExpiredEvent.java new file mode 100644 index 0000000..e9a8ee3 --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldExpiredEvent.java @@ -0,0 +1,22 @@ +package dev.amg.payguard.domain.event; + +import dev.amg.payguard.domain.value_object.HoldId; +import dev.amg.payguard.domain.value_object.Money; +import dev.amg.payguard.domain.value_object.WalletId; +import domain.DomainEvent; +import java.time.Instant; +import java.util.UUID; + +public record FundsHoldExpiredEvent( + UUID eventId, Instant occurredAt, HoldId holdId, WalletId walletId, Money amount) + implements DomainEvent { + public static FundsHoldExpiredEvent now( + HoldId holdId, WalletId walletId, Money amount, Instant now) { + return new FundsHoldExpiredEvent(UUID.randomUUID(), now, holdId, walletId, amount); + } + + @Override + public Instant occurredOn() { + return occurredAt; + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldReleasedEvent.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldReleasedEvent.java new file mode 100644 index 0000000..06a5dfd --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldReleasedEvent.java @@ -0,0 +1,22 @@ +package dev.amg.payguard.domain.event; + +import dev.amg.payguard.domain.value_object.HoldId; +import dev.amg.payguard.domain.value_object.Money; +import dev.amg.payguard.domain.value_object.WalletId; +import domain.DomainEvent; +import java.time.Instant; +import java.util.UUID; + +public record FundsHoldReleasedEvent( + UUID eventId, Instant occurredAt, HoldId holdId, WalletId walletId, Money amount) + implements DomainEvent { + @Override + public Instant occurredOn() { + return occurredAt; + } + + public static FundsHoldReleasedEvent now( + HoldId holdId, WalletId walletId, Money amount, Instant now) { + return new FundsHoldReleasedEvent(UUID.randomUUID(), now, holdId, walletId, amount); + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/MoneyDepositedEvent.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/MoneyDepositedEvent.java new file mode 100644 index 0000000..3a729b6 --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/MoneyDepositedEvent.java @@ -0,0 +1,22 @@ +package dev.amg.payguard.domain.event; + +import dev.amg.payguard.domain.value_object.Money; +import dev.amg.payguard.domain.value_object.TransactionId; +import dev.amg.payguard.domain.value_object.WalletId; +import domain.DomainEvent; +import java.time.Instant; +import java.util.UUID; + +public record MoneyDepositedEvent( + UUID eventId, Instant occurredAt, TransactionId transactionId, WalletId walletId, Money amount) + implements DomainEvent { + @Override + public Instant occurredOn() { + return occurredAt; + } + + public static MoneyDepositedEvent now( + TransactionId transactionId, WalletId walletId, Money amount, Instant now) { + return new MoneyDepositedEvent(UUID.randomUUID(), now, transactionId, walletId, amount); + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/MoneyWithdrawnEvent.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/MoneyWithdrawnEvent.java new file mode 100644 index 0000000..51bda25 --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/MoneyWithdrawnEvent.java @@ -0,0 +1,22 @@ +package dev.amg.payguard.domain.event; + +import dev.amg.payguard.domain.value_object.Money; +import dev.amg.payguard.domain.value_object.TransactionId; +import dev.amg.payguard.domain.value_object.WalletId; +import domain.DomainEvent; +import java.time.Instant; +import java.util.UUID; + +public record MoneyWithdrawnEvent( + UUID eventId, Instant occurredAt, TransactionId transactionId, WalletId walletId, Money amount) + implements DomainEvent { + @Override + public Instant occurredOn() { + return occurredAt; + } + + public static MoneyWithdrawnEvent now( + TransactionId transactionId, WalletId walletId, Money amount, Instant now) { + return new MoneyWithdrawnEvent(UUID.randomUUID(), now, transactionId, walletId, amount); + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/TransferCompletedEvent.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/TransferCompletedEvent.java new file mode 100644 index 0000000..ef256af --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/TransferCompletedEvent.java @@ -0,0 +1,34 @@ +package dev.amg.payguard.domain.event; + +import dev.amg.payguard.domain.value_object.AccountId; +import dev.amg.payguard.domain.value_object.Money; +import dev.amg.payguard.domain.value_object.TransactionId; +import domain.DomainEvent; +import java.time.Instant; +import java.util.UUID; + +public record TransferCompletedEvent( + UUID eventId, + Instant occurredAt, + TransactionId transactionId, + AccountId sourceAccountId, + AccountId destinationAccountId, + Money amount, + Money fee) + implements DomainEvent { + @Override + public Instant occurredOn() { + return occurredAt; + } + + public static TransferCompletedEvent now( + TransactionId transactionId, + AccountId source, + AccountId destination, + Money amount, + Money fee, + Instant now) { + return new TransferCompletedEvent( + UUID.randomUUID(), now, transactionId, source, destination, amount, fee); + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/WalletClosedEvent.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/WalletClosedEvent.java new file mode 100644 index 0000000..67d7f35 --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/WalletClosedEvent.java @@ -0,0 +1,18 @@ +package dev.amg.payguard.domain.event; + +import dev.amg.payguard.domain.value_object.WalletId; +import domain.DomainEvent; +import java.time.Instant; +import java.util.UUID; + +public record WalletClosedEvent(UUID eventId, Instant occurredAt, WalletId walletId) + implements DomainEvent { + @Override + public Instant occurredOn() { + return occurredAt; + } + + public static WalletClosedEvent now(WalletId walletId, Instant now) { + return new WalletClosedEvent(UUID.randomUUID(), now, walletId); + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/WalletCreatedEvent.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/WalletCreatedEvent.java new file mode 100644 index 0000000..566b317 --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/WalletCreatedEvent.java @@ -0,0 +1,22 @@ +package dev.amg.payguard.domain.event; + +import dev.amg.payguard.domain.value_object.UserId; +import dev.amg.payguard.domain.value_object.WalletId; +import domain.DomainEvent; +import java.time.Instant; +import java.util.Currency; +import java.util.UUID; + +public record WalletCreatedEvent( + UUID eventId, Instant occurredAt, WalletId walletId, UserId userId, Currency currency) + implements DomainEvent { + @Override + public Instant occurredOn() { + return occurredAt; + } + + public static WalletCreatedEvent now( + WalletId walletId, UserId userId, Currency currency, Instant now) { + return new WalletCreatedEvent(UUID.randomUUID(), now, walletId, userId, currency); + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/WalletStatusChangedEvent.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/WalletStatusChangedEvent.java new file mode 100644 index 0000000..077e23b --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/WalletStatusChangedEvent.java @@ -0,0 +1,27 @@ +package dev.amg.payguard.domain.event; + +import dev.amg.payguard.domain.enums.WalletStatus; +import dev.amg.payguard.domain.value_object.WalletId; +import domain.DomainEvent; +import java.time.Instant; +import java.util.UUID; + +public record WalletStatusChangedEvent( + UUID eventId, + Instant occurredAt, + WalletId walletId, + WalletStatus previousStatus, + WalletStatus newStatus, + String reason) + implements DomainEvent { + @Override + public Instant occurredOn() { + return occurredAt; + } + + public static WalletStatusChangedEvent now( + WalletId walletId, WalletStatus previous, WalletStatus current, String reason, Instant now) { + return new WalletStatusChangedEvent( + UUID.randomUUID(), now, walletId, previous, current, reason); + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/CurrencyMismatchException.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/CurrencyMismatchException.java new file mode 100644 index 0000000..b47253d --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/CurrencyMismatchException.java @@ -0,0 +1,30 @@ +package dev.amg.payguard.domain.exception; + +import domain.DomainException; + +public class CurrencyMismatchException extends DomainException { + private static final long serialVersionUID = 1L; + + public CurrencyMismatchException() { + this("Currency mismatch"); + } + + public CurrencyMismatchException(String message) { + super(message); + } + + @Override + public String getErrorCode() { + return "CURRENCY_MISMATCH"; + } + + @Override + public String getTitle() { + return "Currency mismatch"; + } + + @Override + public int getHttpStatus() { + return 400; + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/DoubleEntryImbalanceException.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/DoubleEntryImbalanceException.java new file mode 100644 index 0000000..b617fb6 --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/DoubleEntryImbalanceException.java @@ -0,0 +1,30 @@ +package dev.amg.payguard.domain.exception; + +import domain.DomainException; + +public class DoubleEntryImbalanceException extends DomainException { + private static final long serialVersionUID = 1L; + + public DoubleEntryImbalanceException() { + this("Double-entry transaction is imbalanced"); + } + + public DoubleEntryImbalanceException(String message) { + super(message); + } + + @Override + public String getErrorCode() { + return "DOUBLE_ENTRY_IMBALANCE"; + } + + @Override + public String getTitle() { + return "Double-entry imbalance"; + } + + @Override + public int getHttpStatus() { + return 400; + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/HoldExpiredException.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/HoldExpiredException.java new file mode 100644 index 0000000..66a7879 --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/HoldExpiredException.java @@ -0,0 +1,30 @@ +package dev.amg.payguard.domain.exception; + +import domain.DomainException; + +public class HoldExpiredException extends DomainException { + private static final long serialVersionUID = 1L; + + public HoldExpiredException() { + this("Hold has expired"); + } + + public HoldExpiredException(String message) { + super(message); + } + + @Override + public String getErrorCode() { + return "HOLD_EXPIRED"; + } + + @Override + public String getTitle() { + return "Hold expired"; + } + + @Override + public int getHttpStatus() { + return 409; + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InsufficientFundException.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InsufficientFundException.java new file mode 100644 index 0000000..3859a96 --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InsufficientFundException.java @@ -0,0 +1,30 @@ +package dev.amg.payguard.domain.exception; + +import domain.DomainException; + +public class InsufficientFundException extends DomainException { + private static final long serialVersionUID = 1L; + + public InsufficientFundException() { + this("Insufficient funds"); + } + + public InsufficientFundException(String message) { + super(message); + } + + @Override + public String getErrorCode() { + return "INSUFFICIENT_FUNDS"; + } + + @Override + public String getTitle() { + return "Insufficient funds"; + } + + @Override + public int getHttpStatus() { + return 422; + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InvalidHoldStateException.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InvalidHoldStateException.java new file mode 100644 index 0000000..b8fb5b8 --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InvalidHoldStateException.java @@ -0,0 +1,30 @@ +package dev.amg.payguard.domain.exception; + +import domain.DomainException; + +public class InvalidHoldStateException extends DomainException { + private static final long serialVersionUID = 1L; + + public InvalidHoldStateException() { + this("Invalid hold state"); + } + + public InvalidHoldStateException(String message) { + super(message); + } + + @Override + public String getErrorCode() { + return "INVALID_HOLD_STATE"; + } + + @Override + public String getTitle() { + return "Invalid hold state"; + } + + @Override + public int getHttpStatus() { + return 409; + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InvalidIdempotencyKeyException.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InvalidIdempotencyKeyException.java new file mode 100644 index 0000000..4b98606 --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InvalidIdempotencyKeyException.java @@ -0,0 +1,26 @@ +package dev.amg.payguard.domain.exception; + +import domain.DomainException; + +public final class InvalidIdempotencyKeyException extends DomainException { + private static final long serialVersionUID = 1L; + + public InvalidIdempotencyKeyException(String message) { + super(message); + } + + @Override + public String getErrorCode() { + return "INVALID_IDEMPOTENCY_KEY"; + } + + @Override + public String getTitle() { + return "Invalid idempotency key"; + } + + @Override + public int getHttpStatus() { + return 400; + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InvalidWalletStateException.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InvalidWalletStateException.java new file mode 100644 index 0000000..eea1b78 --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InvalidWalletStateException.java @@ -0,0 +1,30 @@ +package dev.amg.payguard.domain.exception; + +import domain.DomainException; + +public class InvalidWalletStateException extends DomainException { + private static final long serialVersionUID = 1L; + + public InvalidWalletStateException() { + this("Invalid wallet state"); + } + + public InvalidWalletStateException(String message) { + super(message); + } + + @Override + public String getErrorCode() { + return "INVALID_WALLET_STATE"; + } + + @Override + public String getTitle() { + return "Invalid wallet state"; + } + + @Override + public int getHttpStatus() { + return 409; + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/LimitExceededException.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/LimitExceededException.java new file mode 100644 index 0000000..2f6d935 --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/LimitExceededException.java @@ -0,0 +1,30 @@ +package dev.amg.payguard.domain.exception; + +import domain.DomainException; + +public class LimitExceededException extends DomainException { + private static final long serialVersionUID = 1L; + + public LimitExceededException() { + this("Wallet limit exceeded"); + } + + public LimitExceededException(String message) { + super(message); + } + + @Override + public String getErrorCode() { + return "LIMIT_EXCEEDED"; + } + + @Override + public String getTitle() { + return "Limit exceeded"; + } + + @Override + public int getHttpStatus() { + return 409; + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/NegativeAmountNotAllowedException.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/NegativeAmountNotAllowedException.java new file mode 100644 index 0000000..fb52913 --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/NegativeAmountNotAllowedException.java @@ -0,0 +1,30 @@ +package dev.amg.payguard.domain.exception; + +import domain.DomainException; + +public class NegativeAmountNotAllowedException extends DomainException { + private static final long serialVersionUID = 1L; + + public NegativeAmountNotAllowedException() { + this("Negative amounts are not allowed"); + } + + public NegativeAmountNotAllowedException(String message) { + super(message); + } + + @Override + public String getErrorCode() { + return "NEGATIVE_AMOUNT_NOT_ALLOWED"; + } + + @Override + public String getTitle() { + return "Negative amount not allowed"; + } + + @Override + public int getHttpStatus() { + return 400; + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/TransactionAlreadySettledException.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/TransactionAlreadySettledException.java new file mode 100644 index 0000000..db66151 --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/TransactionAlreadySettledException.java @@ -0,0 +1,30 @@ +package dev.amg.payguard.domain.exception; + +import domain.DomainException; + +public class TransactionAlreadySettledException extends DomainException { + private static final long serialVersionUID = 1L; + + public TransactionAlreadySettledException() { + this("Transaction has already been settled"); + } + + public TransactionAlreadySettledException(String message) { + super(message); + } + + @Override + public String getErrorCode() { + return "TRANSACTION_ALREADY_SETTLED"; + } + + @Override + public String getTitle() { + return "Transaction already settled"; + } + + @Override + public int getHttpStatus() { + return 409; + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/WalletFrozenException.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/WalletFrozenException.java new file mode 100644 index 0000000..f70412e --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/WalletFrozenException.java @@ -0,0 +1,30 @@ +package dev.amg.payguard.domain.exception; + +import domain.DomainException; + +public class WalletFrozenException extends DomainException { + private static final long serialVersionUID = 1L; + + public WalletFrozenException() { + this("Wallet is frozen"); + } + + public WalletFrozenException(String message) { + super(message); + } + + @Override + public String getErrorCode() { + return "WALLET_FROZEN"; + } + + @Override + public String getTitle() { + return "Wallet frozen"; + } + + @Override + public int getHttpStatus() { + return 409; + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/package-info.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/package-info.java new file mode 100644 index 0000000..44008df --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/package-info.java @@ -0,0 +1,2 @@ +@org.springframework.modulith.NamedInterface("exceptions") +package dev.amg.payguard.domain.exception; diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Hold.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Hold.java new file mode 100644 index 0000000..945d44a --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Hold.java @@ -0,0 +1,209 @@ +package dev.amg.payguard.domain.model; + +import dev.amg.payguard.domain.enums.HoldStatus; +import dev.amg.payguard.domain.event.FundsHoldCapturedEvent; +import dev.amg.payguard.domain.event.FundsHoldExpiredEvent; +import dev.amg.payguard.domain.event.FundsHoldReleasedEvent; +import dev.amg.payguard.domain.exception.HoldExpiredException; +import dev.amg.payguard.domain.exception.InvalidHoldStateException; +import dev.amg.payguard.domain.value_object.HoldId; +import dev.amg.payguard.domain.value_object.Money; +import dev.amg.payguard.domain.value_object.WalletId; +import domain.AggregateRoot; +import domain.DomainEvent; +import domain.DomainValidation; + +import java.time.Instant; +import java.util.Optional; + +/** + * A full-amount reservation, with a half-open validity interval [createdAt, expiresAt). + * + *

Mutable and command-confined. Capture, release and expiry are mutually exclusive + * terminal transitions. All business validation precedes state/event mutation. A failed + * expired capture does not expire the hold implicitly; expiry is a separate successful + * command that must release wallet funds in the same database transaction. + */ +public final class Hold extends AggregateRoot { + private final WalletId walletId; + private final Money amount; + private final String reason; + private final String referenceId; + private final Instant expiresAt; + private final Instant createdAt; + private HoldStatus status; + private Instant resolvedAt; + + private Hold(HoldId id, WalletId walletId, Money amount, String reason, String referenceId, + Instant expiresAt, Instant createdAt, HoldStatus status, Instant resolvedAt) { + super(id); + this.walletId = DomainValidation.requireNonNull(walletId, "Wallet id"); + this.amount = DomainValidation.requireNonNull(amount, "Amount"); + this.reason = DomainValidation.requireText(reason, "Reason"); + this.referenceId = DomainValidation.requireText(referenceId, "Reference id"); + this.expiresAt = DomainValidation.requireNonNull(expiresAt, "Expiration time"); + this.createdAt = DomainValidation.requireNonNull(createdAt, "Creation time"); + this.status = DomainValidation.requireNonNull(status, "Hold status"); + amount.assertNonNegative(); + if (amount.isZero()) { + throw new InvalidHoldStateException("Hold amount must be positive"); + } + if (!expiresAt.isAfter(createdAt)) { + throw new InvalidHoldStateException("Expiration must be strictly after creation"); + } + if ((status == HoldStatus.PENDING) != (resolvedAt == null)) { + throw new InvalidHoldStateException("Resolution time is inconsistent with hold status"); + } + if (resolvedAt != null) { + validateTime(resolvedAt); + if (status == HoldStatus.CAPTURED && !resolvedAt.isBefore(expiresAt)) { + throw new InvalidHoldStateException("A captured hold must have resolved before expiry"); + } + if (status == HoldStatus.EXPIRED && resolvedAt.isBefore(expiresAt)) { + throw new InvalidHoldStateException("An expired hold cannot resolve before expiry"); + } + } + this.resolvedAt = resolvedAt; + } + + /** + * Creates a pending reservation with a strictly positive amount and future deadline. + */ + public static Hold createNew(HoldId id, WalletId walletId, Money amount, String reason, + String referenceId, Instant expiresAt, Instant now) { + return new Hold(id, walletId, amount, reason, referenceId, expiresAt, now, + HoldStatus.PENDING, null); + } + + /** + * Restores a consistent snapshot without generating domain events. + */ + public static Hold restore(HoldId id, WalletId walletId, Money amount, String reason, + String referenceId, Instant expiresAt, Instant createdAt, HoldStatus status, + Instant resolvedAt) { + return new Hold(id, walletId, amount, reason, referenceId, expiresAt, createdAt, + status, resolvedAt); + } + + /** + * Captures a live pending hold; rejected captures leave state and events unchanged. + */ + public void capture(Instant now) { + validateTime(now); + assertPending(); + if (isExpired(now)) { + throw new HoldExpiredException("Cannot capture an expired hold"); + } + FundsHoldCapturedEvent event = + FundsHoldCapturedEvent.now(getId(), walletId, amount, now); + status = HoldStatus.CAPTURED; + resolvedAt = now; + registerEvent(event); + } + + /** + * Releases any pending hold, including an overdue one, without pretending it was captured. + */ + public void release(Instant now) { + validateTime(now); + assertPending(); + FundsHoldReleasedEvent event = + FundsHoldReleasedEvent.now(getId(), walletId, amount, now); + status = HoldStatus.RELEASED; + resolvedAt = now; + registerEvent(event); + } + + /** + * Expires a pending hold at or after its deadline; requires transactional fund release. + */ + public void expire(Instant now) { + validateTime(now); + assertPending(); + if (!isExpired(now)) { + throw new InvalidHoldStateException("The hold deadline has not been reached"); + } + FundsHoldExpiredEvent event = + FundsHoldExpiredEvent.now(getId(), walletId, amount, now); + status = HoldStatus.EXPIRED; + resolvedAt = now; + registerEvent(event); + } + + /** + * Tests the deadline, not the stored lifecycle status; equality with the deadline is expired. + */ + public boolean isExpired(Instant now) { + DomainValidation.requireNonNull(now, "Evaluation time"); + return !now.isBefore(expiresAt); + } + + private void validateTime(Instant now) { + DomainValidation.requireNonNull(now, "Operation time"); + if (now.isBefore(createdAt)) { + throw new InvalidHoldStateException("Operation time precedes hold creation"); + } + } + + private void assertPending() { + if (status != HoldStatus.PENDING) { + throw new InvalidHoldStateException("Hold is not pending: " + status); + } + } + + /** + * Returns the owning wallet identity. + */ + public WalletId getWalletId() { + return walletId; + } + + /** + * Returns the immutable full reservation amount. + */ + public Money getAmount() { + return amount; + } + + /** + * Returns the business reason for the reservation. + */ + public String getReason() { + return reason; + } + + /** + * Returns the caller's business reference, not an idempotency guarantee. + */ + public String getReferenceId() { + return referenceId; + } + + /** + * Returns the exclusive capture deadline. + */ + public Instant getExpiresAt() { + return expiresAt; + } + + /** + * Returns the immutable creation timestamp. + */ + public Instant getCreatedAt() { + return createdAt; + } + + /** + * Returns the stored lifecycle state, which may be pending after the deadline. + */ + public HoldStatus getStatus() { + return status; + } + + /** + * Returns the terminal transition time, absent for pending holds. + */ + public Optional getResolvedAt() { + return Optional.ofNullable(resolvedAt); + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/LedgerEntry.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/LedgerEntry.java new file mode 100644 index 0000000..a303fba --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/LedgerEntry.java @@ -0,0 +1,72 @@ +package dev.amg.payguard.domain.model; + +import dev.amg.payguard.domain.enums.LedgerEntryType; +import dev.amg.payguard.domain.exception.InvalidWalletStateException; +import dev.amg.payguard.domain.value_object.AccountId; +import dev.amg.payguard.domain.value_object.LedgerEntryId; +import dev.amg.payguard.domain.value_object.Money; +import dev.amg.payguard.domain.value_object.TransactionId; +import domain.DomainValidation; + +import java.time.Instant; + +/** + * An immutable accounting entry, with entity equality by entry identifier. + * + *

The amount is strictly positive; direction is expressed by {@code entryType}. + * Sequence numbers are non-negative and ordered within an account. Allocation, + * uniqueness, immutability in storage, and tenant/account ownership belong to persistence. + * A single entry cannot establish double-entry balance; {@link Transaction} validates a batch. + * + * @param id identity of this entry + * @param transactionId transaction owning this entry + * @param accountId account affected by this entry + * @param amount exact, strictly positive posting magnitude + * @param entryType debit or credit direction + * @param sequenceNumber non-negative account posting sequence + * @param timestamp posting timestamp + */ +public record LedgerEntry( + LedgerEntryId id, + TransactionId transactionId, + AccountId accountId, + Money amount, + LedgerEntryType entryType, + long sequenceNumber, + Instant timestamp) { + + /** + * Validates all entry-local invariants without changing the monetary amount. + */ + public LedgerEntry { + DomainValidation.requireNonNull(id, "Ledger entry id"); + DomainValidation.requireNonNull(transactionId, "Transaction id"); + DomainValidation.requireNonNull(accountId, "Account id"); + DomainValidation.requireNonNull(amount, "Amount"); + DomainValidation.requireNonNull(entryType, "Entry type"); + DomainValidation.requireNonNull(timestamp, "Timestamp"); + amount.assertNonNegative(); + if (amount.isZero()) { + throw new InvalidWalletStateException("Ledger entry amount must be positive"); + } + if (sequenceNumber < 0) { + throw new InvalidWalletStateException("Sequence number cannot be negative"); + } + } + + /** + * Compares entry identity, not posting content; never use this to validate retry payloads. + */ + @Override + public boolean equals(Object other) { + return this == other || other instanceof LedgerEntry entry && id.equals(entry.id); + } + + /** + * Returns a stable hash derived only from the immutable identity. + */ + @Override + public int hashCode() { + return id.hashCode(); + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Transaction.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Transaction.java new file mode 100644 index 0000000..a1afe1e --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Transaction.java @@ -0,0 +1,371 @@ +package dev.amg.payguard.domain.model; + +import dev.amg.payguard.domain.enums.LedgerEntryType; +import dev.amg.payguard.domain.enums.TransactionStatus; +import dev.amg.payguard.domain.enums.TransactionType; +import dev.amg.payguard.domain.exception.CurrencyMismatchException; +import dev.amg.payguard.domain.exception.DoubleEntryImbalanceException; +import dev.amg.payguard.domain.exception.InvalidWalletStateException; +import dev.amg.payguard.domain.exception.TransactionAlreadySettledException; +import dev.amg.payguard.domain.value_object.*; +import domain.AggregateRoot; +import domain.DomainEvent; +import domain.DomainValidation; + +import java.time.Instant; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * A single-currency transfer with an optional additional fee paid by the source. + * + *

Explicit policy: {@code totalAmount} is the principal delivered to the destination; + * the source is debited {@code totalAmount + feeAmount}. Separate fee accounts receive + * fee credits. Fees may be split across accounts. FX, netted fees and partial reversals + * are not supported by this model. + * + *

Mutable and command-confined. Settlement validates postings but does not write them. + * The application must atomically persist transaction state, all entries, affected wallet + * balances, idempotency result and outbox in the same local database transaction. + */ +public final class Transaction extends AggregateRoot { + private final IdempotencyKey idempotencyKey; + private final TransactionType type; + private final AccountId sourceAccountId; + private final AccountId destinationAccountId; + private final Money totalAmount; + private final Money feeAmount; + private final Instant createdAt; + private TransactionStatus status; + private String failureReason; + private Instant settledAt; + private TransactionId reversalTransactionId; + private Instant reversedAt; + + private Transaction(TransactionId id, IdempotencyKey idempotencyKey, TransactionType type, + AccountId sourceAccountId, AccountId destinationAccountId, Money totalAmount, Money feeAmount, + TransactionStatus status, String failureReason, Instant createdAt, Instant settledAt, + TransactionId reversalTransactionId, Instant reversedAt) { + super(id); + this.idempotencyKey = DomainValidation.requireNonNull(idempotencyKey, "Idempotency key"); + this.type = DomainValidation.requireNonNull(type, "Transaction type"); + this.sourceAccountId = DomainValidation.requireNonNull(sourceAccountId, "Source account id"); + this.destinationAccountId = + DomainValidation.requireNonNull(destinationAccountId, "Destination account id"); + this.totalAmount = DomainValidation.requireNonNull(totalAmount, "Total amount"); + this.feeAmount = DomainValidation.requireNonNull(feeAmount, "Fee amount"); + this.status = DomainValidation.requireNonNull(status, "Transaction status"); + this.createdAt = DomainValidation.requireNonNull(createdAt, "Creation time"); + totalAmount.assertNonNegative(); + feeAmount.assertNonNegative(); + if (totalAmount.isZero()) { + throw new InvalidWalletStateException("Transaction principal must be positive"); + } + if (sourceAccountId.equals(destinationAccountId)) { + throw new InvalidWalletStateException("Source and destination accounts must differ"); + } + if (!totalAmount.currency().equals(feeAmount.currency())) { + throw new CurrencyMismatchException("Principal and fee currencies must match"); + } + validateSnapshot(status, failureReason, settledAt, reversalTransactionId, reversedAt); + this.failureReason = failureReason; + this.settledAt = settledAt; + this.reversalTransactionId = reversalTransactionId; + this.reversedAt = reversedAt; + } + + /** + * Creates a new, unsettled transaction without performing IO or posting funds. + */ + public static Transaction initiate(TransactionId id, IdempotencyKey idempotencyKey, + TransactionType type, AccountId sourceAccountId, AccountId destinationAccountId, + Money totalAmount, Money feeAmount, Instant now) { + return new Transaction(id, idempotencyKey, type, sourceAccountId, destinationAccountId, + totalAmount, feeAmount, TransactionStatus.INITIATED, null, now, null, null, null); + } + + /** + * Restores a consistent persisted snapshot without replaying any state transitions. + */ + public static Transaction restore(TransactionId id, IdempotencyKey idempotencyKey, + TransactionType type, AccountId sourceAccountId, AccountId destinationAccountId, + Money totalAmount, Money feeAmount, TransactionStatus status, String failureReason, + Instant createdAt, Instant settledAt, TransactionId reversalTransactionId, Instant reversedAt) { + return new Transaction(id, idempotencyKey, type, sourceAccountId, destinationAccountId, + totalAmount, feeAmount, status, failureReason, createdAt, settledAt, + reversalTransactionId, reversedAt); + } + + /** + * Moves an initiated transaction into pending settlement exactly once. + */ + public void markPendingSettlement() { + if (status != TransactionStatus.INITIATED) { + throw new InvalidWalletStateException("Only an initiated transaction can become pending"); + } + status = TransactionStatus.PENDING_SETTLEMENT; + } + + /** + * Validates the complete posting batch and marks an open transaction successful. + * All validation happens before mutation. This is not a database commit. + */ + public void markSettled(List entries, Instant now) { + assertOpen(); + validateTime(now); + validatePosting(entries, now); + status = TransactionStatus.SUCCESSFUL; + settledAt = now; + } + + /** + * Checks entry identity, account sequences, currency, time, principal/fee allocation + * and equal debit/credit totals without changing transaction state. + */ + public void validatePosting(List entries, Instant now) { + DomainValidation.requireNonNull(entries, "Ledger entries"); + validateTime(now); + for (LedgerEntry entry : entries) { + DomainValidation.requireNonNull(entry, "Ledger entry"); + } + List posting = List.copyOf(entries); + if (posting.size() < 2) { + throw new DoubleEntryImbalanceException("A posting requires at least two entries"); + } + Set entryIds = new HashSet<>(); + Set accountSequences = new HashSet<>(); + Money debits = Money.zero(totalAmount.currency()); + Money credits = Money.zero(totalAmount.currency()); + Money sourceDebits = Money.zero(totalAmount.currency()); + Money destinationCredits = Money.zero(totalAmount.currency()); + Money feeCredits = Money.zero(totalAmount.currency()); + + for (LedgerEntry entry : posting) { + if (!getId().equals(entry.transactionId())) { + throw new InvalidWalletStateException("Entry belongs to another transaction"); + } + if (!entryIds.add(entry.id())) { + throw new InvalidWalletStateException("Duplicate ledger entry identity in batch"); + } + if (!accountSequences.add(new AccountSequence(entry.accountId(), entry.sequenceNumber()))) { + throw new InvalidWalletStateException("Duplicate account sequence in batch"); + } + if (!totalAmount.currency().equals(entry.amount().currency())) { + throw new CurrencyMismatchException("Posting currency must match transaction currency"); + } + if (entry.timestamp().isBefore(createdAt) || entry.timestamp().isAfter(now)) { + throw new InvalidWalletStateException("Posting timestamp is outside transaction interval"); + } + if (entry.entryType() == LedgerEntryType.DEBIT) { + if (!entry.accountId().equals(sourceAccountId)) { + throw new InvalidWalletStateException("Only the source account may be debited"); + } + debits = debits.add(entry.amount()); + sourceDebits = sourceDebits.add(entry.amount()); + } else { + if (entry.accountId().equals(sourceAccountId)) { + throw new InvalidWalletStateException("Source account cannot receive credit in this posting"); + } + credits = credits.add(entry.amount()); + if (entry.accountId().equals(destinationAccountId)) { + destinationCredits = destinationCredits.add(entry.amount()); + } else { + feeCredits = feeCredits.add(entry.amount()); + } + } + } + if (!debits.equals(credits)) { + throw new DoubleEntryImbalanceException("Debit and credit totals differ"); + } + if (!sourceDebits.equals(totalAmount.add(feeAmount)) + || !destinationCredits.equals(totalAmount) + || !feeCredits.equals(feeAmount)) { + throw new InvalidWalletStateException("Posting does not match principal and fee allocation"); + } + } + + /** + * Fails only an open transaction; invalid reasons do not alter state. + */ + public void markFailed(String reason) { + assertOpen(); + String validatedReason = DomainValidation.requireText(reason, "Failure reason"); + status = TransactionStatus.FAILED; + failureReason = validatedReason; + } + + /** + * Links a successful, full, zero-fee compensating REFUND transaction. + * + *

This deliberately refuses fee-bearing reversals until a fee-refund posting policy + * is defined. The original entries are never changed. Persist the reversal and this + * link atomically, with a unique original/reversal relationship and tenant checks. + */ + public void markReversed(Transaction reversal) { + DomainValidation.requireNonNull(reversal, "Reversal transaction"); + if (status != TransactionStatus.SUCCESSFUL) { + throw new InvalidWalletStateException("Only a successful transaction can be reversed"); + } + if (reversal == this || getId().equals(reversal.getId())) { + throw new InvalidWalletStateException("A transaction cannot reverse itself"); + } + if (reversal.status != TransactionStatus.SUCCESSFUL || reversal.type != TransactionType.REFUND) { + throw new InvalidWalletStateException("Reversal must be a successful refund transaction"); + } + if (!feeAmount.isZero() || !reversal.feeAmount.isZero()) { + throw new InvalidWalletStateException("Fee-bearing reversals require an explicit refund policy"); + } + if (!sourceAccountId.equals(reversal.destinationAccountId) + || !destinationAccountId.equals(reversal.sourceAccountId) + || !totalAmount.equals(reversal.totalAmount)) { + throw new InvalidWalletStateException("Reversal must return the full original principal"); + } + if (reversal.createdAt.isBefore(settledAt) || reversal.settledAt.isBefore(settledAt)) { + throw new InvalidWalletStateException("Reversal cannot precede original settlement"); + } + reversalTransactionId = reversal.getId(); + reversedAt = reversal.settledAt; + status = TransactionStatus.REVERSED; + } + + private void validateSnapshot(TransactionStatus status, String reason, Instant settlement, + TransactionId reversalId, Instant reversalTime) { + if (settlement != null) { + validateTime(settlement); + } + switch (status) { + case INITIATED, PENDING_SETTLEMENT -> { + if (reason != null || settlement != null || reversalId != null || reversalTime != null) { + throw new InvalidWalletStateException("Open transaction contains terminal metadata"); + } + } + case FAILED -> { + DomainValidation.requireText(reason, "Failure reason"); + if (settlement != null || reversalId != null || reversalTime != null) { + throw new InvalidWalletStateException("Failed transaction contains settlement metadata"); + } + } + case SUCCESSFUL -> { + if (reason != null || settlement == null || reversalId != null || reversalTime != null) { + throw new InvalidWalletStateException("Successful transaction metadata is inconsistent"); + } + } + case REVERSED -> { + if (reason != null || settlement == null || reversalId == null || reversalTime == null) { + throw new InvalidWalletStateException("Reversed transaction metadata is incomplete"); + } + if (getId().equals(reversalId) || reversalTime.isBefore(settlement) || !feeAmount.isZero()) { + throw new InvalidWalletStateException("Invalid reversal reference, time or fee policy"); + } + } + } + } + + private void assertOpen() { + if (status == TransactionStatus.SUCCESSFUL) { + throw new TransactionAlreadySettledException("Transaction is already settled"); + } + if (status != TransactionStatus.INITIATED && status != TransactionStatus.PENDING_SETTLEMENT) { + throw new InvalidWalletStateException("Transaction is terminal: " + status); + } + } + + private void validateTime(Instant now) { + DomainValidation.requireNonNull(now, "Operation time"); + if (now.isBefore(createdAt)) { + throw new InvalidWalletStateException("Operation time precedes transaction creation"); + } + } + + /** + * A local collision key, deliberately not a new public domain abstraction. + */ + private record AccountSequence(AccountId accountId, long sequenceNumber) { + } + + /** + * Returns the exact client-supplied retry key; uniqueness is scoped in persistence. + */ + public IdempotencyKey getIdempotencyKey() { + return idempotencyKey; + } + + /** + * Returns the business transaction classification. + */ + public TransactionType getType() { + return type; + } + + /** + * Returns the account paying principal plus fee. + */ + public AccountId getSourceAccountId() { + return sourceAccountId; + } + + /** + * Returns the account receiving the principal. + */ + public AccountId getDestinationAccountId() { + return destinationAccountId; + } + + /** + * Returns the destination principal, excluding the additional source-paid fee. + */ + public Money getTotalAmount() { + return totalAmount; + } + + /** + * Returns the additional fee, possibly zero. + */ + public Money getFeeAmount() { + return feeAmount; + } + + /** + * Returns the current lifecycle state. + */ + public TransactionStatus getStatus() { + return status; + } + + /** + * Returns a reason only when the transaction failed. + */ + public Optional getFailureReason() { + return Optional.ofNullable(failureReason); + } + + /** + * Returns the immutable initiation timestamp. + */ + public Instant getCreatedAt() { + return createdAt; + } + + /** + * Returns the original settlement time, retained after reversal. + */ + public Optional getSettledAt() { + return Optional.ofNullable(settledAt); + } + + /** + * Returns the successful compensating transaction identity when reversed. + */ + public Optional getReversalTransactionId() { + return Optional.ofNullable(reversalTransactionId); + } + + /** + * Returns the compensating settlement time when reversed. + */ + public Optional getReversedAt() { + return Optional.ofNullable(reversedAt); + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Wallet.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Wallet.java new file mode 100644 index 0000000..a7e7a3e --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Wallet.java @@ -0,0 +1,303 @@ +package dev.amg.payguard.domain.model; + +import dev.amg.payguard.domain.enums.LedgerEntryType; +import dev.amg.payguard.domain.enums.WalletStatus; +import dev.amg.payguard.domain.event.WalletClosedEvent; +import dev.amg.payguard.domain.event.WalletCreatedEvent; +import dev.amg.payguard.domain.event.WalletStatusChangedEvent; +import dev.amg.payguard.domain.exception.CurrencyMismatchException; +import dev.amg.payguard.domain.exception.InsufficientFundException; +import dev.amg.payguard.domain.exception.InvalidWalletStateException; +import dev.amg.payguard.domain.exception.WalletFrozenException; +import dev.amg.payguard.domain.value_object.AccountId; +import dev.amg.payguard.domain.value_object.Money; +import dev.amg.payguard.domain.value_object.UserId; +import dev.amg.payguard.domain.value_object.WalletId; +import domain.AggregateRoot; +import domain.DomainEvent; +import domain.DomainValidation; +import java.time.Instant; +import java.util.Currency; + +/** + * A single-currency, non-overdraft customer wallet backed by an explicit ledger account. + * + *

Always maintains {@code 0 <= reservedBalance <= bookBalance}. Credits increase + * this customer-liability balance; debits decrease it. This is not a generic GL account. + * + *

Instances are mutable and MUST be confined to one command/unit of work, never + * shared across threads or cached as live aggregates. Repository locking or versioned + * conditional updates are required for concurrent commands. The loaded version is an + * expected persistence version, not an in-memory mutation counter. + */ +public final class Wallet extends AggregateRoot { + private final AccountId accountId; + private final UserId userId; + private final Currency currency; + private final Instant createdAt; + private final long version; + private WalletStatus status; + private Money bookBalance; + private Money reservedBalance; + private Instant updatedAt; + private long lastAppliedSequenceNumber; + + private Wallet(WalletId id, AccountId accountId, UserId userId, Currency currency, + WalletStatus status, Money bookBalance, Money reservedBalance, long version, + long lastAppliedSequenceNumber, Instant createdAt, Instant updatedAt) { + super(id); + this.accountId = DomainValidation.requireNonNull(accountId, "Ledger account id"); + this.userId = DomainValidation.requireNonNull(userId, "User id"); + this.currency = DomainValidation.requireNonNull(currency, "Currency"); + this.status = DomainValidation.requireNonNull(status, "Wallet status"); + this.bookBalance = DomainValidation.requireNonNull(bookBalance, "Book balance"); + this.reservedBalance = DomainValidation.requireNonNull(reservedBalance, "Reserved balance"); + this.createdAt = DomainValidation.requireNonNull(createdAt, "Creation time"); + this.updatedAt = DomainValidation.requireNonNull(updatedAt, "Update time"); + requireCurrency(bookBalance); + requireCurrency(reservedBalance); + bookBalance.assertNonNegative(); + reservedBalance.assertNonNegative(); + if (reservedBalance.isGreaterThan(bookBalance)) { + throw new InvalidWalletStateException("Reserved balance cannot exceed book balance"); + } + if (version < 0 || lastAppliedSequenceNumber < -1) { + throw new InvalidWalletStateException("Invalid persisted version or posting sequence"); + } + if (updatedAt.isBefore(createdAt)) { + throw new InvalidWalletStateException("Update time precedes creation time"); + } + if (status == WalletStatus.CLOSED && (!bookBalance.isZero() || !reservedBalance.isZero())) { + throw new InvalidWalletStateException("A closed wallet must have zero balances"); + } + this.version = version; + this.lastAppliedSequenceNumber = lastAppliedSequenceNumber; + } + + /** Creates an empty wallet with an explicitly assigned ledger account; emits one event. */ + public static Wallet create(WalletId id, AccountId accountId, UserId userId, + Currency currency, Instant now) { + Wallet wallet = new Wallet(id, accountId, userId, currency, WalletStatus.ACTIVE, + Money.zero(currency), Money.zero(currency), 0L, -1L, now, now); + wallet.registerEvent(WalletCreatedEvent.now(id, userId, currency, now)); + return wallet; + } + + /** Restores validated persisted state without emitting creation or transition events. */ + public static Wallet restore(WalletId id, AccountId accountId, UserId userId, Currency currency, + WalletStatus status, Money bookBalance, Money reservedBalance, long version, + long lastAppliedSequenceNumber, Instant createdAt, Instant updatedAt) { + return new Wallet(id, accountId, userId, currency, status, bookBalance, reservedBalance, + version, lastAppliedSequenceNumber, createdAt, updatedAt); + } + + /** Returns spendable funds; holds are already excluded from this amount. */ + public Money getAvailableBalance() { + return bookBalance.subtract(reservedBalance); + } + + /** Checks a strictly positive, same-currency debit against status and available funds. */ + public void assertCanDebit(Money amount) { + requirePositiveAmount(amount); + assertDebitEnabled(); + if (getAvailableBalance().isLessThan(amount)) { + throw new InsufficientFundException("Insufficient available funds"); + } + } + + /** Checks a strictly positive, same-currency credit against wallet status. */ + public void assertCanCredit(Money amount) { + requirePositiveAmount(amount); + if (status != WalletStatus.ACTIVE && status != WalletStatus.SUSPENDED_DEBIT) { + throw new WalletFrozenException("Credits are not permitted in state: " + status); + } + } + + /** Reserves spendable funds; the application must create the matching Hold atomically. */ + public void reserveFunds(Money amount, Instant now) { + validateMutationTime(now); + assertCanDebit(amount); + reservedBalance = reservedBalance.add(amount); + updatedAt = now; + } + + /** Releases a reservation even when frozen; the matching Hold must be resolved atomically. */ + public void unreserveFunds(Money amount, Instant now) { + validateMutationTime(now); + assertNotClosed(); + requirePositiveAmount(amount); + if (reservedBalance.isLessThan(amount)) { + throw new InvalidWalletStateException("Cannot release more than the reserved balance"); + } + reservedBalance = reservedBalance.subtract(amount); + updatedAt = now; + } + + /** + * Applies an ordinary posting after validating status, ownership, funds, time and order. + * + *

Entries must be applied in strictly increasing account sequence order. Gaps are + * permitted; sequence allocation and duplicate prevention across processes require SQL + * constraints and a lock or version check. A batch must first pass Transaction validation. + */ + public void applyLedgerEntry(LedgerEntry entry, Instant now) { + validateEntry(entry, now); + bookBalance = switch (entry.entryType()) { + case CREDIT -> { + assertCanCredit(entry.amount()); + yield bookBalance.add(entry.amount()); + } + case DEBIT -> { + assertCanDebit(entry.amount()); + yield bookBalance.subtract(entry.amount()); + } + }; + lastAppliedSequenceNumber = entry.sequenceNumber(); + updatedAt = now; + } + + /** + * Captures reserved funds with a debit, updating book and reserved balances together. + * + *

The command handler must validate the matching pending Hold, its wallet, exact + * amount and expiry, then persist Hold, Wallet, balanced postings and outbox in one + * transaction. This method alone does not authorize or identify a Hold. + */ + public void captureReservedFunds(LedgerEntry debitEntry, Instant now) { + validateEntry(debitEntry, now); + assertDebitEnabled(); + if (debitEntry.entryType() != LedgerEntryType.DEBIT) { + throw new InvalidWalletStateException("Capturing reserved funds requires a debit entry"); + } + if (reservedBalance.isLessThan(debitEntry.amount())) { + throw new InvalidWalletStateException("Capture exceeds the reserved balance"); + } + Money nextBookBalance = bookBalance.subtract(debitEntry.amount()); + Money nextReservedBalance = reservedBalance.subtract(debitEntry.amount()); + bookBalance = nextBookBalance; + reservedBalance = nextReservedBalance; + lastAppliedSequenceNumber = debitEntry.sequenceNumber(); + updatedAt = now; + } + + /** Freezes credits and debits; repeated freezing is a no-op after input validation. */ + public void suspend(String reason, Instant now) { + changeStatus(WalletStatus.FROZEN, reason, now); + } + + /** Suspends debits, preserving any existing credit restriction. */ + public void suspendDebits(String reason, Instant now) { + WalletStatus target = status == WalletStatus.SUSPENDED_CREDIT || status == WalletStatus.FROZEN + ? WalletStatus.FROZEN : WalletStatus.SUSPENDED_DEBIT; + changeStatus(target, reason, now); + } + + /** Suspends credits, preserving any existing debit restriction. */ + public void suspendCredits(String reason, Instant now) { + WalletStatus target = status == WalletStatus.SUSPENDED_DEBIT || status == WalletStatus.FROZEN + ? WalletStatus.FROZEN : WalletStatus.SUSPENDED_CREDIT; + changeStatus(target, reason, now); + } + + /** Reactivates a non-closed wallet; authorization belongs to the command boundary. */ + public void activate(Instant now) { + changeStatus(WalletStatus.ACTIVE, "Reactivated", now); + } + + /** Closes a wallet only when both balances are zero. Closure is terminal. */ + public void close(Instant now) { + validateMutationTime(now); + assertNotClosed(); + if (!bookBalance.isZero() || !reservedBalance.isZero()) { + throw new InvalidWalletStateException("Wallet cannot close with funds or reservations"); + } + WalletClosedEvent event = WalletClosedEvent.now(getId(), now); + status = WalletStatus.CLOSED; + updatedAt = now; + registerEvent(event); + } + + private void validateEntry(LedgerEntry entry, Instant now) { + DomainValidation.requireNonNull(entry, "Ledger entry"); + validateMutationTime(now); + if (!accountId.equals(entry.accountId())) { + throw new InvalidWalletStateException("Ledger entry belongs to another account"); + } + requireCurrency(entry.amount()); + if (entry.sequenceNumber() <= lastAppliedSequenceNumber) { + throw new InvalidWalletStateException("Duplicate or out-of-order ledger entry"); + } + if (entry.timestamp().isBefore(createdAt) || entry.timestamp().isAfter(now)) { + throw new InvalidWalletStateException("Ledger entry timestamp is outside the allowed interval"); + } + } + + private void requirePositiveAmount(Money amount) { + DomainValidation.requireNonNull(amount, "Amount"); + requireCurrency(amount); + amount.assertNonNegative(); + if (amount.isZero()) { + throw new InvalidWalletStateException("Operation amount must be positive"); + } + } + + private void requireCurrency(Money amount) { + if (!currency.equals(amount.currency())) { + throw new CurrencyMismatchException("Amount currency does not match wallet currency"); + } + } + + private void validateMutationTime(Instant now) { + DomainValidation.requireNonNull(now, "Operation time"); + if (now.isBefore(updatedAt)) { + throw new InvalidWalletStateException("Operation time precedes the last update"); + } + } + + private void assertDebitEnabled() { + if (status != WalletStatus.ACTIVE && status != WalletStatus.SUSPENDED_CREDIT) { + throw new WalletFrozenException("Debits are not permitted in state: " + status); + } + } + + private void assertNotClosed() { + if (status == WalletStatus.CLOSED) { + throw new InvalidWalletStateException("Wallet is permanently closed"); + } + } + + private void changeStatus(WalletStatus target, String reason, Instant now) { + validateMutationTime(now); + assertNotClosed(); + DomainValidation.requireText(reason, "Status change reason"); + if (status == target) { + return; + } + WalletStatusChangedEvent event = + WalletStatusChangedEvent.now(getId(), status, target, reason, now); + status = target; + updatedAt = now; + registerEvent(event); + } + + /** Returns the explicit ledger account identity, not a conversion of the wallet identity. */ + public AccountId getAccountId() { return accountId; } + /** Returns the external owner reference. */ + public UserId getUserId() { return userId; } + /** Returns the wallet's single currency. */ + public Currency getCurrency() { return currency; } + /** Returns the current operational state. */ + public WalletStatus getStatus() { return status; } + /** Returns the book balance, including reserved funds. */ + public Money getBookBalance() { return bookBalance; } + /** Returns the reserved part of the book balance. */ + public Money getReservedBalance() { return reservedBalance; } + /** Returns the loaded version for a repository compare-and-update; not a mutation count. */ + public long getVersion() { return version; } + /** Returns the last applied sequence, or -1 before any entry has been applied. */ + public long getLastAppliedSequenceNumber() { return lastAppliedSequenceNumber; } + /** Returns the immutable creation timestamp. */ + public Instant getCreatedAt() { return createdAt; } + /** Returns the time of the last effective state change. */ + public Instant getUpdatedAt() { return updatedAt; } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/ClockPort.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/ClockPort.java new file mode 100644 index 0000000..5507b6a --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/ClockPort.java @@ -0,0 +1,8 @@ +package dev.amg.payguard.domain.port; + +import java.time.Instant; + +@FunctionalInterface +public interface ClockPort { + Instant now(); +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/DomainEventPublisherPort.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/DomainEventPublisherPort.java new file mode 100644 index 0000000..8a3e7f7 --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/DomainEventPublisherPort.java @@ -0,0 +1,10 @@ +package dev.amg.payguard.domain.port; + +import domain.DomainEvent; +import java.util.List; + +public interface DomainEventPublisherPort { + void publish(DomainEvent event); + + void publishAll(List events); +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/HoldRepositoryPort.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/HoldRepositoryPort.java new file mode 100644 index 0000000..9a05f9c --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/HoldRepositoryPort.java @@ -0,0 +1,15 @@ +package dev.amg.payguard.domain.port; + +import dev.amg.payguard.domain.model.Hold; +import dev.amg.payguard.domain.value_object.HoldId; +import dev.amg.payguard.domain.value_object.WalletId; +import java.util.List; +import java.util.Optional; + +public interface HoldRepositoryPort { + Optional findById(HoldId id); + + List findPendingHoldsByWalletId(WalletId walletId); + + Hold save(Hold hold); +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/LedgerRepositoryPort.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/LedgerRepositoryPort.java new file mode 100644 index 0000000..40180da --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/LedgerRepositoryPort.java @@ -0,0 +1,15 @@ +package dev.amg.payguard.domain.port; + +import dev.amg.payguard.domain.model.LedgerEntry; +import dev.amg.payguard.domain.value_object.AccountId; +import dev.amg.payguard.domain.value_object.Money; +import java.util.Currency; +import java.util.List; + +public interface LedgerRepositoryPort { + void saveEntries(List entries); + + Money calculateBalance(AccountId accountId, Currency currency); + + long getNextSequenceNumber(AccountId accountId); +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/LoadWalletPort.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/LoadWalletPort.java new file mode 100644 index 0000000..9ed164f --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/LoadWalletPort.java @@ -0,0 +1,15 @@ +package dev.amg.payguard.domain.port; + +import dev.amg.payguard.domain.model.Wallet; +import dev.amg.payguard.domain.value_object.UserId; +import dev.amg.payguard.domain.value_object.WalletId; +import java.util.Currency; +import java.util.Optional; + +public interface LoadWalletPort { + Optional findById(WalletId id); + + Optional findByIdForUpdate(WalletId id); + + boolean existsByUserIdAndCurrency(UserId userId, Currency currency); +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/SaveWalletPort.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/SaveWalletPort.java new file mode 100644 index 0000000..61fd2ec --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/SaveWalletPort.java @@ -0,0 +1,8 @@ +package dev.amg.payguard.domain.port; + +import dev.amg.payguard.domain.model.Wallet; + +@FunctionalInterface +public interface SaveWalletPort { + Wallet save(Wallet wallet); +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/TransactionRepositoryPort.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/TransactionRepositoryPort.java new file mode 100644 index 0000000..8b88430 --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/TransactionRepositoryPort.java @@ -0,0 +1,14 @@ +package dev.amg.payguard.domain.port; + +import dev.amg.payguard.domain.model.Transaction; +import dev.amg.payguard.domain.value_object.IdempotencyKey; +import dev.amg.payguard.domain.value_object.TransactionId; +import java.util.Optional; + +public interface TransactionRepositoryPort { + Optional findById(TransactionId id); + + Optional findByIdempotencyKey(IdempotencyKey key); + + Transaction save(Transaction transaction); +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/AccountId.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/AccountId.java new file mode 100644 index 0000000..04b9cd8 --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/AccountId.java @@ -0,0 +1,45 @@ +package dev.amg.payguard.domain.value_object; + +import domain.DomainValidation; +import domain.ValueObject; +import java.util.UUID; + +/** + * Immutable, strongly typed identifier for a ledger account. + * + * @param value the UUID identity value + */ +public record AccountId(UUID value) implements ValueObject { + + /** Rejects a null identity. */ + public AccountId { + DomainValidation.requireNonNull(value, "AccountId value"); + } + + /** Generates a new random (version 4) UUID identifier. */ + public static AccountId generate() { + return new AccountId(UUID.randomUUID()); + } + + /** + * Parses a canonical UUID string; uppercase hex digits are accepted. + * + * @param value the canonical UUID string + * @return the corresponding AccountId + * @throws IllegalArgumentException for malformed or noncanonical UUIDs + */ + public static AccountId fromString(String value) { + DomainValidation.requireNonNull(value, "AccountId string"); + UUID parsed = UUID.fromString(value); + if (!parsed.toString().equalsIgnoreCase(value)) { + throw new IllegalArgumentException("Noncanonical AccountId UUID: " + value); + } + return new AccountId(parsed); + } + + /** Compares value objects using their exact record values and types. */ + @Override + public boolean sameValueAs(ValueObject other) { + return equals(other); + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/HoldId.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/HoldId.java new file mode 100644 index 0000000..0d3e245 --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/HoldId.java @@ -0,0 +1,45 @@ +package dev.amg.payguard.domain.value_object; + +import domain.DomainValidation; +import domain.ValueObject; +import java.util.UUID; + +/** + * Immutable, strongly typed identifier for a reservation of funds. + * + * @param value the UUID identity value + */ +public record HoldId(UUID value) implements ValueObject { + + /** Rejects a null identity. */ + public HoldId { + DomainValidation.requireNonNull(value, "HoldId value"); + } + + /** Generates a new random (version 4) UUID identifier. */ + public static HoldId generate() { + return new HoldId(UUID.randomUUID()); + } + + /** + * Parses a canonical UUID string; uppercase hex digits are accepted. + * + * @param value the canonical UUID string + * @return the corresponding HoldId + * @throws IllegalArgumentException for malformed or noncanonical UUIDs + */ + public static HoldId fromString(String value) { + DomainValidation.requireNonNull(value, "HoldId string"); + UUID parsed = UUID.fromString(value); + if (!parsed.toString().equalsIgnoreCase(value)) { + throw new IllegalArgumentException("Noncanonical HoldId UUID: " + value); + } + return new HoldId(parsed); + } + + /** Compares value objects using their exact record values and types. */ + @Override + public boolean sameValueAs(ValueObject other) { + return equals(other); + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/IdempotencyKey.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/IdempotencyKey.java new file mode 100644 index 0000000..e1fe6c0 --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/IdempotencyKey.java @@ -0,0 +1,46 @@ +package dev.amg.payguard.domain.value_object; + +import dev.amg.payguard.domain.exception.InvalidIdempotencyKeyException; +import domain.DomainValidation; +import domain.ValueObject; +import java.util.regex.Pattern; + +/** + * An exact, case-sensitive, client-supplied idempotency key. + * + *

The protocol for this Wallet module accepts 1 to 255 ASCII characters from + * A-Z, a-z, 0-9, period, underscore, tilde and hyphen. Keys are never trimmed, + * case-folded or otherwise normalized. + * + *

Tenant/operation scope, request fingerprint, status, and timestamps belong + * to the persisted idempotency record, not to this value object. + * + * @param value the exact idempotency key + */ +public record IdempotencyKey(String value) implements ValueObject { + + /** Maximum key length, chosen as an explicit Wallet API policy. */ + public static final int MAX_LENGTH = 255; + + private static final Pattern VALID_KEY = Pattern.compile("[A-Za-z0-9._~-]{1,255}"); + + /** Validates that the key is a permitted immutable protocol value. */ + public IdempotencyKey { + DomainValidation.requireNonNull(value, "Idempotency key value"); + if (!VALID_KEY.matcher(value).matches()) { + throw new InvalidIdempotencyKeyException( + "Idempotency key must be 1-255 ASCII letters, digits, '.', '_', '~' or '-'"); + } + } + + /** Creates a key from an exact, client-supplied string. */ + public static IdempotencyKey of(String value) { + return new IdempotencyKey(value); + } + + /** Compares value objects using their exact record values and types. */ + @Override + public boolean sameValueAs(ValueObject other) { + return equals(other); + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/LedgerEntryId.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/LedgerEntryId.java new file mode 100644 index 0000000..2f785e4 --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/LedgerEntryId.java @@ -0,0 +1,45 @@ +package dev.amg.payguard.domain.value_object; + +import domain.DomainValidation; +import domain.ValueObject; +import java.util.UUID; + +/** + * Immutable, strongly typed identifier for an individual ledger posting. + * + * @param value the UUID identity value + */ +public record LedgerEntryId(UUID value) implements ValueObject { + + /** Rejects a null identity. */ + public LedgerEntryId { + DomainValidation.requireNonNull(value, "LedgerEntryId value"); + } + + /** Generates a new random (version 4) UUID identifier. */ + public static LedgerEntryId generate() { + return new LedgerEntryId(UUID.randomUUID()); + } + + /** + * Parses a canonical UUID string; uppercase hex digits are accepted. + * + * @param value the canonical UUID string + * @return the corresponding LedgerEntryId + * @throws IllegalArgumentException for malformed or noncanonical UUIDs + */ + public static LedgerEntryId fromString(String value) { + DomainValidation.requireNonNull(value, "LedgerEntryId string"); + UUID parsed = UUID.fromString(value); + if (!parsed.toString().equalsIgnoreCase(value)) { + throw new IllegalArgumentException("Noncanonical LedgerEntryId UUID: " + value); + } + return new LedgerEntryId(parsed); + } + + /** Compares value objects using their exact record values and types. */ + @Override + public boolean sameValueAs(ValueObject other) { + return equals(other); + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/Money.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/Money.java new file mode 100644 index 0000000..70f0d0c --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/Money.java @@ -0,0 +1,160 @@ +package dev.amg.payguard.domain.value_object; + +import dev.amg.payguard.domain.exception.CurrencyMismatchException; +import dev.amg.payguard.domain.exception.NegativeAmountNotAllowedException; +import domain.DomainValidation; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Currency; +import domain.ValueObject; + +/** + * Exact, immutable monetary amount with its currency. + * + *

Values are normalized to the currency's default ISO 4217 minor-unit scale; + * no implicit rounding is allowed. Negative values are valid; operation-specific + * sign constraints belong to the Wallet domain rules. + * + *

Comparison is supported only for equal currencies. + * + * @param amount exact value in major currency units + * @param currency ISO 4217 currency + */ +public record Money(BigDecimal amount, Currency currency) + implements Comparable, ValueObject { + + /** Validates currency metadata and exact monetary scale. */ + public Money { + DomainValidation.requireNonNull(amount, "Amount"); + DomainValidation.requireNonNull(currency, "Currency"); + int fractionDigits = currency.getDefaultFractionDigits(); + if (fractionDigits < 0) { + throw new IllegalArgumentException( + "Unsupported currency precision: " + currency.getCurrencyCode()); + } + amount = amount.setScale(fractionDigits, RoundingMode.UNNECESSARY); + } + + /** Constructs Money from an exact decimal value. */ + public static Money of(BigDecimal amount, Currency currency) { + return new Money(amount, currency); + } + + /** Parses an exact base-10 decimal representation. */ + public static Money of(String amount, Currency currency) { + DomainValidation.requireNonNull(amount, "Amount string"); + return new Money(new BigDecimal(amount), currency); + } + + /** Constructs Money from whole major currency units (not minor units). */ + public static Money of(long amount, Currency currency) { + return new Money(BigDecimal.valueOf(amount), currency); + } + + /** Creates an exact zero using the currency's configured scale. */ + public static Money zero(Currency currency) { + return new Money(BigDecimal.ZERO, currency); + } + + /** Adds compatible amounts without rounding. */ + public Money add(Money other) { + assertSameCurrency(other); + return new Money(amount.add(other.amount), currency); + } + + /** Subtracts compatible amounts without rounding. */ + public Money subtract(Money other) { + assertSameCurrency(other); + return new Money(amount.subtract(other.amount), currency); + } + + /** Multiplies the amount by an integer factor, preserving exact precision. */ + public Money multiply(long factor) { + return new Money(amount.multiply(BigDecimal.valueOf(factor)), currency); + } + + /** Multiplies by a decimal factor; rejects results that require rounding. */ + public Money multiply(BigDecimal factor) { + DomainValidation.requireNonNull(factor, "Multiplication factor"); + return new Money(amount.multiply(factor), currency); + } + + /** Returns the absolute value in the same currency. */ + public Money abs() { + return new Money(amount.abs(), currency); + } + + /** Returns the additive inverse in the same currency. */ + public Money negate() { + return new Money(amount.negate(), currency); + } + + /** Asserts that the monetary value is zero or positive. */ + public void assertNonNegative() { + if (isNegative()) { + throw new NegativeAmountNotAllowedException( + "Amount cannot be negative: " + amount); + } + } + + /** Tests whether the amount is strictly positive. */ + public boolean isPositive() { + return amount.signum() > 0; + } + + /** Tests whether the amount is strictly negative. */ + public boolean isNegative() { + return amount.signum() < 0; + } + + /** Tests whether the amount is zero. */ + public boolean isZero() { + return amount.signum() == 0; + } + + /** Compares compatible amounts for a strictly greater value. */ + public boolean isGreaterThan(Money other) { + return compareTo(other) > 0; + } + + /** Compares compatible amounts for a greater-or-equal value. */ + public boolean isGreaterThanOrEqual(Money other) { + return compareTo(other) >= 0; + } + + /** Compares compatible amounts for a strictly smaller value. */ + public boolean isLessThan(Money other) { + return compareTo(other) < 0; + } + + /** Compares compatible amounts for a smaller-or-equal value. */ + public boolean isLessThanOrEqual(Money other) { + return compareTo(other) <= 0; + } + + /** + * Orders monetary values in the same currency only. + * + * @throws CurrencyMismatchException for different currencies + */ + @Override + public int compareTo(Money other) { + assertSameCurrency(other); + return amount.compareTo(other.amount); + } + + /** Compares value objects using exact normalized record equality. */ + @Override + public boolean sameValueAs(ValueObject other) { + return equals(other); + } + + private void assertSameCurrency(Money other) { + DomainValidation.requireNonNull(other, "Money operand"); + if (!currency.equals(other.currency)) { + throw new CurrencyMismatchException( + "Currencies do not match: %s vs %s" + .formatted(currency.getCurrencyCode(), other.currency.getCurrencyCode())); + } + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/TransactionId.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/TransactionId.java new file mode 100644 index 0000000..f9d4389 --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/TransactionId.java @@ -0,0 +1,45 @@ +package dev.amg.payguard.domain.value_object; + +import domain.DomainValidation; +import domain.ValueObject; +import java.util.UUID; + +/** + * Immutable, strongly typed identifier for a financial transaction. + * + * @param value the UUID identity value + */ +public record TransactionId(UUID value) implements ValueObject { + + /** Rejects a null identity. */ + public TransactionId { + DomainValidation.requireNonNull(value, "TransactionId value"); + } + + /** Generates a new random (version 4) UUID identifier. */ + public static TransactionId generate() { + return new TransactionId(UUID.randomUUID()); + } + + /** + * Parses a canonical UUID string; uppercase hex digits are accepted. + * + * @param value the canonical UUID string + * @return the corresponding TransactionId + * @throws IllegalArgumentException for malformed or noncanonical UUIDs + */ + public static TransactionId fromString(String value) { + DomainValidation.requireNonNull(value, "TransactionId string"); + UUID parsed = UUID.fromString(value); + if (!parsed.toString().equalsIgnoreCase(value)) { + throw new IllegalArgumentException("Noncanonical TransactionId UUID: " + value); + } + return new TransactionId(parsed); + } + + /** Compares value objects using their exact record values and types. */ + @Override + public boolean sameValueAs(ValueObject other) { + return equals(other); + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/UserId.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/UserId.java new file mode 100644 index 0000000..9770997 --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/UserId.java @@ -0,0 +1,40 @@ +package dev.amg.payguard.domain.value_object; + +import domain.DomainValidation; +import domain.ValueObject; +import java.util.UUID; + +/** + * Immutable, strongly typed identifier for a user in the Identity bounded context. + * + * @param value the UUID identity value + */ +public record UserId(UUID value) implements ValueObject { + + /** Rejects a null identity. */ + public UserId { + DomainValidation.requireNonNull(value, "UserId value"); + } + + /** + * Parses a canonical UUID string; uppercase hex digits are accepted. + * + * @param value the canonical UUID string + * @return the corresponding UserId + * @throws IllegalArgumentException for malformed or noncanonical UUIDs + */ + public static UserId fromString(String value) { + DomainValidation.requireNonNull(value, "UserId string"); + UUID parsed = UUID.fromString(value); + if (!parsed.toString().equalsIgnoreCase(value)) { + throw new IllegalArgumentException("Noncanonical UserId UUID: " + value); + } + return new UserId(parsed); + } + + /** Compares value objects using their exact record values and types. */ + @Override + public boolean sameValueAs(ValueObject other) { + return equals(other); + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/WalletId.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/WalletId.java new file mode 100644 index 0000000..60b97bc --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/WalletId.java @@ -0,0 +1,45 @@ +package dev.amg.payguard.domain.value_object; + +import domain.DomainValidation; +import domain.ValueObject; +import java.util.UUID; + +/** + * Immutable, strongly typed identifier for a wallet aggregate. + * + * @param value the UUID identity value + */ +public record WalletId(UUID value) implements ValueObject { + + /** Rejects a null identity. */ + public WalletId { + DomainValidation.requireNonNull(value, "WalletId value"); + } + + /** Generates a new random (version 4) UUID identifier. */ + public static WalletId generate() { + return new WalletId(UUID.randomUUID()); + } + + /** + * Parses a canonical UUID string; uppercase hex digits are accepted. + * + * @param value the canonical UUID string + * @return the corresponding WalletId + * @throws IllegalArgumentException for malformed or noncanonical UUIDs + */ + public static WalletId fromString(String value) { + DomainValidation.requireNonNull(value, "WalletId string"); + UUID parsed = UUID.fromString(value); + if (!parsed.toString().equalsIgnoreCase(value)) { + throw new IllegalArgumentException("Noncanonical WalletId UUID: " + value); + } + return new WalletId(parsed); + } + + /** Compares value objects using their exact record values and types. */ + @Override + public boolean sameValueAs(ValueObject other) { + return equals(other); + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/annotation/HandleWalletExceptions.java b/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/annotation/HandleWalletExceptions.java new file mode 100644 index 0000000..77a00bd --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/annotation/HandleWalletExceptions.java @@ -0,0 +1,10 @@ +package dev.amg.payguard.infrastructure.aop.exception.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface HandleWalletExceptions {} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/annotation/SecureAuditOnFailure.java b/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/annotation/SecureAuditOnFailure.java new file mode 100644 index 0000000..e4e02f2 --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/annotation/SecureAuditOnFailure.java @@ -0,0 +1,12 @@ +package dev.amg.payguard.infrastructure.aop.exception.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface SecureAuditOnFailure { + String action(); +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/RestControllerExceptionAspect.java b/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/RestControllerExceptionAspect.java new file mode 100644 index 0000000..0c7a0d3 --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/RestControllerExceptionAspect.java @@ -0,0 +1,88 @@ +package dev.amg.payguard.infrastructure.aop.exception.aspect; + +import dev.amg.payguard.domain.exception.DoubleEntryImbalanceException; +import dev.amg.payguard.domain.exception.HoldExpiredException; +import dev.amg.payguard.domain.exception.InsufficientFundException; +import dev.amg.payguard.domain.exception.InvalidHoldStateException; +import dev.amg.payguard.domain.exception.InvalidWalletStateException; +import dev.amg.payguard.domain.exception.LimitExceededException; +import dev.amg.payguard.domain.exception.TransactionAlreadySettledException; +import dev.amg.payguard.domain.exception.WalletFrozenException; +import dev.amg.payguard.infrastructure.aop.exception.model.UnifiedResponse; +import domain.DomainException; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; + +@Aspect +@Component +@Order(1) +public class RestControllerExceptionAspect { + private static final Logger log = LoggerFactory.getLogger(RestControllerExceptionAspect.class); + + @Pointcut( + "@annotation(dev.amg.payguard.infrastructure.aop.exception.annotation.HandleWalletExceptions)" + + " || @within(dev.amg.payguard.infrastructure.aop.exception.annotation.HandleWalletExceptions)" + + " || within(@org.springframework.web.bind.annotation.RestController *)") + public void restControllerLayer() {} + + @Around("restControllerLayer()") + public Object interceptAndHandleExceptions(ProceedingJoinPoint joinPoint) throws Throwable { + try { + return joinPoint.proceed(); + } catch (DomainException ex) { + return mapDomainException(ex, joinPoint); + } catch (IllegalArgumentException ex) { + if (log.isWarnEnabled()) { + log.warn("Invalid argument at [{}]: {}", joinPoint.getSignature(), ex.getMessage()); + } + return build(HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", ex.getMessage()); + } catch (Exception ex) { + if (log.isErrorEnabled()) { + log.error("Unhandled exception at [{}]", joinPoint.getSignature(), ex); + } + return build( + HttpStatus.INTERNAL_SERVER_ERROR, + "INTERNAL_ERROR", + "An unexpected system error occurred."); + } + } + + private ResponseEntity> mapDomainException( + DomainException ex, ProceedingJoinPoint joinPoint) { + if (ex instanceof DoubleEntryImbalanceException) { + if (log.isErrorEnabled()) { + log.error("Ledger imbalance at [{}]: {}", joinPoint.getSignature(), ex.getMessage()); + } + return build( + HttpStatus.INTERNAL_SERVER_ERROR, + "LEDGER_CORRUPTED", + "Transaction could not be balanced."); + } + if (ex instanceof WalletFrozenException) { + return build(HttpStatus.FORBIDDEN, ex.getErrorCode(), ex.getMessage()); + } + if (ex instanceof InsufficientFundException + || ex instanceof LimitExceededException + || ex instanceof HoldExpiredException) { + return build(HttpStatus.UNPROCESSABLE_CONTENT, ex.getErrorCode(), ex.getMessage()); + } + if (ex instanceof TransactionAlreadySettledException + || ex instanceof InvalidHoldStateException + || ex instanceof InvalidWalletStateException) { + return build(HttpStatus.CONFLICT, ex.getErrorCode(), ex.getMessage()); + } + return build(HttpStatus.BAD_REQUEST, ex.getErrorCode(), ex.getMessage()); + } + + private ResponseEntity> build(HttpStatus status, String code, String message) { + return ResponseEntity.status(status).body(UnifiedResponse.fail(code, message)); + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/ServiceExceptionEnricherAspect.java b/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/ServiceExceptionEnricherAspect.java new file mode 100644 index 0000000..7ff0b1d --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/ServiceExceptionEnricherAspect.java @@ -0,0 +1,59 @@ +package dev.amg.payguard.infrastructure.aop.exception.aspect; + +import domain.DomainException; +import java.util.HashMap; +import java.util.Map; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.AfterThrowing; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +@Aspect +@Component +@Order(2) +public class ServiceExceptionEnricherAspect { + + private static final Logger log = LoggerFactory.getLogger(ServiceExceptionEnricherAspect.class); + + @AfterThrowing(pointcut = "execution(* dev.amg.payguard.application..*(..))", throwing = "ex") + public void enrichAndLogServiceException(JoinPoint joinPoint, DomainException ex) { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + String[] paramNames = signature.getParameterNames(); + Object[] args = joinPoint.getArgs(); + + Map context = new HashMap<>(); + if (paramNames != null && args != null) { + int parameterCount = Math.min(paramNames.length, args.length); + for (int i = 0; i < parameterCount; i++) { + String parameterName = paramNames[i]; + String normalizedName = parameterName.toLowerCase(java.util.Locale.ROOT); + if (!normalizedName.contains("pin") + && !normalizedName.contains("password") + && !normalizedName.contains("token") + && !normalizedName.contains("secret")) { + context.put(parameterName, args[i]); + } + } + } + + MDC.put("failed_method", signature.getName()); + MDC.put("exception_type", ex.getClass().getSimpleName()); + try { + if (log.isWarnEnabled()) { + log.warn( + "Service execution failed: method [{}], error: '{}', context: {}", + signature, + ex.getMessage(), + context); + } + } finally { + MDC.remove("failed_method"); + MDC.remove("exception_type"); + } + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/SuspiciousActivityTrackerAspect.java b/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/SuspiciousActivityTrackerAspect.java new file mode 100644 index 0000000..a4afbfc --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/SuspiciousActivityTrackerAspect.java @@ -0,0 +1,40 @@ +package dev.amg.payguard.infrastructure.aop.exception.aspect; + +import dev.amg.payguard.domain.exception.InsufficientFundException; +import dev.amg.payguard.domain.exception.WalletFrozenException; +import dev.amg.payguard.infrastructure.aop.exception.annotation.SecureAuditOnFailure; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.AfterThrowing; +import org.aspectj.lang.annotation.Aspect; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +@Aspect +@Component +public class SuspiciousActivityTrackerAspect { + + private static final Logger securityLog = LoggerFactory.getLogger("SECURITY_AUDIT"); + + @AfterThrowing(pointcut = "@annotation(secureAudit)", throwing = "ex") + public void auditSuspiciousFailure( + JoinPoint joinPoint, SecureAuditOnFailure secureAudit, Exception ex) { + String action = secureAudit.action(); + + if (ex instanceof InsufficientFundException || ex instanceof WalletFrozenException) { + if (securityLog.isErrorEnabled()) { + securityLog.error( + "Fraud alert: suspicious failure during action [{}]. Method: [{}]. Reason: {}", + action, + joinPoint.getSignature(), + ex.getMessage()); + } + + triggerSecurityCountermeasures(); + } + } + + private void triggerSecurityCountermeasures() { + // Hook for a future IP blocklist or SOC notification integration. + } +} diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/model/UnifiedResponse.java b/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/model/UnifiedResponse.java new file mode 100644 index 0000000..e16508c --- /dev/null +++ b/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/model/UnifiedResponse.java @@ -0,0 +1,14 @@ +package dev.amg.payguard.infrastructure.aop.exception.model; + +import java.time.Instant; + +public record UnifiedResponse( + boolean success, String errorCode, String message, T data, Instant timestamp) { + public static UnifiedResponse ok(T data) { + return new UnifiedResponse<>(true, null, "Operation successful", data, Instant.now()); + } + + public static UnifiedResponse fail(String errorCode, String message) { + return new UnifiedResponse<>(false, errorCode, message, null, Instant.now()); + } +} diff --git a/payguard-wallet/src/main/resources/application.yaml b/payguard-wallet/src/main/resources/application.yaml new file mode 100644 index 0000000..f0db46a --- /dev/null +++ b/payguard-wallet/src/main/resources/application.yaml @@ -0,0 +1,30 @@ +spring: + application: + name: wallet-service + datasource: + url: ${SPRING_DATASOURCE_URL} + driver-class-name: org.postgresql.Driver + jooq: + sql-dialect: POSTGRES + +payguard: + vault: + enabled: true + address: ${VAULT_ADDR} + ca-file: ${VAULT_CA_FILE} + role-id-file: ${VAULT_ROLE_ID_FILE} + secret-id-file: ${VAULT_SECRET_ID_FILE} + database-role: ${PAYGUARD_ENVIRONMENT:local}-wallet-runtime + +management: + endpoint: + health: + probes: + enabled: true + group: + readiness: + include: readinessState,db,vault + endpoints: + web: + exposure: + include: health,info diff --git a/payguard-wallet/src/main/resources/banner.txt b/payguard-wallet/src/main/resources/banner.txt new file mode 100644 index 0000000..978a3d7 --- /dev/null +++ b/payguard-wallet/src/main/resources/banner.txt @@ -0,0 +1,7 @@ +____ __ ____ ___ __ __ _______ .___________. +\ \ / \ / / / \ | | | | | ____|| | + \ \/ \/ / / ^ \ | | | | | |__ `---| |----` + \ / / /_\ \ | | | | | __| | | + \ /\ / / _____ \ | `----.| `----.| |____ | | + \__/ \__/ /__/ \__\ |_______||_______||_______| |__| + diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/WalletArchitectureTests.java b/payguard-wallet/src/test/java/dev/amg/payguard/WalletArchitectureTests.java new file mode 100644 index 0000000..90a802b --- /dev/null +++ b/payguard-wallet/src/test/java/dev/amg/payguard/WalletArchitectureTests.java @@ -0,0 +1,13 @@ +package dev.amg.payguard; + +import org.junit.jupiter.api.Test; +import org.springframework.modulith.core.ApplicationModules; + +class WalletArchitectureTests { + private final ApplicationModules modules = ApplicationModules.of(WalletApp.class); + + @Test + void verifiesApplicationModuleBoundaries() { + modules.verify(); + } +} diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/DomainFixtures.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/DomainFixtures.java new file mode 100644 index 0000000..10a4707 --- /dev/null +++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/DomainFixtures.java @@ -0,0 +1,97 @@ +package dev.amg.payguard.domain.model; + +import dev.amg.payguard.domain.enums.*; +import dev.amg.payguard.domain.value_object.*; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Currency; +import java.util.List; +import java.util.UUID; + +/** Deterministic, test-only domain fixtures; no system clock, Spring or database. */ +final class DomainFixtures { + static final Currency CHF = Currency.getInstance("CHF"); + static final Currency USD = Currency.getInstance("USD"); + static final Instant CREATED = Instant.parse("2026-01-01T00:00:00Z"); + static final Instant NOW = CREATED.plusSeconds(10); + static final Instant DEADLINE = CREATED.plusSeconds(60); + static final WalletId WALLET_ID = new WalletId(uuid(1)); + static final AccountId ACCOUNT_ID = new AccountId(uuid(2)); + static final AccountId DESTINATION_ID = new AccountId(uuid(3)); + static final AccountId FEE_ACCOUNT_ID = new AccountId(uuid(4)); + static final UserId USER_ID = new UserId(uuid(5)); + static final HoldId HOLD_ID = new HoldId(uuid(6)); + static final TransactionId TRANSACTION_ID = new TransactionId(uuid(7)); + + private DomainFixtures() {} + + /** Creates reproducible, distinct identifiers without relying on random collisions. */ + static UUID uuid(long value) { return new UUID(0L, value); } + /** Creates exact CHF money. */ + static Money money(String value) { return Money.of(value, CHF); } + /** Restores a funded active wallet with a known loaded version and posting high-water mark. */ + static Wallet wallet() { return wallet(WalletStatus.ACTIVE, "100.00", "0.00"); } + /** Restores a requested wallet state for focused invariant tests. */ + static Wallet wallet(WalletStatus status, String book, String reserved) { + return Wallet.restore(WALLET_ID, ACCOUNT_ID, USER_ID, CHF, status, + money(book), money(reserved), 7L, 9L, CREATED, CREATED); + } + /** Creates a positive full-amount reservation with a deterministic deadline. */ + static Hold hold() { + return Hold.createNew(HOLD_ID, WALLET_ID, money("30.00"), "Purchase", "order-1", DEADLINE, CREATED); + } + /** Creates an initiated transfer with an additional source-paid fee. */ + static Transaction transaction(String principal, String fee) { + return Transaction.initiate(TRANSACTION_ID, IdempotencyKey.of("request-1"), + TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, + money(principal), money(fee), CREATED); + } + /** Creates one independently identified posting. */ + static LedgerEntry entry(long entryId, TransactionId transactionId, AccountId accountId, + Money amount, LedgerEntryType type, long sequence, Instant timestamp) { + return new LedgerEntry(new LedgerEntryId(uuid(entryId)), transactionId, accountId, + amount, type, sequence, timestamp); + } + /** Creates a posting for the standard wallet and transaction. */ + static LedgerEntry walletEntry(LedgerEntryType type, String amount, long sequence) { + return entry(100 + sequence, TRANSACTION_ID, ACCOUNT_ID, money(amount), type, sequence, NOW); + } + /** Creates a balanced batch obeying the principal/fee allocation policy. */ + static List postings(Transaction transaction) { + List entries = new ArrayList<>(); + Instant timestamp = transaction.getCreatedAt().plusSeconds(1); + entries.add(entry(100, transaction.getId(), transaction.getSourceAccountId(), + transaction.getTotalAmount().add(transaction.getFeeAmount()), LedgerEntryType.DEBIT, 10, timestamp)); + entries.add(entry(101, transaction.getId(), transaction.getDestinationAccountId(), + transaction.getTotalAmount(), LedgerEntryType.CREDIT, 10, timestamp)); + if (!transaction.getFeeAmount().isZero()) { + entries.add(entry(102, transaction.getId(), FEE_ACCOUNT_ID, transaction.getFeeAmount(), + LedgerEntryType.CREDIT, 10, timestamp)); + } + return entries; + } + /** Creates and settles a zero-fee original transaction for reversal tests. */ + static Transaction settledTransaction() { + Transaction transaction = transaction("25.00", "0.00"); + transaction.markSettled(postings(transaction), NOW); + return transaction; + } + /** Creates a distinct, fully settled compensating refund. */ + static Transaction refund(Transaction original) { + Transaction refund = Transaction.initiate(new TransactionId(uuid(20)), + IdempotencyKey.of("refund-1"), TransactionType.REFUND, + original.getDestinationAccountId(), original.getSourceAccountId(), + original.getTotalAmount(), money("0.00"), NOW.plusSeconds(1)); + refund.markSettled(postings(refund), NOW.plusSeconds(3)); + return refund; + } + /** Captures all mutable wallet state relevant to rejection atomicity. */ + static WalletSnapshot snapshot(Wallet wallet) { + return new WalletSnapshot(wallet.getStatus(), wallet.getBookBalance(), + wallet.getReservedBalance(), wallet.getUpdatedAt(), wallet.getVersion(), + wallet.getLastAppliedSequenceNumber()); + } + /** Immutable test snapshot for exact before/after comparison. */ + record WalletSnapshot(WalletStatus status, Money book, Money reserved, Instant updated, + long version, long sequence) {} +} diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/HoldTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/HoldTest.java new file mode 100644 index 0000000..4d492a8 --- /dev/null +++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/HoldTest.java @@ -0,0 +1,283 @@ +package dev.amg.payguard.domain.model; + +import static dev.amg.payguard.domain.model.DomainFixtures.*; +import static org.junit.jupiter.api.Assertions.*; + +import dev.amg.payguard.domain.enums.*; +import dev.amg.payguard.domain.event.*; +import dev.amg.payguard.domain.exception.*; +import dev.amg.payguard.domain.value_object.*; +import domain.InvalidDomainArgumentException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Tests expiry boundaries, terminal states, event contents and restore invariants. */ +class HoldTest { + + /** Creates a pending hold without terminal metadata or emitted transition events. */ + @Test + void shouldCreatePendingPositiveHold() { + Hold hold = hold(); + assertEquals(HOLD_ID, hold.getId()); + assertEquals(WALLET_ID, hold.getWalletId()); + assertEquals(money("30"), hold.getAmount()); + assertEquals("Purchase", hold.getReason()); + assertEquals("order-1", hold.getReferenceId()); + assertEquals(CREATED, hold.getCreatedAt()); + assertEquals(DEADLINE, hold.getExpiresAt()); + assertEquals(HoldStatus.PENDING, hold.getStatus()); + assertTrue(hold.getResolvedAt().isEmpty()); + assertTrue(hold.pullDomainEvents().isEmpty()); + } + + /** Regression: capture validity excludes the deadline itself. */ + @Test + void shouldTreatExactDeadlineAsExpired() { + Hold hold = hold(); + assertFalse(hold.isExpired(DEADLINE.minusNanos(1))); + assertTrue(hold.isExpired(DEADLINE)); + assertTrue(hold.isExpired(DEADLINE.plusNanos(1))); + } + + /** Captures at the last valid instant and emits the matching event. */ + @Test + void shouldCaptureImmediatelyBeforeExpiry() { + Hold hold = hold(); + Instant captureTime = DEADLINE.minusNanos(1); + hold.capture(captureTime); + assertEquals(HoldStatus.CAPTURED, hold.getStatus()); + assertEquals(captureTime, hold.getResolvedAt().orElseThrow()); + var event = assertInstanceOf(FundsHoldCapturedEvent.class, hold.pullDomainEvents().getFirst()); + assertEquals(HOLD_ID, event.holdId()); + assertEquals(WALLET_ID, event.walletId()); + assertEquals(money("30"), event.amount()); + assertEquals(captureTime, event.occurredOn()); + } + + /** Regression: rejection must neither expire the hold nor emit an expiry event. */ + @Test + void shouldRejectExpiredCaptureWithoutMutation() { + Hold hold = hold(); + assertThrows(HoldExpiredException.class, () -> hold.capture(DEADLINE)); + assertEquals(HoldStatus.PENDING, hold.getStatus()); + assertTrue(hold.getResolvedAt().isEmpty()); + assertTrue(hold.pullDomainEvents().isEmpty()); + } + + /** An explicit expiry succeeds at the exact boundary. */ + @Test + void shouldExpireAtExactDeadline() { + Hold hold = hold(); + hold.expire(DEADLINE); + assertEquals(HoldStatus.EXPIRED, hold.getStatus()); + assertEquals(DEADLINE, hold.getResolvedAt().orElseThrow()); + assertInstanceOf(FundsHoldExpiredEvent.class, hold.pullDomainEvents().getFirst()); + } + + /** Premature expiry cannot change state or events. */ + @Test + void shouldRejectEarlyExpiryWithoutMutation() { + Hold hold = hold(); + assertThrows(InvalidHoldStateException.class, () -> hold.expire(DEADLINE.minusNanos(1))); + assertEquals(HoldStatus.PENDING, hold.getStatus()); + assertTrue(hold.getResolvedAt().isEmpty()); + assertTrue(hold.pullDomainEvents().isEmpty()); + } + + /** The explicit cancellation policy allows releasing an overdue pending hold. */ + @Test + void shouldReleaseOverduePendingHold() { + Hold hold = hold(); + hold.release(DEADLINE.plusSeconds(1)); + assertEquals(HoldStatus.RELEASED, hold.getStatus()); + assertInstanceOf(FundsHoldReleasedEvent.class, hold.pullDomainEvents().getFirst()); + } + + /** Zero reservations are not valid domain operations. */ + @Test + void shouldRejectZeroHold() { + assertThrows(InvalidHoldStateException.class, () -> Hold.createNew(HOLD_ID, WALLET_ID, + money("0"), "reason", "reference", DEADLINE, CREATED)); + } + + /** Negative reservation amounts are rejected. */ + @Test + void shouldRejectNegativeHold() { + assertThrows(NegativeAmountNotAllowedException.class, () -> Hold.createNew(HOLD_ID, WALLET_ID, + money("-1"), "reason", "reference", DEADLINE, CREATED)); + } + + /** A newly created hold must have a nonempty validity interval. */ + @Test + void shouldRejectDeadlineEqualToCreation() { + assertThrows(InvalidHoldStateException.class, () -> Hold.createNew(HOLD_ID, WALLET_ID, + money("1"), "reason", "reference", CREATED, CREATED)); + } + + /** Rejects a reversed validity interval. */ + @Test + void shouldRejectDeadlineBeforeCreation() { + assertThrows(InvalidHoldStateException.class, () -> Hold.createNew(HOLD_ID, WALLET_ID, + money("1"), "reason", "reference", CREATED.minusNanos(1), CREATED)); + } + + /** Restores valid terminal state without replaying a captured event. */ + @Test + void shouldRestoreCapturedWithoutEvents() { + Hold hold = Hold.restore(HOLD_ID, WALLET_ID, money("30"), "reason", "reference", + DEADLINE, CREATED, HoldStatus.CAPTURED, NOW); + assertEquals(HoldStatus.CAPTURED, hold.getStatus()); + assertTrue(hold.pullDomainEvents().isEmpty()); + } + + /** Null time rejection leaves the aggregate unchanged. */ + @Test + void shouldRejectNullTimeForCapture() { + Hold hold = hold(); + assertThrows(InvalidDomainArgumentException.class, () -> hold.capture(null)); + assertEquals(HoldStatus.PENDING, hold.getStatus()); + assertTrue(hold.getResolvedAt().isEmpty()); + assertTrue(hold.pullDomainEvents().isEmpty()); + } + + /** Rejects transitions predating the hold itself. */ + @Test + void shouldRejectPreCreationTimeForCapture() { + Hold hold = hold(); + assertThrows(InvalidHoldStateException.class, () -> hold.capture(CREATED.minusNanos(1))); + assertEquals(HoldStatus.PENDING, hold.getStatus()); + } + + /** Null time rejection leaves the aggregate unchanged. */ + @Test + void shouldRejectNullTimeForRelease() { + Hold hold = hold(); + assertThrows(InvalidDomainArgumentException.class, () -> hold.release(null)); + assertEquals(HoldStatus.PENDING, hold.getStatus()); + assertTrue(hold.getResolvedAt().isEmpty()); + assertTrue(hold.pullDomainEvents().isEmpty()); + } + + /** Rejects transitions predating the hold itself. */ + @Test + void shouldRejectPreCreationTimeForRelease() { + Hold hold = hold(); + assertThrows(InvalidHoldStateException.class, () -> hold.release(CREATED.minusNanos(1))); + assertEquals(HoldStatus.PENDING, hold.getStatus()); + } + + /** Null time rejection leaves the aggregate unchanged. */ + @Test + void shouldRejectNullTimeForExpire() { + Hold hold = hold(); + assertThrows(InvalidDomainArgumentException.class, () -> hold.expire(null)); + assertEquals(HoldStatus.PENDING, hold.getStatus()); + assertTrue(hold.getResolvedAt().isEmpty()); + assertTrue(hold.pullDomainEvents().isEmpty()); + } + + /** Rejects transitions predating the hold itself. */ + @Test + void shouldRejectPreCreationTimeForExpire() { + Hold hold = hold(); + assertThrows(InvalidHoldStateException.class, () -> hold.expire(CREATED.minusNanos(1))); + assertEquals(HoldStatus.PENDING, hold.getStatus()); + } + + /** Terminal hold states cannot transition a second time. */ + @Test + void shouldRejectTransitionsAfterCaptured() { + Hold hold = hold(); + hold.capture(NOW); + hold.clearDomainEvents(); + assertThrows(InvalidHoldStateException.class, () -> hold.capture(DEADLINE)); + assertThrows(InvalidHoldStateException.class, () -> hold.release(DEADLINE)); + assertThrows(InvalidHoldStateException.class, () -> hold.expire(DEADLINE)); + assertEquals(HoldStatus.CAPTURED, hold.getStatus()); + assertTrue(hold.pullDomainEvents().isEmpty()); + } + + /** Terminal hold states cannot transition a second time. */ + @Test + void shouldRejectTransitionsAfterReleased() { + Hold hold = hold(); + hold.release(NOW); + hold.clearDomainEvents(); + assertThrows(InvalidHoldStateException.class, () -> hold.capture(DEADLINE)); + assertThrows(InvalidHoldStateException.class, () -> hold.release(DEADLINE)); + assertThrows(InvalidHoldStateException.class, () -> hold.expire(DEADLINE)); + assertEquals(HoldStatus.RELEASED, hold.getStatus()); + assertTrue(hold.pullDomainEvents().isEmpty()); + } + + /** Terminal hold states cannot transition a second time. */ + @Test + void shouldRejectTransitionsAfterExpired() { + Hold hold = hold(); + hold.expire(DEADLINE); + hold.clearDomainEvents(); + assertThrows(InvalidHoldStateException.class, () -> hold.capture(DEADLINE)); + assertThrows(InvalidHoldStateException.class, () -> hold.release(DEADLINE)); + assertThrows(InvalidHoldStateException.class, () -> hold.expire(DEADLINE)); + assertEquals(HoldStatus.EXPIRED, hold.getStatus()); + assertTrue(hold.pullDomainEvents().isEmpty()); + } + + /** Restoration enforces the same lifecycle invariants as normal commands. */ + @Test + void shouldRejectPendingWithResolution() { + assertThrows(InvalidHoldStateException.class, () -> Hold.restore(HOLD_ID, WALLET_ID, + money("30"), "reason", "reference", DEADLINE, CREATED, HoldStatus.PENDING, NOW)); + } + + /** Restoration enforces the same lifecycle invariants as normal commands. */ + @Test + void shouldRejectTerminalWithoutResolution() { + assertThrows(InvalidHoldStateException.class, () -> Hold.restore(HOLD_ID, WALLET_ID, + money("30"), "reason", "reference", DEADLINE, CREATED, HoldStatus.CAPTURED, null)); + } + + /** Restoration enforces the same lifecycle invariants as normal commands. */ + @Test + void shouldRejectLateCapturedSnapshot() { + assertThrows(InvalidHoldStateException.class, () -> Hold.restore(HOLD_ID, WALLET_ID, + money("30"), "reason", "reference", DEADLINE, CREATED, HoldStatus.CAPTURED, DEADLINE)); + } + + /** Restoration enforces the same lifecycle invariants as normal commands. */ + @Test + void shouldRejectPrematureExpiredSnapshot() { + assertThrows(InvalidHoldStateException.class, () -> Hold.restore(HOLD_ID, WALLET_ID, + money("30"), "reason", "reference", DEADLINE, CREATED, HoldStatus.EXPIRED, NOW)); + } + + /** Restoration enforces the same lifecycle invariants as normal commands. */ + @Test + void shouldRejectPreCreationResolution() { + assertThrows(InvalidHoldStateException.class, () -> Hold.restore(HOLD_ID, WALLET_ID, + money("30"), "reason", "reference", DEADLINE, CREATED, HoldStatus.RELEASED, CREATED.minusNanos(1))); + } + + /** Rejects missing descriptive metadata before construction. */ + @Test + void shouldRejectBlankReason() { + assertThrows(InvalidDomainArgumentException.class, () -> Hold.createNew(HOLD_ID, WALLET_ID, + money("1"), " ", "ref", DEADLINE, CREATED)); + } + + /** Rejects missing descriptive metadata before construction. */ + @Test + void shouldRejectBlankReference() { + assertThrows(InvalidDomainArgumentException.class, () -> Hold.createNew(HOLD_ID, WALLET_ID, + money("1"), "reason", " ", DEADLINE, CREATED)); + } + + /** Rejects missing descriptive metadata before construction. */ + @Test + void shouldRejectNullReason() { + assertThrows(InvalidDomainArgumentException.class, () -> Hold.createNew(HOLD_ID, WALLET_ID, + money("1"), null, "ref", DEADLINE, CREATED)); + } +} diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/LedgerEntryTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/LedgerEntryTest.java new file mode 100644 index 0000000..c0a5522 --- /dev/null +++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/LedgerEntryTest.java @@ -0,0 +1,103 @@ +package dev.amg.payguard.domain.model; + +import static dev.amg.payguard.domain.model.DomainFixtures.*; +import static org.junit.jupiter.api.Assertions.*; + +import dev.amg.payguard.domain.enums.*; +import dev.amg.payguard.domain.event.*; +import dev.amg.payguard.domain.exception.*; +import dev.amg.payguard.domain.value_object.*; +import domain.InvalidDomainArgumentException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Tests posting-local invariants and explicit entity equality. */ +class LedgerEntryTest { + + /** Preserves all entry-local posting data without transformation. */ + @Test + void shouldPreserveAllPostingComponents() { + LedgerEntry entry = walletEntry(LedgerEntryType.DEBIT, "10.00", 10); + assertAll(() -> assertEquals(TRANSACTION_ID, entry.transactionId()), + () -> assertEquals(ACCOUNT_ID, entry.accountId()), + () -> assertEquals(money("10.00"), entry.amount()), + () -> assertEquals(LedgerEntryType.DEBIT, entry.entryType()), + () -> assertEquals(10L, entry.sequenceNumber()), + () -> assertEquals(NOW, entry.timestamp())); + } + + /** Rejects a null Id before a valid entry can exist. */ + @Test + void shouldRejectNullId() { + assertThrows(InvalidDomainArgumentException.class, () -> new LedgerEntry(null, TRANSACTION_ID, ACCOUNT_ID, money("1"), LedgerEntryType.DEBIT, 0, NOW)); + } + + /** Rejects a null TransactionId before a valid entry can exist. */ + @Test + void shouldRejectNullTransactionId() { + assertThrows(InvalidDomainArgumentException.class, () -> new LedgerEntry(new LedgerEntryId(uuid(100)), null, ACCOUNT_ID, money("1"), LedgerEntryType.DEBIT, 0, NOW)); + } + + /** Rejects a null AccountId before a valid entry can exist. */ + @Test + void shouldRejectNullAccountId() { + assertThrows(InvalidDomainArgumentException.class, () -> new LedgerEntry(new LedgerEntryId(uuid(100)), TRANSACTION_ID, null, money("1"), LedgerEntryType.DEBIT, 0, NOW)); + } + + /** Rejects a null Amount before a valid entry can exist. */ + @Test + void shouldRejectNullAmount() { + assertThrows(InvalidDomainArgumentException.class, () -> new LedgerEntry(new LedgerEntryId(uuid(100)), TRANSACTION_ID, ACCOUNT_ID, null, LedgerEntryType.DEBIT, 0, NOW)); + } + + /** Rejects a null EntryType before a valid entry can exist. */ + @Test + void shouldRejectNullEntryType() { + assertThrows(InvalidDomainArgumentException.class, () -> new LedgerEntry(new LedgerEntryId(uuid(100)), TRANSACTION_ID, ACCOUNT_ID, money("1"), null, 0, NOW)); + } + + /** Rejects a null Timestamp before a valid entry can exist. */ + @Test + void shouldRejectNullTimestamp() { + assertThrows(InvalidDomainArgumentException.class, () -> new LedgerEntry(new LedgerEntryId(uuid(100)), TRANSACTION_ID, ACCOUNT_ID, money("1"), LedgerEntryType.DEBIT, 0, null)); + } + + /** Rejects zero-magnitude entries rather than posting meaningless ledger lines. */ + @Test + void shouldRejectZeroPosting() { + assertThrows(InvalidWalletStateException.class, () -> walletEntry(LedgerEntryType.DEBIT, "0", 10)); + } + + /** Rejects signed amounts because direction belongs to the entry type. */ + @Test + void shouldRejectNegativePosting() { + assertThrows(NegativeAmountNotAllowedException.class, () -> walletEntry(LedgerEntryType.DEBIT, "-1", 10)); + } + + /** Rejects invalid account sequence values. */ + @Test + void shouldRejectNegativeSequence() { + assertThrows(InvalidWalletStateException.class, () -> walletEntry(LedgerEntryType.CREDIT, "1", -1)); + } + + /** Accepts both ends of the non-negative sequence domain. */ + @Test + void shouldAcceptZeroAndMaximumSequence() { + assertEquals(0L, walletEntry(LedgerEntryType.DEBIT, "1", 0).sequenceNumber()); + assertEquals(Long.MAX_VALUE, walletEntry(LedgerEntryType.DEBIT, "1", Long.MAX_VALUE).sequenceNumber()); + } + + /** Preserves the existing entity-by-id equality contract, not content equivalence. */ + @Test + void shouldCompareByIdentity() { + LedgerEntry first = walletEntry(LedgerEntryType.DEBIT, "1", 10); + LedgerEntry second = new LedgerEntry(first.id(), TRANSACTION_ID, ACCOUNT_ID, + money("2"), LedgerEntryType.CREDIT, 11, NOW); + assertEquals(first, second); + assertEquals(first.hashCode(), second.hashCode()); + assertNotEquals(first, walletEntry(LedgerEntryType.DEBIT, "1", 11)); + assertNotEquals(first, null); + } +} diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/TransactionTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/TransactionTest.java new file mode 100644 index 0000000..1e49f15 --- /dev/null +++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/TransactionTest.java @@ -0,0 +1,486 @@ +package dev.amg.payguard.domain.model; + +import static dev.amg.payguard.domain.model.DomainFixtures.*; +import static org.junit.jupiter.api.Assertions.*; + +import dev.amg.payguard.domain.enums.*; +import dev.amg.payguard.domain.event.*; +import dev.amg.payguard.domain.exception.*; +import dev.amg.payguard.domain.value_object.*; +import domain.InvalidDomainArgumentException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Tests exact posting economics, terminal states, reversals and rejection atomicity. */ +class TransactionTest { + + /** Creates an open transfer with no settlement or failure metadata. */ + @Test + void shouldInitiateConsistentTransaction() { + Transaction transaction = transaction("100", "2"); + assertEquals(TransactionStatus.INITIATED, transaction.getStatus()); + assertEquals(TRANSACTION_ID, transaction.getId()); + assertEquals(ACCOUNT_ID, transaction.getSourceAccountId()); + assertEquals(DESTINATION_ID, transaction.getDestinationAccountId()); + assertEquals(money("100"), transaction.getTotalAmount()); + assertEquals(money("2"), transaction.getFeeAmount()); + assertTrue(transaction.getFailureReason().isEmpty()); + assertTrue(transaction.getSettledAt().isEmpty()); + assertTrue(transaction.getReversalTransactionId().isEmpty()); + assertTrue(transaction.getReversedAt().isEmpty()); + } + + /** Settles a balanced principal-only posting. */ + @Test + void shouldSettleBalancedPosting() { + Transaction transaction = transaction("100", "0"); + transaction.markSettled(postings(transaction), NOW); + assertEquals(TransactionStatus.SUCCESSFUL, transaction.getStatus()); + assertEquals(NOW, transaction.getSettledAt().orElseThrow()); + } + + /** Enforces debit principal-plus-fee and separate principal/fee credits. */ + @Test + void shouldSettlePrincipalAndAdditionalFee() { + Transaction transaction = transaction("100", "2"); + transaction.markSettled(postings(transaction), NOW); + assertEquals(TransactionStatus.SUCCESSFUL, transaction.getStatus()); + } + + /** Pending settlement is a permitted open-state transition. */ + @Test + void shouldSettleFromPending() { + Transaction transaction = transaction("100", "0"); + transaction.markPendingSettlement(); + assertEquals(TransactionStatus.PENDING_SETTLEMENT, transaction.getStatus()); + transaction.markSettled(postings(transaction), NOW); + assertEquals(TransactionStatus.SUCCESSFUL, transaction.getStatus()); + } + + /** Does not silently repeat lifecycle transitions. */ + @Test + void shouldRejectRepeatedPendingTransition() { + Transaction transaction = transaction("100", "0"); + transaction.markPendingSettlement(); + assertThrows(InvalidWalletStateException.class, transaction::markPendingSettlement); + } + + /** Rejects empty money movements. */ + @Test + void shouldRejectZeroPrincipal() { + assertThrows(InvalidWalletStateException.class, () -> transaction("0", "0")); + } + + /** Rejects negative transfer magnitudes. */ + @Test + void shouldRejectNegativePrincipal() { + assertThrows(NegativeAmountNotAllowedException.class, () -> transaction("-1", "0")); + } + + /** Fees cannot be negative adjustments. */ + @Test + void shouldRejectNegativeFee() { + assertThrows(NegativeAmountNotAllowedException.class, () -> transaction("1", "-1")); + } + + /** Even a zero fee must carry the principal currency. */ + @Test + void shouldRejectMixedFeeCurrency() { + assertThrows(CurrencyMismatchException.class, () -> Transaction.initiate(TRANSACTION_ID, + IdempotencyKey.of("key"), TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, + money("1"), Money.zero(USD), CREATED)); + } + + /** Source and destination are distinct under the selected transfer contract. */ + @Test + void shouldRejectSelfTransfer() { + assertThrows(InvalidWalletStateException.class, () -> Transaction.initiate(TRANSACTION_ID, + IdempotencyKey.of("key"), TransactionType.P2P_TRANSFER, ACCOUNT_ID, ACCOUNT_ID, + money("1"), money("0"), CREATED)); + } + + /** Regression: a null time cannot leave status marked successful. */ + @Test + void shouldRejectNullSettlementTimeWithoutMutation() { + Transaction transaction = transaction("100", "0"); + assertThrows(InvalidDomainArgumentException.class, () -> transaction.markSettled(postings(transaction), null)); + assertEquals(TransactionStatus.INITIATED, transaction.getStatus()); + assertTrue(transaction.getSettledAt().isEmpty()); + } + + /** Regression: validation must precede FAILED status assignment. */ + @Test + void shouldRejectInvalidFailureReasonWithoutMutation() { + Transaction transaction = transaction("100", "0"); + assertThrows(InvalidDomainArgumentException.class, () -> transaction.markFailed(null)); + assertThrows(InvalidDomainArgumentException.class, () -> transaction.markFailed(" ")); + assertEquals(TransactionStatus.INITIATED, transaction.getStatus()); + assertTrue(transaction.getFailureReason().isEmpty()); + } + + /** A failed transfer cannot settle, fail again or become pending. */ + @Test + void shouldMakeFailedStateTerminal() { + Transaction transaction = transaction("100", "0"); + transaction.markFailed("Rejected by risk policy"); + assertEquals("Rejected by risk policy", transaction.getFailureReason().orElseThrow()); + assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(postings(transaction), NOW)); + assertThrows(InvalidWalletStateException.class, () -> transaction.markFailed("replace")); + assertThrows(InvalidWalletStateException.class, transaction::markPendingSettlement); + } + + /** A successful transfer cannot settle or fail again. */ + @Test + void shouldPreventSecondSettlement() { + Transaction transaction = settledTransaction(); + assertThrows(TransactionAlreadySettledException.class, () -> transaction.markSettled(postings(transaction), NOW)); + assertThrows(TransactionAlreadySettledException.class, () -> transaction.markFailed("late failure")); + } + + /** Rejects a missing posting batch before settlement. */ + @Test + void shouldRejectNullPostingBatch() { + Transaction transaction = transaction("100", "0"); + assertThrows(InvalidDomainArgumentException.class, () -> transaction.markSettled(null, NOW)); + assertEquals(TransactionStatus.INITIATED, transaction.getStatus()); + } + + /** Rejects null entries with the established validation contract. */ + @Test + void shouldRejectNullEntry() { + Transaction transaction = transaction("100", "0"); + List entries = new ArrayList<>(postings(transaction)); + entries.add(null); + assertThrows(InvalidDomainArgumentException.class, () -> transaction.markSettled(entries, NOW)); + } + + /** A single entry cannot be a balanced financial posting. */ + @Test + void shouldRejectEmptyOrSingleEntryBatch() { + Transaction transaction = transaction("100", "0"); + assertThrows(DoubleEntryImbalanceException.class, () -> transaction.markSettled(List.of(), NOW)); + assertThrows(DoubleEntryImbalanceException.class, () -> transaction.markSettled(List.of(postings(transaction).getFirst()), NOW)); + } + + /** Unbalanced totals must not mark the transaction successful. */ + @Test + void shouldRejectUnbalancedBatchWithoutMutation() { + Transaction transaction = transaction("100", "0"); + List entries = List.of(postings(transaction).getFirst(), + entry(101, TRANSACTION_ID, DESTINATION_ID, money("99.99"), LedgerEntryType.CREDIT, 10, NOW)); + assertThrows(DoubleEntryImbalanceException.class, () -> transaction.markSettled(entries, NOW)); + assertEquals(TransactionStatus.INITIATED, transaction.getStatus()); + assertTrue(transaction.getSettledAt().isEmpty()); + } + + /** Balance alone is insufficient when posting amounts differ from the command. */ + @Test + void shouldRejectBalancedButIncorrectPrincipal() { + Transaction transaction = transaction("100", "0"); + List entries = List.of( + entry(100, TRANSACTION_ID, ACCOUNT_ID, money("99"), LedgerEntryType.DEBIT, 10, NOW), + entry(101, TRANSACTION_ID, DESTINATION_ID, money("99"), LedgerEntryType.CREDIT, 10, NOW)); + assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(entries, NOW)); + } + + /** A balanced batch must not credit the fee to the principal destination. */ + @Test + void shouldRejectWrongFeeAllocation() { + Transaction transaction = transaction("100", "2"); + List entries = List.of(postings(transaction).getFirst(), + entry(101, TRANSACTION_ID, DESTINATION_ID, money("102"), LedgerEntryType.CREDIT, 10, NOW)); + assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(entries, NOW)); + } + + /** Supports an explicitly split fee across distinct fee accounts. */ + @Test + void shouldAcceptSplitFeeCredits() { + Transaction transaction = transaction("100", "2"); + List entries = new ArrayList<>(postings(transaction).subList(0, 2)); + entries.add(entry(102, TRANSACTION_ID, FEE_ACCOUNT_ID, money("1"), LedgerEntryType.CREDIT, 10, NOW)); + entries.add(entry(103, TRANSACTION_ID, new AccountId(uuid(40)), money("1"), LedgerEntryType.CREDIT, 10, NOW)); + transaction.markSettled(entries, NOW); + assertEquals(TransactionStatus.SUCCESSFUL, transaction.getStatus()); + } + + /** Rejects duplicate identities even when entry content differs. */ + @Test + void shouldRejectDuplicateEntryIdentity() { + Transaction transaction = transaction("100", "0"); + LedgerEntry debit = postings(transaction).getFirst(); + LedgerEntry credit = new LedgerEntry(debit.id(), TRANSACTION_ID, DESTINATION_ID, + money("100"), LedgerEntryType.CREDIT, 10, NOW); + assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(List.of(debit, credit), NOW)); + } + + /** Two distinct entries cannot claim the same sequence in one account. */ + @Test + void shouldRejectDuplicateAccountSequence() { + Transaction transaction = transaction("100", "0"); + List entries = List.of( + entry(100, TRANSACTION_ID, ACCOUNT_ID, money("40"), LedgerEntryType.DEBIT, 10, NOW), + entry(102, TRANSACTION_ID, ACCOUNT_ID, money("60"), LedgerEntryType.DEBIT, 10, NOW), + postings(transaction).get(1)); + assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(entries, NOW)); + } + + /** Every line must belong to this transaction. */ + @Test + void shouldRejectForeignTransactionEntry() { + Transaction transaction = transaction("100", "0"); + List entries = List.of(postings(transaction).getFirst(), + entry(101, new TransactionId(uuid(99)), DESTINATION_ID, money("100"), LedgerEntryType.CREDIT, 10, NOW)); + assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(entries, NOW)); + } + + /** Rejects mixed-currency ledger lines instead of summing them numerically. */ + @Test + void shouldRejectPostingCurrencyMismatch() { + Transaction transaction = transaction("100", "0"); + List entries = List.of(postings(transaction).getFirst(), + entry(101, TRANSACTION_ID, DESTINATION_ID, Money.of("100", USD), LedgerEntryType.CREDIT, 10, NOW)); + assertThrows(CurrencyMismatchException.class, () -> transaction.markSettled(entries, NOW)); + } + + /** A balanced posting cannot charge an unrequested source account. */ + @Test + void shouldRejectDebitFromDifferentAccount() { + Transaction transaction = transaction("100", "0"); + List entries = List.of( + entry(100, TRANSACTION_ID, FEE_ACCOUNT_ID, money("100"), LedgerEntryType.DEBIT, 10, NOW), + postings(transaction).get(1)); + assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(entries, NOW)); + } + + /** Does not allow a debit/credit loop to masquerade as a transfer. */ + @Test + void shouldRejectCreditBackToSource() { + Transaction transaction = transaction("100", "0"); + List entries = List.of(postings(transaction).getFirst(), + entry(101, TRANSACTION_ID, ACCOUNT_ID, money("100"), LedgerEntryType.CREDIT, 11, NOW)); + assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(entries, NOW)); + } + + /** A preflight validation is not a settlement operation. */ + @Test + void shouldValidatePostingWithoutMutatingState() { + Transaction transaction = transaction("100", "0"); + transaction.validatePosting(postings(transaction), NOW); + assertEquals(TransactionStatus.INITIATED, transaction.getStatus()); + assertTrue(transaction.getSettledAt().isEmpty()); + } + + /** Reversal retains original settlement and records the compensating identity. */ + @Test + void shouldLinkSuccessfulCompensatingRefund() { + Transaction original = settledTransaction(); + Transaction refund = refund(original); + original.markReversed(refund); + assertEquals(TransactionStatus.REVERSED, original.getStatus()); + assertEquals(NOW, original.getSettledAt().orElseThrow()); + assertEquals(refund.getId(), original.getReversalTransactionId().orElseThrow()); + assertEquals(refund.getSettledAt(), original.getReversedAt()); + } + + /** Regression: reversed transactions must not be settled or failed again. */ + @Test + void shouldKeepReversedStateTerminal() { + Transaction original = settledTransaction(); + Transaction refund = refund(original); + original.markReversed(refund); + assertThrows(InvalidWalletStateException.class, () -> original.markSettled(postings(original), NOW)); + assertThrows(InvalidWalletStateException.class, () -> original.markFailed("late")); + assertThrows(InvalidWalletStateException.class, () -> original.markReversed(refund)); + assertThrows(InvalidWalletStateException.class, original::markPendingSettlement); + assertEquals(TransactionStatus.REVERSED, original.getStatus()); + } + + /** A transaction cannot be its own compensating financial movement. */ + @Test + void shouldRejectSelfReversal() { + Transaction transaction = settledTransaction(); + assertThrows(InvalidWalletStateException.class, () -> transaction.markReversed(transaction)); + assertEquals(TransactionStatus.SUCCESSFUL, transaction.getStatus()); + } + + /** Fails closed rather than silently discarding original fee economics. */ + @Test + void shouldRejectFeeBearingReversalUntilPolicyExists() { + Transaction transaction = transaction("25", "1"); + transaction.markSettled(postings(transaction), NOW); + Transaction refund = refund(transaction); + assertThrows(InvalidWalletStateException.class, () -> transaction.markReversed(refund)); + assertEquals(TransactionStatus.SUCCESSFUL, transaction.getStatus()); + } + + /** An initiated refund cannot establish reversal of settled money. */ + @Test + void shouldRejectUnsettledRefund() { + Transaction original = settledTransaction(); + Transaction refund = Transaction.initiate(new TransactionId(uuid(20)), IdempotencyKey.of("refund"), + TransactionType.REFUND, DESTINATION_ID, ACCOUNT_ID, money("25"), money("0"), NOW); + assertThrows(InvalidWalletStateException.class, () -> original.markReversed(refund)); + } + + /** A partial refund cannot be marked as a full reversal. */ + @Test + void shouldRejectWrongRefundAmount() { + Transaction original = settledTransaction(); + Transaction refund = Transaction.initiate(new TransactionId(uuid(20)), IdempotencyKey.of("refund"), + TransactionType.REFUND, DESTINATION_ID, ACCOUNT_ID, money("24"), money("0"), NOW); + refund.markSettled(postings(refund), NOW.plusSeconds(2)); + assertThrows(InvalidWalletStateException.class, () -> original.markReversed(refund)); + } + + /** A compensating movement cannot predate the original settlement. */ + @Test + void shouldRejectRefundCreatedBeforeOriginalSettlement() { + Transaction original = settledTransaction(); + Transaction refund = Transaction.initiate(new TransactionId(uuid(20)), IdempotencyKey.of("refund"), + TransactionType.REFUND, DESTINATION_ID, ACCOUNT_ID, money("25"), money("0"), CREATED); + refund.markSettled(postings(refund), NOW.plusSeconds(2)); + assertThrows(InvalidWalletStateException.class, () -> original.markReversed(refund)); + } + + /** Rejects postings outside the transaction interval. */ + @Test + void shouldRejectFutureEntryTime() { + Transaction transaction = transaction("100", "0"); + List entries = List.of(postings(transaction).getFirst(), + entry(101, TRANSACTION_ID, DESTINATION_ID, money("100"), LedgerEntryType.CREDIT, 10, NOW.plusNanos(1))); + assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(entries, NOW)); + } + + /** Rejects postings outside the transaction interval. */ + @Test + void shouldRejectPreCreationEntryTime() { + Transaction transaction = transaction("100", "0"); + List entries = List.of(postings(transaction).getFirst(), + entry(101, TRANSACTION_ID, DESTINATION_ID, money("100"), LedgerEntryType.CREDIT, 10, CREATED.minusNanos(1))); + assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(entries, NOW)); + } + + /** Rejects inconsistent status metadata during restoration. */ + @Test + void shouldRejectOpenWithSettlementSnapshot() { + assertThrows(InvalidWalletStateException.class, () -> Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"), + TransactionStatus.INITIATED, null, CREATED, NOW, null, null)); + } + + /** Rejects inconsistent status metadata during restoration. */ + @Test + void shouldRejectPendingWithFailureSnapshot() { + assertThrows(InvalidWalletStateException.class, () -> Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"), + TransactionStatus.PENDING_SETTLEMENT, "failure", CREATED, null, null, null)); + } + + /** Rejects inconsistent status metadata during restoration. */ + @Test + void shouldRejectFailedWithoutReasonSnapshot() { + assertThrows(InvalidDomainArgumentException.class, () -> Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"), + TransactionStatus.FAILED, null, CREATED, null, null, null)); + } + + /** Rejects inconsistent status metadata during restoration. */ + @Test + void shouldRejectFailedWithSettlementSnapshot() { + assertThrows(InvalidWalletStateException.class, () -> Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"), + TransactionStatus.FAILED, "failure", CREATED, NOW, null, null)); + } + + /** Rejects inconsistent status metadata during restoration. */ + @Test + void shouldRejectSuccessfulWithoutSettlementSnapshot() { + assertThrows(InvalidWalletStateException.class, () -> Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"), + TransactionStatus.SUCCESSFUL, null, CREATED, null, null, null)); + } + + /** Rejects inconsistent status metadata during restoration. */ + @Test + void shouldRejectSuccessfulWithFailureSnapshot() { + assertThrows(InvalidWalletStateException.class, () -> Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"), + TransactionStatus.SUCCESSFUL, "failure", CREATED, NOW, null, null)); + } + + /** Rejects inconsistent status metadata during restoration. */ + @Test + void shouldRejectReversedWithoutReferenceSnapshot() { + assertThrows(InvalidWalletStateException.class, () -> Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"), + TransactionStatus.REVERSED, null, CREATED, NOW, null, NOW)); + } + + /** Rejects inconsistent status metadata during restoration. */ + @Test + void shouldRejectReversedWithSelfReferenceSnapshot() { + assertThrows(InvalidWalletStateException.class, () -> Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"), + TransactionStatus.REVERSED, null, CREATED, NOW, TRANSACTION_ID, NOW)); + } + + /** Rejects inconsistent status metadata during restoration. */ + @Test + void shouldRejectReversedWithEarlierTimeSnapshot() { + assertThrows(InvalidWalletStateException.class, () -> Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"), + TransactionStatus.REVERSED, null, CREATED, NOW, new TransactionId(uuid(20)), CREATED)); + } + + /** Accepts a consistent stored state without transition events. */ + @Test + void shouldRestoreInitiatedSnapshot() { + Transaction transaction = Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"), + TransactionStatus.INITIATED, null, CREATED, null, null, null); + assertEquals(TransactionStatus.INITIATED, transaction.getStatus()); + assertTrue(transaction.pullDomainEvents().isEmpty()); + } + + /** Accepts a consistent stored state without transition events. */ + @Test + void shouldRestorePendingSettlementSnapshot() { + Transaction transaction = Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"), + TransactionStatus.PENDING_SETTLEMENT, null, CREATED, null, null, null); + assertEquals(TransactionStatus.PENDING_SETTLEMENT, transaction.getStatus()); + assertTrue(transaction.pullDomainEvents().isEmpty()); + } + + /** Accepts a consistent stored state without transition events. */ + @Test + void shouldRestoreFailedSnapshot() { + Transaction transaction = Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"), + TransactionStatus.FAILED, "failure", CREATED, null, null, null); + assertEquals(TransactionStatus.FAILED, transaction.getStatus()); + assertTrue(transaction.pullDomainEvents().isEmpty()); + } + + /** Accepts a consistent stored state without transition events. */ + @Test + void shouldRestoreSuccessfulSnapshot() { + Transaction transaction = Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"), + TransactionStatus.SUCCESSFUL, null, CREATED, NOW, null, null); + assertEquals(TransactionStatus.SUCCESSFUL, transaction.getStatus()); + assertTrue(transaction.pullDomainEvents().isEmpty()); + } + + /** Accepts a consistent stored state without transition events. */ + @Test + void shouldRestoreReversedSnapshot() { + Transaction transaction = Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"), + TransactionStatus.REVERSED, null, CREATED, NOW, new TransactionId(uuid(20)), NOW); + assertEquals(TransactionStatus.REVERSED, transaction.getStatus()); + assertTrue(transaction.pullDomainEvents().isEmpty()); + } +} diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/WalletTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/WalletTest.java new file mode 100644 index 0000000..fb7fe65 --- /dev/null +++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/WalletTest.java @@ -0,0 +1,446 @@ +package dev.amg.payguard.domain.model; + +import static dev.amg.payguard.domain.model.DomainFixtures.*; +import static org.junit.jupiter.api.Assertions.*; + +import dev.amg.payguard.domain.enums.*; +import dev.amg.payguard.domain.event.*; +import dev.amg.payguard.domain.exception.*; +import dev.amg.payguard.domain.value_object.*; +import domain.InvalidDomainArgumentException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Tests wallet invariants, status permissions, posting order and rejection atomicity. */ +class WalletTest { + + /** Creates zero balances and exactly one creation event. */ + @Test + void shouldCreateEmptyWalletWithExplicitAccount() { + Wallet wallet = Wallet.create(WALLET_ID, ACCOUNT_ID, USER_ID, CHF, CREATED); + assertEquals(ACCOUNT_ID, wallet.getAccountId()); + assertEquals(WALLET_ID, wallet.getId()); + assertEquals(USER_ID, wallet.getUserId()); + assertEquals(CHF, wallet.getCurrency()); + assertEquals(CREATED, wallet.getCreatedAt()); + assertEquals(WalletStatus.ACTIVE, wallet.getStatus()); + assertEquals(money("0"), wallet.getAvailableBalance()); + assertEquals(-1L, wallet.getLastAppliedSequenceNumber()); + assertEquals(0L, wallet.getVersion()); + var events = wallet.pullDomainEvents(); + assertEquals(1, events.size()); + WalletCreatedEvent event = assertInstanceOf(WalletCreatedEvent.class, events.getFirst()); + assertEquals(WALLET_ID, event.walletId()); + assertEquals(CREATED, event.occurredOn()); + assertTrue(wallet.pullDomainEvents().isEmpty()); + } + + /** Restoration preserves persisted values and does not replay creation. */ + @Test + void shouldRestoreWithoutEvents() { + Wallet wallet = wallet(); + assertTrue(wallet.pullDomainEvents().isEmpty()); + assertEquals(7L, wallet.getVersion()); + assertEquals(9L, wallet.getLastAppliedSequenceNumber()); + } + + /** Regression: a zero book balance must permit closure. */ + @Test + void shouldCloseZeroBalanceWallet() { + Wallet wallet = wallet(WalletStatus.ACTIVE, "0", "0"); + wallet.close(NOW); + assertEquals(WalletStatus.CLOSED, wallet.getStatus()); + assertInstanceOf(WalletClosedEvent.class, wallet.pullDomainEvents().getFirst()); + } + + /** Regression: nonzero balances must prevent closure without mutation. */ + @Test + void shouldRejectClosingFundedWalletWithoutMutation() { + Wallet wallet = wallet(); + var before = snapshot(wallet); + assertThrows(InvalidWalletStateException.class, () -> wallet.close(NOW)); + assertEquals(before, snapshot(wallet)); + assertTrue(wallet.pullDomainEvents().isEmpty()); + } + + /** Cannot close while reserved funds remain. */ + @Test + void shouldRejectReservedFundsOnClosure() { + Wallet wallet = wallet(WalletStatus.ACTIVE, "30", "30"); + assertThrows(InvalidWalletStateException.class, () -> wallet.close(NOW)); + assertEquals(WalletStatus.ACTIVE, wallet.getStatus()); + } + + /** Closure is terminal and must not emit a new transition. */ + @Test + void shouldNotReactivateClosedWallet() { + Wallet wallet = wallet(WalletStatus.CLOSED, "0", "0"); + assertThrows(InvalidWalletStateException.class, () -> wallet.activate(NOW)); + assertThrows(InvalidWalletStateException.class, () -> wallet.suspend("reason", NOW)); + assertThrows(InvalidWalletStateException.class, () -> wallet.close(NOW)); + assertTrue(wallet.pullDomainEvents().isEmpty()); + } + + /** Prevents restoring an overdraft under the explicit non-overdraft policy. */ + @Test + void shouldRejectNegativeBookBalance() { + assertThrows(NegativeAmountNotAllowedException.class, () -> wallet(WalletStatus.ACTIVE, "-1", "0")); + } + + /** Rejects corrupt negative reservation snapshots. */ + @Test + void shouldRejectNegativeReservedBalance() { + assertThrows(NegativeAmountNotAllowedException.class, () -> wallet(WalletStatus.ACTIVE, "100", "-1")); + } + + /** Rejects a restored snapshot with negative availability. */ + @Test + void shouldRejectReservationsExceedingBookBalance() { + assertThrows(InvalidWalletStateException.class, () -> wallet(WalletStatus.ACTIVE, "10", "11")); + } + + /** Rejects internally inconsistent closed snapshots. */ + @Test + void shouldRejectFundedClosedSnapshot() { + assertThrows(InvalidWalletStateException.class, () -> wallet(WalletStatus.CLOSED, "1", "0")); + } + + /** Reserving changes reserved and available balances, not the book balance. */ + @Test + void shouldReserveAvailableFunds() { + Wallet wallet = wallet(); + wallet.reserveFunds(money("30"), NOW); + assertEquals(money("100"), wallet.getBookBalance()); + assertEquals(money("30"), wallet.getReservedBalance()); + assertEquals(money("70"), wallet.getAvailableBalance()); + assertEquals(NOW, wallet.getUpdatedAt()); + assertEquals(7L, wallet.getVersion()); + } + + /** Allows reserving the final available minor unit. */ + @Test + void shouldAllowExactAvailableBalanceReservation() { + Wallet wallet = wallet(); + wallet.reserveFunds(money("100"), NOW); + assertTrue(wallet.getAvailableBalance().isZero()); + } + + /** A rejected reservation preserves balances, time, sequence and events. */ + @Test + void shouldRejectOverReservationWithoutMutation() { + Wallet wallet = wallet(); + var before = snapshot(wallet); + assertThrows(InsufficientFundException.class, () -> wallet.reserveFunds(money("100.01"), NOW)); + assertEquals(before, snapshot(wallet)); + assertTrue(wallet.pullDomainEvents().isEmpty()); + } + + /** Operational freezing does not trap existing reservations. */ + @Test + void shouldReleaseReservationWhileFrozen() { + Wallet wallet = wallet(WalletStatus.FROZEN, "100", "30"); + wallet.unreserveFunds(money("30"), NOW); + assertTrue(wallet.getReservedBalance().isZero()); + assertEquals(money("100"), wallet.getBookBalance()); + assertEquals(WalletStatus.FROZEN, wallet.getStatus()); + } + + /** Cannot release more than the reserved amount. */ + @Test + void shouldRejectExcessReleaseWithoutMutation() { + Wallet wallet = wallet(WalletStatus.ACTIVE, "100", "30"); + var before = snapshot(wallet); + assertThrows(InvalidWalletStateException.class, () -> wallet.unreserveFunds(money("30.01"), NOW)); + assertEquals(before, snapshot(wallet)); + } + + /** Applies a credit to the explicit account and advances the sequence. */ + @Test + void shouldCreditExplicitAccount() { + Wallet wallet = wallet(); + wallet.applyLedgerEntry(walletEntry(LedgerEntryType.CREDIT, "15", 10), NOW); + assertEquals(money("115"), wallet.getBookBalance()); + assertEquals(10L, wallet.getLastAppliedSequenceNumber()); + } + + /** Regression: matching the wallet UUID does not prove account ownership. */ + @Test + void shouldRejectWalletUuidAsAccountShortcut() { + Wallet wallet = wallet(); + LedgerEntry entry = entry(100, TRANSACTION_ID, new AccountId(WALLET_ID.value()), + money("10"), LedgerEntryType.CREDIT, 10, NOW); + var before = snapshot(wallet); + assertThrows(InvalidWalletStateException.class, () -> wallet.applyLedgerEntry(entry, NOW)); + assertEquals(before, snapshot(wallet)); + } + + /** An ordinary debit cannot spend money held by another operation. */ + @Test + void shouldDebitOnlyAvailableFunds() { + Wallet wallet = wallet(WalletStatus.ACTIVE, "100", "30"); + var before = snapshot(wallet); + assertThrows(InsufficientFundException.class, + () -> wallet.applyLedgerEntry(walletEntry(LedgerEntryType.DEBIT, "70.01", 10), NOW)); + assertEquals(before, snapshot(wallet)); + wallet.applyLedgerEntry(walletEntry(LedgerEntryType.DEBIT, "70", 10), NOW); + assertEquals(money("30"), wallet.getBookBalance()); + assertTrue(wallet.getAvailableBalance().isZero()); + } + + /** Rejects replay in the same loaded aggregate. */ + @Test + void shouldRejectDuplicateSequenceWithoutMutation() { + Wallet wallet = wallet(); + LedgerEntry entry = walletEntry(LedgerEntryType.DEBIT, "10", 10); + wallet.applyLedgerEntry(entry, NOW); + var before = snapshot(wallet); + assertThrows(InvalidWalletStateException.class, () -> wallet.applyLedgerEntry(entry, NOW)); + assertEquals(before, snapshot(wallet)); + } + + /** Rejects an earlier posting sequence even when entry identities differ. */ + @Test + void shouldRejectOutOfOrderSequence() { + Wallet wallet = wallet(); + assertThrows(InvalidWalletStateException.class, + () -> wallet.applyLedgerEntry(walletEntry(LedgerEntryType.CREDIT, "10", 8), NOW)); + } + + /** Does not assume sequences are gap-free. */ + @Test + void shouldAllowSequenceGaps() { + Wallet wallet = wallet(); + wallet.applyLedgerEntry(walletEntry(LedgerEntryType.CREDIT, "1", 100), NOW); + assertEquals(100L, wallet.getLastAppliedSequenceNumber()); + } + + /** Capture reduces book and reserved balances while preserving availability. */ + @Test + void shouldCaptureReservedFundsAtomically() { + Wallet wallet = wallet(WalletStatus.ACTIVE, "100", "30"); + Money beforeAvailable = wallet.getAvailableBalance(); + wallet.captureReservedFunds(walletEntry(LedgerEntryType.DEBIT, "30", 10), NOW); + assertEquals(money("70"), wallet.getBookBalance()); + assertTrue(wallet.getReservedBalance().isZero()); + assertEquals(beforeAvailable, wallet.getAvailableBalance()); + assertEquals(10L, wallet.getLastAppliedSequenceNumber()); + } + + /** A credit cannot be used to consume a reservation. */ + @Test + void shouldRejectCaptureUsingCredit() { + Wallet wallet = wallet(WalletStatus.ACTIVE, "100", "30"); + var before = snapshot(wallet); + assertThrows(InvalidWalletStateException.class, + () -> wallet.captureReservedFunds(walletEntry(LedgerEntryType.CREDIT, "30", 10), NOW)); + assertEquals(before, snapshot(wallet)); + } + + /** Capture cannot consume more than the reserved total. */ + @Test + void shouldRejectCaptureAboveReservation() { + Wallet wallet = wallet(WalletStatus.ACTIVE, "100", "30"); + var before = snapshot(wallet); + assertThrows(InvalidWalletStateException.class, + () -> wallet.captureReservedFunds(walletEntry(LedgerEntryType.DEBIT, "30.01", 10), NOW)); + assertEquals(before, snapshot(wallet)); + } + + /** The explicit policy blocks capture when all debits are frozen. */ + @Test + void shouldRejectFrozenCapture() { + Wallet wallet = wallet(WalletStatus.FROZEN, "100", "30"); + assertThrows(WalletFrozenException.class, + () -> wallet.captureReservedFunds(walletEntry(LedgerEntryType.DEBIT, "30", 10), NOW)); + } + + /** A null mutation timestamp cannot leave a changed balance. */ + @Test + void shouldRejectNullTimeBeforeChangingBalance() { + Wallet wallet = wallet(); + var before = snapshot(wallet); + assertThrows(InvalidDomainArgumentException.class, () -> wallet.reserveFunds(money("1"), null)); + assertThrows(InvalidDomainArgumentException.class, () -> wallet.applyLedgerEntry(walletEntry(LedgerEntryType.CREDIT, "1", 10), null)); + assertEquals(before, snapshot(wallet)); + } + + /** Enforces non-decreasing aggregate mutation timestamps. */ + @Test + void shouldRejectBackdatedMutation() { + Wallet wallet = wallet(); + wallet.reserveFunds(money("1"), NOW); + var before = snapshot(wallet); + assertThrows(InvalidWalletStateException.class, () -> wallet.unreserveFunds(money("1"), CREATED)); + assertEquals(before, snapshot(wallet)); + } + + /** Invalid reasons must not freeze the aggregate or append events. */ + @Test + void shouldRejectNullOrBlankFreezeReasonBeforeMutation() { + Wallet wallet = wallet(); + var before = snapshot(wallet); + assertThrows(InvalidDomainArgumentException.class, () -> wallet.suspend(null, NOW)); + assertThrows(InvalidDomainArgumentException.class, () -> wallet.suspend(" ", NOW)); + assertEquals(before, snapshot(wallet)); + assertTrue(wallet.pullDomainEvents().isEmpty()); + } + + /** Suspending both directions results in a frozen wallet. */ + @Test + void shouldCombineDirectionalSuspensions() { + Wallet wallet = wallet(); + wallet.suspendDebits("review", NOW); + assertEquals(WalletStatus.SUSPENDED_DEBIT, wallet.getStatus()); + wallet.suspendCredits("review", NOW); + assertEquals(WalletStatus.FROZEN, wallet.getStatus()); + assertEquals(2, wallet.pullDomainEvents().size()); + } + + /** Repeated identical status commands do not invent additional transitions. */ + @Test + void shouldAvoidDuplicateStatusEvents() { + Wallet wallet = wallet(); + wallet.suspend("review", NOW); + wallet.suspend("review", NOW.plusSeconds(1)); + assertEquals(1, wallet.pullDomainEvents().size()); + assertEquals(NOW, wallet.getUpdatedAt()); + wallet.activate(NOW.plusSeconds(2)); + wallet.activate(NOW.plusSeconds(3)); + assertEquals(1, wallet.pullDomainEvents().size()); + } + + /** Requires positive amounts for fund-changing operations. */ + @Test + void shouldRejectZeroForReserveFunds() { + Wallet wallet = wallet(); + assertThrows(InvalidWalletStateException.class, () -> wallet.reserveFunds(money("0"), NOW)); + } + + /** Requires positive amounts for fund-changing operations. */ + @Test + void shouldRejectZeroForUnreserveFunds() { + Wallet wallet = wallet(); + assertThrows(InvalidWalletStateException.class, () -> wallet.unreserveFunds(money("0"), NOW)); + } + + /** Requires positive amounts for fund-changing operations. */ + @Test + void shouldRejectZeroForAssertCanDebit() { + Wallet wallet = wallet(); + assertThrows(InvalidWalletStateException.class, () -> wallet.assertCanDebit(money("0"))); + } + + /** Requires positive amounts for fund-changing operations. */ + @Test + void shouldRejectZeroForAssertCanCredit() { + Wallet wallet = wallet(); + assertThrows(InvalidWalletStateException.class, () -> wallet.assertCanCredit(money("0"))); + } + + /** Checks both debit and credit permissions for ACTIVE. */ + @Test + void shouldEnforcePermissionsForActive() { + Wallet wallet = wallet(WalletStatus.ACTIVE, "100", "0"); + assertDoesNotThrow(() -> wallet.assertCanDebit(money("1"))); + assertDoesNotThrow(() -> wallet.assertCanCredit(money("1"))); + } + + /** Checks both debit and credit permissions for SUSPENDED_DEBIT. */ + @Test + void shouldEnforcePermissionsForSuspendedDebit() { + Wallet wallet = wallet(WalletStatus.SUSPENDED_DEBIT, "100", "0"); + assertThrows(WalletFrozenException.class, () -> wallet.assertCanDebit(money("1"))); + assertDoesNotThrow(() -> wallet.assertCanCredit(money("1"))); + } + + /** Checks both debit and credit permissions for SUSPENDED_CREDIT. */ + @Test + void shouldEnforcePermissionsForSuspendedCredit() { + Wallet wallet = wallet(WalletStatus.SUSPENDED_CREDIT, "100", "0"); + assertDoesNotThrow(() -> wallet.assertCanDebit(money("1"))); + assertThrows(WalletFrozenException.class, () -> wallet.assertCanCredit(money("1"))); + } + + /** Checks both debit and credit permissions for FROZEN. */ + @Test + void shouldEnforcePermissionsForFrozen() { + Wallet wallet = wallet(WalletStatus.FROZEN, "100", "0"); + assertThrows(WalletFrozenException.class, () -> wallet.assertCanDebit(money("1"))); + assertThrows(WalletFrozenException.class, () -> wallet.assertCanCredit(money("1"))); + } + + /** Checks both debit and credit permissions for CLOSED. */ + @Test + void shouldEnforcePermissionsForClosed() { + Wallet wallet = wallet(WalletStatus.CLOSED, "0", "0"); + assertThrows(WalletFrozenException.class, () -> wallet.assertCanDebit(money("1"))); + assertThrows(WalletFrozenException.class, () -> wallet.assertCanCredit(money("1"))); + } + + /** Checks currency even during eligibility-only validation. */ + @Test + void shouldRejectCurrencyMismatchOnCredit() { + Wallet wallet = wallet(); + assertThrows(CurrencyMismatchException.class, () -> wallet.assertCanCredit(Money.of("1", USD))); + } + + /** Checks currency even during eligibility-only validation. */ + @Test + void shouldRejectCurrencyMismatchOnDebit() { + Wallet wallet = wallet(); + assertThrows(CurrencyMismatchException.class, () -> wallet.assertCanDebit(Money.of("1", USD))); + } + + /** Rejects invalid restored metadata before exposing an aggregate. */ + @Test + void shouldRejectBookCurrencyOnRestore() { + assertThrows(CurrencyMismatchException.class, () -> Wallet.restore(WALLET_ID, ACCOUNT_ID, USER_ID, CHF, + WalletStatus.ACTIVE, Money.of("100", USD), money("0"), 0, -1, CREATED, CREATED)); + } + + /** Rejects invalid restored metadata before exposing an aggregate. */ + @Test + void shouldRejectReservedCurrencyOnRestore() { + assertThrows(CurrencyMismatchException.class, () -> Wallet.restore(WALLET_ID, ACCOUNT_ID, USER_ID, CHF, + WalletStatus.ACTIVE, money("100"), Money.of("0", USD), 0, -1, CREATED, CREATED)); + } + + /** Rejects invalid restored metadata before exposing an aggregate. */ + @Test + void shouldRejectNegativeVersionOnRestore() { + assertThrows(InvalidWalletStateException.class, () -> Wallet.restore(WALLET_ID, ACCOUNT_ID, USER_ID, CHF, + WalletStatus.ACTIVE, money("100"), money("0"), -1, -1, CREATED, CREATED)); + } + + /** Rejects invalid restored metadata before exposing an aggregate. */ + @Test + void shouldRejectInvalidSequenceOnRestore() { + assertThrows(InvalidWalletStateException.class, () -> Wallet.restore(WALLET_ID, ACCOUNT_ID, USER_ID, CHF, + WalletStatus.ACTIVE, money("100"), money("0"), 0, -2, CREATED, CREATED)); + } + + /** Rejects invalid restored metadata before exposing an aggregate. */ + @Test + void shouldRejectBackdatedSnapshotOnRestore() { + assertThrows(InvalidWalletStateException.class, () -> Wallet.restore(WALLET_ID, ACCOUNT_ID, USER_ID, CHF, + WalletStatus.ACTIVE, money("100"), money("0"), 0, -1, NOW, CREATED)); + } + + /** Rejects posting timestamps outside the wallet/application interval. */ + @Test + void shouldRejectFuturePostingTime() { + Wallet wallet = wallet(); + LedgerEntry entry = entry(100, TRANSACTION_ID, ACCOUNT_ID, money("1"), LedgerEntryType.CREDIT, 10, NOW.plusNanos(1)); + assertThrows(InvalidWalletStateException.class, () -> wallet.applyLedgerEntry(entry, NOW)); + } + + /** Rejects posting timestamps outside the wallet/application interval. */ + @Test + void shouldRejectBeforeCreationPostingTime() { + Wallet wallet = wallet(); + LedgerEntry entry = entry(100, TRANSACTION_ID, ACCOUNT_ID, money("1"), LedgerEntryType.CREDIT, 10, CREATED.minusNanos(1)); + assertThrows(InvalidWalletStateException.class, () -> wallet.applyLedgerEntry(entry, NOW)); + } +} diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/AccountIdTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/AccountIdTest.java new file mode 100644 index 0000000..53f2353 --- /dev/null +++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/AccountIdTest.java @@ -0,0 +1,92 @@ +package dev.amg.payguard.domain.value_object; + +import domain.InvalidDomainArgumentException; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** Tests the immutable identifier contract of {@link AccountId}. */ +class AccountIdTest { + + private static final UUID FIXED_ID = UUID.fromString("123e4567-e89b-42d3-a456-426614174000"); + + /** Direct construction retains the exact UUID without transformation. */ + @Test + void shouldPreserveUuid() { + AccountId id = new AccountId(FIXED_ID); + assertEquals(FIXED_ID, id.value()); + } + + /** Generated identifiers use a non-null random UUID, version 4. */ + @Test + void shouldGenerateVersionFourIdentifier() { + AccountId first = AccountId.generate(); + AccountId second = AccountId.generate(); + assertNotNull(first.value()); + assertEquals(4, first.value().version()); + assertNotEquals(first, second); + } + + /** Canonical UUID strings round-trip through the identifier. */ + @Test + void shouldParseCanonicalUuid() { + assertEquals(new AccountId(FIXED_ID), AccountId.fromString(FIXED_ID.toString())); + } + + /** Uppercase hexadecimal UUID input is allowed. */ + @Test + void shouldAcceptUppercaseUuid() { + assertEquals(new AccountId(FIXED_ID), + AccountId.fromString(FIXED_ID.toString().toUpperCase(java.util.Locale.ROOT))); + } + + /** The JDK's permissive, noncanonical UUID spellings are not accepted. */ + @Test + void shouldRejectNoncanonicalUuid() { + assertThrows(IllegalArgumentException.class, () -> AccountId.fromString("1-1-1-1-1")); + } + + /** Malformed UUID strings must be rejected. */ + @Test + void shouldRejectMalformedUuid() { + assertThrows(IllegalArgumentException.class, () -> AccountId.fromString("not-a-uuid")); + } + + /** Null UUIDs are rejected using the shared-domain validation contract. */ + @Test + void shouldRejectNullUuid() { + assertThrows(InvalidDomainArgumentException.class, () -> new AccountId(null)); + } + + /** Null UUID strings are rejected using the shared-domain validation contract. */ + @Test + void shouldRejectNullString() { + assertThrows(InvalidDomainArgumentException.class, () -> AccountId.fromString(null)); + } + + /** Record equality, hash code, and shared ValueObject equality must agree. */ + @Test + void shouldPreserveValueEquality() { + AccountId first = new AccountId(FIXED_ID); + AccountId second = AccountId.fromString(FIXED_ID.toString()); + assertEquals(first, second); + assertEquals(first.hashCode(), second.hashCode()); + assertTrue(first.sameValueAs(second)); + assertFalse(first.sameValueAs(null)); + } + + /** A different UUID must represent a different identifier. */ + @Test + void shouldRejectDifferentUuidEquality() { + assertNotEquals(new AccountId(FIXED_ID), + new AccountId(UUID.fromString("123e4567-e89b-42d3-a456-426614174001"))); + } + + /** A different identifier type must never compare equal even with the same UUID. */ + @Test + void shouldRejectDifferentIdentifierTypeEquality() { + AccountId identifier = new AccountId(FIXED_ID); + assertNotEquals(identifier, new WalletId(FIXED_ID)); + } +} diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/HoldIdTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/HoldIdTest.java new file mode 100644 index 0000000..ebefa6b --- /dev/null +++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/HoldIdTest.java @@ -0,0 +1,92 @@ +package dev.amg.payguard.domain.value_object; + +import domain.InvalidDomainArgumentException; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** Tests the immutable identifier contract of {@link HoldId}. */ +class HoldIdTest { + + private static final UUID FIXED_ID = UUID.fromString("123e4567-e89b-42d3-a456-426614174000"); + + /** Direct construction retains the exact UUID without transformation. */ + @Test + void shouldPreserveUuid() { + HoldId id = new HoldId(FIXED_ID); + assertEquals(FIXED_ID, id.value()); + } + + /** Generated identifiers use a non-null random UUID, version 4. */ + @Test + void shouldGenerateVersionFourIdentifier() { + HoldId first = HoldId.generate(); + HoldId second = HoldId.generate(); + assertNotNull(first.value()); + assertEquals(4, first.value().version()); + assertNotEquals(first, second); + } + + /** Canonical UUID strings round-trip through the identifier. */ + @Test + void shouldParseCanonicalUuid() { + assertEquals(new HoldId(FIXED_ID), HoldId.fromString(FIXED_ID.toString())); + } + + /** Uppercase hexadecimal UUID input is allowed. */ + @Test + void shouldAcceptUppercaseUuid() { + assertEquals(new HoldId(FIXED_ID), + HoldId.fromString(FIXED_ID.toString().toUpperCase(java.util.Locale.ROOT))); + } + + /** The JDK's permissive, noncanonical UUID spellings are not accepted. */ + @Test + void shouldRejectNoncanonicalUuid() { + assertThrows(IllegalArgumentException.class, () -> HoldId.fromString("1-1-1-1-1")); + } + + /** Malformed UUID strings must be rejected. */ + @Test + void shouldRejectMalformedUuid() { + assertThrows(IllegalArgumentException.class, () -> HoldId.fromString("not-a-uuid")); + } + + /** Null UUIDs are rejected using the shared-domain validation contract. */ + @Test + void shouldRejectNullUuid() { + assertThrows(InvalidDomainArgumentException.class, () -> new HoldId(null)); + } + + /** Null UUID strings are rejected using the shared-domain validation contract. */ + @Test + void shouldRejectNullString() { + assertThrows(InvalidDomainArgumentException.class, () -> HoldId.fromString(null)); + } + + /** Record equality, hash code, and shared ValueObject equality must agree. */ + @Test + void shouldPreserveValueEquality() { + HoldId first = new HoldId(FIXED_ID); + HoldId second = HoldId.fromString(FIXED_ID.toString()); + assertEquals(first, second); + assertEquals(first.hashCode(), second.hashCode()); + assertTrue(first.sameValueAs(second)); + assertFalse(first.sameValueAs(null)); + } + + /** A different UUID must represent a different identifier. */ + @Test + void shouldRejectDifferentUuidEquality() { + assertNotEquals(new HoldId(FIXED_ID), + new HoldId(UUID.fromString("123e4567-e89b-42d3-a456-426614174001"))); + } + + /** A different identifier type must never compare equal even with the same UUID. */ + @Test + void shouldRejectDifferentIdentifierTypeEquality() { + HoldId identifier = new HoldId(FIXED_ID); + assertNotEquals(identifier, new WalletId(FIXED_ID)); + } +} diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/IdempotencyKeyTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/IdempotencyKeyTest.java new file mode 100644 index 0000000..c0c8520 --- /dev/null +++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/IdempotencyKeyTest.java @@ -0,0 +1,105 @@ +package dev.amg.payguard.domain.value_object; + +import dev.amg.payguard.domain.exception.InvalidIdempotencyKeyException; +import domain.InvalidDomainArgumentException; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** Tests exact protocol validation and equality of idempotency keys. */ +class IdempotencyKeyTest { + + /** A normal client-provided key is preserved verbatim. */ + @Test + void shouldPreserveValidKey() { + assertEquals("order-42_v2~A", IdempotencyKey.of("order-42_v2~A").value()); + } + + /** A one-character key is valid. */ + @Test + void shouldAcceptOneCharacterKey() { + assertEquals("a", IdempotencyKey.of("a").value()); + } + + /** A key at the maximum supported length is valid. */ + @Test + void shouldAcceptMaximumLength() { + assertEquals(255, IdempotencyKey.of("a".repeat(255)).value().length()); + } + + /** A key longer than the protocol limit must be rejected. */ + @Test + void shouldRejectExcessiveLength() { + assertThrows(InvalidIdempotencyKeyException.class, + () -> IdempotencyKey.of("a".repeat(256))); + } + + /** Empty strings are not usable idempotency keys. */ + @Test + void shouldRejectEmptyString() { + assertThrows(InvalidIdempotencyKeyException.class, () -> IdempotencyKey.of("")); + } + + /** Whitespace-only strings are not usable idempotency keys. */ + @Test + void shouldRejectBlankString() { + assertThrows(InvalidIdempotencyKeyException.class, () -> IdempotencyKey.of(" ")); + } + + /** Leading and trailing whitespace is rejected rather than trimmed. */ + @Test + void shouldRejectWhitespaceWithoutNormalization() { + assertThrows(InvalidIdempotencyKeyException.class, + () -> IdempotencyKey.of(" abc ")); + } + + /** Control characters are not valid in a key used in an HTTP header. */ + @Test + void shouldRejectControlCharacters() { + assertThrows(InvalidIdempotencyKeyException.class, + () -> IdempotencyKey.of("a\nb")); + } + + /** Non-ASCII characters are rejected by the explicit Wallet API policy. */ + @Test + void shouldRejectNonAscii() { + assertThrows(InvalidIdempotencyKeyException.class, + () -> IdempotencyKey.of("caf\u00e9")); + } + + /** Printable separators outside the allowed alphabet are rejected. */ + @Test + void shouldRejectDisallowedPunctuation() { + assertThrows(InvalidIdempotencyKeyException.class, + () -> IdempotencyKey.of("order/42")); + } + + /** Case is significant for idempotency semantics. */ + @Test + void shouldPreserveCaseSensitiveEquality() { + assertNotEquals(IdempotencyKey.of("Order-42"), IdempotencyKey.of("order-42")); + } + + /** Equal keys have stable record equality and hash codes. */ + @Test + void shouldCompareEqualKeysByValue() { + IdempotencyKey first = IdempotencyKey.of("checkout-123"); + IdempotencyKey second = IdempotencyKey.of("checkout-123"); + assertEquals(first, second); + assertEquals(first.hashCode(), second.hashCode()); + assertTrue(first.sameValueAs(second)); + assertFalse(first.sameValueAs(null)); + } + + /** Null inputs are rejected by the shared-domain validation contract. */ + @Test + void shouldRejectNullKey() { + assertThrows(InvalidDomainArgumentException.class, () -> IdempotencyKey.of(null)); + } + + /** Different values are not interchangeable idempotency keys. */ + @Test + void shouldRejectDifferentKeyEquality() { + assertNotEquals(IdempotencyKey.of("order-1"), IdempotencyKey.of("order-2")); + } +} diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/LedgerEntryIdTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/LedgerEntryIdTest.java new file mode 100644 index 0000000..1ba2b0d --- /dev/null +++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/LedgerEntryIdTest.java @@ -0,0 +1,92 @@ +package dev.amg.payguard.domain.value_object; + +import domain.InvalidDomainArgumentException; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** Tests the immutable identifier contract of {@link LedgerEntryId}. */ +class LedgerEntryIdTest { + + private static final UUID FIXED_ID = UUID.fromString("123e4567-e89b-42d3-a456-426614174000"); + + /** Direct construction retains the exact UUID without transformation. */ + @Test + void shouldPreserveUuid() { + LedgerEntryId id = new LedgerEntryId(FIXED_ID); + assertEquals(FIXED_ID, id.value()); + } + + /** Generated identifiers use a non-null random UUID, version 4. */ + @Test + void shouldGenerateVersionFourIdentifier() { + LedgerEntryId first = LedgerEntryId.generate(); + LedgerEntryId second = LedgerEntryId.generate(); + assertNotNull(first.value()); + assertEquals(4, first.value().version()); + assertNotEquals(first, second); + } + + /** Canonical UUID strings round-trip through the identifier. */ + @Test + void shouldParseCanonicalUuid() { + assertEquals(new LedgerEntryId(FIXED_ID), LedgerEntryId.fromString(FIXED_ID.toString())); + } + + /** Uppercase hexadecimal UUID input is allowed. */ + @Test + void shouldAcceptUppercaseUuid() { + assertEquals(new LedgerEntryId(FIXED_ID), + LedgerEntryId.fromString(FIXED_ID.toString().toUpperCase(java.util.Locale.ROOT))); + } + + /** The JDK's permissive, noncanonical UUID spellings are not accepted. */ + @Test + void shouldRejectNoncanonicalUuid() { + assertThrows(IllegalArgumentException.class, () -> LedgerEntryId.fromString("1-1-1-1-1")); + } + + /** Malformed UUID strings must be rejected. */ + @Test + void shouldRejectMalformedUuid() { + assertThrows(IllegalArgumentException.class, () -> LedgerEntryId.fromString("not-a-uuid")); + } + + /** Null UUIDs are rejected using the shared-domain validation contract. */ + @Test + void shouldRejectNullUuid() { + assertThrows(InvalidDomainArgumentException.class, () -> new LedgerEntryId(null)); + } + + /** Null UUID strings are rejected using the shared-domain validation contract. */ + @Test + void shouldRejectNullString() { + assertThrows(InvalidDomainArgumentException.class, () -> LedgerEntryId.fromString(null)); + } + + /** Record equality, hash code, and shared ValueObject equality must agree. */ + @Test + void shouldPreserveValueEquality() { + LedgerEntryId first = new LedgerEntryId(FIXED_ID); + LedgerEntryId second = LedgerEntryId.fromString(FIXED_ID.toString()); + assertEquals(first, second); + assertEquals(first.hashCode(), second.hashCode()); + assertTrue(first.sameValueAs(second)); + assertFalse(first.sameValueAs(null)); + } + + /** A different UUID must represent a different identifier. */ + @Test + void shouldRejectDifferentUuidEquality() { + assertNotEquals(new LedgerEntryId(FIXED_ID), + new LedgerEntryId(UUID.fromString("123e4567-e89b-42d3-a456-426614174001"))); + } + + /** A different identifier type must never compare equal even with the same UUID. */ + @Test + void shouldRejectDifferentIdentifierTypeEquality() { + LedgerEntryId identifier = new LedgerEntryId(FIXED_ID); + assertNotEquals(identifier, new WalletId(FIXED_ID)); + } +} diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/MoneyTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/MoneyTest.java new file mode 100644 index 0000000..55fa7fa --- /dev/null +++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/MoneyTest.java @@ -0,0 +1,276 @@ +package dev.amg.payguard.domain.value_object; + +import dev.amg.payguard.domain.exception.CurrencyMismatchException; +import dev.amg.payguard.domain.exception.NegativeAmountNotAllowedException; +import domain.InvalidDomainArgumentException; +import java.math.BigDecimal; +import java.util.Currency; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** Tests exact financial arithmetic, normalization and currency isolation. */ +class MoneyTest { + + private static final Currency CHF = Currency.getInstance("CHF"); + private static final Currency USD = Currency.getInstance("USD"); + + /** A normal CHF amount keeps its exact amount and currency. */ + @Test + void shouldAcceptExactChfAmount() { + Money money = Money.of("100.25", CHF); + assertEquals(new BigDecimal("100.25"), money.amount()); + assertEquals(CHF, money.currency()); + } + + /** A CHF amount with one decimal place normalizes to two. */ + @Test + void shouldNormalizeChfFractionalPrecision() { + assertEquals(new BigDecimal("100.50"), Money.of("100.5", CHF).amount()); + } + + /** Excess zero digits can be removed without rounding. */ + @Test + void shouldAcceptRedundantTrailingZeros() { + assertEquals(new BigDecimal("100.50"), Money.of("100.5000", CHF).amount()); + } + + /** A CHF amount needing rounding is rejected rather than adjusted. */ + @Test + void shouldRejectInexactChfFractionalPrecision() { + assertThrows(ArithmeticException.class, () -> Money.of("100.555", CHF)); + } + + /** Even a tiny third decimal digit must not be silently rounded for CHF. */ + @Test + void shouldRejectSubCentChfAmount() { + assertThrows(ArithmeticException.class, () -> Money.of("0.001", CHF)); + } + + /** Zero-currency placeholder XXX has no defined minor-unit precision. */ + @Test + void shouldRejectUndefinedCurrencyPrecision() { + assertThrows(IllegalArgumentException.class, + () -> Money.of("100", Currency.getInstance("XXX"))); + } + + /** Zero is normalized to the chosen currency scale. */ + @Test + void shouldCreateZeroWithNormalizedScale() { + Money zero = Money.zero(CHF); + assertEquals(new BigDecimal("0.00"), zero.amount()); + assertTrue(zero.isZero()); + assertFalse(zero.isPositive()); + assertFalse(zero.isNegative()); + } + + /** Positive values have only the positive predicate set. */ + @Test + void shouldIdentifyPositiveMoney() { + Money value = Money.of("1.00", CHF); + assertTrue(value.isPositive()); + assertFalse(value.isNegative()); + assertFalse(value.isZero()); + } + + /** Negative values remain representable at value-object level. */ + @Test + void shouldIdentifyNegativeMoney() { + Money value = Money.of("-1.00", CHF); + assertTrue(value.isNegative()); + assertFalse(value.isPositive()); + assertFalse(value.isZero()); + } + + /** Adding amounts leaves the two operands unchanged. */ + @Test + void shouldAddExactAmountsWithoutMutation() { + Money first = Money.of("100.25", CHF); + Money second = Money.of("0.35", CHF); + assertEquals(Money.of("100.60", CHF), first.add(second)); + assertEquals(Money.of("100.25", CHF), first); + assertEquals(Money.of("0.35", CHF), second); + } + + /** Subtraction may produce a negative Money value. */ + @Test + void shouldSubtractExactlyIntoNegativeBalance() { + assertEquals(Money.of("-50.00", CHF), + Money.of("100.00", CHF).subtract(Money.of("150.00", CHF))); + } + + /** Mixed-currency addition is always rejected. */ + @Test + void shouldRejectCrossCurrencyAddition() { + assertThrows(CurrencyMismatchException.class, + () -> Money.of("1.00", CHF).add(Money.of("1.00", USD))); + } + + /** Mixed-currency subtraction is always rejected. */ + @Test + void shouldRejectCrossCurrencySubtraction() { + assertThrows(CurrencyMismatchException.class, + () -> Money.of("1.00", CHF).subtract(Money.of("1.00", USD))); + } + + /** Multiplication by a whole number is exact, including sign. */ + @Test + void shouldMultiplyByWholeNumber() { + assertEquals(Money.of("-30.75", CHF), Money.of("10.25", CHF).multiply(-3L)); + } + + /** Decimal multiplication succeeds when the result has valid precision. */ + @Test + void shouldMultiplyByExactDecimalFactor() { + assertEquals(Money.of("5.00", CHF), + Money.of("10.00", CHF).multiply(new BigDecimal("0.5"))); + } + + /** Decimal multiplication fails rather than silently rounding half cents. */ + @Test + void shouldRejectInexactDecimalMultiplication() { + assertThrows(ArithmeticException.class, + () -> Money.of("10.01", CHF).multiply(new BigDecimal("0.5"))); + } + + /** Zero multiplication preserves currency and normalized scale. */ + @Test + void shouldMultiplyByZero() { + assertEquals(Money.zero(CHF), Money.of("10.25", CHF).multiply(0L)); + } + + /** Negation returns the inverse without mutating the original value. */ + @Test + void shouldNegateWithoutMutating() { + Money original = Money.of("5.50", CHF); + assertEquals(Money.of("-5.50", CHF), original.negate()); + assertEquals(Money.of("5.50", CHF), original); + } + + /** Absolute value removes a negative sign. */ + @Test + void shouldReturnAbsoluteValue() { + assertEquals(Money.of("5.50", CHF), Money.of("-5.50", CHF).abs()); + } + + /** A negative amount is rejected where nonnegative is explicitly required. */ + @Test + void shouldRejectNegativeAmountForNonNegativeInvariant() { + assertThrows(NegativeAmountNotAllowedException.class, + () -> Money.of("-0.01", CHF).assertNonNegative()); + } + + /** Zero and positive amounts satisfy the nonnegative invariant. */ + @Test + void shouldAcceptNonNegativeAmounts() { + assertDoesNotThrow(() -> Money.zero(CHF).assertNonNegative()); + assertDoesNotThrow(() -> Money.of("0.01", CHF).assertNonNegative()); + } + + /** Different legal textual representations of a value normalize equally. */ + @Test + void shouldPreserveNormalizedEqualityAndHashCode() { + Money first = Money.of("10.0", CHF); + Money second = Money.of("10.00", CHF); + assertEquals(first, second); + assertEquals(first.hashCode(), second.hashCode()); + assertTrue(first.sameValueAs(second)); + assertFalse(first.sameValueAs(null)); + } + + /** Currency participates in value equality even when amounts are equal. */ + @Test + void shouldIncludeCurrencyInEquality() { + assertNotEquals(Money.of("1.00", CHF), Money.of("1.00", USD)); + } + + /** Same-currency ordering is supported for normal values. */ + @Test + void shouldCompareSameCurrencyAmounts() { + Money one = Money.of("1.00", CHF); + Money two = Money.of("2.00", CHF); + assertTrue(one.compareTo(two) < 0); + assertTrue(two.compareTo(one) > 0); + assertEquals(0, one.compareTo(Money.of("1.0", CHF))); + } + + /** Convenience comparisons share the currency-safety invariant. */ + @Test + void shouldEvaluateConvenienceComparisons() { + Money one = Money.of("1.00", CHF); + Money two = Money.of("2.00", CHF); + assertTrue(two.isGreaterThan(one)); + assertTrue(two.isGreaterThanOrEqual(two)); + assertTrue(one.isLessThan(two)); + assertTrue(one.isLessThanOrEqual(one)); + } + + /** Ordering across currencies is not meaningful. */ + @Test + void shouldRejectCrossCurrencyComparison() { + assertThrows(CurrencyMismatchException.class, + () -> Money.of("1.00", CHF).compareTo(Money.of("1.00", USD))); + } + + /** Null operands are rejected before arithmetic or comparison. */ + @Test + void shouldRejectNullOperands() { + Money one = Money.of("1.00", CHF); + assertThrows(InvalidDomainArgumentException.class, () -> one.add(null)); + assertThrows(InvalidDomainArgumentException.class, () -> one.subtract(null)); + assertThrows(InvalidDomainArgumentException.class, () -> one.compareTo(null)); + } + + /** Null amounts are rejected by the shared-domain validator. */ + @Test + void shouldRejectNullAmount() { + assertThrows(InvalidDomainArgumentException.class, + () -> Money.of((BigDecimal) null, CHF)); + } + + /** Null currencies are rejected by the shared-domain validator. */ + @Test + void shouldRejectNullCurrency() { + assertThrows(InvalidDomainArgumentException.class, () -> Money.of("1", null)); + } + + /** Null text amounts are rejected before parsing. */ + @Test + void shouldRejectNullTextAmount() { + assertThrows(InvalidDomainArgumentException.class, + () -> Money.of((String) null, CHF)); + } + + /** Invalid decimal text is rejected rather than coerced. */ + @Test + void shouldRejectInvalidDecimalText() { + assertThrows(NumberFormatException.class, () -> Money.of("NaN", CHF)); + } + + /** Null multiplication factors are rejected by the domain validator. */ + @Test + void shouldRejectNullDecimalFactor() { + assertThrows(InvalidDomainArgumentException.class, + () -> Money.of("1.00", CHF).multiply((BigDecimal) null)); + } + + /** Large positive long amounts are represented without double precision loss. */ + @Test + void shouldAcceptMaximumLongAmount() { + assertEquals(new BigDecimal("9223372036854775807.00"), + Money.of(Long.MAX_VALUE, CHF).amount()); + } + + /** Large negative long amounts are represented without double precision loss. */ + @Test + void shouldAcceptMinimumLongAmount() { + assertEquals(new BigDecimal("-9223372036854775808.00"), + Money.of(Long.MIN_VALUE, CHF).amount()); + } + + /** The long factory interprets whole major units, not cents. */ + @Test + void shouldInterpretLongAsWholeMajorUnits() { + assertEquals(Money.of("100.00", CHF), Money.of(100L, CHF)); + } +} diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/TransactionIdTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/TransactionIdTest.java new file mode 100644 index 0000000..edf9b78 --- /dev/null +++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/TransactionIdTest.java @@ -0,0 +1,92 @@ +package dev.amg.payguard.domain.value_object; + +import domain.InvalidDomainArgumentException; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** Tests the immutable identifier contract of {@link TransactionId}. */ +class TransactionIdTest { + + private static final UUID FIXED_ID = UUID.fromString("123e4567-e89b-42d3-a456-426614174000"); + + /** Direct construction retains the exact UUID without transformation. */ + @Test + void shouldPreserveUuid() { + TransactionId id = new TransactionId(FIXED_ID); + assertEquals(FIXED_ID, id.value()); + } + + /** Generated identifiers use a non-null random UUID, version 4. */ + @Test + void shouldGenerateVersionFourIdentifier() { + TransactionId first = TransactionId.generate(); + TransactionId second = TransactionId.generate(); + assertNotNull(first.value()); + assertEquals(4, first.value().version()); + assertNotEquals(first, second); + } + + /** Canonical UUID strings round-trip through the identifier. */ + @Test + void shouldParseCanonicalUuid() { + assertEquals(new TransactionId(FIXED_ID), TransactionId.fromString(FIXED_ID.toString())); + } + + /** Uppercase hexadecimal UUID input is allowed. */ + @Test + void shouldAcceptUppercaseUuid() { + assertEquals(new TransactionId(FIXED_ID), + TransactionId.fromString(FIXED_ID.toString().toUpperCase(java.util.Locale.ROOT))); + } + + /** The JDK's permissive, noncanonical UUID spellings are not accepted. */ + @Test + void shouldRejectNoncanonicalUuid() { + assertThrows(IllegalArgumentException.class, () -> TransactionId.fromString("1-1-1-1-1")); + } + + /** Malformed UUID strings must be rejected. */ + @Test + void shouldRejectMalformedUuid() { + assertThrows(IllegalArgumentException.class, () -> TransactionId.fromString("not-a-uuid")); + } + + /** Null UUIDs are rejected using the shared-domain validation contract. */ + @Test + void shouldRejectNullUuid() { + assertThrows(InvalidDomainArgumentException.class, () -> new TransactionId(null)); + } + + /** Null UUID strings are rejected using the shared-domain validation contract. */ + @Test + void shouldRejectNullString() { + assertThrows(InvalidDomainArgumentException.class, () -> TransactionId.fromString(null)); + } + + /** Record equality, hash code, and shared ValueObject equality must agree. */ + @Test + void shouldPreserveValueEquality() { + TransactionId first = new TransactionId(FIXED_ID); + TransactionId second = TransactionId.fromString(FIXED_ID.toString()); + assertEquals(first, second); + assertEquals(first.hashCode(), second.hashCode()); + assertTrue(first.sameValueAs(second)); + assertFalse(first.sameValueAs(null)); + } + + /** A different UUID must represent a different identifier. */ + @Test + void shouldRejectDifferentUuidEquality() { + assertNotEquals(new TransactionId(FIXED_ID), + new TransactionId(UUID.fromString("123e4567-e89b-42d3-a456-426614174001"))); + } + + /** A different identifier type must never compare equal even with the same UUID. */ + @Test + void shouldRejectDifferentIdentifierTypeEquality() { + TransactionId identifier = new TransactionId(FIXED_ID); + assertNotEquals(identifier, new WalletId(FIXED_ID)); + } +} diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/UserIdTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/UserIdTest.java new file mode 100644 index 0000000..b836e05 --- /dev/null +++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/UserIdTest.java @@ -0,0 +1,82 @@ +package dev.amg.payguard.domain.value_object; + +import domain.InvalidDomainArgumentException; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** Tests the immutable identifier contract of {@link UserId}. */ +class UserIdTest { + + private static final UUID FIXED_ID = UUID.fromString("123e4567-e89b-42d3-a456-426614174000"); + + /** Direct construction retains the exact UUID without transformation. */ + @Test + void shouldPreserveUuid() { + UserId id = new UserId(FIXED_ID); + assertEquals(FIXED_ID, id.value()); + } + + /** Canonical UUID strings round-trip through the identifier. */ + @Test + void shouldParseCanonicalUuid() { + assertEquals(new UserId(FIXED_ID), UserId.fromString(FIXED_ID.toString())); + } + + /** Uppercase hexadecimal UUID input is allowed. */ + @Test + void shouldAcceptUppercaseUuid() { + assertEquals(new UserId(FIXED_ID), + UserId.fromString(FIXED_ID.toString().toUpperCase(java.util.Locale.ROOT))); + } + + /** The JDK's permissive, noncanonical UUID spellings are not accepted. */ + @Test + void shouldRejectNoncanonicalUuid() { + assertThrows(IllegalArgumentException.class, () -> UserId.fromString("1-1-1-1-1")); + } + + /** Malformed UUID strings must be rejected. */ + @Test + void shouldRejectMalformedUuid() { + assertThrows(IllegalArgumentException.class, () -> UserId.fromString("not-a-uuid")); + } + + /** Null UUIDs are rejected using the shared-domain validation contract. */ + @Test + void shouldRejectNullUuid() { + assertThrows(InvalidDomainArgumentException.class, () -> new UserId(null)); + } + + /** Null UUID strings are rejected using the shared-domain validation contract. */ + @Test + void shouldRejectNullString() { + assertThrows(InvalidDomainArgumentException.class, () -> UserId.fromString(null)); + } + + /** Record equality, hash code, and shared ValueObject equality must agree. */ + @Test + void shouldPreserveValueEquality() { + UserId first = new UserId(FIXED_ID); + UserId second = UserId.fromString(FIXED_ID.toString()); + assertEquals(first, second); + assertEquals(first.hashCode(), second.hashCode()); + assertTrue(first.sameValueAs(second)); + assertFalse(first.sameValueAs(null)); + } + + /** A different UUID must represent a different identifier. */ + @Test + void shouldRejectDifferentUuidEquality() { + assertNotEquals(new UserId(FIXED_ID), + new UserId(UUID.fromString("123e4567-e89b-42d3-a456-426614174001"))); + } + + /** A different identifier type must never compare equal even with the same UUID. */ + @Test + void shouldRejectDifferentIdentifierTypeEquality() { + UserId identifier = new UserId(FIXED_ID); + assertNotEquals(identifier, new WalletId(FIXED_ID)); + } +} diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/WalletIdTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/WalletIdTest.java new file mode 100644 index 0000000..05c08ac --- /dev/null +++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/WalletIdTest.java @@ -0,0 +1,92 @@ +package dev.amg.payguard.domain.value_object; + +import domain.InvalidDomainArgumentException; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** Tests the immutable identifier contract of {@link WalletId}. */ +class WalletIdTest { + + private static final UUID FIXED_ID = UUID.fromString("123e4567-e89b-42d3-a456-426614174000"); + + /** Direct construction retains the exact UUID without transformation. */ + @Test + void shouldPreserveUuid() { + WalletId id = new WalletId(FIXED_ID); + assertEquals(FIXED_ID, id.value()); + } + + /** Generated identifiers use a non-null random UUID, version 4. */ + @Test + void shouldGenerateVersionFourIdentifier() { + WalletId first = WalletId.generate(); + WalletId second = WalletId.generate(); + assertNotNull(first.value()); + assertEquals(4, first.value().version()); + assertNotEquals(first, second); + } + + /** Canonical UUID strings round-trip through the identifier. */ + @Test + void shouldParseCanonicalUuid() { + assertEquals(new WalletId(FIXED_ID), WalletId.fromString(FIXED_ID.toString())); + } + + /** Uppercase hexadecimal UUID input is allowed. */ + @Test + void shouldAcceptUppercaseUuid() { + assertEquals(new WalletId(FIXED_ID), + WalletId.fromString(FIXED_ID.toString().toUpperCase(java.util.Locale.ROOT))); + } + + /** The JDK's permissive, noncanonical UUID spellings are not accepted. */ + @Test + void shouldRejectNoncanonicalUuid() { + assertThrows(IllegalArgumentException.class, () -> WalletId.fromString("1-1-1-1-1")); + } + + /** Malformed UUID strings must be rejected. */ + @Test + void shouldRejectMalformedUuid() { + assertThrows(IllegalArgumentException.class, () -> WalletId.fromString("not-a-uuid")); + } + + /** Null UUIDs are rejected using the shared-domain validation contract. */ + @Test + void shouldRejectNullUuid() { + assertThrows(InvalidDomainArgumentException.class, () -> new WalletId(null)); + } + + /** Null UUID strings are rejected using the shared-domain validation contract. */ + @Test + void shouldRejectNullString() { + assertThrows(InvalidDomainArgumentException.class, () -> WalletId.fromString(null)); + } + + /** Record equality, hash code, and shared ValueObject equality must agree. */ + @Test + void shouldPreserveValueEquality() { + WalletId first = new WalletId(FIXED_ID); + WalletId second = WalletId.fromString(FIXED_ID.toString()); + assertEquals(first, second); + assertEquals(first.hashCode(), second.hashCode()); + assertTrue(first.sameValueAs(second)); + assertFalse(first.sameValueAs(null)); + } + + /** A different UUID must represent a different identifier. */ + @Test + void shouldRejectDifferentUuidEquality() { + assertNotEquals(new WalletId(FIXED_ID), + new WalletId(UUID.fromString("123e4567-e89b-42d3-a456-426614174001"))); + } + + /** A different identifier type must never compare equal even with the same UUID. */ + @Test + void shouldRejectDifferentIdentifierTypeEquality() { + WalletId identifier = new WalletId(FIXED_ID); + assertNotEquals(identifier, new AccountId(FIXED_ID)); + } +}