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
+44
View File
@@ -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 JanJul 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 |
+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 JanJul 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 -2
View File
@@ -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}
+2 -2
View File
@@ -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}
@@ -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}
+3 -3
View File
@@ -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)
+2 -1
View File
@@ -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}
+2 -2
View File
@@ -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}
+55 -5
View File
@@ -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
* (JanJul 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.
*
* 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 = <participant>`
* 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 = <participant>`,
* `transaction_overrides o` joined on the transaction, and ${STATEMENTS_JOIN}.
*/
export const myShare = (participant = "$1") => `COALESCE(
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") =>
+32 -17
View File
@@ -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,