docs: rewrite the shared-expenses section, which had gone false
ci / lint-test (push) Successful in 39s
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:
@@ -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 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
|
**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 Feb–Jun 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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user