← All notes

Double-entry ledgers: the rules that keep money honest

Published3 min readpaymentsledgerdatabases

A balance column is a claim. A ledger is the evidence. When a payment system has to explain where every naira went, a single balance field cannot do it, and that is the problem a double-entry ledger solves.

The idea

Every movement of money is recorded as two or more entries that sum to zero: something is debited, something else is credited by the same amount. A customer pays for a booking, so the customer's account is debited. The platform fee, the vendor's share and the amount held for settlement are credited. If the entries do not sum to zero, the write is rejected. Fees, settlements and refunds stop being fields on a payment and become entries you can list and trace.

The rules I would not skip

  • Integers, not decimals. Store amounts in the smallest unit of the currency (kobo, cents) as integers. Floating point can turn 0.1 plus 0.2 into something that does not balance.
  • Append only. Never edit or delete an entry. To correct a mistake, post a reversing entry and then the right one. The history is the audit trail.
  • Balance inside the transaction. The entries for one movement are written in a single database transaction, and the check that they sum to zero runs in that same transaction. Balanced entries that commit one at a time are not balanced.
  • One idempotency key per posting. Payment providers retry, queues redeliver, users double-tap. A unique key on the posting makes the second attempt a no-op instead of a second charge.
  • One writer. Route every write through one service or module. When three places can insert rows, three places can break the invariant.
  • Lock in a fixed order. A transfer touches two accounts. If one request locks A then B while another locks B then A, they deadlock. Sort the account ids and lock them in that order every time.

Cached balances need a referee

Summing every entry on every read is slow, so most systems keep a cached balance on the account row. Update it with a delta in SQL, not by reading the value and writing it back:

UPDATE accounts
SET balance_minor = balance_minor + $1
WHERE id = $2;

Two writers can then never overwrite each other's change. The cache will still drift if a bug slips in, so keep a job that compares it with the ledger and reports any account where the two disagree:

SELECT a.id, a.balance_minor AS cached, COALESCE(SUM(e.delta), 0) AS computed
FROM accounts a
LEFT JOIN entries e ON e.account_id = a.id
GROUP BY a.id, a.balance_minor
HAVING a.balance_minor <> COALESCE(SUM(e.delta), 0);

An empty result means the books agree. A non-empty one is a bug found by a query instead of by a customer. I built this pattern, a single writer module, SQL deltas, ordered locks and a mismatch finder, in a personal finance project, and it is the same shape a payment platform needs.

Where ledgers go wrong

  • Fees as an afterthought. A fee computed in application code and added later is a number nobody can reconcile. Post it as an entry in the same movement.
  • Rounding in the wrong place. Splitting an amount three ways loses a unit. Decide who gets the remainder, write it down, and make the entries still sum to zero.
  • Refunds that are not mirrors. A refund is its own movement with its own entries, linked to the original. Editing the original payment hides what happened.
  • Settlement from balances. Settle vendors from ledger entries, not from a balance that may have drifted.

A ledger is not clever. It is strict, and the strictness is what lets you answer a customer, an auditor or yourself months later.

Ayomide Ilesanmi, Lagoscommit feec46d · built 2026-11-13 07:18 UTC