From 359c8dd4e3d77626f37f7989711e47a61706f374 Mon Sep 17 00:00:00 2001 From: DJAngel973 Date: Wed, 22 Apr 2026 22:29:30 -0500 Subject: [PATCH 1/7] feat(history): add TransactionHistory entity - immutable financial state trail (PCI-DSS/OWASP A09) --- .../entity/TransactionHistory.java | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 src/main/java/com/wallet/secure/transaction/entity/TransactionHistory.java diff --git a/src/main/java/com/wallet/secure/transaction/entity/TransactionHistory.java b/src/main/java/com/wallet/secure/transaction/entity/TransactionHistory.java new file mode 100644 index 0000000..4209ea1 --- /dev/null +++ b/src/main/java/com/wallet/secure/transaction/entity/TransactionHistory.java @@ -0,0 +1,175 @@ +package com.wallet.secure.transaction.entity; + +import com.wallet.secure.common.enums.TransactionStatus; +import com.wallet.secure.user.entity.User; +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.Instant; +import java.util.UUID; + +/** + * Records every status change in a transaction's lifecycle. + * + * Maps to: transaction_history table + * + * Why this table exists alongside audit_logs: + * + * audit_logs → WHO did WHAT in the system (security events) + * "user X tried to login", "wallet Y was suspended" + * + * transaction_history → HOW a transaction moved through states (financial trail) + * "transaction Z went PENDING → PROCESSING → COMPLETED" + * + * They serve different purposes: + * audit_logs → security forensics, OWASP A09 + * transaction_history → financial compliance, PCI-DSS, dispute resolution + * + * REAL EXAMPLE — dispute resolution: + * Customer: "I never authorized this transfer" + * Support: queries transaction_history for transaction UUID + * Response: "Transaction was PENDING at 14:01, PROCESSING at 14:01:02, + * COMPLETED at 14:01:03 — initiated from IP 192.168.1.100" + * + * DB CONSTRAINTS verified: + * CHECK (old_status IS DISTINCT FROM new_status) → no redundant entries + * ON DELETE CASCADE → if transaction deleted, history goes with it + * + * OWASP A09: immutable financial trail — never updated, only inserted. + */ +@Entity +@Table(name = "transaction_history") +@EntityListeners(AuditingEntityListener.class) +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TransactionHistory { + + // ─── Identity + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", updatable = false, nullable = false) + private UUID id; + + // ─── Relationship + + /** + * The transaction this history entry belongs to. + * ON DELETE CASCADE — history is meaningless without the transaction. + * FetchType.LAZY — we rarely need the full transaction when reading history. + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "transaction_id", nullable = false) + private Transaction transaction; + + // ─── State Change + + /** + * The status BEFORE this change. + * NULL only for the first entry (PENDING has no previous state). + * Example: null → PENDING (initial creation) + * PENDING → PROCESSING (started execution) + */ + @Enumerated(EnumType.STRING) + @Column(name = "old_status") + private TransactionStatus oldStatus; + + /** + * The status AFTER this change — always set. + * DB constraint: NOT NULL + * DB CHECK: old_status IS DISTINCT FROM new_status (no no-op entries) + */ + @Enumerated(EnumType.STRING) + @Column(name = "new_status", nullable = false) + private TransactionStatus newStatus; + + // ─── Actor + + /** + * The user who triggered this status change. + * NULL = system-initiated change (automatic processing). + * NOT NULL = human-initiated change (admin reversal, user cancellation). + * + * ON DELETE SET NULL — keeps the history even if the user is deleted. + * OWASP A09: we need to know if a human or the system made the change. + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "changed_by") + private User changedBy; + + // ─── Context + + /** + * Human-readable reason for the change. + * NULL for automatic transitions (system processing). + * Required for admin-initiated changes (compliance). + * + * Examples: + * → null (automatic: PENDING → PROCESSING) + * → "Insufficient balance" (automatic: PROCESSING → FAILED) + * → "Reversed by admin due to fraud" (manual: COMPLETED → FAILED) + * → "Cancelled by user request" (manual: PENDING → FAILED) + */ + @Column(name = "reason", columnDefinition = "TEXT") + private String reason; + + // ─── Timestamp + + /** + * When this status change occurred. + * Set by Spring Auditing — never set manually. + * updatable = false — the moment of change is immutable. + * OWASP A09: exact timestamp is critical for financial dispute resolution. + */ + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + // ─── Factory Methods + + /** + * Creates a system-initiated history entry (no human actor, no reason). + * Used for: PENDING, PROCESSING, COMPLETED, FAILED (automatic transitions). + * + * @param transaction the transaction changing state + * @param oldStatus previous status (null for initial PENDING) + * @param newStatus new status after the change + */ + public static TransactionHistory system(Transaction transaction, + TransactionStatus oldStatus, + TransactionStatus newStatus) { + return TransactionHistory.builder() + .transaction(transaction) + .oldStatus(oldStatus) + .newStatus(newStatus) + .build(); + } + + /** + * Creates a human-initiated history entry (admin or user action). + * Used for: manual reversals, cancellations, admin overrides. + * + * @param transaction the transaction changing state + * @param oldStatus previous status + * @param newStatus new status + * @param changedBy the user who made the change + * @param reason why the change was made (required for manual changes) + */ + public static TransactionHistory manual(Transaction transaction, + TransactionStatus oldStatus, + TransactionStatus newStatus, + User changedBy, + String reason) { + return TransactionHistory.builder() + .transaction(transaction) + .oldStatus(oldStatus) + .newStatus(newStatus) + .changedBy(changedBy) + .reason(reason) + .build(); + } +} \ No newline at end of file From ee3d16ddb4ec8d342f41046b4d8d29ed3e3b01f0 Mon Sep 17 00:00:00 2001 From: DJAngel973 Date: Wed, 22 Apr 2026 22:37:12 -0500 Subject: [PATCH 2/7] feat(history): add TransactionHistoryRepository - timeline queries, wallet-level audit --- .../TransactionHistoryRepository.java | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/main/java/com/wallet/secure/transaction/repository/TransactionHistoryRepository.java diff --git a/src/main/java/com/wallet/secure/transaction/repository/TransactionHistoryRepository.java b/src/main/java/com/wallet/secure/transaction/repository/TransactionHistoryRepository.java new file mode 100644 index 0000000..3ee5713 --- /dev/null +++ b/src/main/java/com/wallet/secure/transaction/repository/TransactionHistoryRepository.java @@ -0,0 +1,64 @@ +package com.wallet.secure.transaction.repository; + +import com.wallet.secure.common.enums.TransactionStatus; +import com.wallet.secure.transaction.entity.TransactionHistory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +/** + * Data access for transaction state history. + * + * All queries return ordered by createdAt ASC — chronological timeline. + * This makes it easy to reconstruct "what happened to this transaction" + * in the order it happened. + */ +@Repository +public interface TransactionHistoryRepository extends JpaRepository { + + /** + * Returns the complete state timeline for a transaction. + * Ordered ASC — first entry is always PENDING (the creation). + * + * Use case: "Show me every state change for transaction X" + * Example result: + * null → PENDING (14:01:00) ← creation + * PENDING → PROCESSING (14:01:02) ← execution started + * PROCESSING → COMPLETED (14:01:03) ← success + */ + List findByTransactionIdOrderByCreatedAtAsc(UUID transactionId); + + /** + * Returns all FAILED transitions — useful for failure analysis. + * "How many transactions failed today and why?" + * Used by admin dashboard for monitoring. + */ + @Query("SELECT th FROM TransactionHistory th WHERE th.transaction.id = :transactionId AND th.newStatus = :status ORDER BY th.createdAt ASC") + List findByTransactionIdAndNewStatus( + @Param("transactionId") UUID transactionId, + @Param("status") TransactionStatus status); + + /** + * Returns the full history for all transactions of a wallet. + * Used by admin for wallet-level audit. + * "Show me all state changes for all transactions through wallet X" + * + * Why join through transaction and then wallet: + * transaction_history has no direct wallet FK. + * We navigate: history → transaction → source/target wallet. + */ + @Query("SELECT th FROM TransactionHistory th WHERE th.transaction.sourceWallet.id = :walletId OR th.transaction.targetWallet.id = :walletId ORDER BY th.createdAt DESC") + List findByWalletId(@Param("walletId") UUID walletId); + + /** + * Checks if a transaction has already been processed (has non-PENDING history). + * Used as a safety check before reprocessing a transaction. + * Prevents double-execution of financial operations. + */ + @Query("SELECT COUNT(th) > 0 FROM TransactionHistory th WHERE th.transaction.id = :transactionId AND th.newStatus != com.wallet.secure.common.enums.TransactionStatus.PENDING") + boolean hasBeenProcessed(@Param("transactionId") UUID transactionId); +} \ No newline at end of file From 2eb2494656b2ffaab2c068024144cd6d9652597b Mon Sep 17 00:00:00 2001 From: DJAngel973 Date: Wed, 22 Apr 2026 22:57:58 -0500 Subject: [PATCH 3/7] feat(history): add TransactionHistoryResponse DTO - automatic flag, changedBy snapshot --- .../dto/TransactionHistoryResponse.java | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 src/main/java/com/wallet/secure/transaction/dto/TransactionHistoryResponse.java diff --git a/src/main/java/com/wallet/secure/transaction/dto/TransactionHistoryResponse.java b/src/main/java/com/wallet/secure/transaction/dto/TransactionHistoryResponse.java new file mode 100644 index 0000000..c312e48 --- /dev/null +++ b/src/main/java/com/wallet/secure/transaction/dto/TransactionHistoryResponse.java @@ -0,0 +1,101 @@ +package com.wallet.secure.transaction.dto; + +import com.wallet.secure.common.enums.TransactionStatus; +import com.wallet.secure.transaction.entity.TransactionHistory; +import lombok.Builder; +import lombok.Getter; + +import java.time.Instant; +import java.util.UUID; + +/** + * DTO for transaction history entries. + * + * OWASP A01 — what is NOT exposed: + * → Full Transaction entity ← too much data, use TransactionResponse for that + * → Full User entity (changedBy)← only expose the UUID + email snapshot + * → Internal wallet IDs ← not relevant for history display + * + * WHAT IS exposed and why: + * → transactionId ← client can cross-reference with TransactionResponse + * → oldStatus ← "came FROM this state" + * → newStatus ← "moved TO this state" + * → changedById ← null = system, UUID = human actor + * → changedByEmail ← human-readable actor identification + * → reason ← why it changed (null for automatic transitions) + * → createdAt ← exact timestamp of the change + * → automatic ← convenience flag: true if system-initiated + * + * The combination of oldStatus + newStatus + createdAt + reason + * gives a complete, human-readable audit trail entry. + */ +@Getter +@Builder +public class TransactionHistoryResponse { + + /** UUID of the history entry itself */ + private final UUID id; + + /** UUID of the transaction this entry belongs to */ + private final UUID transactionId; + + /** + * Status before this change. + * NULL for the first entry (initial PENDING creation has no previous state). + */ + private final TransactionStatus oldStatus; + + /** Status after this change — always set */ + private final TransactionStatus newStatus; + + /** + * UUID of the user who triggered the change. + * NULL = system-initiated (automatic processing). + * NOT NULL = human actor (admin reversal, user cancellation). + */ + private final UUID changedById; + + /** + * Email of the user who triggered the change. + * NULL for system-initiated changes. + * Snapshot — the user may no longer exist but the record remains. + */ + private final String changedByEmail; + + /** + * Why the status changed. + * NULL for automatic system transitions. + * Required (and always set) for manual admin changes. + * Examples: "Insufficient balance", "Reversed by admin due to fraud" + */ + private final String reason; + + /** Exact moment this status change occurred */ + private final Instant createdAt; + + /** + * Convenience flag for the frontend. + * true = system made this change automatically + * false = a human (admin or user) made this change + * Derived from changedById == null. + */ + private final boolean automatic; + + // ─── Factory Method + + public static TransactionHistoryResponse fromEntity(TransactionHistory history) { + return TransactionHistoryResponse.builder() + .id(history.getId()) + .transactionId(history.getTransaction().getId()) + .oldStatus(history.getOldStatus()) + .newStatus(history.getNewStatus()) + .changedById(history.getChangedBy() != null + ? history.getChangedBy().getId() : null) + .changedByEmail(history.getChangedBy() != null + ? history.getChangedBy().getEmail() : null) + .reason(history.getReason()) + .createdAt(history.getCreatedAt()) + .automatic(history.getChangedBy() == null) + .build(); + } +} \ No newline at end of file From 2716c1dedf907df3b73728e626fc25db47fdf4ff Mon Sep 17 00:00:00 2001 From: DJAngel973 Date: Wed, 22 Apr 2026 23:14:16 -0500 Subject: [PATCH 4/7] feat(history): add TransactionHistoryService - append-only state trail, ownership-checked reads (OWASP A01/A09) --- .../service/TransactionHistoryService.java | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 src/main/java/com/wallet/secure/transaction/service/TransactionHistoryService.java diff --git a/src/main/java/com/wallet/secure/transaction/service/TransactionHistoryService.java b/src/main/java/com/wallet/secure/transaction/service/TransactionHistoryService.java new file mode 100644 index 0000000..85fa26d --- /dev/null +++ b/src/main/java/com/wallet/secure/transaction/service/TransactionHistoryService.java @@ -0,0 +1,177 @@ +package com.wallet.secure.transaction.service; + +import com.wallet.secure.common.enums.TransactionStatus; +import com.wallet.secure.common.exception.ResourceNotFoundException; +import com.wallet.secure.common.response.ApiResponse; +import com.wallet.secure.transaction.dto.TransactionHistoryResponse; +import com.wallet.secure.transaction.entity.Transaction; +import com.wallet.secure.transaction.entity.TransactionHistory; +import com.wallet.secure.transaction.repository.TransactionHistoryRepository; +import com.wallet.secure.transaction.repository.TransactionRepository; +import com.wallet.secure.user.entity.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +/** + * Manages the transaction state history trail. + * + * TWO responsibilities: + * + * 1. WRITE — record state changes (called by TransactionService) + * Every time a transaction changes status: + * PENDING → PROCESSING → COMPLETED/FAILED + * A new TransactionHistory entry is created. + * + * 2. READ — query history (called by TransactionHistoryController) + * Users see their own transaction timeline. + * Admins see full history including wallet-level audit. + * + * WHY a separate service and not inline in TransactionService: + * TransactionService is already large (deposit, withdraw, transfer). + * History recording is a cross-cutting concern — it happens in ALL operations. + * Extracting it keeps TransactionService focused on financial logic. + * Single Responsibility: TransactionService moves money, + * TransactionHistoryService records the trail. + * + * OWASP A09: history entries are NEVER updated or deleted. + * They are append-only — each INSERT is a permanent forensic record. + */ +@Service +@RequiredArgsConstructor +@Log4j2 +public class TransactionHistoryService { + + private final TransactionHistoryRepository historyRepository; + private final TransactionRepository transactionRepository; + + // ─── Write Operations (called by TransactionService) + + /** + * Records an automatic (system-initiated) status change. + * Called by TransactionService at each lifecycle step. + * + * Usage in TransactionService: + * historyService.record(transaction, null, PENDING) // creation + * historyService.record(transaction, PENDING, PROCESSING) // started + * historyService.record(transaction, PROCESSING, COMPLETED) // success + * historyService.record(transaction, PROCESSING, FAILED) // failure + * + * @param transaction the transaction changing state + * @param oldStatus previous status (null for initial PENDING) + * @param newStatus new status after the change + */ + @Transactional + public void record(Transaction transaction, + TransactionStatus oldStatus, + TransactionStatus newStatus) { + + TransactionHistory entry = TransactionHistory.system(transaction, oldStatus, newStatus); + historyRepository.save(entry); + + log.debug("History recorded: txId={} {} → {}", + transaction.getId(), oldStatus, newStatus); + } + + /** + * Records a manual (human-initiated) status change. + * Called when an admin reverses or cancels a transaction. + * + * @param transaction the transaction changing state + * @param oldStatus previous status + * @param newStatus new status + * @param changedBy the user making the change + * @param reason mandatory explanation for the change + */ + @Transactional + public void recordManual(Transaction transaction, + TransactionStatus oldStatus, + TransactionStatus newStatus, + User changedBy, + String reason) { + + TransactionHistory entry = TransactionHistory.manual( + transaction, oldStatus, newStatus, changedBy, reason); + historyRepository.save(entry); + + log.info("Manual history recorded: txId={} {} → {} by userId={} reason={}", + transaction.getId(), oldStatus, newStatus, changedBy.getId(), reason); + } + + // ─── Read Operations (called by TransactionHistoryController) + + /** + * Returns the complete chronological state timeline for a transaction. + * Verifies the requesting user owns the transaction before returning. + * + * OWASP A01: users can only see history of their OWN transactions. + * findByIdAndUserId ensures ownership — same pattern as TransactionService. + * + * @param transactionId UUID of the transaction + * @param userId authenticated user's ID (from JWT) + */ + @Transactional(readOnly = true) + public ApiResponse> getTransactionTimeline( + UUID transactionId, UUID userId) { + + // Ownership check — user must be sender or receiver of this transaction + transactionRepository.findByIdAndUserId(transactionId, userId) + .orElseThrow(() -> new ResourceNotFoundException("Transaction not found")); + + List timeline = historyRepository + .findByTransactionIdOrderByCreatedAtAsc(transactionId) + .stream() + .map(TransactionHistoryResponse::fromEntity) + .toList(); + + return ApiResponse.ok("Transaction timeline retrieved", timeline); + } + + /** + * Returns the complete state timeline for a transaction — ADMIN only. + * No ownership check — admin can see any transaction's history. + * + * @param transactionId UUID of the transaction + */ + @Transactional(readOnly = true) + public ApiResponse> getTransactionTimelineAdmin( + UUID transactionId) { + + // Verify the transaction exists + transactionRepository.findById(transactionId) + .orElseThrow(() -> new ResourceNotFoundException("Transaction not found")); + + List timeline = historyRepository + .findByTransactionIdOrderByCreatedAtAsc(transactionId) + .stream() + .map(TransactionHistoryResponse::fromEntity) + .toList(); + + return ApiResponse.ok("Transaction timeline retrieved", timeline); + } + + /** + * Returns full history for all transactions through a specific wallet. + * ADMIN only — used for wallet-level forensic investigation. + * + * Use case: "Show me every state change for every transaction + * that touched wallet X" + * + * @param walletId UUID of the wallet to audit + */ + @Transactional(readOnly = true) + public ApiResponse> getWalletHistory(UUID walletId) { + + List history = historyRepository + .findByWalletId(walletId) + .stream() + .map(TransactionHistoryResponse::fromEntity) + .toList(); + + return ApiResponse.ok("Wallet transaction history retrieved", history); + } +} \ No newline at end of file From 74ff5eb1048aac03fac9445af4f4b05a31e94fd3 Mon Sep 17 00:00:00 2001 From: DJAngel973 Date: Wed, 22 Apr 2026 23:22:38 -0500 Subject: [PATCH 5/7] feat(history): integrate TransactionHistoryService into TransactionService - record all PENDING/PROCESSING/COMPLETED/FAILED transitions --- .../transaction/service/TransactionService.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/main/java/com/wallet/secure/transaction/service/TransactionService.java b/src/main/java/com/wallet/secure/transaction/service/TransactionService.java index d78d01a..e1ee779 100644 --- a/src/main/java/com/wallet/secure/transaction/service/TransactionService.java +++ b/src/main/java/com/wallet/secure/transaction/service/TransactionService.java @@ -22,6 +22,8 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.wallet.secure.transaction.service.TransactionHistoryService; +import com.wallet.secure.common.enums.TransactionStatus; import java.util.UUID; @@ -78,6 +80,7 @@ public class TransactionService { private final WalletRepository walletRepository; private final WalletService walletService; private final AuditService auditService; + private final TransactionHistoryService historyService; // --- DEPOSIT @@ -121,10 +124,12 @@ public ApiResponse deposit(UUID userId, DepositRequest requ .referenceCode(request.getReferenceCode()) .build(); transactionRepository.save(transaction); + historyService.record(transaction, null, TransactionStatus.PENDING); try { // Step 2: Mark as PROCESSING - prevents duplicate execution transaction.markAsProcessing(); + historyService.record(transaction, TransactionStatus.PENDING, TransactionStatus.PROCESSING); // Step 3: Acquire lock on target wallet Wallet lockedTarget = walletRepository.findByIdWithLock(targetWallet.getId()) @@ -137,6 +142,7 @@ public ApiResponse deposit(UUID userId, DepositRequest requ // Step 5: Mark as COMPLETED transaction.markAsCompleted(); transactionRepository.save(transaction); + historyService.record(transaction, TransactionStatus.PROCESSING, TransactionStatus.COMPLETED); auditService.logTransactionSuccess( userId, @@ -159,6 +165,7 @@ public ApiResponse deposit(UUID userId, DepositRequest requ // @Transactional will roll back the balance change transaction.markAsFailed(); transactionRepository.save(transaction); + historyService.record(transaction, TransactionStatus.PROCESSING, TransactionStatus.FAILED); auditService.logTransactionFailure( userId, TransactionType.DEPOSIT.name(), @@ -208,9 +215,11 @@ public ApiResponse withdraw(UUID userId, WithdrawRequest re .referenceCode(request.getReferenceCode()) .build(); transactionRepository.save(transaction); + historyService.record(transaction, null, TransactionStatus.PENDING); try { transaction.markAsProcessing(); + historyService.record(transaction, TransactionStatus.PENDING, TransactionStatus.PROCESSING); // Step 3: Acquire lock on source wallet Wallet lockedSource = walletRepository.findByIdWithLock(sourceWallet.getId()) @@ -228,6 +237,7 @@ public ApiResponse withdraw(UUID userId, WithdrawRequest re transaction.markAsCompleted(); transactionRepository.save(transaction); + historyService.record(transaction, TransactionStatus.PROCESSING, TransactionStatus.COMPLETED); auditService.logTransactionSuccess( userId, @@ -248,6 +258,7 @@ public ApiResponse withdraw(UUID userId, WithdrawRequest re } catch (Exception e) { transaction.markAsFailed(); transactionRepository.save(transaction); + historyService.record(transaction, TransactionStatus.PROCESSING, TransactionStatus.FAILED); auditService.logTransactionFailure( userId, @@ -318,9 +329,11 @@ public ApiResponse transfer(UUID userId, TransferRequest re .description(request.getDescription()) .build(); transactionRepository.save(transaction); + historyService.record(transaction, null, TransactionStatus.PENDING); try { transaction.markAsProcessing(); + historyService.record(transaction, TransactionStatus.PENDING, TransactionStatus.PROCESSING); // Step 3: Acquire locks in UUID order — deadlock prevention UUID firstLock = sourceWallet.getId().compareTo(targetWallet.getId()) < 0 @@ -352,6 +365,7 @@ public ApiResponse transfer(UUID userId, TransferRequest re transaction.markAsCompleted(); transactionRepository.save(transaction); + historyService.record(transaction, TransactionStatus.PROCESSING, TransactionStatus.COMPLETED); auditService.logTransactionSuccess( userId, @@ -373,6 +387,7 @@ public ApiResponse transfer(UUID userId, TransferRequest re } catch (Exception e) { transaction.markAsFailed(); transactionRepository.save(transaction); + historyService.record(transaction, TransactionStatus.PROCESSING, TransactionStatus.FAILED); auditService.logTransactionFailure( userId, TransactionType.TRANSFER.name(), e.getMessage(), null, null); From 91ee53c3537ebbd43663918cd9b7b45143011627 Mon Sep 17 00:00:00 2001 From: DJAngel973 Date: Thu, 23 Apr 2026 00:27:02 -0500 Subject: [PATCH 6/7] fix(test): add TransactionHistoryService @Mock in TransactionServiceTest - NullPointerException after history integration --- .../secure/transaction/service/TransactionServiceTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/java/com/wallet/secure/transaction/service/TransactionServiceTest.java b/src/test/java/com/wallet/secure/transaction/service/TransactionServiceTest.java index f76fb7c..9ef29b6 100644 --- a/src/test/java/com/wallet/secure/transaction/service/TransactionServiceTest.java +++ b/src/test/java/com/wallet/secure/transaction/service/TransactionServiceTest.java @@ -25,6 +25,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.wallet.secure.transaction.service.TransactionHistoryService; import java.math.BigDecimal; import java.util.Optional; @@ -60,6 +61,7 @@ class TransactionServiceTest { @Mock private WalletRepository walletRepository; @Mock private WalletService walletService; @Mock private AuditService auditService; + @Mock private TransactionHistoryService historyService; @InjectMocks private TransactionService transactionService; From f463bd4b6ec1914df9cb9d344ba9b12d0470cc8f Mon Sep 17 00:00:00 2001 From: DJAngel973 Date: Thu, 23 Apr 2026 21:08:32 -0500 Subject: [PATCH 7/7] feat(history): add TransactionHistoryController - transaction timeline, wallet audit endpoints (OWASP A01/A09) --- .../TransactionHistoryController.java | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 src/main/java/com/wallet/secure/transaction/controller/TransactionHistoryController.java diff --git a/src/main/java/com/wallet/secure/transaction/controller/TransactionHistoryController.java b/src/main/java/com/wallet/secure/transaction/controller/TransactionHistoryController.java new file mode 100644 index 0000000..36b2a40 --- /dev/null +++ b/src/main/java/com/wallet/secure/transaction/controller/TransactionHistoryController.java @@ -0,0 +1,164 @@ +package com.wallet.secure.transaction.controller; + +import com.wallet.secure.common.response.ApiResponse; +import com.wallet.secure.transaction.dto.TransactionHistoryResponse; +import com.wallet.secure.transaction.service.TransactionHistoryService; +import com.wallet.secure.user.repository.UserRepository; +import com.wallet.secure.common.exception.ResourceNotFoundException; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +/** + * REST Controller for transaction history (state timeline). + * + * Base path: /transactions/history + * + * WHO can call each endpoint: + * → Any authenticated user → timeline of their OWN transactions + * → ADMIN only → timeline of ANY transaction + wallet audit + * + * Endpoint summary: + * GET /transactions/{transactionId}/history + * → User: timeline of one of their own transactions + * + * GET /transactions/{transactionId}/history/admin + * → ADMIN: timeline of any transaction regardless of owner + * + * GET /wallets/{walletId}/history + * → ADMIN: all state changes for all transactions through a wallet + * + * WHY history endpoints are nested under /transactions/{id}: + * History belongs to a transaction — it is a sub-resource. + * REST convention: /transactions/{id}/history reads naturally as + * "the history of transaction X". + * This is consistent with how /wallets/{id}/transactions works. + * + * OWASP A01: userId always from JWT — users cannot access other users' + * transaction history by guessing a transaction UUID. + * Ownership check is enforced in TransactionHistoryService. + * + * OWASP A09: every state change is permanently recorded. + * This controller only READS — history is never modified or deleted. + */ +@RestController +@RequiredArgsConstructor +@Log4j2 +public class TransactionHistoryController { + + private final TransactionHistoryService historyService; + private final UserRepository userRepository; + + // ─── User Endpoints + + /** + * GET /transactions/{transactionId}/history + * Returns the complete state timeline for one of the user's transactions. + * + * Use case: "Show me everything that happened to this transaction" + * Example response for a successful deposit: + * [ + * { oldStatus: null, newStatus: "PENDING", automatic: true }, + * { oldStatus: "PENDING", newStatus: "PROCESSING", automatic: true }, + * { oldStatus: "PROCESSING", newStatus: "COMPLETED", automatic: true } + * ] + * + * Example response for a failed withdrawal: + * [ + * { oldStatus: null, newStatus: "PENDING", automatic: true }, + * { oldStatus: "PENDING", newStatus: "PROCESSING", automatic: true }, + * { oldStatus: "PROCESSING", newStatus: "FAILED", reason: "Insufficient balance" } + * ] + * + * OWASP A01: TransactionHistoryService.getTransactionTimeline() verifies + * the requesting user is the sender or receiver of this transaction. + * If the transaction exists but belongs to someone else → 404 (not 403). + * Same error as "not found" — prevents transaction UUID enumeration. + * + * @param transactionId UUID of the transaction + */ + @GetMapping("/transactions/{transactionId}/history") + public ResponseEntity>> getMyTransactionHistory( + @AuthenticationPrincipal UserDetails userDetails, + @PathVariable UUID transactionId) { + + UUID userId = resolveUserId(userDetails.getUsername()); + + return ResponseEntity.ok( + historyService.getTransactionTimeline(transactionId, userId)); + } + + // ─── Admin Endpoints + + /** + * GET /transactions/{transactionId}/history/admin + * Returns the complete state timeline for ANY transaction — ADMIN only. + * + * Use case: "Customer disputes this transaction — show me the full trail" + * Identical response format to the user endpoint but no ownership check. + * + * WHY a separate endpoint instead of role-checking in the same method: + * → Clear URL structure — admin endpoints are visually distinct + * → @PreAuthorize at method level — authorization is explicit in code + * → Easier to audit: grep "/admin" shows all privileged endpoints + * + * OWASP A01: @PreAuthorize evaluated BEFORE method — non-ADMIN gets 403. + */ + @GetMapping("/transactions/{transactionId}/history/admin") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity>> getTransactionHistoryAdmin( + @PathVariable UUID transactionId) { + + return ResponseEntity.ok( + historyService.getTransactionTimelineAdmin(transactionId)); + } + + /** + * GET /wallets/{walletId}/history + * Returns all state changes for all transactions through a wallet — ADMIN only. + * + * Use case: "Audit everything that happened through this wallet" + * Returns history entries for BOTH source and target sides. + * Example: wallet X sent a transfer AND received a deposit → both appear. + * + * WHY this is admin-only: + * A wallet can receive money from external users. + * Showing that history to the wallet owner would expose other users' + * transaction IDs and amounts → privacy violation. + * ADMIN only sees this for legitimate investigation purposes. + * + * OWASP A01: @PreAuthorize — non-ADMIN gets 403 before any DB query. + * OWASP A09: wallet-level forensic audit for compliance. + * + * @param walletId UUID of the wallet to audit + */ + @GetMapping("/wallets/{walletId}/history") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity>> getWalletHistory( + @PathVariable UUID walletId) { + + log.info("ADMIN wallet history audit: walletId={}", walletId); + + return ResponseEntity.ok( + historyService.getWalletHistory(walletId)); + } + + // ─── Private Helper + + /** + * Resolves user UUID from their email (from JWT). + * OWASP A01: identity always from the trusted token — never from request. + */ + private UUID resolveUserId(String email) { + return userRepository.findByEmail(email) + .orElseThrow(() -> new ResourceNotFoundException("User not found")) + .getId(); + } +} \ No newline at end of file