See It Flow
Splitwise — Shared Expense Manager System Design

Splitwise Architecture Deep Dive: Ledgers, Balances and Debt Simplification

This page assumes the problem framing, requirements, and high-level architecture from Complete Design — group grp-weekend-trip ("Weekend Trip"), members Asha/Ben/Cara, currency INR, amounts tracked internally in paise — and goes straight into mechanism: the data model and its boundaries, the exact allocation and normalization algorithms behind every split type, the immutable ledger that is the one source of truth, the concurrency and idempotency machinery that keeps concurrent edits and retried requests safe, the outbox/projection pipeline that keeps a fast balance view honest, and the settlement, multi-currency, security, scale, and recovery machinery built on top of all of it. Every lettered vector (A–J) below is the same canonical test vector used across this module's other pages, traced through the actual algorithm rather than just asserted.

Mangesh Mandlik·Updated September 2026
Component 01

Domain Model and Boundaries

Every piece of state in this system is scoped under exactly one of two aggregate roots — Group and Expense — and almost every bug class the rest of this page exists to prevent traces back to code that forgot one of those two boundaries: a write that touched data outside its owning group, or an expense edit that mutated shared state without going through the expense's own transaction boundary.

Group as the ownership boundary

grp-weekend-trip owns a fixed currency (INR), a membership roster (Asha, Ben, Cara), and every expense, ledger event, settlement, and balance figure that exists "within" it. Nothing in this design computes a balance, a debt, or a simplified transfer across two different groups — a member who is in five groups has five entirely independent sets of balances, never one blended number. Group identity is the first thing every query, write, and authorization check is scoped by, exactly the way leaderboard identity scopes every ranking operation in that module.

GroupWeekend Trip · INRMembershipAsha · creatorMembershipBen · memberMembershipCara · memberExpensepayer + beneficiaries + split
Group owns membership and expenses; a membership record is what makes a member ID meaningful inside this group at all

Expense identity, and payer/beneficiary as roles, not identities

An expense has its own identity, its own version counter (Component 11), and its own atomic write boundary: creating or editing an expense means writing the expense row, its payer-contribution rows, its split rows, and its ledger entries all inside one transaction, or none of them. Payer and beneficiary are roles attached to a (expense, member) pair, not separate kinds of member — Asha is simultaneously the payer of the dinner expense and one of its three beneficiaries, which is the normal case, not a special one. A design that models "payer" as a property of the Expense row itself (a single paid_by column) cannot represent Vector E, where Ben pays and the split still includes Ben as a beneficiary at a non-zero share — multi-payer and multi-beneficiary both require a proper join table, covered in Component 10.

Aggregate boundary

A boundary within which an invariant is enforced atomically. Expense is the aggregate boundary for "shares sum to total" and "payer contributions sum to total" — both are checked and committed in one transaction, never trusted to hold eventually across separate writes. Group is the aggregate boundary for membership and currency, but never for a cross-member financial invariant computed in real time — balances are derived from the ledger (Component 07), not stored as a mutable field on the Group or Membership row.

Key takeaways
  • Group is the ownership and currency boundary; Expense is the atomic-write boundary for a single financial event — balances are never computed across groups
  • Payer and beneficiary are roles on a (expense, member) pair, and routinely overlap for the same member on the same expense
  • Group membership is a fact (an active Membership row), never inferred from a member ID appearing inside an expense
  • Everything in Components 02-20 operates inside one fixed group+currency scope at a time, the same discipline leaderboard applies to one fixed leaderboard identity
Component 02

Money and Precision

Every amount this system stores, compares, sums, or splits is an integer count of a currency's smallest unit — never a floating-point number — because floating-point arithmetic's rounding error is exactly the kind of silent, cumulative corruption a money ledger cannot tolerate.

Minor units are currency-specific data, not a universal constant

INR's minor unit is the paise, two decimal places, so ₹900.00 is stored as the integer 90000. This two-decimal convention is common but not universal: some currencies' minor-unit exponent is different (some use zero decimal places, some use three), so the exponent that converts a major-unit display amount into an integer minor-unit amount must be looked up per currency, never hardcoded as "multiply by 100" everywhere in the codebase. This design only needs INR end to end, but the conversion function takes a currency code as an explicit input specifically so it is never silently wrong the first time a second currency is introduced.

pseudocodeEvery amount crosses the API boundary as an already-integer minor-unit value — conversion from a user-typed decimal string happens once, at the edge, never repeated downstream
function validateAmount(amountMinorUnits, currency):
    if not isInteger(amountMinorUnits):
        return Invalid("AMOUNT_NOT_INTEGER")           // never accept a float/decimal amount at the boundary

    if amountMinorUnits <= 0:
        return Invalid("AMOUNT_NOT_POSITIVE")

    if amountMinorUnits > MAX_AMOUNT_MINOR_UNITS[currency]:
        return Invalid("AMOUNT_EXCEEDS_BOUND")          // sanity bound, independent of any specific storage width

    return Valid(amountMinorUnits)

Overflow bounds

Ledger deltas, expense totals, and running balances are stored as 64-bit signed integers, which comfortably represent minor-unit amounts many orders of magnitude beyond any plausible shared-expense total without approaching the signed-64-bit ceiling (~9.2×10^18). The real overflow risk in practice is not a single expense but an unbounded aggregation — summing millions of ledger rows for a pathologically long-lived group — which is exactly why MAX_AMOUNT_MINOR_UNITS exists as an explicit, per-currency sanity bound on any single input, rather than relying on the storage width alone to catch a malformed or abusive request.

Key takeaways
  • All amounts are integer minor units (paise for INR) — never floats, never decimals — from the API boundary all the way to the ledger
  • A currency's minor-unit exponent is looked up per currency, never hardcoded, even though this design only needs INR today
  • 64-bit signed integers give enormous headroom for any single amount; an explicit per-currency MAX_AMOUNT bound catches malformed or abusive input rather than relying on storage width alone
  • A float/decimal-typed money field is a correctness defect, not a style choice — binary floating point cannot exactly represent every decimal fraction
Component 03

Equal Split Algorithm

Splitting a total evenly among n members is quotient-and-remainder arithmetic on integers, with one deliberate rule for where the leftover minor units go — because an expense total that does not divide evenly must still be allocated to the exact paisa, and that allocation has to be the same answer no matter which server, which request retry, or which read replica computes it.

pseudocodeEqual split: integer quotient for every member, remainder paise assigned deterministically to the first members in sorted-ID order
function allocateEqualSplit(expenseTotalMinor, memberIds):
    n = len(memberIds)
    quotient  = floor(expenseTotalMinor / n)
    remainder = expenseTotalMinor - quotient * n        // 0 <= remainder < n

    sortedIds = memberIds.sortBy(memberId)               // stable, deterministic order -- never request order or map-iteration order
    allocated = []
    for (i, memberId) in enumerate(sortedIds):
        share = quotient + (i < remainder ? 1 : 0)       // the first `remainder` members in sorted-ID order absorb +1 minor unit
        allocated.append({ memberId: memberId, amountMinorUnits: share })

    // allocated.sum(amountMinorUnits) == expenseTotalMinor always -- by construction, not by a separate check
    return allocated
Expense ₹900paid by AshaAshashare ₹300Benshare ₹300Carashare ₹300300 + 300 + 300 = 900 — shares always conserve the total exactly
Vector A: ₹900 dinner, three members, quotient 30000 paise each, remainder 0 — a clean case where the remainder rule never has to fire

Vector A, traced

Asha pays ₹900 (90,000 paise) for dinner, split equally across p-asha, p-ben, p-cara. quotient = floor(90000 / 3) = 30000, remainder = 90000 - 30000*3 = 0. Every member's share is exactly 30,000 paise (₹300), and the remainder loop never has to assign an extra paisa to anyone. Had the total instead been 90,001 paise, quotient = 30000, remainder = 1, and the sorted-first member (p-asha, alphabetically first) would receive 30,001 while the other two receive 30,000 — a deterministic, reproducible choice rather than "whoever the UI happened to iterate first."

Complexity

Sorting the member-ID list is O(n log n); the allocation pass itself is O(n). For a shared-expense group, n is small (tens of members at most), so this is not a performance-sensitive path — the sort exists purely for determinism, not for speed, and would still be worth doing even if it were the dominant cost.

