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:
@@ -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
|
Use the `EXCLUDE_NON_SPEND` fragment: a bare `category NOT IN (...)` evaluates
|
||||||
to NULL for uncategorised rows and drops them from totals.
|
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
|
### Statement types
|
||||||
|
|
||||||
`statements.statement_type` is constrained to `credit_card | transaction |
|
`statements.statement_type` is constrained to `credit_card | transaction |
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, beforeAll } from "vitest";
|
||||||
import { queryRaw, queryRow } from "../../lib/db";
|
import { queryRaw, queryRow } from "../../lib/db";
|
||||||
import {
|
import {
|
||||||
EXCLUDE_RECONCILED_SOURCE,
|
EXCLUDE_RECONCILED_SOURCE,
|
||||||
NATIVE_CURRENCY,
|
NATIVE_CURRENCY,
|
||||||
AMOUNT_UNCONVERTED,
|
AMOUNT_UNCONVERTED,
|
||||||
INVESTMENT_SIGNED,
|
INVESTMENT_SIGNED,
|
||||||
|
MY_SPEND_SCOPE,
|
||||||
|
NET_SPEND_ROWS,
|
||||||
|
SPEND_SIGNED,
|
||||||
|
myShare,
|
||||||
|
mySplitOf,
|
||||||
} from "../../lib/analytics-sql";
|
} from "../../lib/analytics-sql";
|
||||||
|
import { MY_SHARE_PCT } from "../../lib/queries";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* These fragments are the ones that drifted.
|
* These fragments are the ones that drifted.
|
||||||
@@ -103,9 +109,17 @@ describe("NATIVE_CURRENCY", () => {
|
|||||||
// Opposite convention: on an AUD statement, `amount` is AUD and
|
// Opposite convention: on an AUD statement, `amount` is AUD and
|
||||||
// foreign_currency_code merely notes what was originally charged. Reading
|
// foreign_currency_code merely notes what was originally charged. Reading
|
||||||
// the foreign code here would mislabel an AUD row as USD.
|
// 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 }>(
|
const stmt = await queryRow<{ id: number }>(
|
||||||
`INSERT INTO statements (bank_name, account_number, billing_end_date, currency, filename)
|
`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') RETURNING id`
|
VALUES ('Analytics Fixture Bank','0000','2026-03-31','AUD','analytics-fixture.pdf', $1) RETURNING id`,
|
||||||
|
[owner!.id]
|
||||||
);
|
);
|
||||||
const id = await scratchTxn(
|
const id = await scratchTxn(
|
||||||
"transaction_date, description, amount, amount_aud, transaction_type, statement_id, foreign_currency_amount, foreign_currency_code",
|
"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);
|
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)]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { getCurrentUser } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
import { queryRaw } from "@/lib/db";
|
import { queryRaw } from "@/lib/db";
|
||||||
import {
|
import {
|
||||||
OWNER_SCOPE,
|
MY_SPEND_SCOPE,
|
||||||
STATEMENTS_JOIN,
|
STATEMENTS_JOIN,
|
||||||
EXCLUDE_NON_SPEND,
|
EXCLUDE_NON_SPEND,
|
||||||
EXCLUDE_RECONCILED_SOURCE,
|
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_overrides o ON o.transaction_id = t.id
|
||||||
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
||||||
${STATEMENTS_JOIN}
|
${STATEMENTS_JOIN}
|
||||||
WHERE ${OWNER_SCOPE} = $1
|
WHERE ${MY_SPEND_SCOPE()}
|
||||||
AND ${NET_SPEND_ROWS}
|
AND ${NET_SPEND_ROWS}
|
||||||
AND ${EXCLUDE_NON_SPEND}
|
AND ${EXCLUDE_NON_SPEND}
|
||||||
AND ${EXCLUDE_RECONCILED_SOURCE}
|
AND ${EXCLUDE_RECONCILED_SOURCE}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getCurrentUser } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
import { queryRaw } from "@/lib/db";
|
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.
|
* 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_overrides o ON o.transaction_id = t.id
|
||||||
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
||||||
${STATEMENTS_JOIN}
|
${STATEMENTS_JOIN}
|
||||||
WHERE ${OWNER_SCOPE} = $1
|
WHERE ${MY_SPEND_SCOPE()}
|
||||||
AND t.transaction_type IN ('fee', 'interest')
|
AND t.transaction_type IN ('fee', 'interest')
|
||||||
AND ${EXCLUDE_RECONCILED_SOURCE}
|
AND ${EXCLUDE_RECONCILED_SOURCE}
|
||||||
${txnWindow}
|
${txnWindow}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getCurrentUser } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
import { queryRaw } from "@/lib/db";
|
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";
|
import { bankLabel } from "@/lib/queries";
|
||||||
|
|
||||||
const MY_AMOUNT = mySplitOf(`COALESCE(t.amount_aud, t.amount)`);
|
const MY_AMOUNT = mySplitOf(`COALESCE(t.amount_aud, t.amount)`);
|
||||||
@@ -46,7 +46,7 @@ export async function GET(
|
|||||||
${STATEMENTS_JOIN}
|
${STATEMENTS_JOIN}
|
||||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.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
|
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_type IN ('debit', 'fee', 'interest', 'refund', 'credit')
|
||||||
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = $2
|
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = $2
|
||||||
AND ${EXCLUDE_RECONCILED_SOURCE}
|
AND ${EXCLUDE_RECONCILED_SOURCE}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getCurrentUser } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
import { queryRaw } from "@/lib/db";
|
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)
|
// Split-adjusted amount helper (positive for spend, negative for refunds)
|
||||||
const MY_AMOUNT = mySplitOf(`COALESCE(t.amount_aud, t.amount)`);
|
const MY_AMOUNT = mySplitOf(`COALESCE(t.amount_aud, t.amount)`);
|
||||||
@@ -65,7 +65,7 @@ export async function GET(req: NextRequest) {
|
|||||||
${STATEMENTS_JOIN}
|
${STATEMENTS_JOIN}
|
||||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.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
|
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_type IN ('debit', 'fee', 'interest', 'refund', 'credit')
|
||||||
AND t.transaction_date >= $2
|
AND t.transaction_date >= $2
|
||||||
AND ${EXCLUDE_NON_SPEND}
|
AND ${EXCLUDE_NON_SPEND}
|
||||||
@@ -91,7 +91,7 @@ export async function GET(req: NextRequest) {
|
|||||||
${STATEMENTS_JOIN}
|
${STATEMENTS_JOIN}
|
||||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.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
|
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_type IN ('debit', 'fee', 'interest', 'refund', 'credit')
|
||||||
AND t.transaction_date >= $2
|
AND t.transaction_date >= $2
|
||||||
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = ANY($3)
|
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = ANY($3)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getCurrentUser } from "@/lib/auth";
|
|||||||
import { queryRaw } from "@/lib/db";
|
import { queryRaw } from "@/lib/db";
|
||||||
import {
|
import {
|
||||||
OWNER_SCOPE,
|
OWNER_SCOPE,
|
||||||
|
MY_SPEND_SCOPE,
|
||||||
STATEMENTS_JOIN,
|
STATEMENTS_JOIN,
|
||||||
EFFECTIVE_CATEGORY,
|
EFFECTIVE_CATEGORY,
|
||||||
EXCLUDE_NON_SPEND,
|
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_overrides o ON o.transaction_id = t.id
|
||||||
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
||||||
${STATEMENTS_JOIN}
|
${STATEMENTS_JOIN}
|
||||||
WHERE ${OWNER_SCOPE} = $1
|
WHERE ${MY_SPEND_SCOPE()}
|
||||||
AND ${NET_SPEND_ROWS}
|
AND ${NET_SPEND_ROWS}
|
||||||
AND ${EXCLUDE_NON_SPEND}
|
AND ${EXCLUDE_NON_SPEND}
|
||||||
AND ${EXCLUDE_RECONCILED_SOURCE}
|
AND ${EXCLUDE_RECONCILED_SOURCE}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getCurrentUser } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
import { queryRaw } from "@/lib/db";
|
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) {
|
export async function GET(req: NextRequest) {
|
||||||
const user = await getCurrentUser(req);
|
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_overrides o ON o.transaction_id = t.id
|
||||||
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
||||||
${STATEMENTS_JOIN}
|
${STATEMENTS_JOIN}
|
||||||
WHERE ${OWNER_SCOPE} = $1
|
WHERE ${MY_SPEND_SCOPE()}
|
||||||
AND t.transaction_type IN ('debit', 'fee')
|
AND t.transaction_type IN ('debit', 'fee')
|
||||||
AND ${EXCLUDE_NON_SPEND}
|
AND ${EXCLUDE_NON_SPEND}
|
||||||
AND ${EXCLUDE_RECONCILED_SOURCE}
|
AND ${EXCLUDE_RECONCILED_SOURCE}
|
||||||
|
|||||||
+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. */
|
/** Effective category, honouring overrides. Never NULL. */
|
||||||
export const EFFECTIVE_CATEGORY = `COALESCE(o.category_override, t.category, 'other')`;
|
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.
|
* 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.
|
* 1. An explicit `transaction_splits` row for me.
|
||||||
* 2. The `my_share_percent` override.
|
* 2. The `my_share_percent` override.
|
||||||
* 3. Whatever is left after everyone else's shares.
|
* 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
|
* 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.
|
* otherwise land in my spend at full value.
|
||||||
*
|
*
|
||||||
* Requires `transaction_splits ts` joined on `ts.participant_id = <participant>`
|
* On a row SOMEONE ELSE paid for, only an explicit split row counts, and its
|
||||||
* and `transaction_overrides o` joined on the transaction.
|
* 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(
|
export const myShare = (participant = "$1") => `CASE
|
||||||
ts.share_percent,
|
WHEN ${OWNER_SCOPE} = ${participant} THEN COALESCE(
|
||||||
o.my_share_percent,
|
ts.share_percent,
|
||||||
100 - COALESCE((
|
o.my_share_percent,
|
||||||
SELECT SUM(x.share_percent) FROM transaction_splits x
|
100 - COALESCE((
|
||||||
WHERE x.transaction_id = t.id AND x.participant_id <> ${participant}
|
SELECT SUM(x.share_percent) FROM transaction_splits x
|
||||||
), 0)
|
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. */
|
/** `base` scaled to my share. Use for every per-user spend total. */
|
||||||
export const mySplitOf = (base: string, participant = "$1") =>
|
export const mySplitOf = (base: string, participant = "$1") =>
|
||||||
|
|||||||
+32
-17
@@ -160,6 +160,30 @@ interface TransactionFilters {
|
|||||||
trip_all_rows?: boolean;
|
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) {
|
export async function getTransactions(ownerId: number, filters: TransactionFilters) {
|
||||||
// A real trip id, not "unassigned" — there is no trip to participate in for
|
// 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.
|
// 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,
|
${NATIVE_CURRENCY} as currency,
|
||||||
${AMOUNT_UNCONVERTED} as amount_unconverted,
|
${AMOUNT_UNCONVERTED} as amount_unconverted,
|
||||||
-- My share, resolved the same way analytics does it (see myShare in
|
-- My share, resolved the same way analytics does it (see myShare in
|
||||||
-- analytics-sql.ts): explicit split row, then override, then whatever is
|
-- analytics-sql.ts): on a row I paid for, explicit split row, then
|
||||||
-- left after everyone else. Computed here so the UI cannot drift from
|
-- override, then whatever is left after everyone else; on a row someone
|
||||||
-- the totals it is drilling into.
|
-- else paid for, only an explicit split row, absent meaning 0. Computed
|
||||||
COALESCE(
|
-- here so the UI cannot drift from the totals it is drilling into — this
|
||||||
(SELECT x.share_percent FROM transaction_splits x
|
-- list shows rows I merely hold a split on, and before MY_SPEND_SCOPE
|
||||||
WHERE x.transaction_id = t.id AND x.participant_id = $1),
|
-- those rows read as a share of a total that excluded them entirely.
|
||||||
o.my_share_percent,
|
${MY_SHARE_PCT}::numeric(5,2) as my_share_pct,
|
||||||
100 - COALESCE((SELECT SUM(x.share_percent) FROM transaction_splits x
|
(COALESCE(t.amount_aud, t.amount) * ${MY_SHARE_PCT} / 100)::numeric(12,2) as my_amount,
|
||||||
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,
|
|
||||||
COALESCE(t.owner_id, s.owner_id) as owner_id,
|
COALESCE(t.owner_id, s.owner_id) as owner_id,
|
||||||
p.name as owner_name,
|
p.name as owner_name,
|
||||||
COALESCE(src.created_at, t.created_at) as created_at,
|
COALESCE(src.created_at, t.created_at) as created_at,
|
||||||
|
|||||||
Reference in New Issue
Block a user