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));
- }
-}