Skip to main content

Financial Transaction Ledgers

Financial transaction ledgers are the foundation of any system handling money. They provide an immutable, auditable record of all financial activities using principles refined over centuries of accounting practice. This guide covers double-entry bookkeeping, event sourcing patterns, consistency guarantees, and audit requirements for building robust financial ledgers.

Double-Entry Bookkeeping Principles

Double-entry bookkeeping ensures financial integrity by recording every transaction as two equal and opposite entries. This fundamental principle, dating back to the 15th century, prevents imbalances and provides built-in error detection.

Core Concepts

Every financial transaction affects at least two accounts: one debit and one credit. The total debits must always equal total credits, maintaining the accounting equation:

Assets = Liabilities + Equity

Debits increase asset and expense accounts, decrease liability, equity, and revenue accounts. Credits decrease asset and expense accounts, increase liability, equity, and revenue accounts.

Account Types

Account TypeNormal BalanceDebit EffectCredit EffectExamples
AssetDebitIncreaseDecreaseCash, Accounts Receivable, Inventory
LiabilityCreditDecreaseIncreaseAccounts Payable, Loans, Deferred Revenue
EquityCreditDecreaseIncreaseShare Capital, Retained Earnings
RevenueCreditDecreaseIncreaseSales, Service Revenue, Interest Income
ExpenseDebitIncreaseDecreaseSalaries, Rent, Utilities

Understanding account types is critical for correctly implementing ledger entries. An account's "normal balance" indicates which side (debit or credit) increases its value.

Example: Customer Payment

When a customer pays $100 for a service:

  1. Debit Cash (Asset) +$100 - Cash increases
  2. Credit Revenue (Revenue) +$100 - Revenue increases

Both entries are $100, maintaining balance. The system records both simultaneously in an atomic transaction.

/**
* Domain model representing a double-entry ledger transaction.
*
* Every transaction consists of multiple entries (at least 2) where
* total debits equal total credits. This enforces the fundamental
* accounting equation and provides built-in error detection.
*/
@Entity
@Table(name = "ledger_transactions")
public class LedgerTransaction {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false, unique = true)
private String transactionId; // Business identifier (UUID)

@Column(nullable = false)
private Instant transactionDate;

@Column(nullable = false)
private String description;

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private TransactionType type; // PAYMENT, TRANSFER, REFUND, etc.

@OneToMany(mappedBy = "transaction", cascade = CascadeType.ALL, orphanRemoval = true)
private List<LedgerEntry> entries = new ArrayList<>();

@Column(nullable = false)
private String createdBy;

@Column(nullable = false)
private Instant createdAt;

/**
* Adds a ledger entry to this transaction.
*
* Validates that the entry contributes to a balanced transaction.
* Entries are added in pairs (debit and credit) before persisting.
*/
public void addEntry(LedgerEntry entry) {
entries.add(entry);
entry.setTransaction(this);
}

/**
* Validates that debits equal credits.
*
* This is a fundamental invariant of double-entry bookkeeping.
* Transactions failing this check cannot be persisted.
*/
public boolean isBalanced() {
BigDecimal totalDebits = entries.stream()
.filter(e -> e.getType() == EntryType.DEBIT)
.map(LedgerEntry::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);

BigDecimal totalCredits = entries.stream()
.filter(e -> e.getType() == EntryType.CREDIT)
.map(LedgerEntry::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);

return totalDebits.compareTo(totalCredits) == 0;
}
}

/**
* Individual entry within a double-entry transaction.
*
* Each entry represents a debit or credit to a specific account.
* Entries are never created in isolation; they're always part of
* a balanced transaction.
*/
@Entity
@Table(name = "ledger_entries", indexes = {
@Index(name = "idx_account_id", columnList = "account_id"),
@Index(name = "idx_transaction_date", columnList = "transaction_date")
})
public class LedgerEntry {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(optional = false)
@JoinColumn(name = "transaction_id", nullable = false)
private LedgerTransaction transaction;

@ManyToOne(optional = false)
@JoinColumn(name = "account_id", nullable = false)
private Account account;

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private EntryType type; // DEBIT or CREDIT

@Column(nullable = false, precision = 19, scale = 4)
private BigDecimal amount;

@Column(nullable = false)
private String currency;

@Column(nullable = false)
private Instant transactionDate;

@Column(columnDefinition = "TEXT")
private String description;

// Entries are immutable once created
@PreUpdate
protected void preventUpdate() {
throw new IllegalStateException("Ledger entries are immutable");
}
}

public enum EntryType {
DEBIT,
CREDIT
}

Implementing Double-Entry Transactions

Creating a double-entry transaction requires atomic creation of both entries:

/**
* Service for creating double-entry ledger transactions.
*
* Ensures that every transaction is balanced (debits = credits)
* and that all entries are created atomically. Uses database
* transactions to maintain consistency.
*/
@Service
public class LedgerService {
private final LedgerTransactionRepository transactionRepository;
private final AccountRepository accountRepository;
private final LedgerEventPublisher eventPublisher;

/**
* Records a payment transaction using double-entry bookkeeping.
*
* When a customer pays, cash increases (debit) and revenue
* increases (credit). Both entries are created atomically.
*
* @param request Payment details
* @return Created transaction
* @throws UnbalancedTransactionException if debits != credits
*/
@Transactional
public LedgerTransaction recordPayment(PaymentRequest request) {
// Fetch accounts
Account cashAccount = accountRepository.findByCode("1000") // Cash account
.orElseThrow(() -> new AccountNotFoundException("1000"));

Account revenueAccount = accountRepository.findByCode("4000") // Revenue account
.orElseThrow(() -> new AccountNotFoundException("4000"));

// Create transaction
LedgerTransaction transaction = new LedgerTransaction();
transaction.setTransactionId(UUID.randomUUID().toString());
transaction.setTransactionDate(Instant.now());
transaction.setType(TransactionType.PAYMENT);
transaction.setDescription("Customer payment: " + request.getPaymentId());
transaction.setCreatedBy(request.getCreatedBy());
transaction.setCreatedAt(Instant.now());

// Create debit entry (increase cash)
LedgerEntry debitEntry = new LedgerEntry();
debitEntry.setAccount(cashAccount);
debitEntry.setType(EntryType.DEBIT);
debitEntry.setAmount(request.getAmount());
debitEntry.setCurrency(request.getCurrency());
debitEntry.setTransactionDate(Instant.now());
debitEntry.setDescription("Payment received");

// Create credit entry (increase revenue)
LedgerEntry creditEntry = new LedgerEntry();
creditEntry.setAccount(revenueAccount);
creditEntry.setType(EntryType.CREDIT);
creditEntry.setAmount(request.getAmount());
creditEntry.setCurrency(request.getCurrency());
creditEntry.setTransactionDate(Instant.now());
creditEntry.setDescription("Service revenue");

// Add entries to transaction
transaction.addEntry(debitEntry);
transaction.addEntry(creditEntry);

// Validate balance before persisting
if (!transaction.isBalanced()) {
throw new UnbalancedTransactionException(
"Transaction debits and credits must be equal"
);
}

// Persist transaction (cascades to entries)
LedgerTransaction saved = transactionRepository.save(transaction);

// Publish domain event
eventPublisher.publish(new TransactionRecordedEvent(
saved.getTransactionId(),
saved.getType(),
saved.getTransactionDate()
));

return saved;
}

/**
* Records a transfer between accounts.
*
* Transfer from account A to account B:
* - Debit account B (increase destination)
* - Credit account A (decrease source)
*/
@Transactional
public LedgerTransaction recordTransfer(TransferRequest request) {
Account sourceAccount = accountRepository.findById(request.getSourceAccountId())
.orElseThrow(() -> new AccountNotFoundException(request.getSourceAccountId()));

Account destinationAccount = accountRepository.findById(request.getDestinationAccountId())
.orElseThrow(() -> new AccountNotFoundException(request.getDestinationAccountId()));

LedgerTransaction transaction = new LedgerTransaction();
transaction.setTransactionId(UUID.randomUUID().toString());
transaction.setTransactionDate(Instant.now());
transaction.setType(TransactionType.TRANSFER);
transaction.setDescription(request.getDescription());
transaction.setCreatedBy(request.getCreatedBy());
transaction.setCreatedAt(Instant.now());

// Debit destination (increase)
LedgerEntry debitEntry = new LedgerEntry();
debitEntry.setAccount(destinationAccount);
debitEntry.setType(EntryType.DEBIT);
debitEntry.setAmount(request.getAmount());
debitEntry.setCurrency(request.getCurrency());
debitEntry.setTransactionDate(Instant.now());
debitEntry.setDescription("Transfer received");

// Credit source (decrease)
LedgerEntry creditEntry = new LedgerEntry();
creditEntry.setAccount(sourceAccount);
creditEntry.setType(EntryType.CREDIT);
creditEntry.setAmount(request.getAmount());
creditEntry.setCurrency(request.getCurrency());
creditEntry.setTransactionDate(Instant.now());
creditEntry.setDescription("Transfer sent");

transaction.addEntry(debitEntry);
transaction.addEntry(creditEntry);

if (!transaction.isBalanced()) {
throw new UnbalancedTransactionException("Transfer must be balanced");
}

return transactionRepository.save(transaction);
}
}

