PaddySpeaks · Systems at the Whiteboard · Nº 09

The Ledger Problem

Design Stripe's payments ledger across charges, refunds, chargebacks, payouts and multi-currency FX — where every transaction's entries must sum to exactly zero, nothing is ever updated, and a regulator can ask for the books as they stood on any past day. One decision separates the senior answer: append-only is not a style, it is the invariant. A full working-through — data flow, the double-entry schema, the SUM=0 heart, replay-safe ingestion, the daily integrity check, and the reconciliation dashboard.

§ 01 — THE QUESTIONThe domain with no undo button

Most systems can afford a correction. Payments cannot. The instant money moves, the record of how it moved becomes evidence — and evidence you can edit is evidence you cannot defend. That single constraint dictates the entire model.

Interview Prompt

"Design the data model for Stripe's payment system — charges, refunds, chargebacks, payouts, multi-currency FX, reserves — with rigorous double-entry accounting where every transaction's entries sum to zero per currency. Append-only: never UPDATE, for regulatory audit compliance. How would you scope it?"

LEVEL · SENIOR / STAFFDURATION · 45 MINFORMAT · WHITEBOARD

The prompt hands you the answer and then tests whether you understand why. "Double-entry" and "append-only" are not flourishes; they are the same idea viewed from two sides. A regulator can ask, of a system that moves a trillion dollars a year, "what was merchant X's balance on the fifteenth, as the books showed it then?" — and the answer must be correct regardless of every refund, chargeback, and correction posted since. You cannot reconstruct that from a mutable balance column. You can only reconstruct it from a history that was never allowed to change. The model is forced: an immutable log of signed movements, and a balance that is nothing but a sum over that log.

The weak answer is a charges table with a status column that flips to 'refunded'. It loses the past the moment it updates a row, it cannot survive a retry without double-charging, and it has no honest answer for FX. The strong answer notices that an accountant solved this in the fourteenth century: every event is a balanced set of debits and credits, recorded forever, and the truth is derived, not stored. So before any schema, the working frame for the session:

THE ATOM
The ledger entry. One signed movement on one account in one currency. The source of truth, append-only. Billions of rows; the only thing the system truly persists. Everything else is derived from these.
THE EVENT
The balance transaction. One business action — a charge, a refund, a payout — that groups its entries. The unit the SUM=0 invariant is enforced over, per currency. A charge with a fee is one event, several atoms.
THE STATE
The balance. Never written by a human, only materialized: a running sum of entries per (account × currency). Fast to read, trivial to rebuild, impossible to corrupt independently of the log.
A balance is not a fact you store. It is a fact you can always re-derive — which is precisely why no one is ever allowed to write it directly.

Scoping out loud

Scope, said before any boxes: in scope is the ledger spine, the SUM=0 invariant, idempotency, the reversal pattern for refunds and chargebacks, and multi-currency FX locked at event time. Explicitly out of scope, named aloud: card-network authorization and the issuer rails (we model the outcome of an auth, not the ISO 8583 handshake), fraud scoring (a separate system that reads the ledger), and the payout banking integration beyond the entries that represent it. One non-negotiable I will state up front: there is exactly one writer discipline — append a balanced set of entries, or append nothing. No partial transactions, ever.

Then the envelope math, volunteered. Stripe-shaped numbers:

QuantityEstimateConsequence
Payment volume / year~$1 TSets the regulatory bar: SOX, PCI, banking audit — all assume immutability
Charges / day~10^8The business-event rate; modest writes, enormous stakes
Ledger entries / charge3–8The fan-out that makes the SUM=0 invariant meaningful
Chargeback arrival lag30–90 daysThe past must stay open to new entries long after it "closed"
Currencies in flight~135Every cross-currency event needs an FX leg with a locked rate
Acceptable balance error$0.00The invariant is exact, not approximate; any drift halts payments
Idempotency-key retriesroutineNetworks fail mid-write; replay must be a no-op, not a double-charge

