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:
@@ -804,6 +804,55 @@ is the drill-down for these totals.
|
|||||||
pre-2026 SplitMyExpenses splits are all on rows you own, so nothing before the
|
pre-2026 SplitMyExpenses splits are all on rows you own, so nothing before the
|
||||||
cutover moves.
|
cutover moves.
|
||||||
|
|
||||||
|
### Changing who paid (2026-08-15)
|
||||||
|
|
||||||
|
Owner was **write-once for every ingestion path** until now — a pantry receipt
|
||||||
|
hardcodes `DEFAULT_OWNER_ID` (`receipt-ingestion.ts`), and there was not one
|
||||||
|
`UPDATE ... SET owner_id` in `src/`. A shop the other person paid for was
|
||||||
|
permanently filed as yours.
|
||||||
|
|
||||||
|
Two routes, because the owner lives in two places:
|
||||||
|
|
||||||
|
- **`PATCH /api/transactions/[id]` with `owner_id`** — manual rows only
|
||||||
|
(`statement_id IS NULL`). 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 here would either no-op or
|
||||||
|
detach one row from the account it was extracted from.
|
||||||
|
- **`PATCH /api/statements/[id]` with `owner_id`** — the statements page has had
|
||||||
|
this dropdown since it was built, wired to a route with **no PATCH handler**.
|
||||||
|
Every change 405'd, and `useUpdateStatement` never checked `res.ok`, so it
|
||||||
|
failed silently and the select just snapped back.
|
||||||
|
|
||||||
|
**The statement route 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. It updates rows
|
||||||
|
matching the *old* owner and returns `rows_moved`; all 2,194 agree today, and a
|
||||||
|
row that disagrees was set deliberately and is left alone.
|
||||||
|
|
||||||
|
**Reassignment is a one-way door, and the guard is the point.** Access is
|
||||||
|
`owner OR holds a split` (`canAccessTransactions`), 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. The route returns **409
|
||||||
|
`would_lose_access`** and the modal offers the two real ways forward: add your
|
||||||
|
split first, or "Give it away anyway" (`release: true`). Taking a row *onto*
|
||||||
|
your ledger is never blocked — there is no door to close behind you.
|
||||||
|
|
||||||
|
Reassignment is a correction to a row you already hold, never a way to reach one
|
||||||
|
you do not: `canAccessTransactions` runs first, so claiming a stranger's
|
||||||
|
transaction is a 404 before any owner logic runs.
|
||||||
|
|
||||||
|
**Splits are deliberately not rewritten.** They record shares, not direction —
|
||||||
|
`getParticipantBalances` derives who owes whom from ownership, so a 50/50 flips
|
||||||
|
from "they owe me" to "I owe them" untouched, `settled` included. Tested both
|
||||||
|
directions.
|
||||||
|
|
||||||
|
**Order matters when you do this by hand:** split first, then reassign. The
|
||||||
|
reverse locks you out, which is exactly what the 409 exists to stop.
|
||||||
|
|
||||||
|
`useUpdateTransaction` also gained the missing `res.ok` check. Without it every
|
||||||
|
rejection resolved as success — the modal closed, the list refetched, and the
|
||||||
|
edit silently vanished.
|
||||||
|
|
||||||
### Statement types
|
### Statement types
|
||||||
|
|
||||||
`statements.statement_type` is constrained to `credit_card | transaction |
|
`statements.statement_type` is constrained to `credit_card | transaction |
|
||||||
@@ -923,6 +972,10 @@ See `README.md` → **Known Gaps / TODOs** for full details.
|
|||||||
asset disposals into the income line alongside salary.
|
asset disposals into the income line alongside salary.
|
||||||
- **`payment_method` is not shown in the transactions list** — settable on create
|
- **`payment_method` is not shown in the transactions list** — settable on create
|
||||||
and edit only. Worth a column or filter if cash becomes routine.
|
and edit only. Worth a column or filter if cash becomes routine.
|
||||||
|
- **Pantry receipts still land as yours.** `processReceiptIngestion` hardcodes
|
||||||
|
`DEFAULT_OWNER_ID`; the ingest route takes no owner. Correctable per row now
|
||||||
|
(see "Changing who paid"), but a "Paid by" step at capture time would stop the
|
||||||
|
correction being needed.
|
||||||
- **Raw statement exports live in `dump/`**, gitignored since `31a8177`. They were
|
- **Raw statement exports live in `dump/`**, gitignored since `31a8177`. They were
|
||||||
committed by accident in `030490e` and remain in that commit's history; the repo
|
committed by accident in `030490e` and remain in that commit's history; the repo
|
||||||
has no GitHub remote, so exposure is limited to the local Gitea. Purging history
|
has no GitHub remote, so exposure is limited to the local Gitea. Purging history
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getStatementById } from "@/lib/queries";
|
import { getStatementById } from "@/lib/queries";
|
||||||
import { getCurrentUser } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { prisma, queryRaw } from "@/lib/db";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
@@ -15,3 +16,59 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
return NextResponse.json(stmt);
|
return NextResponse.json(stmt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reassign a statement — whose account it is.
|
||||||
|
*
|
||||||
|
* The statements page has had an owner dropdown since it was built, wired to
|
||||||
|
* `useUpdateStatement`, which PATCHed a route that had no PATCH handler. Every
|
||||||
|
* change 405'd, and because the hook never checked `res.ok` the failure was
|
||||||
|
* silent: the select snapped back on refetch and looked like a UI glitch.
|
||||||
|
*
|
||||||
|
* Both tables are written because both carry the owner. The effective owner is
|
||||||
|
* `COALESCE(t.owner_id, s.owner_id)`, and 2,194 statement rows carry their own
|
||||||
|
* copy against 1,803 that inherit — so updating `statements` alone would move
|
||||||
|
* less than half the rows and split one account's history between two people.
|
||||||
|
* All 2,194 agree with their statement today; a row that disagrees was set
|
||||||
|
* deliberately and is left alone rather than swept up.
|
||||||
|
*/
|
||||||
|
export async function PATCH(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
const { id } = await params;
|
||||||
|
const statementId = Number(id);
|
||||||
|
|
||||||
|
const stmt = await getStatementById(statementId);
|
||||||
|
// Only the current owner reassigns. A statement is an account, not a shared
|
||||||
|
// expense — there is no split that grants a second person a say in it.
|
||||||
|
if (!stmt || stmt.owner_id !== user.id) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { owner_id } = (await req.json()) as { owner_id?: number };
|
||||||
|
if (typeof owner_id !== "number" || !Number.isInteger(owner_id)) {
|
||||||
|
return NextResponse.json({ error: "owner_id must be a participant id" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const known = await queryRaw(`SELECT id FROM participants WHERE id = $1`, [owner_id]);
|
||||||
|
if (!known.length) {
|
||||||
|
return NextResponse.json({ error: "Unknown participant" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (owner_id === stmt.owner_id) {
|
||||||
|
return NextResponse.json({ ok: true, statement_id: statementId, rows_moved: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, rowsMoved] = await prisma.$transaction([
|
||||||
|
prisma.$executeRawUnsafe(`UPDATE statements SET owner_id = $1 WHERE id = $2`, owner_id, statementId),
|
||||||
|
prisma.$executeRawUnsafe(
|
||||||
|
`UPDATE transactions SET owner_id = $1 WHERE statement_id = $2 AND owner_id = $3`,
|
||||||
|
owner_id,
|
||||||
|
statementId,
|
||||||
|
stmt.owner_id
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, statement_id: statementId, rows_moved: rowsMoved });
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export async function PATCH(
|
|||||||
}
|
}
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
|
|
||||||
const { category, merchant_normalized, notes, transaction_type, my_share_percent, description, amount, transaction_date, trip_id, payment_method } = body as {
|
const { category, merchant_normalized, notes, transaction_type, my_share_percent, description, amount, transaction_date, trip_id, payment_method, owner_id, release } = body as {
|
||||||
category?: string;
|
category?: string;
|
||||||
merchant_normalized?: string;
|
merchant_normalized?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
@@ -45,6 +45,9 @@ export async function PATCH(
|
|||||||
amount?: number;
|
amount?: number;
|
||||||
transaction_date?: string;
|
transaction_date?: string;
|
||||||
trip_id?: number | null;
|
trip_id?: number | null;
|
||||||
|
owner_id?: number;
|
||||||
|
/** Acknowledge that reassigning will remove the row from my own view. */
|
||||||
|
release?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (my_share_percent !== undefined && my_share_percent !== null) {
|
if (my_share_percent !== undefined && my_share_percent !== null) {
|
||||||
@@ -98,6 +101,75 @@ export async function PATCH(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Owner — who actually paid. A direct column, not an override: it decides
|
||||||
|
// whose account the money left, which every balance and every spend analytic
|
||||||
|
// scopes on (MY_SPEND_SCOPE / OWNER_SCOPE).
|
||||||
|
//
|
||||||
|
// Handled here rather than in the direct-fields block above because it has
|
||||||
|
// its own rules, and because getting it wrong is not a cosmetic error: it
|
||||||
|
// moves money between two people's ledgers.
|
||||||
|
if (owner_id !== undefined) {
|
||||||
|
if (typeof owner_id !== "number" || !Number.isInteger(owner_id)) {
|
||||||
|
return NextResponse.json({ error: "owner_id must be a participant id" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const txRows = await queryRaw<{ statement_id: number | null; owner_id: number | null }>(
|
||||||
|
`SELECT statement_id, owner_id FROM transactions WHERE id = $1`,
|
||||||
|
[transactionId]
|
||||||
|
);
|
||||||
|
const tx = txRows[0];
|
||||||
|
if (!tx) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
// A statement row's owner is the statement's owner — the effective owner is
|
||||||
|
// COALESCE(t.owner_id, s.owner_id), so setting it here would either be a
|
||||||
|
// no-op or would silently detach one row from the account it was extracted
|
||||||
|
// from. Reassigning the statement is the correct move and moves its rows
|
||||||
|
// with it.
|
||||||
|
if (tx.statement_id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"This row came from a statement, so its owner is the statement's owner. Change the statement's owner instead — that moves every row on it.",
|
||||||
|
code: "statement_owned",
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const known = await queryRaw(`SELECT id FROM participants WHERE id = $1`, [owner_id]);
|
||||||
|
if (!known.length) {
|
||||||
|
return NextResponse.json({ error: "Unknown participant" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one-way door. Access is "owner OR holds a split"
|
||||||
|
// (canAccessTransactions), so handing a row to someone else while holding
|
||||||
|
// no split on it removes it from the caller's list and 404s every route
|
||||||
|
// that could put it back — only the new owner can undo it. Splitting first
|
||||||
|
// keeps the row reachable AND is what makes the balance correct, so the
|
||||||
|
// refusal points at the step that was skipped rather than just blocking.
|
||||||
|
if (owner_id !== user.id && !release) {
|
||||||
|
const mine = await queryRaw(
|
||||||
|
`SELECT 1 FROM transaction_splits WHERE transaction_id = $1 AND participant_id = $2`,
|
||||||
|
[transactionId, user.id]
|
||||||
|
);
|
||||||
|
if (!mine.length) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"You hold no split on this transaction, so reassigning it would remove it from your view for good — only the new owner could change it back. Add your split first, or confirm you are giving it away entirely.",
|
||||||
|
code: "would_lose_access",
|
||||||
|
},
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing splits are deliberately left alone. They record shares, not
|
||||||
|
// direction: getParticipantBalances derives who owes whom from ownership,
|
||||||
|
// so a 50/50 row flips from "they owe me" to "I owe them" with no rewrite.
|
||||||
|
await queryRaw(`UPDATE transactions SET owner_id = $1 WHERE id = $2`, [owner_id, transactionId]);
|
||||||
|
}
|
||||||
|
|
||||||
// category/merchant/notes/my_share_percent/trip_id go through the overrides table
|
// category/merchant/notes/my_share_percent/trip_id go through the overrides table
|
||||||
const hasOverride = category !== undefined || merchant_normalized !== undefined || notes !== undefined || my_share_percent !== undefined || trip_id !== undefined;
|
const hasOverride = category !== undefined || merchant_normalized !== undefined || notes !== undefined || my_share_percent !== undefined || trip_id !== undefined;
|
||||||
if (!hasOverride) {
|
if (!hasOverride) {
|
||||||
|
|||||||
@@ -255,9 +255,24 @@ export default function StatementsPage() {
|
|||||||
{participants?.length ? (
|
{participants?.length ? (
|
||||||
<select
|
<select
|
||||||
value={s.owner_id ?? ""}
|
value={s.owner_id ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
updateStatement.mutate({ id: s.id, owner_id: Number(e.target.value) })
|
const next = Number(e.target.value);
|
||||||
|
const name = participants.find((p) => p.id === next)?.name ?? "them";
|
||||||
|
// This page only lists statements you own, so handing
|
||||||
|
// one over removes it — and every transaction on it —
|
||||||
|
// from your view, and only they can hand it back.
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
`Reassign this statement to ${name}?\n\nEvery transaction on it moves to their ledger, and the statement leaves your list — only ${name} can move it back.`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
updateStatement.mutate(
|
||||||
|
{ id: s.id, owner_id: next },
|
||||||
|
{ onError: (err) => alert(err instanceof Error ? err.message : "Failed to reassign") }
|
||||||
|
);
|
||||||
|
}}
|
||||||
className="bg-zinc-800 border border-zinc-700 rounded text-xs px-2 py-1 text-zinc-300 cursor-pointer hover:border-zinc-600 focus:outline-none focus:border-indigo-500"
|
className="bg-zinc-800 border border-zinc-700 rounded text-xs px-2 py-1 text-zinc-300 cursor-pointer hover:border-zinc-600 focus:outline-none focus:border-indigo-500"
|
||||||
>
|
>
|
||||||
{participants.map((p) => (
|
{participants.map((p) => (
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
useRemoveTransactionTag,
|
useRemoveTransactionTag,
|
||||||
useTransactionSplits,
|
useTransactionSplits,
|
||||||
useTrips,
|
useTrips,
|
||||||
|
useParticipants,
|
||||||
|
useCurrentUser,
|
||||||
} from "@/lib/hooks";
|
} from "@/lib/hooks";
|
||||||
import { SplitModal } from "./split-modal";
|
import { SplitModal } from "./split-modal";
|
||||||
import { OrderDetails } from "./order-details";
|
import { OrderDetails } from "./order-details";
|
||||||
@@ -96,6 +98,8 @@ export function EditTransactionModal({
|
|||||||
const isManual = !transaction.statement_id;
|
const isManual = !transaction.statement_id;
|
||||||
const updateTxn = useUpdateTransaction();
|
const updateTxn = useUpdateTransaction();
|
||||||
const { data: trips = [] } = useTrips();
|
const { data: trips = [] } = useTrips();
|
||||||
|
const { data: participants = [] } = useParticipants();
|
||||||
|
const { data: me } = useCurrentUser();
|
||||||
|
|
||||||
// Editable override fields
|
// Editable override fields
|
||||||
const [merchant, setMerchant] = useState(transaction.merchant_override ?? transaction.merchant_normalized ?? "");
|
const [merchant, setMerchant] = useState(transaction.merchant_override ?? transaction.merchant_normalized ?? "");
|
||||||
@@ -109,14 +113,24 @@ export function EditTransactionModal({
|
|||||||
const [amount, setAmount] = useState(String(transaction.amount));
|
const [amount, setAmount] = useState(String(transaction.amount));
|
||||||
|
|
||||||
const [tripId, setTripId] = useState<number | null>(transaction.trip_id ?? null);
|
const [tripId, setTripId] = useState<number | null>(transaction.trip_id ?? null);
|
||||||
|
const [ownerId, setOwnerId] = useState<number | null>(transaction.owner_id ?? null);
|
||||||
|
|
||||||
// Splits — live via hook so they refresh after SplitModal saves
|
// Splits — live via hook so they refresh after SplitModal saves
|
||||||
const { data: liveSplits = [] } = useTransactionSplits(transaction.id);
|
const { data: liveSplits = [] } = useTransactionSplits(transaction.id);
|
||||||
|
|
||||||
const [showSplitModal, setShowSplitModal] = useState(false);
|
const [showSplitModal, setShowSplitModal] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
// Set when the API refuses an owner change that would hide the row from me.
|
||||||
|
// Holding it in state (rather than confirm()) keeps the way out — add a
|
||||||
|
// split — one click away instead of behind a dialog.
|
||||||
|
const [releasePrompt, setReleasePrompt] = useState(false);
|
||||||
|
|
||||||
async function handleSave() {
|
const ownerChanged = ownerId !== null && ownerId !== (transaction.owner_id ?? null);
|
||||||
|
const iHoldASplit = liveSplits.some(
|
||||||
|
(s: { participant_id: number }) => s.participant_id === me?.id
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleSave(release = false) {
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const patch: Parameters<typeof updateTxn.mutateAsync>[0] = { id: transaction.id };
|
const patch: Parameters<typeof updateTxn.mutateAsync>[0] = { id: transaction.id };
|
||||||
@@ -144,9 +158,18 @@ export function EditTransactionModal({
|
|||||||
if (tripId !== (transaction.trip_id ?? null))
|
if (tripId !== (transaction.trip_id ?? null))
|
||||||
patch.trip_id = tripId;
|
patch.trip_id = tripId;
|
||||||
|
|
||||||
|
// Owner is manual-only: a statement row's owner comes from its statement.
|
||||||
|
if (isManual && ownerChanged) {
|
||||||
|
patch.owner_id = ownerId!;
|
||||||
|
if (release) patch.release = true;
|
||||||
|
}
|
||||||
|
|
||||||
await updateTxn.mutateAsync(patch);
|
await updateTxn.mutateAsync(patch);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if ((e as { code?: string })?.code === "would_lose_access") {
|
||||||
|
setReleasePrompt(true);
|
||||||
|
}
|
||||||
setError(e instanceof Error ? e.message : "Failed to save");
|
setError(e instanceof Error ? e.message : "Failed to save");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,6 +307,46 @@ export function EditTransactionModal({
|
|||||||
<InlineTags transactionId={transaction.id} initialTags={transaction.tags ?? []} />
|
<InlineTags transactionId={transaction.id} initialTags={transaction.tags ?? []} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Paid by — sits next to Splits deliberately: together they are
|
||||||
|
whose money went out and whose share it was. */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Paid by</label>
|
||||||
|
{isManual ? (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
value={ownerId ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
setOwnerId(e.target.value ? Number(e.target.value) : null);
|
||||||
|
setReleasePrompt(false);
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
{participants.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{ownerChanged && (
|
||||||
|
<p className="text-xs text-amber-400/90 mt-1.5">
|
||||||
|
{ownerId === me?.id
|
||||||
|
? "This moves the spend onto your ledger, and any split you hold becomes their share of it."
|
||||||
|
: iHoldASplit
|
||||||
|
? "This moves the spend onto their ledger. Your split becomes what you owe them rather than what they owe you."
|
||||||
|
: "You hold no split on this. Add one first, or it leaves your view for good."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="bg-zinc-800/50 rounded px-2 py-1.5">
|
||||||
|
<p className="text-sm text-zinc-300">{transaction.owner_name ?? "—"}</p>
|
||||||
|
<p className="text-xs text-zinc-500 mt-0.5">
|
||||||
|
From a statement, so the owner is the statement's. Change it on the
|
||||||
|
Statements page to move every row on that statement.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Splits */}
|
{/* Splits */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
@@ -320,9 +383,31 @@ export function EditTransactionModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="px-6 py-4 border-t border-zinc-800 flex gap-2">
|
<div className="px-6 py-4 border-t border-zinc-800 space-y-3">
|
||||||
{error && <p className="text-red-400 text-xs flex-1 self-center">{error}</p>}
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
<div className="flex gap-2 ml-auto">
|
{/* The refusal names the step that was skipped, so offer both: add
|
||||||
|
the split (keeps the row reachable and gets the balance right)
|
||||||
|
or hand it over knowingly. */}
|
||||||
|
{releasePrompt && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowSplitModal(true)}
|
||||||
|
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded-lg text-xs"
|
||||||
|
>
|
||||||
|
Add my split first
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSave(true)}
|
||||||
|
disabled={updateTxn.isPending}
|
||||||
|
className="px-3 py-1.5 bg-amber-700 hover:bg-amber-600 disabled:opacity-50 text-white rounded-lg text-xs"
|
||||||
|
>
|
||||||
|
Give it away anyway
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -332,7 +417,7 @@ export function EditTransactionModal({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSave}
|
onClick={() => handleSave()}
|
||||||
disabled={updateTxn.isPending}
|
disabled={updateTxn.isPending}
|
||||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
|
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
|
||||||
>
|
>
|
||||||
|
|||||||
+21
-1
@@ -148,18 +148,31 @@ export function useUpdateTransaction() {
|
|||||||
transaction_date?: string;
|
transaction_date?: string;
|
||||||
trip_id?: number | null;
|
trip_id?: number | null;
|
||||||
payment_method?: string | null;
|
payment_method?: string | null;
|
||||||
|
owner_id?: number;
|
||||||
|
release?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const res = await fetch(`/api/transactions/${id}`, {
|
const res = await fetch(`/api/transactions/${id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
|
// Without this every rejection resolved as success: the modal closed, the
|
||||||
|
// list refetched, and the edit silently vanished. The owner guard returns
|
||||||
|
// 409 with a `code`, so the caller needs both the message and the code.
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw Object.assign(new Error(body.error || "Failed to update transaction"), {
|
||||||
|
code: body.code as string | undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||||
qc.invalidateQueries({ queryKey: ["transaction"] });
|
qc.invalidateQueries({ queryKey: ["transaction"] });
|
||||||
qc.invalidateQueries({ queryKey: ["analytics"] });
|
qc.invalidateQueries({ queryKey: ["analytics"] });
|
||||||
|
// An owner change flips which side of a balance the splits sit on.
|
||||||
|
qc.invalidateQueries({ queryKey: ["participant-balances"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -538,11 +551,18 @@ export function useUpdateStatement() {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ owner_id }),
|
body: JSON.stringify({ owner_id }),
|
||||||
});
|
});
|
||||||
return res.json();
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || "Failed to reassign statement");
|
||||||
|
}
|
||||||
|
return res.json() as Promise<{ statement_id: number; rows_moved: number }>;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["statements"] });
|
qc.invalidateQueries({ queryKey: ["statements"] });
|
||||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||||
|
// The rows moved with it, so every per-person total changed.
|
||||||
|
qc.invalidateQueries({ queryKey: ["analytics"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["participant-balances"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user