Ledger Design

Double-entry wins for any product that moves money because it makes imbalance structurally impossible, gives every unit of value a from and a to, and turns audits from investigations into queries. Here is the design at schema level — and the traps that cost me time so they need not cost you any.

The three tables that carry the whole discipline

Events. Immutable business facts: payment_received, fee_charged, refund_issued, payout_initiated. An event records that something happened in the world, with its timestamp, its external reference, and its payload. Events are appended, never updated, never deleted. This table is your history, and its immutability is the property that every downstream guarantee depends on.

Postings. Each event explodes into two or more posting lines, each naming an account, a direction and an amount. The invariant — the signed sum of all postings for one event equals exactly zero — is enforced at the write, in a transaction, in the database. Not checked in application code afterwards. Enforced, so that an imbalanced state cannot be persisted even by a bug.

Accounts. The chart: user wallets, revenue, fees, gateway receivables, bank accounts, suspense. Each account has a currency and a type, and the type determines the sign convention you apply when reading it.

Balances are the fourth thing, and they are not a table in the conceptual model — they are a view, materialised for speed, rebuildable from postings at any moment. That is the whole design. Three tables, one invariant, five hundred years of prior art.

A worked example: one UPI payment, with a fee, refunded the next day

Take a ₹1,000 customer payment through a gateway that charges ₹20 and settles net.

Day one, payment received. One event, four postings:

  • Gateway receivable, debit ₹1,000 — the gateway now owes you this
  • Customer receivable, credit ₹1,000 — the customer’s obligation is discharged
  • Fee expense, debit ₹20
  • Gateway receivable, credit ₹20 — the gateway will net this from settlement

Sum: +1000 − 1000 + 20 − 20 = 0. The books balance by construction.

Day one, later, settlement arrives. The gateway deposits ₹980. One event, two postings: bank debit ₹980, gateway receivable credit ₹980. The receivable is now flat, which is exactly what reconciliation should confirm.

Day two, refund. The customer is refunded ₹1,000. Note carefully what does not happen: nobody deletes or edits day one. A new event posts a reversal — customer receivable debit ₹1,000, bank credit ₹1,000 — plus whatever the gateway does with the original fee, which is its own event when the evidence arrives.

The result is that on day three you can answer, from data: what was charged, what was settled, what was refunded, what the fee cost, and whether the gateway returned it. Every one of those is a query. In a single-entry system with a mutable balance, most of them are an archaeology project.

The traps, learned expensively

Floating point anywhere near money. Integers in minor units, always. A float’s inability to represent 0.1 exactly is a fun trivia fact until it is a reconciliation difference of ₹0.03 that takes a day to trace and recurs monthly.

One timestamp where two are needed. Business time and record time are different facts. A trade agreed Monday and settled Tuesday has two dates, and reports must be able to ask for either. Systems that store one blur agreed-versus-settled and produce phantom cash — the T+1 problem lives entirely in that gap.

Deleting anything, ever. Reversals are new entries. An auditor’s first instinct is to look for evidence of deletion, and finding it reframes every other answer you give. Soft-delete flags are barely better: they mean your queries now carry a condition that someone will eventually forget.

One giant “cash” account. Granular accounts make reconciliation tractable. Per-gateway receivables turn “we are short by ₹40,000” into “the Razorpay Tuesday settlement file is short by ₹40,000”, which is a solvable statement rather than a worrying one. Account granularity is the cheapest debugging investment in the entire design.

Skipping the idempotency key because “we will add it later.” Later is an incident. Keys go in the schema on day one, enforced by a unique constraint — the full argument.

Mixing currencies in one account. Each account gets exactly one currency; cross-currency movements post through an explicit FX account so the rate is recorded rather than implied. Implied rates are unauditable by definition.

Letting the application compute the invariant. Two concurrent requests can both pass an if statement. Only the database can guarantee that a set of postings sums to zero and that a key is unique. Put the constraint where the concurrency is resolved.

Why double-entry rather than a careful single-entry design

Because single-entry cannot answer the question that always eventually gets asked: whose money is this?

A single row saying “received ₹1,000” records what happened and nothing about the relationship it changed. The moment you hold value on behalf of anyone — a wallet, a marketplace seller balance, a deposit, a subscription credit — the questions become relational: how much do we owe this seller, what portion of this cash is ours versus held, what is our actual exposure if everyone withdrew today. Double-entry’s account graph answers those structurally, because every unit of value is always somewhere and always came from somewhere.

