Files
finance-app/src/app/api/statements/[id]/route.ts
T
siddharthd 95d8544752
ci / lint-test (push) Successful in 46s
transactions: change who paid, on a row and on a statement
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.
2026-08-15 16:17:38 +10:00

75 lines
3.0 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { getStatementById } from "@/lib/queries";
import { getCurrentUser } from "@/lib/auth";
import { prisma, queryRaw } from "@/lib/db";
export async function GET(
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 stmt = await getStatementById(Number(id));
if (!stmt || stmt.owner_id !== user.id) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
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 });
}