From d081d80a3f41f4d641cd9862fa12a3f50ede93a0 Mon Sep 17 00:00:00 2001 From: siddharthd Date: Tue, 28 Jul 2026 14:41:05 +1000 Subject: [PATCH] docs: rewrite the shared-expenses section, which had gone false Every warning in it was inverted by this week's work: - "settled is dead data, false on every row" -- there are now 1,266 settled splits across 657 pre-2026 transactions. - "do not fix getParticipantBalances to exclude settled splits" -- it now excludes them, via ACTIVE_OBLIGATION, and must. - "settlement cannot be attributed per trip" -- migration 0022 added split_payments.trip_id and it is attributed. - "splits exist from 2026-01-09 only" -- pre-2026 transactions are now split deliberately, to stop them inflating spend. Replaced with what is actually true, including the rule that matters most: the cutover DATE is the primary balance gate and the settled flag only refines it, so pre-2026 expenses can be split freely. --- CLAUDE.md | 82 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 60 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 001101b..7da2365 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,29 +111,64 @@ it silently deletes the unlogged remainder from spend totals. ### Shared expenses and settlement — read before touching -The model is under active redesign. See `docs/shared-expenses-design.md` for the -proposal and what is already decided. Three traps: +Rebuilt 2026-07-28. `docs/shared-expenses-design.md` describes the live model; +it is no longer a proposal. Everything the old version of this section warned +about has changed — if you are working from memory of it, re-read. -**`transaction_splits.settled` is dead data.** It is `false` on every row. Its -only writer was `/api/splits/settle`, removed in `3f04cbd` because nothing called -it and one request could mark all of a participant's splits settled. Do not build -on this flag until settlement contexts exist. +**The cutover date is the primary balance gate, not the `settled` flag.** +`ACTIVE_OBLIGATION` (`src/lib/analytics-sql.ts`) is +`ts.settled = false AND t.transaction_date >= '2026-01-09'`. Nothing dated +before the cutover can ever be owed, because carryover transaction **2348** +(dated 2026-01-09, $1,093.22) already carries the whole pre-cutover balance as +one figure. The bound is **inclusive** — 2348 is itself dated 2026-01-09, so an +exclusive bound would drop the carryover and the entire pre-cutover balance. -**`getParticipantBalances` computes `splits − payments` and is correct.** Do not -"fix" it to exclude settled splits — the payments that settled them are still -subtracted, so you would double-count. The two settlement models (running tab vs -per-split flag) must not be mixed. +Consequence: **pre-2026 transactions can be split freely.** A split on a 2024 +grocery shop describes how the expense was shared — which is what stops it +inflating spend — without asserting a debt. 657 pre-2026 transactions carry +1,266 such splits, imported from SplitMyExpenses and marked `settled`. -**Settlement cannot be attributed per trip.** `split_payments` records only -from/to/amount/date. Any per-trip settled/unsettled figure is fabricated; the -trip view used to show one and always reported 100% unsettled. Trips show share -only, and point at `/shared` for real balances. +**Spend counts settled splits; owed does not.** `myShare`/`mySplitOf` must NOT +filter on `settled` — half a 2025 grocery shop was your expense whether or not +the other half was repaid. Filtering it out re-inflates exactly the figures the +historical import exists to correct. -Also: settlements already exist twice. Four of eight `split_payments` match an -offset-account credit exactly on amount and date, with `linked_transaction_id` -populated on only one. And Sonu's loan contributions (`…emi` in the offset -account, 39 rows, $37,980.24) are categorised `transfers`, indistinguishable from -ordinary internal transfers. +**Any split write path that deletes-and-recreates must carry `settled` across.** +`POST /api/transactions/[id]/splits` did not, and silently converted discharged +obligations into live debt — $37,233.28 was exposed. Fixed in `6add958`. +`rule-actions.ts` is safe only by the shape of its upsert +(`ON CONFLICT DO UPDATE SET share_percent` never touches the flag). +The rules-apply revert route restores it explicitly. + +**Settling up is recording a payment.** There is deliberately no "mark settled" +action. `settled` marks obligations discharged *outside* this app; doing both +would subtract the settlement twice. + +**Payments carry scope, and one transfer can carry several rows.** +`split_payments.trip_id` (migration 0022) says which tab a payment settles; +NULL is the ongoing household tab. There is no unique constraint on +`linked_transaction_id`, so a grouped transfer is recorded as one row per scope +that re-add to the transfer — that is how Sonu's $3,779.33 and $4,794.06 were +allocated Europe-first with the remainder to household. + +**Trip owed must be owner-scoped; trip cost must not be.** The owed query +applies `OWNER_SCOPE`; without it a debt between the *other two* participants +reads as owed to you ($1,605.49 on Europe 2026). Trip *cost* deliberately counts +every payer — a trip cost what the group put into it — which is why the stat card +says "all payers, net of refunds". Do not "fix" the missing scoping there. + +**Duplicates are superseded, never deleted.** `transactions.superseded_by_id` +(migration 0023); 31 rows / $42,040.68 from overlapping ANZ statements 107/142/143. +Every child of `transactions` is `ON DELETE CASCADE`. The exclusion lives *inside* +`EXCLUDE_RECONCILED_SOURCE`, so any query applying that fragment gets it free — +and any query that does not still double-counts. + +**Refunds:** a *partial* refund is netted in SQL (`NET_SPEND_ROWS`/`SPEND_SIGNED`); +a *cancelled* booking has both legs untagged from the trip by hand, because a +trip never incurred a cost it cancelled. + +**Trips:** Europe 2026 (id 1, 19 Mar–12 Apr), Auckland 2026 (id 2), +Europe — Sonu + Sunny (id 3, 12–28 Apr, created 2026-07-28 from tag 5). **Partial split coverage inside a category is usually correct, not a gap.** Only *shared* items are split. `utilities` sits at 69% yours because Globird, OVO, GWW @@ -144,9 +179,12 @@ Billdu, Spotify and Patreon are not. `fees` and `charity` are 100% yours and correct. Check the merchants before concluding a rule was never applied — a category-level ratio that "looks wrong" usually is not. -Splits exist in this app from **2026-01-09** only; earlier splits lived in -SplitMyExpenses. So a trailing-12-month per-person series splices six months of -gross onto six months of net. Use Feb–Jun 2026 for anything per-person. +**Still true, and still a caveat:** Sonu's loan contributions (`…emi` in the +offset account, 39 rows, $37,980.24) are categorised `transfers`, indistinguishable +from ordinary internal transfers. The loan model below is unbuilt. + +**`order_reviews` is a table wired to nothing** — 0 rows, no API, no UI, no +writes. The "never order from here again" capability does not exist. ### The shared loan