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
+221 -3
View File
@@ -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<boolean> {
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<number> {
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<number> {
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)]);
}
});
});
});