Auditors do not prefer double-entry aesthetically. They prefer it because their sampling methodology assumes it: pick an entry, trace both sides, verify the counterparty. In a single-entry system that procedure has nowhere to go.

The properties you get for free once this is in place

Replay. Rebuild any projection from history. Bug in the balance computation? Fix the code, recompute, done — no data migration, no reconciliation of the fix itself.

Time travel. What was this account’s balance on 3 March? A query, not an estimate. This matters more than teams expect: it is what makes historical reporting stable, so last quarter’s numbers do not quietly change when someone fixes something.

Audit as query. Show me every fee this customer was charged, with sources. Seconds, with evidence attached. In India this is now an operational requirement rather than a nicety — the digital-lending framework’s fee-transparency expectations assume systems that can do exactly this.

Structural honesty. You cannot silently lose money. You can misplace it — post to the wrong account, misclassify a fee — but the total is always conserved, so the error is always findable. That is a categorically different failure mode from “the number is wrong and we do not know why.”

What this looks like in production

The stack is deliberately boring: PostgreSQL, typed services, a queue for the asynchronous parts. Entries partitioned by time once volume justifies it. Projections updated in the same transaction as the postings for small-volume accounts, batched asynchronously for hot ones, with a scheduled job that recomputes and compares — because a projection nobody verifies is a second source of truth wearing a cache’s clothing.

Reconciliation runs daily against every external source, with exceptions queued by reason code. Manual entries exist, because reality requires them, but they carry maker-checker and a mandatory reason, which makes them decisions rather than interventions.

Boring is the point. In money systems, excitement is a defect — and the fourteen teardowns show the same conclusion reached independently by companies operating at wildly different scales.

Two design questions worth settling early

Where does the chart of accounts live — code or data? Data, with a migration path. Accounts as configuration means a new fee type or a new gateway is an insert rather than a deploy, and finance can read the chart without reading source. But the types and their sign conventions belong in code, because those encode invariants and should not be editable by anyone in an admin panel at 6 PM.

How do you handle the suspense account? You will need one — money arrives that cannot yet be attributed, and it has to go somewhere balanced rather than nowhere. The rule that keeps it honest: every suspense entry carries an expected resolution date, and the suspense balance is a report someone owns. A suspense account nobody ages becomes the place where unexplained money accumulates permanently, which defeats the purpose of having built any of this.

Both questions look administrative and both determine whether the design survives its second year. The schema is the easy part; the operating discipline around it is what actually holds.

/blog/why-your-ledger-drifts — what skipping this costs · /blog/double-entry — the accounting intuition for engineers · /blog/idempotency-done-right — the write-path discipline · /products/ledger-platform — this design, deployable · /services/fintech — having it built for you

Questions I actually get

Do I need three tables exactly?

Three is the minimum honest shape — events, postings, accounts. Real systems add idempotency keys, reconciliation state and often a separate table for external references. What matters is the invariant rather than the count: events are immutable, postings sum to zero per event, and balances are derived.

Should balances be materialised or computed on the fly?

Materialised, almost always, because computing from millions of entries per request does not scale. The rule is that the materialised value must be rebuildable from entries at any moment and must be verified against a recomputation on a schedule. A cache you cannot rebuild is not a cache, it is a second source of truth.

Integers or decimals for money?

Integers in minor units — paise, fils, cents. Postgres NUMERIC is also defensible and some teams prefer it for multi-currency work with odd exponents, but never floating point anywhere near money, and never a language float in the application layer either.

How do I handle multiple currencies?

Each account is denominated in exactly one currency, and cross-currency movements post through an FX account so the rate used is recorded as data rather than implied. A single account holding mixed currencies is the fastest route to a book nobody can reconcile.

What about performance at high write volume?

Postgres handles substantial volumes with partitioned entry tables, batched projection updates, and careful index discipline. The teardown series covers this pattern at national payment scale — the architecture is not the bottleneck, and premature sharding usually creates more problems than it solves.

Can I add double-entry to an existing single-entry system?

Yes, alongside rather than in place: post to the new ledger in parallel, reconcile the two daily, and cut over once the difference holds at zero. That is the migration method, and it is the only version that does not require freezing the business.