The key principles:

  • Atomic creation: Both entries created in single database transaction
  • Balance validation: Enforce debits = credits before persisting
  • Immutability: Entries cannot be updated after creation (enforced via @PreUpdate)
  • Auditability: Every entry linked to original transaction with metadata

Complex Transactions

Some transactions involve more than two entries. For example, a payment with fees:

Customer pays $100, processor charges $3 fee:

  1. Debit Cash (Asset) +$97 - Net cash received
  2. Debit Processing Fees (Expense) +$3 - Fee expense
  3. Credit Revenue (Revenue) +$100 - Gross revenue

Total debits ($97 + $3 = $100) = Total credits ($100). The transaction remains balanced.

@Transactional
public LedgerTransaction recordPaymentWithFees(PaymentWithFeesRequest request) {
Account cashAccount = accountRepository.findByCode("1000");
Account revenueAccount = accountRepository.findByCode("4000");
Account feesAccount = accountRepository.findByCode("5100"); // Expense account

LedgerTransaction transaction = new LedgerTransaction();
transaction.setTransactionId(UUID.randomUUID().toString());
transaction.setTransactionDate(Instant.now());
transaction.setType(TransactionType.PAYMENT);
transaction.setDescription("Payment with processing fees");
transaction.setCreatedBy(request.getCreatedBy());
transaction.setCreatedAt(Instant.now());

BigDecimal grossAmount = request.getAmount();
BigDecimal feeAmount = request.getFeeAmount();
BigDecimal netAmount = grossAmount.subtract(feeAmount);

// Debit: Cash account (net amount received)
transaction.addEntry(createEntry(
cashAccount, EntryType.DEBIT, netAmount, "Net payment received"
));

// Debit: Fees expense account
transaction.addEntry(createEntry(
feesAccount, EntryType.DEBIT, feeAmount, "Processing fee"
));

// Credit: Revenue account (gross amount)
transaction.addEntry(createEntry(
revenueAccount, EntryType.CREDIT, grossAmount, "Service revenue"
));

if (!transaction.isBalanced()) {
throw new UnbalancedTransactionException("Payment transaction must be balanced");
}

return transactionRepository.save(transaction);
}

This pattern extends to any number of entries as long as total debits equal total credits.

Event Sourcing for Financial Transactions

Event sourcing stores transactions as an immutable sequence of events rather than maintaining current state. This pattern is particularly powerful for financial systems where audit trails and historical reconstruction are critical.

Why Event Sourcing?

Traditional systems store current state (e.g., account balance). Event sourcing stores the sequence of events that led to that state:

Traditional (State-Based):

Account 12345: Balance = $1,543.21

Event Sourcing:

Account 12345 Events:
1. AccountOpened: +$0
2. DepositReceived: +$1,000
3. PaymentMade: -$50
4. DepositReceived: +$600
5. PaymentMade: -$6.79
Current Balance: $1,543.21 (calculated from events)

Benefits of event sourcing for financial systems:

  • Complete audit trail: Every state change is recorded permanently
  • Temporal queries: "What was the balance on March 15?" is answered by replaying events up to that date
  • Debugging: Reproduce bugs by replaying event sequence
  • Compliance: Regulatory requirements for complete transaction history
  • Event replay: Rebuild state if projection corrupted
  • Business intelligence: Analyze event patterns for insights

Event-Sourced Architecture

Commands (write operations) generate events stored in the event store. Projections build read models (like current balances) from events.

Implementation

/**
* Event store for financial transactions.
*
* Stores immutable events representing all state changes.
* Events are append-only and never deleted or modified.
*/
@Entity
@Table(name = "ledger_events", indexes = {
@Index(name = "idx_aggregate_id", columnList = "aggregate_id"),
@Index(name = "idx_event_timestamp", columnList = "event_timestamp")
})
public class LedgerEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "event_id", nullable = false, unique = true)
private String eventId; // UUID

@Column(name = "aggregate_id", nullable = false)
private String aggregateId; // Account ID or transaction ID

@Column(name = "aggregate_type", nullable = false)
private String aggregateType; // "Account", "Transaction"

@Column(name = "event_type", nullable = false)
private String eventType; // "AccountCredited", "AccountDebited"

@Column(name = "event_data", nullable = false, columnDefinition = "TEXT")
private String eventData; // JSON payload

@Column(name = "event_timestamp", nullable = false)
private Instant eventTimestamp;

@Column(name = "sequence_number", nullable = false)
private Long sequenceNumber; // Ordering within aggregate

@Column(name = "caused_by")
private String causedBy; // User or system that triggered event

@Column(name = "correlation_id")
private String correlationId; // Links related events

// Events are immutable
@PreUpdate
protected void preventUpdate() {
throw new IllegalStateException("Events are immutable");
}
}

