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
+44
View File
@@ -760,6 +760,50 @@ rather than hand-rolling them. Two failure modes they exist to prevent:
Use the `EXCLUDE_NON_SPEND` fragment: a bare `category NOT IN (...)` evaluates
to NULL for uncategorised rows and drops them from totals.
### Spend is gated on MY_SPEND_SCOPE, not OWNER_SCOPE (2026-08-15)
**Ownership is not a precondition for an expense being yours.** Every spend
analytic used to gate on `OWNER_SCOPE = $1`, with `mySplitOf` scaling *within*
that gate — so your half of a shop Sonu paid for counted as **zero**, in monthly,
daily, merchants, subscriptions, fees and the budget page. 167 rows / **$3,210.91**
across JanJul 2026, worst in April (+$1,354.83, the Europe trips). Meanwhile
`getParticipantBalances` booked the matching debt correctly, so the app could say
you owed her for a shop while insisting you had not spent anything on it.
Use `MY_SPEND_SCOPE()``owner = me OR I hold a split` — for anything measuring
what *I* spent. `OWNER_SCOPE` is still right for anything measuring an **account**:
the income and investment lines in `/analytics/monthly`, and the statement-level
fee rollup in `/analytics/fees` (which reads `statements`, where splits are
meaningless).
**`myShare` had to change with it, and widening the gate alone would have been
worse than the bug.** Its old third fallback, `100 - everyone else`, is the
*payer's* remainder — on someone else's unsplit row it returns 100 and would have
moved their entire bill onto you. It now branches on ownership:
- **My row** — unchanged: explicit split, then `my_share_percent`, then the
remainder.
- **Their row** — an explicit split row only; absent means **0**.
`my_share_percent` is deliberately not consulted on someone else's row. It is one
unscoped column on `transaction_overrides` 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.
That 0 is what makes the wider gate safe: admitting a row can never add more than
the share actually held. Tested both ways in
`src/__tests__/integration/analytics-sql.test.ts` — the discriminating cases are
all ones where you hold **no** split on someone else's row, because that is the
only place old and new disagree.
`MY_SHARE_PCT` in `queries.ts` mirrors `myShare` in subselect form for the
transactions list (which has no viewer-scoped `ts` join). A test asserts the two
agree row for row across seven fixture shapes — keep it that way, since the list
is the drill-down for these totals.
**No historical restatement:** every non-owner split is 2026-dated. The 1,266
pre-2026 SplitMyExpenses splits are all on rows you own, so nothing before the
cutover moves.
### Statement types
`statements.statement_type` is constrained to `credit_card | transaction |