
@tosinolugbenga
Wallet Ledger Architecture
Separating Intent, Balance, and Audit History
Most products that move money internally eventually grow a wallets table with a balance column. That works until you need to answer harder questions: Why did this user's balance change? Can we prove we did not credit them twice for one card charge? What were we owed at midnight last Tuesday?
A durable approach is to treat intent, current balance, and audit history as three separate concerns. This article explains that split without tying you to a specific framework or vendor.
Why a Single Balance Column Is Not Enough
Updating balance = balance + 100 in application code is fast and easy. It fails quietly in four common ways:
No story — Support cannot reconstruct why the number changed.
Double application — The same payment webhook or retry applies the increment twice.
Race conditions — Two debits read the same balance before either writes.
Disputes — "We never received the funds" vs "our row says we credited you" with no immutable trail.
Regulated fintech and serious marketplaces eventually need an append-only explanation for every balance movement. That explanation is your ledger.
The Three Layers
Think of three tables (names vary; roles do not):
Layer | Typical Table | Question It Answers |
|---|---|---|
Intent | transactions | What operation was requested, for how much, in what state? |
Balance | wallets | What is spendable right now? |
Audit | ledgers | What was the balance immediately before and after this completed movement? |
A transaction row is a promise or attempt, not necessarily settled money. It usually carries:
type: credit (money in) or debit (money out)
amount
status: e.g. pending, held, completed, failed, cancelled
reference: payment gateway id, transfer id, invoice id
meta: channel, gateway, verification payloads, webhook ids
Critical rule: Creating a pending transaction does not change the wallet balance.
The wallet holds one authoritative number per currency (per owner): current balance, often with a constraint balance >= 0.
This is a cache for fast reads and eligibility checks. The ledger plus completed transactions are how you prove the cache is correct.
Each ledger row is a snapshot tied to a completed transaction:
balance_before
balance_after
transaction_id
wallet_id
There is no separate "amount" on the ledger if amount lives on the transaction: the delta is implied by before/after.
Critical rule: Insert a ledger row only when money actually moves (typically when status becomes completed).
The sequence for every money movement:
1. App → transactions: create PENDING
wallets: balance UNCHANGED
ledgers: no row
2. App → transactions: finalize COMPLETED
ledgers: insert before/after snapshot
wallets: update balance (atomic)
Create pending — Reserve the business action (debit validation may run here).
Finalize — Transition status; on completed only, write ledger + update wallet atomically inside the database.
Terminal failure — failed / cancelled: no ledger, no balance change.
Statuses like held model escrow: money is still not settled until you complete or release.
Pending debits are commitments against the wallet even before they complete. When creating a new debit, compute:
available = wallet.balance - sum(pending_and_held_debits)
Reject new debits if available < amount. That prevents five parallel "withdraw everything" requests from all passing a naive balance >= amount check.
Finalize time should re-check balance under a row lock, because another debit may have completed in between.
Debits spend money you already have. Credits create money in your system. A classic bug is trusting the client or an unauthenticated caller to mark a credit completed because they passed JSON that looks like a successful payment.
Production pattern:
Online channel — Finalize credit only after server-side verification against the payment provider (API or signed webhook processed with service credentials).
Internal channel — Wallet-to-wallet moves finalized by trusted server functions that move both legs together.
Never let a browser session finalize an online credit by supplying verificationData it forged.
Support reads transactions + ledger, not raw balance hacks.
Finance reconciles gateway settlement files to reference on completed credits.
Engineering can add idempotency keys and unique indexes on (reference, wallet) for in-flight rows without rewriting history.
Compliance gets an append-only trail per wallet.
Transactions record intent and state.
Wallets hold the current spendable balance.
Ledgers record provable before/after snapshots per completed movement.
Money moves only when a transaction reaches a terminal completed state inside a database routine that updates all three consistently.
Next: Part 2 — Stopping double spend without a blockchain covers row locks, idempotency, partial unique indexes, and webhook replay.
Other articles you might enjoy