T requireNonNull(T value, String fieldName) {
+ if (value == null) {
+ throw new InvalidDomainArgumentException(fieldName + " cannot be null");
+ }
+ return value;
+ }
+
+ public static String requireText(String value, String fieldName) {
+ if (value == null || value.isBlank()) {
+ throw new InvalidDomainArgumentException(fieldName + " cannot be blank");
+ }
+ return value;
+ }
+}
diff --git a/payguard-shared/src/main/java/domain/InvalidDomainArgumentException.java b/payguard-shared/src/main/java/domain/InvalidDomainArgumentException.java
new file mode 100644
index 0000000..5c5d7cf
--- /dev/null
+++ b/payguard-shared/src/main/java/domain/InvalidDomainArgumentException.java
@@ -0,0 +1,26 @@
+package domain;
+
+/** Raised when a domain value or command contains invalid input. */
+public final class InvalidDomainArgumentException extends DomainException {
+
+ private static final long serialVersionUID = 1L;
+
+ public InvalidDomainArgumentException(String message) {
+ super(message);
+ }
+
+ @Override
+ public String getErrorCode() {
+ return "INVALID_DOMAIN_ARGUMENT";
+ }
+
+ @Override
+ public String getTitle() {
+ return "Invalid domain argument";
+ }
+
+ @Override
+ public int getHttpStatus() {
+ return 400;
+ }
+}
diff --git a/payguard-shared/src/main/java/domain/InvalidDomainStateException.java b/payguard-shared/src/main/java/domain/InvalidDomainStateException.java
new file mode 100644
index 0000000..bcb7272
--- /dev/null
+++ b/payguard-shared/src/main/java/domain/InvalidDomainStateException.java
@@ -0,0 +1,26 @@
+package domain;
+
+/** Raised when an operation violates a domain state transition. */
+public final class InvalidDomainStateException extends DomainException {
+
+ private static final long serialVersionUID = 1L;
+
+ public InvalidDomainStateException(String message) {
+ super(message);
+ }
+
+ @Override
+ public String getErrorCode() {
+ return "INVALID_DOMAIN_STATE";
+ }
+
+ @Override
+ public String getTitle() {
+ return "Invalid domain state";
+ }
+
+ @Override
+ public int getHttpStatus() {
+ return 409;
+ }
+}
diff --git a/payguard-shared/src/main/java/domain/ValueObject.java b/payguard-shared/src/main/java/domain/ValueObject.java
index 60f9a8c..0cd18e3 100755
--- a/payguard-shared/src/main/java/domain/ValueObject.java
+++ b/payguard-shared/src/main/java/domain/ValueObject.java
@@ -1,9 +1,13 @@
package domain;
-/**
- * Marker interface identifying a type as a Domain-Driven Design Value Object.
- *
- * Deliberately empty — immutability and structural equality/hashCode are guaranteed by
- * implementing this interface via a {@code record}, not by inheritance.
- */
-public interface ValueObject {}
+/** Identifies a domain value object and defines its value-based equality contract. */
+@FunctionalInterface
+public interface ValueObject {
+ /**
+ * Returns whether this value object represents the same value as {@code other}.
+ *
+ *
Implementations should compare all components that define the value object and should not
+ * compare object identity.
+ */
+ boolean sameValueAs(ValueObject other);
+}
diff --git a/payguard-wallet-service/pom.xml b/payguard-wallet-service/pom.xml
index 01bd7a9..5faa39a 100644
--- a/payguard-wallet-service/pom.xml
+++ b/payguard-wallet-service/pom.xml
@@ -25,6 +25,11 @@
+
+ org.aspectj
+ aspectjweaver
+
+
org.springframework.boot
spring-boot-starter-webmvc
@@ -67,6 +72,13 @@
spring-modulith-starter-test
test
+
+
+
+ dev.amg.payguard
+ payguard-shared
+ ${project.version}
+
diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/FinancialAccountType.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/FinancialAccountType.java
new file mode 100644
index 0000000..55b6947
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/enums/HoldStatus.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/HoldStatus.java
new file mode 100644
index 0000000..03fe121
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/enums/LedgerEntryType.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/LedgerEntryType.java
new file mode 100644
index 0000000..8e5fb86
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/enums/TransactionStatus.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/TransactionStatus.java
new file mode 100644
index 0000000..5f6785c
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/enums/TransactionType.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/TransactionType.java
new file mode 100644
index 0000000..82e6883
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/enums/WalletStatus.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/WalletStatus.java
new file mode 100644
index 0000000..aa1a64d
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/event/FundsHoldCapturedEvent.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/FundsHoldCapturedEvent.java
new file mode 100644
index 0000000..1f825cf
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/event/FundsHoldCreatedEvent.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/FundsHoldCreatedEvent.java
new file mode 100644
index 0000000..874471f
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/event/FundsHoldExpiredEvent.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/FundsHoldExpiredEvent.java
new file mode 100644
index 0000000..e9a8ee3
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/event/FundsHoldReleasedEvent.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/FundsHoldReleasedEvent.java
new file mode 100644
index 0000000..06a5dfd
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/event/MoneyDepositedEvent.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/MoneyDepositedEvent.java
new file mode 100644
index 0000000..3a729b6
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/event/MoneyWithdrawnEvent.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/MoneyWithdrawnEvent.java
new file mode 100644
index 0000000..51bda25
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/event/TransferCompletedEvent.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/TransferCompletedEvent.java
new file mode 100644
index 0000000..ef256af
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/event/WalletClosedEvent.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/WalletClosedEvent.java
new file mode 100644
index 0000000..67d7f35
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/event/WalletCreatedEvent.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/WalletCreatedEvent.java
new file mode 100644
index 0000000..566b317
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/event/WalletStatusChangedEvent.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/WalletStatusChangedEvent.java
new file mode 100644
index 0000000..077e23b
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/exception/CurrencyMismatchException.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/CurrencyMismatchException.java
new file mode 100644
index 0000000..b47253d
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/exception/DoubleEntryImbalanceException.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/DoubleEntryImbalanceException.java
new file mode 100644
index 0000000..b617fb6
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/exception/HoldExpiredException.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/HoldExpiredException.java
new file mode 100644
index 0000000..66a7879
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/exception/InsufficientFundException.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/InsufficientFundException.java
new file mode 100644
index 0000000..3859a96
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/exception/InvalidHoldStateException.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/InvalidHoldStateException.java
new file mode 100644
index 0000000..b8fb5b8
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/exception/InvalidIdempotencyKeyException.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/InvalidIdempotencyKeyException.java
new file mode 100644
index 0000000..4b98606
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/exception/InvalidWalletStateException.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/InvalidWalletStateException.java
new file mode 100644
index 0000000..eea1b78
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/exception/LimitExceededException.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/LimitExceededException.java
new file mode 100644
index 0000000..2f6d935
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/exception/NegativeAmountNotAllowedException.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/NegativeAmountNotAllowedException.java
new file mode 100644
index 0000000..fb52913
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/exception/TransactionAlreadySettledException.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/TransactionAlreadySettledException.java
new file mode 100644
index 0000000..db66151
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/exception/WalletFrozenException.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/WalletFrozenException.java
new file mode 100644
index 0000000..f70412e
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/exception/package-info.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/package-info.java
new file mode 100644
index 0000000..44008df
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/model/Hold.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/model/Hold.java
new file mode 100644
index 0000000..0e5b380
--- /dev/null
+++ b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/model/Hold.java
@@ -0,0 +1,132 @@
+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;
+
+public class Hold extends AggregateRoot {
+ private final HoldId id;
+ 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;
+
+ public Hold(
+ HoldId id,
+ WalletId walletId,
+ Money amount,
+ String reason,
+ String referenceId,
+ Instant expiresAt,
+ Instant createdAt,
+ HoldStatus status) {
+ super(id);
+ this.id = DomainValidation.requireNonNull(id, "Hold 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();
+ }
+
+ public static Hold createNew(
+ HoldId id,
+ WalletId walletId,
+ Money amount,
+ String reason,
+ String referenceId,
+ Instant expiresAt,
+ Instant now) {
+ if (expiresAt.isBefore(now)) {
+ throw new InvalidHoldStateException("Expiration time must be in the future");
+ }
+ return new Hold(id, walletId, amount, reason, referenceId, expiresAt, now, HoldStatus.PENDING);
+ }
+
+ public void capture(Instant now) {
+ assertPending();
+ if (isExpired(now)) {
+ this.status = HoldStatus.EXPIRED;
+ registerEvent(FundsHoldExpiredEvent.now(this.id, this.walletId, this.amount, now));
+ throw new HoldExpiredException("Cannot capture hold: Hold is already expired");
+ }
+ this.status = HoldStatus.CAPTURED;
+ registerEvent(FundsHoldCapturedEvent.now(this.id, this.walletId, this.amount, now));
+ }
+
+ public void release(Instant now) {
+ assertPending();
+ this.status = HoldStatus.RELEASED;
+ registerEvent(FundsHoldReleasedEvent.now(this.id, this.walletId, this.amount, now));
+ }
+
+ public void expire(Instant now) {
+ assertPending();
+ if (!isExpired(now)) {
+ throw new InvalidHoldStateException(
+ "Cannot expire hold: Expiration time has not elapsed yet");
+ }
+ this.status = HoldStatus.EXPIRED;
+ registerEvent(FundsHoldExpiredEvent.now(this.id, this.walletId, this.amount, now));
+ }
+
+ public boolean isExpired(Instant now) {
+ return now.isAfter(this.expiresAt);
+ }
+
+ private void assertPending() {
+ if (this.status != HoldStatus.PENDING) {
+ throw new InvalidHoldStateException("Hold is not in PENDING state. Current: " + this.status);
+ }
+ }
+
+ @Override
+ public HoldId getId() {
+ return id;
+ }
+
+ public WalletId getWalletId() {
+ return walletId;
+ }
+
+ public Money getAmount() {
+ return amount;
+ }
+
+ public String getReason() {
+ return reason;
+ }
+
+ public String getReferenceId() {
+ return referenceId;
+ }
+
+ public Instant getExpiresAt() {
+ return expiresAt;
+ }
+
+ public Instant getCreatedAt() {
+ return createdAt;
+ }
+
+ public HoldStatus getStatus() {
+ return status;
+ }
+}
diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/model/LedgerEntry.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/model/LedgerEntry.java
new file mode 100644
index 0000000..12b02ce
--- /dev/null
+++ b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/model/LedgerEntry.java
@@ -0,0 +1,54 @@
+package dev.amg.payguard.domain.model;
+
+import dev.amg.payguard.domain.enums.LedgerEntryType;
+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;
+
+public record LedgerEntry(
+ LedgerEntryId id,
+ TransactionId transactionId,
+ AccountId accountId,
+ Money amount,
+ LedgerEntryType entryType,
+ long sequenceNumber,
+ Instant timestamp) {
+ public LedgerEntry(
+ LedgerEntryId id,
+ TransactionId transactionId,
+ AccountId accountId,
+ Money amount,
+ LedgerEntryType entryType,
+ long sequenceNumber,
+ Instant timestamp) {
+ this.id = DomainValidation.requireNonNull(id, "Ledger entry id");
+ this.transactionId = DomainValidation.requireNonNull(transactionId, "Transaction id");
+ this.accountId = DomainValidation.requireNonNull(accountId, "Account id");
+ this.amount = DomainValidation.requireNonNull(amount, "Amount");
+ this.entryType = DomainValidation.requireNonNull(entryType, "Entry type");
+ this.timestamp = DomainValidation.requireNonNull(timestamp, "Timestamp");
+
+ amount.assertNonNegative();
+
+ if (sequenceNumber < 0) {
+ throw new dev.amg.payguard.domain.exception.InvalidWalletStateException(
+ "Sequence number cannot be negative");
+ }
+ this.sequenceNumber = sequenceNumber;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof LedgerEntry that)) return false;
+ return id.equals(that.id);
+ }
+
+ @Override
+ public int hashCode() {
+ return id.hashCode();
+ }
+}
diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/model/Transaction.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/model/Transaction.java
new file mode 100644
index 0000000..de40c0d
--- /dev/null
+++ b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/model/Transaction.java
@@ -0,0 +1,155 @@
+package dev.amg.payguard.domain.model;
+
+import dev.amg.payguard.domain.enums.TransactionStatus;
+import dev.amg.payguard.domain.enums.TransactionType;
+import dev.amg.payguard.domain.exception.InvalidWalletStateException;
+import dev.amg.payguard.domain.exception.TransactionAlreadySettledException;
+import dev.amg.payguard.domain.value_object.AccountId;
+import dev.amg.payguard.domain.value_object.IdempotencyKey;
+import dev.amg.payguard.domain.value_object.Money;
+import dev.amg.payguard.domain.value_object.TransactionId;
+import domain.AggregateRoot;
+import domain.DomainEvent;
+import domain.DomainValidation;
+import java.time.Instant;
+import java.util.Optional;
+
+public class Transaction extends AggregateRoot {
+ private final TransactionId id;
+ 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;
+
+ public Transaction(
+ TransactionId id,
+ IdempotencyKey idempotencyKey,
+ TransactionType type,
+ AccountId sourceAccountId,
+ AccountId destinationAccountId,
+ Money totalAmount,
+ Money feeAmount,
+ TransactionStatus status,
+ String failureReason,
+ Instant createdAt,
+ Instant settledAt) {
+ super(id);
+ this.id = DomainValidation.requireNonNull(id, "Transaction 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");
+ this.failureReason = failureReason;
+ this.settledAt = settledAt;
+
+ totalAmount.assertNonNegative();
+ feeAmount.assertNonNegative();
+ }
+
+ 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);
+ }
+
+ public void markSettled(Instant now) {
+ if (this.status == TransactionStatus.SUCCESSFUL) {
+ throw new TransactionAlreadySettledException(
+ "Transaction has already been settled successfully: " + this.id);
+ }
+ if (this.status == TransactionStatus.FAILED) {
+ throw new InvalidWalletStateException("Cannot settle a failed transaction: " + this.id);
+ }
+ this.status = TransactionStatus.SUCCESSFUL;
+ this.settledAt = DomainValidation.requireNonNull(now, "Settlement time");
+ }
+
+ public void markFailed(String reason) {
+ if (this.status == TransactionStatus.SUCCESSFUL) {
+ throw new TransactionAlreadySettledException(
+ "Cannot fail an already settled transaction: " + this.id);
+ }
+ this.status = TransactionStatus.FAILED;
+ this.failureReason = DomainValidation.requireText(reason, "Failure reason");
+ }
+
+ public void reverse() {
+ if (this.status != TransactionStatus.SUCCESSFUL) {
+ throw new InvalidWalletStateException("Only successful transactions can be reversed");
+ }
+ this.status = TransactionStatus.REVERSED;
+ }
+
+ @Override
+ public TransactionId getId() {
+ return id;
+ }
+
+ public IdempotencyKey getIdempotencyKey() {
+ return idempotencyKey;
+ }
+
+ public TransactionType getType() {
+ return type;
+ }
+
+ public AccountId getSourceAccountId() {
+ return sourceAccountId;
+ }
+
+ public AccountId getDestinationAccountId() {
+ return destinationAccountId;
+ }
+
+ public Money getTotalAmount() {
+ return totalAmount;
+ }
+
+ public Money getFeeAmount() {
+ return feeAmount;
+ }
+
+ public TransactionStatus getStatus() {
+ return status;
+ }
+
+ public Optional getFailureReason() {
+ return Optional.ofNullable(failureReason);
+ }
+
+ public Instant getCreatedAt() {
+ return createdAt;
+ }
+
+ public Optional getSettledAt() {
+ return Optional.ofNullable(settledAt);
+ }
+}
diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/model/Wallet.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/model/Wallet.java
new file mode 100644
index 0000000..d7c23b7
--- /dev/null
+++ b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/model/Wallet.java
@@ -0,0 +1,206 @@
+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.InsufficientFundException;
+import dev.amg.payguard.domain.exception.InvalidWalletStateException;
+import dev.amg.payguard.domain.exception.WalletFrozenException;
+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;
+
+public class Wallet extends AggregateRoot {
+ private final WalletId id;
+ private final UserId userId;
+ private final Currency currency;
+ private final Instant createdAt;
+ private WalletStatus status;
+ private Money bookBalance;
+ private Money reservedBalance;
+ private long version;
+ private Instant updatedAt;
+
+ public Wallet(
+ WalletId id,
+ UserId userId,
+ Currency currency,
+ WalletStatus status,
+ Money bookBalance,
+ Money reservedBalance,
+ long version,
+ Instant createdAt,
+ Instant updatedAt) {
+ super(id);
+ this.id = DomainValidation.requireNonNull(id, "Wallet 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");
+ this.version = version;
+
+ reservedBalance.assertNonNegative();
+ }
+
+ public static Wallet create(WalletId id, UserId userId, Currency currency, Instant now) {
+ Wallet wallet =
+ new Wallet(
+ id,
+ userId,
+ currency,
+ WalletStatus.ACTIVE,
+ Money.zero(currency),
+ Money.zero(currency),
+ 0L,
+ now,
+ now);
+ wallet.registerEvent(WalletCreatedEvent.now(id, userId, currency, now));
+ return wallet;
+ }
+
+ public Money getAvailableBalance() {
+ return this.bookBalance.subtract(this.reservedBalance);
+ }
+
+ public void assertCanDebit(Money amount) {
+ assertActive();
+ if (this.status == WalletStatus.SUSPENDED_DEBIT) {
+ throw new WalletFrozenException("Debits are currently suspended for wallet: " + this.id);
+ }
+ amount.assertNonNegative();
+ if (getAvailableBalance().isLessThan(amount)) {
+ throw new InsufficientFundException(
+ String.format(
+ "Insufficient funds in wallet: %s. Available: %s, Requested: %s",
+ this.id.value(), getAvailableBalance().amount(), amount.amount()));
+ }
+ }
+
+ public void assertCanCredit(Money amount) {
+ assertActive();
+ if (this.status == WalletStatus.SUSPENDED_CREDIT) {
+ throw new WalletFrozenException("Credits are currently suspended for wallet: " + this.id);
+ }
+ amount.assertNonNegative();
+ }
+
+ public void reserveFunds(Money amount, Instant now) {
+ assertCanDebit(amount);
+ this.reservedBalance = this.reservedBalance.add(amount);
+ this.updatedAt = now;
+ }
+
+ public void unreserveFunds(Money amount, Instant now) {
+ amount.assertNonNegative();
+ if (this.reservedBalance.isLessThan(amount)) {
+ throw new InvalidWalletStateException(
+ "Cannot unreserve more funds than are currently reserved");
+ }
+ this.reservedBalance = this.reservedBalance.subtract(amount);
+ this.updatedAt = now;
+ }
+
+ public void applyLedgerEntry(LedgerEntry entry, Instant now) {
+ DomainValidation.requireNonNull(entry, "Ledger entry");
+ if (!entry.accountId().value().equals(this.id.value())) {
+ throw new InvalidWalletStateException("Ledger entry account mismatch");
+ }
+
+ if (entry.entryType() == LedgerEntryType.CREDIT) {
+ this.bookBalance = this.bookBalance.add(entry.amount());
+ } else if (entry.entryType() == LedgerEntryType.DEBIT) {
+ this.bookBalance = this.bookBalance.subtract(entry.amount());
+ }
+ this.updatedAt = now;
+ }
+
+ public void suspend(String reason, Instant now) {
+ assertNotClosed();
+ WalletStatus previous = this.status;
+ this.status = WalletStatus.FROZEN;
+ this.updatedAt = now;
+ registerEvent(WalletStatusChangedEvent.now(this.id, previous, this.status, reason, now));
+ }
+
+ public void activate(Instant now) {
+ assertNotClosed();
+ WalletStatus previous = this.status;
+ this.status = WalletStatus.ACTIVE;
+ this.updatedAt = now;
+ registerEvent(WalletStatusChangedEvent.now(this.id, previous, this.status, "Reactivated", now));
+ }
+
+ public void close(Instant now) {
+ assertNotClosed();
+ if (!this.bookBalance.isZero()) {
+ throw new InvalidWalletStateException(
+ "Wallet cannot be closed while book balance is non-zero: " + this.bookBalance);
+ }
+ if (!this.reservedBalance.isZero()) {
+ throw new InvalidWalletStateException(
+ "Wallet cannot be closed while funds are on hold: " + this.reservedBalance);
+ }
+ this.status = WalletStatus.CLOSED;
+ this.updatedAt = now;
+ registerEvent(WalletClosedEvent.now(this.id, now));
+ }
+
+ private void assertActive() {
+ if (this.status == WalletStatus.FROZEN || this.status == WalletStatus.CLOSED) {
+ throw new WalletFrozenException("Wallet is in inactive state: " + this.status);
+ }
+ }
+
+ private void assertNotClosed() {
+ if (this.status == WalletStatus.CLOSED) {
+ throw new InvalidWalletStateException("Wallet is permanently closed: " + this.id);
+ }
+ }
+
+ @Override
+ public WalletId getId() {
+ return id;
+ }
+
+ public UserId getUserId() {
+ return userId;
+ }
+
+ public Currency getCurrency() {
+ return currency;
+ }
+
+ public WalletStatus getStatus() {
+ return status;
+ }
+
+ public Money getBookBalance() {
+ return bookBalance;
+ }
+
+ public Money getReservedBalance() {
+ return reservedBalance;
+ }
+
+ public long getVersion() {
+ return version;
+ }
+
+ public Instant getCreatedAt() {
+ return createdAt;
+ }
+
+ public Instant getUpdatedAt() {
+ return updatedAt;
+ }
+}
diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/ClockPort.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/ClockPort.java
new file mode 100644
index 0000000..5507b6a
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/port/DomainEventPublisherPort.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/DomainEventPublisherPort.java
new file mode 100644
index 0000000..8a3e7f7
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/port/HoldRepositoryPort.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/HoldRepositoryPort.java
new file mode 100644
index 0000000..9a05f9c
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/port/LedgerRepositoryPort.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/LedgerRepositoryPort.java
new file mode 100644
index 0000000..40180da
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/port/LoadWalletPort.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/LoadWalletPort.java
new file mode 100644
index 0000000..9ed164f
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/port/SaveWalletPort.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/SaveWalletPort.java
new file mode 100644
index 0000000..61fd2ec
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/port/TransactionRepositoryPort.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/TransactionRepositoryPort.java
new file mode 100644
index 0000000..8b88430
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/domain/value_object/AccountId.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/AccountId.java
new file mode 100644
index 0000000..d26b606
--- /dev/null
+++ b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/AccountId.java
@@ -0,0 +1,24 @@
+package dev.amg.payguard.domain.value_object;
+
+import domain.DomainValidation;
+import domain.ValueObject;
+import java.util.UUID;
+
+public record AccountId(UUID value) implements ValueObject {
+ public AccountId {
+ DomainValidation.requireNonNull(value, "AccountId value");
+ }
+
+ public static AccountId generate() {
+ return new AccountId(UUID.randomUUID());
+ }
+
+ public static AccountId fromString(String uuidString) {
+ return new AccountId(UUID.fromString(uuidString));
+ }
+
+ @Override
+ public boolean sameValueAs(ValueObject other) {
+ return equals(other);
+ }
+}
diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/HoldId.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/HoldId.java
new file mode 100644
index 0000000..9712943
--- /dev/null
+++ b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/HoldId.java
@@ -0,0 +1,24 @@
+package dev.amg.payguard.domain.value_object;
+
+import domain.DomainValidation;
+import domain.ValueObject;
+import java.util.UUID;
+
+public record HoldId(UUID value) implements ValueObject {
+ public HoldId {
+ DomainValidation.requireNonNull(value, "HoldId value");
+ }
+
+ public static HoldId generate() {
+ return new HoldId(UUID.randomUUID());
+ }
+
+ public static HoldId fromString(String uuidString) {
+ return new HoldId(UUID.fromString(uuidString));
+ }
+
+ @Override
+ public boolean sameValueAs(ValueObject other) {
+ return equals(other);
+ }
+}
diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/IdempotencyKey.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/IdempotencyKey.java
new file mode 100644
index 0000000..5ed8283
--- /dev/null
+++ b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/IdempotencyKey.java
@@ -0,0 +1,25 @@
+package dev.amg.payguard.domain.value_object;
+
+import dev.amg.payguard.domain.exception.InvalidIdempotencyKeyException;
+import domain.DomainValidation;
+import domain.ValueObject;
+import java.time.Instant;
+
+public record IdempotencyKey(String value, Instant createdAt) implements ValueObject {
+ public IdempotencyKey {
+ DomainValidation.requireNonNull(value, "Idempotency key value");
+ DomainValidation.requireNonNull(createdAt, "CreatedAt");
+ if (value.isBlank()) {
+ throw new InvalidIdempotencyKeyException("Idempotency key cannot be blank");
+ }
+ }
+
+ public static IdempotencyKey of(String value, Instant now) {
+ return new IdempotencyKey(value, now);
+ }
+
+ @Override
+ public boolean sameValueAs(ValueObject other) {
+ return equals(other);
+ }
+}
diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/LedgerEntryId.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/LedgerEntryId.java
new file mode 100644
index 0000000..ce7e3b6
--- /dev/null
+++ b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/LedgerEntryId.java
@@ -0,0 +1,24 @@
+package dev.amg.payguard.domain.value_object;
+
+import domain.DomainValidation;
+import domain.ValueObject;
+import java.util.UUID;
+
+public record LedgerEntryId(UUID value) implements ValueObject {
+ public LedgerEntryId {
+ DomainValidation.requireNonNull(value, "LedgerEntryId value");
+ }
+
+ public static LedgerEntryId generate() {
+ return new LedgerEntryId(UUID.randomUUID());
+ }
+
+ public static LedgerEntryId fromString(String uuidString) {
+ return new LedgerEntryId(UUID.fromString(uuidString));
+ }
+
+ @Override
+ public boolean sameValueAs(ValueObject other) {
+ return equals(other);
+ }
+}
diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/Money.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/Money.java
new file mode 100644
index 0000000..547d786
--- /dev/null
+++ b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/Money.java
@@ -0,0 +1,140 @@
+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 domain.ValueObject;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.Currency;
+import java.util.Objects;
+
+public record Money(BigDecimal amount, Currency currency)
+ implements Comparable, ValueObject {
+
+ public Money {
+ DomainValidation.requireNonNull(amount, "Amount");
+ DomainValidation.requireNonNull(currency, "Currency");
+
+ int fractionDigits = Math.max(currency.getDefaultFractionDigits(), 0);
+ amount = amount.setScale(fractionDigits, RoundingMode.HALF_EVEN);
+ }
+
+ public static Money of(BigDecimal amount, Currency currency) {
+ return new Money(amount, currency);
+ }
+
+ public static Money of(String amount, Currency currency) {
+ return new Money(new BigDecimal(amount), currency);
+ }
+
+ public static Money of(long amount, Currency currency) {
+ return new Money(BigDecimal.valueOf(amount), currency);
+ }
+
+ public static Money zero(Currency currency) {
+ return new Money(BigDecimal.ZERO, currency);
+ }
+
+ public Money add(Money other) {
+ assertSameCurrency(other);
+ return new Money(this.amount.add(other.amount), this.currency);
+ }
+
+ public Money subtract(Money other) {
+ assertSameCurrency(other);
+ return new Money(this.amount.subtract(other.amount), this.currency);
+ }
+
+ public Money multiply(long factor) {
+ return new Money(this.amount.multiply(BigDecimal.valueOf(factor)), this.currency);
+ }
+
+ public Money multiply(BigDecimal factor) {
+ DomainValidation.requireNonNull(factor, "Multiplication factor");
+ return new Money(this.amount.multiply(factor), this.currency);
+ }
+
+ public Money abs() {
+ return new Money(this.amount.abs(), this.currency);
+ }
+
+ public Money negate() {
+ return new Money(this.amount.negate(), this.currency);
+ }
+
+ public void assertNonNegative() {
+ if (isNegative()) {
+ throw new NegativeAmountNotAllowedException("Amount cannot be negative: " + this.amount);
+ }
+ }
+
+ public boolean isPositive() {
+ return this.amount.compareTo(BigDecimal.ZERO) > 0;
+ }
+
+ public boolean isNegative() {
+ return this.amount.compareTo(BigDecimal.ZERO) < 0;
+ }
+
+ public boolean isZero() {
+ return this.amount.compareTo(BigDecimal.ZERO) == 0;
+ }
+
+ public boolean isGreaterThan(Money other) {
+ assertSameCurrency(other);
+ return this.compareTo(other) > 0;
+ }
+
+ public boolean isGreaterThanOrEqual(Money other) {
+ assertSameCurrency(other);
+ return this.compareTo(other) >= 0;
+ }
+
+ public boolean isLessThan(Money other) {
+ assertSameCurrency(other);
+ return this.compareTo(other) < 0;
+ }
+
+ public boolean isLessThanOrEqual(Money other) {
+ assertSameCurrency(other);
+ return this.compareTo(other) <= 0;
+ }
+
+ @Override
+ public boolean sameValueAs(ValueObject other) {
+ return equals(other);
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (this == other) {
+ return true;
+ }
+ if (!(other instanceof Money money)) {
+ return false;
+ }
+ return amount.equals(money.amount) && currency.equals(money.currency);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(amount, currency);
+ }
+
+ private void assertSameCurrency(Money other) {
+ DomainValidation.requireNonNull(other, "Comparing Money operand");
+ if (!this.currency.equals(other.currency)) {
+ throw new CurrencyMismatchException(
+ String.format(
+ "Currencies do not match: %s vs %s",
+ this.currency.getCurrencyCode(), other.currency.getCurrencyCode()));
+ }
+ }
+
+ @Override
+ public int compareTo(Money o) {
+ assertSameCurrency(o);
+ return this.amount.compareTo(o.amount);
+ }
+}
diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/TransactionId.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/TransactionId.java
new file mode 100644
index 0000000..de60972
--- /dev/null
+++ b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/TransactionId.java
@@ -0,0 +1,24 @@
+package dev.amg.payguard.domain.value_object;
+
+import domain.DomainValidation;
+import domain.ValueObject;
+import java.util.UUID;
+
+public record TransactionId(UUID value) implements ValueObject {
+ public TransactionId {
+ DomainValidation.requireNonNull(value, "TransactionId value");
+ }
+
+ public static TransactionId generate() {
+ return new TransactionId(UUID.randomUUID());
+ }
+
+ public static TransactionId fromString(String uuidString) {
+ return new TransactionId(UUID.fromString(uuidString));
+ }
+
+ @Override
+ public boolean sameValueAs(ValueObject other) {
+ return equals(other);
+ }
+}
diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/UserId.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/UserId.java
new file mode 100644
index 0000000..ad77d0e
--- /dev/null
+++ b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/UserId.java
@@ -0,0 +1,24 @@
+package dev.amg.payguard.domain.value_object;
+
+import domain.DomainValidation;
+import domain.ValueObject;
+import java.util.UUID;
+
+public record UserId(UUID value) implements ValueObject {
+ public UserId {
+ DomainValidation.requireNonNull(value, "UserId value");
+ }
+
+ public static UserId generate() {
+ return new UserId(UUID.randomUUID());
+ }
+
+ public static UserId fromString(String uuidString) {
+ return new UserId(UUID.fromString(uuidString));
+ }
+
+ @Override
+ public boolean sameValueAs(ValueObject other) {
+ return equals(other);
+ }
+}
diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/WalletId.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/WalletId.java
new file mode 100644
index 0000000..7c1d0c7
--- /dev/null
+++ b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/WalletId.java
@@ -0,0 +1,28 @@
+package dev.amg.payguard.domain.value_object;
+
+import domain.DomainValidation;
+import domain.ValueObject;
+import java.util.UUID;
+
+public record WalletId(UUID value) implements ValueObject {
+ public WalletId {
+ DomainValidation.requireNonNull(value, "WalletId value");
+ }
+
+ public static WalletId generate() {
+ return new WalletId(UUID.randomUUID());
+ }
+
+ public static WalletId fromString(String uuidString) {
+ return new WalletId(UUID.fromString(uuidString));
+ }
+
+ public AccountId toAccountId() {
+ return new AccountId(this.value);
+ }
+
+ @Override
+ public boolean sameValueAs(ValueObject other) {
+ return equals(other);
+ }
+}
diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/annotation/HandleWalletExceptions.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/annotation/HandleWalletExceptions.java
new file mode 100644
index 0000000..77a00bd
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/annotation/SecureAuditOnFailure.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/annotation/SecureAuditOnFailure.java
new file mode 100644
index 0000000..e4e02f2
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/RestControllerExceptionAspect.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/RestControllerExceptionAspect.java
new file mode 100644
index 0000000..0c7a0d3
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/ServiceExceptionEnricherAspect.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/ServiceExceptionEnricherAspect.java
new file mode 100644
index 0000000..7ff0b1d
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/SuspiciousActivityTrackerAspect.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/SuspiciousActivityTrackerAspect.java
new file mode 100644
index 0000000..a4afbfc
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/model/UnifiedResponse.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/model/UnifiedResponse.java
new file mode 100644
index 0000000..e16508c
--- /dev/null
+++ b/payguard-wallet-service/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-service/src/main/resources/application.yaml b/payguard-wallet-service/src/main/resources/application.yaml
index ec775aa..3b31bc3 100644
--- a/payguard-wallet-service/src/main/resources/application.yaml
+++ b/payguard-wallet-service/src/main/resources/application.yaml
@@ -7,4 +7,4 @@ spring:
password: ${ORACLE_APP_PASSWORD:payguard_pass}
driver-class-name: oracle.jdbc.OracleDriver
jooq:
- sql-dialect: oracle
+ sql-dialect: default
diff --git a/payguard-wallet-service/src/test/java/dev/amg/payguard/WalletArchitectureTests.java b/payguard-wallet-service/src/test/java/dev/amg/payguard/WalletArchitectureTests.java
new file mode 100644
index 0000000..90a802b
--- /dev/null
+++ b/payguard-wallet-service/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/pom.xml b/pom.xml
index 4cf81dc..09150aa 100644
--- a/pom.xml
+++ b/pom.xml
@@ -22,14 +22,15 @@
25
- 25
- 25
+ 25
UTF-8
4.0.1
1.68.0
3.25.5
3.19.15
23.6.0.24.10
+ 0.6.1
+ 1.7.1
@@ -48,6 +49,16 @@
pom
import
+
+ com.google.protobuf
+ protobuf-java
+ ${protobuf.version}
+
+
+ com.google.protobuf
+ protobuf-java-util
+ ${protobuf.version}
+
com.oracle.database.jdbc
ojdbc11
@@ -61,6 +72,13 @@
+
+
+ kr.motd.maven
+ os-maven-plugin
+ ${os.maven.plugin.version}
+
+
org.springframework.boot
@@ -145,6 +163,9 @@
21
true
true
+
+ ${project.build.directory}/generated-sources
+
category/java/errorprone.xml