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; 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); }); });