From a16bda1cb577e58aa1251d5fa29ea7dd7a726671 Mon Sep 17 00:00:00 2001 From: Amir Golmoradi Date: Thu, 29 Oct 2026 01:51:53 +0330 Subject: [PATCH] Revert "Harden wallet domain invariants and add regression tests (#37)" This reverts commit 35358ca0f9a61cdc2baeff4bc8d2c49ebf0ac5bf. --- .../main/java/dev/amg/payguard/WalletApp.java | 12 - .../domain/enums/FinancialAccountType.java | 9 - .../amg/payguard/domain/enums/HoldStatus.java | 8 - .../domain/enums/LedgerEntryType.java | 6 - .../domain/enums/TransactionStatus.java | 9 - .../domain/enums/TransactionType.java | 10 - .../payguard/domain/enums/WalletStatus.java | 9 - .../domain/event/FundsHoldCapturedEvent.java | 22 - .../domain/event/FundsHoldCreatedEvent.java | 27 - .../domain/event/FundsHoldExpiredEvent.java | 22 - .../domain/event/FundsHoldReleasedEvent.java | 22 - .../domain/event/MoneyDepositedEvent.java | 22 - .../domain/event/MoneyWithdrawnEvent.java | 22 - .../domain/event/TransferCompletedEvent.java | 34 -- .../domain/event/WalletClosedEvent.java | 18 - .../domain/event/WalletCreatedEvent.java | 22 - .../event/WalletStatusChangedEvent.java | 27 - .../exception/CurrencyMismatchException.java | 30 -- .../DoubleEntryImbalanceException.java | 30 -- .../exception/HoldExpiredException.java | 30 -- .../exception/InsufficientFundException.java | 30 -- .../exception/InvalidHoldStateException.java | 30 -- .../InvalidIdempotencyKeyException.java | 26 - .../InvalidWalletStateException.java | 30 -- .../exception/LimitExceededException.java | 30 -- .../NegativeAmountNotAllowedException.java | 30 -- .../TransactionAlreadySettledException.java | 30 -- .../exception/WalletFrozenException.java | 30 -- .../domain/exception/package-info.java | 2 - .../dev/amg/payguard/domain/model/Hold.java | 209 -------- .../payguard/domain/model/LedgerEntry.java | 72 --- .../payguard/domain/model/Transaction.java | 371 ------------- .../dev/amg/payguard/domain/model/Wallet.java | 303 ----------- .../amg/payguard/domain/port/ClockPort.java | 8 - .../domain/port/DomainEventPublisherPort.java | 10 - .../domain/port/HoldRepositoryPort.java | 15 - .../domain/port/LedgerRepositoryPort.java | 15 - .../payguard/domain/port/LoadWalletPort.java | 15 - .../payguard/domain/port/SaveWalletPort.java | 8 - .../port/TransactionRepositoryPort.java | 14 - .../domain/value_object/AccountId.java | 45 -- .../payguard/domain/value_object/HoldId.java | 45 -- .../domain/value_object/IdempotencyKey.java | 46 -- .../domain/value_object/LedgerEntryId.java | 45 -- .../payguard/domain/value_object/Money.java | 160 ------ .../domain/value_object/TransactionId.java | 45 -- .../payguard/domain/value_object/UserId.java | 40 -- .../domain/value_object/WalletId.java | 45 -- .../annotation/HandleWalletExceptions.java | 10 - .../annotation/SecureAuditOnFailure.java | 12 - .../aspect/RestControllerExceptionAspect.java | 88 ---- .../ServiceExceptionEnricherAspect.java | 59 --- .../SuspiciousActivityTrackerAspect.java | 40 -- .../aop/exception/model/UnifiedResponse.java | 14 - .../src/main/resources/application.yaml | 30 -- payguard-wallet/src/main/resources/banner.txt | 7 - .../amg/payguard/WalletArchitectureTests.java | 13 - .../payguard/domain/model/DomainFixtures.java | 97 ---- .../amg/payguard/domain/model/HoldTest.java | 283 ---------- .../domain/model/LedgerEntryTest.java | 103 ---- .../domain/model/TransactionTest.java | 486 ------------------ .../amg/payguard/domain/model/WalletTest.java | 446 ---------------- .../domain/value_object/AccountIdTest.java | 92 ---- .../domain/value_object/HoldIdTest.java | 92 ---- .../value_object/IdempotencyKeyTest.java | 105 ---- .../value_object/LedgerEntryIdTest.java | 92 ---- .../domain/value_object/MoneyTest.java | 276 ---------- .../value_object/TransactionIdTest.java | 92 ---- .../domain/value_object/UserIdTest.java | 82 --- .../domain/value_object/WalletIdTest.java | 92 ---- 70 files changed, 4751 deletions(-) delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/WalletApp.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/FinancialAccountType.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/HoldStatus.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/LedgerEntryType.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/TransactionStatus.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/TransactionType.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/WalletStatus.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldCapturedEvent.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldCreatedEvent.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldExpiredEvent.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldReleasedEvent.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/event/MoneyDepositedEvent.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/event/MoneyWithdrawnEvent.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/event/TransferCompletedEvent.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/event/WalletClosedEvent.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/event/WalletCreatedEvent.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/event/WalletStatusChangedEvent.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/CurrencyMismatchException.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/DoubleEntryImbalanceException.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/HoldExpiredException.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InsufficientFundException.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InvalidHoldStateException.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InvalidIdempotencyKeyException.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InvalidWalletStateException.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/LimitExceededException.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/NegativeAmountNotAllowedException.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/TransactionAlreadySettledException.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/WalletFrozenException.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/package-info.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Hold.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/model/LedgerEntry.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Transaction.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Wallet.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/port/ClockPort.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/port/DomainEventPublisherPort.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/port/HoldRepositoryPort.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/port/LedgerRepositoryPort.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/port/LoadWalletPort.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/port/SaveWalletPort.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/port/TransactionRepositoryPort.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/AccountId.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/HoldId.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/IdempotencyKey.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/LedgerEntryId.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/Money.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/TransactionId.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/UserId.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/WalletId.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/annotation/HandleWalletExceptions.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/annotation/SecureAuditOnFailure.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/RestControllerExceptionAspect.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/ServiceExceptionEnricherAspect.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/SuspiciousActivityTrackerAspect.java delete mode 100644 payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/model/UnifiedResponse.java delete mode 100644 payguard-wallet/src/main/resources/application.yaml delete mode 100644 payguard-wallet/src/main/resources/banner.txt delete mode 100644 payguard-wallet/src/test/java/dev/amg/payguard/WalletArchitectureTests.java delete mode 100644 payguard-wallet/src/test/java/dev/amg/payguard/domain/model/DomainFixtures.java delete mode 100644 payguard-wallet/src/test/java/dev/amg/payguard/domain/model/HoldTest.java delete mode 100644 payguard-wallet/src/test/java/dev/amg/payguard/domain/model/LedgerEntryTest.java delete mode 100644 payguard-wallet/src/test/java/dev/amg/payguard/domain/model/TransactionTest.java delete mode 100644 payguard-wallet/src/test/java/dev/amg/payguard/domain/model/WalletTest.java delete mode 100644 payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/AccountIdTest.java delete mode 100644 payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/HoldIdTest.java delete mode 100644 payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/IdempotencyKeyTest.java delete mode 100644 payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/LedgerEntryIdTest.java delete mode 100644 payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/MoneyTest.java delete mode 100644 payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/TransactionIdTest.java delete mode 100644 payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/UserIdTest.java delete mode 100644 payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/WalletIdTest.java diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/WalletApp.java b/payguard-wallet/src/main/java/dev/amg/payguard/WalletApp.java deleted file mode 100644 index de780f3..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/WalletApp.java +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 55b6947..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/FinancialAccountType.java +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 03fe121..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/HoldStatus.java +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 8e5fb86..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/LedgerEntryType.java +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 5f6785c..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/TransactionStatus.java +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 82e6883..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/TransactionType.java +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index aa1a64d..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/WalletStatus.java +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 1f825cf..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldCapturedEvent.java +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 874471f..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldCreatedEvent.java +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index e9a8ee3..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldExpiredEvent.java +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 06a5dfd..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldReleasedEvent.java +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 3a729b6..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/MoneyDepositedEvent.java +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 51bda25..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/MoneyWithdrawnEvent.java +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index ef256af..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/TransferCompletedEvent.java +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 67d7f35..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/WalletClosedEvent.java +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 566b317..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/WalletCreatedEvent.java +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 077e23b..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/WalletStatusChangedEvent.java +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index b47253d..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/CurrencyMismatchException.java +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index b617fb6..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/DoubleEntryImbalanceException.java +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 66a7879..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/HoldExpiredException.java +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 3859a96..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InsufficientFundException.java +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index b8fb5b8..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InvalidHoldStateException.java +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 4b98606..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InvalidIdempotencyKeyException.java +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index eea1b78..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InvalidWalletStateException.java +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 2f6d935..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/LimitExceededException.java +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index fb52913..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/NegativeAmountNotAllowedException.java +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index db66151..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/TransactionAlreadySettledException.java +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index f70412e..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/WalletFrozenException.java +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 44008df..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/package-info.java +++ /dev/null @@ -1,2 +0,0 @@ -@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 deleted file mode 100644 index 945d44a..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Hold.java +++ /dev/null @@ -1,209 +0,0 @@ -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 deleted file mode 100644 index a303fba..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/LedgerEntry.java +++ /dev/null @@ -1,72 +0,0 @@ -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 deleted file mode 100644 index a1afe1e..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Transaction.java +++ /dev/null @@ -1,371 +0,0 @@ -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 deleted file mode 100644 index a7e7a3e..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Wallet.java +++ /dev/null @@ -1,303 +0,0 @@ -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 deleted file mode 100644 index 5507b6a..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/ClockPort.java +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 8a3e7f7..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/DomainEventPublisherPort.java +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 9a05f9c..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/HoldRepositoryPort.java +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 40180da..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/LedgerRepositoryPort.java +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 9ed164f..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/LoadWalletPort.java +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 61fd2ec..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/SaveWalletPort.java +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 8b88430..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/TransactionRepositoryPort.java +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 04b9cd8..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/AccountId.java +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index 0d3e245..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/HoldId.java +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index e1fe6c0..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/IdempotencyKey.java +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index 2f785e4..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/LedgerEntryId.java +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index 70f0d0c..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/Money.java +++ /dev/null @@ -1,160 +0,0 @@ -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 deleted file mode 100644 index f9d4389..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/TransactionId.java +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index 9770997..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/UserId.java +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 60b97bc..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/WalletId.java +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index 77a00bd..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/annotation/HandleWalletExceptions.java +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index e4e02f2..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/annotation/SecureAuditOnFailure.java +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 0c7a0d3..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/RestControllerExceptionAspect.java +++ /dev/null @@ -1,88 +0,0 @@ -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 deleted file mode 100644 index 7ff0b1d..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/ServiceExceptionEnricherAspect.java +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index a4afbfc..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/SuspiciousActivityTrackerAspect.java +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index e16508c..0000000 --- a/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/model/UnifiedResponse.java +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index f0db46a..0000000 --- a/payguard-wallet/src/main/resources/application.yaml +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 978a3d7..0000000 --- a/payguard-wallet/src/main/resources/banner.txt +++ /dev/null @@ -1,7 +0,0 @@ -____ __ ____ ___ __ __ _______ .___________. -\ \ / \ / / / \ | | | | | ____|| | - \ \/ \/ / / ^ \ | | | | | |__ `---| |----` - \ / / /_\ \ | | | | | __| | | - \ /\ / / _____ \ | `----.| `----.| |____ | | - \__/ \__/ /__/ \__\ |_______||_______||_______| |__| - diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/WalletArchitectureTests.java b/payguard-wallet/src/test/java/dev/amg/payguard/WalletArchitectureTests.java deleted file mode 100644 index 90a802b..0000000 --- a/payguard-wallet/src/test/java/dev/amg/payguard/WalletArchitectureTests.java +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 10a4707..0000000 --- a/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/DomainFixtures.java +++ /dev/null @@ -1,97 +0,0 @@ -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 deleted file mode 100644 index 4d492a8..0000000 --- a/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/HoldTest.java +++ /dev/null @@ -1,283 +0,0 @@ -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 deleted file mode 100644 index c0a5522..0000000 --- a/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/LedgerEntryTest.java +++ /dev/null @@ -1,103 +0,0 @@ -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 deleted file mode 100644 index 1e49f15..0000000 --- a/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/TransactionTest.java +++ /dev/null @@ -1,486 +0,0 @@ -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 deleted file mode 100644 index fb7fe65..0000000 --- a/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/WalletTest.java +++ /dev/null @@ -1,446 +0,0 @@ -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 deleted file mode 100644 index 53f2353..0000000 --- a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/AccountIdTest.java +++ /dev/null @@ -1,92 +0,0 @@ -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 deleted file mode 100644 index ebefa6b..0000000 --- a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/HoldIdTest.java +++ /dev/null @@ -1,92 +0,0 @@ -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 deleted file mode 100644 index c0c8520..0000000 --- a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/IdempotencyKeyTest.java +++ /dev/null @@ -1,105 +0,0 @@ -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 deleted file mode 100644 index 1ba2b0d..0000000 --- a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/LedgerEntryIdTest.java +++ /dev/null @@ -1,92 +0,0 @@ -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 deleted file mode 100644 index 55fa7fa..0000000 --- a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/MoneyTest.java +++ /dev/null @@ -1,276 +0,0 @@ -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 deleted file mode 100644 index edf9b78..0000000 --- a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/TransactionIdTest.java +++ /dev/null @@ -1,92 +0,0 @@ -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 deleted file mode 100644 index b836e05..0000000 --- a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/UserIdTest.java +++ /dev/null @@ -1,82 +0,0 @@ -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 deleted file mode 100644 index 05c08ac..0000000 --- a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/WalletIdTest.java +++ /dev/null @@ -1,92 +0,0 @@ -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)); - } -}