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:
@@ -34,7 +34,7 @@ export async function PATCH(
|
||||
}
|
||||
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;
|
||||
merchant_normalized?: string;
|
||||
notes?: string;
|
||||
@@ -45,6 +45,9 @@ export async function PATCH(
|
||||
amount?: number;
|
||||
transaction_date?: string;
|
||||
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) {
|
||||
@@ -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
|
||||
const hasOverride = category !== undefined || merchant_normalized !== undefined || notes !== undefined || my_share_percent !== undefined || trip_id !== undefined;
|
||||
if (!hasOverride) {
|
||||
|
||||
Reference in New Issue
Block a user