Topic 16.2
Payments and Banking: Double-Entry Ledgers
In one line
Money systems record every movement as immutable double-entry ledger entries where debits equal credits per transaction; balances are derived (or maintained with strict constraints). Payment intents track the lifecycle with idempotency keys, and settlement and reconciliation compare internal records with the processor and bank files daily.
Think of it like this
An accountant's journal. Nothing is erased; a mistake is corrected with a new reversing entry, and every entry has two sides so the books always balance.
Key ideas
- 01
Double entry: a transfer of ₹500 from A to B creates a journal transaction with two entries: A −500 (debit) and B +500 (credit). The invariant is that the sum of amounts per transaction = 0, enforced by a deferred constraint trigger or by inserting through a single function.
- 02
Immutability: no UPDATE or DELETE on entries; refunds and corrections are new transactions referencing the original. Store amounts as integer minor units with currency.
- 03
Balances: derive with
sum(amount)per account (with snapshots for speed), or maintainaccount.balancein the same transaction as the entries withCHECK (balance >= 0)for accounts that can't go negative, locking accounts in ID order to avoid deadlocks. - 04
Payment lifecycle: payment_intent (created → requires_action → processing → succeeded/failed) with an idempotency key; each processor call and webhook recorded; ledger entries posted only on success.
- 05
Reconciliation: daily jobs match internal ledger and payment records with processor settlement reports and bank statements; mismatches go to a queue for investigation. Audit logs capture every change and actor.
Code & diagrams
CREATE TABLE account (
id bigint PRIMARY KEY, owner_id bigint NOT NULL, currency char(3) NOT NULL,
kind text NOT NULL CHECK (kind IN ('customer','merchant','fees','settlement')),
balance_minor bigint NOT NULL DEFAULT 0,
allow_negative boolean NOT NULL DEFAULT false,
CHECK (allow_negative OR balance_minor >= 0)
);
CREATE TABLE journal_txn (
id uuid PRIMARY KEY, kind text NOT NULL, ref text, created_at timestamptz NOT NULL DEFAULT now(),
idempotency_key text UNIQUE
);
CREATE TABLE ledger_entry (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
txn_id uuid NOT NULL REFERENCES journal_txn(id),
account_id bigint NOT NULL REFERENCES account(id),
amount_minor bigint NOT NULL CHECK (amount_minor <> 0),
currency char(3) NOT NULL
);
CREATE INDEX ON ledger_entry (account_id, id);
REVOKE UPDATE, DELETE ON ledger_entry, journal_txn FROM app_rw; -- append-only
-- transfer 500.00 INR from account 1 to 2, deadlock-safe and balanced
BEGIN;
SELECT id FROM account WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
INSERT INTO journal_txn (id, kind, idempotency_key) VALUES ('0192f0c1-8a2e-7c11-b3a4-5d6e7f809a1b', 'transfer', 'tr-7781');
INSERT INTO ledger_entry (txn_id, account_id, amount_minor, currency) VALUES
('0192f0c1-8a2e-7c11-b3a4-5d6e7f809a1b', 1, -50000, 'INR'),
('0192f0c1-8a2e-7c11-b3a4-5d6e7f809a1b', 2, 50000, 'INR');
UPDATE account SET balance_minor = balance_minor - 50000 WHERE id = 1; -- CHECK rejects overdraft
UPDATE account SET balance_minor = balance_minor + 50000 WHERE id = 2;
COMMIT;
-- invariant check (should return no rows)
SELECT txn_id FROM ledger_entry GROUP BY txn_id HAVING sum(amount_minor) <> 0;Interview problem
The problem
Design a wallet and payments ledger
A wallet app supports top-ups via card, P2P transfers, merchant payments with a 2% fee, and refunds. Requirements: never lose or create money, no double charges on retries, full audit, and daily reconciliation with the card processor. Design the schema and flows.
When it breaks
Balance updated without a matching ledger entry
What you see
A bug path changes balances directly; money appears or disappears and reconciliation can't explain it.
Fix & prevent
Allow balance changes only through one posting function that writes entries and balances together; revoke direct UPDATE on balances from the app role; run the invariant checks continuously.
Explain it without notes
Why are ledgers append-only?
Practice
Compute a user's balance as of 1 September from entries.
Trade-offs
- ↔
Double-entry ledgers add rows and discipline, in exchange for provable correctness and audit.
Done when you can
I can design a double-entry ledger with idempotent payment flows, refunds and reconciliation.