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 anentry_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_balanceview (and anas_of-bounded query for point-in-time). No balance column anywhere. - Idempotency:
UNIQUE(tenant_id, idempotency_key)+ a storedrequest_fingerprint. Same key + same payload → return the existing transaction; same key + different payload →FailedPrecondition. - Append-only:
BEFORE UPDATE OR DELETEtriggers reject mutation of transactions and entries. Corrections are new transactions viaReverse(), which recordsreverses_transaction_id. - Tenant isolation:
tenant_idon every row + fail-closed RLS keyed oncurrent_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
LedgerAccountsAdapterresolves); add the balance-snapshot optimization only if needed.