docs: rewrite the shared-expenses section, which had gone false
ci / lint-test (push) Successful in 39s

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.
This commit is contained in:
2026-07-28 14:41:05 +10:00
parent db6b7f8375
commit d081d80a3f
+60 -22
View File
@@ -111,29 +111,64 @@ it silently deletes the unlogged remainder from spend totals.
### Shared expenses and settlement — read before touching ### Shared expenses and settlement — read before touching
The model is under active redesign. See `docs/shared-expenses-design.md` for the Rebuilt 2026-07-28. `docs/shared-expenses-design.md` describes the live model;
proposal and what is already decided. Three traps: 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 **The cutover date is the primary balance gate, not the `settled` flag.**
only writer was `/api/splits/settle`, removed in `3f04cbd` because nothing called `ACTIVE_OBLIGATION` (`src/lib/analytics-sql.ts`) is
it and one request could mark all of a participant's splits settled. Do not build `ts.settled = false AND t.transaction_date >= '2026-01-09'`. Nothing dated
on this flag until settlement contexts exist. 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 Consequence: **pre-2026 transactions can be split freely.** A split on a 2024
"fix" it to exclude settled splits — the payments that settled them are still grocery shop describes how the expense was shared — which is what stops it
subtracted, so you would double-count. The two settlement models (running tab vs inflating spend — without asserting a debt. 657 pre-2026 transactions carry
per-split flag) must not be mixed. 1,266 such splits, imported from SplitMyExpenses and marked `settled`.
**Settlement cannot be attributed per trip.** `split_payments` records only **Spend counts settled splits; owed does not.** `myShare`/`mySplitOf` must NOT
from/to/amount/date. Any per-trip settled/unsettled figure is fabricated; the filter on `settled` — half a 2025 grocery shop was your expense whether or not
trip view used to show one and always reported 100% unsettled. Trips show share the other half was repaid. Filtering it out re-inflates exactly the figures the
only, and point at `/shared` for real balances. historical import exists to correct.
Also: settlements already exist twice. Four of eight `split_payments` match an **Any split write path that deletes-and-recreates must carry `settled` across.**
offset-account credit exactly on amount and date, with `linked_transaction_id` `POST /api/transactions/[id]/splits` did not, and silently converted discharged
populated on only one. And Sonu's loan contributions (`…emi` in the offset obligations into live debt — $37,233.28 was exposed. Fixed in `6add958`.
account, 39 rows, $37,980.24) are categorised `transfers`, indistinguishable from `rule-actions.ts` is safe only by the shape of its upsert
ordinary internal transfers. (`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 Mar12 Apr), Auckland 2026 (id 2),
Europe — Sonu + Sunny (id 3, 1228 Apr, created 2026-07-28 from tag 5).
**Partial split coverage inside a category is usually correct, not a gap.** Only **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 *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 correct. Check the merchants before concluding a rule was never applied — a
category-level ratio that "looks wrong" usually is not. category-level ratio that "looks wrong" usually is not.
Splits exist in this app from **2026-01-09** only; earlier splits lived in **Still true, and still a caveat:** Sonu's loan contributions (`…emi` in the
SplitMyExpenses. So a trailing-12-month per-person series splices six months of offset account, 39 rows, $37,980.24) are categorised `transfers`, indistinguishable
gross onto six months of net. Use FebJun 2026 for anything per-person. 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 ### The shared loan