Notice the row that is unlike any other system: the acceptable error is zero. Not "five nines," not "eventually consistent" — zero, exactly, per currency, per transaction, continuously. A recommender can be noisy and a search index can be stale, but a ledger that is off by a cent is not a degraded ledger, it is a broken one. That absolutism is the spine of the whole design, and it is enforceable precisely because the model is append-only.


§ 02 — DATA FLOWFollowing a charge into the books

One spine, two readers. Producers translate business events into balanced entry sets and append them through an idempotency gate. The ledger is the immutable record; balances and reports are derived views that can be thrown away and rebuilt at any time.

WRITE PATH · BALANCED OR NOTHING READ PATH · DERIVED, REBUILDABLE PRODUCERS charge · refund · payout dispute webhook ENTRY BUILDER event → N signed entries FX rate locked here asserts Σ=0 before send IDEMPOTENCY GATE UNIQUE(source_event) retry → no-op fct_ledger_entries APPEND-ONLY · the source of truth signed amount · per (txn × currency) Σ=0 no UPDATE · no DELETE · ever INTEGRITY CHECK Σ per txn×ccy = 0? fail → halt + page BALANCE MATERIALIZER running sum per account snapshot daily · rebuildable MERCHANT BALANCE API available · pending · reserve FINANCE · RECON · REGULATOR as-of-date queries · audit trail chargeback 30–90d later → NEW entries, never an edit SOLID — the write path · DASHED — derived reads & the late-dispute loop · The log is written once; everything else is recomputed.
FIG. 1 — One immutable spine. Producers append balanced sets through an idempotency gate; balances, APIs and audits are views. A dispute that lands months later appends new entries — it does not reopen the old ones.

Three properties of this picture carry the interview. First, the entry builder asserts SUM=0 before anything is sent — an unbalanced transaction never reaches the log, so the invariant is a precondition of writing, not a hope checked afterward. Second, the idempotency gate sits between the builder and the log, so a producer that retries after a timeout collides on a unique key and the second attempt is silently absorbed; money is moved at-most-once even though the network guarantees at-least-once delivery. Third, everything below the dashed line is derived: balances, the merchant API, the regulator's as-of-date view. Drop the entire read layer and you have lost nothing but cache — the truth survives intact in the append-only log, and the balances rebuild from a single scan.

The Failure Philosophy, In One Rule

The log never lies, and the log never forgets. Under load, a balance read may serve a slightly stale snapshot — acceptable, because the merchant API is a convenience view. But a write degrades in exactly one direction: it either appends a fully balanced, idempotent set of entries, or it appends nothing and fails loudly. There is no half-posted transaction, no UPDATE to patch a mistake, no DELETE to make a problem disappear. Corrections move forward as new entries; the past is read-only by construction.


§ 03 — DATA MODELOne log, a chart of accounts, two derived views

The schema is almost austere. A single append-only fact carries every movement. A small chart of accounts gives each party a balance. FX rates are a slowly-changing dimension. The balance and the business-event grouping are derived. The discipline is in the constraints, not the column count.

The atom — every movement, signed and immutable

The ledger entry is the only table the system genuinely owns. Each row is one signed amount on one account in one currency, tied to a business event by balance_txn_id. The two load-bearing columns are source_event_id, the idempotency key with a UNIQUE constraint that turns retries into no-ops, and reversal_of_id, the self-reference that makes a refund or chargeback point back at what it undoes — without ever touching the original row.

