Owner was write-once for every ingestion path — a pantry receipt hardcodes DEFAULT_OWNER_ID and there was not one `UPDATE ... SET owner_id` in src/ — so a shop the other person paid for was permanently filed as yours. PATCH /api/transactions/[id] now takes owner_id, for manual rows only. A statement row returns 400 statement_owned and points at the statements page: its effective owner is COALESCE(t.owner_id, s.owner_id), so writing it there would either no-op or detach one row from the account it came from. PATCH /api/statements/[id] is new. The statements page has had an owner dropdown since it was built, wired to a route with no PATCH handler — every change 405'd, and because useUpdateStatement never checked res.ok it failed silently and the select snapped back on refetch. It writes both tables: 2,194 statement rows carry their own owner_id against 1,803 that inherit, so updating `statements` alone moves less than half and splits one account's history between two people. The guard is the point. Access is "owner OR holds a split", so handing a row over while holding no split removes it from your list and 404s every route that could put it back — only the new owner can undo it. That is 409 would_lose_access, and the modal offers both ways forward: add my split first, or give it away anyway. Taking a row onto your own ledger is never blocked, and claiming a row you cannot see is a 404 before any owner logic runs. Splits are deliberately not rewritten. They record shares, not direction, so a 50/50 flips from "they owe me" to "I owe them" untouched, settled included. Also adds the missing res.ok check to useUpdateTransaction, without which every rejection resolved as success: the modal closed, the list refetched, and the edit silently vanished. 14 new integration tests; 203 integration + 130 unit green.
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
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 Res = { status: number; json: () => Promise<Record<string, unknown>> };
|
||||
|
||||
let txPATCH: (req: unknown, ctx: { params: Promise<{ id: string }> }) => Promise<Res>;
|
||||
let stmtPATCH: (req: unknown, ctx: { params: Promise<{ id: string }> }) => Promise<Res>;
|
||||
|
||||
const req = (email: string | null, body: unknown) => ({
|
||||
headers: { get: (h: string) => (h.toLowerCase() === "x-forwarded-user" ? email : null) },
|
||||
json: async () => body,
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user