Overview / Ledger / money
ADR 0029
The double-entry ledger is an async, rebuildable projection of the capture-path ledger
AcceptedLedger / money
Context
The money-capture path (a charge, a fee, a refund) has long written a single-row ledger record per event (the legacy ledger_transactions). For balances, wallets, payouts and reporting the platform needs a proper double-entry ledger with per-account running balances. Rewriting the live capture path to emit balanced debit/credit pairs inline is risky on the most sensitive code in the system.
Decision
Keep the capture-path record as the source and materialize a double-entry ledger (transaction_entries) asynchronously, as a rebuildable projection of it — rather than rewriting the capture path to write double-entry inline.
- Projection, not system of record. A background worker derives balanced entries from the capture-path ledger into
transaction_entries; because it is a pure projection, it can be dropped and rebuilt from source, and audited against it. - Stored running balance. Each entry carries a
running_balancesnapshot (per account, per currency) so balance reads are O(1) rather than a sum-over-history. - Writes are serialized per account window. Before reading the latest running balance and appending, the writer takes a Postgres advisory lock (
pg_advisory_xact_lock) on(account_type, account_id, currency_code), so concurrent entries for the same account cannot interleave and corrupt the running balance — without resorting toSERIALIZABLEisolation or DB triggers. - A fast per-account balance cache sits alongside for lookups, with integrity fields to verify it against the projection.
Consequences
- Positive: a real double-entry model (balances, wallets, payouts, audit history) without a risky rewrite of the live capture path; the projection is rebuildable and reconcilable against source.
- Positive: advisory-lock-per-account keeps running balances correct under concurrency, targeted to the contended window rather than a global isolation level.
- Current state / to revisit: two ledgers coexist — the capture-path
ledger_transactions(the live write, historically:float) and thetransaction_entriesprojection (:decimal). This is a consistency boundary and a float→decimal precision seam, and the projection is eventually consistent (async) with capture. The repo'sREADME-ledger-modernizationdescribes a different, unshipped design (DB triggers / dual-write) and is stale — this ADR records what actually runs.
Alternatives considered
- Write balanced double-entry inline on the capture path (one ledger): rejected for now — it is a rewrite of the live money path; a projection delivers the model without touching capture, and can be promoted to the source later.
- Derive balances on-read by summing entries: rejected — too slow at volume; a stored
running_balanceplus a cache makes balance reads constant-time. - DB triggers or
SERIALIZABLEisolation for concurrency: rejected — an advisory lock on the account window is precise and cheap; triggers hide the logic in the database and global isolation taxes every transaction.