DDL · THE APPEND-ONLY SPINE
-- The "Atom" grain. One row per debit OR credit on (account, currency). -- APPEND-ONLY: no UPDATE, no DELETE. Enforced by permissions and by a -- trigger that raises on any modification. This is the source of truth. CREATE TABLE fct_ledger_entries ( entry_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, balance_txn_id BIGINT NOT NULL, -- groups one business event account_id TEXT NOT NULL REFERENCES dim_accounts, merchant_id BIGINT NOT NULL, currency_code CHAR(3) NOT NULL, -- Signed convention: positive = credit, negative = debit. -- The invariant: SUM(amount) per (balance_txn_id, currency_code) = 0. amount NUMERIC(20,4) NOT NULL, entry_type TEXT NOT NULL -- charge|refund|fee|fx_gain| CHECK (entry_type IN -- fx_loss|payout|chargeback| ('charge','refund','fee', -- reserve_hold|reserve_release 'fx_gain','fx_loss','payout', 'chargeback','reserve_hold','reserve_release')), fx_rate_locked NUMERIC(18,8), -- the rate AT posting, frozen effective_ts TIMESTAMPTZ NOT NULL, -- when the event happened posted_ts TIMESTAMPTZ NOT NULL DEFAULT now(), -- when it hit the books reversal_of_id BIGINT, -- → the entry this undoes source_event_id TEXT NOT NULL, -- Idempotency at the row: a producer retry writes the SAME key and -- bounces off this constraint instead of duplicating money. UNIQUE (source_event_id) ); CREATE INDEX idx_le_txn ON fct_ledger_entries (balance_txn_id); CREATE INDEX idx_le_balance ON fct_ledger_entries (account_id, merchant_id, currency_code, posted_ts);

Two timestamps, not one, and the distinction is the whole audit story. effective_ts is when the money moved in the world; posted_ts is when the row landed in the books. A chargeback that happened in March but posts in May has a March effective_ts and a May posted_ts — and "the balance as the books showed it on April 1" is a filter on posted_ts <= '2025-04-01', which correctly excludes a movement not yet recorded then. One column separates economic reality from recorded reality, and regulators care about both.

The chart of accounts and the FX dimension

Every party in a transaction is an account with a balance — not just merchants and customers, but internal accounts: fees, reserve, tax, fx_holding, chargeback_reserve. This is what lets a single charge balance to zero: the money the merchant gains is exactly the money the customer's account loses plus the fee revenue Stripe recognizes. FX rates are SCD2 because the rate used must be the one that was in force at posting — frozen onto the entry, never re-derived at read time.

DDL · CHART OF ACCOUNTS & FX RATES (SCD2)
-- The chart. Each party is an account; internal accounts make every -- transaction self-balancing. SCD1 — the chart rarely changes shape. CREATE TABLE dim_accounts ( account_id TEXT PRIMARY KEY, -- 'merchant_balance','fees','reserve'… account_kind TEXT NOT NULL -- asset|liability|revenue|holding CHECK (account_kind IN ('asset','liability','revenue','holding')), is_internal BOOLEAN NOT NULL ); -- FX rates as a daily SCD2 dimension. The entry builder reads the row -- valid at effective_ts and STAMPS the rate onto the ledger entry, so a -- later rate revision can never retroactively change a posted conversion. CREATE TABLE dim_fx_rates ( currency_pair TEXT NOT NULL, -- 'EUR/USD' rate NUMERIC(18,8) NOT NULL, effective_from TIMESTAMPTZ NOT NULL, effective_to TIMESTAMPTZ NOT NULL DEFAULT '9999-12-31', PRIMARY KEY (currency_pair, effective_from) );

The derived views — balance and business event

Two more tables, both derived, neither a source of truth. fct_balance_transactions groups the atoms of one business event for convenient query; snap_account_balance_daily is the running sum materialized for sub-second balance reads. The crucial property: both can be dropped and rebuilt from the log by replaying it. They exist for speed, and they are allowed to be temporarily stale, because correctness lives upstream of them.