Key takeaways
  • Equal split is an integer quotient plus a remainder of at most n-1 minor units, assigned by a fixed, deterministic rule — never left to incidental iteration order
  • Sorted member-ID order is the tie-break for who absorbs the extra paisa, making the allocation reproducible by any caller given the same inputs
  • Vector A divides evenly (90,000 / 3 = 30,000 with remainder 0), so every member's share is identical and the remainder rule never fires
  • The allocation sums to the exact total by construction — there is no separate reconciliation step needed after an equal split
Component 04

Exact, Percentage and Share Splits

Exact splits need only a sum check; percentage and weighted-share splits need a normalization step that converts a human-friendly input (percentages, ratios) into integer minor units that sum exactly to the total — with the same deterministic remainder-assignment discipline as the equal-split algorithm, not a new, looser rule.

Exact split: a pure validation, no allocation needed

pseudocodeExact split validation: the caller already chose the amounts; the only job here is to reject anything that doesn't sum to the total exactly
function validateExactSplit(expenseTotalMinor, shares):    // shares: [{ memberId, amountMinorUnits }]
    total = sum(s.amountMinorUnits for s in shares)
    if total != expenseTotalMinor:
        return Invalid("EXACT_SPLIT_SUM_MISMATCH", expected = expenseTotalMinor, actual = total)
    if any(s.amountMinorUnits <= 0 for s in shares):
        return Invalid("EXACT_SPLIT_NON_POSITIVE_SHARE")
    return Valid(shares)

Percentage split: basis points in, integer minor units out

Percentages are accepted as basis points (one basis point = 0.01%, so 100.00% = 10,000 bps) specifically so the sum check is an exact integer comparison rather than a floating-point "close enough to 100%" judgment call. Allocation applies the quotient to every member except the last in sorted-ID order, and lets that last member absorb whatever rounding remainder is left — the same deterministic-remainder shape as Component 03, applied to a percentage input instead of an equal count.

pseudocodePercentage normalization: validate the bps sum first, then allocate by truncation with the final member absorbing the exact remainder
function normalizePercentageSplit(expenseTotalMinor, percentages):   // percentages: [{ memberId, percentBps }]
    totalBps = sum(p.percentBps for p in percentages)
    if totalBps != 10000:
        return Invalid("PERCENTAGE_SUM_MISMATCH", expected = 10000, actual = totalBps)

    sortedByIdAsc = percentages.sortBy(memberId)
    allocated = []
    runningSum = 0
    for (i, p) in enumerate(sortedByIdAsc):
        if i < len(sortedByIdAsc) - 1:
            share = floor(expenseTotalMinor * p.percentBps / 10000)
            runningSum += share
        else:
            share = expenseTotalMinor - runningSum            // last member (sorted order) absorbs the rounding remainder
        allocated.append({ memberId: p.memberId, amountMinorUnits: share })
    return Valid(allocated)

Vector D, traced: 33.33 / 33.33 / 33.34

Asha pays ₹100.00 (10,000 paise), split 33.33% / 33.33% / 33.34% across Asha/Ben/Cara — in basis points, 3,333 / 3,333 / 3,334, summing to exactly 10,000, so the sum check passes. In sorted-ID order (p-asha, p-ben, p-cara), the first two apply the quotient: floor(10000 * 3333 / 10000) = 3333 each, running sum 6,666. The last member in sorted order, Cara, absorbs the remainder: 10000 - 6666 = 3334. The result — Asha 3,333, Ben 3,333, Cara 3,334 — matches the stated percentages exactly and sums to 10,000 paise with no leftover. Asha's net is her payment minus her own share: 10000 - 3333 = 6667 paise (+₹66.67); Ben owes 3,333 paise (-₹33.33); Cara owes 3,334 paise (-₹33.34).

Share (weighted) split: same shape, a different normalization base

pseudocodeWeighted-share normalization: identical shape to percentage normalization, with weight/totalWeight replacing percentBps/10000 as the fraction
function normalizeWeightedSplit(expenseTotalMinor, weights):   // weights: [{ memberId, weight }], weight is a positive integer
    totalWeight = sum(w.weight for w in weights)
    if totalWeight <= 0:
        return Invalid("WEIGHT_SUM_NOT_POSITIVE")
    if any(w.weight <= 0 for w in weights):
        return Invalid("WEIGHT_NOT_POSITIVE")

    sortedByIdAsc = weights.sortBy(memberId)
    allocated = []
    runningSum = 0
    for (i, w) in enumerate(sortedByIdAsc):
        if i < len(sortedByIdAsc) - 1:
            share = floor(expenseTotalMinor * w.weight / totalWeight)
            runningSum += share
        else:
            share = expenseTotalMinor - runningSum
        allocated.append({ memberId: w.memberId, amountMinorUnits: share })
    return Valid(allocated)

Vector E, traced: weights 1:2:2

Ben pays ₹1,000 (100,000 paise), split by weight 1:2:2 (Asha:Ben:Cara), totalWeight = 5. In sorted-ID order, Asha's share is floor(100000 * 1 / 5) = 20000; Ben's is floor(100000 * 2 / 5) = 40000; running sum after both is 60,000. Cara, last in sorted order, absorbs the remainder: 100000 - 60000 = 40000. Result: Asha ₹200, Ben ₹400, Cara ₹400 — exactly the stated weighted split, with zero rounding remainder to absorb in this particular case since 100,000/5 divides evenly. Ben's net: he paid 100,000 and owes his own 40,000 share, so +60000 paise (+₹600); Asha owes -20000 (-₹200); Cara owes -40000 (-₹400).

Key takeaways
  • Exact splits are validated by a straight integer sum check against the expense total — no allocation step is needed
  • Percentage splits are accepted as basis points specifically so the 100% sum check is exact integer comparison, never a float tolerance window
  • Vector D: 3,333 / 3,333 / 3,334 paise sums to exactly 10,000 — the last sorted member absorbs the rounding remainder deterministically
  • Vector E: weights 1:2:2 normalize to 20,000 / 40,000 / 40,000 paise via the same quotient-plus-remainder shape as percentage splits
  • A sum mismatch on any split type is always rejected, never silently rescaled to force an artificial match
Component 05

Multiple Payers

An expense with more than one payer needs its own sum invariant — total paid by all payers equals the expense total — validated independently of the beneficiary-side split invariant, since the two sides can use entirely different allocation rules (an exact split on the payer side, a percentage split on the beneficiary side) and both must hold simultaneously.

pseudocodePayer-side zero-sum validation: every payer's contribution is positive, and the contributions sum to exactly the expense total
function validateMultiPayerContributions(expenseTotalMinor, payerContributions):   // [{ memberId, amountMinorUnits }]
    totalPaid = sum(c.amountMinorUnits for c in payerContributions)
    if totalPaid != expenseTotalMinor:
        return Invalid("PAYER_CONTRIBUTION_SUM_MISMATCH", expected = expenseTotalMinor, actual = totalPaid)
    if any(c.amountMinorUnits <= 0 for c in payerContributions):
        return Invalid("PAYER_CONTRIBUTION_NON_POSITIVE")
    return Valid(payerContributions)

Per-member net delta generalizes cleanly

Once both sides are validated independently, a single member's net ledger delta for the expense is simply contribution_as_payer - share_as_beneficiary, where either term is zero if that member doesn't hold that role on this expense. This is exactly the formula Component 06 uses to write ledger entries, and it already covers the single-payer case as the special case where every non-payer's contribution term is zero — there is no separate code path for "one payer" versus "many payers," only a payer-contribution list that happens to have one row or several.

Why the two invariants are checked separately

Checking only that sum(payer contributions) == sum(beneficiary shares) (rather than both against the expense total independently) would let a client-side bug silently record an expense with a different total on each side as long as the two mismatched totals happened to agree with each other — correct relative to each other, but wrong relative to the expense's own stated total, and therefore wrong relative to every ledger entry and reconciliation job (Component 20) that treats expense_total as the authoritative figure for this event. Both invariants are anchored to the one stored expenseTotalMinor value, never to each other.

