Ledger
Keep money as one number per account and it drifts the first time something crashes halfway. Keep it the way accountants do, as paired lines, and it cannot.
Try it
Three accounts, two ways of writing things down. Every button moves the same money in both. 'Crash ×20' kills the process halfway through, twenty times. Watch which book stops adding up.
- Nothing pressed yet
- Nothing pressed yet
Three accounts, two books, both empty. Every button moves the same money in both. Your goal: make the simple book stop adding up.
What the database wrote down, row by row
| At | Side | Op | Outcome |
|---|---|---|---|
| No operations yet. | |||
| At | Memo | Legs (cents) |
|---|---|---|
| No committed transactions yet. | ||
Limit: 200 clicks per hour per visitor. Journal rows older than 24 hours are deleted with their entries.
Why the accountant's way wins
Nothing is ever erased
Mistakes are fixed by adding a reversing line, never by editing. The history is the data, so 'what was the balance on the 3rd' is a simple question.
Every cent has two addresses
Money never appears or vanishes. It moves from one account to another, and both sides are written. If the lines do not add up to zero, the database refuses to save them.
Checking against the bank gets easy
The bank statement is a list of movements. So is the book. Put them side by side and the difference is a short list of transactions, not a mystery number.
The code
What runs behind the buttons, in one sentence. The code itself is one click away.
The bad version updates two numbers in two separate steps. The good version writes two matching lines in one step, and a database rule rejects anything that does not add up to zero.
Show the code anyway
export async function pay(wallet: string, amount: number) { await db.query( "UPDATE balances SET amount = amount - $2 WHERE account = $1", [wallet, amount] ); // ← deploy, OOM, timeout, pod eviction await db.query( "UPDATE balances SET amount = amount + $2 WHERE account = $1", ["revenue", amount] );}export async function pay(wallet: string, amount: number) { await db.query("BEGIN"); const { rows: [{ tx_id }] } = await db.query( "INSERT INTO journal (memo) VALUES ($1) RETURNING tx_id", ["pay"] ); await db.query( "INSERT INTO entries (tx_id, account, amount_cents) VALUES ($1, $2, $3), ($1, $4, $5)", [tx_id, wallet, +amount, "revenue", -amount] ); await db.query("COMMIT"); // trigger runs here}// balance of any account, at any point in time:// SELECT SUM(amount_cents) FROM entries WHERE account = $1 AND created_at <= $2CREATE OR REPLACE FUNCTION lg.assert_balanced() RETURNS TRIGGER AS $$BEGIN IF EXISTS ( SELECT 1 FROM lg.entries WHERE tx_id = COALESCE(NEW.tx_id, OLD.tx_id) GROUP BY tx_id HAVING SUM(amount_cents) <> 0 ) THEN RAISE EXCEPTION 'ledger transaction does not sum to zero' USING ERRCODE = 'check_violation', CONSTRAINT = 'lg_tx_balanced'; END IF; RETURN NULL;END $$ LANGUAGE plpgsql;CREATE CONSTRAINT TRIGGER lg_tx_balanced AFTER INSERT OR UPDATE OR DELETE ON lg.entries DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION lg.assert_balanced();Still curious?
A few more questions people ask. Open what you need.
Why does the simple book drift at all?
Because the two updates are two separate saves. A crash between them leaves one done and one not. And even without crashes, a single number tells you nothing about how it got there.
Do I need accounting software for this?
No. Two tables and one rule. It is less code than most people expect.
Isn't adding up every line slow?
You can keep a running total for speed, as long as the lines stay the truth and you check the total against them regularly.
What does 'write half a movement' test?
That the database itself says no to a half-written movement, even when the code tries to save one.
Membership: money that had to reconcile
This article is the toy version of a problem from a real project. The case page tells what happened and what it cost.