DDL · DERIVED — EVENT GROUPING & DAILY BALANCE
-- The "Business event" grain. One row per charge/refund/payout/dispute. -- A convenience grouping over the atoms; rebuildable from the log. CREATE TABLE fct_balance_transactions ( balance_txn_id BIGINT PRIMARY KEY, txn_type TEXT NOT NULL, -- charge|refund|payout|chargeback… source_object_id TEXT NOT NULL, -- charge_id / refund_id merchant_id BIGINT NOT NULL, status TEXT NOT NULL, txn_ts TIMESTAMPTZ NOT NULL ); -- The "State" grain. Running balance per (account × merchant × currency -- × day). DERIVED from fct_ledger_entries. Read for speed; if it ever -- disagrees with the log, the log wins and this is recomputed. CREATE TABLE snap_account_balance_daily ( account_id TEXT NOT NULL, merchant_id BIGINT NOT NULL, currency_code CHAR(3) NOT NULL, as_of_date DATE NOT NULL, opening NUMERIC(20,4) NOT NULL, debits NUMERIC(20,4) NOT NULL, credits NUMERIC(20,4) NOT NULL, closing NUMERIC(20,4) NOT NULL, PRIMARY KEY (account_id, merchant_id, currency_code, as_of_date) );

§ 04 — THE CORE INVARIANTSUM equals zero, or payments stop

Every correctness guarantee in this system collapses to one statement: for any business event, the signed entries sum to zero within each currency. It is not a report you run nightly to feel reassured. It is a gate the books are not allowed to pass through unbalanced.

Double-entry's genius is that it makes a whole class of bugs arithmetically impossible to hide. If a producer drops a leg, miscomputes a fee, or fat-fingers a sign, the transaction's currency sum is no longer zero, and a single query finds it. There is no way for money to silently appear or vanish, because every credit is born with its matching debit. The model does not trust the producer to be correct; it gives the producer a property that is cheap to verify and impossible to fake.

Consider a €100 charge with a €2.90 fee. It is one business event and four atoms, and the EUR sum is exactly zero:

SQL · A CHARGE, AS BALANCED ENTRIES
-- Business event: charge €100, Stripe fee €2.90. One balance_txn, 4 atoms. INSERT INTO fct_ledger_entries (balance_txn_id, account_id, merchant_id, currency_code, amount, entry_type, effective_ts, source_event_id) VALUES (1001, 'merchant_balance', 42, 'EUR', 100.00, 'charge', now(), 'evt_a'), (1001, 'fees', 42, 'EUR', -2.90, 'fee', now(), 'evt_b'), (1001, 'customer_balance', 42, 'EUR', -100.00, 'charge', now(), 'evt_c'), (1001, 'fees_revenue', 42, 'EUR', 2.90, 'fee', now(), 'evt_d'); -- SUM(amount WHERE currency='EUR') = 100 - 2.90 - 100 + 2.90 = 0 ✓

The continuous integrity check

The invariant runs continuously, not as reassurance but as a circuit breaker. An empty result set is health; a single returned row is a producer bug, and the response is not a Jira ticket — it is to halt the payments pipeline before the corruption propagates into a merchant payout. This is the one query the on-call engineer is woken for.

SQL · THE INVARIANT AS A CIRCUIT BREAKER
-- Continuous DQ. Empty = healthy. Any row = a producer regression that -- broke double-entry. Page on-call; freeze payouts until it is resolved. SELECT balance_txn_id, currency_code, round(sum(amount), 4) AS net, count(*) AS entry_count FROM fct_ledger_entries WHERE posted_ts >= now() - INTERVAL '1 hour' GROUP BY balance_txn_id, currency_code HAVING abs(sum(amount)) > 0.0001; -- exact-zero, modulo float dust
A ledger off by a cent is not a degraded ledger. It is a broken one. The invariant does not warn — it stops the line.LEDGER RULE Nº 1 — BALANCED OR NOT AT ALL

There is a second invariant hiding inside the first, and it is what makes the system replay-safe: the UNIQUE constraint on source_event_id. A producer that times out mid-write does not know whether its entries landed, so it retries with the identical event id. The retry's INSERT collides on the unique key; the application catches the violation and returns the original transaction. The money is moved exactly once even though the write was attempted twice — idempotency enforced at the database, not hoped for in application logic. SUM=0 keeps the books honest; UNIQUE keeps them from double-counting. Together they are the entire correctness story.


