Skip to content

Scenario: payments ledger

Write money as entries summing to zero, serialise each account's debits on its row, and post each payment once, checked nightly by id.

Predict first

Before you read: predict

ExamHall keeps each candidate's credit as ledger entries, and the balance is their sum; candidate 7's entries sum to 50.00. A refund to the candidate's card runs at Read Committed: BEGIN; SELECT (SELECT sum(amount) FROM entries WHERE account_id = 7) AS balance FROM accounts WHERE id = 7 FOR UPDATE; if the balance is at least 30.00, insert the refund's two entries (-30.00 on account 7, +30.00 on the provider's clearing account); COMMIT. Two 30.00 refunds for candidate 7 arrive together. The second one's SELECT starts while the first holds the lock, and waits until the first commits.

What happens to the second refund?

Pick one

The 30-second version

A ledger never stores money as one number. Each transfer writes immutable entries, in integer cents, that sum to zero; a balance is their sum. Two deep dives decide it. Concurrency: lock the account row, then read the sum in a new statement. The provider: keep a pending row with its key, post once per payment, reconcile nightly by id.

Write money as entries summing to zero, serialise each account's debits on its row, and post each payment once, checked nightly by id.

How it works

Step 1 of 14

Entries, not a balance. A transfer writes immutable entry rows in integer cents: a debit on one account, a credit on another, summing to zero.
In the kitchen
  • each move writes two lines: out of the safe, into a cutter's hands → a transfer's debit and credit, summing to zero (step 1)

Analogy: A diamond dealer's memo book

Every parcel of stones that leaves or enters the dealer's safe is written into one bound memo book, and the dealer keeps no other record.

At the dealer's In the ledger
each move writes two lines: out of the safe, into a cutter's hands a transfer's debit and credit, summing to zero (step 1)
a wrong line is never crossed out; a reversing pair is written below a reversing transfer (step 2)
what the safe holds is the sum of its lines; the card on the safe door is a copy the derived balance and its cache (step 3)
parcels are counted whole, never as part of a parcel integer cents (step 4)
two buyers ask for the last parcel of one size; the dealer takes the safe's page before counting it lock the account row, then read (steps 8–9)
a dealer who counted before waiting for the page a sum inside the locking statement (step 9)
a courier parcel sent again after a lost receipt keeps its parcel number the pending row's key (steps 10–11)
each morning the courier's manifest is paired with the book, parcel number by parcel number the nightly reconcile by id (steps 13–14)

Breaks down when: a parcel sits in the safe and can be counted by looking. An account's money exists only as the sum of its entries (step 3).

Contrasting case: a safe with only the door card, rubbed out and rewritten at each move. When it disagrees with the courier's manifest, nothing says which move was wrong: a balance column with no entries.

In real terms: write entries, never balances; serialise debits on the account's row; and make every payment post once, proved each night by id.

Examples, run for real

4.1 ExamHall EXH-5060: a refund the ledger never posted

ExamHall's nightly reconcile pairs the provider's report with its postings by payment id. One night it finds a re-sit refund the provider paid and the ledger never posted: the webhook arrived while a deploy was down.

4.1 EXH-5060 before: report 4 lines, postings 3 · paired by provider id: re_77 (-60.00) has no posting · fees balance 150.00 · transfers not summing to zero 0
4.1 MR !5360: re_77 posted from the report line · the provider's retried webhook for re_77: refused by the unique index (23505), nothing posted · unpaired lines 0 · fees balance 90.00 · transfers not summing to zero 0

MR !5360 posts the refund from the report line under a unique index on the provider's id, so the provider's retried webhook posts nothing (steps 1, 3, 12–13).

4.2 A chat tip whose reply was lost

A chat app's worker commits a pending payment row with its key, then calls the provider (a model that keeps the first response for each key, as its page says). The reply is lost and the worker restarts.

4.2 pending row tip_88 committed with its key · charge call: ECONNRESET (the provider charged) · row still pending · worker restarted, retried with the stored key: saved response ch_1 · card charges 1 · creator 3 +5.00, posted once

The retry sends the stored key, so the card is charged once (steps 10–11).

Try it

Answer first, then check. Nothing is saved.

Question 1 of 6

A chat app pays creators their tips. Creator 12's entries sum to 80.00. To be safe, the team runs each cash-out of 50.00 at REPEATABLE READ: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT id FROM accounts WHERE id = 12 FOR UPDATE; SELECT sum(amount) FROM entries WHERE account_id = 12; the cash-out's two entries if that sum covers it; COMMIT. Two 50.00 cash-outs for creator 12 run together, step by step, the second's step just after the first's; a step that must wait for a lock waits.

What happens to the second cash-out?

Pick one

Question 2 of 6

A chat app posts a tip by either of two paths: the worker that made the charge call posts when the provider's reply says "succeeded", and the webhook handler posts on each payment_intent.succeeded event. In this model of the two paths and their deliveries, each path posts 5.00 from the fan's clearing account to creator 3 in one transaction, and only the webhook handler deduplicates: in its posting's transaction it inserts the payment id under a unique index, and posts only if that insert succeeds. For pi_9, the worker's call gets its reply; then webhook evt_1 for pi_9 arrives, and is sent again while the first delivery is still inside its transaction. A minute later the same fan tips 5.00 again, pi_10, and its webhook evt_2 arrives while the worker's transaction for pi_10 is still open.

How many times is each payment posted to creator 3?

Pick one

Question 3 of 6

A chat app's nightly job pairs yesterday's provider report with the ledger's postings by payment id, counts each side, and flags every id whose two counts differ. The report lists pi_31 5.00, pi_32 12.00 and pi_34 5.00. The ledger's postings are pi_31 5.00, pi_32 21.00, pi_33 5.00 and pi_34 5.00.

Which payment ids does the job flag?

Pick one

Question 4 of 6

A chat app keeps each creator's balance as a cache column on the account row, accounts.balance, changed in the same transaction as the entries. Creator 21's balance is 90.00. A cash-out of 60.00 runs at Read Committed: BEGIN; SELECT balance FROM accounts WHERE id = 21 FOR UPDATE; if that balance covers it, the cash-out's two entries and a 60.00 decrease of the cache; COMMIT. Two 60.00 cash-outs for creator 21 run together, step by step, the second's step just after the first's; a step that must wait for a lock waits.

What does the second cash-out's SELECT return?

Pick one

Question 5 of 6

A podcast service splits each 100.00 sponsorship payout equally between six co-hosts. Amounts are cents in a bigint column, and each co-host's leg is 10000 / 6, rounded to the nearest cent. A transfer inserts the sponsor's leg of -10000 first, then the six co-hosts' legs, then commits. A constraint trigger declared DEFERRABLE INITIALLY DEFERRED refuses any transfer whose legs do not sum to zero.

What happens to the transfer?

Pick one

Question 6 of 6

A ticketing site's organiser dashboard reads organisers.balance, a cache of the organiser's entries; for organiser 12 both show 120.00. Each 40.00 sale writes the sale's two entries in one transaction, then adds 40.00 to the cache in a statement of its own (balance = balance + 40.00), retried by the worker until it succeeds. A deploy kills the worker right after sale 1's entries commit. The restarted worker repeats nothing and takes sale 2, which runs to the end.

What do the cache and the entries' sum show after sale 2?

Pick one

Go deeper

Pro tips
  1. Check every transfer sums to zero after any change to the posting code. When: after a migration, a fix script or a new payment type (steps 1, 5). Status: RAN.
Sources

quote-check.mjs finds every passage below verbatim in its saved text. The row names all three. Valid as of 2026-10-18.

  • Stripe, Idempotent requests: https://docs.stripe.com/api/idempotent_requests (stripe-idempotent-requests.txt). Quoted: "Then, if a connection error occurs, you can safely repeat the request without risk of creating a second object or performing the update twice."; "For API v1, Stripe saves the status code and body of the first request made with a given idempotency key after endpoint execution begins. Retries with the same key return the saved response, including 500 errors."
  • Stripe, Webhooks: https://docs.stripe.com/webhooks (stripe-webhooks.txt). Quoted: "Webhook endpoints might occasionally receive the same event more than once."; "In some cases, two separate Event objects are generated and sent. To identify these duplicates, use the ID of the object in data . object along with the event . type ."; "Stripe attempts to deliver events to your destination for up to three days with an exponential back off in live mode." Supports step 12 and the provider's webhook deliveries.
  • PostgreSQL 17, Transaction Isolation: https://www.postgresql.org/docs/17/transaction-iso.html (pg-transaction-iso.txt). Quoted: "In effect, a SELECT query sees a snapshot of the database as of the instant the query begins to run."; "Because of the above rules, it is possible for an updating command to see an inconsistent snapshot: it can see the effects of concurrent updating commands on the same rows it is trying to update, but it does not see effects of those commands on other rows in the database."; "a query in a repeatable read transaction sees a snapshot as of the start of the first non-transaction-control statement in the transaction" Supports steps 7–9.

The curriculum row's pro tip states the integer-cents rule (step 4).

Checked against the tools and docs as of 2026-10-18.