CLAUDE.md gains the traps a new session would otherwise re-discover: - Rules: a zero-condition rule matches everything (rule 43 would split all ~3,700 transactions); preview-then-apply-by-id is the safe pattern and why it beats auto-applying on ingestion; how run provenance works. - Shared expenses: transaction_splits.settled is dead data; getParticipantBalances is correct and must not be 'fixed'; settlement cannot be attributed per trip. - The shared loan: separate ledger, fixed 50% with a tracked receivable, why the share must not be derived from actual payments, and why interest stays as spend. - Extraction: balance assertions are the check that works, do not derive opening_balance or add a totals assertion (both would be tautological), Gemini invents summary fields it was not given, empty statements must not throw, FX is per-date, and CSV comparisons need millisecond ordering. The design doc records Phase 0 as done - including that the original Phase 0 plan was wrong, since reading the code first is what prevented breaking a working balance page. Known Gaps lists what is open: the unbuilt phases, 11 failing assertions, the uncategorised Up rows, and the CSVs sitting in 030490e's history.
12 KiB
Shared expenses, settlement, and the shared loan — design proposal
Status: proposal, nothing built. Written 2026-07-26 for review.
Why this exists
Three questions have no answer in the current model:
- Which splits does a settlement payment settle?
- Is the Europe trip settled, separately from the ongoing household tab?
- Whose expense is a $2,500 loan repayment when Sonu funds part of it?
They look like three problems. They are one: the app records money moving, and separately records who owes whom, and the two never meet.
What is actually broken
Two settlement models, neither finished
| Model | Where | State |
|---|---|---|
| Running tab | split_payments (from, to, amount, date) |
in use — 8 payments, $37,881.10 |
| Per-split flag | transaction_splits.settled |
never used — all 673 splits are false |
They are honoured inconsistently:
getParticipantBalances(the shared page) ignoressettledentirely/api/participants/[id]/balancefilters onsettled = falsegetTripAnalyticsreports settled/unsettled from the unused flag
The third is a live bug. Every trip shows 100% unsettled forever, even though Molina has paid $20,782.79 against $19,556.07 of splits and is square.
Settlements exist twice, unlinked
Four of the eight recorded payments match an offset-account credit exactly:
| Payment date | From | Amount | Offset transaction |
|---|---|---|---|
| 2026-02-02 | Molina | 7,500.00 | Transfer from - MEGHALEE BOSE mummy Pa… |
| 2026-04-12 | Sonu | 3,779.33 | Transfer from - MEGHALEE BOSE transfer |
| 2026-04-21 | Molina | 1,685.24 | Transfer from - MEGHALEE BOSE mummy split |
| 2026-05-16 | Sonu | 4,794.06 | Transfer from - MEGHALEE BOSE transfer |
The same money is a split_payments row and a transactions row.
split_payments.linked_transaction_id exists but only 1 of 8 rows uses it. So a
settlement is bookkeeping that happens to resemble a bank credit, rather than
being that credit.
The shared loan is invisible
Sonu's contributions are already in the ledger and unrecognised:
| Pattern in offset credits | Rows | Total | Meaning |
|---|---|---|---|
…emi |
39 | $37,980.24 | Sonu's loan contribution |
…mummy… |
6 | $29,721.24 | Molina's money, forwarded by Sonu |
| other Meghalee | 15 | $71,130.27 | Sonu's own settlements |
All are categorised transfers — correct for spend purposes, but it means a
loan contribution and an expense settlement are indistinguishable.
Meanwhile the loan itself, over the 12 imported months:
Principal repaid (investment, excluded from spend) |
$63,500.00 |
Interest charged (loan_interest, the only part counted as spend) |
$16,523.64 |
At roughly $25,000/year of emi against ~$80,000 of annual repayments, Sonu
funds about 31% — of both the equity being built and the interest being paid.
Today 100% of the interest counts as your spend and 100% of the equity as yours.
The core problem
The model conflates two different things:
- Money movement — a credit landed in the offset account
- Obligation — someone owed someone else, and now owes less
A settlement is both. A loan contribution is both. Right now movement lives in
transactions and obligation lives in transaction_splits / split_payments,
with nothing joining them. That is why a payment cannot say what it settles: it
was never attached to anything in the first place.
Proposed model
1. Settlement contexts
Splits belong to something that is settled as a unit. Payments name which unit they settle. Balance is computed per context, not globally.
| Context | Splits | Settled by | State |
|---|---|---|---|
| Household (default) | ongoing | periodic payments | running tab |
| Europe 2026 | trip-bound | lump sum | closeable |
| Pre-2026 (SplitMyExpenses) | historical | settled elsewhere | born closed |
A closed context still contributes to analytics — you see your true share — but contributes nothing to what anyone owes.
This answers all three opening questions, and it dissolves the splitFrom date
cutoff: pre-2026 splits can be applied retroactively because they are born
into a closed context, so they fix the analytics without creating debt. No date
guard needed, no risk of resurrecting settled obligations.
Mechanically: settlement_contexts table; transaction_splits.context_id;
split_payments.context_id. transaction_splits.settled becomes derived
("is my context closed?") or is dropped.
2. Payments are transactions, not a side table
A settlement is the offset-account credit. split_payments becomes a thin
attribution layer over a real transaction rather than a parallel record of it:
- Populate
linked_transaction_idon all existing payments where a match exists - On ingestion, an incoming credit that looks like a settlement is proposed as
one for confirmation, rather than silently becoming
transfers - A payment with no matching transaction (cash, or an account not imported) stays as a manual row — the model must tolerate that
3. The shared loan — a separate ledger
Not a settlement context. The loan is a jointly funded asset with its own obligation, and mixing it with expense settlement would let a contribution accidentally settle a dinner.
emicredits are recognised as contributions, not generic transfers- A contribution schedule states what is owed per period (50% of the repayment), independent of what was actually paid
- The running difference is a receivable — currently $4,000.00
The schedule matters: during Sonu's leave the obligation did not change, only the payment did. A percentage-of-actual model would silently redefine her share as 30% and make the shortfall disappear.
Migration path
Phase 0 — DONE (2026-07-26, commit 3f04cbd)
Stop the trip view reporting a settlement breakdown it cannot compute.
The original plan was "make getTripAnalytics and getParticipantBalances
agree". That plan was wrong and reading the code before building is what
caught it:
getParticipantBalancesis not buggy. It computessplits − payments, which is coherent. Excluding settled splits there while still subtracting the payments that settled them would have double-counted and broken a working page.- The real defect was narrower: the trip view showed Settled/Unsettled from
transaction_splits.settled, which nothing sets. A correct per-trip figure is not computable at all, becausesplit_paymentshas no trip attribution.
So the fix was subtractive: the trip view now shows each participant's share and points at Shared for what is actually owed.
Also removed /api/splits/settle — unreachable from the UI but live on its URL,
where one call with participant_id would mark every one of that person's splits
settled, writing a flag nothing reads.
transaction_splits.settled / settled_at still exist and are now pure dead
data. Phase 1 either repurposes them ("is my context closed?") or drops them.
Phase 1 — settlement contexts
Add contexts; put every existing split in "Household"; every payment likewise.
Balance queries group by context. Touches queries.ts (both balance CTEs),
shared/page.tsx, trips/[id]/page.tsx, split-payments/route.ts. ~1 day.
Phase 2 — link payments to transactions
Backfill linked_transaction_id for the four exact matches; flag the other four
for manual linking. On ingestion, propose a matching credit as a settlement
rather than silently categorising it transfers. ~half a day.
Phase 3 — retroactive pre-2026 split
Create the "Pre-2026" closed context. Apply household split rules into it via the
rule preview (/api/rules/[id]/matches, built 2026-07-26) — fixes ~$97,627 of
the trailing 12 months currently shown as 100% yours. Then delete the splitFrom
cutoff entirely.
Validate the ratio first. This assumes today's 50/50 held through 2025. The SplitMyExpenses CSVs should be used to check that assumption — not to reconcile, since transactions were sometimes combined and exact matching is impossible.
Phase 4 — loan ledger
Contribution schedule, contributions recognised from emi credits, running
receivable. Independent of contexts — the loan is a separate ledger. ~1–2 days.
Decisions taken (2026-07-26)
The loan is separate from shared expenses
Different obligations, different rhythms, different nature: one funds an asset, the other funds consumption. They do not share a settlement context and a contribution is never a settlement.
The share is 50/50, fixed — with the shortfall tracked
Not derived from actual payments, which fluctuate. Sonu's obligation is half the repayment; the difference between obligation and actual is a receivable, and it is the interesting number.
Over 2025-07-01 → 2026-06-30:
| Repayments | $63,500.00 |
| Sonu's 50% obligation | $31,750.00 |
| Actually contributed (26 payments) | $27,750.00 |
| Shortfall | $4,000.00 |
She never missed a fortnight; the rate changed:
| Rate | Payments | Period |
|---|---|---|
| $1,250 | 15 | Aug 2025 – Feb 2026 (the correct 50%) |
| $1,000 | 3 | Jul 2025 (pre-adjustment) |
| $750 | 8 | Mar – Jun 2026 (leave) |
So the model needs a contribution schedule (expected per period) alongside actual contributions, with the running difference as a tracked balance. A flat percentage cannot express "obligation unchanged, payment temporarily reduced, difference owed".
Interest: recommended as expense, pending final call
The mechanics are as described — interest is debited to the loan and repayments pay down the combined balance. Reconciles exactly:
134: 31,000.00 − 8,553.27 = 22,446.73 = balance reduction
133: 32,500.00 − 7,970.37 − 2,849.00 = 21,680.63 = balance reduction
But mechanics are not the same as economics. Over 12 months $63,500 of cash left and debt fell by $44,127.36. The $16,523.64 difference bought nothing and is not recoverable — that is an expense by definition. Excluding it leaves the balance sheet unable to reconcile cash out against equity gained, and understates annual cost by ~10%.
The legitimate concern is that interest is non-discretionary. The answer to that is a fixed-commitments grouping alongside rent, insurance and utilities — a presentation change, not an exclusion.
Recommendation: keep loan_interest as spend, add a fixed/discretionary
split. Flagged rather than settled: it is a judgement about what "spend" means
in your own reporting.
Open questions
-
Does equity need tracking per person? If Sonu accrues a share of the principal, that is a balance-sheet item the app has no concept of. Probably belongs in the net-worth view rather than here.
-
Attribution of forwarded payments.
mummyin the description reliably marks Molina's money in all six known cases, but it is a description match on a free-text field. Acceptable as a suggestion requiring confirmation; not as an automatic rule. -
Retroactive split ratios. Applying today's household rules to 2025 assumes the arrangement has not changed. The SplitMyExpenses CSVs could give real historical shares, but transactions were sometimes combined, so matching is imperfect. Recommendation: use today's ratios, accept the approximation — the goal is a truer analytics picture, not a restated ledger.
What I would not do
- Do not restate history from the SplitMyExpenses CSVs. The combining problem makes exact reconciliation impossible, and the value is low: those balances are settled and will not change.
- Do not make the loan a shared expense. It is a funded asset. Modelling it as a recurring split would put $2,500 a fortnight of principal into spend, which is the error migration 0014 was written to prevent.