§ 05 — INGESTION & WRITESPython that refuses to imbalance the books

Three small programs carry the write path: the entry builder that turns an event into a balanced set, the FX builder that locks a rate into the ledger, and the idempotent appender that makes a retry a no-op. Each is defined as much by what it refuses to do as by what it does.

1 · The entry builder — balanced before it leaves the process

The builder's contract is absolute: it returns a set of entries whose per-currency sums are zero, or it raises. It never returns a partial transaction for the caller to "finish later," because a partial transaction is the one thing the ledger must never see. The SUM=0 assertion lives here, in the producer, so that an unbalanced set dies in application code and never reaches the durable log.

PYTHON · ENTRY BUILDER — Σ=0 IS A PRECONDITION
from collections import defaultdict from decimal import Decimal class UnbalancedTransaction(Exception): ... def build_charge(txn_id, merchant_id, currency, gross, fee, source_id): """Return the balanced entry set for a charge, or raise. We assert the invariant in the producer so an imbalanced transaction can never be appended. There is no 'draft' state and no half-written charge.""" g, f = Decimal(gross), Decimal(fee) entries = [ _entry(txn_id, "merchant_balance", merchant_id, currency, g, "charge", f"{source_id}:mb"), _entry(txn_id, "fees", merchant_id, currency, -f, "fee", f"{source_id}:fee"), _entry(txn_id, "customer_balance", merchant_id, currency, -g, "charge", f"{source_id}:cb"), _entry(txn_id, "fees_revenue", merchant_id, currency, f, "fee", f"{source_id}:rev"), ] _assert_balanced(entries) # the gate every builder funnels through return entries def _assert_balanced(entries) -> None: sums = defaultdict(lambda: Decimal(0)) for e in entries: sums[e["currency_code"]] += e["amount"] for ccy, total in sums.items(): if total != Decimal(0): # Decimal, not float: cents are exact raise UnbalancedTransaction(f"{ccy} nets {total}, must be 0")

Note the use of Decimal, not floating point. Money in binary float is a bug waiting for the right two numbers; cents must be exact, and the invariant must hold to the cent, not to the nearest epsilon. This is a small choice that an interviewer will notice you making, and a large one to get wrong.

2 · The FX builder — lock the rate into the ledger

A cross-currency event is the canonical four-entry transaction, and its discipline is to record the conversion rather than compute it at read time. The rate is read from the SCD2 dimension as of the event, stamped onto the entries, and balanced per currency independently. "What did we earn in USD in March?" then sums the USD-denominated rows and never re-runs FX — because the rate that mattered is already frozen in the row.

PYTHON · FX PAYOUT — RATE FROZEN AT EVENT TIME
def build_fx_payout(txn_id, merchant_id, eur_amount, effective_ts, source_id, fx_lookup): """EUR balance → USD bank. Four entries: the EUR side closes, the USD side opens, and the locked rate is written onto the rows. We never 'convert at read time' — every conversion is a recorded event.""" rate = fx_lookup("EUR/USD", as_of=effective_ts) # SCD2 as-of read eur = Decimal(eur_amount) usd = (eur * rate).quantize(Decimal("0.0001")) entries = [ _entry(txn_id, "merchant_balance", merchant_id, "EUR", -eur, "payout", f"{source_id}:eur_out", fx=rate), _entry(txn_id, "fx_holding", merchant_id, "EUR", eur, "payout", f"{source_id}:eur_hold", fx=rate), _entry(txn_id, "fx_holding", merchant_id, "USD", -usd, "payout", f"{source_id}:usd_hold", fx=rate), _entry(txn_id, "merchant_bank", merchant_id, "USD", usd, "payout", f"{source_id}:usd_out", fx=rate), ] _assert_balanced(entries) # EUR nets 0; USD nets 0 — independently return entries

3 · The idempotent appender — a retry is a no-op