Key takeaways
  • Multi-payer contributions have their own zero-sum invariant, validated against the expense total independently of the beneficiary-side split
  • A member's net ledger delta is contribution-as-payer minus share-as-beneficiary — the single-payer case is simply the special case where every non-payer's contribution term is zero
  • Both the payer-side and beneficiary-side sums are checked against the stored expense total directly, never against each other, to avoid a pair of mutually-consistent-but-wrong totals slipping through
Component 06

Ledger Representation

The ledger is an immutable, append-only journal of signed per-participant deltas — every financial fact this system has ever recorded exists as a row here, forever, and nothing downstream (balances, debts, settlements) is anything other than a derived view computed from these rows.

LEDGER EVENTS — expense evt-dinner-01p-asha+60000 paisep-ben-30000 paisep-cara-30000 paiseImmutable, append-only — a correction adds a reversal + new event, never edits these rows
Vector A's ledger entries for expense evt-dinner-01: Asha +60,000 paise, Ben -30,000, Cara -30,000 — a signed delta per participant, summing to zero for this one source event

One source event, deltas that sum to zero

Every expense produces one ledger entry per participant who has a nonzero net effect from it, and those entries always sum to zero for that source event alone — not just across the whole group. For Vector A (evt-dinner-01, ₹900 dinner paid by Asha, equal split): Asha +60000 (she paid 90,000 and owes a 30,000 share), Ben -30000, Cara -30000; 60000 - 30000 - 30000 = 0. This per-event zero-sum property, not just the group-wide one from Component 07, is what lets a single expense be reversed cleanly (below) without touching any other expense's entries.

Source references and reversal, not deletion

Every ledger row carries source_type (expense, settlement, correction, reversal), source_id, and source_version — an explicit pointer back to exactly what produced it. A reversal is itself a new set of ledger rows, tagged source_type = reversal, source_id = <the event being reversed>, carrying the negated deltas of the original — it never updates or deletes the original rows. The original event's entries remain in the ledger forever, exactly as they were written; the reversal's entries sit alongside them and net out to zero when summed together.

Vector I, traced: reversing the ₹900 dinner

Reversing evt-dinner-01 appends three new rows — p-asha: -60000, p-ben: +30000, p-cara: +30000 — tagged as a reversal of evt-dinner-01. Summing the original three rows and these three new rows for each member gives exactly zero per member: Asha 60000 - 60000 = 0, Ben -30000 + 30000 = 0, Cara -30000 + 30000 = 0. The dinner's net effect on every member's balance is now zero, exactly as if it had never been charged — but the ledger still shows six rows, not zero: the original charge, and the reversal that cancels it, both permanently visible to anyone auditing this group's history.

Key takeaways
  • The ledger is append-only: every financial fact is an INSERT, never an UPDATE or DELETE, enforced at the database privilege level, not just by convention
  • Each source event's own ledger entries sum to zero independently — a per-event invariant, stronger than the group-wide zero-sum check
  • Every ledger row carries source_type/source_id/source_version — an explicit, queryable pointer back to exactly what produced it
  • Vector I: reversing the dinner appends negated entries rather than touching the original three rows — net effect returns to zero, full history remains visible
Component 07

Net Balance Computation

A member's net balance is never a stored, mutable field — it is the sum of every signed ledger delta ever recorded for that member, in that group, in that currency, which is also exactly what makes the ledger the one place corrections and audits ever need to look.

sqlNet balance per member is a straight aggregation over every ledger row for that group and currency — this query is the definition of "balance," not an approximation of it
SELECT member_id,
       currency,
       SUM(delta_minor) AS net_balance_minor
FROM ledger_events
WHERE group_id = :group_id
  AND currency = :currency
GROUP BY member_id, currency;

Vectors A, B and C, traced with running numbers

After Vector A (evt-dinner-01): Asha +60,000, Ben -30,000, Cara -30,000 paise. After Vector B (evt-taxi-01, Ben pays ₹600 exact-split Asha ₹100/Ben ₹200/Cara ₹300): Ben's contribution 60,000 minus his own 20,000 share gives +40000; Asha owes -10000; Cara owes -30000. Summing both events: Asha 60000 - 10000 = 50000 (+₹500), Ben -30000 + 40000 = 10000 (+₹100), Cara -30000 - 30000 = -60000 (-₹600) — exactly the stated combined net for Vector B. After Vector C (Cara repays ₹200 to Asha, settlement stl-cara-asha-01, entries Cara +20000, Asha -20000): Asha 50000 - 20000 = 30000 (+₹300), Cara -60000 + 20000 = -40000 (-₹400), Ben unchanged at +10000 (+₹100) — exactly matching Vector C's stated final net.

Zero-sum conservation proof

sqlA closed-currency ledger's deltas must sum to exactly zero across the whole group; a nonzero total means money was created or destroyed by a bug, not a legitimate state
SELECT currency, SUM(delta_minor) AS total
FROM ledger_events
WHERE group_id = :group_id
GROUP BY currency
HAVING SUM(delta_minor) <> 0;   -- any row returned here is a conservation violation, not an edge case to tolerate

Running the conservation check after Vectors A, B and C: 60000 - 30000 - 30000 (A) + (-10000) + 40000 - 30000 (B) + (-20000) + 20000 (C) = 0. This holds because every individual source event already sums to zero on its own (Component 06) — the group-wide total is just the sum of a series of individually-zero contributions, which is precisely why this query returning any nonzero row is a strong, specific signal: it points at a source event whose own entries didn't sum to zero when they were written, which should be structurally impossible if Components 03-06 are implemented correctly.

Key takeaways
  • A net balance is a live aggregation over ledger_events, never a stored field — the ledger is the only source of truth for this number
  • Vectors A→B→C trace exactly to Asha +500→+300, Ben +100 (unchanged by C), Cara -600→-400 — the running sum after each event matches the canonical vectors precisely
  • The group-wide zero-sum check is a direct consequence of every individual source event already summing to zero — a violation points at a specific broken write, not a vague inconsistency
Component 08

Pairwise Debts vs Netted Positions

"Who owes whom, for what" and "who owes how much, overall" are two different questions with two different answers, and conflating them — showing only one when a product or a dispute genuinely needs the other — is a real design decision with real consequences, not a detail.

RAW PAIRWISE (4 debts)AshaBenCara100300300SIMPLIFIED (2 debts)AshaBenCara500100
Vector B's four raw pairwise debts (Ben→Asha ₹300, Cara→Asha ₹300, Asha→Ben ₹100, Cara→Ben ₹300) collapse to two net transfers (Cara→Asha ₹500, Cara→Ben ₹100) — same net positions, zero shared provenance

Vector B's raw pairwise debts

Taken expense-by-expense, Vector A and Vector B each produce their own pairwise obligations: from the dinner, Ben owes Asha ₹300 and Cara owes Asha ₹300; from the taxi, Asha owes Ben ₹100 and Cara owes Ben ₹300. That is four distinct pairwise debts, each one traceable to a specific expense. Netting per pair first (Ben/Asha: Ben owes 300, nothing flows the other way, stays 300; Cara/Asha: stays 300; Asha/Ben: Asha owes 100 while Ben owes nothing back, stays 100; Cara/Ben: stays 300) still leaves four edges — pairwise netting alone does not reduce the edge count here, because no two edges share the same pair of members in opposite directions.

The netted position is a different, smaller object

The group's netted positions after Vector B — Asha +₹500, Ben +₹100, Cara -₹600 — are a pure function of the ledger sum (Component 07), entirely independent of which specific expenses produced them. Debt simplification (Component 09) operates on this smaller object, not on the four raw pairwise debts, which is exactly how it collapses four edges into two (Cara→Asha ₹500, Cara→Ben ₹100): those two edges satisfy the same net positions with no remaining provenance connecting either edge back to a specific expense.

If Cara disputes the dinner specifically — she claims she never ate, or the amount was wrong — the question "what was Cara's pairwise obligation to Asha from evt-dinner-01" has one specific, traceable answer (₹300, from that one ledger entry), resolvable by reversing that one expense (Component 06) without touching the taxi at all. If instead Cara disputes "my overall balance is wrong," the netted position by itself cannot tell her which expense to dispute — it is the right number for "how much do I owe in total," and the wrong number to hand someone asking "prove it, expense by expense." A UI or support flow that only ever shows simplified, netted transfers and discards the pairwise/provenance view has made it structurally harder to answer the first kind of question, even though the math underneath is entirely correct.

