Inkress ADRsarchitecture decisions
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_balance snapshot (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 to SERIALIZABLE isolation 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 the transaction_entries projection (:decimal). This is a consistency boundary and a float→decimal precision seam, and the projection is eventually consistent (async) with capture. The repo's README-ledger-modernization describes 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_balance plus a cache makes balance reads constant-time.
  • DB triggers or SERIALIZABLE isolation 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.
← 0028 Service-to-service authentication uses three schemes, chosen per trust boundary 0030 Money is stored as a plain numeric amount plus a currency code →