PYTHON · APPEND — IDEMPOTENT BY THE UNIQUE KEY
async def append_transaction(db, entries) -> str: """Append a balanced set atomically. The UNIQUE(source_event_id) does the idempotency: a retried producer writes the same keys and collides, and we return the ORIGINAL txn instead of doubling the money. No UPDATE path exists in this function — there is no way to mutate the past, only to append to it.""" try: async with db.transaction(): # all entries land, or none do await db.execute_many(INSERT_ENTRY_SQL, entries) return entries[0]["balance_txn_id"] except UniqueViolation: # The retry path: the event already posted. Idempotent success — # look up and return the transaction we wrote the first time. return await db.scalar( "SELECT balance_txn_id FROM fct_ledger_entries " "WHERE source_event_id = $1", entries[0]["source_event_id"])

The carve-out an interviewer listens for is in that docstring: this function has no UPDATE path. There is deliberately no code anywhere that mutates a posted entry, because the existence of such a path is itself the vulnerability. Append, or fail, or absorb a duplicate — those are the only three outcomes, by construction.


§ 06 — AGGREGATIONReversals, balances, and the open past

Two derived layers sit atop the log: balances, which are running sums, and corrections, which are new transactions. A refund is not an edit. A chargeback is not an edit. They are fresh, balanced sets of entries that point back at what they undo — which is the only way the past can stay both correct and immutable.

The junior instinct is to mark a charge "refunded" with an UPDATE. The double-entry answer appends the mirror image: every leg of the original, with the sign flipped, in a new transaction whose entries carry reversal_of_id back to the originals. Both transactions exist forever. The merchant's balance moves because the sum over the log moved — not because any historical row changed.

SQL · A REFUND IS A NEW, BALANCED TRANSACTION
-- Refund of balance_txn 1001. New txn 1002; each leg mirrors an original -- and links to it via reversal_of_id. The original rows are untouched. INSERT INTO fct_ledger_entries (balance_txn_id, account_id, merchant_id, currency_code, amount, entry_type, effective_ts, reversal_of_id, source_event_id) VALUES (1002, 'merchant_balance', 42, 'EUR', -100.00, 'refund', now(), 1, 'evt_r1'), (1002, 'fees', 42, 'EUR', 2.90, 'refund', now(), 2, 'evt_r2'), (1002, 'customer_balance', 42, 'EUR', 100.00, 'refund', now(), 3, 'evt_r3'), (1002, 'fees_revenue', 42, 'EUR', -2.90, 'refund', now(), 4, 'evt_r4'); -- New txn nets 0 in EUR ✓ ; the merchant's running balance falls by 100, -- because SUM over the log fell — no historical row was ever rewritten.

Balances, then, are pure derivation. The daily snapshot is a windowed running sum over the log per account and currency — materialized for speed, but defined entirely by the entries. Drop it and rebuild it from one scan and nothing is lost. This is the property that lets a finance team trust a sub-second balance read: it is not an independent number that could be wrong, it is a cache of a sum that is always recomputable.

SQL · BALANCE AS A MATERIALIZED RUNNING SUM
-- Rebuild the daily balance snapshot from the log. Idempotent: re-running -- for a date reproduces the identical numbers, because the log is immutable. INSERT INTO snap_account_balance_daily (account_id, merchant_id, currency_code, as_of_date, opening, debits, credits, closing) SELECT account_id, merchant_id, currency_code, :as_of_date AS as_of_date, coalesce(sum(amount) FILTER (WHERE posted_ts < :as_of_date), 0) AS opening, sum(amount) FILTER (WHERE amount < 0 AND posted_ts::date = :as_of_date) AS debits, sum(amount) FILTER (WHERE amount > 0 AND posted_ts::date = :as_of_date) AS credits, sum(amount) FILTER (WHERE posted_ts <= :as_of_date) AS closing FROM fct_ledger_entries GROUP BY account_id, merchant_id, currency_code;
A monthly close locks the past. But the past is never sealed against new entries — a chargeback from sixty days ago lands today, in a post-close adjustment sub-period, as fresh entries that were never an edit.LEDGER RULE Nº 2 — THE BOOKS CLOSE, BUT THEY NEVER LOCK OUT

