Part 1 · 2 chapters · ~12 min
Ledger Schemas, Accounts, Balances and Holds
Tables for accounts, journal entries and postings, enforcing balanced entries, posted, pending and available balances, holds and authorisations, concurrency with versions or row locks, hot accounts, immutability and corrections by reversal, and partitioning a growing journal.
3
The tables
code
CREATE TABLE accounts (id bigserial PRIMARY KEY, type text NOT NULL, currency char(3) NOT NULL, owner_id bigint, status text NOT NULL DEFAULT 'active'); CREATE TABLE journal_entries (id bigserial PRIMARY KEY, idempotency_key text UNIQUE NOT NULL, kind text NOT NULL, effective_at timestamptz NOT NULL DEFAULT now(), external_ref text, metadata jsonb); CREATE TABLE postings (id bigserial PRIMARY KEY, entry_id bigint NOT NULL REFERENCES journal_entries, account_id bigint NOT NULL REFERENCES accounts, amount_minor bigint NOT NULL CHECK (amount_minor <> 0), currency char(3) NOT NULL); CREATE TABLE balances (account_id bigint PRIMARY KEY REFERENCES accounts, posted bigint NOT NULL DEFAULT 0, held bigint NOT NULL DEFAULT 0, version bigint NOT NULL DEFAULT 0); -- the journal is append-only: no UPDATE or DELETE for the application role REVOKE UPDATE, DELETE ON journal_entries, postings FROM app_rw;
LEDGER TABLES
accounts, journal entries, postings, balances and holds
swipe the figure sideways, or tap expand for full screen
1/5
accounts
Every account has a type (from the chart of accounts), one currency and an owner. Multi-currency customers have one account per currency.
one currency per accounttypes from the chart of accounts
4
Posting safely: concurrency, hot accounts and corrections
code
-- post an entry atomically: lock the affected balances in a fixed order (avoids deadlocks), check funds, write BEGIN; SELECT account_id, posted, held FROM balances WHERE account_id = ANY($accounts) ORDER BY account_id FOR UPDATE; -- application checks: posted - held + delta >= 0 for debited customer accounts INSERT INTO journal_entries (idempotency_key, kind, external_ref) VALUES ($key, 'p2p_transfer', $ref) RETURNING id; INSERT INTO postings (entry_id, account_id, amount_minor, currency) VALUES ($e, $ada, -502500, 'NGN'), ($e, $bayo, 500000, 'NGN'), ($e, $fees, 2500, 'NGN'); UPDATE balances SET posted = posted + d.delta, version = version + 1 FROM (VALUES ($ada, -502500), ($bayo, 500000), ($fees, 2500)) d(id, delta) WHERE account_id = d.id; COMMIT;
| problem | solution |
|---|---|
| hot internal accounts (fees, settlement) locked by every transfer | split into N sub-accounts chosen at random or by hash; report on their sum; or update their balance asynchronously from postings |
| mistakes in posted entries | never edit: post a reversal entry (the exact negation) and a new correct entry, both linked to the original |
| a journal of billions of rows | partition postings by month; keep balances as the fast path; archive old partitions read-only |
| proving correctness | nightly: rebuild every balance from postings and compare; check Σ postings = 0 per currency |