analytics: your share of what someone else paid is your spend
ci / lint-test (push) Successful in 1m25s
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:
+61
-11
@@ -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") =>
|
||||
|
||||
Reference in New Issue
Block a user