The ledger I wish I'd written first
Three rewrites later, here is the double-entry core I'd start every payments product with.
The first version of every payments product I've worked on had a balance column. That column is the original sin. Everything I've rewritten since has been an attempt to atone for it.
Here is the core I'd start with now. It fits on a page.
1. There is no balance column. There are only entries.
A balance is a fact you derive, not a fact you store. The ledger is an append-only table of entries, each one a movement of value from one account to another. A balance is SUM(credits) - SUM(debits) over an account, optionally cached, never authoritative. The moment you store a balance and update it in place, you've created two sources of truth, and they will disagree at 2am on a Sunday.
2. Every transaction is a set of entries that sums to zero.
Double-entry is not accounting theatre. It's a constraint: value cannot appear or disappear, only move. A deposit is not "add $100 to the user." It is "debit the processor's receivable account $100, credit the user's wallet $100." A fee is a third line: "debit the user's wallet $2, credit the platform revenue account $2." If the lines don't sum to zero, the transaction doesn't commit. That single check catches more bugs than any test suite.
3. Pending and posted are different states, not different tables.
The most expensive bug I've shipped was a pending balance that silently became invisible because of a missing column in a summary query. Partners saw money vanish. Nothing was lost; it was simply not shown. The fix in design terms: every entry carries a status, and every balance query is explicit about which statuses it includes. "Available" is posted entries. "Total" is posted plus pending. Never let one query mean both.
4. One unit of account. Convert at the edge.
If the product touches more than one asset, the ledger still runs in one denomination. Fix the exchange rate at the moment the obligation is created, write that rate onto the entry, and let treasury manage the actual conversion separately with its own float. Users hold stable value; the operator holds the volatility. A ledger that stores amounts in five currencies is not a ledger, it's a spreadsheet with a database wrapped around it.
5. Every entry carries every dimension you'll ever want to group by.
Partner, sub-account, product, settlement batch, provider reference. Denormalise it onto the row. Joins to reconstruct "whose money is this" are fine until you have a partner running ten stores under one balance and need per-store views in a hurry. Ledger views are cheap if the dimensions are already on the entry, and ruinous if they aren't.
6. External events are inputs, not entries.
A processor webhook is a claim that something happened. It gets logged raw in its own table, then interpreted into ledger entries by code you control. When a column rename once caused card payments to stop recording in the settlement events table, the raw log was how we reconstructed what had actually happened. Keep the claim and the interpretation apart.
That's the whole core. Six rules, one table that only grows, and a balance you can recompute from scratch at any moment. Everything else, fees, hierarchies, settlement sweeps, reconciliation, is a view or a process built on top.
I've now rebuilt this three times. The rewrite is always the same shape, and it always starts by deleting the balance column.
One chart, one argument, most Fridays.