Audit every transfer attempt, rejected ones included
Java · Spring Boot · advanced · modification
Compliance wants a trail of every transfer attempt, including the ones we turn down, so this adds a `transfer_audit` table (Flyway V14), the `TransferAudit` entity and its repository. `TransferService.transfer` now wraps the existing logic, moved unchanged into a private `move` method. A completed transfer saves its `COMPLETED` row inside the transfer's transaction, so the row and the balance changes commit together. A rejected one is caught, recorded through `recordRejection`, which is marked `REQUIRES_NEW` so the `REJECTED` row is written in a transaction of its own, and the exception is rethrown so the controller still answers 422.
Banking backend on Spring Boot 3.3.5 (Java 21), Spring Data JPA with Hibernate 6.5 on PostgreSQL 16 (read committed), Flyway for schema changes with `spring.jpa.hibernate.ddl-auto=validate` and `spring.jpa.open-in-view=false`. Transactions are Spring's default proxy-based `@Transactional` (no AspectJ weaving); `TransferService` is a singleton bean that only `TransferController` calls. Existing and unchanged: `TransferController` (`POST /api/transfers`, `@Valid` body, the signed-in `BankUser`'s `customerId`), `TransferRequest` (`fromAccountId` and `toAccountId` non-null, `amount` positive with at most 2 decimals and at most 1,000,000.00), `TransferResult`, `RejectionReason`, `TransferRejectedException` (a `RuntimeException` annotated `@ResponseStatus(UNPROCESSABLE_ENTITY)`), `AccountNotFoundException` (a `RuntimeException`, `@ResponseStatus(NOT_FOUND)`), the `Account` entity (`balance` is `numeric(19,2)` and stays far below its range) and `AccountRepository.findForUpdate` (`@Lock(PESSIMISTIC_WRITE)`, a row write lock: `SELECT ... FOR NO KEY UPDATE` on PostgreSQL). Spring's default rollback rules apply: an unchecked exception leaving a `@Transactional` method rolls its transaction back. Error bodies are Spring Boot's default with `server.error.include-message=never`. The Hikari pool (20 connections) is sized for two connections per request at the server's request concurrency, which the platform's other `REQUIRES_NEW` writers already rely on. The `Clock` bean is `Clock.systemUTC()`.
Requirements
- Every transfer attempt that `TransferService.transfer` completes or rejects leaves exactly one row in the new `transfer_audit` table: the customer, both account ids, the amount, the outcome (`COMPLETED` or `REJECTED`), the rejection reason for a rejected attempt (null otherwise) and the time from the application's `Clock` bean.
- A completed transfer's audit row is written in the same transaction as the balance changes: it commits if and only if the transfer commits.
- A rejected attempt (`TransferRejectedException`: `SAME_ACCOUNT`, `ACCOUNT_FROZEN` or `INSUFFICIENT_FUNDS`) must still leave its audit row in the database once the request has finished, even though the transfer's own transaction rolls back and no balance changes. The client still gets the existing 422 for the rejection.
- An attempt answered 404 (`AccountNotFoundException`: an unknown account, or a source account that is not the caller's) is not a transfer attempt for this audit and writes no row. If writing an audit row fails, the request may fail with a server error.
- The transfer rules themselves (the checks, their order, the 422 and 404 answers, the balance updates) do not change.
Files touched
- src/main/java/com/acme/bank/transfer/TransferService.java
- src/main/java/com/acme/bank/transfer/TransferAudit.java
- src/main/java/com/acme/bank/transfer/TransferAuditRepository.java
- src/main/resources/db/migration/V14__transfer_audit.sql
--- src/main/java/com/acme/bank/transfer/TransferService.java
import com.acme.bank.account.AccountNotFoundException;
import com.acme.bank.account.AccountRepository;
+import java.time.Clock;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
private final AccountRepository accounts;
+ private final TransferAuditRepository audits;
+ private final Clock clock;
- public TransferService(AccountRepository accounts) {
+ public TransferService(AccountRepository accounts, TransferAuditRepository audits, Clock clock) {
this.accounts = accounts;
+ this.audits = audits;
+ this.clock = clock;
}
@Transactional
public TransferResult transfer(long customerId, TransferRequest req) {
+ try {
+ TransferResult result = move(customerId, req);
+ audits.save(TransferAudit.completed(customerId, req, clock.instant()));
+ return result;
+ } catch (TransferRejectedException e) {
+ recordRejection(customerId, req, e.getReason());
+ throw e;
+ }