Files
finance-app/src/__tests__/integration/transaction-owner.test.ts
T
siddharthd 2cfaf8e8c8
ci / lint-test (push) Successful in 42s
statements: derive account identity, fix the new-bank alert (0033)
account_number fragments exactly like bank_name did, from the same cause:
ANZ's Access Advantage arrived as both '4085-56264' (4 statements) and
'408556264' (1). uq_statement_identity keyed on it, so re-importing one
period under the other spelling evaded the duplicate check.

account_number_key is GENERATED ALWAYS — the number with separators
stripped — and the unique index moves onto it. Derived rather than
rewritten because the raw number is the readable one and some of it is
structure: Up stores '633-123 / 176540052', a BSB and an account number,
and flattening it would lose a distinction a human reads at a glance to
fix a machine problem. The Amex mask and case are preserved; 'X' records
which digits were redacted.

Not the cause of the documented 31-row / $42,040.68 ANZ duplication —
107/142/143 overlap on different end dates, which that index cannot catch
at any spelling.

Adds statement_identity_drift: one account under two bank names, or one
bank under two spellings of one number. Non-empty means fragmentation the
normaliser could not know about. Currently one row, the ANZ pair above,
now unified by key.

Also documents the N8N regression this series caused. Check Known Bank
compared the raw Gemini name against the canonical column before the
insert, so nothing ever matched and every Zip statement was tagged
pending and held for Slack approval — a stall, not just a noisy alert.
Fixed in workflow FysADdFwEtwONQl4 by comparing like with like through
normalize_bank_name(), passing currency so a genuinely new national
entity still alerts. Verified live: 12s after vs 30-53s before.
2026-08-15 17:03:13 +10:00

275 lines
12 KiB
TypeScript