/**
* Service for appending events to the event store.
*
* Ensures events are written atomically and sequentially per aggregate.
*/
@Service
public class EventStore {
private final LedgerEventRepository eventRepository;
private final EventPublisher eventPublisher;

/**
* Appends an event to the event store.
*
* Events are assigned sequence numbers to maintain ordering.
* The sequence ensures events can be replayed in correct order.
*/
@Transactional
public void append(Event event) {
// Get next sequence number for this aggregate
Long nextSequence = eventRepository
.findMaxSequenceNumber(event.getAggregateId())
.map(max -> max + 1)
.orElse(1L);

LedgerEvent ledgerEvent = new LedgerEvent();
ledgerEvent.setEventId(UUID.randomUUID().toString());
ledgerEvent.setAggregateId(event.getAggregateId());
ledgerEvent.setAggregateType(event.getAggregateType());
ledgerEvent.setEventType(event.getEventType());
ledgerEvent.setEventData(serializeEvent(event));
ledgerEvent.setEventTimestamp(Instant.now());
ledgerEvent.setSequenceNumber(nextSequence);
ledgerEvent.setCausedBy(event.getCausedBy());
ledgerEvent.setCorrelationId(event.getCorrelationId());

eventRepository.save(ledgerEvent);

// Publish event for projections and downstream systems
eventPublisher.publish(event);
}

/**
* Retrieves all events for an aggregate in sequence order.
*
* Used to rebuild aggregate state from events.
*/
public List<Event> getEventsForAggregate(String aggregateId) {
return eventRepository
.findByAggregateIdOrderBySequenceNumber(aggregateId)
.stream()
.map(this::deserializeEvent)
.collect(Collectors.toList());
}

/**
* Retrieves events within a time range.
*
* Used for temporal queries like "What was the balance at date X?"
*/
public List<Event> getEventsForAggregateUntil(String aggregateId, Instant until) {
return eventRepository
.findByAggregateIdAndEventTimestampBeforeOrderBySequenceNumber(aggregateId, until)
.stream()
.map(this::deserializeEvent)
.collect(Collectors.toList());
}

private String serializeEvent(Event event) {
// Serialize to JSON using Jackson or similar
try {
return objectMapper.writeValueAsString(event);
} catch (JsonProcessingException e) {
throw new EventSerializationException("Failed to serialize event", e);
}
}

private Event deserializeEvent(LedgerEvent ledgerEvent) {
// Deserialize from JSON
try {
Class<?> eventClass = Class.forName(ledgerEvent.getEventType());
return (Event) objectMapper.readValue(ledgerEvent.getEventData(), eventClass);
} catch (Exception e) {
throw new EventDeserializationException("Failed to deserialize event", e);
}
}
}

Event Types

Define events for all state changes:

/**
* Base interface for all ledger events.
*/
public interface Event {
String getAggregateId();
String getAggregateType();
String getEventType();
String getCausedBy();
String getCorrelationId();
}

/**
* Event fired when account is credited (balance increases).
*/
@Data
@Builder
public class AccountCreditedEvent implements Event {
private String aggregateId; // Account ID
private String accountNumber;
private BigDecimal amount;
private String currency;
private String transactionId;
private String description;
private Instant timestamp;
private String causedBy;
private String correlationId;

@Override
public String getAggregateType() {
return "Account";
}

@Override
public String getEventType() {
return "AccountCredited";
}
}

/**
* Event fired when account is debited (balance decreases).
*/
@Data
@Builder
public class AccountDebitedEvent implements Event {
private String aggregateId; // Account ID
private String accountNumber;
private BigDecimal amount;
private String currency;
private String transactionId;
private String description;
private Instant timestamp;
private String causedBy;
private String correlationId;

@Override
public String getAggregateType() {
return "Account";
}

@Override
public String getEventType() {
return "AccountDebited";
}
}

/**
* Event fired when new transaction is recorded.
*/
@Data
@Builder
public class TransactionRecordedEvent implements Event {
private String aggregateId; // Transaction ID
private TransactionType transactionType;
private List<EntryData> entries;
private Instant transactionDate;
private String description;
private String causedBy;
private String correlationId;

@Override
public String getAggregateType() {
return "Transaction";
}

@Override
public String getEventType() {
return "TransactionRecorded";
}
}

Building Projections

Projections consume events to build read models:

/**
* Projection that builds current account balances from events.
*
* Listens to account credit/debit events and maintains a denormalized
* view of current balances for fast querying.
*/
@Service
public class AccountBalanceProjection {
private final AccountBalanceRepository balanceRepository;

/**
* Handles account credited event by increasing balance.
*/
@EventListener
@Transactional
public void on(AccountCreditedEvent event) {
AccountBalance balance = balanceRepository
.findByAccountNumber(event.getAccountNumber())
.orElseGet(() -> {
AccountBalance newBalance = new AccountBalance();
newBalance.setAccountNumber(event.getAccountNumber());
newBalance.setBalance(BigDecimal.ZERO);
newBalance.setCurrency(event.getCurrency());
return newBalance;
});

balance.setBalance(balance.getBalance().add(event.getAmount()));
balance.setLastUpdated(event.getTimestamp());

balanceRepository.save(balance);
}

/**
* Handles account debited event by decreasing balance.
*/
@EventListener
@Transactional
public void on(AccountDebitedEvent event) {
AccountBalance balance = balanceRepository
.findByAccountNumber(event.getAccountNumber())
.orElseThrow(() -> new AccountNotFoundException(event.getAccountNumber()));

balance.setBalance(balance.getBalance().subtract(event.getAmount()));
balance.setLastUpdated(event.getTimestamp());

balanceRepository.save(balance);
}

/**
* Rebuilds projection from event store.
*
* Used when projection is corrupted or when adding new projection types.
*/
public void rebuild(String accountNumber) {
// Delete existing projection
balanceRepository.deleteByAccountNumber(accountNumber);

// Replay all events for account
List<Event> events = eventStore.getEventsForAggregate(accountNumber);

for (Event event : events) {
if (event instanceof AccountCreditedEvent) {
on((AccountCreditedEvent) event);
} else if (event instanceof AccountDebitedEvent) {
on((AccountDebitedEvent) event);
}
}
}
}

Projections can be rebuilt from events at any time, providing resilience against data corruption.

Temporal Queries

Event sourcing enables querying historical state:

/**
* Service for querying historical account balances.
*/
@Service
public class AccountHistoryService {
private final EventStore eventStore;

/**
* Calculates account balance at a specific point in time.
*
* Replays events up to the specified timestamp to reconstruct
* historical state. This is not possible with state-based systems.
*/
public BigDecimal getBalanceAt(String accountNumber, Instant timestamp) {
List<Event> events = eventStore.getEventsForAggregateUntil(accountNumber, timestamp);

BigDecimal balance = BigDecimal.ZERO;

for (Event event : events) {
if (event instanceof AccountCreditedEvent) {
balance = balance.add(((AccountCreditedEvent) event).getAmount());
} else if (event instanceof AccountDebitedEvent) {
balance = balance.subtract(((AccountDebitedEvent) event).getAmount());
}
}

return balance;
}

/**
* Generates account statement for a date range.
*/
public AccountStatement generateStatement(
String accountNumber,
Instant startDate,
Instant endDate
) {
List<Event> events = eventStore.getEventsInRange(accountNumber, startDate, endDate);

BigDecimal openingBalance = getBalanceAt(accountNumber, startDate);
BigDecimal closingBalance = getBalanceAt(accountNumber, endDate);

List<StatementEntry> entries = events.stream()
.map(this::toStatementEntry)
.collect(Collectors.toList());

return AccountStatement.builder()
.accountNumber(accountNumber)
.startDate(startDate)
.endDate(endDate)
.openingBalance(openingBalance)
.closingBalance(closingBalance)
.entries(entries)
.build();
}
}