That rule resolves the hardest tension in payments accounting. Card networks deliver chargebacks thirty to ninety days after the charge — long after the period that contained the charge has "closed." A model that treats a closed month as immutable-and-sealed has nowhere to put them. This model does: the close locks the past against edits, but the append-only log always accepts new entries. A late chargeback posts today, with an effective_ts back in the original month for economic reporting and a posted_ts of now for the audit trail, attributed to a post-close adjustment sub-period. The merchant's balance silently steps down sixty days later — correctly, traceably, and without anyone having reopened a single historical row.


§ 07 — ANALYTICS SQLInterrogating the books

The append-only log answers questions no mutable table can. Three queries an interviewer loves, each carrying a named pattern: the as-of-date audit reconstruction, a self-join for dispute lag, and conditional aggregation for realized FX.

The balance as the books showed it then — as-of-date audit

The regulator's question, and the one the whole design exists to answer: merchant X's balance on a historical date, as it appeared then, regardless of corrections posted since. The pattern is the as-of-date reconstruction — a filter on posted_ts, summing the immutable log. Because rows are never edited, the answer is stable forever; re-run it next year and it returns the same number.

SQL · BALANCE AS OF A HISTORICAL DATE
-- Merchant balance per currency as the books showed it on a past date. -- posted_ts <= cutoff: a chargeback that posted AFTER the cutoff (even if -- it happened before) is correctly excluded. Defensible regardless of any -- later correction — the append-only contract, expressed as a predicate. SELECT merchant_id, currency_code, round(sum(amount), 2) AS balance_as_of FROM fct_ledger_entries WHERE account_id = 'merchant_balance' AND merchant_id = :merchant_id AND posted_ts <= :as_of_ts -- recorded-reality cutoff GROUP BY merchant_id, currency_code;

Chargeback rate and dispute lag — the reversal self-join

Risk's question: which merchants are bleeding chargebacks, and how long after the charge do disputes land? The pattern is a self-join over the log — charges joined to their reversing chargebacks via reversal_of_id — with conditional aggregation for the rate and a timestamp difference for the lag. Merchants over a threshold trigger a reserve increase, closing the loop back to the chart of accounts.

SQL · CHARGEBACK RATE & DISPUTE LAG PER MERCHANT
WITH charges AS ( SELECT merchant_id, balance_txn_id, effective_ts AS charge_ts FROM fct_ledger_entries WHERE entry_type = 'charge' AND account_id = 'merchant_balance' AND amount > 0 ), chargebacks AS ( SELECT reversal_of_id, effective_ts AS chargeback_ts FROM fct_ledger_entries WHERE entry_type = 'chargeback' ) SELECT c.merchant_id, count(c.balance_txn_id) AS charges, count(cb.reversal_of_id) AS chargebacks, round(100.0 * count(cb.reversal_of_id) / nullif(count(c.balance_txn_id), 0), 2) AS cb_rate_pct, round(avg(extract(epoch FROM cb.chargeback_ts - c.charge_ts) / 86400), 1) AS avg_lag_days FROM charges c LEFT JOIN chargebacks cb ON cb.reversal_of_id = c.balance_txn_id WHERE c.charge_ts >= now() - INTERVAL '90 days' GROUP BY c.merchant_id HAVING count(c.balance_txn_id) > 100 ORDER BY cb_rate_pct DESC; -- >1% → raise reserve_pct

Realized FX gain and loss — conditional aggregation by currency

Finance's question: how much did currency movement actually cost or earn? Because every conversion is a recorded entry with a frozen rate, realized FX is just the sum of the dedicated fx_gain and fx_loss legs — no re-conversion, no guessing what rate applied. The pattern is conditional aggregation, slicing the same log by entry type and reporting currency.

