The Double-Entry Ledger
This lesson goes beyond the design
The chapter has a ledger component, states that data integrity is the first non-functional requirement, and describes reconciliation as comparing ledger totals against a settlement file. It never mentions double-entry bookkeeping, and its ledger schema has no amount column and no account (Lesson 3).
Double-entry is the technique that makes all three of those things work together. It is roughly seven hundred years old, it is what every real financial system uses, and naming it is the strongest signal available that you have designed a payment system rather than a CRUD service with a balance column.
The idea
Money never appears or disappears — it moves, so every entry has a counterpart
The single premise: money is conserved. A dollar that arrives in one account left another. So a financial event is never recorded as one change; it is recorded as at least two entries that sum to zero.
A customer pays a merchant $100:
SINGLE-ENTRY (what the design does):
merchant.balance = merchant.balance + 100
-> Where did it come from? Nothing records that.
-> Is the number right? Nothing can say.
DOUBLE-ENTRY:
customer_receivable -100.00
merchant_payable +100.00
--------
SUM 0.00 <- must ALWAYS hold
That 0.00 is the invariant, and everything valuable follows from it.
Balances are derived, not stored. A merchant's balance is the sum of ledger entries for their account. There is no mutable number to lose an update on — which dissolves the concurrency bug in Lesson 3's Wallet.balance rather than guarding against it.
Errors are detectable. Sum every entry in the ledger. If the total is not zero, something is wrong, and you know it without comparing against anything external. No other design property in this module gives you a self-check that strong.
Every movement has a cause. Each entry names its counterpart, so "where did this money come from?" is always answerable — which is precisely what an auditor, a regulator, or a customer in a dispute asks.
Double-entry converts financial integrity from a hope into an arithmetic identity you can assert on. That is what the chapter's first non-functional requirement actually demands.
What a real payment looks like
A $100 purchase where the platform takes a $3 fee:
| Account | Entry | Meaning |
|---|---|---|
customer:receivable | −100.00 | The customer owes us $100 (their issuer will pay it) |
merchant:payable | +97.00 | We owe the merchant $97 |
platform:revenue | +3.00 | Our fee |
| Sum | 0.00 | The invariant holds |
The fee split is where single-entry designs quietly break
Notice how naturally the fee fits. In the design's model — "the payment service updates the merchant's wallet to reflect the new balance" — the fee has nowhere to live. You credit the merchant $97 and the missing $3 is implicit, recorded nowhere, derivable from nothing.
In double-entry it is just another entry in the same balanced set, and the same is true of everything else that complicates real payments:
Multi-seller split: one -150 customer entry, three merchant entries, one fee entry Refund: a NEW balanced set with the signs reversed Chargeback: a NEW balanced set moving funds back plus a dispute fee Currency conversion: entries in each currency plus an FX gain/loss account
Every financial event, however complicated, is one balanced set of entries — which is why this model has survived seven centuries of increasingly complicated finance.
And note the multi-seller case specifically: Lesson 4 framed it as a distributed-transaction problem across three wallets. In a ledger it is one atomic append of five entries, and the distributed transaction disappears.
The schema
Four properties of this schema, each fixing a defect from Lesson 3
amount_minor is a signed integer in minor units. Signed, so direction is in the value rather than in a separate flag. Integer, so it cannot drift the way accumulated floats do. Minor units, so 10000 means $100.00 and the decimal count is a property of the currency rather than of the column. This replaces DECIMAL(10,2) and its 99,999,999.99 ceiling.
currency sits on the entry itself. Lesson 3's Transaction table had an amount and no currency — an amount without a currency is not money. Here they travel together, and entries in different currencies simply never sum against each other.
transaction_id groups the balanced set. The invariant is checkable per transaction:
SELECT transaction_id, SUM(amount_minor) FROM ledger_entry GROUP BY transaction_id, currency HAVING SUM(amount_minor) != 0; -- must return ZERO rows
That query is the integrity requirement, expressed as something you can run every minute in production and alert on.
idempotency_key is UNIQUE. The database itself refuses a duplicate append. Lesson 6 covers why this specific placement matters more than any application-level check.
Plus ACCOUNT_BALANCE as a materialized sum with a last_entry_id watermark — because summing millions of entries per balance read is impractical, and the watermark lets you both incrementally update it and rebuild it from scratch to verify it.
The ledger is the truth; the balance table is a cache of it that can always be recomputed. That is the whole design in one sentence, and it is the opposite of the design's model where the balance is the truth and nothing can check it.
Why append-only makes concurrency disappear
The lost update from Lesson 3 cannot occur here
Recall the failure:
MUTABLE BALANCE: Balance = 500 Payment A reads 500 -> writes 530 Payment B reads 500 -> writes 545 Final: 545. The $30 payment VANISHED.
With an append-only ledger:
APPEND-ONLY: Payment A appends +30 Payment B appends +45 Balance = SUM(entries) = 500 + 30 + 45 = 575 Nothing can be lost — nothing is ever overwritten.
Appends do not conflict. There is no read-modify-write, so there is no window in which two writers can read the same stale value.
The materialized balance still needs care — it is updated concurrently — but the consequences change completely: if the materialized value drifts, the ledger is still correct and the balance can be recomputed. A wrong cache is an inconvenience; a wrong source of truth is lost money.
Making the design of truth append-only converts a correctness problem into a caching problem, and caching problems are recoverable. This is the same reasoning behind the immutable operation log in that building block and content-addressed artifacts in that building block.
Reversals, not deletions
A refund is not the removal of a charge
The instinct is to treat a refund as undoing the original — delete the entries, or flip the status. That is wrong, and the reason generalizes.
WRONG: delete or modify the original entries
-> the record of the charge is gone
-> the customer's statement and yours no longer agree
-> an auditor cannot see what happened
RIGHT: append a NEW balanced set with reversed signs
Original (txn_abc): customer -10000, merchant +9700, platform +300
Refund (txn_xyz): customer +10000, merchant -9700, platform -300
^ references txn_abc as its reason
Both sets remain. The net effect is zero, and the history shows that a charge happened and was later refunded — which is what actually occurred and what every downstream party needs to see.
The same discipline applies to correcting a bug. If a deployment double-applied a credit, you do not fix it with an UPDATE; you append a reversing entry with an explanation. The error stays visible next to its correction.
In an immutable ledger the only operation is append, so every correction is a new fact rather than an edited one. It is the financial form of the expand-migrate-contract discipline from the previous chapter: never destroy the state you might need to explain.
What this buys reconciliation
The chapter's own reconciliation example only works if the ledger has amounts
"If a merchant's ledger shows three payments totaling $225, the system verifies that the PSP's settlement file matches those payments."
That is exactly the right procedure and it is not executable against the schema in Lesson 3, which has no amount column. You cannot total a table with no amounts.
With a double-entry ledger it becomes a straightforward comparison, and — more valuably — a two-sided one:
INTERNAL CHECK (needs nothing external, run continuously):
SUM(all entries) == 0 -> our books are internally consistent
EXTERNAL CHECK (daily, against the PSP file):
SUM(merchant entries for the day) == settlement file total
-> our books match reality
The first catches our bugs — a lost message, a half-applied update, a double-consumed event. The second catches discrepancies with the outside world — a transaction the PSP has and we don't, or a fee we didn't expect.
Two independent checks with different failure coverage, and the internal one is available every second while the external one waits for a daily file.
A self-checking invariant is worth more than any amount of external comparison, because it tells you something is wrong now rather than tomorrow, and it narrows the cause to your own system.
| Question | Source's model | Double-entry |
|---|---|---|
| Where is the balance? | A mutable balance column | Derived from entries; materialized as a cache |
| Can it be verified? | No — nothing to compare against | Yes — recompute from the ledger |
| Concurrent updates? | 🔴 Lost update | ✅ Appends don't conflict |
| Where does a fee live? | Nowhere — implicit | An entry in the same balanced set |
| Multi-seller split? | Three independent wallet updates — a distributed transaction | One atomic set of entries |
| How is a refund recorded? | Unspecified | A new reversing set — both remain visible |
| Is integrity checkable? | 🔴 Only by external comparison | ✅ SUM(entries) == 0, continuously |
Key takeaway
Double-entry records every financial event as balanced entries summing to zero, and that identity is what the chapter's first non-functional requirement actually demands: it converts integrity from a hope into an arithmetic assertion you can run in production. Three defects from Lesson 3 dissolve rather than being patched — balances are derived rather than stored, so the lost update cannot occur; currency travels with the amount as signed integer minor units; and the ledger has the amounts that make the chapter's own reconciliation example executable. It also absorbs everything real payments require — fees, multi-seller splits, refunds, and chargebacks are all just more entries in a balanced set — turning Lesson 4's distributed transaction into one atomic append. And corrections are reversing entries, never edits: never destroy the state you might need to explain.
Next: idempotency and the three-way timeout ambiguity.