analytics: your share of what someone else paid is your spend
ci / lint-test (push) Successful in 1m25s

Every spend analytic gated on `OWNER_SCOPE = $1` and scaled by `mySplitOf`
*within* that gate, so ownership was a precondition for an expense being
yours. Half a grocery shop Sonu paid for counted as zero — in monthly,
daily, merchants, subscriptions, fees and the budget page. 167 rows /
$3,210.91 across Jan-Jul 2026, worst in April (+$1,354.83, the Europe
trips), while getParticipantBalances booked the matching debt correctly.
The app could say you owed her for a shop while insisting you had not
spent anything on it.

New MY_SPEND_SCOPE(): owner = me OR I hold a split. OWNER_SCOPE stays on
the things that measure an *account* rather than a person — the income
and investment lines, and the statement-level fee rollup.

myShare had to change with it, and widening the gate alone would have
been worse than the bug: its `100 - everyone else` fallback is the
payer's remainder, so on someone else's unsplit row it returns 100 and
moves their whole bill onto you. It now branches on ownership — my row
resolves as before; their row takes an explicit split row only, absent
meaning 0. That 0 is what makes the wider gate safe.

my_share_percent is deliberately not read on someone else's row: one
unscoped column, writable by anyone who can see the row, so "my" can
only mean the owner's. All 402 rows carrying one today are owner-side.

MY_SHARE_PCT mirrors myShare for the transactions list, which has no
viewer-scoped ts join; a test asserts the two agree across seven fixture
shapes.

No historical restatement — every non-owner split is 2026-dated, and the
1,266 pre-2026 SplitMyExpenses splits are all on rows you own.

Also fixes a latent failure in the NATIVE_CURRENCY test, which inserted a
statement relying on participant id 1 existing (owner_id is NOT NULL
DEFAULT 1 with an FK) and only passed when a sibling file had left one
behind. It now owns its fixture.

15 new integration tests; 189 integration + 130 unit green.
This commit is contained in:
2026-08-15 15:37:32 +10:00
parent 22c2349a47
commit 6c14b493ef
10 changed files with 371 additions and 43 deletions
+61 -11
View File
@@ -129,10 +129,44 @@ export const SPEND_BASE = `CASE WHEN t.interest_amount IS NOT NULL THEN t.intere
/** Effective category, honouring overrides. Never NULL. */
export const EFFECTIVE_CATEGORY = `COALESCE(o.category_override, t.category, 'other')`;
/**
* Rows whose cost is partly mine: ones I paid for, plus ones someone else paid
* for that I hold a split on.
*
* Every spend analytic used to gate on `${OWNER_SCOPE} = $1` alone, with
* `mySplitOf` scaling *within* that gate. Ownership was therefore a
* precondition for an expense being mine — which is backwards for a shared
* household. Half a grocery shop is my expense whether the card was mine or
* Sonu's; who owns the row says whose *account* the money left, a different
* question. It is the same principle the `settled` rule already states: my
* share is my expense regardless of who fronted it or whether it has since
* been repaid.
*
* Measured on 2026-08-15 the gate hid 167 rows / $3,210.91 of my own share
* (Jan–Jul 2026) from monthly, daily, merchants, subscriptions, fees and the
* budget page, while `getParticipantBalances` booked the matching debt
* correctly — so the app could tell you that you owed Sonu for a shop while
* insisting you had not spent anything on it. Worst month was April 2026
* (+$1,354.83), the Europe trips, where she paid for a lot.
*
* That figure is net of `EXCLUDE_NON_BUDGET_TAGS`: a further $456.50 of
* non-owned share in July is `family`-tagged and correctly stays out. Counting
* on transaction_type alone gives $3,667.41 and is the wrong comparison.
*
* Requires `transaction_splits ts` joined on `ts.participant_id =
* <participant>` and ${STATEMENTS_JOIN}. That join cannot multiply rows:
* `(transaction_id, participant_id)` is UNIQUE.
*
* Pair it with `myShare`, which returns 0 for a non-owned row with no split —
* so admitting a row here can never add more than the share actually held.
*/
export const MY_SPEND_SCOPE = (participant = "$1") =>
`(${OWNER_SCOPE} = ${participant} OR ts.participant_id IS NOT NULL)`;
/**
* My percentage share of a transaction, 0-100.
*
* Resolution order:
* On a row I paid for, resolution order:
* 1. An explicit `transaction_splits` row for me.
* 2. The `my_share_percent` override.
* 3. Whatever is left after everyone else's shares.
@@ -142,17 +176,33 @@ export const EFFECTIVE_CATEGORY = `COALESCE(o.category_override, t.category, 'ot
* they owe all of it, and there is no row for me to find. Those rows would
* otherwise land in my spend at full value.
*
* Requires `transaction_splits ts` joined on `ts.participant_id = <participant>`
* and `transaction_overrides o` joined on the transaction.
* On a row SOMEONE ELSE paid for, only an explicit split row counts, and its
* absence means 0. Both of the other two steps are meaningless there and one of
* them is actively wrong:
*
* - `my_share_percent` is a single column on `transaction_overrides` with no
* participant scoping, writable by anyone who can see the row. "My" can only
* coherently mean the owner's. Reading it as the viewer's share on someone
* else's transaction would be a guess, and all 402 rows carrying one today
* are owner-side, so there is nothing to be gained by guessing.
* - `100 - everyone else` is the *payer's* remainder by definition. Applied to
* a row I did not pay for it would hand me the payer's share on top of my
* own.
*
* Requires `transaction_splits ts` joined on `ts.participant_id = <participant>`,
* `transaction_overrides o` joined on the transaction, and ${STATEMENTS_JOIN}.
*/
export const myShare = (participant = "$1") => `COALESCE(
ts.share_percent,
o.my_share_percent,
100 - COALESCE((
SELECT SUM(x.share_percent) FROM transaction_splits x
WHERE x.transaction_id = t.id AND x.participant_id <> ${participant}
), 0)
)`;
export const myShare = (participant = "$1") => `CASE
WHEN ${OWNER_SCOPE} = ${participant} THEN COALESCE(
ts.share_percent,
o.my_share_percent,
100 - COALESCE((
SELECT SUM(x.share_percent) FROM transaction_splits x
WHERE x.transaction_id = t.id AND x.participant_id <> ${participant}
), 0)
)
ELSE COALESCE(ts.share_percent, 0)
END`;
/** `base` scaled to my share. Use for every per-user spend total. */
export const mySplitOf = (base: string, participant = "$1") =>