SQL · REALIZED FX, STRAIGHT FROM THE LEDGER
SELECT merchant_id, currency_code, round(sum(amount) FILTER (WHERE entry_type = 'fx_gain'), 2) AS fx_gain, round(sum(amount) FILTER (WHERE entry_type = 'fx_loss'), 2) AS fx_loss, round(sum(amount) FILTER (WHERE entry_type IN ('fx_gain','fx_loss')), 2) AS net_fx FROM fct_ledger_entries WHERE posted_ts >= date_trunc('month', CURRENT_DATE) GROUP BY merchant_id, currency_code HAVING sum(amount) FILTER (WHERE entry_type IN ('fx_gain','fx_loss')) <> 0 ORDER BY net_fx;

§ 08 — THE DASHBOARDProving the books are honest

A ledger's dashboard has one tile that matters more than all the others: the invariant. Everything else is context. The operator's first glance answers a single question — is every transaction still summing to zero? — and only then looks at volume, disputes, and FX.

INTEGRITY
unbalanced txns in the last hour (must be zero — anything else freezes payouts), idempotency collisions absorbed (proof retries are no-ops), and append latency p99 on the write path. This row is the heartbeat; if it is red, nothing else is read.
MONEY MOVEMENT
charge volume, refund rate, payout backlog, and reserve balance across merchants — the business view, all derived from the same log the integrity row guards.
THE OPEN PAST
chargeback arrivals per day with their dispute-lag distribution, post-close adjustments landing against sealed months, and net realized FX — the late-arriving truth the append-only model is built to absorb.
Ledger Ops — Global WED 14:05 UTC · ALL CURRENCIES · 60s REFRESH
Unbalanced Txns / 1h
0
Idempotency Collisions
1.4k/s
Append Latency p99
11ms
Charge Volume
1.18k/s
Chargeback Dispute Lag — arrivals today by days-since-charge
peak ≈ 45–60d 90d
Refund Rate
2.1%
Payout Backlog
$4.2M
Reserve Held
$61M
Post-Close Adj
318
Net Realized FX
−$83k
Currencies Active
134
FIG. 2 — A healthy ledger. The integrity tile reads zero — the only number that can stop the line — while 1.4k idempotency collisions a second prove retries are being absorbed, not double-charged. The dispute-lag histogram peaks at 45–60 days, exactly the window the open-past design exists to serve.

Read the amber and red tiles together and the dashboard narrates the open past from §06. Chargebacks are landing on a 45-to-60-day lag, post-close adjustments are flowing into prior months as new entries, and net realized FX is mildly negative on a strong-dollar week. None of it touches a historical row; all of it moves balances forward. And the one green tile that governs everything — zero unbalanced transactions — is what lets finance trust every other number on the screen.


§ 09 — THE RUBRICWhat was actually being tested

Strip away the payments specifics and the question was probing five judgments, each of which generalizes far past Stripe:

DERIVE, DON'T STORE
Recognizing that the balance is a sum over an immutable log, not a column to mutate — so it can never be independently wrong and always rebuilds from truth.
INVARIANT AS GATE
Enforcing SUM=0 per transaction and currency as a precondition of writing and a continuous circuit breaker — making a whole class of bugs arithmetically impossible to hide.
IDEMPOTENCY AT THE ROW
Turning a unique constraint into replay-safety, so a retried write under a network partition is a no-op rather than a double-charge — correctness pushed down to the database.
CORRECTIONS MOVE FORWARD
Modeling refunds and chargebacks as new, balanced, back-linked transactions instead of edits — keeping the past simultaneously correct and immutable.
FREEZE THE RATE
Recording FX as ledger entries with the rate locked at event time, so reporting sums recorded reality and never re-converts — the same discipline any time-sensitive external input demands.
The balance is a story you can always retell from the beginning, exactly the same way, no matter who asks or when. Append-only is not a constraint on the ledger — it is what makes the ledger worth believing.— CLOSING ARGUMENT