diff --git a/CLAUDE.md b/CLAUDE.md index e3cbf5c..47c9bc3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 Jan–Jul 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 | diff --git a/src/__tests__/integration/analytics-sql.test.ts b/src/__tests__/integration/analytics-sql.test.ts index e812467..dbedd16 100644 --- a/src/__tests__/integration/analytics-sql.test.ts +++ b/src/__tests__/integration/analytics-sql.test.ts @@ -1,11 +1,17 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeAll } from "vitest"; import { queryRaw, queryRow } from "../../lib/db"; import { EXCLUDE_RECONCILED_SOURCE, NATIVE_CURRENCY, AMOUNT_UNCONVERTED, INVESTMENT_SIGNED, + MY_SPEND_SCOPE, + NET_SPEND_ROWS, + SPEND_SIGNED, + myShare, + mySplitOf, } from "../../lib/analytics-sql"; +import { MY_SHARE_PCT } from "../../lib/queries"; /** * These fragments are the ones that drifted. @@ -103,9 +109,17 @@ describe("NATIVE_CURRENCY", () => { // Opposite convention: on an AUD statement, `amount` is AUD and // foreign_currency_code merely notes what was originally charged. Reading // the foreign code here would mislabel an AUD row as USD. + // owner_id is NOT NULL DEFAULT 1 with an FK to participants, so this insert + // relied on a participant with id 1 already existing — true only when a + // sibling test file had seeded one and left it behind. On a freshly reset + // database it failed the FK. Own the fixture instead of inheriting it. + const owner = await queryRow<{ id: number }>( + `INSERT INTO participants (name) VALUES ('Analytics fixture — owner') RETURNING id` + ); const stmt = await queryRow<{ id: number }>( - `INSERT INTO statements (bank_name, account_number, billing_end_date, currency, filename) - VALUES ('Analytics Fixture Bank','0000','2026-03-31','AUD','analytics-fixture.pdf') RETURNING id` + `INSERT INTO statements (bank_name, account_number, billing_end_date, currency, filename, owner_id) + VALUES ('Analytics Fixture Bank','0000','2026-03-31','AUD','analytics-fixture.pdf', $1) RETURNING id`, + [owner!.id] ); const id = await scratchTxn( "transaction_date, description, amount, amount_aud, transaction_type, statement_id, foreign_currency_amount, foreign_currency_code", @@ -183,3 +197,207 @@ describe("INVESTMENT_SIGNED", () => { expect(await signedValue(id)).toBe(-1500); }); }); + +/** + * Whose expense is it — the gate that decides, and the share that scales it. + * + * Both fragments changed together on 2026-08-15 and neither is safe alone. + * `OWNER_SCOPE = $1` was the gate on every spend analytic, with `mySplitOf` + * scaling within it, so a row someone else paid for contributed nothing to my + * spend no matter what share I held — 167 rows / $3,210.91 of my own share + * missing from Jan–Jul 2026, while the balance queries booked the debt + * correctly. Widening the gate without also fixing `myShare` is worse than + * leaving it: the old remainder fallback hands the viewer the PAYER's share on + * a row with no split of theirs, which on an unsplit row is the full amount. + * + * So the discriminating cases here are all ones where I hold NO split row on + * someone else's transaction. That is where old and new disagree, and it is the + * only place the widened gate could do damage. + */ +describe("MY_SPEND_SCOPE / myShare", () => { + let me = 0; + let them = 0; + let third = 0; + + beforeAll(async () => { + const mk = async (name: string) => + (await queryRow<{ id: number }>( + `INSERT INTO participants (name) VALUES ($1) RETURNING id`, + [name] + ))!.id; + me = await mk("Share fixture — viewer"); + them = await mk("Share fixture — payer"); + third = await mk("Share fixture — third party"); + }); + + /** A transaction owned by `owner`, optionally split, optionally overridden. */ + async function fixture( + label: string, + owner: number, + splits: [number, number][] = [], + myShareOverride?: number + ) { + const id = await scratchTxn( + "transaction_date, description, amount, transaction_type, category, owner_id", + `'2026-06-01', $1, 100.00, 'debit', 'groceries', $2`, + [`Share fixture — ${label}`, owner] + ); + for (const [pid, pct] of splits) { + await queryRaw( + `INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) + VALUES ($1, $2, $3)`, + [id, pid, pct] + ); + } + if (myShareOverride !== undefined) { + await queryRaw( + `INSERT INTO transaction_overrides (transaction_id, my_share_percent) VALUES ($1, $2)`, + [id, myShareOverride] + ); + } + return id; + } + + const JOINS = ` + FROM transactions t + LEFT JOIN statements s ON s.id = t.statement_id + LEFT JOIN transaction_overrides o ON o.transaction_id = t.id + LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1`; + + async function inScope(id: number, viewer: number): Promise { + const rows = await queryRaw( + `SELECT t.id ${JOINS} WHERE t.id = $2 AND ${MY_SPEND_SCOPE()}`, + [viewer, id] + ); + return rows.length === 1; + } + + async function share(id: number, viewer: number): Promise { + const row = await queryRow<{ v: string }>( + `SELECT (${myShare()})::numeric AS v ${JOINS} WHERE t.id = $2`, + [viewer, id] + ); + return Number(row!.v); + } + + /** What this row contributes to the viewer's net spend. */ + async function spend(id: number, viewer: number): Promise { + const row = await queryRow<{ v: string | null }>( + `SELECT SUM(${mySplitOf(SPEND_SIGNED)})::numeric(14,4) AS v ${JOINS} + WHERE t.id = $2 AND ${MY_SPEND_SCOPE()} AND ${NET_SPEND_ROWS}`, + [viewer, id] + ); + return Number(row?.v ?? 0); + } + + describe("the gate", () => { + it("admits a row someone else paid for that I hold a split on", async () => { + const id = await fixture("their shop, my half", them, [[me, 50], [them, 50]]); + expect(await inScope(id, me)).toBe(true); + }); + + it("still admits a row I paid for with no splits at all", async () => { + const id = await fixture("mine alone", me); + expect(await inScope(id, me)).toBe(true); + }); + + it("rejects a row someone else paid for that I have no split on", async () => { + const id = await fixture("not mine", them, [[them, 60], [third, 40]]); + expect(await inScope(id, me)).toBe(false); + }); + }); + + describe("my share of someone else's row", () => { + it("is my split percent", async () => { + const id = await fixture("their shop, my quarter", them, [[me, 25], [them, 75]]); + expect(await share(id, me)).toBe(25); + }); + + it("is 0 with no split of mine — NOT the payer's remainder", async () => { + // The load-bearing case. An unsplit row someone else paid for has no + // `ts.share_percent`, so the old `100 - everyone else` fallback returned + // 100 and would have moved their entire grocery bill onto my spend the + // moment the gate widened. + const id = await fixture("entirely theirs", them); + expect(await share(id, me)).toBe(0); + expect(await spend(id, me)).toBe(0); + }); + + it("ignores my_share_percent, which can only mean the owner's share", async () => { + // One unscoped column per transaction, writable by anyone who can see the + // row. Reading it as the viewer's share on someone else's transaction + // would be a guess. + const id = await fixture("their row, their override", them, [], 30); + expect(await share(id, me)).toBe(0); + }); + }); + + describe("my share of my own row is unchanged", () => { + it("prefers an explicit split row for me", async () => { + const id = await fixture("mine, split explicitly", me, [[me, 35], [them, 65]]); + expect(await share(id, me)).toBe(35); + }); + + it("falls back to the my_share_percent override", async () => { + const id = await fixture("mine, overridden", me, [], 80); + expect(await share(id, me)).toBe(80); + }); + + it("falls back to whatever is left after everyone else", async () => { + const id = await fixture("mine, remainder", me, [[them, 40]]); + expect(await share(id, me)).toBe(60); + }); + + it("is 0 when a row I paid for is allocated entirely to someone else", async () => { + const id = await fixture("mine, all theirs", me, [[them, 100]]); + expect(await share(id, me)).toBe(0); + }); + }); + + describe("the regression it was written for", () => { + it("counts my half of a shop the other person paid for", async () => { + const id = await fixture("the grocery shop", them, [[me, 50], [them, 50]]); + expect(await spend(id, me)).toBe(50); + }); + + it("still counts my half of the same shop when I paid", async () => { + const id = await fixture("the same shop, my card", me, [[me, 50], [them, 50]]); + expect(await spend(id, me)).toBe(50); + }); + + it("counts my share whether or not the split is settled", async () => { + // `myShare` must not filter on `settled` — half a shop was my expense + // whether or not the other half was repaid. Same rule, payer-side. + const id = await fixture("settled shop", them, [[me, 50], [them, 50]]); + await queryRaw(`UPDATE transaction_splits SET settled = true WHERE transaction_id = $1`, [id]); + expect(await spend(id, me)).toBe(50); + }); + + it("never counts one row for both people at full value", async () => { + const id = await fixture("one shop, two people", them, [[me, 50], [them, 50]]); + expect(await spend(id, me) + await spend(id, them)).toBe(100); + }); + }); + + describe("the transactions-list mirror agrees", () => { + it("MY_SHARE_PCT matches myShare on every fixture shape", async () => { + const shapes: [string, number, [number, number][], number | undefined][] = [ + ["mirror A", them, [[me, 25], [them, 75]], undefined], + ["mirror B", them, [], undefined], + ["mirror C", them, [], 30], + ["mirror D", me, [[me, 35], [them, 65]], undefined], + ["mirror E", me, [], 80], + ["mirror F", me, [[them, 40]], undefined], + ["mirror G", me, [], undefined], + ]; + for (const [label, owner, splits, override] of shapes) { + const id = await fixture(label, owner, splits, override); + const mirrored = await queryRow<{ v: string }>( + `SELECT (${MY_SHARE_PCT})::numeric AS v ${JOINS} WHERE t.id = $2`, + [me, id] + ); + expect([label, Number(mirrored!.v)]).toEqual([label, await share(id, me)]); + } + }); + }); +}); diff --git a/src/app/api/analytics/daily/route.ts b/src/app/api/analytics/daily/route.ts index 4cb2225..38d8b43 100644 --- a/src/app/api/analytics/daily/route.ts +++ b/src/app/api/analytics/daily/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCurrentUser } from "@/lib/auth"; import { queryRaw } from "@/lib/db"; import { - OWNER_SCOPE, + MY_SPEND_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND, EXCLUDE_RECONCILED_SOURCE, @@ -57,7 +57,7 @@ export async function GET(req: NextRequest) { LEFT JOIN transaction_overrides o ON o.transaction_id = t.id LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1 ${STATEMENTS_JOIN} - WHERE ${OWNER_SCOPE} = $1 + WHERE ${MY_SPEND_SCOPE()} AND ${NET_SPEND_ROWS} AND ${EXCLUDE_NON_SPEND} AND ${EXCLUDE_RECONCILED_SOURCE} diff --git a/src/app/api/analytics/fees/route.ts b/src/app/api/analytics/fees/route.ts index 06ad48b..ac3be86 100644 --- a/src/app/api/analytics/fees/route.ts +++ b/src/app/api/analytics/fees/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCurrentUser } from "@/lib/auth"; import { queryRaw } from "@/lib/db"; -import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_RECONCILED_SOURCE, mySplitOf, toDateStr } from "@/lib/analytics-sql"; +import { MY_SPEND_SCOPE, STATEMENTS_JOIN, EXCLUDE_RECONCILED_SOURCE, mySplitOf, toDateStr } from "@/lib/analytics-sql"; /** * Fees and interest over an explicit window. @@ -71,7 +71,7 @@ export async function GET(req: NextRequest) { LEFT JOIN transaction_overrides o ON o.transaction_id = t.id LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1 ${STATEMENTS_JOIN} - WHERE ${OWNER_SCOPE} = $1 + WHERE ${MY_SPEND_SCOPE()} AND t.transaction_type IN ('fee', 'interest') AND ${EXCLUDE_RECONCILED_SOURCE} ${txnWindow} diff --git a/src/app/api/analytics/merchants/[merchant]/route.ts b/src/app/api/analytics/merchants/[merchant]/route.ts index c1b734f..565148f 100644 --- a/src/app/api/analytics/merchants/[merchant]/route.ts +++ b/src/app/api/analytics/merchants/[merchant]/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCurrentUser } from "@/lib/auth"; import { queryRaw } from "@/lib/db"; -import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_RECONCILED_SOURCE, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql"; +import { MY_SPEND_SCOPE, STATEMENTS_JOIN, EXCLUDE_RECONCILED_SOURCE, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql"; import { bankLabel } from "@/lib/queries"; const MY_AMOUNT = mySplitOf(`COALESCE(t.amount_aud, t.amount)`); @@ -46,7 +46,7 @@ export async function GET( ${STATEMENTS_JOIN} LEFT JOIN transaction_overrides o ON o.transaction_id = t.id LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1 - WHERE ${OWNER_SCOPE} = $1 + WHERE ${MY_SPEND_SCOPE()} AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit') AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = $2 AND ${EXCLUDE_RECONCILED_SOURCE} diff --git a/src/app/api/analytics/merchants/route.ts b/src/app/api/analytics/merchants/route.ts index 6137e31..e13ecec 100644 --- a/src/app/api/analytics/merchants/route.ts +++ b/src/app/api/analytics/merchants/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCurrentUser } from "@/lib/auth"; import { queryRaw } from "@/lib/db"; -import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND, EXCLUDE_RECONCILED_SOURCE, EFFECTIVE_CATEGORY, mySplitOf, toDateStr } from "@/lib/analytics-sql"; +import { MY_SPEND_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND, EXCLUDE_RECONCILED_SOURCE, EFFECTIVE_CATEGORY, mySplitOf, toDateStr } from "@/lib/analytics-sql"; // Split-adjusted amount helper (positive for spend, negative for refunds) const MY_AMOUNT = mySplitOf(`COALESCE(t.amount_aud, t.amount)`); @@ -65,7 +65,7 @@ export async function GET(req: NextRequest) { ${STATEMENTS_JOIN} LEFT JOIN transaction_overrides o ON o.transaction_id = t.id LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1 - WHERE ${OWNER_SCOPE} = $1 + WHERE ${MY_SPEND_SCOPE()} AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit') AND t.transaction_date >= $2 AND ${EXCLUDE_NON_SPEND} @@ -91,7 +91,7 @@ export async function GET(req: NextRequest) { ${STATEMENTS_JOIN} LEFT JOIN transaction_overrides o ON o.transaction_id = t.id LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1 - WHERE ${OWNER_SCOPE} = $1 + WHERE ${MY_SPEND_SCOPE()} AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit') AND t.transaction_date >= $2 AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = ANY($3) diff --git a/src/app/api/analytics/monthly/route.ts b/src/app/api/analytics/monthly/route.ts index d24a193..57729e6 100644 --- a/src/app/api/analytics/monthly/route.ts +++ b/src/app/api/analytics/monthly/route.ts @@ -3,6 +3,7 @@ import { getCurrentUser } from "@/lib/auth"; import { queryRaw } from "@/lib/db"; import { OWNER_SCOPE, + MY_SPEND_SCOPE, STATEMENTS_JOIN, EFFECTIVE_CATEGORY, EXCLUDE_NON_SPEND, @@ -46,7 +47,7 @@ export async function GET(req: NextRequest) { LEFT JOIN transaction_overrides o ON o.transaction_id = t.id LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1 ${STATEMENTS_JOIN} - WHERE ${OWNER_SCOPE} = $1 + WHERE ${MY_SPEND_SCOPE()} AND ${NET_SPEND_ROWS} AND ${EXCLUDE_NON_SPEND} AND ${EXCLUDE_RECONCILED_SOURCE} diff --git a/src/app/api/analytics/subscriptions/route.ts b/src/app/api/analytics/subscriptions/route.ts index 46ab938..4580ea4 100644 --- a/src/app/api/analytics/subscriptions/route.ts +++ b/src/app/api/analytics/subscriptions/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCurrentUser } from "@/lib/auth"; import { queryRaw } from "@/lib/db"; -import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND, EXCLUDE_RECONCILED_SOURCE, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql"; +import { MY_SPEND_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND, EXCLUDE_RECONCILED_SOURCE, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql"; export async function GET(req: NextRequest) { const user = await getCurrentUser(req); @@ -28,7 +28,7 @@ export async function GET(req: NextRequest) { LEFT JOIN transaction_overrides o ON o.transaction_id = t.id LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1 ${STATEMENTS_JOIN} - WHERE ${OWNER_SCOPE} = $1 + WHERE ${MY_SPEND_SCOPE()} AND t.transaction_type IN ('debit', 'fee') AND ${EXCLUDE_NON_SPEND} AND ${EXCLUDE_RECONCILED_SOURCE} diff --git a/src/lib/analytics-sql.ts b/src/lib/analytics-sql.ts index c501219..ce70069 100644 --- a/src/lib/analytics-sql.ts +++ b/src/lib/analytics-sql.ts @@ -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 = + * ` 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 = ` - * 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 = `, + * `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") => diff --git a/src/lib/queries.ts b/src/lib/queries.ts index a4f0384..6fffce4 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -160,6 +160,30 @@ interface TransactionFilters { trip_all_rows?: boolean; } +/** + * `myShare` from analytics-sql.ts, in subselect form. + * + * Same resolution and the same ownership branch, spelled out inline because + * this query has no `transaction_splits ts` join scoped to the viewer — it + * already aggregates every participant's splits for display, and adding a + * second viewer-scoped join purely to reuse the fragment would be the more + * confusing of the two. Kept adjacent to its twin so a change to one is an + * obvious prompt to change the other; the mirror is covered by a test that + * asserts the two agree row for row. + */ +export const MY_SHARE_PCT = `CASE + WHEN COALESCE(t.owner_id, s.owner_id) = $1 THEN COALESCE( + (SELECT x.share_percent FROM transaction_splits x + WHERE x.transaction_id = t.id AND x.participant_id = $1), + 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 <> $1), 0) + ) + ELSE COALESCE( + (SELECT x.share_percent FROM transaction_splits x + WHERE x.transaction_id = t.id AND x.participant_id = $1), 0) +END`; + export async function getTransactions(ownerId: number, filters: TransactionFilters) { // A real trip id, not "unassigned" — there is no trip to participate in for // rows that belong to none, so the owner scoping has to stand there. @@ -297,23 +321,14 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte ${NATIVE_CURRENCY} as currency, ${AMOUNT_UNCONVERTED} as amount_unconverted, -- My share, resolved the same way analytics does it (see myShare in - -- analytics-sql.ts): explicit split row, then override, then whatever is - -- left after everyone else. Computed here so the UI cannot drift from - -- the totals it is drilling into. - COALESCE( - (SELECT x.share_percent FROM transaction_splits x - WHERE x.transaction_id = t.id AND x.participant_id = $1), - 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 <> $1), 0) - )::numeric(5,2) as my_share_pct, - (COALESCE(t.amount_aud, t.amount) * COALESCE( - (SELECT x.share_percent FROM transaction_splits x - WHERE x.transaction_id = t.id AND x.participant_id = $1), - 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 <> $1), 0) - ) / 100)::numeric(12,2) as my_amount, + -- analytics-sql.ts): on a row I paid for, explicit split row, then + -- override, then whatever is left after everyone else; on a row someone + -- else paid for, only an explicit split row, absent meaning 0. Computed + -- here so the UI cannot drift from the totals it is drilling into — this + -- list shows rows I merely hold a split on, and before MY_SPEND_SCOPE + -- those rows read as a share of a total that excluded them entirely. + ${MY_SHARE_PCT}::numeric(5,2) as my_share_pct, + (COALESCE(t.amount_aud, t.amount) * ${MY_SHARE_PCT} / 100)::numeric(12,2) as my_amount, COALESCE(t.owner_id, s.owner_id) as owner_id, p.name as owner_name, COALESCE(src.created_at, t.created_at) as created_at,