Free preview

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:

AccountEntryMeaning
customer:receivable−100.00The customer owes us $100 (their issuer will pay it)
merchant:payable+97.00We owe the merchant $97
platform:revenue+3.00Our fee
Sum0.00The 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.

QuestionSource's modelDouble-entry
Where is the balance?A mutable balance columnDerived from entries; materialized as a cache
Can it be verified?No — nothing to compare againstYes — recompute from the ledger
Concurrent updates?🔴 Lost update✅ Appends don't conflict
Where does a fee live?Nowhere — implicitAn entry in the same balanced set
Multi-seller split?Three independent wallet updates — a distributed transactionOne atomic set of entries
How is a refund recorded?UnspecifiedA new reversing set — both remain visible
Is integrity checkable?🔴 Only by external comparisonSUM(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.

Enjoying the preview?

Create a free account to unlock the rest of this course, the in-browser judge, and live AI mock interviews.

Sign up free to continue