Temporal queries are essential for compliance (generating historical reports) and debugging (reproducing issues at specific points in time).

ACID Properties and Consistency

Financial transactions require strict consistency guarantees provided by ACID (Atomicity, Consistency, Isolation, Durability) properties.

ACID Properties Explained

PropertyDefinitionFinancial Implication
AtomicityTransaction completes entirely or not at allBoth debit and credit entries are created together, or neither is created
ConsistencyDatabase remains in valid stateDebits always equal credits; balances never inconsistent
IsolationConcurrent transactions don't interferePrevents race conditions in balance calculations
DurabilityCommitted transactions survive system failuresOnce confirmed, transactions are permanent

Transaction Isolation Levels

Different isolation levels provide different consistency guarantees:

LevelDirty ReadNon-Repeatable ReadPhantom ReadUse Case
Read UncommittedYesYesYesNever use for financial data
Read CommittedNoYesYesDefault for most databases, acceptable for reads
Repeatable ReadNoNoYesGood balance for most operations
SerializableNoNoNoRequired for balance calculations and transfers

For financial transactions, use Serializable or Repeatable Read isolation:

/**
* Service for account transfers with proper isolation.
*
* Uses Serializable isolation to prevent race conditions when
* reading balances and creating transfer entries.
*/
@Service
public class AccountTransferService {
private final AccountRepository accountRepository;
private final LedgerService ledgerService;

/**
* Transfers funds between accounts with ACID guarantees.
*
* Uses SERIALIZABLE isolation to prevent:
* - Dirty reads: Reading uncommitted changes
* - Non-repeatable reads: Balance changes between reads
* - Phantom reads: New transactions appearing during execution
*/
@Transactional(isolation = Isolation.SERIALIZABLE)
public TransferResult transfer(TransferRequest request) {
// 1. Lock and fetch accounts (order by ID to prevent deadlocks)
List<Long> accountIds = List.of(
request.getSourceAccountId(),
request.getDestinationAccountId()
).stream().sorted().collect(Collectors.toList());

Account sourceAccount = accountRepository
.findByIdWithLock(accountIds.get(0))
.orElseThrow(() -> new AccountNotFoundException(accountIds.get(0)));

Account destinationAccount = accountRepository
.findByIdWithLock(accountIds.get(1))
.orElseThrow(() -> new AccountNotFoundException(accountIds.get(1)));

// Determine which is source and which is destination
if (!sourceAccount.getId().equals(request.getSourceAccountId())) {
Account temp = sourceAccount;
sourceAccount = destinationAccount;
destinationAccount = temp;
}

// 2. Validate business rules
if (sourceAccount.getBalance().compareTo(request.getAmount()) < 0) {
throw new InsufficientFundsException(
"Source account has insufficient funds"
);
}

if (!sourceAccount.isActive() || !destinationAccount.isActive()) {
throw new AccountInactiveException("One or both accounts inactive");
}

// 3. Create double-entry ledger transaction
LedgerTransaction transaction = ledgerService.recordTransfer(request);

// 4. Update account balances (optional - can be derived from ledger)
sourceAccount.setBalance(
sourceAccount.getBalance().subtract(request.getAmount())
);
destinationAccount.setBalance(
destinationAccount.getBalance().add(request.getAmount())
);

accountRepository.save(sourceAccount);
accountRepository.save(destinationAccount);

return TransferResult.success(transaction.getTransactionId());
}
}

Pessimistic Locking

For critical operations, use pessimistic locking to prevent concurrent modifications:

public interface AccountRepository extends JpaRepository<Account, Long> {
/**
* Finds account with pessimistic write lock.
*
* Locks the row for update, preventing other transactions from
* reading or modifying until this transaction completes.
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT a FROM Account a WHERE a.id = :id")
Optional<Account> findByIdWithLock(@Param("id") Long id);
}

Pessimistic locking ensures that once an account is read for a transfer, no other transaction can modify it until the transfer completes.

Deadlock Prevention

When locking multiple accounts, always lock in consistent order to prevent deadlocks:

// GOOD: Lock accounts in ascending ID order
List<Long> accountIds = List.of(sourceId, destinationId)
.stream()
.sorted()
.collect(Collectors.toList());

Account first = accountRepository.findByIdWithLock(accountIds.get(0));
Account second = accountRepository.findByIdWithLock(accountIds.get(1));

// BAD: Lock accounts in arbitrary order (can cause deadlock)
Account source = accountRepository.findByIdWithLock(sourceId);
Account destination = accountRepository.findByIdWithLock(destinationId);

If Transaction A locks Account 1 then Account 2, while Transaction B locks Account 2 then Account 1, a deadlock occurs. Consistent ordering prevents this.

Optimistic Locking

For lower contention scenarios, use optimistic locking with version numbers:

@Entity
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Version
private Long version; // Incremented on each update

@Column(nullable = false)
private BigDecimal balance;

// Other fields...
}

Optimistic locking detects concurrent modifications:

@Transactional
public void updateAccount(Long accountId, BigDecimal newBalance) {
Account account = accountRepository.findById(accountId)
.orElseThrow(() -> new AccountNotFoundException(accountId));

account.setBalance(newBalance);

try {
accountRepository.save(account);
} catch (OptimisticLockingFailureException e) {
// Another transaction modified the account
throw new ConcurrentModificationException(
"Account was modified by another transaction"
);
}
}

Use optimistic locking when contention is low; use pessimistic locking for high-contention operations like transfers.

Transaction Reversal and Compensation

Financial systems must handle corrections, refunds, and reversals. Unlike deleting records, financial transactions are never deleted - instead, compensating transactions reverse their effects.

Reversal Types

TypeDescriptionWhen UsedLedger Treatment
VoidCancels transaction before settlementPayment authorized but not capturedMark original as voided, no ledger entry needed
RefundReturns funds after settlementCustomer returns productNew transaction with opposite entries
ChargebackBank-initiated reversalCustomer disputes chargeCompensating transaction plus chargeback fee
CorrectionFixes erroneous transactionData entry errorReversing transaction plus corrected transaction

Implementing Refunds

Refunds create new transactions with opposite entries from the original:

/**
* Service for creating compensating transactions (refunds, reversals).
*/
@Service
public class TransactionReversalService {
private final LedgerTransactionRepository transactionRepository;
private final LedgerService ledgerService;

/**
* Creates a refund transaction that reverses a payment.
*
* Original payment:
* - Debit: Cash +$100
* - Credit: Revenue +$100
*
* Refund transaction:
* - Debit: Revenue +$100 (reverses original credit)
* - Credit: Cash +$100 (reverses original debit)
*/
@Transactional
public LedgerTransaction refundPayment(RefundRequest request) {
// Find original transaction
LedgerTransaction originalTransaction = transactionRepository
.findByTransactionId(request.getOriginalTransactionId())
.orElseThrow(() -> new TransactionNotFoundException(
request.getOriginalTransactionId()
));

// Validate refund amount doesn't exceed original
BigDecimal originalAmount = originalTransaction.getEntries().get(0).getAmount();
if (request.getAmount().compareTo(originalAmount) > 0) {
throw new InvalidRefundAmountException(
"Refund amount cannot exceed original transaction amount"
);
}

// Create reversing entries
LedgerTransaction refundTransaction = new LedgerTransaction();
refundTransaction.setTransactionId(UUID.randomUUID().toString());
refundTransaction.setTransactionDate(Instant.now());
refundTransaction.setType(TransactionType.REFUND);
refundTransaction.setDescription("Refund of transaction: " +
request.getOriginalTransactionId());
refundTransaction.setCreatedBy(request.getCreatedBy());
refundTransaction.setCreatedAt(Instant.now());

// Reverse each entry from original transaction
for (LedgerEntry originalEntry : originalTransaction.getEntries()) {
LedgerEntry reversingEntry = new LedgerEntry();
reversingEntry.setAccount(originalEntry.getAccount());

// Flip debit/credit
reversingEntry.setType(
originalEntry.getType() == EntryType.DEBIT
? EntryType.CREDIT
: EntryType.DEBIT
);

reversingEntry.setAmount(request.getAmount());
reversingEntry.setCurrency(originalEntry.getCurrency());
reversingEntry.setTransactionDate(Instant.now());
reversingEntry.setDescription("Refund: " + originalEntry.getDescription());

refundTransaction.addEntry(reversingEntry);
}

if (!refundTransaction.isBalanced()) {
throw new UnbalancedTransactionException("Refund transaction must be balanced");
}

// Link refund to original transaction
refundTransaction.setReferencesTransaction(originalTransaction.getId());

return transactionRepository.save(refundTransaction);
}

/**
* Creates a correction transaction.
*
* Used when an error was made in the original transaction.
* Creates two transactions:
* 1. Reversing transaction (cancels original)
* 2. Corrected transaction (records correct values)
*/
@Transactional
public CorrectionResult correctTransaction(CorrectionRequest request) {
// 1. Create reversing transaction
LedgerTransaction reversal = refundPayment(RefundRequest.builder()
.originalTransactionId(request.getOriginalTransactionId())
.amount(request.getOriginalAmount())
.createdBy(request.getCreatedBy())
.build());

// 2. Create corrected transaction
LedgerTransaction corrected = ledgerService.recordPayment(PaymentRequest.builder()
.amount(request.getCorrectedAmount())
.currency(request.getCurrency())
.createdBy(request.getCreatedBy())
.build());

// Link transactions
corrected.setReferencesTransaction(reversal.getId());

return CorrectionResult.builder()
.reversalTransactionId(reversal.getTransactionId())
.correctedTransactionId(corrected.getTransactionId())
.build();
}
}

Handling Chargebacks

Chargebacks involve additional complexity due to fees and dispute processes (see Payment Processing - Chargebacks):

@Transactional
public LedgerTransaction recordChargeback(ChargebackRequest request) {
LedgerTransaction originalTransaction = transactionRepository
.findByTransactionId(request.getOriginalTransactionId())
.orElseThrow(() -> new TransactionNotFoundException(
request.getOriginalTransactionId()
));

Account cashAccount = accountRepository.findByCode("1000");
Account revenueAccount = accountRepository.findByCode("4000");
Account chargebackFeeAccount = accountRepository.findByCode("5200"); // Expense

LedgerTransaction chargeback = new LedgerTransaction();
chargeback.setTransactionId(UUID.randomUUID().toString());
chargeback.setTransactionDate(Instant.now());
chargeback.setType(TransactionType.CHARGEBACK);
chargeback.setDescription("Chargeback: " + request.getOriginalTransactionId());
chargeback.setCreatedBy("SYSTEM");
chargeback.setCreatedAt(Instant.now());

// Reverse revenue (debit revenue, credit cash)
chargeback.addEntry(createEntry(
revenueAccount, EntryType.DEBIT, request.getAmount(), "Chargeback reversal"
));
chargeback.addEntry(createEntry(
cashAccount, EntryType.CREDIT, request.getAmount(), "Funds returned"
));

// Record chargeback fee
BigDecimal chargebackFee = new BigDecimal("15.00");
chargeback.addEntry(createEntry(
chargebackFeeAccount, EntryType.DEBIT, chargebackFee, "Chargeback fee"
));
chargeback.addEntry(createEntry(
cashAccount, EntryType.CREDIT, chargebackFee, "Chargeback fee deducted"
));

if (!chargeback.isBalanced()) {
throw new UnbalancedTransactionException("Chargeback transaction must be balanced");
}

chargeback.setReferencesTransaction(originalTransaction.getId());

return transactionRepository.save(chargeback);
}

Chargebacks must record both the reversed payment and the fee charged by the payment processor.

Account Balance Calculation Strategies

Account balances can be calculated in two ways: real-time aggregation from ledger entries, or materialized views updated on each transaction.

Real-Time Calculation

Calculate balance by summing all ledger entries:

/**
* Calculates account balance by aggregating ledger entries.
*
* Always accurate but can be slow for accounts with many transactions.
* Use for accounts with low transaction volume or where absolute
* accuracy is critical.
*/
@Service
public class RealTimeBalanceCalculator {
private final LedgerEntryRepository entryRepository;

/**
* Calculates current balance from ledger entries.
*
* Sums all debits and credits for the account. Debits increase
* asset accounts, credits decrease them (or vice versa for liabilities).
*/
public BigDecimal calculateBalance(Long accountId, AccountType accountType) {
List<LedgerEntry> entries = entryRepository.findByAccountId(accountId);

BigDecimal totalDebits = entries.stream()
.filter(e -> e.getType() == EntryType.DEBIT)
.map(LedgerEntry::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);

BigDecimal totalCredits = entries.stream()
.filter(e -> e.getType() == EntryType.CREDIT)
.map(LedgerEntry::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);

// Balance calculation depends on account type
if (accountType.isDebitNormal()) {
// Asset, Expense accounts: Debit - Credit
return totalDebits.subtract(totalCredits);
} else {
// Liability, Equity, Revenue accounts: Credit - Debit
return totalCredits.subtract(totalDebits);
}
}

/**
* More efficient query using database aggregation.
*/
public BigDecimal calculateBalanceOptimized(Long accountId, AccountType accountType) {
BigDecimal totalDebits = entryRepository.sumDebitsByAccount(accountId);
BigDecimal totalCredits = entryRepository.sumCreditsByAccount(accountId);

return accountType.isDebitNormal()
? totalDebits.subtract(totalCredits)
: totalCredits.subtract(totalDebits);
}
}

@Repository
public interface LedgerEntryRepository extends JpaRepository<LedgerEntry, Long> {
@Query("SELECT COALESCE(SUM(e.amount), 0) FROM LedgerEntry e " +
"WHERE e.account.id = :accountId AND e.type = 'DEBIT'")
BigDecimal sumDebitsByAccount(@Param("accountId") Long accountId);

@Query("SELECT COALESCE(SUM(e.amount), 0) FROM LedgerEntry e " +
"WHERE e.account.id = :accountId AND e.type = 'CREDIT'")
BigDecimal sumCreditsByAccount(@Param("accountId") Long accountId);
}

Advantages:

  • Always accurate (source of truth is ledger entries)
  • No risk of balance drift from synchronization issues

Disadvantages:

  • Slower for high-volume accounts
  • Requires database query for every balance check

Materialized Balance

Store current balance and update on each transaction:

/**
* Account entity with materialized balance.
*
* Balance is stored and updated with each transaction for fast queries.
* Balance must be kept in sync with ledger entries.
*/
@Entity
@Table(name = "accounts")
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false, unique = true)
private String accountNumber;

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private AccountType accountType;

@Column(nullable = false, precision = 19, scale = 4)
private BigDecimal balance = BigDecimal.ZERO;

@Column(nullable = false)
private String currency;

@Version
private Long version; // Optimistic locking

// Update balance atomically
public void credit(BigDecimal amount) {
if (accountType.isDebitNormal()) {
this.balance = this.balance.subtract(amount);
} else {
this.balance = this.balance.add(amount);
}
}

public void debit(BigDecimal amount) {
if (accountType.isDebitNormal()) {
this.balance = this.balance.add(amount);
} else {
this.balance = this.balance.subtract(amount);
}
}
}

/**
* Service that updates materialized balances on transaction creation.
*/
@Service
public class BalanceMaintainer {
private final AccountRepository accountRepository;

/**
* Updates account balances when transaction is created.
*
* Must be called within the same database transaction as
* ledger entry creation to maintain consistency.
*/
@Transactional
public void updateBalances(LedgerTransaction transaction) {
for (LedgerEntry entry : transaction.getEntries()) {
Account account = accountRepository
.findByIdWithLock(entry.getAccount().getId())
.orElseThrow(() -> new AccountNotFoundException(entry.getAccount().getId()));

if (entry.getType() == EntryType.DEBIT) {
account.debit(entry.getAmount());
} else {
account.credit(entry.getAmount());
}

accountRepository.save(account);
}
}
}

Advantages:

  • Fast balance queries (single row lookup)
  • Suitable for high-volume accounts

Disadvantages:

  • Risk of balance drift if updates fail
  • Requires periodic reconciliation with ledger entries

Hybrid Approach

Combine both approaches:

@Service
public class HybridBalanceService {
private final Account account;
private final RealTimeBalanceCalculator calculator;

/**
* Returns materialized balance with periodic verification.
*
* Uses fast materialized balance for queries but periodically
* validates against ledger entries to detect drift.
*/
public BigDecimal getBalance(Long accountId) {
Account account = accountRepository.findById(accountId)
.orElseThrow(() -> new AccountNotFoundException(accountId));

// Return materialized balance
BigDecimal materializedBalance = account.getBalance();

// Periodically verify (e.g., every 1000th request or daily scheduled job)
if (shouldVerify(account)) {
BigDecimal calculatedBalance = calculator.calculateBalanceOptimized(
accountId,
account.getAccountType()
);

if (!materializedBalance.equals(calculatedBalance)) {
// Drift detected - alert and repair
handleBalanceDrift(account, materializedBalance, calculatedBalance);
}
}

return materializedBalance;
}

private boolean shouldVerify(Account account) {
// Verify periodically based on last verification time
return account.getLastVerified()
.plus(Duration.ofHours(24))
.isBefore(Instant.now());
}

@Transactional
private void handleBalanceDrift(
Account account,
BigDecimal materializedBalance,
BigDecimal calculatedBalance
) {
// Log critical alert
log.error("Balance drift detected for account {}: materialized={}, calculated={}",
account.getAccountNumber(), materializedBalance, calculatedBalance);

// Correct materialized balance
account.setBalance(calculatedBalance);
accountRepository.save(account);

// Trigger investigation workflow
alertService.sendCriticalAlert(
"Balance drift detected",
String.format("Account %s had balance drift", account.getAccountNumber())
);
}
}

This hybrid approach provides performance of materialized balances with the correctness guarantee of real-time calculation.

Audit Trail Requirements

Financial audit trails must be comprehensive, immutable, and retained for regulatory periods.

Audit Requirements

Every financial transaction must answer:

  1. Who: Which user or system initiated the transaction?
  2. What: What operation was performed?
  3. When: Exact timestamp (with timezone)
  4. Why: Business reason (order ID, refund reason, etc.)
  5. How much: Transaction amounts in all affected accounts
  6. What changed: Before and after states
  7. Related transactions: Links to original transactions for refunds/reversals

Implementing Audit Trail

The ledger itself provides an audit trail, but additional audit logging captures context:

/**
* Comprehensive audit entry for financial operations.
*
* Captures full context of financial operations beyond what's
* stored in ledger entries. Used for compliance reporting,
* investigation, and regulatory audits.
*/
@Entity
@Table(name = "financial_audit_log", indexes = {
@Index(name = "idx_user_id", columnList = "user_id"),
@Index(name = "idx_transaction_date", columnList = "transaction_date"),
@Index(name = "idx_ledger_transaction_id", columnList = "ledger_transaction_id")
})
public class FinancialAuditLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "audit_id", nullable = false, unique = true)
private String auditId; // UUID

// Who
@Column(name = "user_id")
private String userId;

@Column(name = "user_email")
private String userEmail;

@Column(name = "ip_address")
private String ipAddress;

@Column(name = "user_agent")
private String userAgent;

// What
@Column(name = "operation_type", nullable = false)
@Enumerated(EnumType.STRING)
private OperationType operationType; // PAYMENT, TRANSFER, REFUND, etc.

@Column(name = "ledger_transaction_id")
private String ledgerTransactionId;

@Column(name = "affected_accounts", columnDefinition = "TEXT")
private String affectedAccounts; // JSON array of account IDs

// When
@Column(name = "transaction_date", nullable = false)
private Instant transactionDate;

// Why
@Column(name = "business_reason", columnDefinition = "TEXT")
private String businessReason;

@Column(name = "order_id")
private String orderId;

@Column(name = "reference_number")
private String referenceNumber;

// How much
@Column(name = "amount", precision = 19, scale = 4)
private BigDecimal amount;

@Column(name = "currency")
private String currency;

// State changes
@Column(name = "before_state", columnDefinition = "TEXT")
private String beforeState; // JSON

@Column(name = "after_state", columnDefinition = "TEXT")
private String afterState; // JSON

