Files
finance-app/src/lib/analytics-sql.ts
T
siddharthd 6c14b493ef
ci / lint-test (push) Successful in 1m25s
analytics: your share of what someone else paid is your spend
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.
2026-08-15 15:37:32 +10:00

330 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Shared SQL fragments for analytics queries, so spend/income semantics stay
// identical across routes.
//
// Three rules every analytics query must follow:
//
// 1. Join `statements` with LEFT JOIN and scope on COALESCE(t.owner_id, s.owner_id).
// An INNER JOIN silently drops every manual/CSV transaction (statement_id IS
// NULL) — which is most of the reconciliation and cash-spend data.
// 2. Exclude `transfers` and `investment` from spend. Once bank statements are
// imported, a credit-card payment appears twice: once as a debit leaving the
// bank account and again as the underlying purchases on the card statement.
// Categorising the money movement as `transfers` and excluding it here is what
// stops the double count. Investments are a balance-sheet move, not spend.
// 3. Apply EXCLUDE_RECONCILED_SOURCE to every row-level query. The transaction
// queries have always done this (`queries.ts`); analytics never did, which is
// the other half of the same double count.
/**
* `YYYY-MM-DD` for a Date, read in local time.
*
* `toISOString().slice(0, 10)` is the obvious thing and it is wrong here: these
* are calendar boundaries built with `new Date(y, m, 1)`, which is local
* midnight. In any timezone east of UTC that converts to the *previous* day, so
* every window silently started and ended a day early — visible once the fees
* endpoint began reporting the range it had used ("2026-04-30" for a window
* meant to open on 1 May).
*/
export function toDateStr(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
/** Owner scoping that works for both statement-linked and manual transactions. */
export const OWNER_SCOPE = `COALESCE(t.owner_id, s.owner_id)`;
/** Join clause to pair with OWNER_SCOPE. */
export const STATEMENTS_JOIN = `LEFT JOIN statements s ON s.id = t.statement_id`;
/**
* Drops rows that a different row has replaced. Two distinct cases:
*
* **1. The manual/CSV row a statement line superseded** (`reconciled_with_id`).
*
* Reconciliation keeps both rows: the manual one the user entered and the
* statement line it turned out to be. Only the statement line should count, or
* the same purchase is spent twice. `queries.ts` has always applied this; the
* analytics routes did not, so every reconciled row was double-counted in the
* category totals, movers, Pareto, and merchant rankings.
*
* Scoped to `statement_id IS NULL` deliberately: the *source* row is the manual
* one. A statement line pointing at something else is the survivor, not the
* duplicate.
*
* Order-receipt rows (`payment_method = 'credits'`) are unaffected — they are
* inserted with `reconciled_with_id` NULL and are held out of the reconcile
* queue by `needsCardMatch()`, so nothing ever sets it. If one is reconciled by
* hand against a card line, this is what stops it double-counting.
*
* **2. The statement row imported twice** (`superseded_by_id`, migration 0023).
*
* When two statements for one account bill overlapping periods, every
* transaction in the overlap arrives twice — 31 pairs on ANZ `4085-56264`, from
* statements 107/142/143. Case 1 cannot express this: its predicate is scoped
* to `statement_id IS NULL` on purpose, and here BOTH rows are statement lines.
*
* The superseded row is excluded rather than deleted because every child of
* `transactions` cascades on delete, and the curation is not reliably on the
* surviving side.
*
* Adding it to this fragment rather than making a new one is deliberate: every
* query that already asks "count each purchase once" now excludes both kinds
* without being edited. Anything summing transactions without this fragment
* still double-counts — that is the same gap that let reconciled rows into the
* analytics routes in the first place.
*/
export const EXCLUDE_RECONCILED_SOURCE = `NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)
AND t.superseded_by_id IS NULL`;
/**
* The currency `t.amount` is actually denominated in.
*
* Two different conventions meet here and the COALESCE order is what keeps them
* apart:
*
* - A statement row is denominated in its statement's currency. If that row is
* an overseas purchase on an AUD statement, `amount` is still AUD and
* `foreign_currency_code` merely records what was originally charged — so
* `s.currency` must win.
* - An order-receipt row has no statement. There, `amount` IS the native
* figure and `foreign_currency_code` names it (order-ingestion.ts leaves
* `amount_aud` NULL rather than asserting an FX rate it does not have).
*
* Reading `s.currency` alone labels every foreign order row as AUD.
* `s` must be the statements alias in scope.
*/
export const NATIVE_CURRENCY = `COALESCE(s.currency, t.foreign_currency_code, 'AUD')`;
/**
* True when a row's AUD value is unknown: it is denominated in something other
* than AUD and carries no converted figure.
*
* Every settlement total uses `COALESCE(t.amount_aud, t.amount)`, which for such
* a row silently nets a foreign figure against AUD ones. Rather than drop the
* row (which changes a balance with no trace) or convert it (with no rate),
* count these and let the UI say the balance is incomplete.
*/
export const AMOUNT_UNCONVERTED = `(t.amount_aud IS NULL AND ${NATIVE_CURRENCY} <> 'AUD')`;
/** Transaction types that represent money going out. */
export const SPEND_TYPES = `('debit', 'fee', 'interest')`;
/**
* Rows that count towards spend.
*
* The `interest_amount IS NOT NULL` arm is for loan repayments: when a lender
* itemises principal and interest on a single repayment row, that row is usually
* typed 'payment' (money reducing the loan balance) and would otherwise be
* skipped — but its interest portion is real spend.
*/
export const SPEND_ROWS = `(t.transaction_type IN ('debit', 'fee', 'interest') OR t.interest_amount IS NOT NULL)`;
/**
* The amount of a row that counts as spend, before split adjustment.
*
* For an itemised loan repayment only the interest portion is an expense; the
* principal builds equity and is a balance-sheet move, not spending.
*/
export const SPEND_BASE = `CASE WHEN t.interest_amount IS NOT NULL THEN t.interest_amount ELSE COALESCE(t.amount_aud, t.amount) END`;
/** 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.
*
* 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.
*
* Step 3 is the one that matters. Assuming 100% when no split row exists for me
* is wrong whenever a transaction is allocated entirely to someone else — I paid,
* 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.
*
* 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") => `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") =>
`((${base}) * ${myShare(participant)} / 100)`;
export const NON_BUDGET_TAGS = ['family'];
export const EXCLUDE_NON_BUDGET_TAGS = `NOT EXISTS (
SELECT 1 FROM transaction_tags tt JOIN tags tg ON tg.id = tt.tag_id
WHERE tt.transaction_id = t.id AND tg.name = ANY(ARRAY['family'])
)`;
/**
* Predicate excluding categories and tags that are not spend.
* The COALESCE matters: a bare `category NOT IN (...)` evaluates to NULL for
* uncategorised rows, which silently drops them from spend totals.
*/
export const EXCLUDE_NON_SPEND = `${EFFECTIVE_CATEGORY} NOT IN ('transfers', 'investment', 'income') AND ${EXCLUDE_NON_BUDGET_TAGS}`;
/**
* Rows that count towards NET spend — outgoings plus the refunds that cancel
* them. Pair with SPEND_SIGNED, which carries the direction.
*/
export const NET_SPEND_ROWS = `(${SPEND_ROWS} OR t.transaction_type IN ('refund', 'credit'))`;
/**
* SPEND_BASE, signed: refunds and credits come back as negatives so they cancel
* the original purchase.
*
* Without this a refund is counted nowhere. It is excluded from spend by type
* and only counted as income if categorised 'income', which a refund is not —
* so it falls through both and never reduces anything. A $2,888.92 Expedia
* purchase refunded in full eight days later still read as $2,888.92 of spend.
*/
export const SPEND_SIGNED = `CASE
WHEN t.transaction_type IN ('refund', 'credit') THEN -(${SPEND_BASE})
ELSE (${SPEND_BASE})
END`;
/**
* The investment line, signed: withdrawals come back as negatives so they net
* against contributions.
*
* A withdrawal from a fund is a disinvestment — units converted back to cash,
* net worth unchanged. Summed unsigned it read as *more* money invested: March
* 2026 showed $38,615.34 of investing in a month that was net -$11,384.66,
* because a $25,000 Raiz withdrawal was added to a $8,563.80 IBKR deposit
* instead of cancelling it. Overstated by $50,000 in that month alone — each
* credit costs twice, once for being added and once for not being subtracted.
*
* Filing withdrawals as `income` instead is the other tempting answer and is
* worse: it books an asset disposal as earnings and feeds the same figure into
* `net = income - spent - investments` with a flattering sign. Same reason the
* Up item sales in Known Gaps do not belong on the income line.
*
* Caveat this cannot resolve: part of a withdrawal genuinely is income — the
* capital gain. The bank descriptor is a single gross figure with no cost base,
* so it cannot be decomposed here. Netting tracks cash committed against cash
* returned and leaves the gain for holdings data to surface; it does not claim
* the gain is zero.
*/
export const INVESTMENT_SIGNED = `CASE
WHEN t.transaction_type IN ('refund', 'credit') THEN -COALESCE(t.amount_aud, t.amount)
ELSE COALESCE(t.amount_aud, t.amount)
END`;
/**
* A split that still counts towards what someone owes.
*
* This is the line between the two questions the same table answers, and
* conflating them is what made three different "owed" figures disagree:
*
* - **Owed** — what is still outstanding between two people. Must apply this.
* - **Spend** — what a purchase cost me. Must NOT apply this.
*
* A settled split is still a real expense: my half of a 2025 grocery shop is my
* spend whether or not the other half was ever repaid. Filtering settled rows
* out of `myShare`/`mySplitOf` would re-inflate exactly the figures that
* importing settled history exists to correct.
*
* `settled` marks obligations discharged OUTSIDE this app — imported
* SplitMyExpenses history, whose repayments happened on a platform we no longer
* run and which therefore has no `split_payments` row here.
*
* A live obligation is NOT settled by flipping this. It is settled by recording
* the payment, and the balance nets to zero on its own. Doing both would
* subtract the settlement twice — the splits leave the sum AND the payment is
* deducted — driving the balance negative by the amount repaid. So there is
* deliberately no "mark settled" action anywhere: settling up is recording a
* payment, and this column is only ever written by the historical import.
*
* **The date is the real guard, and the flag is only a refinement of it.**
* Nothing before SPLIT_CUTOVER can be owed, because carryover transaction 2348
* already carries the entire pre-cutover balance as a single figure. A split on
* an older transaction is therefore free to describe *how an expense was shared*
* — which is what stops it inflating spend — without ever asserting a debt.
*
* That separation is what makes splitting history safe. Before it, the only
* thing keeping $37,233.28 of paid debt out of the balances was a boolean that
* any delete-and-recreate write path silently reset to false. Now losing the
* flag on a pre-cutover row costs nothing: the date still excludes it. The flag
* matters only for rows on or after the cutover, where it marks the handful
* settled outside this app.
*
* The boundary is inclusive because transaction 2348 is itself dated
* 2026-01-09 — an exclusive bound would drop the carryover and with it the
* entire pre-cutover balance.
*
* Assumes the `transaction_splits` alias is `ts` and `transactions` is `t`.
*/
export const SPLIT_CUTOVER = "2026-01-09";
export const ACTIVE_OBLIGATION = `ts.settled = false
AND t.transaction_date >= '${SPLIT_CUTOVER}'`;
/**
* The tab a split belongs to: its transaction's trip, else the household.
*
* Membership already lives on `transaction_overrides.trip_id`, so this is a
* read of existing data rather than a new grouping key. Assumes an
* `transaction_overrides` alias `o` is joined (LEFT — a transaction with no
* override row has no trip, which is the common case and means household).
*/
export const SPLIT_SCOPE = `o.trip_id`;