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:
@@ -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)]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user