// Related transactions
@Column(name = "references_transaction_id")
private String referencesTransactionId;

@Column(name = "correlation_id")
private String correlationId; // Links related operations

// Metadata
@Column(name = "metadata", columnDefinition = "TEXT")
private String metadata; // JSON with additional context

// Audit entries are immutable
@PreUpdate
protected void preventUpdate() {
throw new IllegalStateException("Audit entries are immutable");
}
}

/**
* Service for creating audit log entries.
*/
@Service
public class FinancialAuditService {
private final FinancialAuditLogRepository auditRepository;

/**
* Logs a financial operation with full context.
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void auditTransaction(FinancialAuditContext context) {
FinancialAuditLog audit = new FinancialAuditLog();
audit.setAuditId(UUID.randomUUID().toString());

// Who
audit.setUserId(context.getUserId());
audit.setUserEmail(context.getUserEmail());
audit.setIpAddress(context.getIpAddress());
audit.setUserAgent(context.getUserAgent());

// What
audit.setOperationType(context.getOperationType());
audit.setLedgerTransactionId(context.getLedgerTransactionId());
audit.setAffectedAccounts(serializeAccounts(context.getAffectedAccounts()));

// When
audit.setTransactionDate(Instant.now());

// Why
audit.setBusinessReason(context.getBusinessReason());
audit.setOrderId(context.getOrderId());
audit.setReferenceNumber(context.getReferenceNumber());

// How much
audit.setAmount(context.getAmount());
audit.setCurrency(context.getCurrency());

// State changes
audit.setBeforeState(serialize(context.getBeforeState()));
audit.setAfterState(serialize(context.getAfterState()));

// Related
audit.setReferencesTransactionId(context.getReferencesTransactionId());
audit.setCorrelationId(context.getCorrelationId());

// Metadata
audit.setMetadata(serialize(context.getMetadata()));

auditRepository.save(audit);
}

private String serializeAccounts(List<String> accounts) {
try {
return objectMapper.writeValueAsString(accounts);
} catch (JsonProcessingException e) {
throw new AuditSerializationException("Failed to serialize accounts", e);
}
}

private String serialize(Object object) {
if (object == null) return null;
try {
return objectMapper.writeValueAsString(object);
} catch (JsonProcessingException e) {
throw new AuditSerializationException("Failed to serialize object", e);
}
}
}

Audit Log Retention

Regulatory requirements typically mandate 7+ years retention for financial audit logs:

/**
* Configuration for audit log retention.
*/
@Configuration
public class AuditRetentionConfig {
/**
* Scheduled job to archive old audit logs.
*
* Moves logs older than active retention period to cold storage.
* Active logs: 2 years in database for fast querying
* Archived logs: 2-10 years in S3/Glacier for compliance
*/
@Scheduled(cron = "0 0 2 * * *") // 2 AM daily
public void archiveOldAuditLogs() {
Instant archiveThreshold = Instant.now()
.minus(Duration.ofDays(730)); // 2 years

List<FinancialAuditLog> logsToArchive = auditRepository
.findByTransactionDateBefore(archiveThreshold);

// Export to S3/Glacier
archiveService.archiveLogs(logsToArchive);

// Delete from database after successful archive
auditRepository.deleteAll(logsToArchive);
}
}

Archived logs must remain accessible for regulatory investigations even if query performance is slower.

Reconciliation Processes

Reconciliation ensures internal ledger matches external sources (payment processors, bank statements, partner systems).

Daily Reconciliation

/**
* Service for reconciling ledger with external systems.
*/
@Service
public class LedgerReconciliationService {
private final LedgerEntryRepository entryRepository;
private final ExternalSystemClient externalSystemClient;
private final ReconciliationReportRepository reportRepository;

/**
* Reconciles ledger entries with external system transactions.
*
* Compares internal ledger entries with transactions from payment
* processor, bank feeds, or partner systems to identify discrepancies.
*/
@Scheduled(cron = "0 0 4 * * *") // 4 AM daily
@Transactional
public ReconciliationReport reconcileDaily() {
LocalDate reconciliationDate = LocalDate.now().minusDays(1);

// Fetch internal transactions
List<LedgerEntry> internalEntries = entryRepository
.findByTransactionDateBetween(
reconciliationDate.atStartOfDay(ZoneOffset.UTC).toInstant(),
reconciliationDate.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant()
);

// Fetch external transactions
List<ExternalTransaction> externalTransactions = externalSystemClient
.getTransactionsForDate(reconciliationDate);

// Match transactions
ReconciliationResult result = matchTransactions(
internalEntries,
externalTransactions
);

// Generate report
ReconciliationReport report = ReconciliationReport.builder()
.reconciliationDate(reconciliationDate)
.totalInternalTransactions(internalEntries.size())
.totalExternalTransactions(externalTransactions.size())
.matchedTransactions(result.getMatched().size())
.missingInInternal(result.getMissingInInternal())
.missingInExternal(result.getMissingInExternal())
.amountMismatches(result.getAmountMismatches())
.build();

reportRepository.save(report);

// Alert if discrepancies found
if (result.hasDiscrepancies()) {
alertService.sendAlert(
AlertLevel.HIGH,
"Ledger reconciliation discrepancies",
String.format("Found %d discrepancies for %s",
result.getTotalDiscrepancies(),
reconciliationDate)
);
}

return report;
}

private ReconciliationResult matchTransactions(
List<LedgerEntry> internal,
List<ExternalTransaction> external
) {
Map<String, LedgerEntry> internalMap = internal.stream()
.collect(Collectors.toMap(
LedgerEntry::getExternalReferenceId,
Function.identity()
));

Map<String, ExternalTransaction> externalMap = external.stream()
.collect(Collectors.toMap(
ExternalTransaction::getId,
Function.identity()
));

List<TransactionPair> matched = new ArrayList<>();
List<ExternalTransaction> missingInInternal = new ArrayList<>();
List<LedgerEntry> missingInExternal = new ArrayList<>();
List<AmountMismatch> amountMismatches = new ArrayList<>();

// Check each external transaction
for (ExternalTransaction ext : external) {
LedgerEntry internal = internalMap.get(ext.getId());

if (internal == null) {
missingInInternal.add(ext);
} else {
if (!internal.getAmount().equals(ext.getAmount())) {
amountMismatches.add(new AmountMismatch(internal, ext));
} else {
matched.add(new TransactionPair(internal, ext));
}
internalMap.remove(ext.getId());
}
}

// Remaining internal entries not found in external
missingInExternal.addAll(internalMap.values());

return ReconciliationResult.builder()
.matched(matched)
.missingInInternal(missingInInternal)
.missingInExternal(missingInExternal)
.amountMismatches(amountMismatches)
.build();
}
}

Daily reconciliation is essential for detecting integration issues, missed webhooks (see Payment Processing - Webhook Handling), and data synchronization problems.

Handling Reconciliation Discrepancies

When discrepancies are found:

