Skip to main content

ADR-012: Ledger accounting model — double-entry, derived balances, DB-enforced invariants

  • Status: Accepted
  • Date: 2026-05-26
  • Deciders: Principal architect, backend

Context

ADR-006 makes the ledger the sole source of money truth and forbids stored balances; ADR-005 fixes money as integer santim; ADR-009 forbids deletes. The ledger.proto contract (PostTransaction, GetBalance, Reverse) lists five invariants. We need to decide how those invariants are guaranteed — in service code, in the database, or both — before writing the Go service, so the implementation has no room for interpretation. A ledger that is "mostly correct" is worse than none: silent imbalance is unrecoverable trust loss.

Decision

The ledger uses classic double-entry over three tables in its own Postgres database (ledger_account, ledger_transaction, ledger_entry). The proto invariants are enforced in the database, not just in Go, so they hold even if the service has a bug:

  • Account types ASSET, LIABILITY, EQUITY, REVENUE, EXPENSE. ASSET/EXPENSE are debit-normal; the rest credit-normal. Sign is carried by an entry_direction (DEBIT/CREDIT); amount_santim NUMERIC(20,0) is always positive.
  • Balanced transactions: a deferred constraint trigger asserts Σdebits = Σcredits per currency at commit. An unbalanced transaction cannot exist.
  • Balances are derived, never stored — the ledger_account_balance view (and an as_of-bounded query for point-in-time). No balance column anywhere.
  • Idempotency: UNIQUE(tenant_id, idempotency_key) + a stored request_fingerprint. Same key + same payload → return the existing transaction; same key + different payload → FailedPrecondition.
  • Append-only: BEFORE UPDATE OR DELETE triggers reject mutation of transactions and entries. Corrections are new transactions via Reverse(), which records reverses_transaction_id.
  • Tenant isolation: tenant_id on every row + fail-closed RLS keyed on current_setting('app.tenant_id').

Schema lives in services/ledger/migrations/0001_init.up.sql (golang-migrate).

Alternatives considered

  • Signed amounts, no direction column — store negative for credits. Rejected: loses the debit/credit semantics auditors expect and makes the balanced-check less obvious.
  • Enforce invariants only in Go — simpler schema. Rejected: a single bad deploy could write an unbalanced or mutated entry; for the money-truth system the DB must be the last line of defence.
  • Stored running balance column — faster reads. Rejected outright by ADR-006; derived balances are the whole point. Add a materialized snapshot later if read latency demands it, recomputed from entries.
  • Single shared DB with the monolith — simpler ops. Rejected: ADR-001/006 isolate the ledger's database so its integrity and blast radius are independent.

Consequences

  • Positive: imbalance, mutation, and cross-tenant leakage are structurally impossible, not just discouraged. The Go service can be thin and trusted.
  • Negative: balance reads are aggregations (mitigate with the view + indexes, and a snapshot table only if profiling demands). Multi-entry posts must happen in one transaction so the deferred check sees them together.
  • Follow-ups: implement the Go service against this schema; provision each tenant's chart of accounts at onboarding (the codes EWA's LedgerAccountsAdapter resolves); add the balance-snapshot optimization only if needed.