Your ledger is the one component you cannot get 80 percent right. A flaky webhook handler loses a notification and you reconcile later. A ledger that drops a cent under load produces a balance that nobody can explain, and explaining balances is the entire job. Once the number is wrong, every downstream report, payout, and dispute response inherits the error, and you usually find out from a customer or a regulator rather than a dashboard.

This module assumes you have already scoped the unhappy paths and picked your PSP. Here we design the place where money actually lives, under three conditions that break naive implementations: process failure mid-write, concurrent attempts to spend the same balance, and funds that move in the real world days after your system records intent.

Start with double entry, and mean it

A correct ledger records every movement as balanced debit and credit entries against accounts. The invariant is simple and absolute: within any single transaction, total debits equal total credits. A customer top-up debits a cash account and credits the customer's wallet by the same amount. Nothing is ever created or destroyed, only moved.

This is not bookkeeping nostalgia. The constraint is what lets you detect corruption. If you sum every entry in the system and the result is not zero, you have a bug, and you can find it without guessing.

Entries are immutable; corrections are new entries

The journal is append-only. You never update or delete a posted entry. When you get something wrong, you post a compensating reversal that nets it out, and the original stays visible.

This matters more than it looks. A mutable ledger cannot answer "what did the balance look like last Tuesday," which is exactly the question a dispute or an audit asks. An append-only journal answers it by replaying entries up to a timestamp. It also means a buggy deploy can corrupt new entries but cannot rewrite history.

Balances are derived, not stored as truth

Treat the running balance as a materialized view over the journal, not as the source of truth. The journal is authoritative; the balance is a cache you can always rebuild by summing entries.

You will still keep a fast balance column for performance, because summing millions of entries on every read does not scale. The discipline is that the column is reconstructable. If it ever disagrees with the entry sum, the entries win and you recompute.

Surviving failure: the write must be atomic

The dangerous moment is a crash between recording the debit and recording the credit, or between posting an entry and acknowledging the caller. If your debit and credit are two separate database writes, a process kill in the middle leaves money half-moved.

The fix is to post both legs of a transaction inside a single database transaction. Both entries commit together or neither does. This is the floor, not an optimization.

Failure also arrives as the retry. A client times out, never sees your response, and sends the same request again. Without protection, you post the movement twice. Enforce idempotency at the ledger boundary: store a unique key per logical operation, typically (caller_id, idempotency_key), and let a unique constraint reject the duplicate. The second request finds the first result and returns it instead of posting again. Make the idempotency record and the journal entries commit in the same transaction, or you reopen the exact gap you closed.

Concurrency: stopping the double spend

Two requests try to spend from a wallet that holds $50. Each reads the balance, sees $50, each checks that its $40 withdrawal fits, and each posts. The wallet is now at negative $30. Both checks passed because neither saw the other.

A transaction alone does not save you here. Under PostgreSQL's default Read Committed isolation, each transaction reads a committed snapshot and the two interleave without conflict. You need to force the conflict to be visible.

Two ways to force it

The pessimistic option is to lock the account row before reading its balance, using SELECT ... FOR UPDATE. The second transaction blocks until the first commits, then reads the updated balance, sees $10, and correctly rejects the second $40 withdrawal. Locking serializes access to that account, which is precisely what you want for a contended balance and costs you throughput only on the same account.

The optimistic option is to run the transaction under Serializable isolation. PostgreSQL implements this with Serializable Snapshot Isolation, which tracks read/write dependencies between concurrent transactions and aborts one with a serialization failure if the interleaving could break serial-equivalent ordering. The catch is that your application must catch that error and retry the whole transaction from the start. Without the retry loop, serializable isolation just converts contention into intermittent errors.

Both are correct. Row locking is simpler to reason about for hot accounts; serializable pushes the work onto the database and the retry loop. Pick one deliberately and apply it consistently, because a single unprotected write path is enough to corrupt the whole ledger.

Async settlement: model intent and movement separately

Card and bank rails do not settle when the user clicks pay. Authorization happens in seconds; the money lands days later, and sometimes it does not land at all. If your ledger records "paid" the moment intent is captured, your balances claim money you do not yet hold.

Model the lifecycle as distinct postings. An authorization posts a pending entry into a holding or in-transit account. Settlement posts the entry that moves funds into the realized account. A failed settlement posts a reversal of the pending entry. The customer-visible "available" balance is a query over realized entries; the "pending" balance is a query over the holding account.

A worked example

A user pays $100 for a payout that settles on a two-day rail.

Every state change is a balanced posting. At no point did a derived balance assert money the system did not control, and the reconciliation module in the next lesson can match each realized entry against the rail's settlement file because the entries already carry the settlement reference.

The takeaway

A ledger stays correct because of constraints you refuse to relax, not because of careful coding. Keep debits equal to credits in every transaction. Keep the journal append-only and rebuild balances from it. Commit both legs and the idempotency key together. Force concurrent writes to conflict with row locks or serializable retries. Separate authorized intent from settled movement so your balances never claim money you do not hold. Each rule is cheap to enforce on day one and expensive to retrofit after the first balance nobody can explain.

← Previous
Build vs Buy vs Orchestrate Your PSP
Next →
Idempotency, Retries, and Webhook Hell