diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/WalletApp.java b/payguard-wallet/src/main/java/dev/amg/payguard/WalletApp.java
new file mode 100644
index 0000000..de780f3
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/WalletApp.java
@@ -0,0 +1,12 @@
+package dev.amg.payguard;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class WalletApp {
+
+ public static void main(String[] args) {
+ SpringApplication.run(WalletApp.class, args);
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/FinancialAccountType.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/FinancialAccountType.java
new file mode 100644
index 0000000..55b6947
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/FinancialAccountType.java
@@ -0,0 +1,9 @@
+package dev.amg.payguard.domain.enums;
+
+public enum FinancialAccountType {
+ USER_WALLET,
+ BANK_CLEARING,
+ COMPANY_REVENUE,
+ PROMOTIONAL_EXPENSE,
+ SETTLEMENT_SUSPENSE
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/HoldStatus.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/HoldStatus.java
new file mode 100644
index 0000000..03fe121
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/HoldStatus.java
@@ -0,0 +1,8 @@
+package dev.amg.payguard.domain.enums;
+
+public enum HoldStatus {
+ PENDING,
+ CAPTURED,
+ RELEASED,
+ EXPIRED
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/LedgerEntryType.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/LedgerEntryType.java
new file mode 100644
index 0000000..8e5fb86
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/LedgerEntryType.java
@@ -0,0 +1,6 @@
+package dev.amg.payguard.domain.enums;
+
+public enum LedgerEntryType {
+ DEBIT,
+ CREDIT
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/TransactionStatus.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/TransactionStatus.java
new file mode 100644
index 0000000..5f6785c
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/TransactionStatus.java
@@ -0,0 +1,9 @@
+package dev.amg.payguard.domain.enums;
+
+public enum TransactionStatus {
+ INITIATED,
+ PENDING_SETTLEMENT,
+ SUCCESSFUL,
+ FAILED,
+ REVERSED
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/TransactionType.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/TransactionType.java
new file mode 100644
index 0000000..82e6883
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/TransactionType.java
@@ -0,0 +1,10 @@
+package dev.amg.payguard.domain.enums;
+
+public enum TransactionType {
+ DEPOSIT,
+ WITHDRAWAL,
+ P2P_TRANSFER,
+ PURCHASE,
+ REFUND,
+ FEE
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/WalletStatus.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/WalletStatus.java
new file mode 100644
index 0000000..aa1a64d
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/enums/WalletStatus.java
@@ -0,0 +1,9 @@
+package dev.amg.payguard.domain.enums;
+
+public enum WalletStatus {
+ ACTIVE,
+ SUSPENDED_DEBIT,
+ SUSPENDED_CREDIT,
+ FROZEN,
+ CLOSED
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldCapturedEvent.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldCapturedEvent.java
new file mode 100644
index 0000000..1f825cf
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldCapturedEvent.java
@@ -0,0 +1,22 @@
+package dev.amg.payguard.domain.event;
+
+import dev.amg.payguard.domain.value_object.HoldId;
+import dev.amg.payguard.domain.value_object.Money;
+import dev.amg.payguard.domain.value_object.WalletId;
+import domain.DomainEvent;
+import java.time.Instant;
+import java.util.UUID;
+
+public record FundsHoldCapturedEvent(
+ UUID eventId, Instant occurredAt, HoldId holdId, WalletId walletId, Money amount)
+ implements DomainEvent {
+ @Override
+ public Instant occurredOn() {
+ return occurredAt;
+ }
+
+ public static FundsHoldCapturedEvent now(
+ HoldId holdId, WalletId walletId, Money amount, Instant now) {
+ return new FundsHoldCapturedEvent(UUID.randomUUID(), now, holdId, walletId, amount);
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldCreatedEvent.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldCreatedEvent.java
new file mode 100644
index 0000000..874471f
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldCreatedEvent.java
@@ -0,0 +1,27 @@
+package dev.amg.payguard.domain.event;
+
+import dev.amg.payguard.domain.value_object.HoldId;
+import dev.amg.payguard.domain.value_object.Money;
+import dev.amg.payguard.domain.value_object.WalletId;
+import domain.DomainEvent;
+import java.time.Instant;
+import java.util.UUID;
+
+public record FundsHoldCreatedEvent(
+ UUID eventId,
+ Instant occurredAt,
+ HoldId holdId,
+ WalletId walletId,
+ Money amount,
+ Instant expiresAt)
+ implements DomainEvent {
+ @Override
+ public Instant occurredOn() {
+ return occurredAt;
+ }
+
+ public static FundsHoldCreatedEvent now(
+ HoldId holdId, WalletId walletId, Money amount, Instant expiresAt, Instant now) {
+ return new FundsHoldCreatedEvent(UUID.randomUUID(), now, holdId, walletId, amount, expiresAt);
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldExpiredEvent.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldExpiredEvent.java
new file mode 100644
index 0000000..e9a8ee3
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldExpiredEvent.java
@@ -0,0 +1,22 @@
+package dev.amg.payguard.domain.event;
+
+import dev.amg.payguard.domain.value_object.HoldId;
+import dev.amg.payguard.domain.value_object.Money;
+import dev.amg.payguard.domain.value_object.WalletId;
+import domain.DomainEvent;
+import java.time.Instant;
+import java.util.UUID;
+
+public record FundsHoldExpiredEvent(
+ UUID eventId, Instant occurredAt, HoldId holdId, WalletId walletId, Money amount)
+ implements DomainEvent {
+ public static FundsHoldExpiredEvent now(
+ HoldId holdId, WalletId walletId, Money amount, Instant now) {
+ return new FundsHoldExpiredEvent(UUID.randomUUID(), now, holdId, walletId, amount);
+ }
+
+ @Override
+ public Instant occurredOn() {
+ return occurredAt;
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldReleasedEvent.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldReleasedEvent.java
new file mode 100644
index 0000000..06a5dfd
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/FundsHoldReleasedEvent.java
@@ -0,0 +1,22 @@
+package dev.amg.payguard.domain.event;
+
+import dev.amg.payguard.domain.value_object.HoldId;
+import dev.amg.payguard.domain.value_object.Money;
+import dev.amg.payguard.domain.value_object.WalletId;
+import domain.DomainEvent;
+import java.time.Instant;
+import java.util.UUID;
+
+public record FundsHoldReleasedEvent(
+ UUID eventId, Instant occurredAt, HoldId holdId, WalletId walletId, Money amount)
+ implements DomainEvent {
+ @Override
+ public Instant occurredOn() {
+ return occurredAt;
+ }
+
+ public static FundsHoldReleasedEvent now(
+ HoldId holdId, WalletId walletId, Money amount, Instant now) {
+ return new FundsHoldReleasedEvent(UUID.randomUUID(), now, holdId, walletId, amount);
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/MoneyDepositedEvent.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/MoneyDepositedEvent.java
new file mode 100644
index 0000000..3a729b6
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/MoneyDepositedEvent.java
@@ -0,0 +1,22 @@
+package dev.amg.payguard.domain.event;
+
+import dev.amg.payguard.domain.value_object.Money;
+import dev.amg.payguard.domain.value_object.TransactionId;
+import dev.amg.payguard.domain.value_object.WalletId;
+import domain.DomainEvent;
+import java.time.Instant;
+import java.util.UUID;
+
+public record MoneyDepositedEvent(
+ UUID eventId, Instant occurredAt, TransactionId transactionId, WalletId walletId, Money amount)
+ implements DomainEvent {
+ @Override
+ public Instant occurredOn() {
+ return occurredAt;
+ }
+
+ public static MoneyDepositedEvent now(
+ TransactionId transactionId, WalletId walletId, Money amount, Instant now) {
+ return new MoneyDepositedEvent(UUID.randomUUID(), now, transactionId, walletId, amount);
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/MoneyWithdrawnEvent.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/MoneyWithdrawnEvent.java
new file mode 100644
index 0000000..51bda25
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/MoneyWithdrawnEvent.java
@@ -0,0 +1,22 @@
+package dev.amg.payguard.domain.event;
+
+import dev.amg.payguard.domain.value_object.Money;
+import dev.amg.payguard.domain.value_object.TransactionId;
+import dev.amg.payguard.domain.value_object.WalletId;
+import domain.DomainEvent;
+import java.time.Instant;
+import java.util.UUID;
+
+public record MoneyWithdrawnEvent(
+ UUID eventId, Instant occurredAt, TransactionId transactionId, WalletId walletId, Money amount)
+ implements DomainEvent {
+ @Override
+ public Instant occurredOn() {
+ return occurredAt;
+ }
+
+ public static MoneyWithdrawnEvent now(
+ TransactionId transactionId, WalletId walletId, Money amount, Instant now) {
+ return new MoneyWithdrawnEvent(UUID.randomUUID(), now, transactionId, walletId, amount);
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/TransferCompletedEvent.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/TransferCompletedEvent.java
new file mode 100644
index 0000000..ef256af
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/TransferCompletedEvent.java
@@ -0,0 +1,34 @@
+package dev.amg.payguard.domain.event;
+
+import dev.amg.payguard.domain.value_object.AccountId;
+import dev.amg.payguard.domain.value_object.Money;
+import dev.amg.payguard.domain.value_object.TransactionId;
+import domain.DomainEvent;
+import java.time.Instant;
+import java.util.UUID;
+
+public record TransferCompletedEvent(
+ UUID eventId,
+ Instant occurredAt,
+ TransactionId transactionId,
+ AccountId sourceAccountId,
+ AccountId destinationAccountId,
+ Money amount,
+ Money fee)
+ implements DomainEvent {
+ @Override
+ public Instant occurredOn() {
+ return occurredAt;
+ }
+
+ public static TransferCompletedEvent now(
+ TransactionId transactionId,
+ AccountId source,
+ AccountId destination,
+ Money amount,
+ Money fee,
+ Instant now) {
+ return new TransferCompletedEvent(
+ UUID.randomUUID(), now, transactionId, source, destination, amount, fee);
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/WalletClosedEvent.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/WalletClosedEvent.java
new file mode 100644
index 0000000..67d7f35
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/WalletClosedEvent.java
@@ -0,0 +1,18 @@
+package dev.amg.payguard.domain.event;
+
+import dev.amg.payguard.domain.value_object.WalletId;
+import domain.DomainEvent;
+import java.time.Instant;
+import java.util.UUID;
+
+public record WalletClosedEvent(UUID eventId, Instant occurredAt, WalletId walletId)
+ implements DomainEvent {
+ @Override
+ public Instant occurredOn() {
+ return occurredAt;
+ }
+
+ public static WalletClosedEvent now(WalletId walletId, Instant now) {
+ return new WalletClosedEvent(UUID.randomUUID(), now, walletId);
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/WalletCreatedEvent.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/WalletCreatedEvent.java
new file mode 100644
index 0000000..566b317
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/WalletCreatedEvent.java
@@ -0,0 +1,22 @@
+package dev.amg.payguard.domain.event;
+
+import dev.amg.payguard.domain.value_object.UserId;
+import dev.amg.payguard.domain.value_object.WalletId;
+import domain.DomainEvent;
+import java.time.Instant;
+import java.util.Currency;
+import java.util.UUID;
+
+public record WalletCreatedEvent(
+ UUID eventId, Instant occurredAt, WalletId walletId, UserId userId, Currency currency)
+ implements DomainEvent {
+ @Override
+ public Instant occurredOn() {
+ return occurredAt;
+ }
+
+ public static WalletCreatedEvent now(
+ WalletId walletId, UserId userId, Currency currency, Instant now) {
+ return new WalletCreatedEvent(UUID.randomUUID(), now, walletId, userId, currency);
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/WalletStatusChangedEvent.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/WalletStatusChangedEvent.java
new file mode 100644
index 0000000..077e23b
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/event/WalletStatusChangedEvent.java
@@ -0,0 +1,27 @@
+package dev.amg.payguard.domain.event;
+
+import dev.amg.payguard.domain.enums.WalletStatus;
+import dev.amg.payguard.domain.value_object.WalletId;
+import domain.DomainEvent;
+import java.time.Instant;
+import java.util.UUID;
+
+public record WalletStatusChangedEvent(
+ UUID eventId,
+ Instant occurredAt,
+ WalletId walletId,
+ WalletStatus previousStatus,
+ WalletStatus newStatus,
+ String reason)
+ implements DomainEvent {
+ @Override
+ public Instant occurredOn() {
+ return occurredAt;
+ }
+
+ public static WalletStatusChangedEvent now(
+ WalletId walletId, WalletStatus previous, WalletStatus current, String reason, Instant now) {
+ return new WalletStatusChangedEvent(
+ UUID.randomUUID(), now, walletId, previous, current, reason);
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/CurrencyMismatchException.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/CurrencyMismatchException.java
new file mode 100644
index 0000000..b47253d
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/CurrencyMismatchException.java
@@ -0,0 +1,30 @@
+package dev.amg.payguard.domain.exception;
+
+import domain.DomainException;
+
+public class CurrencyMismatchException extends DomainException {
+ private static final long serialVersionUID = 1L;
+
+ public CurrencyMismatchException() {
+ this("Currency mismatch");
+ }
+
+ public CurrencyMismatchException(String message) {
+ super(message);
+ }
+
+ @Override
+ public String getErrorCode() {
+ return "CURRENCY_MISMATCH";
+ }
+
+ @Override
+ public String getTitle() {
+ return "Currency mismatch";
+ }
+
+ @Override
+ public int getHttpStatus() {
+ return 400;
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/DoubleEntryImbalanceException.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/DoubleEntryImbalanceException.java
new file mode 100644
index 0000000..b617fb6
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/DoubleEntryImbalanceException.java
@@ -0,0 +1,30 @@
+package dev.amg.payguard.domain.exception;
+
+import domain.DomainException;
+
+public class DoubleEntryImbalanceException extends DomainException {
+ private static final long serialVersionUID = 1L;
+
+ public DoubleEntryImbalanceException() {
+ this("Double-entry transaction is imbalanced");
+ }
+
+ public DoubleEntryImbalanceException(String message) {
+ super(message);
+ }
+
+ @Override
+ public String getErrorCode() {
+ return "DOUBLE_ENTRY_IMBALANCE";
+ }
+
+ @Override
+ public String getTitle() {
+ return "Double-entry imbalance";
+ }
+
+ @Override
+ public int getHttpStatus() {
+ return 400;
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/HoldExpiredException.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/HoldExpiredException.java
new file mode 100644
index 0000000..66a7879
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/HoldExpiredException.java
@@ -0,0 +1,30 @@
+package dev.amg.payguard.domain.exception;
+
+import domain.DomainException;
+
+public class HoldExpiredException extends DomainException {
+ private static final long serialVersionUID = 1L;
+
+ public HoldExpiredException() {
+ this("Hold has expired");
+ }
+
+ public HoldExpiredException(String message) {
+ super(message);
+ }
+
+ @Override
+ public String getErrorCode() {
+ return "HOLD_EXPIRED";
+ }
+
+ @Override
+ public String getTitle() {
+ return "Hold expired";
+ }
+
+ @Override
+ public int getHttpStatus() {
+ return 409;
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InsufficientFundException.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InsufficientFundException.java
new file mode 100644
index 0000000..3859a96
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InsufficientFundException.java
@@ -0,0 +1,30 @@
+package dev.amg.payguard.domain.exception;
+
+import domain.DomainException;
+
+public class InsufficientFundException extends DomainException {
+ private static final long serialVersionUID = 1L;
+
+ public InsufficientFundException() {
+ this("Insufficient funds");
+ }
+
+ public InsufficientFundException(String message) {
+ super(message);
+ }
+
+ @Override
+ public String getErrorCode() {
+ return "INSUFFICIENT_FUNDS";
+ }
+
+ @Override
+ public String getTitle() {
+ return "Insufficient funds";
+ }
+
+ @Override
+ public int getHttpStatus() {
+ return 422;
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InvalidHoldStateException.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InvalidHoldStateException.java
new file mode 100644
index 0000000..b8fb5b8
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InvalidHoldStateException.java
@@ -0,0 +1,30 @@
+package dev.amg.payguard.domain.exception;
+
+import domain.DomainException;
+
+public class InvalidHoldStateException extends DomainException {
+ private static final long serialVersionUID = 1L;
+
+ public InvalidHoldStateException() {
+ this("Invalid hold state");
+ }
+
+ public InvalidHoldStateException(String message) {
+ super(message);
+ }
+
+ @Override
+ public String getErrorCode() {
+ return "INVALID_HOLD_STATE";
+ }
+
+ @Override
+ public String getTitle() {
+ return "Invalid hold state";
+ }
+
+ @Override
+ public int getHttpStatus() {
+ return 409;
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InvalidIdempotencyKeyException.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InvalidIdempotencyKeyException.java
new file mode 100644
index 0000000..4b98606
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InvalidIdempotencyKeyException.java
@@ -0,0 +1,26 @@
+package dev.amg.payguard.domain.exception;
+
+import domain.DomainException;
+
+public final class InvalidIdempotencyKeyException extends DomainException {
+ private static final long serialVersionUID = 1L;
+
+ public InvalidIdempotencyKeyException(String message) {
+ super(message);
+ }
+
+ @Override
+ public String getErrorCode() {
+ return "INVALID_IDEMPOTENCY_KEY";
+ }
+
+ @Override
+ public String getTitle() {
+ return "Invalid idempotency key";
+ }
+
+ @Override
+ public int getHttpStatus() {
+ return 400;
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InvalidWalletStateException.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InvalidWalletStateException.java
new file mode 100644
index 0000000..eea1b78
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/InvalidWalletStateException.java
@@ -0,0 +1,30 @@
+package dev.amg.payguard.domain.exception;
+
+import domain.DomainException;
+
+public class InvalidWalletStateException extends DomainException {
+ private static final long serialVersionUID = 1L;
+
+ public InvalidWalletStateException() {
+ this("Invalid wallet state");
+ }
+
+ public InvalidWalletStateException(String message) {
+ super(message);
+ }
+
+ @Override
+ public String getErrorCode() {
+ return "INVALID_WALLET_STATE";
+ }
+
+ @Override
+ public String getTitle() {
+ return "Invalid wallet state";
+ }
+
+ @Override
+ public int getHttpStatus() {
+ return 409;
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/LimitExceededException.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/LimitExceededException.java
new file mode 100644
index 0000000..2f6d935
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/LimitExceededException.java
@@ -0,0 +1,30 @@
+package dev.amg.payguard.domain.exception;
+
+import domain.DomainException;
+
+public class LimitExceededException extends DomainException {
+ private static final long serialVersionUID = 1L;
+
+ public LimitExceededException() {
+ this("Wallet limit exceeded");
+ }
+
+ public LimitExceededException(String message) {
+ super(message);
+ }
+
+ @Override
+ public String getErrorCode() {
+ return "LIMIT_EXCEEDED";
+ }
+
+ @Override
+ public String getTitle() {
+ return "Limit exceeded";
+ }
+
+ @Override
+ public int getHttpStatus() {
+ return 409;
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/NegativeAmountNotAllowedException.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/NegativeAmountNotAllowedException.java
new file mode 100644
index 0000000..fb52913
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/NegativeAmountNotAllowedException.java
@@ -0,0 +1,30 @@
+package dev.amg.payguard.domain.exception;
+
+import domain.DomainException;
+
+public class NegativeAmountNotAllowedException extends DomainException {
+ private static final long serialVersionUID = 1L;
+
+ public NegativeAmountNotAllowedException() {
+ this("Negative amounts are not allowed");
+ }
+
+ public NegativeAmountNotAllowedException(String message) {
+ super(message);
+ }
+
+ @Override
+ public String getErrorCode() {
+ return "NEGATIVE_AMOUNT_NOT_ALLOWED";
+ }
+
+ @Override
+ public String getTitle() {
+ return "Negative amount not allowed";
+ }
+
+ @Override
+ public int getHttpStatus() {
+ return 400;
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/TransactionAlreadySettledException.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/TransactionAlreadySettledException.java
new file mode 100644
index 0000000..db66151
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/TransactionAlreadySettledException.java
@@ -0,0 +1,30 @@
+package dev.amg.payguard.domain.exception;
+
+import domain.DomainException;
+
+public class TransactionAlreadySettledException extends DomainException {
+ private static final long serialVersionUID = 1L;
+
+ public TransactionAlreadySettledException() {
+ this("Transaction has already been settled");
+ }
+
+ public TransactionAlreadySettledException(String message) {
+ super(message);
+ }
+
+ @Override
+ public String getErrorCode() {
+ return "TRANSACTION_ALREADY_SETTLED";
+ }
+
+ @Override
+ public String getTitle() {
+ return "Transaction already settled";
+ }
+
+ @Override
+ public int getHttpStatus() {
+ return 409;
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/WalletFrozenException.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/WalletFrozenException.java
new file mode 100644
index 0000000..f70412e
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/WalletFrozenException.java
@@ -0,0 +1,30 @@
+package dev.amg.payguard.domain.exception;
+
+import domain.DomainException;
+
+public class WalletFrozenException extends DomainException {
+ private static final long serialVersionUID = 1L;
+
+ public WalletFrozenException() {
+ this("Wallet is frozen");
+ }
+
+ public WalletFrozenException(String message) {
+ super(message);
+ }
+
+ @Override
+ public String getErrorCode() {
+ return "WALLET_FROZEN";
+ }
+
+ @Override
+ public String getTitle() {
+ return "Wallet frozen";
+ }
+
+ @Override
+ public int getHttpStatus() {
+ return 409;
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/package-info.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/package-info.java
new file mode 100644
index 0000000..44008df
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/exception/package-info.java
@@ -0,0 +1,2 @@
+@org.springframework.modulith.NamedInterface("exceptions")
+package dev.amg.payguard.domain.exception;
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Hold.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Hold.java
new file mode 100644
index 0000000..945d44a
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Hold.java
@@ -0,0 +1,209 @@
+package dev.amg.payguard.domain.model;
+
+import dev.amg.payguard.domain.enums.HoldStatus;
+import dev.amg.payguard.domain.event.FundsHoldCapturedEvent;
+import dev.amg.payguard.domain.event.FundsHoldExpiredEvent;
+import dev.amg.payguard.domain.event.FundsHoldReleasedEvent;
+import dev.amg.payguard.domain.exception.HoldExpiredException;
+import dev.amg.payguard.domain.exception.InvalidHoldStateException;
+import dev.amg.payguard.domain.value_object.HoldId;
+import dev.amg.payguard.domain.value_object.Money;
+import dev.amg.payguard.domain.value_object.WalletId;
+import domain.AggregateRoot;
+import domain.DomainEvent;
+import domain.DomainValidation;
+
+import java.time.Instant;
+import java.util.Optional;
+
+/**
+ * A full-amount reservation, with a half-open validity interval [createdAt, expiresAt).
+ *
+ *
Mutable and command-confined. Capture, release and expiry are mutually exclusive
+ * terminal transitions. All business validation precedes state/event mutation. A failed
+ * expired capture does not expire the hold implicitly; expiry is a separate successful
+ * command that must release wallet funds in the same database transaction.
+ */
+public final class Hold extends AggregateRoot {
+ private final WalletId walletId;
+ private final Money amount;
+ private final String reason;
+ private final String referenceId;
+ private final Instant expiresAt;
+ private final Instant createdAt;
+ private HoldStatus status;
+ private Instant resolvedAt;
+
+ private Hold(HoldId id, WalletId walletId, Money amount, String reason, String referenceId,
+ Instant expiresAt, Instant createdAt, HoldStatus status, Instant resolvedAt) {
+ super(id);
+ this.walletId = DomainValidation.requireNonNull(walletId, "Wallet id");
+ this.amount = DomainValidation.requireNonNull(amount, "Amount");
+ this.reason = DomainValidation.requireText(reason, "Reason");
+ this.referenceId = DomainValidation.requireText(referenceId, "Reference id");
+ this.expiresAt = DomainValidation.requireNonNull(expiresAt, "Expiration time");
+ this.createdAt = DomainValidation.requireNonNull(createdAt, "Creation time");
+ this.status = DomainValidation.requireNonNull(status, "Hold status");
+ amount.assertNonNegative();
+ if (amount.isZero()) {
+ throw new InvalidHoldStateException("Hold amount must be positive");
+ }
+ if (!expiresAt.isAfter(createdAt)) {
+ throw new InvalidHoldStateException("Expiration must be strictly after creation");
+ }
+ if ((status == HoldStatus.PENDING) != (resolvedAt == null)) {
+ throw new InvalidHoldStateException("Resolution time is inconsistent with hold status");
+ }
+ if (resolvedAt != null) {
+ validateTime(resolvedAt);
+ if (status == HoldStatus.CAPTURED && !resolvedAt.isBefore(expiresAt)) {
+ throw new InvalidHoldStateException("A captured hold must have resolved before expiry");
+ }
+ if (status == HoldStatus.EXPIRED && resolvedAt.isBefore(expiresAt)) {
+ throw new InvalidHoldStateException("An expired hold cannot resolve before expiry");
+ }
+ }
+ this.resolvedAt = resolvedAt;
+ }
+
+ /**
+ * Creates a pending reservation with a strictly positive amount and future deadline.
+ */
+ public static Hold createNew(HoldId id, WalletId walletId, Money amount, String reason,
+ String referenceId, Instant expiresAt, Instant now) {
+ return new Hold(id, walletId, amount, reason, referenceId, expiresAt, now,
+ HoldStatus.PENDING, null);
+ }
+
+ /**
+ * Restores a consistent snapshot without generating domain events.
+ */
+ public static Hold restore(HoldId id, WalletId walletId, Money amount, String reason,
+ String referenceId, Instant expiresAt, Instant createdAt, HoldStatus status,
+ Instant resolvedAt) {
+ return new Hold(id, walletId, amount, reason, referenceId, expiresAt, createdAt,
+ status, resolvedAt);
+ }
+
+ /**
+ * Captures a live pending hold; rejected captures leave state and events unchanged.
+ */
+ public void capture(Instant now) {
+ validateTime(now);
+ assertPending();
+ if (isExpired(now)) {
+ throw new HoldExpiredException("Cannot capture an expired hold");
+ }
+ FundsHoldCapturedEvent event =
+ FundsHoldCapturedEvent.now(getId(), walletId, amount, now);
+ status = HoldStatus.CAPTURED;
+ resolvedAt = now;
+ registerEvent(event);
+ }
+
+ /**
+ * Releases any pending hold, including an overdue one, without pretending it was captured.
+ */
+ public void release(Instant now) {
+ validateTime(now);
+ assertPending();
+ FundsHoldReleasedEvent event =
+ FundsHoldReleasedEvent.now(getId(), walletId, amount, now);
+ status = HoldStatus.RELEASED;
+ resolvedAt = now;
+ registerEvent(event);
+ }
+
+ /**
+ * Expires a pending hold at or after its deadline; requires transactional fund release.
+ */
+ public void expire(Instant now) {
+ validateTime(now);
+ assertPending();
+ if (!isExpired(now)) {
+ throw new InvalidHoldStateException("The hold deadline has not been reached");
+ }
+ FundsHoldExpiredEvent event =
+ FundsHoldExpiredEvent.now(getId(), walletId, amount, now);
+ status = HoldStatus.EXPIRED;
+ resolvedAt = now;
+ registerEvent(event);
+ }
+
+ /**
+ * Tests the deadline, not the stored lifecycle status; equality with the deadline is expired.
+ */
+ public boolean isExpired(Instant now) {
+ DomainValidation.requireNonNull(now, "Evaluation time");
+ return !now.isBefore(expiresAt);
+ }
+
+ private void validateTime(Instant now) {
+ DomainValidation.requireNonNull(now, "Operation time");
+ if (now.isBefore(createdAt)) {
+ throw new InvalidHoldStateException("Operation time precedes hold creation");
+ }
+ }
+
+ private void assertPending() {
+ if (status != HoldStatus.PENDING) {
+ throw new InvalidHoldStateException("Hold is not pending: " + status);
+ }
+ }
+
+ /**
+ * Returns the owning wallet identity.
+ */
+ public WalletId getWalletId() {
+ return walletId;
+ }
+
+ /**
+ * Returns the immutable full reservation amount.
+ */
+ public Money getAmount() {
+ return amount;
+ }
+
+ /**
+ * Returns the business reason for the reservation.
+ */
+ public String getReason() {
+ return reason;
+ }
+
+ /**
+ * Returns the caller's business reference, not an idempotency guarantee.
+ */
+ public String getReferenceId() {
+ return referenceId;
+ }
+
+ /**
+ * Returns the exclusive capture deadline.
+ */
+ public Instant getExpiresAt() {
+ return expiresAt;
+ }
+
+ /**
+ * Returns the immutable creation timestamp.
+ */
+ public Instant getCreatedAt() {
+ return createdAt;
+ }
+
+ /**
+ * Returns the stored lifecycle state, which may be pending after the deadline.
+ */
+ public HoldStatus getStatus() {
+ return status;
+ }
+
+ /**
+ * Returns the terminal transition time, absent for pending holds.
+ */
+ public Optional getResolvedAt() {
+ return Optional.ofNullable(resolvedAt);
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/LedgerEntry.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/LedgerEntry.java
new file mode 100644
index 0000000..a303fba
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/LedgerEntry.java
@@ -0,0 +1,72 @@
+package dev.amg.payguard.domain.model;
+
+import dev.amg.payguard.domain.enums.LedgerEntryType;
+import dev.amg.payguard.domain.exception.InvalidWalletStateException;
+import dev.amg.payguard.domain.value_object.AccountId;
+import dev.amg.payguard.domain.value_object.LedgerEntryId;
+import dev.amg.payguard.domain.value_object.Money;
+import dev.amg.payguard.domain.value_object.TransactionId;
+import domain.DomainValidation;
+
+import java.time.Instant;
+
+/**
+ * An immutable accounting entry, with entity equality by entry identifier.
+ *
+ * The amount is strictly positive; direction is expressed by {@code entryType}.
+ * Sequence numbers are non-negative and ordered within an account. Allocation,
+ * uniqueness, immutability in storage, and tenant/account ownership belong to persistence.
+ * A single entry cannot establish double-entry balance; {@link Transaction} validates a batch.
+ *
+ * @param id identity of this entry
+ * @param transactionId transaction owning this entry
+ * @param accountId account affected by this entry
+ * @param amount exact, strictly positive posting magnitude
+ * @param entryType debit or credit direction
+ * @param sequenceNumber non-negative account posting sequence
+ * @param timestamp posting timestamp
+ */
+public record LedgerEntry(
+ LedgerEntryId id,
+ TransactionId transactionId,
+ AccountId accountId,
+ Money amount,
+ LedgerEntryType entryType,
+ long sequenceNumber,
+ Instant timestamp) {
+
+ /**
+ * Validates all entry-local invariants without changing the monetary amount.
+ */
+ public LedgerEntry {
+ DomainValidation.requireNonNull(id, "Ledger entry id");
+ DomainValidation.requireNonNull(transactionId, "Transaction id");
+ DomainValidation.requireNonNull(accountId, "Account id");
+ DomainValidation.requireNonNull(amount, "Amount");
+ DomainValidation.requireNonNull(entryType, "Entry type");
+ DomainValidation.requireNonNull(timestamp, "Timestamp");
+ amount.assertNonNegative();
+ if (amount.isZero()) {
+ throw new InvalidWalletStateException("Ledger entry amount must be positive");
+ }
+ if (sequenceNumber < 0) {
+ throw new InvalidWalletStateException("Sequence number cannot be negative");
+ }
+ }
+
+ /**
+ * Compares entry identity, not posting content; never use this to validate retry payloads.
+ */
+ @Override
+ public boolean equals(Object other) {
+ return this == other || other instanceof LedgerEntry entry && id.equals(entry.id);
+ }
+
+ /**
+ * Returns a stable hash derived only from the immutable identity.
+ */
+ @Override
+ public int hashCode() {
+ return id.hashCode();
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Transaction.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Transaction.java
new file mode 100644
index 0000000..a1afe1e
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Transaction.java
@@ -0,0 +1,371 @@
+package dev.amg.payguard.domain.model;
+
+import dev.amg.payguard.domain.enums.LedgerEntryType;
+import dev.amg.payguard.domain.enums.TransactionStatus;
+import dev.amg.payguard.domain.enums.TransactionType;
+import dev.amg.payguard.domain.exception.CurrencyMismatchException;
+import dev.amg.payguard.domain.exception.DoubleEntryImbalanceException;
+import dev.amg.payguard.domain.exception.InvalidWalletStateException;
+import dev.amg.payguard.domain.exception.TransactionAlreadySettledException;
+import dev.amg.payguard.domain.value_object.*;
+import domain.AggregateRoot;
+import domain.DomainEvent;
+import domain.DomainValidation;
+
+import java.time.Instant;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * A single-currency transfer with an optional additional fee paid by the source.
+ *
+ *
Explicit policy: {@code totalAmount} is the principal delivered to the destination;
+ * the source is debited {@code totalAmount + feeAmount}. Separate fee accounts receive
+ * fee credits. Fees may be split across accounts. FX, netted fees and partial reversals
+ * are not supported by this model.
+ *
+ *
Mutable and command-confined. Settlement validates postings but does not write them.
+ * The application must atomically persist transaction state, all entries, affected wallet
+ * balances, idempotency result and outbox in the same local database transaction.
+ */
+public final class Transaction extends AggregateRoot {
+ private final IdempotencyKey idempotencyKey;
+ private final TransactionType type;
+ private final AccountId sourceAccountId;
+ private final AccountId destinationAccountId;
+ private final Money totalAmount;
+ private final Money feeAmount;
+ private final Instant createdAt;
+ private TransactionStatus status;
+ private String failureReason;
+ private Instant settledAt;
+ private TransactionId reversalTransactionId;
+ private Instant reversedAt;
+
+ private Transaction(TransactionId id, IdempotencyKey idempotencyKey, TransactionType type,
+ AccountId sourceAccountId, AccountId destinationAccountId, Money totalAmount, Money feeAmount,
+ TransactionStatus status, String failureReason, Instant createdAt, Instant settledAt,
+ TransactionId reversalTransactionId, Instant reversedAt) {
+ super(id);
+ this.idempotencyKey = DomainValidation.requireNonNull(idempotencyKey, "Idempotency key");
+ this.type = DomainValidation.requireNonNull(type, "Transaction type");
+ this.sourceAccountId = DomainValidation.requireNonNull(sourceAccountId, "Source account id");
+ this.destinationAccountId =
+ DomainValidation.requireNonNull(destinationAccountId, "Destination account id");
+ this.totalAmount = DomainValidation.requireNonNull(totalAmount, "Total amount");
+ this.feeAmount = DomainValidation.requireNonNull(feeAmount, "Fee amount");
+ this.status = DomainValidation.requireNonNull(status, "Transaction status");
+ this.createdAt = DomainValidation.requireNonNull(createdAt, "Creation time");
+ totalAmount.assertNonNegative();
+ feeAmount.assertNonNegative();
+ if (totalAmount.isZero()) {
+ throw new InvalidWalletStateException("Transaction principal must be positive");
+ }
+ if (sourceAccountId.equals(destinationAccountId)) {
+ throw new InvalidWalletStateException("Source and destination accounts must differ");
+ }
+ if (!totalAmount.currency().equals(feeAmount.currency())) {
+ throw new CurrencyMismatchException("Principal and fee currencies must match");
+ }
+ validateSnapshot(status, failureReason, settledAt, reversalTransactionId, reversedAt);
+ this.failureReason = failureReason;
+ this.settledAt = settledAt;
+ this.reversalTransactionId = reversalTransactionId;
+ this.reversedAt = reversedAt;
+ }
+
+ /**
+ * Creates a new, unsettled transaction without performing IO or posting funds.
+ */
+ public static Transaction initiate(TransactionId id, IdempotencyKey idempotencyKey,
+ TransactionType type, AccountId sourceAccountId, AccountId destinationAccountId,
+ Money totalAmount, Money feeAmount, Instant now) {
+ return new Transaction(id, idempotencyKey, type, sourceAccountId, destinationAccountId,
+ totalAmount, feeAmount, TransactionStatus.INITIATED, null, now, null, null, null);
+ }
+
+ /**
+ * Restores a consistent persisted snapshot without replaying any state transitions.
+ */
+ public static Transaction restore(TransactionId id, IdempotencyKey idempotencyKey,
+ TransactionType type, AccountId sourceAccountId, AccountId destinationAccountId,
+ Money totalAmount, Money feeAmount, TransactionStatus status, String failureReason,
+ Instant createdAt, Instant settledAt, TransactionId reversalTransactionId, Instant reversedAt) {
+ return new Transaction(id, idempotencyKey, type, sourceAccountId, destinationAccountId,
+ totalAmount, feeAmount, status, failureReason, createdAt, settledAt,
+ reversalTransactionId, reversedAt);
+ }
+
+ /**
+ * Moves an initiated transaction into pending settlement exactly once.
+ */
+ public void markPendingSettlement() {
+ if (status != TransactionStatus.INITIATED) {
+ throw new InvalidWalletStateException("Only an initiated transaction can become pending");
+ }
+ status = TransactionStatus.PENDING_SETTLEMENT;
+ }
+
+ /**
+ * Validates the complete posting batch and marks an open transaction successful.
+ * All validation happens before mutation. This is not a database commit.
+ */
+ public void markSettled(List entries, Instant now) {
+ assertOpen();
+ validateTime(now);
+ validatePosting(entries, now);
+ status = TransactionStatus.SUCCESSFUL;
+ settledAt = now;
+ }
+
+ /**
+ * Checks entry identity, account sequences, currency, time, principal/fee allocation
+ * and equal debit/credit totals without changing transaction state.
+ */
+ public void validatePosting(List entries, Instant now) {
+ DomainValidation.requireNonNull(entries, "Ledger entries");
+ validateTime(now);
+ for (LedgerEntry entry : entries) {
+ DomainValidation.requireNonNull(entry, "Ledger entry");
+ }
+ List posting = List.copyOf(entries);
+ if (posting.size() < 2) {
+ throw new DoubleEntryImbalanceException("A posting requires at least two entries");
+ }
+ Set entryIds = new HashSet<>();
+ Set accountSequences = new HashSet<>();
+ Money debits = Money.zero(totalAmount.currency());
+ Money credits = Money.zero(totalAmount.currency());
+ Money sourceDebits = Money.zero(totalAmount.currency());
+ Money destinationCredits = Money.zero(totalAmount.currency());
+ Money feeCredits = Money.zero(totalAmount.currency());
+
+ for (LedgerEntry entry : posting) {
+ if (!getId().equals(entry.transactionId())) {
+ throw new InvalidWalletStateException("Entry belongs to another transaction");
+ }
+ if (!entryIds.add(entry.id())) {
+ throw new InvalidWalletStateException("Duplicate ledger entry identity in batch");
+ }
+ if (!accountSequences.add(new AccountSequence(entry.accountId(), entry.sequenceNumber()))) {
+ throw new InvalidWalletStateException("Duplicate account sequence in batch");
+ }
+ if (!totalAmount.currency().equals(entry.amount().currency())) {
+ throw new CurrencyMismatchException("Posting currency must match transaction currency");
+ }
+ if (entry.timestamp().isBefore(createdAt) || entry.timestamp().isAfter(now)) {
+ throw new InvalidWalletStateException("Posting timestamp is outside transaction interval");
+ }
+ if (entry.entryType() == LedgerEntryType.DEBIT) {
+ if (!entry.accountId().equals(sourceAccountId)) {
+ throw new InvalidWalletStateException("Only the source account may be debited");
+ }
+ debits = debits.add(entry.amount());
+ sourceDebits = sourceDebits.add(entry.amount());
+ } else {
+ if (entry.accountId().equals(sourceAccountId)) {
+ throw new InvalidWalletStateException("Source account cannot receive credit in this posting");
+ }
+ credits = credits.add(entry.amount());
+ if (entry.accountId().equals(destinationAccountId)) {
+ destinationCredits = destinationCredits.add(entry.amount());
+ } else {
+ feeCredits = feeCredits.add(entry.amount());
+ }
+ }
+ }
+ if (!debits.equals(credits)) {
+ throw new DoubleEntryImbalanceException("Debit and credit totals differ");
+ }
+ if (!sourceDebits.equals(totalAmount.add(feeAmount))
+ || !destinationCredits.equals(totalAmount)
+ || !feeCredits.equals(feeAmount)) {
+ throw new InvalidWalletStateException("Posting does not match principal and fee allocation");
+ }
+ }
+
+ /**
+ * Fails only an open transaction; invalid reasons do not alter state.
+ */
+ public void markFailed(String reason) {
+ assertOpen();
+ String validatedReason = DomainValidation.requireText(reason, "Failure reason");
+ status = TransactionStatus.FAILED;
+ failureReason = validatedReason;
+ }
+
+ /**
+ * Links a successful, full, zero-fee compensating REFUND transaction.
+ *
+ * This deliberately refuses fee-bearing reversals until a fee-refund posting policy
+ * is defined. The original entries are never changed. Persist the reversal and this
+ * link atomically, with a unique original/reversal relationship and tenant checks.
+ */
+ public void markReversed(Transaction reversal) {
+ DomainValidation.requireNonNull(reversal, "Reversal transaction");
+ if (status != TransactionStatus.SUCCESSFUL) {
+ throw new InvalidWalletStateException("Only a successful transaction can be reversed");
+ }
+ if (reversal == this || getId().equals(reversal.getId())) {
+ throw new InvalidWalletStateException("A transaction cannot reverse itself");
+ }
+ if (reversal.status != TransactionStatus.SUCCESSFUL || reversal.type != TransactionType.REFUND) {
+ throw new InvalidWalletStateException("Reversal must be a successful refund transaction");
+ }
+ if (!feeAmount.isZero() || !reversal.feeAmount.isZero()) {
+ throw new InvalidWalletStateException("Fee-bearing reversals require an explicit refund policy");
+ }
+ if (!sourceAccountId.equals(reversal.destinationAccountId)
+ || !destinationAccountId.equals(reversal.sourceAccountId)
+ || !totalAmount.equals(reversal.totalAmount)) {
+ throw new InvalidWalletStateException("Reversal must return the full original principal");
+ }
+ if (reversal.createdAt.isBefore(settledAt) || reversal.settledAt.isBefore(settledAt)) {
+ throw new InvalidWalletStateException("Reversal cannot precede original settlement");
+ }
+ reversalTransactionId = reversal.getId();
+ reversedAt = reversal.settledAt;
+ status = TransactionStatus.REVERSED;
+ }
+
+ private void validateSnapshot(TransactionStatus status, String reason, Instant settlement,
+ TransactionId reversalId, Instant reversalTime) {
+ if (settlement != null) {
+ validateTime(settlement);
+ }
+ switch (status) {
+ case INITIATED, PENDING_SETTLEMENT -> {
+ if (reason != null || settlement != null || reversalId != null || reversalTime != null) {
+ throw new InvalidWalletStateException("Open transaction contains terminal metadata");
+ }
+ }
+ case FAILED -> {
+ DomainValidation.requireText(reason, "Failure reason");
+ if (settlement != null || reversalId != null || reversalTime != null) {
+ throw new InvalidWalletStateException("Failed transaction contains settlement metadata");
+ }
+ }
+ case SUCCESSFUL -> {
+ if (reason != null || settlement == null || reversalId != null || reversalTime != null) {
+ throw new InvalidWalletStateException("Successful transaction metadata is inconsistent");
+ }
+ }
+ case REVERSED -> {
+ if (reason != null || settlement == null || reversalId == null || reversalTime == null) {
+ throw new InvalidWalletStateException("Reversed transaction metadata is incomplete");
+ }
+ if (getId().equals(reversalId) || reversalTime.isBefore(settlement) || !feeAmount.isZero()) {
+ throw new InvalidWalletStateException("Invalid reversal reference, time or fee policy");
+ }
+ }
+ }
+ }
+
+ private void assertOpen() {
+ if (status == TransactionStatus.SUCCESSFUL) {
+ throw new TransactionAlreadySettledException("Transaction is already settled");
+ }
+ if (status != TransactionStatus.INITIATED && status != TransactionStatus.PENDING_SETTLEMENT) {
+ throw new InvalidWalletStateException("Transaction is terminal: " + status);
+ }
+ }
+
+ private void validateTime(Instant now) {
+ DomainValidation.requireNonNull(now, "Operation time");
+ if (now.isBefore(createdAt)) {
+ throw new InvalidWalletStateException("Operation time precedes transaction creation");
+ }
+ }
+
+ /**
+ * A local collision key, deliberately not a new public domain abstraction.
+ */
+ private record AccountSequence(AccountId accountId, long sequenceNumber) {
+ }
+
+ /**
+ * Returns the exact client-supplied retry key; uniqueness is scoped in persistence.
+ */
+ public IdempotencyKey getIdempotencyKey() {
+ return idempotencyKey;
+ }
+
+ /**
+ * Returns the business transaction classification.
+ */
+ public TransactionType getType() {
+ return type;
+ }
+
+ /**
+ * Returns the account paying principal plus fee.
+ */
+ public AccountId getSourceAccountId() {
+ return sourceAccountId;
+ }
+
+ /**
+ * Returns the account receiving the principal.
+ */
+ public AccountId getDestinationAccountId() {
+ return destinationAccountId;
+ }
+
+ /**
+ * Returns the destination principal, excluding the additional source-paid fee.
+ */
+ public Money getTotalAmount() {
+ return totalAmount;
+ }
+
+ /**
+ * Returns the additional fee, possibly zero.
+ */
+ public Money getFeeAmount() {
+ return feeAmount;
+ }
+
+ /**
+ * Returns the current lifecycle state.
+ */
+ public TransactionStatus getStatus() {
+ return status;
+ }
+
+ /**
+ * Returns a reason only when the transaction failed.
+ */
+ public Optional getFailureReason() {
+ return Optional.ofNullable(failureReason);
+ }
+
+ /**
+ * Returns the immutable initiation timestamp.
+ */
+ public Instant getCreatedAt() {
+ return createdAt;
+ }
+
+ /**
+ * Returns the original settlement time, retained after reversal.
+ */
+ public Optional getSettledAt() {
+ return Optional.ofNullable(settledAt);
+ }
+
+ /**
+ * Returns the successful compensating transaction identity when reversed.
+ */
+ public Optional getReversalTransactionId() {
+ return Optional.ofNullable(reversalTransactionId);
+ }
+
+ /**
+ * Returns the compensating settlement time when reversed.
+ */
+ public Optional getReversedAt() {
+ return Optional.ofNullable(reversedAt);
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Wallet.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Wallet.java
new file mode 100644
index 0000000..a7e7a3e
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Wallet.java
@@ -0,0 +1,303 @@
+package dev.amg.payguard.domain.model;
+
+import dev.amg.payguard.domain.enums.LedgerEntryType;
+import dev.amg.payguard.domain.enums.WalletStatus;
+import dev.amg.payguard.domain.event.WalletClosedEvent;
+import dev.amg.payguard.domain.event.WalletCreatedEvent;
+import dev.amg.payguard.domain.event.WalletStatusChangedEvent;
+import dev.amg.payguard.domain.exception.CurrencyMismatchException;
+import dev.amg.payguard.domain.exception.InsufficientFundException;
+import dev.amg.payguard.domain.exception.InvalidWalletStateException;
+import dev.amg.payguard.domain.exception.WalletFrozenException;
+import dev.amg.payguard.domain.value_object.AccountId;
+import dev.amg.payguard.domain.value_object.Money;
+import dev.amg.payguard.domain.value_object.UserId;
+import dev.amg.payguard.domain.value_object.WalletId;
+import domain.AggregateRoot;
+import domain.DomainEvent;
+import domain.DomainValidation;
+import java.time.Instant;
+import java.util.Currency;
+
+/**
+ * A single-currency, non-overdraft customer wallet backed by an explicit ledger account.
+ *
+ * Always maintains {@code 0 <= reservedBalance <= bookBalance}. Credits increase
+ * this customer-liability balance; debits decrease it. This is not a generic GL account.
+ *
+ *
Instances are mutable and MUST be confined to one command/unit of work, never
+ * shared across threads or cached as live aggregates. Repository locking or versioned
+ * conditional updates are required for concurrent commands. The loaded version is an
+ * expected persistence version, not an in-memory mutation counter.
+ */
+public final class Wallet extends AggregateRoot {
+ private final AccountId accountId;
+ private final UserId userId;
+ private final Currency currency;
+ private final Instant createdAt;
+ private final long version;
+ private WalletStatus status;
+ private Money bookBalance;
+ private Money reservedBalance;
+ private Instant updatedAt;
+ private long lastAppliedSequenceNumber;
+
+ private Wallet(WalletId id, AccountId accountId, UserId userId, Currency currency,
+ WalletStatus status, Money bookBalance, Money reservedBalance, long version,
+ long lastAppliedSequenceNumber, Instant createdAt, Instant updatedAt) {
+ super(id);
+ this.accountId = DomainValidation.requireNonNull(accountId, "Ledger account id");
+ this.userId = DomainValidation.requireNonNull(userId, "User id");
+ this.currency = DomainValidation.requireNonNull(currency, "Currency");
+ this.status = DomainValidation.requireNonNull(status, "Wallet status");
+ this.bookBalance = DomainValidation.requireNonNull(bookBalance, "Book balance");
+ this.reservedBalance = DomainValidation.requireNonNull(reservedBalance, "Reserved balance");
+ this.createdAt = DomainValidation.requireNonNull(createdAt, "Creation time");
+ this.updatedAt = DomainValidation.requireNonNull(updatedAt, "Update time");
+ requireCurrency(bookBalance);
+ requireCurrency(reservedBalance);
+ bookBalance.assertNonNegative();
+ reservedBalance.assertNonNegative();
+ if (reservedBalance.isGreaterThan(bookBalance)) {
+ throw new InvalidWalletStateException("Reserved balance cannot exceed book balance");
+ }
+ if (version < 0 || lastAppliedSequenceNumber < -1) {
+ throw new InvalidWalletStateException("Invalid persisted version or posting sequence");
+ }
+ if (updatedAt.isBefore(createdAt)) {
+ throw new InvalidWalletStateException("Update time precedes creation time");
+ }
+ if (status == WalletStatus.CLOSED && (!bookBalance.isZero() || !reservedBalance.isZero())) {
+ throw new InvalidWalletStateException("A closed wallet must have zero balances");
+ }
+ this.version = version;
+ this.lastAppliedSequenceNumber = lastAppliedSequenceNumber;
+ }
+
+ /** Creates an empty wallet with an explicitly assigned ledger account; emits one event. */
+ public static Wallet create(WalletId id, AccountId accountId, UserId userId,
+ Currency currency, Instant now) {
+ Wallet wallet = new Wallet(id, accountId, userId, currency, WalletStatus.ACTIVE,
+ Money.zero(currency), Money.zero(currency), 0L, -1L, now, now);
+ wallet.registerEvent(WalletCreatedEvent.now(id, userId, currency, now));
+ return wallet;
+ }
+
+ /** Restores validated persisted state without emitting creation or transition events. */
+ public static Wallet restore(WalletId id, AccountId accountId, UserId userId, Currency currency,
+ WalletStatus status, Money bookBalance, Money reservedBalance, long version,
+ long lastAppliedSequenceNumber, Instant createdAt, Instant updatedAt) {
+ return new Wallet(id, accountId, userId, currency, status, bookBalance, reservedBalance,
+ version, lastAppliedSequenceNumber, createdAt, updatedAt);
+ }
+
+ /** Returns spendable funds; holds are already excluded from this amount. */
+ public Money getAvailableBalance() {
+ return bookBalance.subtract(reservedBalance);
+ }
+
+ /** Checks a strictly positive, same-currency debit against status and available funds. */
+ public void assertCanDebit(Money amount) {
+ requirePositiveAmount(amount);
+ assertDebitEnabled();
+ if (getAvailableBalance().isLessThan(amount)) {
+ throw new InsufficientFundException("Insufficient available funds");
+ }
+ }
+
+ /** Checks a strictly positive, same-currency credit against wallet status. */
+ public void assertCanCredit(Money amount) {
+ requirePositiveAmount(amount);
+ if (status != WalletStatus.ACTIVE && status != WalletStatus.SUSPENDED_DEBIT) {
+ throw new WalletFrozenException("Credits are not permitted in state: " + status);
+ }
+ }
+
+ /** Reserves spendable funds; the application must create the matching Hold atomically. */
+ public void reserveFunds(Money amount, Instant now) {
+ validateMutationTime(now);
+ assertCanDebit(amount);
+ reservedBalance = reservedBalance.add(amount);
+ updatedAt = now;
+ }
+
+ /** Releases a reservation even when frozen; the matching Hold must be resolved atomically. */
+ public void unreserveFunds(Money amount, Instant now) {
+ validateMutationTime(now);
+ assertNotClosed();
+ requirePositiveAmount(amount);
+ if (reservedBalance.isLessThan(amount)) {
+ throw new InvalidWalletStateException("Cannot release more than the reserved balance");
+ }
+ reservedBalance = reservedBalance.subtract(amount);
+ updatedAt = now;
+ }
+
+ /**
+ * Applies an ordinary posting after validating status, ownership, funds, time and order.
+ *
+ * Entries must be applied in strictly increasing account sequence order. Gaps are
+ * permitted; sequence allocation and duplicate prevention across processes require SQL
+ * constraints and a lock or version check. A batch must first pass Transaction validation.
+ */
+ public void applyLedgerEntry(LedgerEntry entry, Instant now) {
+ validateEntry(entry, now);
+ bookBalance = switch (entry.entryType()) {
+ case CREDIT -> {
+ assertCanCredit(entry.amount());
+ yield bookBalance.add(entry.amount());
+ }
+ case DEBIT -> {
+ assertCanDebit(entry.amount());
+ yield bookBalance.subtract(entry.amount());
+ }
+ };
+ lastAppliedSequenceNumber = entry.sequenceNumber();
+ updatedAt = now;
+ }
+
+ /**
+ * Captures reserved funds with a debit, updating book and reserved balances together.
+ *
+ *
The command handler must validate the matching pending Hold, its wallet, exact
+ * amount and expiry, then persist Hold, Wallet, balanced postings and outbox in one
+ * transaction. This method alone does not authorize or identify a Hold.
+ */
+ public void captureReservedFunds(LedgerEntry debitEntry, Instant now) {
+ validateEntry(debitEntry, now);
+ assertDebitEnabled();
+ if (debitEntry.entryType() != LedgerEntryType.DEBIT) {
+ throw new InvalidWalletStateException("Capturing reserved funds requires a debit entry");
+ }
+ if (reservedBalance.isLessThan(debitEntry.amount())) {
+ throw new InvalidWalletStateException("Capture exceeds the reserved balance");
+ }
+ Money nextBookBalance = bookBalance.subtract(debitEntry.amount());
+ Money nextReservedBalance = reservedBalance.subtract(debitEntry.amount());
+ bookBalance = nextBookBalance;
+ reservedBalance = nextReservedBalance;
+ lastAppliedSequenceNumber = debitEntry.sequenceNumber();
+ updatedAt = now;
+ }
+
+ /** Freezes credits and debits; repeated freezing is a no-op after input validation. */
+ public void suspend(String reason, Instant now) {
+ changeStatus(WalletStatus.FROZEN, reason, now);
+ }
+
+ /** Suspends debits, preserving any existing credit restriction. */
+ public void suspendDebits(String reason, Instant now) {
+ WalletStatus target = status == WalletStatus.SUSPENDED_CREDIT || status == WalletStatus.FROZEN
+ ? WalletStatus.FROZEN : WalletStatus.SUSPENDED_DEBIT;
+ changeStatus(target, reason, now);
+ }
+
+ /** Suspends credits, preserving any existing debit restriction. */
+ public void suspendCredits(String reason, Instant now) {
+ WalletStatus target = status == WalletStatus.SUSPENDED_DEBIT || status == WalletStatus.FROZEN
+ ? WalletStatus.FROZEN : WalletStatus.SUSPENDED_CREDIT;
+ changeStatus(target, reason, now);
+ }
+
+ /** Reactivates a non-closed wallet; authorization belongs to the command boundary. */
+ public void activate(Instant now) {
+ changeStatus(WalletStatus.ACTIVE, "Reactivated", now);
+ }
+
+ /** Closes a wallet only when both balances are zero. Closure is terminal. */
+ public void close(Instant now) {
+ validateMutationTime(now);
+ assertNotClosed();
+ if (!bookBalance.isZero() || !reservedBalance.isZero()) {
+ throw new InvalidWalletStateException("Wallet cannot close with funds or reservations");
+ }
+ WalletClosedEvent event = WalletClosedEvent.now(getId(), now);
+ status = WalletStatus.CLOSED;
+ updatedAt = now;
+ registerEvent(event);
+ }
+
+ private void validateEntry(LedgerEntry entry, Instant now) {
+ DomainValidation.requireNonNull(entry, "Ledger entry");
+ validateMutationTime(now);
+ if (!accountId.equals(entry.accountId())) {
+ throw new InvalidWalletStateException("Ledger entry belongs to another account");
+ }
+ requireCurrency(entry.amount());
+ if (entry.sequenceNumber() <= lastAppliedSequenceNumber) {
+ throw new InvalidWalletStateException("Duplicate or out-of-order ledger entry");
+ }
+ if (entry.timestamp().isBefore(createdAt) || entry.timestamp().isAfter(now)) {
+ throw new InvalidWalletStateException("Ledger entry timestamp is outside the allowed interval");
+ }
+ }
+
+ private void requirePositiveAmount(Money amount) {
+ DomainValidation.requireNonNull(amount, "Amount");
+ requireCurrency(amount);
+ amount.assertNonNegative();
+ if (amount.isZero()) {
+ throw new InvalidWalletStateException("Operation amount must be positive");
+ }
+ }
+
+ private void requireCurrency(Money amount) {
+ if (!currency.equals(amount.currency())) {
+ throw new CurrencyMismatchException("Amount currency does not match wallet currency");
+ }
+ }
+
+ private void validateMutationTime(Instant now) {
+ DomainValidation.requireNonNull(now, "Operation time");
+ if (now.isBefore(updatedAt)) {
+ throw new InvalidWalletStateException("Operation time precedes the last update");
+ }
+ }
+
+ private void assertDebitEnabled() {
+ if (status != WalletStatus.ACTIVE && status != WalletStatus.SUSPENDED_CREDIT) {
+ throw new WalletFrozenException("Debits are not permitted in state: " + status);
+ }
+ }
+
+ private void assertNotClosed() {
+ if (status == WalletStatus.CLOSED) {
+ throw new InvalidWalletStateException("Wallet is permanently closed");
+ }
+ }
+
+ private void changeStatus(WalletStatus target, String reason, Instant now) {
+ validateMutationTime(now);
+ assertNotClosed();
+ DomainValidation.requireText(reason, "Status change reason");
+ if (status == target) {
+ return;
+ }
+ WalletStatusChangedEvent event =
+ WalletStatusChangedEvent.now(getId(), status, target, reason, now);
+ status = target;
+ updatedAt = now;
+ registerEvent(event);
+ }
+
+ /** Returns the explicit ledger account identity, not a conversion of the wallet identity. */
+ public AccountId getAccountId() { return accountId; }
+ /** Returns the external owner reference. */
+ public UserId getUserId() { return userId; }
+ /** Returns the wallet's single currency. */
+ public Currency getCurrency() { return currency; }
+ /** Returns the current operational state. */
+ public WalletStatus getStatus() { return status; }
+ /** Returns the book balance, including reserved funds. */
+ public Money getBookBalance() { return bookBalance; }
+ /** Returns the reserved part of the book balance. */
+ public Money getReservedBalance() { return reservedBalance; }
+ /** Returns the loaded version for a repository compare-and-update; not a mutation count. */
+ public long getVersion() { return version; }
+ /** Returns the last applied sequence, or -1 before any entry has been applied. */
+ public long getLastAppliedSequenceNumber() { return lastAppliedSequenceNumber; }
+ /** Returns the immutable creation timestamp. */
+ public Instant getCreatedAt() { return createdAt; }
+ /** Returns the time of the last effective state change. */
+ public Instant getUpdatedAt() { return updatedAt; }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/ClockPort.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/ClockPort.java
new file mode 100644
index 0000000..5507b6a
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/ClockPort.java
@@ -0,0 +1,8 @@
+package dev.amg.payguard.domain.port;
+
+import java.time.Instant;
+
+@FunctionalInterface
+public interface ClockPort {
+ Instant now();
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/DomainEventPublisherPort.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/DomainEventPublisherPort.java
new file mode 100644
index 0000000..8a3e7f7
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/DomainEventPublisherPort.java
@@ -0,0 +1,10 @@
+package dev.amg.payguard.domain.port;
+
+import domain.DomainEvent;
+import java.util.List;
+
+public interface DomainEventPublisherPort {
+ void publish(DomainEvent event);
+
+ void publishAll(List events);
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/HoldRepositoryPort.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/HoldRepositoryPort.java
new file mode 100644
index 0000000..9a05f9c
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/HoldRepositoryPort.java
@@ -0,0 +1,15 @@
+package dev.amg.payguard.domain.port;
+
+import dev.amg.payguard.domain.model.Hold;
+import dev.amg.payguard.domain.value_object.HoldId;
+import dev.amg.payguard.domain.value_object.WalletId;
+import java.util.List;
+import java.util.Optional;
+
+public interface HoldRepositoryPort {
+ Optional findById(HoldId id);
+
+ List findPendingHoldsByWalletId(WalletId walletId);
+
+ Hold save(Hold hold);
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/LedgerRepositoryPort.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/LedgerRepositoryPort.java
new file mode 100644
index 0000000..40180da
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/LedgerRepositoryPort.java
@@ -0,0 +1,15 @@
+package dev.amg.payguard.domain.port;
+
+import dev.amg.payguard.domain.model.LedgerEntry;
+import dev.amg.payguard.domain.value_object.AccountId;
+import dev.amg.payguard.domain.value_object.Money;
+import java.util.Currency;
+import java.util.List;
+
+public interface LedgerRepositoryPort {
+ void saveEntries(List entries);
+
+ Money calculateBalance(AccountId accountId, Currency currency);
+
+ long getNextSequenceNumber(AccountId accountId);
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/LoadWalletPort.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/LoadWalletPort.java
new file mode 100644
index 0000000..9ed164f
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/LoadWalletPort.java
@@ -0,0 +1,15 @@
+package dev.amg.payguard.domain.port;
+
+import dev.amg.payguard.domain.model.Wallet;
+import dev.amg.payguard.domain.value_object.UserId;
+import dev.amg.payguard.domain.value_object.WalletId;
+import java.util.Currency;
+import java.util.Optional;
+
+public interface LoadWalletPort {
+ Optional findById(WalletId id);
+
+ Optional findByIdForUpdate(WalletId id);
+
+ boolean existsByUserIdAndCurrency(UserId userId, Currency currency);
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/SaveWalletPort.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/SaveWalletPort.java
new file mode 100644
index 0000000..61fd2ec
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/SaveWalletPort.java
@@ -0,0 +1,8 @@
+package dev.amg.payguard.domain.port;
+
+import dev.amg.payguard.domain.model.Wallet;
+
+@FunctionalInterface
+public interface SaveWalletPort {
+ Wallet save(Wallet wallet);
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/TransactionRepositoryPort.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/TransactionRepositoryPort.java
new file mode 100644
index 0000000..8b88430
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/port/TransactionRepositoryPort.java
@@ -0,0 +1,14 @@
+package dev.amg.payguard.domain.port;
+
+import dev.amg.payguard.domain.model.Transaction;
+import dev.amg.payguard.domain.value_object.IdempotencyKey;
+import dev.amg.payguard.domain.value_object.TransactionId;
+import java.util.Optional;
+
+public interface TransactionRepositoryPort {
+ Optional findById(TransactionId id);
+
+ Optional findByIdempotencyKey(IdempotencyKey key);
+
+ Transaction save(Transaction transaction);
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/AccountId.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/AccountId.java
new file mode 100644
index 0000000..04b9cd8
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/AccountId.java
@@ -0,0 +1,45 @@
+package dev.amg.payguard.domain.value_object;
+
+import domain.DomainValidation;
+import domain.ValueObject;
+import java.util.UUID;
+
+/**
+ * Immutable, strongly typed identifier for a ledger account.
+ *
+ * @param value the UUID identity value
+ */
+public record AccountId(UUID value) implements ValueObject {
+
+ /** Rejects a null identity. */
+ public AccountId {
+ DomainValidation.requireNonNull(value, "AccountId value");
+ }
+
+ /** Generates a new random (version 4) UUID identifier. */
+ public static AccountId generate() {
+ return new AccountId(UUID.randomUUID());
+ }
+
+ /**
+ * Parses a canonical UUID string; uppercase hex digits are accepted.
+ *
+ * @param value the canonical UUID string
+ * @return the corresponding AccountId
+ * @throws IllegalArgumentException for malformed or noncanonical UUIDs
+ */
+ public static AccountId fromString(String value) {
+ DomainValidation.requireNonNull(value, "AccountId string");
+ UUID parsed = UUID.fromString(value);
+ if (!parsed.toString().equalsIgnoreCase(value)) {
+ throw new IllegalArgumentException("Noncanonical AccountId UUID: " + value);
+ }
+ return new AccountId(parsed);
+ }
+
+ /** Compares value objects using their exact record values and types. */
+ @Override
+ public boolean sameValueAs(ValueObject other) {
+ return equals(other);
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/HoldId.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/HoldId.java
new file mode 100644
index 0000000..0d3e245
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/HoldId.java
@@ -0,0 +1,45 @@
+package dev.amg.payguard.domain.value_object;
+
+import domain.DomainValidation;
+import domain.ValueObject;
+import java.util.UUID;
+
+/**
+ * Immutable, strongly typed identifier for a reservation of funds.
+ *
+ * @param value the UUID identity value
+ */
+public record HoldId(UUID value) implements ValueObject {
+
+ /** Rejects a null identity. */
+ public HoldId {
+ DomainValidation.requireNonNull(value, "HoldId value");
+ }
+
+ /** Generates a new random (version 4) UUID identifier. */
+ public static HoldId generate() {
+ return new HoldId(UUID.randomUUID());
+ }
+
+ /**
+ * Parses a canonical UUID string; uppercase hex digits are accepted.
+ *
+ * @param value the canonical UUID string
+ * @return the corresponding HoldId
+ * @throws IllegalArgumentException for malformed or noncanonical UUIDs
+ */
+ public static HoldId fromString(String value) {
+ DomainValidation.requireNonNull(value, "HoldId string");
+ UUID parsed = UUID.fromString(value);
+ if (!parsed.toString().equalsIgnoreCase(value)) {
+ throw new IllegalArgumentException("Noncanonical HoldId UUID: " + value);
+ }
+ return new HoldId(parsed);
+ }
+
+ /** Compares value objects using their exact record values and types. */
+ @Override
+ public boolean sameValueAs(ValueObject other) {
+ return equals(other);
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/IdempotencyKey.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/IdempotencyKey.java
new file mode 100644
index 0000000..e1fe6c0
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/IdempotencyKey.java
@@ -0,0 +1,46 @@
+package dev.amg.payguard.domain.value_object;
+
+import dev.amg.payguard.domain.exception.InvalidIdempotencyKeyException;
+import domain.DomainValidation;
+import domain.ValueObject;
+import java.util.regex.Pattern;
+
+/**
+ * An exact, case-sensitive, client-supplied idempotency key.
+ *
+ * The protocol for this Wallet module accepts 1 to 255 ASCII characters from
+ * A-Z, a-z, 0-9, period, underscore, tilde and hyphen. Keys are never trimmed,
+ * case-folded or otherwise normalized.
+ *
+ *
Tenant/operation scope, request fingerprint, status, and timestamps belong
+ * to the persisted idempotency record, not to this value object.
+ *
+ * @param value the exact idempotency key
+ */
+public record IdempotencyKey(String value) implements ValueObject {
+
+ /** Maximum key length, chosen as an explicit Wallet API policy. */
+ public static final int MAX_LENGTH = 255;
+
+ private static final Pattern VALID_KEY = Pattern.compile("[A-Za-z0-9._~-]{1,255}");
+
+ /** Validates that the key is a permitted immutable protocol value. */
+ public IdempotencyKey {
+ DomainValidation.requireNonNull(value, "Idempotency key value");
+ if (!VALID_KEY.matcher(value).matches()) {
+ throw new InvalidIdempotencyKeyException(
+ "Idempotency key must be 1-255 ASCII letters, digits, '.', '_', '~' or '-'");
+ }
+ }
+
+ /** Creates a key from an exact, client-supplied string. */
+ public static IdempotencyKey of(String value) {
+ return new IdempotencyKey(value);
+ }
+
+ /** Compares value objects using their exact record values and types. */
+ @Override
+ public boolean sameValueAs(ValueObject other) {
+ return equals(other);
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/LedgerEntryId.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/LedgerEntryId.java
new file mode 100644
index 0000000..2f785e4
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/LedgerEntryId.java
@@ -0,0 +1,45 @@
+package dev.amg.payguard.domain.value_object;
+
+import domain.DomainValidation;
+import domain.ValueObject;
+import java.util.UUID;
+
+/**
+ * Immutable, strongly typed identifier for an individual ledger posting.
+ *
+ * @param value the UUID identity value
+ */
+public record LedgerEntryId(UUID value) implements ValueObject {
+
+ /** Rejects a null identity. */
+ public LedgerEntryId {
+ DomainValidation.requireNonNull(value, "LedgerEntryId value");
+ }
+
+ /** Generates a new random (version 4) UUID identifier. */
+ public static LedgerEntryId generate() {
+ return new LedgerEntryId(UUID.randomUUID());
+ }
+
+ /**
+ * Parses a canonical UUID string; uppercase hex digits are accepted.
+ *
+ * @param value the canonical UUID string
+ * @return the corresponding LedgerEntryId
+ * @throws IllegalArgumentException for malformed or noncanonical UUIDs
+ */
+ public static LedgerEntryId fromString(String value) {
+ DomainValidation.requireNonNull(value, "LedgerEntryId string");
+ UUID parsed = UUID.fromString(value);
+ if (!parsed.toString().equalsIgnoreCase(value)) {
+ throw new IllegalArgumentException("Noncanonical LedgerEntryId UUID: " + value);
+ }
+ return new LedgerEntryId(parsed);
+ }
+
+ /** Compares value objects using their exact record values and types. */
+ @Override
+ public boolean sameValueAs(ValueObject other) {
+ return equals(other);
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/Money.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/Money.java
new file mode 100644
index 0000000..70f0d0c
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/Money.java
@@ -0,0 +1,160 @@
+package dev.amg.payguard.domain.value_object;
+
+import dev.amg.payguard.domain.exception.CurrencyMismatchException;
+import dev.amg.payguard.domain.exception.NegativeAmountNotAllowedException;
+import domain.DomainValidation;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.Currency;
+import domain.ValueObject;
+
+/**
+ * Exact, immutable monetary amount with its currency.
+ *
+ *
Values are normalized to the currency's default ISO 4217 minor-unit scale;
+ * no implicit rounding is allowed. Negative values are valid; operation-specific
+ * sign constraints belong to the Wallet domain rules.
+ *
+ *
Comparison is supported only for equal currencies.
+ *
+ * @param amount exact value in major currency units
+ * @param currency ISO 4217 currency
+ */
+public record Money(BigDecimal amount, Currency currency)
+ implements Comparable, ValueObject {
+
+ /** Validates currency metadata and exact monetary scale. */
+ public Money {
+ DomainValidation.requireNonNull(amount, "Amount");
+ DomainValidation.requireNonNull(currency, "Currency");
+ int fractionDigits = currency.getDefaultFractionDigits();
+ if (fractionDigits < 0) {
+ throw new IllegalArgumentException(
+ "Unsupported currency precision: " + currency.getCurrencyCode());
+ }
+ amount = amount.setScale(fractionDigits, RoundingMode.UNNECESSARY);
+ }
+
+ /** Constructs Money from an exact decimal value. */
+ public static Money of(BigDecimal amount, Currency currency) {
+ return new Money(amount, currency);
+ }
+
+ /** Parses an exact base-10 decimal representation. */
+ public static Money of(String amount, Currency currency) {
+ DomainValidation.requireNonNull(amount, "Amount string");
+ return new Money(new BigDecimal(amount), currency);
+ }
+
+ /** Constructs Money from whole major currency units (not minor units). */
+ public static Money of(long amount, Currency currency) {
+ return new Money(BigDecimal.valueOf(amount), currency);
+ }
+
+ /** Creates an exact zero using the currency's configured scale. */
+ public static Money zero(Currency currency) {
+ return new Money(BigDecimal.ZERO, currency);
+ }
+
+ /** Adds compatible amounts without rounding. */
+ public Money add(Money other) {
+ assertSameCurrency(other);
+ return new Money(amount.add(other.amount), currency);
+ }
+
+ /** Subtracts compatible amounts without rounding. */
+ public Money subtract(Money other) {
+ assertSameCurrency(other);
+ return new Money(amount.subtract(other.amount), currency);
+ }
+
+ /** Multiplies the amount by an integer factor, preserving exact precision. */
+ public Money multiply(long factor) {
+ return new Money(amount.multiply(BigDecimal.valueOf(factor)), currency);
+ }
+
+ /** Multiplies by a decimal factor; rejects results that require rounding. */
+ public Money multiply(BigDecimal factor) {
+ DomainValidation.requireNonNull(factor, "Multiplication factor");
+ return new Money(amount.multiply(factor), currency);
+ }
+
+ /** Returns the absolute value in the same currency. */
+ public Money abs() {
+ return new Money(amount.abs(), currency);
+ }
+
+ /** Returns the additive inverse in the same currency. */
+ public Money negate() {
+ return new Money(amount.negate(), currency);
+ }
+
+ /** Asserts that the monetary value is zero or positive. */
+ public void assertNonNegative() {
+ if (isNegative()) {
+ throw new NegativeAmountNotAllowedException(
+ "Amount cannot be negative: " + amount);
+ }
+ }
+
+ /** Tests whether the amount is strictly positive. */
+ public boolean isPositive() {
+ return amount.signum() > 0;
+ }
+
+ /** Tests whether the amount is strictly negative. */
+ public boolean isNegative() {
+ return amount.signum() < 0;
+ }
+
+ /** Tests whether the amount is zero. */
+ public boolean isZero() {
+ return amount.signum() == 0;
+ }
+
+ /** Compares compatible amounts for a strictly greater value. */
+ public boolean isGreaterThan(Money other) {
+ return compareTo(other) > 0;
+ }
+
+ /** Compares compatible amounts for a greater-or-equal value. */
+ public boolean isGreaterThanOrEqual(Money other) {
+ return compareTo(other) >= 0;
+ }
+
+ /** Compares compatible amounts for a strictly smaller value. */
+ public boolean isLessThan(Money other) {
+ return compareTo(other) < 0;
+ }
+
+ /** Compares compatible amounts for a smaller-or-equal value. */
+ public boolean isLessThanOrEqual(Money other) {
+ return compareTo(other) <= 0;
+ }
+
+ /**
+ * Orders monetary values in the same currency only.
+ *
+ * @throws CurrencyMismatchException for different currencies
+ */
+ @Override
+ public int compareTo(Money other) {
+ assertSameCurrency(other);
+ return amount.compareTo(other.amount);
+ }
+
+ /** Compares value objects using exact normalized record equality. */
+ @Override
+ public boolean sameValueAs(ValueObject other) {
+ return equals(other);
+ }
+
+ private void assertSameCurrency(Money other) {
+ DomainValidation.requireNonNull(other, "Money operand");
+ if (!currency.equals(other.currency)) {
+ throw new CurrencyMismatchException(
+ "Currencies do not match: %s vs %s"
+ .formatted(currency.getCurrencyCode(), other.currency.getCurrencyCode()));
+ }
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/TransactionId.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/TransactionId.java
new file mode 100644
index 0000000..f9d4389
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/TransactionId.java
@@ -0,0 +1,45 @@
+package dev.amg.payguard.domain.value_object;
+
+import domain.DomainValidation;
+import domain.ValueObject;
+import java.util.UUID;
+
+/**
+ * Immutable, strongly typed identifier for a financial transaction.
+ *
+ * @param value the UUID identity value
+ */
+public record TransactionId(UUID value) implements ValueObject {
+
+ /** Rejects a null identity. */
+ public TransactionId {
+ DomainValidation.requireNonNull(value, "TransactionId value");
+ }
+
+ /** Generates a new random (version 4) UUID identifier. */
+ public static TransactionId generate() {
+ return new TransactionId(UUID.randomUUID());
+ }
+
+ /**
+ * Parses a canonical UUID string; uppercase hex digits are accepted.
+ *
+ * @param value the canonical UUID string
+ * @return the corresponding TransactionId
+ * @throws IllegalArgumentException for malformed or noncanonical UUIDs
+ */
+ public static TransactionId fromString(String value) {
+ DomainValidation.requireNonNull(value, "TransactionId string");
+ UUID parsed = UUID.fromString(value);
+ if (!parsed.toString().equalsIgnoreCase(value)) {
+ throw new IllegalArgumentException("Noncanonical TransactionId UUID: " + value);
+ }
+ return new TransactionId(parsed);
+ }
+
+ /** Compares value objects using their exact record values and types. */
+ @Override
+ public boolean sameValueAs(ValueObject other) {
+ return equals(other);
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/UserId.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/UserId.java
new file mode 100644
index 0000000..9770997
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/UserId.java
@@ -0,0 +1,40 @@
+package dev.amg.payguard.domain.value_object;
+
+import domain.DomainValidation;
+import domain.ValueObject;
+import java.util.UUID;
+
+/**
+ * Immutable, strongly typed identifier for a user in the Identity bounded context.
+ *
+ * @param value the UUID identity value
+ */
+public record UserId(UUID value) implements ValueObject {
+
+ /** Rejects a null identity. */
+ public UserId {
+ DomainValidation.requireNonNull(value, "UserId value");
+ }
+
+ /**
+ * Parses a canonical UUID string; uppercase hex digits are accepted.
+ *
+ * @param value the canonical UUID string
+ * @return the corresponding UserId
+ * @throws IllegalArgumentException for malformed or noncanonical UUIDs
+ */
+ public static UserId fromString(String value) {
+ DomainValidation.requireNonNull(value, "UserId string");
+ UUID parsed = UUID.fromString(value);
+ if (!parsed.toString().equalsIgnoreCase(value)) {
+ throw new IllegalArgumentException("Noncanonical UserId UUID: " + value);
+ }
+ return new UserId(parsed);
+ }
+
+ /** Compares value objects using their exact record values and types. */
+ @Override
+ public boolean sameValueAs(ValueObject other) {
+ return equals(other);
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/WalletId.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/WalletId.java
new file mode 100644
index 0000000..60b97bc
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/WalletId.java
@@ -0,0 +1,45 @@
+package dev.amg.payguard.domain.value_object;
+
+import domain.DomainValidation;
+import domain.ValueObject;
+import java.util.UUID;
+
+/**
+ * Immutable, strongly typed identifier for a wallet aggregate.
+ *
+ * @param value the UUID identity value
+ */
+public record WalletId(UUID value) implements ValueObject {
+
+ /** Rejects a null identity. */
+ public WalletId {
+ DomainValidation.requireNonNull(value, "WalletId value");
+ }
+
+ /** Generates a new random (version 4) UUID identifier. */
+ public static WalletId generate() {
+ return new WalletId(UUID.randomUUID());
+ }
+
+ /**
+ * Parses a canonical UUID string; uppercase hex digits are accepted.
+ *
+ * @param value the canonical UUID string
+ * @return the corresponding WalletId
+ * @throws IllegalArgumentException for malformed or noncanonical UUIDs
+ */
+ public static WalletId fromString(String value) {
+ DomainValidation.requireNonNull(value, "WalletId string");
+ UUID parsed = UUID.fromString(value);
+ if (!parsed.toString().equalsIgnoreCase(value)) {
+ throw new IllegalArgumentException("Noncanonical WalletId UUID: " + value);
+ }
+ return new WalletId(parsed);
+ }
+
+ /** Compares value objects using their exact record values and types. */
+ @Override
+ public boolean sameValueAs(ValueObject other) {
+ return equals(other);
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/annotation/HandleWalletExceptions.java b/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/annotation/HandleWalletExceptions.java
new file mode 100644
index 0000000..77a00bd
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/annotation/HandleWalletExceptions.java
@@ -0,0 +1,10 @@
+package dev.amg.payguard.infrastructure.aop.exception.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Target({ElementType.TYPE, ElementType.METHOD})
+@Retention(RetentionPolicy.RUNTIME)
+public @interface HandleWalletExceptions {}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/annotation/SecureAuditOnFailure.java b/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/annotation/SecureAuditOnFailure.java
new file mode 100644
index 0000000..e4e02f2
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/annotation/SecureAuditOnFailure.java
@@ -0,0 +1,12 @@
+package dev.amg.payguard.infrastructure.aop.exception.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface SecureAuditOnFailure {
+ String action();
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/RestControllerExceptionAspect.java b/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/RestControllerExceptionAspect.java
new file mode 100644
index 0000000..0c7a0d3
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/RestControllerExceptionAspect.java
@@ -0,0 +1,88 @@
+package dev.amg.payguard.infrastructure.aop.exception.aspect;
+
+import dev.amg.payguard.domain.exception.DoubleEntryImbalanceException;
+import dev.amg.payguard.domain.exception.HoldExpiredException;
+import dev.amg.payguard.domain.exception.InsufficientFundException;
+import dev.amg.payguard.domain.exception.InvalidHoldStateException;
+import dev.amg.payguard.domain.exception.InvalidWalletStateException;
+import dev.amg.payguard.domain.exception.LimitExceededException;
+import dev.amg.payguard.domain.exception.TransactionAlreadySettledException;
+import dev.amg.payguard.domain.exception.WalletFrozenException;
+import dev.amg.payguard.infrastructure.aop.exception.model.UnifiedResponse;
+import domain.DomainException;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Pointcut;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.core.annotation.Order;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Component;
+
+@Aspect
+@Component
+@Order(1)
+public class RestControllerExceptionAspect {
+ private static final Logger log = LoggerFactory.getLogger(RestControllerExceptionAspect.class);
+
+ @Pointcut(
+ "@annotation(dev.amg.payguard.infrastructure.aop.exception.annotation.HandleWalletExceptions)"
+ + " || @within(dev.amg.payguard.infrastructure.aop.exception.annotation.HandleWalletExceptions)"
+ + " || within(@org.springframework.web.bind.annotation.RestController *)")
+ public void restControllerLayer() {}
+
+ @Around("restControllerLayer()")
+ public Object interceptAndHandleExceptions(ProceedingJoinPoint joinPoint) throws Throwable {
+ try {
+ return joinPoint.proceed();
+ } catch (DomainException ex) {
+ return mapDomainException(ex, joinPoint);
+ } catch (IllegalArgumentException ex) {
+ if (log.isWarnEnabled()) {
+ log.warn("Invalid argument at [{}]: {}", joinPoint.getSignature(), ex.getMessage());
+ }
+ return build(HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", ex.getMessage());
+ } catch (Exception ex) {
+ if (log.isErrorEnabled()) {
+ log.error("Unhandled exception at [{}]", joinPoint.getSignature(), ex);
+ }
+ return build(
+ HttpStatus.INTERNAL_SERVER_ERROR,
+ "INTERNAL_ERROR",
+ "An unexpected system error occurred.");
+ }
+ }
+
+ private ResponseEntity> mapDomainException(
+ DomainException ex, ProceedingJoinPoint joinPoint) {
+ if (ex instanceof DoubleEntryImbalanceException) {
+ if (log.isErrorEnabled()) {
+ log.error("Ledger imbalance at [{}]: {}", joinPoint.getSignature(), ex.getMessage());
+ }
+ return build(
+ HttpStatus.INTERNAL_SERVER_ERROR,
+ "LEDGER_CORRUPTED",
+ "Transaction could not be balanced.");
+ }
+ if (ex instanceof WalletFrozenException) {
+ return build(HttpStatus.FORBIDDEN, ex.getErrorCode(), ex.getMessage());
+ }
+ if (ex instanceof InsufficientFundException
+ || ex instanceof LimitExceededException
+ || ex instanceof HoldExpiredException) {
+ return build(HttpStatus.UNPROCESSABLE_CONTENT, ex.getErrorCode(), ex.getMessage());
+ }
+ if (ex instanceof TransactionAlreadySettledException
+ || ex instanceof InvalidHoldStateException
+ || ex instanceof InvalidWalletStateException) {
+ return build(HttpStatus.CONFLICT, ex.getErrorCode(), ex.getMessage());
+ }
+ return build(HttpStatus.BAD_REQUEST, ex.getErrorCode(), ex.getMessage());
+ }
+
+ private ResponseEntity> build(HttpStatus status, String code, String message) {
+ return ResponseEntity.status(status).body(UnifiedResponse.fail(code, message));
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/ServiceExceptionEnricherAspect.java b/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/ServiceExceptionEnricherAspect.java
new file mode 100644
index 0000000..7ff0b1d
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/ServiceExceptionEnricherAspect.java
@@ -0,0 +1,59 @@
+package dev.amg.payguard.infrastructure.aop.exception.aspect;
+
+import domain.DomainException;
+import java.util.HashMap;
+import java.util.Map;
+import org.aspectj.lang.JoinPoint;
+import org.aspectj.lang.annotation.AfterThrowing;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.slf4j.MDC;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+
+@Aspect
+@Component
+@Order(2)
+public class ServiceExceptionEnricherAspect {
+
+ private static final Logger log = LoggerFactory.getLogger(ServiceExceptionEnricherAspect.class);
+
+ @AfterThrowing(pointcut = "execution(* dev.amg.payguard.application..*(..))", throwing = "ex")
+ public void enrichAndLogServiceException(JoinPoint joinPoint, DomainException ex) {
+ MethodSignature signature = (MethodSignature) joinPoint.getSignature();
+ String[] paramNames = signature.getParameterNames();
+ Object[] args = joinPoint.getArgs();
+
+ Map context = new HashMap<>();
+ if (paramNames != null && args != null) {
+ int parameterCount = Math.min(paramNames.length, args.length);
+ for (int i = 0; i < parameterCount; i++) {
+ String parameterName = paramNames[i];
+ String normalizedName = parameterName.toLowerCase(java.util.Locale.ROOT);
+ if (!normalizedName.contains("pin")
+ && !normalizedName.contains("password")
+ && !normalizedName.contains("token")
+ && !normalizedName.contains("secret")) {
+ context.put(parameterName, args[i]);
+ }
+ }
+ }
+
+ MDC.put("failed_method", signature.getName());
+ MDC.put("exception_type", ex.getClass().getSimpleName());
+ try {
+ if (log.isWarnEnabled()) {
+ log.warn(
+ "Service execution failed: method [{}], error: '{}', context: {}",
+ signature,
+ ex.getMessage(),
+ context);
+ }
+ } finally {
+ MDC.remove("failed_method");
+ MDC.remove("exception_type");
+ }
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/SuspiciousActivityTrackerAspect.java b/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/SuspiciousActivityTrackerAspect.java
new file mode 100644
index 0000000..a4afbfc
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/SuspiciousActivityTrackerAspect.java
@@ -0,0 +1,40 @@
+package dev.amg.payguard.infrastructure.aop.exception.aspect;
+
+import dev.amg.payguard.domain.exception.InsufficientFundException;
+import dev.amg.payguard.domain.exception.WalletFrozenException;
+import dev.amg.payguard.infrastructure.aop.exception.annotation.SecureAuditOnFailure;
+import org.aspectj.lang.JoinPoint;
+import org.aspectj.lang.annotation.AfterThrowing;
+import org.aspectj.lang.annotation.Aspect;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+@Aspect
+@Component
+public class SuspiciousActivityTrackerAspect {
+
+ private static final Logger securityLog = LoggerFactory.getLogger("SECURITY_AUDIT");
+
+ @AfterThrowing(pointcut = "@annotation(secureAudit)", throwing = "ex")
+ public void auditSuspiciousFailure(
+ JoinPoint joinPoint, SecureAuditOnFailure secureAudit, Exception ex) {
+ String action = secureAudit.action();
+
+ if (ex instanceof InsufficientFundException || ex instanceof WalletFrozenException) {
+ if (securityLog.isErrorEnabled()) {
+ securityLog.error(
+ "Fraud alert: suspicious failure during action [{}]. Method: [{}]. Reason: {}",
+ action,
+ joinPoint.getSignature(),
+ ex.getMessage());
+ }
+
+ triggerSecurityCountermeasures();
+ }
+ }
+
+ private void triggerSecurityCountermeasures() {
+ // Hook for a future IP blocklist or SOC notification integration.
+ }
+}
diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/model/UnifiedResponse.java b/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/model/UnifiedResponse.java
new file mode 100644
index 0000000..e16508c
--- /dev/null
+++ b/payguard-wallet/src/main/java/dev/amg/payguard/infrastructure/aop/exception/model/UnifiedResponse.java
@@ -0,0 +1,14 @@
+package dev.amg.payguard.infrastructure.aop.exception.model;
+
+import java.time.Instant;
+
+public record UnifiedResponse(
+ boolean success, String errorCode, String message, T data, Instant timestamp) {
+ public static UnifiedResponse ok(T data) {
+ return new UnifiedResponse<>(true, null, "Operation successful", data, Instant.now());
+ }
+
+ public static UnifiedResponse fail(String errorCode, String message) {
+ return new UnifiedResponse<>(false, errorCode, message, null, Instant.now());
+ }
+}
diff --git a/payguard-wallet/src/main/resources/application.yaml b/payguard-wallet/src/main/resources/application.yaml
new file mode 100644
index 0000000..f0db46a
--- /dev/null
+++ b/payguard-wallet/src/main/resources/application.yaml
@@ -0,0 +1,30 @@
+spring:
+ application:
+ name: wallet-service
+ datasource:
+ url: ${SPRING_DATASOURCE_URL}
+ driver-class-name: org.postgresql.Driver
+ jooq:
+ sql-dialect: POSTGRES
+
+payguard:
+ vault:
+ enabled: true
+ address: ${VAULT_ADDR}
+ ca-file: ${VAULT_CA_FILE}
+ role-id-file: ${VAULT_ROLE_ID_FILE}
+ secret-id-file: ${VAULT_SECRET_ID_FILE}
+ database-role: ${PAYGUARD_ENVIRONMENT:local}-wallet-runtime
+
+management:
+ endpoint:
+ health:
+ probes:
+ enabled: true
+ group:
+ readiness:
+ include: readinessState,db,vault
+ endpoints:
+ web:
+ exposure:
+ include: health,info
diff --git a/payguard-wallet/src/main/resources/banner.txt b/payguard-wallet/src/main/resources/banner.txt
new file mode 100644
index 0000000..978a3d7
--- /dev/null
+++ b/payguard-wallet/src/main/resources/banner.txt
@@ -0,0 +1,7 @@
+____ __ ____ ___ __ __ _______ .___________.
+\ \ / \ / / / \ | | | | | ____|| |
+ \ \/ \/ / / ^ \ | | | | | |__ `---| |----`
+ \ / / /_\ \ | | | | | __| | |
+ \ /\ / / _____ \ | `----.| `----.| |____ | |
+ \__/ \__/ /__/ \__\ |_______||_______||_______| |__|
+
diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/WalletArchitectureTests.java b/payguard-wallet/src/test/java/dev/amg/payguard/WalletArchitectureTests.java
new file mode 100644
index 0000000..90a802b
--- /dev/null
+++ b/payguard-wallet/src/test/java/dev/amg/payguard/WalletArchitectureTests.java
@@ -0,0 +1,13 @@
+package dev.amg.payguard;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.modulith.core.ApplicationModules;
+
+class WalletArchitectureTests {
+ private final ApplicationModules modules = ApplicationModules.of(WalletApp.class);
+
+ @Test
+ void verifiesApplicationModuleBoundaries() {
+ modules.verify();
+ }
+}
diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/DomainFixtures.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/DomainFixtures.java
new file mode 100644
index 0000000..10a4707
--- /dev/null
+++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/DomainFixtures.java
@@ -0,0 +1,97 @@
+package dev.amg.payguard.domain.model;
+
+import dev.amg.payguard.domain.enums.*;
+import dev.amg.payguard.domain.value_object.*;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Currency;
+import java.util.List;
+import java.util.UUID;
+
+/** Deterministic, test-only domain fixtures; no system clock, Spring or database. */
+final class DomainFixtures {
+ static final Currency CHF = Currency.getInstance("CHF");
+ static final Currency USD = Currency.getInstance("USD");
+ static final Instant CREATED = Instant.parse("2026-01-01T00:00:00Z");
+ static final Instant NOW = CREATED.plusSeconds(10);
+ static final Instant DEADLINE = CREATED.plusSeconds(60);
+ static final WalletId WALLET_ID = new WalletId(uuid(1));
+ static final AccountId ACCOUNT_ID = new AccountId(uuid(2));
+ static final AccountId DESTINATION_ID = new AccountId(uuid(3));
+ static final AccountId FEE_ACCOUNT_ID = new AccountId(uuid(4));
+ static final UserId USER_ID = new UserId(uuid(5));
+ static final HoldId HOLD_ID = new HoldId(uuid(6));
+ static final TransactionId TRANSACTION_ID = new TransactionId(uuid(7));
+
+ private DomainFixtures() {}
+
+ /** Creates reproducible, distinct identifiers without relying on random collisions. */
+ static UUID uuid(long value) { return new UUID(0L, value); }
+ /** Creates exact CHF money. */
+ static Money money(String value) { return Money.of(value, CHF); }
+ /** Restores a funded active wallet with a known loaded version and posting high-water mark. */
+ static Wallet wallet() { return wallet(WalletStatus.ACTIVE, "100.00", "0.00"); }
+ /** Restores a requested wallet state for focused invariant tests. */
+ static Wallet wallet(WalletStatus status, String book, String reserved) {
+ return Wallet.restore(WALLET_ID, ACCOUNT_ID, USER_ID, CHF, status,
+ money(book), money(reserved), 7L, 9L, CREATED, CREATED);
+ }
+ /** Creates a positive full-amount reservation with a deterministic deadline. */
+ static Hold hold() {
+ return Hold.createNew(HOLD_ID, WALLET_ID, money("30.00"), "Purchase", "order-1", DEADLINE, CREATED);
+ }
+ /** Creates an initiated transfer with an additional source-paid fee. */
+ static Transaction transaction(String principal, String fee) {
+ return Transaction.initiate(TRANSACTION_ID, IdempotencyKey.of("request-1"),
+ TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID,
+ money(principal), money(fee), CREATED);
+ }
+ /** Creates one independently identified posting. */
+ static LedgerEntry entry(long entryId, TransactionId transactionId, AccountId accountId,
+ Money amount, LedgerEntryType type, long sequence, Instant timestamp) {
+ return new LedgerEntry(new LedgerEntryId(uuid(entryId)), transactionId, accountId,
+ amount, type, sequence, timestamp);
+ }
+ /** Creates a posting for the standard wallet and transaction. */
+ static LedgerEntry walletEntry(LedgerEntryType type, String amount, long sequence) {
+ return entry(100 + sequence, TRANSACTION_ID, ACCOUNT_ID, money(amount), type, sequence, NOW);
+ }
+ /** Creates a balanced batch obeying the principal/fee allocation policy. */
+ static List postings(Transaction transaction) {
+ List entries = new ArrayList<>();
+ Instant timestamp = transaction.getCreatedAt().plusSeconds(1);
+ entries.add(entry(100, transaction.getId(), transaction.getSourceAccountId(),
+ transaction.getTotalAmount().add(transaction.getFeeAmount()), LedgerEntryType.DEBIT, 10, timestamp));
+ entries.add(entry(101, transaction.getId(), transaction.getDestinationAccountId(),
+ transaction.getTotalAmount(), LedgerEntryType.CREDIT, 10, timestamp));
+ if (!transaction.getFeeAmount().isZero()) {
+ entries.add(entry(102, transaction.getId(), FEE_ACCOUNT_ID, transaction.getFeeAmount(),
+ LedgerEntryType.CREDIT, 10, timestamp));
+ }
+ return entries;
+ }
+ /** Creates and settles a zero-fee original transaction for reversal tests. */
+ static Transaction settledTransaction() {
+ Transaction transaction = transaction("25.00", "0.00");
+ transaction.markSettled(postings(transaction), NOW);
+ return transaction;
+ }
+ /** Creates a distinct, fully settled compensating refund. */
+ static Transaction refund(Transaction original) {
+ Transaction refund = Transaction.initiate(new TransactionId(uuid(20)),
+ IdempotencyKey.of("refund-1"), TransactionType.REFUND,
+ original.getDestinationAccountId(), original.getSourceAccountId(),
+ original.getTotalAmount(), money("0.00"), NOW.plusSeconds(1));
+ refund.markSettled(postings(refund), NOW.plusSeconds(3));
+ return refund;
+ }
+ /** Captures all mutable wallet state relevant to rejection atomicity. */
+ static WalletSnapshot snapshot(Wallet wallet) {
+ return new WalletSnapshot(wallet.getStatus(), wallet.getBookBalance(),
+ wallet.getReservedBalance(), wallet.getUpdatedAt(), wallet.getVersion(),
+ wallet.getLastAppliedSequenceNumber());
+ }
+ /** Immutable test snapshot for exact before/after comparison. */
+ record WalletSnapshot(WalletStatus status, Money book, Money reserved, Instant updated,
+ long version, long sequence) {}
+}
diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/HoldTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/HoldTest.java
new file mode 100644
index 0000000..4d492a8
--- /dev/null
+++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/HoldTest.java
@@ -0,0 +1,283 @@
+package dev.amg.payguard.domain.model;
+
+import static dev.amg.payguard.domain.model.DomainFixtures.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import dev.amg.payguard.domain.enums.*;
+import dev.amg.payguard.domain.event.*;
+import dev.amg.payguard.domain.exception.*;
+import dev.amg.payguard.domain.value_object.*;
+import domain.InvalidDomainArgumentException;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+/** Tests expiry boundaries, terminal states, event contents and restore invariants. */
+class HoldTest {
+
+ /** Creates a pending hold without terminal metadata or emitted transition events. */
+ @Test
+ void shouldCreatePendingPositiveHold() {
+ Hold hold = hold();
+ assertEquals(HOLD_ID, hold.getId());
+ assertEquals(WALLET_ID, hold.getWalletId());
+ assertEquals(money("30"), hold.getAmount());
+ assertEquals("Purchase", hold.getReason());
+ assertEquals("order-1", hold.getReferenceId());
+ assertEquals(CREATED, hold.getCreatedAt());
+ assertEquals(DEADLINE, hold.getExpiresAt());
+ assertEquals(HoldStatus.PENDING, hold.getStatus());
+ assertTrue(hold.getResolvedAt().isEmpty());
+ assertTrue(hold.pullDomainEvents().isEmpty());
+ }
+
+ /** Regression: capture validity excludes the deadline itself. */
+ @Test
+ void shouldTreatExactDeadlineAsExpired() {
+ Hold hold = hold();
+ assertFalse(hold.isExpired(DEADLINE.minusNanos(1)));
+ assertTrue(hold.isExpired(DEADLINE));
+ assertTrue(hold.isExpired(DEADLINE.plusNanos(1)));
+ }
+
+ /** Captures at the last valid instant and emits the matching event. */
+ @Test
+ void shouldCaptureImmediatelyBeforeExpiry() {
+ Hold hold = hold();
+ Instant captureTime = DEADLINE.minusNanos(1);
+ hold.capture(captureTime);
+ assertEquals(HoldStatus.CAPTURED, hold.getStatus());
+ assertEquals(captureTime, hold.getResolvedAt().orElseThrow());
+ var event = assertInstanceOf(FundsHoldCapturedEvent.class, hold.pullDomainEvents().getFirst());
+ assertEquals(HOLD_ID, event.holdId());
+ assertEquals(WALLET_ID, event.walletId());
+ assertEquals(money("30"), event.amount());
+ assertEquals(captureTime, event.occurredOn());
+ }
+
+ /** Regression: rejection must neither expire the hold nor emit an expiry event. */
+ @Test
+ void shouldRejectExpiredCaptureWithoutMutation() {
+ Hold hold = hold();
+ assertThrows(HoldExpiredException.class, () -> hold.capture(DEADLINE));
+ assertEquals(HoldStatus.PENDING, hold.getStatus());
+ assertTrue(hold.getResolvedAt().isEmpty());
+ assertTrue(hold.pullDomainEvents().isEmpty());
+ }
+
+ /** An explicit expiry succeeds at the exact boundary. */
+ @Test
+ void shouldExpireAtExactDeadline() {
+ Hold hold = hold();
+ hold.expire(DEADLINE);
+ assertEquals(HoldStatus.EXPIRED, hold.getStatus());
+ assertEquals(DEADLINE, hold.getResolvedAt().orElseThrow());
+ assertInstanceOf(FundsHoldExpiredEvent.class, hold.pullDomainEvents().getFirst());
+ }
+
+ /** Premature expiry cannot change state or events. */
+ @Test
+ void shouldRejectEarlyExpiryWithoutMutation() {
+ Hold hold = hold();
+ assertThrows(InvalidHoldStateException.class, () -> hold.expire(DEADLINE.minusNanos(1)));
+ assertEquals(HoldStatus.PENDING, hold.getStatus());
+ assertTrue(hold.getResolvedAt().isEmpty());
+ assertTrue(hold.pullDomainEvents().isEmpty());
+ }
+
+ /** The explicit cancellation policy allows releasing an overdue pending hold. */
+ @Test
+ void shouldReleaseOverduePendingHold() {
+ Hold hold = hold();
+ hold.release(DEADLINE.plusSeconds(1));
+ assertEquals(HoldStatus.RELEASED, hold.getStatus());
+ assertInstanceOf(FundsHoldReleasedEvent.class, hold.pullDomainEvents().getFirst());
+ }
+
+ /** Zero reservations are not valid domain operations. */
+ @Test
+ void shouldRejectZeroHold() {
+ assertThrows(InvalidHoldStateException.class, () -> Hold.createNew(HOLD_ID, WALLET_ID,
+ money("0"), "reason", "reference", DEADLINE, CREATED));
+ }
+
+ /** Negative reservation amounts are rejected. */
+ @Test
+ void shouldRejectNegativeHold() {
+ assertThrows(NegativeAmountNotAllowedException.class, () -> Hold.createNew(HOLD_ID, WALLET_ID,
+ money("-1"), "reason", "reference", DEADLINE, CREATED));
+ }
+
+ /** A newly created hold must have a nonempty validity interval. */
+ @Test
+ void shouldRejectDeadlineEqualToCreation() {
+ assertThrows(InvalidHoldStateException.class, () -> Hold.createNew(HOLD_ID, WALLET_ID,
+ money("1"), "reason", "reference", CREATED, CREATED));
+ }
+
+ /** Rejects a reversed validity interval. */
+ @Test
+ void shouldRejectDeadlineBeforeCreation() {
+ assertThrows(InvalidHoldStateException.class, () -> Hold.createNew(HOLD_ID, WALLET_ID,
+ money("1"), "reason", "reference", CREATED.minusNanos(1), CREATED));
+ }
+
+ /** Restores valid terminal state without replaying a captured event. */
+ @Test
+ void shouldRestoreCapturedWithoutEvents() {
+ Hold hold = Hold.restore(HOLD_ID, WALLET_ID, money("30"), "reason", "reference",
+ DEADLINE, CREATED, HoldStatus.CAPTURED, NOW);
+ assertEquals(HoldStatus.CAPTURED, hold.getStatus());
+ assertTrue(hold.pullDomainEvents().isEmpty());
+ }
+
+ /** Null time rejection leaves the aggregate unchanged. */
+ @Test
+ void shouldRejectNullTimeForCapture() {
+ Hold hold = hold();
+ assertThrows(InvalidDomainArgumentException.class, () -> hold.capture(null));
+ assertEquals(HoldStatus.PENDING, hold.getStatus());
+ assertTrue(hold.getResolvedAt().isEmpty());
+ assertTrue(hold.pullDomainEvents().isEmpty());
+ }
+
+ /** Rejects transitions predating the hold itself. */
+ @Test
+ void shouldRejectPreCreationTimeForCapture() {
+ Hold hold = hold();
+ assertThrows(InvalidHoldStateException.class, () -> hold.capture(CREATED.minusNanos(1)));
+ assertEquals(HoldStatus.PENDING, hold.getStatus());
+ }
+
+ /** Null time rejection leaves the aggregate unchanged. */
+ @Test
+ void shouldRejectNullTimeForRelease() {
+ Hold hold = hold();
+ assertThrows(InvalidDomainArgumentException.class, () -> hold.release(null));
+ assertEquals(HoldStatus.PENDING, hold.getStatus());
+ assertTrue(hold.getResolvedAt().isEmpty());
+ assertTrue(hold.pullDomainEvents().isEmpty());
+ }
+
+ /** Rejects transitions predating the hold itself. */
+ @Test
+ void shouldRejectPreCreationTimeForRelease() {
+ Hold hold = hold();
+ assertThrows(InvalidHoldStateException.class, () -> hold.release(CREATED.minusNanos(1)));
+ assertEquals(HoldStatus.PENDING, hold.getStatus());
+ }
+
+ /** Null time rejection leaves the aggregate unchanged. */
+ @Test
+ void shouldRejectNullTimeForExpire() {
+ Hold hold = hold();
+ assertThrows(InvalidDomainArgumentException.class, () -> hold.expire(null));
+ assertEquals(HoldStatus.PENDING, hold.getStatus());
+ assertTrue(hold.getResolvedAt().isEmpty());
+ assertTrue(hold.pullDomainEvents().isEmpty());
+ }
+
+ /** Rejects transitions predating the hold itself. */
+ @Test
+ void shouldRejectPreCreationTimeForExpire() {
+ Hold hold = hold();
+ assertThrows(InvalidHoldStateException.class, () -> hold.expire(CREATED.minusNanos(1)));
+ assertEquals(HoldStatus.PENDING, hold.getStatus());
+ }
+
+ /** Terminal hold states cannot transition a second time. */
+ @Test
+ void shouldRejectTransitionsAfterCaptured() {
+ Hold hold = hold();
+ hold.capture(NOW);
+ hold.clearDomainEvents();
+ assertThrows(InvalidHoldStateException.class, () -> hold.capture(DEADLINE));
+ assertThrows(InvalidHoldStateException.class, () -> hold.release(DEADLINE));
+ assertThrows(InvalidHoldStateException.class, () -> hold.expire(DEADLINE));
+ assertEquals(HoldStatus.CAPTURED, hold.getStatus());
+ assertTrue(hold.pullDomainEvents().isEmpty());
+ }
+
+ /** Terminal hold states cannot transition a second time. */
+ @Test
+ void shouldRejectTransitionsAfterReleased() {
+ Hold hold = hold();
+ hold.release(NOW);
+ hold.clearDomainEvents();
+ assertThrows(InvalidHoldStateException.class, () -> hold.capture(DEADLINE));
+ assertThrows(InvalidHoldStateException.class, () -> hold.release(DEADLINE));
+ assertThrows(InvalidHoldStateException.class, () -> hold.expire(DEADLINE));
+ assertEquals(HoldStatus.RELEASED, hold.getStatus());
+ assertTrue(hold.pullDomainEvents().isEmpty());
+ }
+
+ /** Terminal hold states cannot transition a second time. */
+ @Test
+ void shouldRejectTransitionsAfterExpired() {
+ Hold hold = hold();
+ hold.expire(DEADLINE);
+ hold.clearDomainEvents();
+ assertThrows(InvalidHoldStateException.class, () -> hold.capture(DEADLINE));
+ assertThrows(InvalidHoldStateException.class, () -> hold.release(DEADLINE));
+ assertThrows(InvalidHoldStateException.class, () -> hold.expire(DEADLINE));
+ assertEquals(HoldStatus.EXPIRED, hold.getStatus());
+ assertTrue(hold.pullDomainEvents().isEmpty());
+ }
+
+ /** Restoration enforces the same lifecycle invariants as normal commands. */
+ @Test
+ void shouldRejectPendingWithResolution() {
+ assertThrows(InvalidHoldStateException.class, () -> Hold.restore(HOLD_ID, WALLET_ID,
+ money("30"), "reason", "reference", DEADLINE, CREATED, HoldStatus.PENDING, NOW));
+ }
+
+ /** Restoration enforces the same lifecycle invariants as normal commands. */
+ @Test
+ void shouldRejectTerminalWithoutResolution() {
+ assertThrows(InvalidHoldStateException.class, () -> Hold.restore(HOLD_ID, WALLET_ID,
+ money("30"), "reason", "reference", DEADLINE, CREATED, HoldStatus.CAPTURED, null));
+ }
+
+ /** Restoration enforces the same lifecycle invariants as normal commands. */
+ @Test
+ void shouldRejectLateCapturedSnapshot() {
+ assertThrows(InvalidHoldStateException.class, () -> Hold.restore(HOLD_ID, WALLET_ID,
+ money("30"), "reason", "reference", DEADLINE, CREATED, HoldStatus.CAPTURED, DEADLINE));
+ }
+
+ /** Restoration enforces the same lifecycle invariants as normal commands. */
+ @Test
+ void shouldRejectPrematureExpiredSnapshot() {
+ assertThrows(InvalidHoldStateException.class, () -> Hold.restore(HOLD_ID, WALLET_ID,
+ money("30"), "reason", "reference", DEADLINE, CREATED, HoldStatus.EXPIRED, NOW));
+ }
+
+ /** Restoration enforces the same lifecycle invariants as normal commands. */
+ @Test
+ void shouldRejectPreCreationResolution() {
+ assertThrows(InvalidHoldStateException.class, () -> Hold.restore(HOLD_ID, WALLET_ID,
+ money("30"), "reason", "reference", DEADLINE, CREATED, HoldStatus.RELEASED, CREATED.minusNanos(1)));
+ }
+
+ /** Rejects missing descriptive metadata before construction. */
+ @Test
+ void shouldRejectBlankReason() {
+ assertThrows(InvalidDomainArgumentException.class, () -> Hold.createNew(HOLD_ID, WALLET_ID,
+ money("1"), " ", "ref", DEADLINE, CREATED));
+ }
+
+ /** Rejects missing descriptive metadata before construction. */
+ @Test
+ void shouldRejectBlankReference() {
+ assertThrows(InvalidDomainArgumentException.class, () -> Hold.createNew(HOLD_ID, WALLET_ID,
+ money("1"), "reason", " ", DEADLINE, CREATED));
+ }
+
+ /** Rejects missing descriptive metadata before construction. */
+ @Test
+ void shouldRejectNullReason() {
+ assertThrows(InvalidDomainArgumentException.class, () -> Hold.createNew(HOLD_ID, WALLET_ID,
+ money("1"), null, "ref", DEADLINE, CREATED));
+ }
+}
diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/LedgerEntryTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/LedgerEntryTest.java
new file mode 100644
index 0000000..c0a5522
--- /dev/null
+++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/LedgerEntryTest.java
@@ -0,0 +1,103 @@
+package dev.amg.payguard.domain.model;
+
+import static dev.amg.payguard.domain.model.DomainFixtures.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import dev.amg.payguard.domain.enums.*;
+import dev.amg.payguard.domain.event.*;
+import dev.amg.payguard.domain.exception.*;
+import dev.amg.payguard.domain.value_object.*;
+import domain.InvalidDomainArgumentException;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+/** Tests posting-local invariants and explicit entity equality. */
+class LedgerEntryTest {
+
+ /** Preserves all entry-local posting data without transformation. */
+ @Test
+ void shouldPreserveAllPostingComponents() {
+ LedgerEntry entry = walletEntry(LedgerEntryType.DEBIT, "10.00", 10);
+ assertAll(() -> assertEquals(TRANSACTION_ID, entry.transactionId()),
+ () -> assertEquals(ACCOUNT_ID, entry.accountId()),
+ () -> assertEquals(money("10.00"), entry.amount()),
+ () -> assertEquals(LedgerEntryType.DEBIT, entry.entryType()),
+ () -> assertEquals(10L, entry.sequenceNumber()),
+ () -> assertEquals(NOW, entry.timestamp()));
+ }
+
+ /** Rejects a null Id before a valid entry can exist. */
+ @Test
+ void shouldRejectNullId() {
+ assertThrows(InvalidDomainArgumentException.class, () -> new LedgerEntry(null, TRANSACTION_ID, ACCOUNT_ID, money("1"), LedgerEntryType.DEBIT, 0, NOW));
+ }
+
+ /** Rejects a null TransactionId before a valid entry can exist. */
+ @Test
+ void shouldRejectNullTransactionId() {
+ assertThrows(InvalidDomainArgumentException.class, () -> new LedgerEntry(new LedgerEntryId(uuid(100)), null, ACCOUNT_ID, money("1"), LedgerEntryType.DEBIT, 0, NOW));
+ }
+
+ /** Rejects a null AccountId before a valid entry can exist. */
+ @Test
+ void shouldRejectNullAccountId() {
+ assertThrows(InvalidDomainArgumentException.class, () -> new LedgerEntry(new LedgerEntryId(uuid(100)), TRANSACTION_ID, null, money("1"), LedgerEntryType.DEBIT, 0, NOW));
+ }
+
+ /** Rejects a null Amount before a valid entry can exist. */
+ @Test
+ void shouldRejectNullAmount() {
+ assertThrows(InvalidDomainArgumentException.class, () -> new LedgerEntry(new LedgerEntryId(uuid(100)), TRANSACTION_ID, ACCOUNT_ID, null, LedgerEntryType.DEBIT, 0, NOW));
+ }
+
+ /** Rejects a null EntryType before a valid entry can exist. */
+ @Test
+ void shouldRejectNullEntryType() {
+ assertThrows(InvalidDomainArgumentException.class, () -> new LedgerEntry(new LedgerEntryId(uuid(100)), TRANSACTION_ID, ACCOUNT_ID, money("1"), null, 0, NOW));
+ }
+
+ /** Rejects a null Timestamp before a valid entry can exist. */
+ @Test
+ void shouldRejectNullTimestamp() {
+ assertThrows(InvalidDomainArgumentException.class, () -> new LedgerEntry(new LedgerEntryId(uuid(100)), TRANSACTION_ID, ACCOUNT_ID, money("1"), LedgerEntryType.DEBIT, 0, null));
+ }
+
+ /** Rejects zero-magnitude entries rather than posting meaningless ledger lines. */
+ @Test
+ void shouldRejectZeroPosting() {
+ assertThrows(InvalidWalletStateException.class, () -> walletEntry(LedgerEntryType.DEBIT, "0", 10));
+ }
+
+ /** Rejects signed amounts because direction belongs to the entry type. */
+ @Test
+ void shouldRejectNegativePosting() {
+ assertThrows(NegativeAmountNotAllowedException.class, () -> walletEntry(LedgerEntryType.DEBIT, "-1", 10));
+ }
+
+ /** Rejects invalid account sequence values. */
+ @Test
+ void shouldRejectNegativeSequence() {
+ assertThrows(InvalidWalletStateException.class, () -> walletEntry(LedgerEntryType.CREDIT, "1", -1));
+ }
+
+ /** Accepts both ends of the non-negative sequence domain. */
+ @Test
+ void shouldAcceptZeroAndMaximumSequence() {
+ assertEquals(0L, walletEntry(LedgerEntryType.DEBIT, "1", 0).sequenceNumber());
+ assertEquals(Long.MAX_VALUE, walletEntry(LedgerEntryType.DEBIT, "1", Long.MAX_VALUE).sequenceNumber());
+ }
+
+ /** Preserves the existing entity-by-id equality contract, not content equivalence. */
+ @Test
+ void shouldCompareByIdentity() {
+ LedgerEntry first = walletEntry(LedgerEntryType.DEBIT, "1", 10);
+ LedgerEntry second = new LedgerEntry(first.id(), TRANSACTION_ID, ACCOUNT_ID,
+ money("2"), LedgerEntryType.CREDIT, 11, NOW);
+ assertEquals(first, second);
+ assertEquals(first.hashCode(), second.hashCode());
+ assertNotEquals(first, walletEntry(LedgerEntryType.DEBIT, "1", 11));
+ assertNotEquals(first, null);
+ }
+}
diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/TransactionTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/TransactionTest.java
new file mode 100644
index 0000000..1e49f15
--- /dev/null
+++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/TransactionTest.java
@@ -0,0 +1,486 @@
+package dev.amg.payguard.domain.model;
+
+import static dev.amg.payguard.domain.model.DomainFixtures.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import dev.amg.payguard.domain.enums.*;
+import dev.amg.payguard.domain.event.*;
+import dev.amg.payguard.domain.exception.*;
+import dev.amg.payguard.domain.value_object.*;
+import domain.InvalidDomainArgumentException;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+/** Tests exact posting economics, terminal states, reversals and rejection atomicity. */
+class TransactionTest {
+
+ /** Creates an open transfer with no settlement or failure metadata. */
+ @Test
+ void shouldInitiateConsistentTransaction() {
+ Transaction transaction = transaction("100", "2");
+ assertEquals(TransactionStatus.INITIATED, transaction.getStatus());
+ assertEquals(TRANSACTION_ID, transaction.getId());
+ assertEquals(ACCOUNT_ID, transaction.getSourceAccountId());
+ assertEquals(DESTINATION_ID, transaction.getDestinationAccountId());
+ assertEquals(money("100"), transaction.getTotalAmount());
+ assertEquals(money("2"), transaction.getFeeAmount());
+ assertTrue(transaction.getFailureReason().isEmpty());
+ assertTrue(transaction.getSettledAt().isEmpty());
+ assertTrue(transaction.getReversalTransactionId().isEmpty());
+ assertTrue(transaction.getReversedAt().isEmpty());
+ }
+
+ /** Settles a balanced principal-only posting. */
+ @Test
+ void shouldSettleBalancedPosting() {
+ Transaction transaction = transaction("100", "0");
+ transaction.markSettled(postings(transaction), NOW);
+ assertEquals(TransactionStatus.SUCCESSFUL, transaction.getStatus());
+ assertEquals(NOW, transaction.getSettledAt().orElseThrow());
+ }
+
+ /** Enforces debit principal-plus-fee and separate principal/fee credits. */
+ @Test
+ void shouldSettlePrincipalAndAdditionalFee() {
+ Transaction transaction = transaction("100", "2");
+ transaction.markSettled(postings(transaction), NOW);
+ assertEquals(TransactionStatus.SUCCESSFUL, transaction.getStatus());
+ }
+
+ /** Pending settlement is a permitted open-state transition. */
+ @Test
+ void shouldSettleFromPending() {
+ Transaction transaction = transaction("100", "0");
+ transaction.markPendingSettlement();
+ assertEquals(TransactionStatus.PENDING_SETTLEMENT, transaction.getStatus());
+ transaction.markSettled(postings(transaction), NOW);
+ assertEquals(TransactionStatus.SUCCESSFUL, transaction.getStatus());
+ }
+
+ /** Does not silently repeat lifecycle transitions. */
+ @Test
+ void shouldRejectRepeatedPendingTransition() {
+ Transaction transaction = transaction("100", "0");
+ transaction.markPendingSettlement();
+ assertThrows(InvalidWalletStateException.class, transaction::markPendingSettlement);
+ }
+
+ /** Rejects empty money movements. */
+ @Test
+ void shouldRejectZeroPrincipal() {
+ assertThrows(InvalidWalletStateException.class, () -> transaction("0", "0"));
+ }
+
+ /** Rejects negative transfer magnitudes. */
+ @Test
+ void shouldRejectNegativePrincipal() {
+ assertThrows(NegativeAmountNotAllowedException.class, () -> transaction("-1", "0"));
+ }
+
+ /** Fees cannot be negative adjustments. */
+ @Test
+ void shouldRejectNegativeFee() {
+ assertThrows(NegativeAmountNotAllowedException.class, () -> transaction("1", "-1"));
+ }
+
+ /** Even a zero fee must carry the principal currency. */
+ @Test
+ void shouldRejectMixedFeeCurrency() {
+ assertThrows(CurrencyMismatchException.class, () -> Transaction.initiate(TRANSACTION_ID,
+ IdempotencyKey.of("key"), TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID,
+ money("1"), Money.zero(USD), CREATED));
+ }
+
+ /** Source and destination are distinct under the selected transfer contract. */
+ @Test
+ void shouldRejectSelfTransfer() {
+ assertThrows(InvalidWalletStateException.class, () -> Transaction.initiate(TRANSACTION_ID,
+ IdempotencyKey.of("key"), TransactionType.P2P_TRANSFER, ACCOUNT_ID, ACCOUNT_ID,
+ money("1"), money("0"), CREATED));
+ }
+
+ /** Regression: a null time cannot leave status marked successful. */
+ @Test
+ void shouldRejectNullSettlementTimeWithoutMutation() {
+ Transaction transaction = transaction("100", "0");
+ assertThrows(InvalidDomainArgumentException.class, () -> transaction.markSettled(postings(transaction), null));
+ assertEquals(TransactionStatus.INITIATED, transaction.getStatus());
+ assertTrue(transaction.getSettledAt().isEmpty());
+ }
+
+ /** Regression: validation must precede FAILED status assignment. */
+ @Test
+ void shouldRejectInvalidFailureReasonWithoutMutation() {
+ Transaction transaction = transaction("100", "0");
+ assertThrows(InvalidDomainArgumentException.class, () -> transaction.markFailed(null));
+ assertThrows(InvalidDomainArgumentException.class, () -> transaction.markFailed(" "));
+ assertEquals(TransactionStatus.INITIATED, transaction.getStatus());
+ assertTrue(transaction.getFailureReason().isEmpty());
+ }
+
+ /** A failed transfer cannot settle, fail again or become pending. */
+ @Test
+ void shouldMakeFailedStateTerminal() {
+ Transaction transaction = transaction("100", "0");
+ transaction.markFailed("Rejected by risk policy");
+ assertEquals("Rejected by risk policy", transaction.getFailureReason().orElseThrow());
+ assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(postings(transaction), NOW));
+ assertThrows(InvalidWalletStateException.class, () -> transaction.markFailed("replace"));
+ assertThrows(InvalidWalletStateException.class, transaction::markPendingSettlement);
+ }
+
+ /** A successful transfer cannot settle or fail again. */
+ @Test
+ void shouldPreventSecondSettlement() {
+ Transaction transaction = settledTransaction();
+ assertThrows(TransactionAlreadySettledException.class, () -> transaction.markSettled(postings(transaction), NOW));
+ assertThrows(TransactionAlreadySettledException.class, () -> transaction.markFailed("late failure"));
+ }
+
+ /** Rejects a missing posting batch before settlement. */
+ @Test
+ void shouldRejectNullPostingBatch() {
+ Transaction transaction = transaction("100", "0");
+ assertThrows(InvalidDomainArgumentException.class, () -> transaction.markSettled(null, NOW));
+ assertEquals(TransactionStatus.INITIATED, transaction.getStatus());
+ }
+
+ /** Rejects null entries with the established validation contract. */
+ @Test
+ void shouldRejectNullEntry() {
+ Transaction transaction = transaction("100", "0");
+ List entries = new ArrayList<>(postings(transaction));
+ entries.add(null);
+ assertThrows(InvalidDomainArgumentException.class, () -> transaction.markSettled(entries, NOW));
+ }
+
+ /** A single entry cannot be a balanced financial posting. */
+ @Test
+ void shouldRejectEmptyOrSingleEntryBatch() {
+ Transaction transaction = transaction("100", "0");
+ assertThrows(DoubleEntryImbalanceException.class, () -> transaction.markSettled(List.of(), NOW));
+ assertThrows(DoubleEntryImbalanceException.class, () -> transaction.markSettled(List.of(postings(transaction).getFirst()), NOW));
+ }
+
+ /** Unbalanced totals must not mark the transaction successful. */
+ @Test
+ void shouldRejectUnbalancedBatchWithoutMutation() {
+ Transaction transaction = transaction("100", "0");
+ List entries = List.of(postings(transaction).getFirst(),
+ entry(101, TRANSACTION_ID, DESTINATION_ID, money("99.99"), LedgerEntryType.CREDIT, 10, NOW));
+ assertThrows(DoubleEntryImbalanceException.class, () -> transaction.markSettled(entries, NOW));
+ assertEquals(TransactionStatus.INITIATED, transaction.getStatus());
+ assertTrue(transaction.getSettledAt().isEmpty());
+ }
+
+ /** Balance alone is insufficient when posting amounts differ from the command. */
+ @Test
+ void shouldRejectBalancedButIncorrectPrincipal() {
+ Transaction transaction = transaction("100", "0");
+ List entries = List.of(
+ entry(100, TRANSACTION_ID, ACCOUNT_ID, money("99"), LedgerEntryType.DEBIT, 10, NOW),
+ entry(101, TRANSACTION_ID, DESTINATION_ID, money("99"), LedgerEntryType.CREDIT, 10, NOW));
+ assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(entries, NOW));
+ }
+
+ /** A balanced batch must not credit the fee to the principal destination. */
+ @Test
+ void shouldRejectWrongFeeAllocation() {
+ Transaction transaction = transaction("100", "2");
+ List entries = List.of(postings(transaction).getFirst(),
+ entry(101, TRANSACTION_ID, DESTINATION_ID, money("102"), LedgerEntryType.CREDIT, 10, NOW));
+ assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(entries, NOW));
+ }
+
+ /** Supports an explicitly split fee across distinct fee accounts. */
+ @Test
+ void shouldAcceptSplitFeeCredits() {
+ Transaction transaction = transaction("100", "2");
+ List entries = new ArrayList<>(postings(transaction).subList(0, 2));
+ entries.add(entry(102, TRANSACTION_ID, FEE_ACCOUNT_ID, money("1"), LedgerEntryType.CREDIT, 10, NOW));
+ entries.add(entry(103, TRANSACTION_ID, new AccountId(uuid(40)), money("1"), LedgerEntryType.CREDIT, 10, NOW));
+ transaction.markSettled(entries, NOW);
+ assertEquals(TransactionStatus.SUCCESSFUL, transaction.getStatus());
+ }
+
+ /** Rejects duplicate identities even when entry content differs. */
+ @Test
+ void shouldRejectDuplicateEntryIdentity() {
+ Transaction transaction = transaction("100", "0");
+ LedgerEntry debit = postings(transaction).getFirst();
+ LedgerEntry credit = new LedgerEntry(debit.id(), TRANSACTION_ID, DESTINATION_ID,
+ money("100"), LedgerEntryType.CREDIT, 10, NOW);
+ assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(List.of(debit, credit), NOW));
+ }
+
+ /** Two distinct entries cannot claim the same sequence in one account. */
+ @Test
+ void shouldRejectDuplicateAccountSequence() {
+ Transaction transaction = transaction("100", "0");
+ List entries = List.of(
+ entry(100, TRANSACTION_ID, ACCOUNT_ID, money("40"), LedgerEntryType.DEBIT, 10, NOW),
+ entry(102, TRANSACTION_ID, ACCOUNT_ID, money("60"), LedgerEntryType.DEBIT, 10, NOW),
+ postings(transaction).get(1));
+ assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(entries, NOW));
+ }
+
+ /** Every line must belong to this transaction. */
+ @Test
+ void shouldRejectForeignTransactionEntry() {
+ Transaction transaction = transaction("100", "0");
+ List entries = List.of(postings(transaction).getFirst(),
+ entry(101, new TransactionId(uuid(99)), DESTINATION_ID, money("100"), LedgerEntryType.CREDIT, 10, NOW));
+ assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(entries, NOW));
+ }
+
+ /** Rejects mixed-currency ledger lines instead of summing them numerically. */
+ @Test
+ void shouldRejectPostingCurrencyMismatch() {
+ Transaction transaction = transaction("100", "0");
+ List entries = List.of(postings(transaction).getFirst(),
+ entry(101, TRANSACTION_ID, DESTINATION_ID, Money.of("100", USD), LedgerEntryType.CREDIT, 10, NOW));
+ assertThrows(CurrencyMismatchException.class, () -> transaction.markSettled(entries, NOW));
+ }
+
+ /** A balanced posting cannot charge an unrequested source account. */
+ @Test
+ void shouldRejectDebitFromDifferentAccount() {
+ Transaction transaction = transaction("100", "0");
+ List entries = List.of(
+ entry(100, TRANSACTION_ID, FEE_ACCOUNT_ID, money("100"), LedgerEntryType.DEBIT, 10, NOW),
+ postings(transaction).get(1));
+ assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(entries, NOW));
+ }
+
+ /** Does not allow a debit/credit loop to masquerade as a transfer. */
+ @Test
+ void shouldRejectCreditBackToSource() {
+ Transaction transaction = transaction("100", "0");
+ List entries = List.of(postings(transaction).getFirst(),
+ entry(101, TRANSACTION_ID, ACCOUNT_ID, money("100"), LedgerEntryType.CREDIT, 11, NOW));
+ assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(entries, NOW));
+ }
+
+ /** A preflight validation is not a settlement operation. */
+ @Test
+ void shouldValidatePostingWithoutMutatingState() {
+ Transaction transaction = transaction("100", "0");
+ transaction.validatePosting(postings(transaction), NOW);
+ assertEquals(TransactionStatus.INITIATED, transaction.getStatus());
+ assertTrue(transaction.getSettledAt().isEmpty());
+ }
+
+ /** Reversal retains original settlement and records the compensating identity. */
+ @Test
+ void shouldLinkSuccessfulCompensatingRefund() {
+ Transaction original = settledTransaction();
+ Transaction refund = refund(original);
+ original.markReversed(refund);
+ assertEquals(TransactionStatus.REVERSED, original.getStatus());
+ assertEquals(NOW, original.getSettledAt().orElseThrow());
+ assertEquals(refund.getId(), original.getReversalTransactionId().orElseThrow());
+ assertEquals(refund.getSettledAt(), original.getReversedAt());
+ }
+
+ /** Regression: reversed transactions must not be settled or failed again. */
+ @Test
+ void shouldKeepReversedStateTerminal() {
+ Transaction original = settledTransaction();
+ Transaction refund = refund(original);
+ original.markReversed(refund);
+ assertThrows(InvalidWalletStateException.class, () -> original.markSettled(postings(original), NOW));
+ assertThrows(InvalidWalletStateException.class, () -> original.markFailed("late"));
+ assertThrows(InvalidWalletStateException.class, () -> original.markReversed(refund));
+ assertThrows(InvalidWalletStateException.class, original::markPendingSettlement);
+ assertEquals(TransactionStatus.REVERSED, original.getStatus());
+ }
+
+ /** A transaction cannot be its own compensating financial movement. */
+ @Test
+ void shouldRejectSelfReversal() {
+ Transaction transaction = settledTransaction();
+ assertThrows(InvalidWalletStateException.class, () -> transaction.markReversed(transaction));
+ assertEquals(TransactionStatus.SUCCESSFUL, transaction.getStatus());
+ }
+
+ /** Fails closed rather than silently discarding original fee economics. */
+ @Test
+ void shouldRejectFeeBearingReversalUntilPolicyExists() {
+ Transaction transaction = transaction("25", "1");
+ transaction.markSettled(postings(transaction), NOW);
+ Transaction refund = refund(transaction);
+ assertThrows(InvalidWalletStateException.class, () -> transaction.markReversed(refund));
+ assertEquals(TransactionStatus.SUCCESSFUL, transaction.getStatus());
+ }
+
+ /** An initiated refund cannot establish reversal of settled money. */
+ @Test
+ void shouldRejectUnsettledRefund() {
+ Transaction original = settledTransaction();
+ Transaction refund = Transaction.initiate(new TransactionId(uuid(20)), IdempotencyKey.of("refund"),
+ TransactionType.REFUND, DESTINATION_ID, ACCOUNT_ID, money("25"), money("0"), NOW);
+ assertThrows(InvalidWalletStateException.class, () -> original.markReversed(refund));
+ }
+
+ /** A partial refund cannot be marked as a full reversal. */
+ @Test
+ void shouldRejectWrongRefundAmount() {
+ Transaction original = settledTransaction();
+ Transaction refund = Transaction.initiate(new TransactionId(uuid(20)), IdempotencyKey.of("refund"),
+ TransactionType.REFUND, DESTINATION_ID, ACCOUNT_ID, money("24"), money("0"), NOW);
+ refund.markSettled(postings(refund), NOW.plusSeconds(2));
+ assertThrows(InvalidWalletStateException.class, () -> original.markReversed(refund));
+ }
+
+ /** A compensating movement cannot predate the original settlement. */
+ @Test
+ void shouldRejectRefundCreatedBeforeOriginalSettlement() {
+ Transaction original = settledTransaction();
+ Transaction refund = Transaction.initiate(new TransactionId(uuid(20)), IdempotencyKey.of("refund"),
+ TransactionType.REFUND, DESTINATION_ID, ACCOUNT_ID, money("25"), money("0"), CREATED);
+ refund.markSettled(postings(refund), NOW.plusSeconds(2));
+ assertThrows(InvalidWalletStateException.class, () -> original.markReversed(refund));
+ }
+
+ /** Rejects postings outside the transaction interval. */
+ @Test
+ void shouldRejectFutureEntryTime() {
+ Transaction transaction = transaction("100", "0");
+ List entries = List.of(postings(transaction).getFirst(),
+ entry(101, TRANSACTION_ID, DESTINATION_ID, money("100"), LedgerEntryType.CREDIT, 10, NOW.plusNanos(1)));
+ assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(entries, NOW));
+ }
+
+ /** Rejects postings outside the transaction interval. */
+ @Test
+ void shouldRejectPreCreationEntryTime() {
+ Transaction transaction = transaction("100", "0");
+ List entries = List.of(postings(transaction).getFirst(),
+ entry(101, TRANSACTION_ID, DESTINATION_ID, money("100"), LedgerEntryType.CREDIT, 10, CREATED.minusNanos(1)));
+ assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(entries, NOW));
+ }
+
+ /** Rejects inconsistent status metadata during restoration. */
+ @Test
+ void shouldRejectOpenWithSettlementSnapshot() {
+ assertThrows(InvalidWalletStateException.class, () -> Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"),
+ TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"),
+ TransactionStatus.INITIATED, null, CREATED, NOW, null, null));
+ }
+
+ /** Rejects inconsistent status metadata during restoration. */
+ @Test
+ void shouldRejectPendingWithFailureSnapshot() {
+ assertThrows(InvalidWalletStateException.class, () -> Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"),
+ TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"),
+ TransactionStatus.PENDING_SETTLEMENT, "failure", CREATED, null, null, null));
+ }
+
+ /** Rejects inconsistent status metadata during restoration. */
+ @Test
+ void shouldRejectFailedWithoutReasonSnapshot() {
+ assertThrows(InvalidDomainArgumentException.class, () -> Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"),
+ TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"),
+ TransactionStatus.FAILED, null, CREATED, null, null, null));
+ }
+
+ /** Rejects inconsistent status metadata during restoration. */
+ @Test
+ void shouldRejectFailedWithSettlementSnapshot() {
+ assertThrows(InvalidWalletStateException.class, () -> Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"),
+ TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"),
+ TransactionStatus.FAILED, "failure", CREATED, NOW, null, null));
+ }
+
+ /** Rejects inconsistent status metadata during restoration. */
+ @Test
+ void shouldRejectSuccessfulWithoutSettlementSnapshot() {
+ assertThrows(InvalidWalletStateException.class, () -> Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"),
+ TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"),
+ TransactionStatus.SUCCESSFUL, null, CREATED, null, null, null));
+ }
+
+ /** Rejects inconsistent status metadata during restoration. */
+ @Test
+ void shouldRejectSuccessfulWithFailureSnapshot() {
+ assertThrows(InvalidWalletStateException.class, () -> Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"),
+ TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"),
+ TransactionStatus.SUCCESSFUL, "failure", CREATED, NOW, null, null));
+ }
+
+ /** Rejects inconsistent status metadata during restoration. */
+ @Test
+ void shouldRejectReversedWithoutReferenceSnapshot() {
+ assertThrows(InvalidWalletStateException.class, () -> Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"),
+ TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"),
+ TransactionStatus.REVERSED, null, CREATED, NOW, null, NOW));
+ }
+
+ /** Rejects inconsistent status metadata during restoration. */
+ @Test
+ void shouldRejectReversedWithSelfReferenceSnapshot() {
+ assertThrows(InvalidWalletStateException.class, () -> Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"),
+ TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"),
+ TransactionStatus.REVERSED, null, CREATED, NOW, TRANSACTION_ID, NOW));
+ }
+
+ /** Rejects inconsistent status metadata during restoration. */
+ @Test
+ void shouldRejectReversedWithEarlierTimeSnapshot() {
+ assertThrows(InvalidWalletStateException.class, () -> Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"),
+ TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"),
+ TransactionStatus.REVERSED, null, CREATED, NOW, new TransactionId(uuid(20)), CREATED));
+ }
+
+ /** Accepts a consistent stored state without transition events. */
+ @Test
+ void shouldRestoreInitiatedSnapshot() {
+ Transaction transaction = Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"),
+ TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"),
+ TransactionStatus.INITIATED, null, CREATED, null, null, null);
+ assertEquals(TransactionStatus.INITIATED, transaction.getStatus());
+ assertTrue(transaction.pullDomainEvents().isEmpty());
+ }
+
+ /** Accepts a consistent stored state without transition events. */
+ @Test
+ void shouldRestorePendingSettlementSnapshot() {
+ Transaction transaction = Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"),
+ TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"),
+ TransactionStatus.PENDING_SETTLEMENT, null, CREATED, null, null, null);
+ assertEquals(TransactionStatus.PENDING_SETTLEMENT, transaction.getStatus());
+ assertTrue(transaction.pullDomainEvents().isEmpty());
+ }
+
+ /** Accepts a consistent stored state without transition events. */
+ @Test
+ void shouldRestoreFailedSnapshot() {
+ Transaction transaction = Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"),
+ TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"),
+ TransactionStatus.FAILED, "failure", CREATED, null, null, null);
+ assertEquals(TransactionStatus.FAILED, transaction.getStatus());
+ assertTrue(transaction.pullDomainEvents().isEmpty());
+ }
+
+ /** Accepts a consistent stored state without transition events. */
+ @Test
+ void shouldRestoreSuccessfulSnapshot() {
+ Transaction transaction = Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"),
+ TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"),
+ TransactionStatus.SUCCESSFUL, null, CREATED, NOW, null, null);
+ assertEquals(TransactionStatus.SUCCESSFUL, transaction.getStatus());
+ assertTrue(transaction.pullDomainEvents().isEmpty());
+ }
+
+ /** Accepts a consistent stored state without transition events. */
+ @Test
+ void shouldRestoreReversedSnapshot() {
+ Transaction transaction = Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"),
+ TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"),
+ TransactionStatus.REVERSED, null, CREATED, NOW, new TransactionId(uuid(20)), NOW);
+ assertEquals(TransactionStatus.REVERSED, transaction.getStatus());
+ assertTrue(transaction.pullDomainEvents().isEmpty());
+ }
+}
diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/WalletTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/WalletTest.java
new file mode 100644
index 0000000..fb7fe65
--- /dev/null
+++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/WalletTest.java
@@ -0,0 +1,446 @@
+package dev.amg.payguard.domain.model;
+
+import static dev.amg.payguard.domain.model.DomainFixtures.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import dev.amg.payguard.domain.enums.*;
+import dev.amg.payguard.domain.event.*;
+import dev.amg.payguard.domain.exception.*;
+import dev.amg.payguard.domain.value_object.*;
+import domain.InvalidDomainArgumentException;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+/** Tests wallet invariants, status permissions, posting order and rejection atomicity. */
+class WalletTest {
+
+ /** Creates zero balances and exactly one creation event. */
+ @Test
+ void shouldCreateEmptyWalletWithExplicitAccount() {
+ Wallet wallet = Wallet.create(WALLET_ID, ACCOUNT_ID, USER_ID, CHF, CREATED);
+ assertEquals(ACCOUNT_ID, wallet.getAccountId());
+ assertEquals(WALLET_ID, wallet.getId());
+ assertEquals(USER_ID, wallet.getUserId());
+ assertEquals(CHF, wallet.getCurrency());
+ assertEquals(CREATED, wallet.getCreatedAt());
+ assertEquals(WalletStatus.ACTIVE, wallet.getStatus());
+ assertEquals(money("0"), wallet.getAvailableBalance());
+ assertEquals(-1L, wallet.getLastAppliedSequenceNumber());
+ assertEquals(0L, wallet.getVersion());
+ var events = wallet.pullDomainEvents();
+ assertEquals(1, events.size());
+ WalletCreatedEvent event = assertInstanceOf(WalletCreatedEvent.class, events.getFirst());
+ assertEquals(WALLET_ID, event.walletId());
+ assertEquals(CREATED, event.occurredOn());
+ assertTrue(wallet.pullDomainEvents().isEmpty());
+ }
+
+ /** Restoration preserves persisted values and does not replay creation. */
+ @Test
+ void shouldRestoreWithoutEvents() {
+ Wallet wallet = wallet();
+ assertTrue(wallet.pullDomainEvents().isEmpty());
+ assertEquals(7L, wallet.getVersion());
+ assertEquals(9L, wallet.getLastAppliedSequenceNumber());
+ }
+
+ /** Regression: a zero book balance must permit closure. */
+ @Test
+ void shouldCloseZeroBalanceWallet() {
+ Wallet wallet = wallet(WalletStatus.ACTIVE, "0", "0");
+ wallet.close(NOW);
+ assertEquals(WalletStatus.CLOSED, wallet.getStatus());
+ assertInstanceOf(WalletClosedEvent.class, wallet.pullDomainEvents().getFirst());
+ }
+
+ /** Regression: nonzero balances must prevent closure without mutation. */
+ @Test
+ void shouldRejectClosingFundedWalletWithoutMutation() {
+ Wallet wallet = wallet();
+ var before = snapshot(wallet);
+ assertThrows(InvalidWalletStateException.class, () -> wallet.close(NOW));
+ assertEquals(before, snapshot(wallet));
+ assertTrue(wallet.pullDomainEvents().isEmpty());
+ }
+
+ /** Cannot close while reserved funds remain. */
+ @Test
+ void shouldRejectReservedFundsOnClosure() {
+ Wallet wallet = wallet(WalletStatus.ACTIVE, "30", "30");
+ assertThrows(InvalidWalletStateException.class, () -> wallet.close(NOW));
+ assertEquals(WalletStatus.ACTIVE, wallet.getStatus());
+ }
+
+ /** Closure is terminal and must not emit a new transition. */
+ @Test
+ void shouldNotReactivateClosedWallet() {
+ Wallet wallet = wallet(WalletStatus.CLOSED, "0", "0");
+ assertThrows(InvalidWalletStateException.class, () -> wallet.activate(NOW));
+ assertThrows(InvalidWalletStateException.class, () -> wallet.suspend("reason", NOW));
+ assertThrows(InvalidWalletStateException.class, () -> wallet.close(NOW));
+ assertTrue(wallet.pullDomainEvents().isEmpty());
+ }
+
+ /** Prevents restoring an overdraft under the explicit non-overdraft policy. */
+ @Test
+ void shouldRejectNegativeBookBalance() {
+ assertThrows(NegativeAmountNotAllowedException.class, () -> wallet(WalletStatus.ACTIVE, "-1", "0"));
+ }
+
+ /** Rejects corrupt negative reservation snapshots. */
+ @Test
+ void shouldRejectNegativeReservedBalance() {
+ assertThrows(NegativeAmountNotAllowedException.class, () -> wallet(WalletStatus.ACTIVE, "100", "-1"));
+ }
+
+ /** Rejects a restored snapshot with negative availability. */
+ @Test
+ void shouldRejectReservationsExceedingBookBalance() {
+ assertThrows(InvalidWalletStateException.class, () -> wallet(WalletStatus.ACTIVE, "10", "11"));
+ }
+
+ /** Rejects internally inconsistent closed snapshots. */
+ @Test
+ void shouldRejectFundedClosedSnapshot() {
+ assertThrows(InvalidWalletStateException.class, () -> wallet(WalletStatus.CLOSED, "1", "0"));
+ }
+
+ /** Reserving changes reserved and available balances, not the book balance. */
+ @Test
+ void shouldReserveAvailableFunds() {
+ Wallet wallet = wallet();
+ wallet.reserveFunds(money("30"), NOW);
+ assertEquals(money("100"), wallet.getBookBalance());
+ assertEquals(money("30"), wallet.getReservedBalance());
+ assertEquals(money("70"), wallet.getAvailableBalance());
+ assertEquals(NOW, wallet.getUpdatedAt());
+ assertEquals(7L, wallet.getVersion());
+ }
+
+ /** Allows reserving the final available minor unit. */
+ @Test
+ void shouldAllowExactAvailableBalanceReservation() {
+ Wallet wallet = wallet();
+ wallet.reserveFunds(money("100"), NOW);
+ assertTrue(wallet.getAvailableBalance().isZero());
+ }
+
+ /** A rejected reservation preserves balances, time, sequence and events. */
+ @Test
+ void shouldRejectOverReservationWithoutMutation() {
+ Wallet wallet = wallet();
+ var before = snapshot(wallet);
+ assertThrows(InsufficientFundException.class, () -> wallet.reserveFunds(money("100.01"), NOW));
+ assertEquals(before, snapshot(wallet));
+ assertTrue(wallet.pullDomainEvents().isEmpty());
+ }
+
+ /** Operational freezing does not trap existing reservations. */
+ @Test
+ void shouldReleaseReservationWhileFrozen() {
+ Wallet wallet = wallet(WalletStatus.FROZEN, "100", "30");
+ wallet.unreserveFunds(money("30"), NOW);
+ assertTrue(wallet.getReservedBalance().isZero());
+ assertEquals(money("100"), wallet.getBookBalance());
+ assertEquals(WalletStatus.FROZEN, wallet.getStatus());
+ }
+
+ /** Cannot release more than the reserved amount. */
+ @Test
+ void shouldRejectExcessReleaseWithoutMutation() {
+ Wallet wallet = wallet(WalletStatus.ACTIVE, "100", "30");
+ var before = snapshot(wallet);
+ assertThrows(InvalidWalletStateException.class, () -> wallet.unreserveFunds(money("30.01"), NOW));
+ assertEquals(before, snapshot(wallet));
+ }
+
+ /** Applies a credit to the explicit account and advances the sequence. */
+ @Test
+ void shouldCreditExplicitAccount() {
+ Wallet wallet = wallet();
+ wallet.applyLedgerEntry(walletEntry(LedgerEntryType.CREDIT, "15", 10), NOW);
+ assertEquals(money("115"), wallet.getBookBalance());
+ assertEquals(10L, wallet.getLastAppliedSequenceNumber());
+ }
+
+ /** Regression: matching the wallet UUID does not prove account ownership. */
+ @Test
+ void shouldRejectWalletUuidAsAccountShortcut() {
+ Wallet wallet = wallet();
+ LedgerEntry entry = entry(100, TRANSACTION_ID, new AccountId(WALLET_ID.value()),
+ money("10"), LedgerEntryType.CREDIT, 10, NOW);
+ var before = snapshot(wallet);
+ assertThrows(InvalidWalletStateException.class, () -> wallet.applyLedgerEntry(entry, NOW));
+ assertEquals(before, snapshot(wallet));
+ }
+
+ /** An ordinary debit cannot spend money held by another operation. */
+ @Test
+ void shouldDebitOnlyAvailableFunds() {
+ Wallet wallet = wallet(WalletStatus.ACTIVE, "100", "30");
+ var before = snapshot(wallet);
+ assertThrows(InsufficientFundException.class,
+ () -> wallet.applyLedgerEntry(walletEntry(LedgerEntryType.DEBIT, "70.01", 10), NOW));
+ assertEquals(before, snapshot(wallet));
+ wallet.applyLedgerEntry(walletEntry(LedgerEntryType.DEBIT, "70", 10), NOW);
+ assertEquals(money("30"), wallet.getBookBalance());
+ assertTrue(wallet.getAvailableBalance().isZero());
+ }
+
+ /** Rejects replay in the same loaded aggregate. */
+ @Test
+ void shouldRejectDuplicateSequenceWithoutMutation() {
+ Wallet wallet = wallet();
+ LedgerEntry entry = walletEntry(LedgerEntryType.DEBIT, "10", 10);
+ wallet.applyLedgerEntry(entry, NOW);
+ var before = snapshot(wallet);
+ assertThrows(InvalidWalletStateException.class, () -> wallet.applyLedgerEntry(entry, NOW));
+ assertEquals(before, snapshot(wallet));
+ }
+
+ /** Rejects an earlier posting sequence even when entry identities differ. */
+ @Test
+ void shouldRejectOutOfOrderSequence() {
+ Wallet wallet = wallet();
+ assertThrows(InvalidWalletStateException.class,
+ () -> wallet.applyLedgerEntry(walletEntry(LedgerEntryType.CREDIT, "10", 8), NOW));
+ }
+
+ /** Does not assume sequences are gap-free. */
+ @Test
+ void shouldAllowSequenceGaps() {
+ Wallet wallet = wallet();
+ wallet.applyLedgerEntry(walletEntry(LedgerEntryType.CREDIT, "1", 100), NOW);
+ assertEquals(100L, wallet.getLastAppliedSequenceNumber());
+ }
+
+ /** Capture reduces book and reserved balances while preserving availability. */
+ @Test
+ void shouldCaptureReservedFundsAtomically() {
+ Wallet wallet = wallet(WalletStatus.ACTIVE, "100", "30");
+ Money beforeAvailable = wallet.getAvailableBalance();
+ wallet.captureReservedFunds(walletEntry(LedgerEntryType.DEBIT, "30", 10), NOW);
+ assertEquals(money("70"), wallet.getBookBalance());
+ assertTrue(wallet.getReservedBalance().isZero());
+ assertEquals(beforeAvailable, wallet.getAvailableBalance());
+ assertEquals(10L, wallet.getLastAppliedSequenceNumber());
+ }
+
+ /** A credit cannot be used to consume a reservation. */
+ @Test
+ void shouldRejectCaptureUsingCredit() {
+ Wallet wallet = wallet(WalletStatus.ACTIVE, "100", "30");
+ var before = snapshot(wallet);
+ assertThrows(InvalidWalletStateException.class,
+ () -> wallet.captureReservedFunds(walletEntry(LedgerEntryType.CREDIT, "30", 10), NOW));
+ assertEquals(before, snapshot(wallet));
+ }
+
+ /** Capture cannot consume more than the reserved total. */
+ @Test
+ void shouldRejectCaptureAboveReservation() {
+ Wallet wallet = wallet(WalletStatus.ACTIVE, "100", "30");
+ var before = snapshot(wallet);
+ assertThrows(InvalidWalletStateException.class,
+ () -> wallet.captureReservedFunds(walletEntry(LedgerEntryType.DEBIT, "30.01", 10), NOW));
+ assertEquals(before, snapshot(wallet));
+ }
+
+ /** The explicit policy blocks capture when all debits are frozen. */
+ @Test
+ void shouldRejectFrozenCapture() {
+ Wallet wallet = wallet(WalletStatus.FROZEN, "100", "30");
+ assertThrows(WalletFrozenException.class,
+ () -> wallet.captureReservedFunds(walletEntry(LedgerEntryType.DEBIT, "30", 10), NOW));
+ }
+
+ /** A null mutation timestamp cannot leave a changed balance. */
+ @Test
+ void shouldRejectNullTimeBeforeChangingBalance() {
+ Wallet wallet = wallet();
+ var before = snapshot(wallet);
+ assertThrows(InvalidDomainArgumentException.class, () -> wallet.reserveFunds(money("1"), null));
+ assertThrows(InvalidDomainArgumentException.class, () -> wallet.applyLedgerEntry(walletEntry(LedgerEntryType.CREDIT, "1", 10), null));
+ assertEquals(before, snapshot(wallet));
+ }
+
+ /** Enforces non-decreasing aggregate mutation timestamps. */
+ @Test
+ void shouldRejectBackdatedMutation() {
+ Wallet wallet = wallet();
+ wallet.reserveFunds(money("1"), NOW);
+ var before = snapshot(wallet);
+ assertThrows(InvalidWalletStateException.class, () -> wallet.unreserveFunds(money("1"), CREATED));
+ assertEquals(before, snapshot(wallet));
+ }
+
+ /** Invalid reasons must not freeze the aggregate or append events. */
+ @Test
+ void shouldRejectNullOrBlankFreezeReasonBeforeMutation() {
+ Wallet wallet = wallet();
+ var before = snapshot(wallet);
+ assertThrows(InvalidDomainArgumentException.class, () -> wallet.suspend(null, NOW));
+ assertThrows(InvalidDomainArgumentException.class, () -> wallet.suspend(" ", NOW));
+ assertEquals(before, snapshot(wallet));
+ assertTrue(wallet.pullDomainEvents().isEmpty());
+ }
+
+ /** Suspending both directions results in a frozen wallet. */
+ @Test
+ void shouldCombineDirectionalSuspensions() {
+ Wallet wallet = wallet();
+ wallet.suspendDebits("review", NOW);
+ assertEquals(WalletStatus.SUSPENDED_DEBIT, wallet.getStatus());
+ wallet.suspendCredits("review", NOW);
+ assertEquals(WalletStatus.FROZEN, wallet.getStatus());
+ assertEquals(2, wallet.pullDomainEvents().size());
+ }
+
+ /** Repeated identical status commands do not invent additional transitions. */
+ @Test
+ void shouldAvoidDuplicateStatusEvents() {
+ Wallet wallet = wallet();
+ wallet.suspend("review", NOW);
+ wallet.suspend("review", NOW.plusSeconds(1));
+ assertEquals(1, wallet.pullDomainEvents().size());
+ assertEquals(NOW, wallet.getUpdatedAt());
+ wallet.activate(NOW.plusSeconds(2));
+ wallet.activate(NOW.plusSeconds(3));
+ assertEquals(1, wallet.pullDomainEvents().size());
+ }
+
+ /** Requires positive amounts for fund-changing operations. */
+ @Test
+ void shouldRejectZeroForReserveFunds() {
+ Wallet wallet = wallet();
+ assertThrows(InvalidWalletStateException.class, () -> wallet.reserveFunds(money("0"), NOW));
+ }
+
+ /** Requires positive amounts for fund-changing operations. */
+ @Test
+ void shouldRejectZeroForUnreserveFunds() {
+ Wallet wallet = wallet();
+ assertThrows(InvalidWalletStateException.class, () -> wallet.unreserveFunds(money("0"), NOW));
+ }
+
+ /** Requires positive amounts for fund-changing operations. */
+ @Test
+ void shouldRejectZeroForAssertCanDebit() {
+ Wallet wallet = wallet();
+ assertThrows(InvalidWalletStateException.class, () -> wallet.assertCanDebit(money("0")));
+ }
+
+ /** Requires positive amounts for fund-changing operations. */
+ @Test
+ void shouldRejectZeroForAssertCanCredit() {
+ Wallet wallet = wallet();
+ assertThrows(InvalidWalletStateException.class, () -> wallet.assertCanCredit(money("0")));
+ }
+
+ /** Checks both debit and credit permissions for ACTIVE. */
+ @Test
+ void shouldEnforcePermissionsForActive() {
+ Wallet wallet = wallet(WalletStatus.ACTIVE, "100", "0");
+ assertDoesNotThrow(() -> wallet.assertCanDebit(money("1")));
+ assertDoesNotThrow(() -> wallet.assertCanCredit(money("1")));
+ }
+
+ /** Checks both debit and credit permissions for SUSPENDED_DEBIT. */
+ @Test
+ void shouldEnforcePermissionsForSuspendedDebit() {
+ Wallet wallet = wallet(WalletStatus.SUSPENDED_DEBIT, "100", "0");
+ assertThrows(WalletFrozenException.class, () -> wallet.assertCanDebit(money("1")));
+ assertDoesNotThrow(() -> wallet.assertCanCredit(money("1")));
+ }
+
+ /** Checks both debit and credit permissions for SUSPENDED_CREDIT. */
+ @Test
+ void shouldEnforcePermissionsForSuspendedCredit() {
+ Wallet wallet = wallet(WalletStatus.SUSPENDED_CREDIT, "100", "0");
+ assertDoesNotThrow(() -> wallet.assertCanDebit(money("1")));
+ assertThrows(WalletFrozenException.class, () -> wallet.assertCanCredit(money("1")));
+ }
+
+ /** Checks both debit and credit permissions for FROZEN. */
+ @Test
+ void shouldEnforcePermissionsForFrozen() {
+ Wallet wallet = wallet(WalletStatus.FROZEN, "100", "0");
+ assertThrows(WalletFrozenException.class, () -> wallet.assertCanDebit(money("1")));
+ assertThrows(WalletFrozenException.class, () -> wallet.assertCanCredit(money("1")));
+ }
+
+ /** Checks both debit and credit permissions for CLOSED. */
+ @Test
+ void shouldEnforcePermissionsForClosed() {
+ Wallet wallet = wallet(WalletStatus.CLOSED, "0", "0");
+ assertThrows(WalletFrozenException.class, () -> wallet.assertCanDebit(money("1")));
+ assertThrows(WalletFrozenException.class, () -> wallet.assertCanCredit(money("1")));
+ }
+
+ /** Checks currency even during eligibility-only validation. */
+ @Test
+ void shouldRejectCurrencyMismatchOnCredit() {
+ Wallet wallet = wallet();
+ assertThrows(CurrencyMismatchException.class, () -> wallet.assertCanCredit(Money.of("1", USD)));
+ }
+
+ /** Checks currency even during eligibility-only validation. */
+ @Test
+ void shouldRejectCurrencyMismatchOnDebit() {
+ Wallet wallet = wallet();
+ assertThrows(CurrencyMismatchException.class, () -> wallet.assertCanDebit(Money.of("1", USD)));
+ }
+
+ /** Rejects invalid restored metadata before exposing an aggregate. */
+ @Test
+ void shouldRejectBookCurrencyOnRestore() {
+ assertThrows(CurrencyMismatchException.class, () -> Wallet.restore(WALLET_ID, ACCOUNT_ID, USER_ID, CHF,
+ WalletStatus.ACTIVE, Money.of("100", USD), money("0"), 0, -1, CREATED, CREATED));
+ }
+
+ /** Rejects invalid restored metadata before exposing an aggregate. */
+ @Test
+ void shouldRejectReservedCurrencyOnRestore() {
+ assertThrows(CurrencyMismatchException.class, () -> Wallet.restore(WALLET_ID, ACCOUNT_ID, USER_ID, CHF,
+ WalletStatus.ACTIVE, money("100"), Money.of("0", USD), 0, -1, CREATED, CREATED));
+ }
+
+ /** Rejects invalid restored metadata before exposing an aggregate. */
+ @Test
+ void shouldRejectNegativeVersionOnRestore() {
+ assertThrows(InvalidWalletStateException.class, () -> Wallet.restore(WALLET_ID, ACCOUNT_ID, USER_ID, CHF,
+ WalletStatus.ACTIVE, money("100"), money("0"), -1, -1, CREATED, CREATED));
+ }
+
+ /** Rejects invalid restored metadata before exposing an aggregate. */
+ @Test
+ void shouldRejectInvalidSequenceOnRestore() {
+ assertThrows(InvalidWalletStateException.class, () -> Wallet.restore(WALLET_ID, ACCOUNT_ID, USER_ID, CHF,
+ WalletStatus.ACTIVE, money("100"), money("0"), 0, -2, CREATED, CREATED));
+ }
+
+ /** Rejects invalid restored metadata before exposing an aggregate. */
+ @Test
+ void shouldRejectBackdatedSnapshotOnRestore() {
+ assertThrows(InvalidWalletStateException.class, () -> Wallet.restore(WALLET_ID, ACCOUNT_ID, USER_ID, CHF,
+ WalletStatus.ACTIVE, money("100"), money("0"), 0, -1, NOW, CREATED));
+ }
+
+ /** Rejects posting timestamps outside the wallet/application interval. */
+ @Test
+ void shouldRejectFuturePostingTime() {
+ Wallet wallet = wallet();
+ LedgerEntry entry = entry(100, TRANSACTION_ID, ACCOUNT_ID, money("1"), LedgerEntryType.CREDIT, 10, NOW.plusNanos(1));
+ assertThrows(InvalidWalletStateException.class, () -> wallet.applyLedgerEntry(entry, NOW));
+ }
+
+ /** Rejects posting timestamps outside the wallet/application interval. */
+ @Test
+ void shouldRejectBeforeCreationPostingTime() {
+ Wallet wallet = wallet();
+ LedgerEntry entry = entry(100, TRANSACTION_ID, ACCOUNT_ID, money("1"), LedgerEntryType.CREDIT, 10, CREATED.minusNanos(1));
+ assertThrows(InvalidWalletStateException.class, () -> wallet.applyLedgerEntry(entry, NOW));
+ }
+}
diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/AccountIdTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/AccountIdTest.java
new file mode 100644
index 0000000..53f2353
--- /dev/null
+++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/AccountIdTest.java
@@ -0,0 +1,92 @@
+package dev.amg.payguard.domain.value_object;
+
+import domain.InvalidDomainArgumentException;
+import java.util.UUID;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/** Tests the immutable identifier contract of {@link AccountId}. */
+class AccountIdTest {
+
+ private static final UUID FIXED_ID = UUID.fromString("123e4567-e89b-42d3-a456-426614174000");
+
+ /** Direct construction retains the exact UUID without transformation. */
+ @Test
+ void shouldPreserveUuid() {
+ AccountId id = new AccountId(FIXED_ID);
+ assertEquals(FIXED_ID, id.value());
+ }
+
+ /** Generated identifiers use a non-null random UUID, version 4. */
+ @Test
+ void shouldGenerateVersionFourIdentifier() {
+ AccountId first = AccountId.generate();
+ AccountId second = AccountId.generate();
+ assertNotNull(first.value());
+ assertEquals(4, first.value().version());
+ assertNotEquals(first, second);
+ }
+
+ /** Canonical UUID strings round-trip through the identifier. */
+ @Test
+ void shouldParseCanonicalUuid() {
+ assertEquals(new AccountId(FIXED_ID), AccountId.fromString(FIXED_ID.toString()));
+ }
+
+ /** Uppercase hexadecimal UUID input is allowed. */
+ @Test
+ void shouldAcceptUppercaseUuid() {
+ assertEquals(new AccountId(FIXED_ID),
+ AccountId.fromString(FIXED_ID.toString().toUpperCase(java.util.Locale.ROOT)));
+ }
+
+ /** The JDK's permissive, noncanonical UUID spellings are not accepted. */
+ @Test
+ void shouldRejectNoncanonicalUuid() {
+ assertThrows(IllegalArgumentException.class, () -> AccountId.fromString("1-1-1-1-1"));
+ }
+
+ /** Malformed UUID strings must be rejected. */
+ @Test
+ void shouldRejectMalformedUuid() {
+ assertThrows(IllegalArgumentException.class, () -> AccountId.fromString("not-a-uuid"));
+ }
+
+ /** Null UUIDs are rejected using the shared-domain validation contract. */
+ @Test
+ void shouldRejectNullUuid() {
+ assertThrows(InvalidDomainArgumentException.class, () -> new AccountId(null));
+ }
+
+ /** Null UUID strings are rejected using the shared-domain validation contract. */
+ @Test
+ void shouldRejectNullString() {
+ assertThrows(InvalidDomainArgumentException.class, () -> AccountId.fromString(null));
+ }
+
+ /** Record equality, hash code, and shared ValueObject equality must agree. */
+ @Test
+ void shouldPreserveValueEquality() {
+ AccountId first = new AccountId(FIXED_ID);
+ AccountId second = AccountId.fromString(FIXED_ID.toString());
+ assertEquals(first, second);
+ assertEquals(first.hashCode(), second.hashCode());
+ assertTrue(first.sameValueAs(second));
+ assertFalse(first.sameValueAs(null));
+ }
+
+ /** A different UUID must represent a different identifier. */
+ @Test
+ void shouldRejectDifferentUuidEquality() {
+ assertNotEquals(new AccountId(FIXED_ID),
+ new AccountId(UUID.fromString("123e4567-e89b-42d3-a456-426614174001")));
+ }
+
+ /** A different identifier type must never compare equal even with the same UUID. */
+ @Test
+ void shouldRejectDifferentIdentifierTypeEquality() {
+ AccountId identifier = new AccountId(FIXED_ID);
+ assertNotEquals(identifier, new WalletId(FIXED_ID));
+ }
+}
diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/HoldIdTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/HoldIdTest.java
new file mode 100644
index 0000000..ebefa6b
--- /dev/null
+++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/HoldIdTest.java
@@ -0,0 +1,92 @@
+package dev.amg.payguard.domain.value_object;
+
+import domain.InvalidDomainArgumentException;
+import java.util.UUID;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/** Tests the immutable identifier contract of {@link HoldId}. */
+class HoldIdTest {
+
+ private static final UUID FIXED_ID = UUID.fromString("123e4567-e89b-42d3-a456-426614174000");
+
+ /** Direct construction retains the exact UUID without transformation. */
+ @Test
+ void shouldPreserveUuid() {
+ HoldId id = new HoldId(FIXED_ID);
+ assertEquals(FIXED_ID, id.value());
+ }
+
+ /** Generated identifiers use a non-null random UUID, version 4. */
+ @Test
+ void shouldGenerateVersionFourIdentifier() {
+ HoldId first = HoldId.generate();
+ HoldId second = HoldId.generate();
+ assertNotNull(first.value());
+ assertEquals(4, first.value().version());
+ assertNotEquals(first, second);
+ }
+
+ /** Canonical UUID strings round-trip through the identifier. */
+ @Test
+ void shouldParseCanonicalUuid() {
+ assertEquals(new HoldId(FIXED_ID), HoldId.fromString(FIXED_ID.toString()));
+ }
+
+ /** Uppercase hexadecimal UUID input is allowed. */
+ @Test
+ void shouldAcceptUppercaseUuid() {
+ assertEquals(new HoldId(FIXED_ID),
+ HoldId.fromString(FIXED_ID.toString().toUpperCase(java.util.Locale.ROOT)));
+ }
+
+ /** The JDK's permissive, noncanonical UUID spellings are not accepted. */
+ @Test
+ void shouldRejectNoncanonicalUuid() {
+ assertThrows(IllegalArgumentException.class, () -> HoldId.fromString("1-1-1-1-1"));
+ }
+
+ /** Malformed UUID strings must be rejected. */
+ @Test
+ void shouldRejectMalformedUuid() {
+ assertThrows(IllegalArgumentException.class, () -> HoldId.fromString("not-a-uuid"));
+ }
+
+ /** Null UUIDs are rejected using the shared-domain validation contract. */
+ @Test
+ void shouldRejectNullUuid() {
+ assertThrows(InvalidDomainArgumentException.class, () -> new HoldId(null));
+ }
+
+ /** Null UUID strings are rejected using the shared-domain validation contract. */
+ @Test
+ void shouldRejectNullString() {
+ assertThrows(InvalidDomainArgumentException.class, () -> HoldId.fromString(null));
+ }
+
+ /** Record equality, hash code, and shared ValueObject equality must agree. */
+ @Test
+ void shouldPreserveValueEquality() {
+ HoldId first = new HoldId(FIXED_ID);
+ HoldId second = HoldId.fromString(FIXED_ID.toString());
+ assertEquals(first, second);
+ assertEquals(first.hashCode(), second.hashCode());
+ assertTrue(first.sameValueAs(second));
+ assertFalse(first.sameValueAs(null));
+ }
+
+ /** A different UUID must represent a different identifier. */
+ @Test
+ void shouldRejectDifferentUuidEquality() {
+ assertNotEquals(new HoldId(FIXED_ID),
+ new HoldId(UUID.fromString("123e4567-e89b-42d3-a456-426614174001")));
+ }
+
+ /** A different identifier type must never compare equal even with the same UUID. */
+ @Test
+ void shouldRejectDifferentIdentifierTypeEquality() {
+ HoldId identifier = new HoldId(FIXED_ID);
+ assertNotEquals(identifier, new WalletId(FIXED_ID));
+ }
+}
diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/IdempotencyKeyTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/IdempotencyKeyTest.java
new file mode 100644
index 0000000..c0c8520
--- /dev/null
+++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/IdempotencyKeyTest.java
@@ -0,0 +1,105 @@
+package dev.amg.payguard.domain.value_object;
+
+import dev.amg.payguard.domain.exception.InvalidIdempotencyKeyException;
+import domain.InvalidDomainArgumentException;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/** Tests exact protocol validation and equality of idempotency keys. */
+class IdempotencyKeyTest {
+
+ /** A normal client-provided key is preserved verbatim. */
+ @Test
+ void shouldPreserveValidKey() {
+ assertEquals("order-42_v2~A", IdempotencyKey.of("order-42_v2~A").value());
+ }
+
+ /** A one-character key is valid. */
+ @Test
+ void shouldAcceptOneCharacterKey() {
+ assertEquals("a", IdempotencyKey.of("a").value());
+ }
+
+ /** A key at the maximum supported length is valid. */
+ @Test
+ void shouldAcceptMaximumLength() {
+ assertEquals(255, IdempotencyKey.of("a".repeat(255)).value().length());
+ }
+
+ /** A key longer than the protocol limit must be rejected. */
+ @Test
+ void shouldRejectExcessiveLength() {
+ assertThrows(InvalidIdempotencyKeyException.class,
+ () -> IdempotencyKey.of("a".repeat(256)));
+ }
+
+ /** Empty strings are not usable idempotency keys. */
+ @Test
+ void shouldRejectEmptyString() {
+ assertThrows(InvalidIdempotencyKeyException.class, () -> IdempotencyKey.of(""));
+ }
+
+ /** Whitespace-only strings are not usable idempotency keys. */
+ @Test
+ void shouldRejectBlankString() {
+ assertThrows(InvalidIdempotencyKeyException.class, () -> IdempotencyKey.of(" "));
+ }
+
+ /** Leading and trailing whitespace is rejected rather than trimmed. */
+ @Test
+ void shouldRejectWhitespaceWithoutNormalization() {
+ assertThrows(InvalidIdempotencyKeyException.class,
+ () -> IdempotencyKey.of(" abc "));
+ }
+
+ /** Control characters are not valid in a key used in an HTTP header. */
+ @Test
+ void shouldRejectControlCharacters() {
+ assertThrows(InvalidIdempotencyKeyException.class,
+ () -> IdempotencyKey.of("a\nb"));
+ }
+
+ /** Non-ASCII characters are rejected by the explicit Wallet API policy. */
+ @Test
+ void shouldRejectNonAscii() {
+ assertThrows(InvalidIdempotencyKeyException.class,
+ () -> IdempotencyKey.of("caf\u00e9"));
+ }
+
+ /** Printable separators outside the allowed alphabet are rejected. */
+ @Test
+ void shouldRejectDisallowedPunctuation() {
+ assertThrows(InvalidIdempotencyKeyException.class,
+ () -> IdempotencyKey.of("order/42"));
+ }
+
+ /** Case is significant for idempotency semantics. */
+ @Test
+ void shouldPreserveCaseSensitiveEquality() {
+ assertNotEquals(IdempotencyKey.of("Order-42"), IdempotencyKey.of("order-42"));
+ }
+
+ /** Equal keys have stable record equality and hash codes. */
+ @Test
+ void shouldCompareEqualKeysByValue() {
+ IdempotencyKey first = IdempotencyKey.of("checkout-123");
+ IdempotencyKey second = IdempotencyKey.of("checkout-123");
+ assertEquals(first, second);
+ assertEquals(first.hashCode(), second.hashCode());
+ assertTrue(first.sameValueAs(second));
+ assertFalse(first.sameValueAs(null));
+ }
+
+ /** Null inputs are rejected by the shared-domain validation contract. */
+ @Test
+ void shouldRejectNullKey() {
+ assertThrows(InvalidDomainArgumentException.class, () -> IdempotencyKey.of(null));
+ }
+
+ /** Different values are not interchangeable idempotency keys. */
+ @Test
+ void shouldRejectDifferentKeyEquality() {
+ assertNotEquals(IdempotencyKey.of("order-1"), IdempotencyKey.of("order-2"));
+ }
+}
diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/LedgerEntryIdTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/LedgerEntryIdTest.java
new file mode 100644
index 0000000..1ba2b0d
--- /dev/null
+++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/LedgerEntryIdTest.java
@@ -0,0 +1,92 @@
+package dev.amg.payguard.domain.value_object;
+
+import domain.InvalidDomainArgumentException;
+import java.util.UUID;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/** Tests the immutable identifier contract of {@link LedgerEntryId}. */
+class LedgerEntryIdTest {
+
+ private static final UUID FIXED_ID = UUID.fromString("123e4567-e89b-42d3-a456-426614174000");
+
+ /** Direct construction retains the exact UUID without transformation. */
+ @Test
+ void shouldPreserveUuid() {
+ LedgerEntryId id = new LedgerEntryId(FIXED_ID);
+ assertEquals(FIXED_ID, id.value());
+ }
+
+ /** Generated identifiers use a non-null random UUID, version 4. */
+ @Test
+ void shouldGenerateVersionFourIdentifier() {
+ LedgerEntryId first = LedgerEntryId.generate();
+ LedgerEntryId second = LedgerEntryId.generate();
+ assertNotNull(first.value());
+ assertEquals(4, first.value().version());
+ assertNotEquals(first, second);
+ }
+
+ /** Canonical UUID strings round-trip through the identifier. */
+ @Test
+ void shouldParseCanonicalUuid() {
+ assertEquals(new LedgerEntryId(FIXED_ID), LedgerEntryId.fromString(FIXED_ID.toString()));
+ }
+
+ /** Uppercase hexadecimal UUID input is allowed. */
+ @Test
+ void shouldAcceptUppercaseUuid() {
+ assertEquals(new LedgerEntryId(FIXED_ID),
+ LedgerEntryId.fromString(FIXED_ID.toString().toUpperCase(java.util.Locale.ROOT)));
+ }
+
+ /** The JDK's permissive, noncanonical UUID spellings are not accepted. */
+ @Test
+ void shouldRejectNoncanonicalUuid() {
+ assertThrows(IllegalArgumentException.class, () -> LedgerEntryId.fromString("1-1-1-1-1"));
+ }
+
+ /** Malformed UUID strings must be rejected. */
+ @Test
+ void shouldRejectMalformedUuid() {
+ assertThrows(IllegalArgumentException.class, () -> LedgerEntryId.fromString("not-a-uuid"));
+ }
+
+ /** Null UUIDs are rejected using the shared-domain validation contract. */
+ @Test
+ void shouldRejectNullUuid() {
+ assertThrows(InvalidDomainArgumentException.class, () -> new LedgerEntryId(null));
+ }
+
+ /** Null UUID strings are rejected using the shared-domain validation contract. */
+ @Test
+ void shouldRejectNullString() {
+ assertThrows(InvalidDomainArgumentException.class, () -> LedgerEntryId.fromString(null));
+ }
+
+ /** Record equality, hash code, and shared ValueObject equality must agree. */
+ @Test
+ void shouldPreserveValueEquality() {
+ LedgerEntryId first = new LedgerEntryId(FIXED_ID);
+ LedgerEntryId second = LedgerEntryId.fromString(FIXED_ID.toString());
+ assertEquals(first, second);
+ assertEquals(first.hashCode(), second.hashCode());
+ assertTrue(first.sameValueAs(second));
+ assertFalse(first.sameValueAs(null));
+ }
+
+ /** A different UUID must represent a different identifier. */
+ @Test
+ void shouldRejectDifferentUuidEquality() {
+ assertNotEquals(new LedgerEntryId(FIXED_ID),
+ new LedgerEntryId(UUID.fromString("123e4567-e89b-42d3-a456-426614174001")));
+ }
+
+ /** A different identifier type must never compare equal even with the same UUID. */
+ @Test
+ void shouldRejectDifferentIdentifierTypeEquality() {
+ LedgerEntryId identifier = new LedgerEntryId(FIXED_ID);
+ assertNotEquals(identifier, new WalletId(FIXED_ID));
+ }
+}
diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/MoneyTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/MoneyTest.java
new file mode 100644
index 0000000..55fa7fa
--- /dev/null
+++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/MoneyTest.java
@@ -0,0 +1,276 @@
+package dev.amg.payguard.domain.value_object;
+
+import dev.amg.payguard.domain.exception.CurrencyMismatchException;
+import dev.amg.payguard.domain.exception.NegativeAmountNotAllowedException;
+import domain.InvalidDomainArgumentException;
+import java.math.BigDecimal;
+import java.util.Currency;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/** Tests exact financial arithmetic, normalization and currency isolation. */
+class MoneyTest {
+
+ private static final Currency CHF = Currency.getInstance("CHF");
+ private static final Currency USD = Currency.getInstance("USD");
+
+ /** A normal CHF amount keeps its exact amount and currency. */
+ @Test
+ void shouldAcceptExactChfAmount() {
+ Money money = Money.of("100.25", CHF);
+ assertEquals(new BigDecimal("100.25"), money.amount());
+ assertEquals(CHF, money.currency());
+ }
+
+ /** A CHF amount with one decimal place normalizes to two. */
+ @Test
+ void shouldNormalizeChfFractionalPrecision() {
+ assertEquals(new BigDecimal("100.50"), Money.of("100.5", CHF).amount());
+ }
+
+ /** Excess zero digits can be removed without rounding. */
+ @Test
+ void shouldAcceptRedundantTrailingZeros() {
+ assertEquals(new BigDecimal("100.50"), Money.of("100.5000", CHF).amount());
+ }
+
+ /** A CHF amount needing rounding is rejected rather than adjusted. */
+ @Test
+ void shouldRejectInexactChfFractionalPrecision() {
+ assertThrows(ArithmeticException.class, () -> Money.of("100.555", CHF));
+ }
+
+ /** Even a tiny third decimal digit must not be silently rounded for CHF. */
+ @Test
+ void shouldRejectSubCentChfAmount() {
+ assertThrows(ArithmeticException.class, () -> Money.of("0.001", CHF));
+ }
+
+ /** Zero-currency placeholder XXX has no defined minor-unit precision. */
+ @Test
+ void shouldRejectUndefinedCurrencyPrecision() {
+ assertThrows(IllegalArgumentException.class,
+ () -> Money.of("100", Currency.getInstance("XXX")));
+ }
+
+ /** Zero is normalized to the chosen currency scale. */
+ @Test
+ void shouldCreateZeroWithNormalizedScale() {
+ Money zero = Money.zero(CHF);
+ assertEquals(new BigDecimal("0.00"), zero.amount());
+ assertTrue(zero.isZero());
+ assertFalse(zero.isPositive());
+ assertFalse(zero.isNegative());
+ }
+
+ /** Positive values have only the positive predicate set. */
+ @Test
+ void shouldIdentifyPositiveMoney() {
+ Money value = Money.of("1.00", CHF);
+ assertTrue(value.isPositive());
+ assertFalse(value.isNegative());
+ assertFalse(value.isZero());
+ }
+
+ /** Negative values remain representable at value-object level. */
+ @Test
+ void shouldIdentifyNegativeMoney() {
+ Money value = Money.of("-1.00", CHF);
+ assertTrue(value.isNegative());
+ assertFalse(value.isPositive());
+ assertFalse(value.isZero());
+ }
+
+ /** Adding amounts leaves the two operands unchanged. */
+ @Test
+ void shouldAddExactAmountsWithoutMutation() {
+ Money first = Money.of("100.25", CHF);
+ Money second = Money.of("0.35", CHF);
+ assertEquals(Money.of("100.60", CHF), first.add(second));
+ assertEquals(Money.of("100.25", CHF), first);
+ assertEquals(Money.of("0.35", CHF), second);
+ }
+
+ /** Subtraction may produce a negative Money value. */
+ @Test
+ void shouldSubtractExactlyIntoNegativeBalance() {
+ assertEquals(Money.of("-50.00", CHF),
+ Money.of("100.00", CHF).subtract(Money.of("150.00", CHF)));
+ }
+
+ /** Mixed-currency addition is always rejected. */
+ @Test
+ void shouldRejectCrossCurrencyAddition() {
+ assertThrows(CurrencyMismatchException.class,
+ () -> Money.of("1.00", CHF).add(Money.of("1.00", USD)));
+ }
+
+ /** Mixed-currency subtraction is always rejected. */
+ @Test
+ void shouldRejectCrossCurrencySubtraction() {
+ assertThrows(CurrencyMismatchException.class,
+ () -> Money.of("1.00", CHF).subtract(Money.of("1.00", USD)));
+ }
+
+ /** Multiplication by a whole number is exact, including sign. */
+ @Test
+ void shouldMultiplyByWholeNumber() {
+ assertEquals(Money.of("-30.75", CHF), Money.of("10.25", CHF).multiply(-3L));
+ }
+
+ /** Decimal multiplication succeeds when the result has valid precision. */
+ @Test
+ void shouldMultiplyByExactDecimalFactor() {
+ assertEquals(Money.of("5.00", CHF),
+ Money.of("10.00", CHF).multiply(new BigDecimal("0.5")));
+ }
+
+ /** Decimal multiplication fails rather than silently rounding half cents. */
+ @Test
+ void shouldRejectInexactDecimalMultiplication() {
+ assertThrows(ArithmeticException.class,
+ () -> Money.of("10.01", CHF).multiply(new BigDecimal("0.5")));
+ }
+
+ /** Zero multiplication preserves currency and normalized scale. */
+ @Test
+ void shouldMultiplyByZero() {
+ assertEquals(Money.zero(CHF), Money.of("10.25", CHF).multiply(0L));
+ }
+
+ /** Negation returns the inverse without mutating the original value. */
+ @Test
+ void shouldNegateWithoutMutating() {
+ Money original = Money.of("5.50", CHF);
+ assertEquals(Money.of("-5.50", CHF), original.negate());
+ assertEquals(Money.of("5.50", CHF), original);
+ }
+
+ /** Absolute value removes a negative sign. */
+ @Test
+ void shouldReturnAbsoluteValue() {
+ assertEquals(Money.of("5.50", CHF), Money.of("-5.50", CHF).abs());
+ }
+
+ /** A negative amount is rejected where nonnegative is explicitly required. */
+ @Test
+ void shouldRejectNegativeAmountForNonNegativeInvariant() {
+ assertThrows(NegativeAmountNotAllowedException.class,
+ () -> Money.of("-0.01", CHF).assertNonNegative());
+ }
+
+ /** Zero and positive amounts satisfy the nonnegative invariant. */
+ @Test
+ void shouldAcceptNonNegativeAmounts() {
+ assertDoesNotThrow(() -> Money.zero(CHF).assertNonNegative());
+ assertDoesNotThrow(() -> Money.of("0.01", CHF).assertNonNegative());
+ }
+
+ /** Different legal textual representations of a value normalize equally. */
+ @Test
+ void shouldPreserveNormalizedEqualityAndHashCode() {
+ Money first = Money.of("10.0", CHF);
+ Money second = Money.of("10.00", CHF);
+ assertEquals(first, second);
+ assertEquals(first.hashCode(), second.hashCode());
+ assertTrue(first.sameValueAs(second));
+ assertFalse(first.sameValueAs(null));
+ }
+
+ /** Currency participates in value equality even when amounts are equal. */
+ @Test
+ void shouldIncludeCurrencyInEquality() {
+ assertNotEquals(Money.of("1.00", CHF), Money.of("1.00", USD));
+ }
+
+ /** Same-currency ordering is supported for normal values. */
+ @Test
+ void shouldCompareSameCurrencyAmounts() {
+ Money one = Money.of("1.00", CHF);
+ Money two = Money.of("2.00", CHF);
+ assertTrue(one.compareTo(two) < 0);
+ assertTrue(two.compareTo(one) > 0);
+ assertEquals(0, one.compareTo(Money.of("1.0", CHF)));
+ }
+
+ /** Convenience comparisons share the currency-safety invariant. */
+ @Test
+ void shouldEvaluateConvenienceComparisons() {
+ Money one = Money.of("1.00", CHF);
+ Money two = Money.of("2.00", CHF);
+ assertTrue(two.isGreaterThan(one));
+ assertTrue(two.isGreaterThanOrEqual(two));
+ assertTrue(one.isLessThan(two));
+ assertTrue(one.isLessThanOrEqual(one));
+ }
+
+ /** Ordering across currencies is not meaningful. */
+ @Test
+ void shouldRejectCrossCurrencyComparison() {
+ assertThrows(CurrencyMismatchException.class,
+ () -> Money.of("1.00", CHF).compareTo(Money.of("1.00", USD)));
+ }
+
+ /** Null operands are rejected before arithmetic or comparison. */
+ @Test
+ void shouldRejectNullOperands() {
+ Money one = Money.of("1.00", CHF);
+ assertThrows(InvalidDomainArgumentException.class, () -> one.add(null));
+ assertThrows(InvalidDomainArgumentException.class, () -> one.subtract(null));
+ assertThrows(InvalidDomainArgumentException.class, () -> one.compareTo(null));
+ }
+
+ /** Null amounts are rejected by the shared-domain validator. */
+ @Test
+ void shouldRejectNullAmount() {
+ assertThrows(InvalidDomainArgumentException.class,
+ () -> Money.of((BigDecimal) null, CHF));
+ }
+
+ /** Null currencies are rejected by the shared-domain validator. */
+ @Test
+ void shouldRejectNullCurrency() {
+ assertThrows(InvalidDomainArgumentException.class, () -> Money.of("1", null));
+ }
+
+ /** Null text amounts are rejected before parsing. */
+ @Test
+ void shouldRejectNullTextAmount() {
+ assertThrows(InvalidDomainArgumentException.class,
+ () -> Money.of((String) null, CHF));
+ }
+
+ /** Invalid decimal text is rejected rather than coerced. */
+ @Test
+ void shouldRejectInvalidDecimalText() {
+ assertThrows(NumberFormatException.class, () -> Money.of("NaN", CHF));
+ }
+
+ /** Null multiplication factors are rejected by the domain validator. */
+ @Test
+ void shouldRejectNullDecimalFactor() {
+ assertThrows(InvalidDomainArgumentException.class,
+ () -> Money.of("1.00", CHF).multiply((BigDecimal) null));
+ }
+
+ /** Large positive long amounts are represented without double precision loss. */
+ @Test
+ void shouldAcceptMaximumLongAmount() {
+ assertEquals(new BigDecimal("9223372036854775807.00"),
+ Money.of(Long.MAX_VALUE, CHF).amount());
+ }
+
+ /** Large negative long amounts are represented without double precision loss. */
+ @Test
+ void shouldAcceptMinimumLongAmount() {
+ assertEquals(new BigDecimal("-9223372036854775808.00"),
+ Money.of(Long.MIN_VALUE, CHF).amount());
+ }
+
+ /** The long factory interprets whole major units, not cents. */
+ @Test
+ void shouldInterpretLongAsWholeMajorUnits() {
+ assertEquals(Money.of("100.00", CHF), Money.of(100L, CHF));
+ }
+}
diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/TransactionIdTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/TransactionIdTest.java
new file mode 100644
index 0000000..edf9b78
--- /dev/null
+++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/TransactionIdTest.java
@@ -0,0 +1,92 @@
+package dev.amg.payguard.domain.value_object;
+
+import domain.InvalidDomainArgumentException;
+import java.util.UUID;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/** Tests the immutable identifier contract of {@link TransactionId}. */
+class TransactionIdTest {
+
+ private static final UUID FIXED_ID = UUID.fromString("123e4567-e89b-42d3-a456-426614174000");
+
+ /** Direct construction retains the exact UUID without transformation. */
+ @Test
+ void shouldPreserveUuid() {
+ TransactionId id = new TransactionId(FIXED_ID);
+ assertEquals(FIXED_ID, id.value());
+ }
+
+ /** Generated identifiers use a non-null random UUID, version 4. */
+ @Test
+ void shouldGenerateVersionFourIdentifier() {
+ TransactionId first = TransactionId.generate();
+ TransactionId second = TransactionId.generate();
+ assertNotNull(first.value());
+ assertEquals(4, first.value().version());
+ assertNotEquals(first, second);
+ }
+
+ /** Canonical UUID strings round-trip through the identifier. */
+ @Test
+ void shouldParseCanonicalUuid() {
+ assertEquals(new TransactionId(FIXED_ID), TransactionId.fromString(FIXED_ID.toString()));
+ }
+
+ /** Uppercase hexadecimal UUID input is allowed. */
+ @Test
+ void shouldAcceptUppercaseUuid() {
+ assertEquals(new TransactionId(FIXED_ID),
+ TransactionId.fromString(FIXED_ID.toString().toUpperCase(java.util.Locale.ROOT)));
+ }
+
+ /** The JDK's permissive, noncanonical UUID spellings are not accepted. */
+ @Test
+ void shouldRejectNoncanonicalUuid() {
+ assertThrows(IllegalArgumentException.class, () -> TransactionId.fromString("1-1-1-1-1"));
+ }
+
+ /** Malformed UUID strings must be rejected. */
+ @Test
+ void shouldRejectMalformedUuid() {
+ assertThrows(IllegalArgumentException.class, () -> TransactionId.fromString("not-a-uuid"));
+ }
+
+ /** Null UUIDs are rejected using the shared-domain validation contract. */
+ @Test
+ void shouldRejectNullUuid() {
+ assertThrows(InvalidDomainArgumentException.class, () -> new TransactionId(null));
+ }
+
+ /** Null UUID strings are rejected using the shared-domain validation contract. */
+ @Test
+ void shouldRejectNullString() {
+ assertThrows(InvalidDomainArgumentException.class, () -> TransactionId.fromString(null));
+ }
+
+ /** Record equality, hash code, and shared ValueObject equality must agree. */
+ @Test
+ void shouldPreserveValueEquality() {
+ TransactionId first = new TransactionId(FIXED_ID);
+ TransactionId second = TransactionId.fromString(FIXED_ID.toString());
+ assertEquals(first, second);
+ assertEquals(first.hashCode(), second.hashCode());
+ assertTrue(first.sameValueAs(second));
+ assertFalse(first.sameValueAs(null));
+ }
+
+ /** A different UUID must represent a different identifier. */
+ @Test
+ void shouldRejectDifferentUuidEquality() {
+ assertNotEquals(new TransactionId(FIXED_ID),
+ new TransactionId(UUID.fromString("123e4567-e89b-42d3-a456-426614174001")));
+ }
+
+ /** A different identifier type must never compare equal even with the same UUID. */
+ @Test
+ void shouldRejectDifferentIdentifierTypeEquality() {
+ TransactionId identifier = new TransactionId(FIXED_ID);
+ assertNotEquals(identifier, new WalletId(FIXED_ID));
+ }
+}
diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/UserIdTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/UserIdTest.java
new file mode 100644
index 0000000..b836e05
--- /dev/null
+++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/UserIdTest.java
@@ -0,0 +1,82 @@
+package dev.amg.payguard.domain.value_object;
+
+import domain.InvalidDomainArgumentException;
+import java.util.UUID;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/** Tests the immutable identifier contract of {@link UserId}. */
+class UserIdTest {
+
+ private static final UUID FIXED_ID = UUID.fromString("123e4567-e89b-42d3-a456-426614174000");
+
+ /** Direct construction retains the exact UUID without transformation. */
+ @Test
+ void shouldPreserveUuid() {
+ UserId id = new UserId(FIXED_ID);
+ assertEquals(FIXED_ID, id.value());
+ }
+
+ /** Canonical UUID strings round-trip through the identifier. */
+ @Test
+ void shouldParseCanonicalUuid() {
+ assertEquals(new UserId(FIXED_ID), UserId.fromString(FIXED_ID.toString()));
+ }
+
+ /** Uppercase hexadecimal UUID input is allowed. */
+ @Test
+ void shouldAcceptUppercaseUuid() {
+ assertEquals(new UserId(FIXED_ID),
+ UserId.fromString(FIXED_ID.toString().toUpperCase(java.util.Locale.ROOT)));
+ }
+
+ /** The JDK's permissive, noncanonical UUID spellings are not accepted. */
+ @Test
+ void shouldRejectNoncanonicalUuid() {
+ assertThrows(IllegalArgumentException.class, () -> UserId.fromString("1-1-1-1-1"));
+ }
+
+ /** Malformed UUID strings must be rejected. */
+ @Test
+ void shouldRejectMalformedUuid() {
+ assertThrows(IllegalArgumentException.class, () -> UserId.fromString("not-a-uuid"));
+ }
+
+ /** Null UUIDs are rejected using the shared-domain validation contract. */
+ @Test
+ void shouldRejectNullUuid() {
+ assertThrows(InvalidDomainArgumentException.class, () -> new UserId(null));
+ }
+
+ /** Null UUID strings are rejected using the shared-domain validation contract. */
+ @Test
+ void shouldRejectNullString() {
+ assertThrows(InvalidDomainArgumentException.class, () -> UserId.fromString(null));
+ }
+
+ /** Record equality, hash code, and shared ValueObject equality must agree. */
+ @Test
+ void shouldPreserveValueEquality() {
+ UserId first = new UserId(FIXED_ID);
+ UserId second = UserId.fromString(FIXED_ID.toString());
+ assertEquals(first, second);
+ assertEquals(first.hashCode(), second.hashCode());
+ assertTrue(first.sameValueAs(second));
+ assertFalse(first.sameValueAs(null));
+ }
+
+ /** A different UUID must represent a different identifier. */
+ @Test
+ void shouldRejectDifferentUuidEquality() {
+ assertNotEquals(new UserId(FIXED_ID),
+ new UserId(UUID.fromString("123e4567-e89b-42d3-a456-426614174001")));
+ }
+
+ /** A different identifier type must never compare equal even with the same UUID. */
+ @Test
+ void shouldRejectDifferentIdentifierTypeEquality() {
+ UserId identifier = new UserId(FIXED_ID);
+ assertNotEquals(identifier, new WalletId(FIXED_ID));
+ }
+}
diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/WalletIdTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/WalletIdTest.java
new file mode 100644
index 0000000..05c08ac
--- /dev/null
+++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/WalletIdTest.java
@@ -0,0 +1,92 @@
+package dev.amg.payguard.domain.value_object;
+
+import domain.InvalidDomainArgumentException;
+import java.util.UUID;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/** Tests the immutable identifier contract of {@link WalletId}. */
+class WalletIdTest {
+
+ private static final UUID FIXED_ID = UUID.fromString("123e4567-e89b-42d3-a456-426614174000");
+
+ /** Direct construction retains the exact UUID without transformation. */
+ @Test
+ void shouldPreserveUuid() {
+ WalletId id = new WalletId(FIXED_ID);
+ assertEquals(FIXED_ID, id.value());
+ }
+
+ /** Generated identifiers use a non-null random UUID, version 4. */
+ @Test
+ void shouldGenerateVersionFourIdentifier() {
+ WalletId first = WalletId.generate();
+ WalletId second = WalletId.generate();
+ assertNotNull(first.value());
+ assertEquals(4, first.value().version());
+ assertNotEquals(first, second);
+ }
+
+ /** Canonical UUID strings round-trip through the identifier. */
+ @Test
+ void shouldParseCanonicalUuid() {
+ assertEquals(new WalletId(FIXED_ID), WalletId.fromString(FIXED_ID.toString()));
+ }
+
+ /** Uppercase hexadecimal UUID input is allowed. */
+ @Test
+ void shouldAcceptUppercaseUuid() {
+ assertEquals(new WalletId(FIXED_ID),
+ WalletId.fromString(FIXED_ID.toString().toUpperCase(java.util.Locale.ROOT)));
+ }
+
+ /** The JDK's permissive, noncanonical UUID spellings are not accepted. */
+ @Test
+ void shouldRejectNoncanonicalUuid() {
+ assertThrows(IllegalArgumentException.class, () -> WalletId.fromString("1-1-1-1-1"));
+ }
+
+ /** Malformed UUID strings must be rejected. */
+ @Test
+ void shouldRejectMalformedUuid() {
+ assertThrows(IllegalArgumentException.class, () -> WalletId.fromString("not-a-uuid"));
+ }
+
+ /** Null UUIDs are rejected using the shared-domain validation contract. */
+ @Test
+ void shouldRejectNullUuid() {
+ assertThrows(InvalidDomainArgumentException.class, () -> new WalletId(null));
+ }
+
+ /** Null UUID strings are rejected using the shared-domain validation contract. */
+ @Test
+ void shouldRejectNullString() {
+ assertThrows(InvalidDomainArgumentException.class, () -> WalletId.fromString(null));
+ }
+
+ /** Record equality, hash code, and shared ValueObject equality must agree. */
+ @Test
+ void shouldPreserveValueEquality() {
+ WalletId first = new WalletId(FIXED_ID);
+ WalletId second = WalletId.fromString(FIXED_ID.toString());
+ assertEquals(first, second);
+ assertEquals(first.hashCode(), second.hashCode());
+ assertTrue(first.sameValueAs(second));
+ assertFalse(first.sameValueAs(null));
+ }
+
+ /** A different UUID must represent a different identifier. */
+ @Test
+ void shouldRejectDifferentUuidEquality() {
+ assertNotEquals(new WalletId(FIXED_ID),
+ new WalletId(UUID.fromString("123e4567-e89b-42d3-a456-426614174001")));
+ }
+
+ /** A different identifier type must never compare equal even with the same UUID. */
+ @Test
+ void shouldRejectDifferentIdentifierTypeEquality() {
+ WalletId identifier = new WalletId(FIXED_ID);
+ assertNotEquals(identifier, new AccountId(FIXED_ID));
+ }
+}