Key takeaways
  • Pairwise debts carry provenance (which expense produced this specific obligation); netted positions answer "how much overall," with no provenance attached
  • Vector B: four raw pairwise debts (300, 300, 100, 300) net down to two simplified transfers (500, 100) — same net positions, zero shared transaction history
  • Disputing a specific expense needs the pairwise/provenance view; disputing an overall balance needs the netted view — a design that only exposes one has made the other question harder to answer
  • Debt simplification (Component 09) operates on netted positions, never on the raw pairwise debts directly
Component 09

Debt Simplification Algorithm

Debt simplification is a suggestion for which transfers would settle every net position with the fewest payments — greedy largest-creditor-to-largest-debtor matching, with a deterministic tie-break, computed fresh from the current net positions whenever it's displayed, never stored as a commitment anyone is obligated to follow.

pseudocodeGreedy debt simplification: repeatedly match the single largest remaining creditor against the single largest remaining debtor, re-evaluated at every step
function simplifyDebts(netBalancesMinor):          // map: memberId -> signed net balance, paise
    creditors = []   // each entry (balance, memberId); popped largest balance first, ties broken by lower memberId
    debtors   = []   // each entry (owedAmount, memberId) where owedAmount = -balance; popped largest first, ties broken by lower memberId

    for (memberId, balance) in netBalancesMinor:
        if balance > 0: creditors.push((balance, memberId))
        elif balance < 0: debtors.push((-balance, memberId))
        // balance == 0 -> already settled, excluded entirely

    transactions = []
    while creditors not empty and debtors not empty:
        (creditAmt, creditorId) = creditors.popLargest()      // re-evaluated every iteration, not pre-sorted once
        (debtAmt,   debtorId)   = debtors.popLargest()

        amount = min(creditAmt, debtAmt)
        transactions.append({ from: debtorId, to: creditorId, amountMinorUnits: amount })

        if creditAmt > amount: creditors.push((creditAmt - amount, creditorId))
        if debtAmt   > amount: debtors.push((debtAmt - amount, debtorId))

    return transactions   // at most (count(creditors) + count(debtors) - 1) transactions

Vector B, traced

Net positions after Vectors A and B: Asha +50,000, Ben +10,000, Cara -60,000 paise. Creditors: Asha(50000), Ben(10000). Debtors: Cara(60000) — the only debtor. Step 1: largest creditor Asha(50000) vs largest (only) debtor Cara(60000); transfer min(50000, 60000) = 50000. Asha settles (0), Cara remains at 10,000. Step 2: largest remaining creditor Ben(10000) vs Cara(10000); transfer 10,000, both settle. Result: Cara→Asha ₹500, Cara→Ben ₹100 — two transactions, exactly the simplified graph shown above, down from four raw pairwise debts.

Complexity and tie-break determinism

With a heap (or any priority structure) for creditors and debtors, each step pops and pushes in O(log k) where k is the number of distinct nonzero balances, and each step fully settles at least one party, so the loop runs at most k - 1 times — overall roughly O(k log k). Ties (two creditors or two debtors with the exact same remaining amount) are broken by lower member ID, so re-running the algorithm against the same net positions always produces the exact same transaction list — a hard requirement, since a debt-simplification view that changes on every refresh with no underlying balance change would be actively misleading.

An illustrative counterexample: greedy is not provably minimal

This greedy matching always produces at most k - 1 transactions and is a reasonable, fast, deterministic suggestion — but it is not guaranteed to find the theoretical minimum number of transactions for every input, because matching strictly by largest-magnitude-first can miss a smaller zero-sum subset hiding inside the larger set. Illustrative only (not Weekend Trip data): five members P, Q, R, S, T with net balances P +6, Q +4, R -3, S -3, T -4 (sums to zero). Greedy, re-evaluating the largest remaining balance at every step: step 1 matches creditor P(6) against debtor T(4) — the single largest debtor — leaving P at +2; step 2 now has Q(4) as the largest remaining creditor against debtor R(3), leaving Q at +1 after R settles; step 3 matches P(2) against debtor S(3), leaving S at 1; step 4 matches Q(1) against S(1), settling both. That is four transactions (T→P, R→Q, S→P, S→Q). But {Q, T} alone sum to zero (Q +4, T -4) and can settle in one transaction (T pays Q 4 directly), while {P, R, S} alone also sum to zero (P +6, R -3, S -3) and settle in two (R pays P 3, S pays P 3) — three transactions total, one fewer than greedy's four, because recognizing those two independent zero-sum subsets beats matching strictly by magnitude. Finding the true minimum in general is a harder combinatorial problem (related to optimal subset partitioning); greedy is a deliberate, explicit trade of "not always minimal" for "fast, simple, and deterministic."

Key takeaways
  • Greedy debt simplification repeatedly matches the single largest remaining creditor against the single largest remaining debtor, re-evaluated at every step, with lower-member-ID as the tie-break
  • Vector B: Asha +500, Ben +100, Cara -600 simplify to exactly two transactions — Cara→Asha 500, Cara→Ben 100
  • Complexity is roughly O(k log k) for k distinct nonzero balances, bounded at k-1 transactions total
  • An illustrative five-member example shows greedy can produce one more transaction than a theoretically optimal partition — greedy is fast and deterministic, not provably minimal
  • Debt simplification is always a suggestion computed fresh from current net positions — never a forced transfer, never persisted as a commitment
Component 10

Transactional Relational Schema

Every invariant from Components 01-09 is enforced by a concrete combination of primary keys, foreign keys, unique constraints, and transaction boundaries — none of it is "application convention that happens to hold," it is schema-level guarantee backed by the database.

UserGroupMembershipExpenseversion, group_idExpensePayerExpenseSplitLedgerEventimmutableSettlementIdempotencyRecord
Core entities: Group and Membership bound the group; Expense, ExpensePayer and ExpenseSplit bound one atomic financial event; LedgerEvent is the immutable append-only journal; Settlement and IdempotencyRecord sit alongside it
EntityKey fieldsConstraints
Groupid (PK), currency, created_atcurrency is immutable once the group's first expense exists
Membershipgroup_id (FK), member_id (FK), role, joined_at, removed_at (nullable)PK (group_id, member_id); removed_at = null means an active member (Component 18)
Expenseid (PK), group_id (FK), total_amount_minor, currency, split_type, version, status, created_by, created_atversion starts at 1, incremented on every accepted edit (Component 11); FK group_id → Group
ExpensePayerexpense_id (FK), member_id (FK), amount_paid_minorPK (expense_id, member_id); SUM(amount_paid_minor) per expense_id = Expense.total_amount_minor
ExpenseSplitexpense_id (FK), member_id (FK), share_minorPK (expense_id, member_id); SUM(share_minor) per expense_id = Expense.total_amount_minor
LedgerEventid (PK), group_id (FK), source_type, source_id, source_version, member_id (FK), delta_minor, currency, created_atappend-only (INSERT-only grant); index on (group_id, member_id, currency) for Component 07's aggregation
Settlementid (PK), group_id (FK), payer_member_id, payee_member_id, amount_minor, currency, status, provider_ref (nullable), created_at, confirmed_at (nullable)status enum (initiated, pending, confirmed, failed, reversed) — Component 16
IdempotencyRecordidempotency_key, actor_member_id, group_id, action_type (composite PK), payload_hash, response_snapshot, created_at, expires_atunique index on (actor_member_id, group_id, action_type, idempotency_key) — Component 12
Core relational data model — entities, key fields, and the constraints that enforce this page's invariants

Transaction boundaries

Creating or editing an expense writes the Expense row, its ExpensePayer rows, its ExpenseSplit rows, and its LedgerEvent rows inside exactly one database transaction — all four invariants (payer sum, split sum, ledger per-event zero-sum, and version increment) are checked and committed together, or none of them take effect. A settlement's state transition (Component 16) and its corresponding ledger entries are likewise always one transaction.

Isolation level: read-committed plus explicit version checks is enough

