Command Palette

Search for a command to run...

Hectal
PHASE 16Advanced ~9 min· topic 2 of 8

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.

0/8 · 0%

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

  1. 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.

  2. 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.

  3. 03

    Balances: derive with sum(amount) per account (with snapshots for speed), or maintain account.balance in the same transaction as the entries with CHECK (balance >= 0) for accounts that can't go negative, locking accounts in ID order to avoid deadlocks.

  4. 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.

  5. 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

ledger.sqlsql
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

01

Why are ledgers append-only?

Practice

01

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.