Under the Hood

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 expense engine: splitting money without losing a paisa

Ask someone what a bill-splitting app does and they'll say "it divides the bill." That's the easy 20%. The hard 80% is doing it exactly, in integers, across four different ways people want to split, through edits and deletions and retries, so that months later every member's phone agrees to the paisa on who owes whom. The data-model chapter covered where this lives (paisa-as-bigint, ULIDs, the ledger tables); this chapter is the engine that runs on top — the math that turns a bill into splits, the projection that turns splits into balances, and the concurrency that keeps both honest when the ledger changes underneath you.

The governing rule, inherited from the data model and never once relaxed: money is integer minor units (paisa), and the split of any expense sums to exactly the total. Not approximately. Every algorithm below exists to make that true by construction.

Four ways to split, one invariant

People split bills in genuinely different ways, so Fable's @hisaab/money package exposes four split functions — and each is written so its outputs sum to the input, with the rounding handled honestly rather than swept under a float:

  • EqualsplitEqual(₹100, 3). The naive 100/3 loses a paisa. Instead: integer-divide to get the base share (q = amount / n), take the remainder (r = amount % n), and hand the leftover r paisa out one each to the first r participants. splitEqual(10000, 3)[3334, 3333, 3333]. Sum: exactly 10000. No paisa created, none lost.
  • ExactsplitExact(₹100, [5000, 3000, 2000]). The user typed the amounts; the function's job is to validate, not compute — it throws unless the parts sum to the total. The database's splits-sum invariant, enforced at the door.
  • PercentsplitPercent(₹100, [3333, 3333, 3334]). Note those aren't percentages — they're basis points (integer ten-thousandths). You cannot pass 33.33 as a percentage; you pass 3333 bp, and the function throws unless the basis points sum to exactly 10000. This is deliberate: percentages invite the float ("33.33% + 33.33% + 33.33% = 99.99%, where's the last paisa?"), while integer basis points make "the weights sum to exactly 100%" a checkable integer fact.
  • SharessplitShares(₹100, [2, 1, 1]). Split by integer weights — the first person covers half, the others a quarter each. Good for "I had the big meal."

Percent and shares both route through one proportional split with the largest-remainder method: distribute by integer division, then award the truncation leftovers to the participants whose fractional remainder was largest (ties broken by input order). It's the same rounding banks and electoral apportionment use, and it's what guarantees the invariant holds for weighted splits too — the leftover paisa go somewhere deterministic, never vanish. The whole package is generic over a currency-branded Minor<C> type, so the compiler refuses to add two currencies, and there is no floating-point number anywhere in the path.

Balances are a projection, not a number

Here's the decision that keeps Fable's ledger trustworthy over years: a balance is never stored as truth. An expense produces expense_splits (who owes) and expense_payers (who paid); a person's net position in a group is a pure function computed over those plus confirmed settlements:

net[user] = Σ paid − Σ owed
          − Σ confirmed settlements where user is payer
          + Σ confirmed settlements where user is recipient