This schema does not need serializable isolation for its core write path. The invariants that must hold across concurrent transactions — "this expense's version hasn't moved since I read it," "this idempotency key hasn't already been used" — are each scoped to a single row or a single unique key, and are each enforced by an explicit, narrow mechanism (Component 11's conditional UPDATE, Component 12's unique constraint) rather than by relying on the isolation level to prevent every possible anomaly implicitly. Read-committed is sufficient here because no invariant in this design spans multiple unrelated rows read at two different points in one transaction without an explicit guard already covering that exact gap — the moment such an invariant existed, it would need its own guard anyway, since even serializable isolation only prevents anomalies within the database; it does nothing for a correction that must span two already-committed transactions (Component 06's reversal pattern).

Key takeaways
  • Every money invariant from this page maps to a concrete constraint: PKs for identity, FKs for group scoping, unique keys for idempotency, and application-level sum checks inside one transaction for split/payer/ledger consistency
  • Expense creation and editing is one transaction touching Expense, ExpensePayer, ExpenseSplit and LedgerEvent together — never a partial write across any subset of them
  • Read-committed isolation plus explicit per-row version checks and unique-key dedup is sufficient here, because every cross-transaction invariant is already covered by a narrow, explicit mechanism rather than left to the isolation level alone
Component 11

Concurrency Control

Two members editing the same expense at nearly the same instant is resolved by optimistic locking scoped to that one expense row — the version counter from Component 10 is what turns a race into a deterministic winner and an explicit, actionable conflict for the loser, rather than a silently lost update.

EXPENSE ROW — starts at version 4Asha PATCH v4→v5Ben PATCH v4→v5DB: now v5wins — commits409 conflict
Vector G: Asha and Ben both PATCH expense version 4 at nearly the same time — one commits as version 5, the other receives an explicit 409 conflict
sqlOptimistic-concurrency update: the WHERE clause's version check is the entire conflict-detection mechanism, enforced by the database, not by application-level locking
UPDATE expenses
SET total_amount_minor = :new_total,
    split_type        = :new_split_type,
    version           = version + 1,
    updated_at        = now()
WHERE id      = :expense_id
  AND version = :expected_version;

-- rowCount == 0 means either the expense no longer exists, or `version`
-- has already moved past :expected_version -- re-read the current row and
-- return an explicit version-conflict response; never retry blindly with
-- the same expected_version, and never silently overwrite.

Vector G, traced

Both Asha and Ben read the expense at version 4 and submit conflicting edits nearly simultaneously, each PATCH carrying expectedVersion = 4. The database serializes the two UPDATE statements regardless of how close together they arrive: whichever commits first matches version = 4 in its WHERE clause, succeeds, and the row becomes version 5. The second UPDATE to reach the database now has a WHERE clause expecting version = 4, but the row is already at version 5 — rowCount = 0. The server responds to that second caller with an explicit HTTP 409 carrying the current version (5) and the current expense body, and the client's retry path is to re-fetch, re-apply its intended change against the fresh version, and resubmit — never to assume its own write silently succeeded, and never to retry blindly against the same stale expectedVersion.

Serialization scope: per-expense, not per-group

The version check and its row-level lock apply only to the one expense being edited. Two members editing two different expenses in the same group — or even two different members each creating a brand-new expense — never contend with each other at all; they are entirely independent transactions against independent rows. This is the same discipline leaderboard's per-player version scoping uses: serialize exactly where a real conflict can occur, and nowhere broader than that.

Key takeaways
  • Optimistic locking via UPDATE ... WHERE id = ? AND version = ? is the entire concurrency mechanism for expense edits — no separate application-level lock is needed
  • Vector G: both Asha's and Ben's version-4 edits race; exactly one commits as version 5, the other receives an explicit 409 with the current version and body for retry
  • Serialization scope is per-expense — unrelated expenses and unrelated members' writes in the same group never contend
  • A version conflict response must carry the current state, not just a bare rejection, since the loser's intended change may still be reconcilable against it
Component 12

Idempotency

A client-supplied idempotency key, scoped to one actor, one group, and one action type, is the only thing standing between a retried network request and a duplicated financial effect — checked and recorded atomically in the same transaction as the write it protects, never as a best-effort filter applied before or after.

pseudocodeAtomic check-and-insert dedup: the SELECT ... FOR UPDATE, the business write, and the idempotency-record INSERT all commit in one transaction
function handleIdempotentRequest(actorMemberId, groupId, actionType, idempotencyKey, payload):
    payloadHash = sha256(canonicalize(payload))

    BEGIN TRANSACTION
    existing = SELECT * FROM idempotency_records
               WHERE actor_member_id = actorMemberId AND group_id = groupId
                 AND action_type = actionType AND idempotency_key = idempotencyKey
               FOR UPDATE

    if existing != null:
        ROLLBACK
        if existing.payload_hash == payloadHash:
            return existing.response_snapshot          // Vector F, same-key retry: replay the stored response, never reprocess
        else:
            return Conflict("IDEMPOTENCY_KEY_REUSE_MISMATCH")   // Vector F, same key + different payload: explicit conflict

    result = performBusinessWrite(payload)              // e.g. insert Expense + ExpensePayer + ExpenseSplit + LedgerEvent rows
    INSERT INTO idempotency_records
        (actor_member_id, group_id, action_type, idempotency_key, payload_hash, response_snapshot, created_at, expires_at)
    VALUES (actorMemberId, groupId, actionType, idempotencyKey, payloadHash, snapshot(result), now(), now() + RETENTION_WINDOW)
    -- if this INSERT fails on the unique constraint (a concurrent duplicate request won the race),
    -- catch that failure and re-read the now-existing record instead of surfacing a raw 500
    COMMIT
    return result

Vector F, traced

Asha's client submits the dinner expense with idempotency key idem-dinner-01; the request times out on the client side before a response arrives, so the client retries with the exact same key and the exact same payload. The retry's SELECT ... FOR UPDATE finds the record already inserted by the first (successful) attempt, computes the same payloadHash, finds it matches, and returns the stored response_snapshot directly — the business write never runs a second time, and exactly one expense exists. If instead the retry carried a different payload under the same key — a client bug that reused the key for a different expense, or an attempt to overwrite a submitted value — the comparison fails and the server returns IDEMPOTENCY_KEY_REUSE_MISMATCH, never silently applying the new payload under the old key's identity.

Key scope, payload hash, and retention

The key is scoped to (actor, group, action type, client-supplied key) rather than to the key alone, because the same literal key string chosen independently by two different members, or for two different action types, must never collide. The payload hash exists specifically to distinguish a true retry (same key, same payload — replay) from a key-reuse bug or an adversarial overwrite attempt (same key, different payload — conflict), which a dedup check keyed on the idempotency key alone cannot tell apart. The retention window is a deliberate, bounded product choice — long enough to cover the slowest realistic client retry/backoff ceiling the system is expected to tolerate — after which the key can be reused; records are not kept indefinitely, since an unbounded idempotency_records table is a real, unbounded storage cost for a benefit (protecting against an arbitrarily old retry) that has a point of diminishing returns.

Key takeaways
  • The dedup check, the business write, and the idempotency-record insert commit in one transaction — never as separately-failable steps
  • Key scope is (actor, group, action type, client key) — never the client key alone, which could otherwise collide across unrelated actors or action types
  • Vector F: same key + same payload hash → replay the stored response, zero reprocessing; same key + different payload hash → explicit conflict, never a silent overwrite
  • A unique constraint on the idempotency key tuple is the backstop against two concurrent requests with the same key both passing the initial SELECT before either INSERTs
  • Retention is a bounded, deliberate window sized against realistic retry/backoff ceilings — not an indefinite record of every key ever used
Component 13

Outbox and Event Delivery

Writing the authoritative expense/ledger rows and publishing the corresponding event as two independent operations against two independent systems is a dual-write problem — the transactional outbox pattern closes that gap by making the event row commit in the very same database transaction as the business write it describes.

Expense Tablesame TXOutbox Tablesame TXRelay WorkerEvent Busat-least-onceone DB transactionpollpublish
The expense write and its outbox row commit in one transaction; a separate relay worker polls the outbox and publishes at-least-once

Commit ordering

When an expense is created, the same transaction that inserts the Expense, ExpensePayer, ExpenseSplit and LedgerEvent rows also inserts a row into the outbox table describing the event (topic, aggregate_id, payload, a monotonically increasing sequence_id, published_at = null). If the transaction commits, both the business state and the outbox row exist together; if it rolls back, neither does — there is no window where the business write succeeded but no event was ever queued, and no window where an event was queued for a write that never actually committed.

pseudocodeRelay loop: poll unpublished rows in commit order, publish with retry, mark published only after a confirmed publish
function outboxRelayLoop():
    while true:
        batch = SELECT * FROM outbox
                WHERE published_at IS NULL
                ORDER BY sequence_id ASC
                LIMIT BATCH_SIZE
                FOR UPDATE SKIP LOCKED        // lets multiple relay workers run without double-claiming the same row

        if batch.isEmpty():
            sleep(POLL_INTERVAL)
            continue

        for row in batch:
            publishWithRetry(eventBus, topic = row.topic, key = row.aggregate_id,
                              eventId = row.id, payload = row.payload)   // retried on transient failure; row stays unpublished until this succeeds
            UPDATE outbox SET published_at = now() WHERE id = row.id

At-least-once delivery, consumer-side dedup

A crash between publishWithRetry succeeding and the UPDATE outbox SET published_at = now() committing means the row is still published_at IS NULL on the next pass, and gets republished — this is why delivery is at-least-once, not exactly-once, and why every consumer of these events (Component 14's projector, any notification service) must dedup on the event's own id, exactly the same dedup-by-event-ID discipline Component 12 applies on the write path. Exactly-once delivery is not attempted at the transport layer here; idempotent consumption is what actually achieves the equivalent outcome.

Key takeaways
  • The outbox row commits in the exact same transaction as the business write — there is no window with one but not the other
  • FOR UPDATE SKIP LOCKED lets multiple relay workers poll the same outbox table without double-claiming or double-publishing a row
  • Delivery is at-least-once: a crash after publish but before marking published republishes the row, so every consumer must dedup by event ID
  • Exactly-once is achieved at the application level (idempotent consumers), never claimed as a transport-layer guarantee
Component 14

Balance Projections

The fast balance view members actually see on every app open is a materialized projection, kept current by incrementally applying ledger events as they arrive — never the system of record itself, and always rebuildable from the ledger from scratch if it is ever lost, corrupted, or suspected of drifting.

Event BusProjectoridempotent per event IDBalance Viewversioned
An outbox event reaches the projector, which applies its delta idempotently (by event ID) to the versioned balance view
pseudocodeIncremental projection update: dedup by event ID first, then apply the signed delta and bump the projection's own version in one transaction
function applyLedgerEventToProjection(event):    // { eventId, groupId, memberId, currency, deltaMinor, sequenceId }
    if projectionDedup.hasApplied(event.eventId):
        return Outcome.DUPLICATE_IGNORED          // at-least-once delivery from Component 13 -- consumer-side dedup

    BEGIN TRANSACTION
    row = SELECT * FROM balance_projection
          WHERE group_id = event.groupId AND member_id = event.memberId AND currency = event.currency
          FOR UPDATE

    newBalance          = (row == null ? 0 : row.balance_minor) + event.deltaMinor
    newProjectionVersion = (row == null ? 1 : row.projection_version + 1)

    UPSERT INTO balance_projection
        (group_id, member_id, currency, balance_minor, projection_version, last_applied_sequence_id)
    VALUES (event.groupId, event.memberId, event.currency, newBalance, newProjectionVersion, event.sequenceId)

    projectionDedup.markApplied(event.eventId)     // same transaction as the upsert
    COMMIT
    return Outcome.APPLIED

Per-group ordering, and why it matters for corrections

A group's events are consumed in the ledger's own committed sequence_id order (e.g. one consumer partition per group, matching Component 19's group-key locality). Plain addition is commutative, so a running sum would reach the same final total regardless of order — but a reversal event (Vector I) must be applied strictly after the event it reverses, or a concurrent reader could observe a brief, genuinely impossible intermediate state (the reversal's effect visible before the original charge it's reversing). Preserving commit order removes that entire class of ordering bug.

Rebuild from ledger, and drift detection

Outboxdurable backlogReplay (at-least-once)Projectionidempotent per event IDReprocessing the same event twice must produce the same end state, never a double-applied balance
Rebuild replays every ledger event for a group in committed order through the same idempotent apply function, landing in a shadow table before cutover

Rebuilding a group's projection from scratch is simply running applyLedgerEventToProjection over every ledger_events row for that group, in sequence_id order, starting from an empty balance table — the identical function the live path uses, not a separate reconstruction routine that could drift from it. The rebuild lands in a shadow table, is checksum-compared against the live projection (or, for a fresh rebuild with nothing to compare against, spot-checked against Component 07's direct aggregation query for a sample of members), and only then cut over — a rebuild is never trusted to be correct just because it ran without errors. A periodic drift-detection job compares balance_projection.balance_minor against Component 07's SUM(delta_minor) aggregation for the same (group, member, currency) key; any mismatch is an alert and a trigger for a scheduled rebuild of the affected group, never a silent auto-correction applied without investigation.

Key takeaways
  • The balance projection applies ledger deltas incrementally and idempotently (by event ID) — it is never the system of record, only a fast, derived view
  • Per-group commit order must be preserved specifically so reversal/correction events never become visible before the event they reverse
  • Rebuilding from the ledger uses the exact same apply function as the live path, lands in a shadow table, and is checksum-verified before cutover
  • Drift detection periodically compares the projection against the ledger's own aggregation; any mismatch alerts and triggers an investigated rebuild, never a silent patch
Component 15

Read Consistency and Caching

A member who just added an expense must never immediately reload their balance and see the old number — read-your-writes is an explicit contract enforced by versioned reads, not an accident of low enough projection lag.

Read-your-writes via a version hint

When a write commits, the response carries the ledger sequence_id that write produced. The client's subsequent balance read includes that sequence ID as a freshness requirement. The read path checks whether balance_projection.last_applied_sequence_id for the relevant member/currency has already caught up to (or past) that sequence ID: if so, it serves straight from the projection; if not, it either waits briefly for the projection to catch up (bounded by a short timeout) or falls back to Component 07's direct ledger aggregation for that one read — guaranteeing the client never sees a balance older than its own write, at the cost of an explicit bounded wait or a heavier direct read on the rare occasion the projection is genuinely behind.

Explicit staleness, not implicit freshness

Every balance response carries its own projection_version/last_applied_sequence_id, so any caller — a client, a cache layer, a dashboard — can tell precisely how current a given balance figure is rather than treating "a balance" as an undifferentiated, inherently-current number. A cached balance entry is keyed by the version it was read at; a new write bumps the version, and a stale cache entry is revalidated against the current version on next access rather than attempting fine-grained, per-field invalidation that would need to reason about exactly which ledger events affect which cached fields.

Pagination over ledger/activity history

The group's activity feed and any per-member ledger history view paginate by a stable cursor — the ledger's own sequence_id — rather than a numeric offset, so a new expense inserted between two page requests can never shift which rows land on which page, duplicate a row across a page boundary, or silently skip one. This is the same cursor-over-offset discipline leaderboard's board-version pagination relies on, applied here to the ledger's commit sequence instead of a board's rank-change sequence.

Key takeaways
  • Read-your-writes is enforced by the client presenting the sequence ID its own write produced, and the read path waiting briefly or falling back to a direct ledger read until the projection has caught up to it
  • Every balance response is versioned, so staleness is always explicit and measurable, never an implicit assumption
  • Cache entries are keyed by projection version and revalidated on access, rather than attempting fine-grained field-level invalidation
  • Ledger/activity pagination uses a stable sequence-ID cursor, never a numeric offset that concurrent inserts could shift under a paging client
Component 16

Settlement Lifecycle

A settlement is either a member's own assertion that a repayment happened outside the system, or a provider-mediated payment that only counts as settled once the provider itself says so — and this design never blurs the two into one undifferentiated "paid" state.

InitiatedPendingnot yet confirmedConfirmedbalance reducedFailedbalance unchangedprovider confirmsprovider fails
Initiated → Pending (not yet confirmed, balance unchanged) → Confirmed (balance reduced) or Failed (balance unchanged) — confirmation is always the provider's event, never the client's optimism

Vector C: asserted repayment

Cara records "I repaid Asha ₹200 in cash" with no payment provider involved at all. This Settlement row is created with status = confirmed immediately — there is no external confirmation to wait for, because the member is the authoritative source for their own cash-handoff claim — and its ledger entries (p-cara +20000, p-asha -20000) apply in the same transaction as the Settlement insert. The record is explicitly tagged as self-asserted (no provider_ref), which matters if it is later disputed: "Asha says this never happened" is a dispute over a user assertion, not over a payment provider's authoritative record, and is resolved differently (a reversal event plus a product-level dispute flow) than a provider-mediated dispute.

Vector J: provider-mediated pending/confirmed/failed

A settlement routed through a payment provider starts at status = initiated, moves to pending once the provider acknowledges the attempt was received, and only moves to confirmed on the provider's own authoritative confirmation event (webhook or polled status, below) — never on the client submitting the payment form, and never on the mere fact that pending was reached. Ledger entries that reduce the confirmed balance are appended only on the confirmed transition; a pending settlement may be surfaced in the UI as a separate, clearly-labeled pending indicator, but it never reduces the confirmed net balance Component 07 computes. If the provider reports failure, the settlement moves to status = failed with zero ledger effect — a failed payment is never treated as settled, regardless of how far it progressed through the provider's own flow before failing.

Webhook verification and reconciliation

Every inbound provider callback is verified against an expected signature (e.g. an HMAC computed over the raw payload with a provider-issued signing secret) before its claimed status transition is trusted at all — an unverified or invalid-signature callback is rejected and logged, never applied speculatively. Because a webhook delivery can be lost entirely (network failure, an outage on either side), a reconciliation job independently polls the provider's own authoritative status for any settlement that has sat in pending past a grace period, rather than relying on webhook delivery as the only path to confirmed.

pseudocodeReconciliation job: a backstop for lost webhook deliveries, polling the provider's own status rather than assuming silence means success
function reconcilePendingSettlements():
    pending = SELECT * FROM settlements
              WHERE status = 'pending' AND created_at < now() - GRACE_PERIOD

    for s in pending:
        providerStatus = paymentProvider.queryStatus(s.provider_ref)   // authoritative poll, independent of webhook delivery
        if providerStatus == CONFIRMED and s.status != 'confirmed':
            applySettlementConfirmed(s)      // same idempotent transition the webhook handler itself uses
        elif providerStatus == FAILED and s.status != 'failed':
            applySettlementFailed(s)
        // else: genuinely still pending, or the provider itself is lagging -- leave as is, re-check next run

Reversing a confirmed-then-disputed payment

A chargeback or dispute against an already-confirmed settlement is handled the same way any other correction is handled in this design: a new reversal ledger event negates the original settlement's deltas, and the Settlement row transitions to status = reversed — the original confirmed row and its original ledger entries are never edited or deleted, preserving the full history of what was believed true at each point in time.

Key takeaways
  • Vector C: a self-asserted repayment is confirmed immediately with no provider involved, explicitly tagged as user-asserted for later dispute handling
  • Vector J: a provider-mediated settlement only reaches confirmed on the provider's own authoritative event — pending never reduces the confirmed balance, and failed is never treated as settled
  • Every inbound webhook is signature-verified before its status claim is trusted; an independent reconciliation job polls provider status directly as a backstop for lost webhook deliveries
  • A disputed, already-confirmed settlement is corrected via a new reversal event and a reversed status transition — never an edit to the original confirmed row
Component 17

Multi-Currency Handling

A group balance is scoped by currency as a hard partition, not a display preference — Component 07's aggregation query already groups by (member_id, currency), which means two different currencies for the same member pair are, structurally, two entirely separate numbers that are never summed together implicitly.

Vector H: ₹1,000 and $20 are separate ledgers

If Weekend Trip ever records both an INR-denominated balance and a USD-denominated balance between the same two members, those are two independent rows from Component 07's query — "Ben owes Asha ₹1,000" and "Ben owes Asha $20" are two separate facts, never collapsed into one blended figure by any code path in this design. The only way those two numbers are ever combined into a single comparable amount is through an explicit, recorded conversion event — never an implicit arithmetic shortcut inside a balance or debt-simplification computation.

FX rate snapshot structure

A conversion event references an immutable rate snapshot: { rate_id, from_currency, to_currency, rate_scaled (a fixed-point integer with an explicit scale, e.g. 1e8, never a float), source (e.g. a named rate feed or "manual entry" — not a claim about any specific real-world provider's guarantees), captured_at, immutable: true }. Once any conversion event references a given rate_id, that snapshot's fields never change afterward — correcting a wrong rate means capturing a new snapshot with a new rate_id and recording a new, separate conversion event, the same reversal-and-replacement principle Component 06 applies to every other correction in this design.

Conversion audit trail

A conversion event is its own attributable record — { conversion_id, group_id, from_currency, from_amount_minor, to_currency, to_amount_minor, rate_id, initiated_by, created_at } — so that if a product ever chooses to support cross-currency netting as an explicit, opt-in feature, every resulting combined figure traces back to one specific, rate-stamped, attributable decision made by a specific actor at a specific time, never an implicit "just add the numbers together" shortcut buried in a query.

Key takeaways
  • Vector H: balances are partitioned by currency at the aggregation-query level — two currencies between the same members are two separate numbers, never implicitly summed
  • An FX rate snapshot is immutable once referenced by a conversion event; a rate correction creates a new snapshot and a new conversion event, never an edit to the old one
  • Every conversion is its own attributable, timestamped, rate-stamped audit record — cross-currency netting, if ever supported, is always traceable to one explicit decision
  • Debt simplification (Component 09) always runs within one currency at a time — pooling multiple currencies' balances together without an explicit conversion first produces numerically plausible but financially meaningless output
Component 18

Membership and Security

Authentication proves who is making a request; authorization for anything in this system additionally requires proving that actor is an active member of the specific group the request touches — a check that must run on every expense, balance, and settlement operation, not just at the group's front door.

pseudocodeObject-level authorization: every expense/balance/settlement access checks group membership explicitly — an authenticated identity alone is never sufficient
function authorizeExpenseAccess(actorMemberId, expense):
    membership = SELECT * FROM memberships
                 WHERE group_id = expense.group_id AND member_id = actorMemberId

    if membership == null:
        return Forbidden("NOT_A_GROUP_MEMBER")      // authenticated != authorized -- this check is mandatory on every read or write

    // membership.removed_at may be non-null here -- historical-access policy below decides what that means for THIS action
    return proceedWithActionSpecificRules(membership, expense)

Invitation race handling

Two near-simultaneous "accept invitation" requests for the same invitation must not both succeed and create two Membership rows, or double-add the member into splits already being drafted. The accept path uses the same atomic check-and-insert shape as Component 12: a unique constraint on (group_id, invited_member_id) for any pending-or-accepted invitation means the first accept wins and commits the Membership row; the second, losing the race against that unique constraint, returns an explicit ALREADY_ACCEPTED response rather than a raw constraint-violation error or a silently duplicated membership.

Member removal: historical access is a deliberate policy, not an oversight

Removing a member sets Membership.removed_at — the row is never deleted. From that point forward, the removed member cannot be added to new expenses and no longer receives the group's live activity feed. Every expense and ledger entry that already references their member_id, however, remains fully visible to the remaining members (and to the removed member, for their own historical records) — this is a deliberate policy choice, not an oversight: an expense history is evidence of a financial event that genuinely occurred, and erasing a departed member's trace from it would break exactly the audit trail the entire ledger design (Component 06) exists to preserve. A removed member with a nonzero outstanding net balance is a product-level block on removal (or a required settlement first) — an application-layer policy decision this architecture supports but does not itself prescribe further.

Key takeaways
  • Authorization requires an active Membership row for the specific group being accessed — authentication alone is never sufficient, and this check runs on every expense/balance/settlement operation
  • Invitation acceptance uses the same atomic check-and-insert, unique-constraint-backed shape as idempotency — the first accept wins, the second gets an explicit ALREADY_ACCEPTED, never a duplicate row
  • Member removal sets removed_at and blocks future involvement, but never hides historical expenses or ledger entries the removed member already participated in — a deliberate audit-preservation policy
Component 19

Scale and Partitioning

Sharding by group ID keeps one group's writes and reads entirely co-located, which is the overwhelming majority of real traffic shape for a shared-expense product — and it deliberately pushes any cross-group feature onto a separate analytical path rather than letting it touch the hot write-path partition at all.

Group-key locality

The relational store, and the event log/outbox topic partitioning, are both keyed by group_id, so every write and every read for grp-weekend-trip — its expenses, splits, ledger entries, balance projection rows, and activity feed — lives on one shard/partition and never requires a cross-shard join or a distributed transaction for ordinary group activity. This mirrors leaderboard's per-board-identity sharding: partitioning at the natural ownership key is essentially free correctness-wise, because nothing in normal operation ever needs to span two groups atomically.

Hot-group handling

A very large or very active group — hundreds of members, high expense volume in a short window — is this design's version of leaderboard's hot-key problem, and the mitigations are the same shape: route balance and activity reads to replicas with an explicit, bounded staleness window; cache the group's top-level balance summary with a short TTL; and, if a burst of expense submissions for the same group arrives in a tight window, let the projector (Component 14) catch up in batches rather than forcing synchronous, per-event application under load — all without changing the per-expense transaction semantics or the ledger's append-only guarantee.

Shard migration sketch

Rebalancing a group onto a different shard is an explicit, careful procedure, not a background copy with a pointer flip: freeze new writes for that group_id (anchored to a position in its own event log, the same idea as a season freeze in the leaderboard module), replicate its full ledger history to the destination shard, replay that history to build an identical projection there, checksum-verify the result against the source, and only then atomically repoint routing for that group_id to the destination shard before resuming writes.

Cross-group dashboard aggregation is a separate path

A feature like "all of your groups' balances in one dashboard" inherently reads across many shards at once, and is deliberately served by a separate, denormalized analytical path — a periodically refreshed summary table or warehouse, populated by consuming the same per-group outbox events each group's projector already consumes — rather than by fanning out a live query to every relevant group's hot write-path partition on every dashboard load. Keeping that cross-cutting, inherently multi-shard read demand off the transactional path is what keeps one member's dashboard view from ever becoming a source of contention for any single group's normal write traffic.

Key takeaways
  • Group ID is the sharding key for both the relational store and the event log, keeping all of one group's normal traffic co-located and free of cross-shard transactions
  • A hot group gets the same mitigations as a hot leaderboard key — read replicas with explicit staleness, short-TTL summary caching, and batched projector catch-up — without changing per-expense transaction semantics
  • Shard migration is an explicit freeze-replicate-replay-verify-cutover procedure, anchored to the group's own event-log offset, never a live copy with an in-place pointer flip
  • Cross-group dashboards are served by a separate, denormalized analytical path fed from the same outbox events — never by fanning out live queries across every group's hot write-path partition
Component 20

Observability, Repair and Recovery

Every guarantee this page describes is only real if it is continuously checked — a periodic zero-sum reconciliation job, explicit drift detection between the ledger and its projection, and a rebuild/restore procedure that is simply this design's own idempotent replay logic run from the beginning, not a special-cased emergency script.

pseudocodePeriodic zero-sum reconciliation: a group+currency's ledger deltas must sum to exactly zero; any nonzero total is treated as a conservation-violating finding, never averaged away
function reconcileZeroSum(groupId):
    for currency in distinctCurrencies(groupId):
        total = SELECT SUM(delta_minor) FROM ledger_events
                WHERE group_id = groupId AND currency = currency
        if total != 0:
            alert("ZERO_SUM_VIOLATION", groupId, currency, total)   // the nonzero total IS the finding -- investigate before any auto-repair
Outboxdurable backlogReplay (at-least-once)Projectionidempotent per event IDReprocessing the same event twice must produce the same end state, never a double-applied balance
The same replay machinery used for routine rebuilds (Component 14) is also disaster recovery — there is no separate emergency code path

Drift detection and repair

Alongside the zero-sum check, a periodic job compares balance_projection against Component 07's direct ledger aggregation for the same (group, member, currency) key. Any divergence triggers an alert and schedules a rebuild of the affected group's projection into a shadow table, checksum-verified against the ledger before cutover — identical in shape to the on-demand rebuild in Component 14, because drift repair is not a different mechanism, it is the same mechanism triggered automatically by a monitored signal instead of manually.

Backup, restore, and what's actually unrecoverable

The append-only ledger_events table is the single highest-priority backup target: a restore that recovers it but loses balance_projection, in-flight idempotency_records, or unpublished outbox rows is still fully recoverable — the projection rebuilds by replay (Component 14), and any lost in-flight idempotency or outbox state can be conservatively treated as "this request might be retried or this event might be republished," which the write path (Component 12) and the relay loop (Component 13) already tolerate by design. Losing ledger_events itself, with no independent durable copy, is the one genuinely unrecoverable scenario in this architecture — which is exactly why it is backed up and replicated more aggressively than any other table.

Audit evidence retrieval

For a disputed expense or settlement, every relevant state transition — the original creation, any correction or reversal, any settlement attempt and its confirm/fail/reverse outcome — is retrievable as the ordered sequence of ledger_events, idempotency_records, and settlements rows referencing that source_id. This retrieval is complete and trustworthy specifically because nothing in this design is ever edited or deleted in place (Components 06, 11, 16, 17) — an audit query here is a straightforward, ordered read, never a best-effort reconstruction from incomplete history.

InvariantWhy it mattersWhat breaks without it
Integer minor units only, never floating pointMoney arithmetic must be exact, not approximately correctRounding error accumulates silently; two values that should compare equal can diverge by a fraction of a paisa
Shares/payer contributions sum exactly to the expense totalAn expense must be fully and only accounted forMoney is silently created or destroyed on every mismatched expense, corrupting every downstream balance that touches it
Payer and beneficiary are distinct roles, not distinct identitiesThe same member routinely plays both roles on one expenseA schema that can't represent overlap can't model Vector A or E at all, forcing an incorrect workaround
Zero-sum per closed currency ledgerA group's internal economy never gains or loses money on its ownA reconciliation job has no ground truth to check against; a leak goes undetected indefinitely
Net balance = live ledger aggregation, never a stored mutable fieldThe ledger must remain the one place every number is derived fromA stored balance can drift from its own history with no way to tell which one is wrong
Corrections are versioned reversal + replacement, never in-place mutationDisputed or wrong events must stay in the permanent recordAn in-place edit destroys the audit trail a dispute or regulator-style review depends on
Idempotent writes (retries never duplicate financial effect)Network retries and duplicate submissions are a normal operating condition, not an edge caseA retried request after a timeout silently double-charges or double-creates an expense
Object-level authorization (membership, not just authentication)An authenticated identity may still have no standing in a specific groupAny authenticated user could read or alter a group they were never added to
Debt simplification preserves net positions, not provenance, and is a suggestionSimplified transfers and pairwise history answer different questionsMembers lose the ability to trace or dispute a specific expense's debt once only the simplified view is kept
No implicit cross-currency nettingDifferent currencies are not interchangeable without an explicit, rate-stamped decisionBalances in different currencies get silently blended into one financially meaningless number
"Settled" is a user assertion unless provider-confirmedA pending or attempted payment is not the same fact as a completed oneA balance is marked paid before money has actually and verifiably moved, misleading every member relying on it
Read-model staleness is explicit and versioned; the ledger is always the source of truthA fast cached or projected view will sometimes lag the ledgerMembers silently see stale balances with no way to tell, and no fallback path to the authoritative number
Invariants summary: what each one protects, and what fails without it
Key takeaways
  • Zero-sum reconciliation and ledger-vs-projection drift detection run continuously, not reactively — a finding is investigated before any auto-repair, never silently patched
  • ledger_events is the single highest-priority backup target; every other table (projection, idempotency records, outbox) is recoverable from it by the same replay logic already used for routine rebuilds
  • Audit evidence retrieval is a straightforward ordered read specifically because this design never edits or deletes financial history in place
  • Every invariant in the summary table above is enforced by a specific, named mechanism elsewhere in this page — none of them are aspirational or merely conventional