Settlement flow: a state machine for money moving between people
Recording who owes what is arithmetic; recording that a debt was actually paid is a negotiation between two people, and it's where a splitting app either earns trust or loses it. This chapter walks Fable's settlement flow as what it really is — a small, actor-gated state machine (requested → marked_paid → confirmed, with dispute and cancel escapes) guarded by a Postgres advisory lock so two concurrent confirmations can't pay a debt twice. Then the real bug that shipped: an over-settle guard tied to the wrong decomposition of a group's debts, which rejected perfectly valid payments by a few paise when 'simplify debts' was on.
Settlement flow: a state machine for money moving between people
There are two completely different money problems in a group-expenses app, and conflating them is how these apps go wrong. The first is accounting: given a pile of expenses and splits, what is each person's net position? That's a pure function over the ledger — the expense engine's job, deterministic and unambiguous. The second is settlement: recording that Aditi actually handed Ravi ₹1,433. And that one isn't arithmetic at all — it's a negotiation between two people over time, mediated by an app that has to stay correct while both of them tap buttons on flaky phone connections. Aditi says she paid; Ravi has to confirm he received it; maybe he disputes it; maybe she cancels because she paid the wrong person. This is not a number you compute. It's a workflow with states, and modeling it as anything less is how you end up with debts marked paid that nobody received.
So Fable models settlement as an explicit state machine, and this chapter is that machine — the states, the actor rules, the lock that keeps concurrent confirmations honest, and the guard that broke in a genuinely subtle way.
Why a state machine, and not a boolean
The naive model is a paid: boolean on a debt. It falls apart on contact with reality: who set it — the payer or the payee? Can the payer flip it alone (then they can mark debts paid that they never paid)? What if the payee disagrees? What about a payment made outside the app that both just want to record? A boolean can't answer any of these, because settlement has more than two states and the transitions between them are governed by who is acting.
The states Fable actually uses:
requested— the recipient is asking to be paid ("you owe me, please settle").marked_paid— the payer asserts they've sent the money. Asserted, not confirmed — this is the crucial in-between state a boolean can't represent.confirmed— the recipient agrees the money arrived. This is the only state where money actually moves in the ledger, and it's terminal.disputed— the recipient says "I marked-paid but I didn't actually get it," bouncing it back for resolution.cancelled— called off. Also terminal.
And the transitions aren't free-for-all — each is gated on the actor performing it. The state machine (state-machine.ts, a table of rules the service consults) encodes exactly who may do what:
| From | To | Who's allowed |
|---|---|---|
| (new) | requested | recipient |
| (new) | marked_paid | payer |
| (new) | confirmed | recipient (a direct "mark received" — I was paid in cash, record it settled) |
requested | marked_paid | payer |
requested | cancelled | payer or recipient |
marked_paid | confirmed | recipient |
marked_paid | disputed | recipient (or system) |
marked_paid | cancelled | payer — only within 30 minutes of marking paid |
disputed | confirmed | recipient |
disputed | cancelled | payer or recipient |
Two design choices in that table are worth calling out. Only the recipient can confirm — the payer can claim to have paid (marked_paid), but the money doesn't move in the ledger until the person receiving it agrees. That asymmetry is the whole trust model: you can't mark your own debts settled. And the 30-minute window on marked_paid → cancelled is a small, deliberate compensating escape — a payer who fat-fingered "I paid" can back out, but only briefly, so the payee isn't left forever unsure whether a marked-paid debt is real. Attempt any transition not in the table and the machine throws a typed SETTLEMENT_INVALID_TRANSITION (surfaced as a 422); the service itself is just an I/O wrapper that loads the row, works out whether the caller is the payer or recipient, asks the machine "is this legal?", and only then writes.
The lock: why two confirmations can't pay a debt twice
Confirmation is where money moves, so it's where concurrency can corrupt the ledger — and this is the locks lesson made real. Picture two devices confirming settlements between the same pair of people at the same instant. Each transaction reads the current outstanding balance, sees the debt is still owed, and proceeds to confirm — and now the debt is settled twice, driving the pair's net past zero. Real money, mis-recorded, no error raised. MVCC alone doesn't stop it: both reads saw a valid state.
Fable prevents it with a transaction-scoped advisory lock on the pair. Before touching balances, the settlement transaction runs pg_advisory_xact_lock(hashtext(key)), where the key is built from the group and the two user ids sorted into a canonical [low, high] order — `${groupId}:${low}:${high}`. The second concurrent confirmation blocks on the lock; when it proceeds, it re-reads the now-reduced outstanding and correctly sees there's nothing (or less) left to settle. Two details map straight to the lessons: the lock is pessimistic on purpose (confirmations on one pair are rare, so serializing the occasional collision is a fine price for airtight money correctness), and sorting the ids into [low, high] is the consistent lock-ordering rule that prevents deadlocks — because the key is always built low-id-first, two transactions touching the same pair can never acquire it in opposite orders. The same lock guards the "born-confirmed" direct-settle path (markReceived), because that moves money immediately too.
Idempotency and the collapse of duplicate intents
Settlement writes carry the same client-generated idempotency key as every money write, with a unique constraint (groupId, payerUserId, idempotencyKey) — so a retry over a dropped connection loses to the database rather than creating a second settlement. But settlement adds a subtler dedup problem: a user taps Pay, navigates back, and taps Pay again — two different logical intents (different keys), but they shouldn't create two parallel open settlements for the same debt. So the flow collapses into any existing open (requested or marked_paid) settlement for that pair instead of forking a new one — the payer marking paid on an already-open request advances that row rather than starting a competing one. Born-confirmed direct settles never collapse (they're immediate and final). It's the same instinct as idempotency keys — don't create a duplicate, recognize the existing one — applied to workflow state rather than to a single insert.
What moves, and what the rest of the app sees
When a settlement reaches confirmed, two things happen beyond the state write. The ledger's net positions change — but note they aren't stored; a settlement is just another row the balance projection sums over, so "confirmed" money is simply confirmed settlements entering the net[user] formula. And because a confirmation changes what both people (and their other devices) should see, the service emits a realtime ServerEvent so the settled state refreshes everywhere at once — without it, one participant would confirm and the other would keep seeing an unsettled debt until they manually refreshed. There's also a settlement_reminders table with a cooldown index behind the "nudge" feature, so you can remind someone to pay without being able to spam them.
Planned (TDD)
Settlement as requested → marked_paid → confirmed, with an over-settle guard checking the proposed amount against the pairwise debt edge.
Shipped
The same happy path, plus disputed/cancelled states, actor-gated transitions with a 30-minute payer-cancel window, an advisory-lock over the sorted pair, an open-settlement collapse for double-taps, and an over-settle guard rewritten to cap at per-user net rather than a single edge.
The edge-based guard rejected valid payments by a few paise once Simplify Debts offered a different (equally valid) decomposition. Money workflows grow states and guards as you meet the real ways two people disagree about a payment — the happy path was never the hard part.
Interview takeaway
If an interview asks you to design settlements, payments, approvals, or any two-party workflow — and "design Splitwise / Venmo / an approvals system" always does — here's what this chapter teaches you to say:
- "I'd model it as an explicit state machine, not a boolean, with transitions gated on the actor." Say the states out loud (
requested → marked_paid → confirmed, plusdisputed/cancelled) and stress the asymmetry: only the recipient confirms, because you can't let someone mark their own debts settled. - "Money moves in exactly one state, and I'd serialize concurrent moves with a lock keyed on the canonical pair." Mention the advisory lock and the
[low, high]ordering as deadlock prevention — it signals you've thought about two confirmations racing. - "Retries and double-taps get idempotency keys and a collapse into the existing open workflow, so the network can't create phantom settlements."
- "The over-settle guard validates against the invariant (nobody's net passes zero), not against one UI representation of the debt." This is the highest-signal point — it shows you understand that the same truth has multiple valid decompositions and a guard must be robust to all of them.
The meta-point, in the spirit of this whole series: the happy path (requested → marked_paid → confirmed) was the easy 20%. The states, actors, locks, collapses, and the reframed guard are the 80%, and every one of them exists because two humans disagreeing about a payment is genuinely harder than adding up a bill. The accounting that produces the debts in the first place is the expense engine; where these rows physically live and churn is the data model.
Go deeper
- Martin Fowler — Accounting Patterns (narratives, entries) — The canonical treatment of modeling money movement as immutable entries and events rather than mutable balances — the conceptual root of "confirmed settlement is a row you sum, not a balance you overwrite."
- PostgreSQL docs — advisory locks — The exact primitive behind the pair lock: transaction-scoped advisory locks, auto-released at commit, keyed by an application-chosen integer — how Fable serializes confirmations on a pair.
- Finite-state machines — overview — A refresher on states, transitions, and guards as a formal model — the frame that turns "a tangle of booleans and if-statements" into the clean transition table this chapter is built on.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- Why is a paid: boolean the wrong model for settlement? Name at least three questions it cannot answer, and explain what the marked_paid state represents that a boolean cannot.
- Only the recipient can move a settlement to confirmed, even though the payer is the one sending money. Explain the trust reason for that asymmetry, and what the 30-minute payer-cancel window is protecting against.
- Two devices confirm settlements between the same pair simultaneously. Walk through how that double-pays a debt, and exactly how the advisory lock on the sorted [low, high] pair key prevents it — including why the sorting matters.
- A user taps Pay, navigates back, and taps Pay again with a different idempotency key. Why should this NOT create two open settlements, and what does "collapse into the existing open settlement" do instead?
- The over-settle guard rejected valid payments by a few paise when Simplify Debts was on. Explain the root cause (two decompositions of the same net) and why capping at per-user net min(deficit, surplus) fixes it for both raw and greedy edges.
- State the general lesson the over-settle bug teaches about validating user actions, and how it applies beyond money to any case where the UI can offer more than one valid representation of the same underlying truth.
The expense engine: splitting money without losing a paisa
The core of a splitting app is one deceptively hard function: turn 'one bill, these people, this split rule' into an exact ledger of who owes whom — and keep it exact through edits, deletes, and a dozen rounding edge cases. This chapter is Fable's expense engine: the four split modes and the largest-remainder rounding that guarantees the parts always sum to the total, why percentages are stored as integer basis points, balances as a projection rather than stored truth, the greedy algorithm that simplifies a group's debts to the fewest transfers, and the check-then-act race that let a deleted expense invert a balance into a phantom 'owed back'.
The media pipeline: uploads you can trust, bytes you don't proxy
Receipts and photos are the one place a splitting app handles arbitrary user bytes, and that makes the media path both a bandwidth problem and a security problem. Fable's answer: the API never touches the bytes (clients upload and download directly to Cloudflare R2 via presigned URLs, served through a CDN), and it never trusts what the client says a file is (every upload is validated against its actual magic-byte signature, not its claimed MIME type). This chapter walks that pipeline, the access control that gates private media, and the honest list of what it deliberately doesn't do yet.