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:
@@ -255,9 +255,24 @@ export default function StatementsPage() {
|
||||
{participants?.length ? (
|
||||
<select
|
||||
value={s.owner_id ?? ""}
|
||||
onChange={(e) =>
|
||||
updateStatement.mutate({ id: s.id, owner_id: Number(e.target.value) })
|
||||
}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
>
|
||||
{participants.map((p) => (
|
||||
|
||||
Reference in New Issue
Block a user