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.
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.
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.
- 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
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.
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.
- 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
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.
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 allocatedVector 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.
- 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
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.
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.
- 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
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.
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.
- 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
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.
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
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 tolerateRunning 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.
- 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
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.
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.
Why the distinction has product and legal weight
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.
- 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
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.
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) transactionsVector 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."
- 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
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.
| Entity | Key fields | Constraints |
|---|---|---|
| Group | id (PK), currency, created_at | currency is immutable once the group's first expense exists |
| Membership | group_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) |
| Expense | id (PK), group_id (FK), total_amount_minor, currency, split_type, version, status, created_by, created_at | version starts at 1, incremented on every accepted edit (Component 11); FK group_id → Group |
| ExpensePayer | expense_id (FK), member_id (FK), amount_paid_minor | PK (expense_id, member_id); SUM(amount_paid_minor) per expense_id = Expense.total_amount_minor |
| ExpenseSplit | expense_id (FK), member_id (FK), share_minor | PK (expense_id, member_id); SUM(share_minor) per expense_id = Expense.total_amount_minor |
| LedgerEvent | id (PK), group_id (FK), source_type, source_id, source_version, member_id (FK), delta_minor, currency, created_at | append-only (INSERT-only grant); index on (group_id, member_id, currency) for Component 07's aggregation |
| Settlement | id (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 |
| IdempotencyRecord | idempotency_key, actor_member_id, group_id, action_type (composite PK), payload_hash, response_snapshot, created_at, expires_at | unique index on (actor_member_id, group_id, action_type, idempotency_key) — Component 12 |
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).
- 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
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.
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.
- 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
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.
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 resultVector 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.
- 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
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.
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.
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.idAt-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.
- 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 LOCKEDlets 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
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.
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.APPLIEDPer-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
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.
- 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
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.
- 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
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.
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.
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 runReversing 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.
- Vector C: a self-asserted repayment is
confirmedimmediately with no provider involved, explicitly tagged as user-asserted for later dispute handling - Vector J: a provider-mediated settlement only reaches
confirmedon 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
reversedstatus transition — never an edit to the original confirmed row
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.
- 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
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.
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.
- 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
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.
- 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
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.
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-repairDrift 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.
| Invariant | Why it matters | What breaks without it |
|---|---|---|
| Integer minor units only, never floating point | Money arithmetic must be exact, not approximately correct | Rounding 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 total | An expense must be fully and only accounted for | Money is silently created or destroyed on every mismatched expense, corrupting every downstream balance that touches it |
| Payer and beneficiary are distinct roles, not distinct identities | The same member routinely plays both roles on one expense | A schema that can't represent overlap can't model Vector A or E at all, forcing an incorrect workaround |
| Zero-sum per closed currency ledger | A group's internal economy never gains or loses money on its own | A reconciliation job has no ground truth to check against; a leak goes undetected indefinitely |
| Net balance = live ledger aggregation, never a stored mutable field | The ledger must remain the one place every number is derived from | A 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 mutation | Disputed or wrong events must stay in the permanent record | An 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 case | A 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 group | Any authenticated user could read or alter a group they were never added to |
| Debt simplification preserves net positions, not provenance, and is a suggestion | Simplified transfers and pairwise history answer different questions | Members lose the ability to trace or dispute a specific expense's debt once only the simplified view is kept |
| No implicit cross-currency netting | Different currencies are not interchangeable without an explicit, rate-stamped decision | Balances in different currencies get silently blended into one financially meaningless number |
| "Settled" is a user assertion unless provider-confirmed | A pending or attempted payment is not the same fact as a completed one | A 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 truth | A fast cached or projected view will sometimes lag the ledger | Members silently see stale balances with no way to tell, and no fallback path to the authoritative number |
- 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