  1. Investigate: Determine root cause (timing difference, missing webhook, system error)
  2. Correct: Create adjusting entries if needed
  3. Prevent: Update processes to prevent recurrence
  4. Document: Record resolution for audit trail
@Service
public class ReconciliationDiscrepancyHandler {
/**
* Handles discovered reconciliation discrepancy.
*/
@Transactional
public void handleDiscrepancy(Discrepancy discrepancy) {
// Log for investigation
log.warn("Reconciliation discrepancy: {}", discrepancy);

// Create investigation case
Investigation investigation = Investigation.builder()
.discrepancyId(discrepancy.getId())
.status(InvestigationStatus.OPEN)
.assignedTo(determineAssignee(discrepancy))
.createdAt(Instant.now())
.build();

investigationRepository.save(investigation);

// Notify team
notificationService.notifyTeam(
"Reconciliation discrepancy requires investigation",
discrepancy.getDescription()
);
}

/**
* Creates adjusting entry to resolve discrepancy.
*/
@Transactional
public void createAdjustment(AdjustmentRequest request) {
// Create ledger transaction for adjustment
LedgerTransaction adjustment = ledgerService.recordAdjustment(request);

// Link to discrepancy and investigation
DiscrepancyResolution resolution = DiscrepancyResolution.builder()
.discrepancyId(request.getDiscrepancyId())
.adjustmentTransactionId(adjustment.getTransactionId())
.resolution(request.getResolution())
.resolvedBy(request.getResolvedBy())
.resolvedAt(Instant.now())
.build();

resolutionRepository.save(resolution);

// Close investigation
Investigation investigation = investigationRepository
.findByDiscrepancyId(request.getDiscrepancyId())
.orElseThrow();

investigation.setStatus(InvestigationStatus.RESOLVED);
investigation.setResolvedAt(Instant.now());

investigationRepository.save(investigation);
}
}

Reporting and Analytics

Financial ledgers provide data for various reports: income statements, balance sheets, transaction histories, and regulatory filings.

Trial Balance Report

Trial balance lists all accounts with their debit and credit totals to verify ledger is balanced:

@Service
public class FinancialReportingService {
/**
* Generates trial balance report.
*
* Lists all accounts with debit and credit totals.
* Total debits must equal total credits if ledger is balanced.
*/
public TrialBalanceReport generateTrialBalance(LocalDate asOfDate) {
Instant asOfInstant = asOfDate.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();

List<Account> accounts = accountRepository.findAll();

List<TrialBalanceEntry> entries = accounts.stream()
.map(account -> {
BigDecimal debits = entryRepository.sumDebitsByAccountUntil(
account.getId(),
asOfInstant
);

BigDecimal credits = entryRepository.sumCreditsByAccountUntil(
account.getId(),
asOfInstant
);

return TrialBalanceEntry.builder()
.accountNumber(account.getAccountNumber())
.accountName(account.getName())
.accountType(account.getAccountType())
.debits(debits)
.credits(credits)
.build();
})
.collect(Collectors.toList());

BigDecimal totalDebits = entries.stream()
.map(TrialBalanceEntry::getDebits)
.reduce(BigDecimal.ZERO, BigDecimal::add);

BigDecimal totalCredits = entries.stream()
.map(TrialBalanceEntry::getCredits)
.reduce(BigDecimal.ZERO, BigDecimal::add);

boolean balanced = totalDebits.compareTo(totalCredits) == 0;

return TrialBalanceReport.builder()
.asOfDate(asOfDate)
.entries(entries)
.totalDebits(totalDebits)
.totalCredits(totalCredits)
.balanced(balanced)
.generatedAt(Instant.now())
.build();
}
}

Trial balance should always be balanced. If not, there's an error in ledger entry creation.

Income Statement

Income statement shows revenue and expenses for a period:

public IncomeStatement generateIncomeStatement(LocalDate startDate, LocalDate endDate) {
Instant start = startDate.atStartOfDay(ZoneOffset.UTC).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();

// Revenue accounts (4000-4999)
BigDecimal totalRevenue = entryRepository
.sumCreditsByAccountTypeAndDateRange(AccountType.REVENUE, start, end)
.subtract(entryRepository.sumDebitsByAccountTypeAndDateRange(AccountType.REVENUE, start, end));

// Expense accounts (5000-5999)
BigDecimal totalExpenses = entryRepository
.sumDebitsByAccountTypeAndDateRange(AccountType.EXPENSE, start, end)
.subtract(entryRepository.sumCreditsByAccountTypeAndDateRange(AccountType.EXPENSE, start, end));

BigDecimal netIncome = totalRevenue.subtract(totalExpenses);

return IncomeStatement.builder()
.startDate(startDate)
.endDate(endDate)
.totalRevenue(totalRevenue)
.totalExpenses(totalExpenses)
.netIncome(netIncome)
.generatedAt(Instant.now())
.build();
}

Financial reports should be generated from ledger entries (source of truth) rather than materialized balances to ensure accuracy.

Regulatory Compliance

Financial transaction ledgers must comply with various regulations:

  • SOX (Sarbanes-Oxley): Accurate financial reporting, internal controls, audit trails
  • GDPR: Right to erasure conflicts with immutable ledger (pseudonymization solution)
  • PCI-DSS: No card data in ledger entries (see Payment Processing - PCI-DSS)
  • AML (Anti-Money Laundering): Transaction monitoring, suspicious activity reporting (covered in future AML guide)

SOX Compliance

SOX requires:

  • Internal controls: Segregation of duties, approval workflows
  • Audit trails: Immutable record of all financial transactions
  • Accurate reporting: Financial statements must be accurate and verifiable

Ledger implementation supports SOX through:

  • Immutable entries (no updates, only reversals)
  • Comprehensive audit logging
  • Balanced transactions (built-in error detection)
  • Financial reports generated from source data

GDPR and Immutable Ledgers

GDPR "right to erasure" conflicts with immutable financial records. Solution:

  1. Pseudonymization: Store customer IDs, not names/emails in ledger
  2. Separate PII: Store personally identifiable information separately
  3. Retention requirements: Financial data has legal retention requirements that override GDPR in certain cases
/**
* Ledger entry with pseudonymized customer reference.
*
* Stores customer ID but not PII. Customer details stored in
* separate system that can be deleted per GDPR while maintaining
* financial records for compliance.
*/
@Entity
public class LedgerEntry {
// ... other fields

@Column(name = "customer_id")
private String customerId; // Pseudonymous identifier, not PII

// Do NOT store customer name, email, phone, address in ledger
// These are stored in customer service and can be deleted
}

/**
* Service for handling GDPR deletion requests.
*/
@Service
public class GDPRDeletionService {
/**
* Handles customer data deletion per GDPR.
*
* Deletes PII from customer service but preserves financial
* transaction records with pseudonymous customer ID for compliance.
*/
@Transactional
public void deleteCustomerData(String customerId) {
// Delete PII from customer service
customerService.deletePII(customerId);

// Financial ledger entries are NOT deleted (legal requirement)
// Customer ID remains but is no longer linkable to PII

// Log deletion for audit
gdprAuditService.logDeletion(customerId, Instant.now());
}
}

Consult legal counsel to balance GDPR requirements with financial recordkeeping obligations.