That's the entire truth of "who's up and who's down," and it's derived — recomputed from the append-only ledger, never mutated in place. There's a group_balance_cache for fast reads, but it is explicitly a cache: recomputable at any time, invalidated on every write, and rebuilt from scratch nightly with a zero-drift assertion. This is the difference between an app that's still correct after two years and one that has slowly drifted into balances nobody can explain — storing a derived number as state is how Splitwise-likes rot, and Fable refuses to. (It's also a textbook read-cache: the derived value is expensive-ish to compute and read constantly, so cache it, but the ledger stays the source of truth.)

Simplifying debts: the fewest transfers that settle everyone

A four-person trip generates a tangle: A owes B, B owes C, C owes A, everyone owes the person who booked the hotel. Settling every raw edge means a dozen transfers. Debt simplification collapses that to the minimum: net everyone out, then match who's down against who's up with as few transfers as possible.

Fable's greedyPairwise does it deterministically. Take the net-per-user vector, split it into creditors (positive net, sorted descending) and debtors (negative net, sorted by magnitude), then repeatedly match the largest debtor against the largest creditor, emitting a transfer for the smaller of the two remaining amounts and advancing whichever hits zero:

while creditors and debtors remain:
  amount = min(current creditor's remaining, current debtor's remaining)
  emit edge (debtor → creditor, amount)
  subtract from both; advance whichever reached zero

It's greedy and it's deterministic (ties broken by input order), which matters more than it looks: two devices computing the simplified debts must produce the identical set of transfers, or users would see different "pay this person" instructions. The result is the fewest edges that zero out every account — A→C directly instead of A→B→C→A. And critically, the greedy simplified edges and the raw pairwise edges are two valid decompositions of the same net vector — a fact that, as the settlement chapter's war story shows, you must respect when validating a payment, or you reject valid settlements offered by the other decomposition.

The hard part: editing and deleting a settled-against expense

Adding an expense is easy. Deleting one is where the engine meets the locks lesson head-on, because an expense doesn't live in isolation — its debts may already have been settled. Delete an expense whose debt someone just paid, and you have to reconcile a payment against a debt that no longer exists.

Expenses are soft-deleted (a deletedAt timestamp and a version increment, never a hard DELETE) so history and the ledger stay auditable, and the balance cache for every affected pair is invalidated. But the ordering of operations is the subtle, correctness-critical part — which is the war story.

Planned (TDD)

Expenses with four split modes and a balance view, deletion as a soft-delete plus a balance recompute.

Shipped

The same, plus largest-remainder rounding proven to sum to the total, integer basis points for percent splits, a nightly zero-drift balance-cache assertion, deterministic greedy debt-simplification, and expense deletion that takes settlement pair-locks before its pending-settlement check to close a phantom-'owed-back' race.

The happy path (add, split, view) was straightforward. The paisa-exact rounding, the derived-not-stored balances, and above all the cross-subsystem delete/confirm race were the real work — correctness under rounding and under concurrency is the entire point of a money engine.

Interview takeaway

For any "design a bill-splitter / expense tracker / ledger" prompt:

  • "Splits are computed in integer minor units with a largest-remainder method, so the parts always sum to the total." Show splitEqual(100, 3) → [34, 33, 33] and name largest-remainder — it proves you know rounding loses money if you're careless.
  • "Percentages are stored as integer basis points that must sum to 10,000." This one sentence signals you've seen "three 33.33%s don't add to 100%" bite.
  • "Balances are a projection over an append-only ledger, cached but never stored as truth, with a drift assertion." The single most important correctness statement about a ledger.
  • "Debt simplification is a deterministic greedy match of creditors against debtors." Stress deterministic — every device must compute the same transfers.
  • "Cross-subsystem operations like deleting a settled-against expense serialize on a shared lock, held around the check, not just the write." This is the highest-signal point — it shows you understand check-then-act races span services, not just rows.

The theme of the whole Fable, Deconstructed series holds here: the arithmetic was never the hard part. Getting the last paisa right under weird splits, keeping balances derived rather than stored, and closing the race where a delete and a confirmation collide — that's the engine. The money actually moving between people is the settlement flow; the schema underneath is the data model.

Go deeper

  • The largest-remainder method The apportionment algorithm behind splitEqual/splitPercent/splitShares — how leftover units are distributed so parts sum exactly to the whole, used from elections to banking.
  • Martin Fowler — the Money pattern Why money is a type carrying amount + currency with explicit, integer-safe arithmetic and a defined allocation/split operation — exactly what @hisaab/money implements.
  • PostgreSQL docs — advisory locks The primitive that closes the delete/confirm race: an application-keyed lock both the expense engine and the settlement flow acquire, so a check-then-act across the two subsystems can't interleave.

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. splitEqual(₹100, 3) cannot just return 100/3. Describe the largest-remainder computation it uses instead, what it returns, and why the result sums to exactly the total.
  2. Fable stores percentage splits as integer basis points (3333) rather than percentages (33.33). Explain the specific correctness problem with percentages that basis points eliminate.
  3. A balance in Fable is never stored as truth. Write the net-position projection in words, explain what group_balance_cache is and is not, and why storing balances as state is how ledgers drift.
  4. Debt simplification uses a greedy creditor-vs-debtor match. Why must this algorithm be deterministic, and what would break if two devices computed the simplified debts differently?
  5. Walk through the phantom "owed back" bug: a delete and a settlement confirmation interleave. What state does the balance projection end up computing, and why does a check-then-act without a lock permit it?
  6. The fix takes settlement pair-locks before the pending-settlement check. Explain why the lock must be held around the check (not just the write), and state the general rule about check-then-act across two subsystems.