import { describe, it, expect, beforeAll } from "vitest";
import type { NextRequest } from "next/server";
import { queryRaw, queryRow } from "../../lib/db";
/**
* Changing who paid.
*
* Owner was write-once for every ingestion path — a pantry receipt hardcodes
* DEFAULT_OWNER_ID, and no route, modal or bulk action could correct it, so a
* shop the other person paid for was permanently filed as yours. There was not
* a single `UPDATE ... SET owner_id` anywhere in src/.
*
* Two things make this more than a field edit, and both are tested here:
*
* 1. **It is a one-way door.** Access is "owner OR holds a split"
* (canAccessTransactions), so handing a row over while holding no split on
* it removes it from your list and 404s every route that could put it back.
* Only the new owner can undo it.
* 2. **It moves money between two ledgers.** Splits record shares, not
* direction — getParticipantBalances derives who owes whom from ownership —
* so the same 50/50 rows flip sides untouched.
*/
type Ctx = { params: Promise<{ id: string }> };
type Handler = (req: NextRequest, ctx: Ctx) => Promise<Response>;
let txPATCH: Handler;
let stmtPATCH: Handler;
/** Enough of a NextRequest for these routes: the auth header and the body. */
const req = (email: string | null, body: unknown) =>
({
headers: { get: (h: string) => (h.toLowerCase() === "x-forwarded-user" ? email : null) },
json: async () => body,
}) as unknown as NextRequest;
const ctx = (id: number) => ({ params: Promise.resolve({ id: String(id) }) });
let me = 0;
let them = 0;
let meEmail = "";
let themEmail = "";
beforeAll(async () => {
({ PATCH: txPATCH } = await import("../../app/api/transactions/[id]/route"));
({ PATCH: stmtPATCH } = await import("../../app/api/statements/[id]/route"));
// Fixtures are named, not random, so a re-run is idempotent. `participants`
// has a unique email and no cascade from `transactions.owner_id`, so the
// teardown order is rows → statements → people.
await queryRaw(`DELETE FROM transactions WHERE description LIKE 'Owner fixture — %'`);
await queryRaw(`DELETE FROM statements WHERE bank_name = 'Owner Fixture Bank'`);
await queryRaw(`DELETE FROM participants WHERE email LIKE 'owner-fixture-%@example.test'`);
const mk = async (label: string) => {
const email = `owner-fixture-${label}@example.test`;
const row = await queryRow<{ id: number }>(
`INSERT INTO participants (name, email) VALUES ($1, $2) RETURNING id`,
[`Owner fixture — ${label}`, email]
);
return { id: row!.id, email };
};
const a = await mk("me");
const b = await mk("them");
me = a.id;
meEmail = a.email;
them = b.id;
themEmail = b.email;
});
/** A manual transaction owned by `owner`, with optional splits. */
async function manualTxn(label: string, owner: number, splits: [number, number][] = []) {
const row = await queryRow<{ id: number }>(
`INSERT INTO transactions (transaction_date, description, amount, transaction_type, category, owner_id)
VALUES ('2026-06-01', $1, 100.00, 'debit', 'groceries', $2) RETURNING id`,
[`Owner 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)`,
[row!.id, pid, pct]
);
}
return row!.id;
}
const ownerOf = async (id: number) =>
(await queryRow<{ owner_id: number | null }>(`SELECT owner_id FROM transactions WHERE id = $1`, [id]))!
.owner_id;
describe("PATCH /api/transactions/[id] — owner", () => {
it("reassigns a manual row when I hold a split on it", async () => {
const id = await manualTxn("shared shop", me, [[me, 50], [them, 50]]);
const res = await txPATCH(req(meEmail, { owner_id: them }), ctx(id));
expect(res.status).toBe(200);
expect(await ownerOf(id)).toBe(them);
});
it("refuses to hand away a row I hold no split on", async () => {
const id = await manualTxn("nothing of mine", me);
const res = await txPATCH(req(meEmail, { owner_id: them }), ctx(id));
expect(res.status).toBe(409);
expect((await res.json()).code).toBe("would_lose_access");
// Unchanged — a refusal must not half-apply.
expect(await ownerOf(id)).toBe(me);
});
it("hands it away when the release is explicit", async () => {
const id = await manualTxn("given away", me);
const res = await txPATCH(req(meEmail, { owner_id: them, release: true }), ctx(id));
expect(res.status).toBe(200);
expect(await ownerOf(id)).toBe(them);
});
it("never blocks taking onto my ledger a row I can already see", async () => {
// No `release` needed: I end up the owner, so there is no door to close
// behind me. The split is what lets me see it in the first place.
const id = await manualTxn("taken on", them, [[me, 50], [them, 50]]);
const res = await txPATCH(req(meEmail, { owner_id: me }), ctx(id));
expect(res.status).toBe(200);
expect(await ownerOf(id)).toBe(me);
});
it("cannot claim a row I cannot see", async () => {
// Their transaction, no split of mine — canAccessTransactions rejects
// before any owner logic runs. Reassignment is a correction to a row you
// already have, never a way to reach one you do not.
const id = await manualTxn("out of reach", them);
const res = await txPATCH(req(meEmail, { owner_id: me }), ctx(id));
expect(res.status).toBe(404);
expect(await ownerOf(id)).toBe(them);
});
it("refuses a statement row and says where to change it", async () => {
const stmt = await queryRow<{ id: number }>(
`INSERT INTO statements (bank_name, account_number, billing_end_date, currency, filename, owner_id)
VALUES ('Owner Fixture Bank','1111','2026-06-30','AUD','owner-fixture-1.pdf',$1) RETURNING id`,
[me]
);
const row = await queryRow<{ id: number }>(
`INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type)
VALUES ($1,'2026-06-02','Owner fixture — statement row', 40.00, 'debit') RETURNING id`,
[stmt!.id]
);
const res = await txPATCH(req(meEmail, { owner_id: them }), ctx(row!.id));
expect(res.status).toBe(400);
expect((await res.json()).code).toBe("statement_owned");
});
it("rejects a participant that does not exist", async () => {
const id = await manualTxn("bad participant", me, [[me, 100]]);
const res = await txPATCH(req(meEmail, { owner_id: 999999 }), ctx(id));
expect(res.status).toBe(400);
expect(await ownerOf(id)).toBe(me);
});
it("leaves splits untouched — they record shares, not direction", async () => {
const id = await manualTxn("shares survive", me, [[me, 50], [them, 50]]);
await txPATCH(req(meEmail, { owner_id: them }), ctx(id));
const splits = await queryRaw<{ participant_id: number; share_percent: string; settled: boolean }>(
`SELECT participant_id, share_percent, settled FROM transaction_splits
WHERE transaction_id = $1 ORDER BY participant_id`,
[id]
);
expect(splits.map((s) => [s.participant_id, Number(s.share_percent)])).toEqual([
[me, 50],
[them, 50],
]);
expect(splits.every((s) => s.settled === false)).toBe(true);
});
it("flips which side of the balance the same rows sit on", async () => {
const id = await manualTxn("balance flip", me, [[me, 50], [them, 50]]);
// "They owe me": their split on a row I own.
const owedToMe = async () =>
Number(
(await queryRow<{ v: string | null }>(
`SELECT COALESCE(SUM(t.amount * ts.share_percent / 100), 0) AS v
FROM transaction_splits ts JOIN transactions t ON t.id = ts.transaction_id
WHERE t.id = $1 AND t.owner_id = $2 AND ts.participant_id <> $2`,
[id, me]
))!.v ?? 0
);
// "I owe them": my split on a row they own.
const owedByMe = async () =>
Number(
(await queryRow<{ v: string | null }>(
`SELECT COALESCE(SUM(t.amount * ts.share_percent / 100), 0) AS v
FROM transaction_splits ts JOIN transactions t ON t.id = ts.transaction_id
WHERE t.id = $1 AND t.owner_id <> $2 AND ts.participant_id = $2`,
[id, me]
))!.v ?? 0
);
expect(await owedToMe()).toBe(50);
expect(await owedByMe()).toBe(0);
await txPATCH(req(meEmail, { owner_id: them }), ctx(id));
expect(await owedToMe()).toBe(0);
expect(await owedByMe()).toBe(50);
});
it("rejects an unauthenticated caller", async () => {
const id = await manualTxn("no auth", me, [[me, 100]]);
const res = await txPATCH(req("nobody@example.test", { owner_id: them }), ctx(id));
expect(res.status).toBe(403);
expect(await ownerOf(id)).toBe(me);
});
});
describe("PATCH /api/statements/[id] — owner", () => {
async function statementWithRows(label: string, owner: number) {
const stmt = await queryRow<{ id: number }>(
`INSERT INTO statements (bank_name, account_number, billing_end_date, currency, filename, owner_id)
VALUES ('Owner Fixture Bank', $1, '2026-06-30','AUD', $2, $3) RETURNING id`,
[label, `owner-fixture-${label}.pdf`, owner]
);
// One row inheriting the owner (owner_id NULL) and one carrying its own
// copy — the live table holds 1,803 and 2,194 of these respectively, and
// updating `statements` alone would move only the first kind.
const inheriting = await queryRow<{ id: number }>(
`INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type)
VALUES ($1,'2026-06-03','Owner fixture — inheriting', 10.00, 'debit') RETURNING id`,
[stmt!.id]
);
const carrying = await queryRow<{ id: number }>(
`INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type, owner_id)
VALUES ($1,'2026-06-04','Owner fixture — carrying', 20.00, 'debit', $2) RETURNING id`,
[stmt!.id, owner]
);
return { stmtId: stmt!.id, inheriting: inheriting!.id, carrying: carrying!.id };
}
/** The owner the app actually reads: COALESCE(t.owner_id, s.owner_id). */
const effectiveOwner = async (id: number) =>
(await queryRow<{ v: number }>(
`SELECT COALESCE(t.owner_id, s.owner_id) AS v FROM transactions t
LEFT JOIN statements s ON s.id = t.statement_id WHERE t.id = $1`,
[id]
))!.v;
it("moves the statement and every row on it, both kinds", async () => {
const { stmtId, inheriting, carrying } = await statementWithRows("move", me);
const res = await stmtPATCH(req(meEmail, { owner_id: them }), ctx(stmtId));
expect(res.status).toBe(200);
expect((await res.json()).rows_moved).toBe(1); // only the one carrying a copy
expect(await effectiveOwner(inheriting)).toBe(them);
expect(await effectiveOwner(carrying)).toBe(them);
});
it("refuses a caller who does not own it", async () => {
const { stmtId, carrying } = await statementWithRows("not-mine", me);
const res = await stmtPATCH(req(themEmail, { owner_id: them }), ctx(stmtId));
expect(res.status).toBe(404);
expect(await effectiveOwner(carrying)).toBe(me);
});
it("rejects a participant that does not exist", async () => {
const { stmtId, carrying } = await statementWithRows("bad-participant", me);
const res = await stmtPATCH(req(meEmail, { owner_id: 999999 }), ctx(stmtId));
expect(res.status).toBe(400);
expect(await effectiveOwner(carrying)).toBe(me);
});
it("is a no-op when the owner is unchanged", async () => {
const { stmtId } = await statementWithRows("same-owner", me);
const res = await stmtPATCH(req(meEmail, { owner_id: me }), ctx(stmtId));
expect(res.status).toBe(200);
expect((await res.json()).rows_moved).toBe(0);
});
});