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:
+21
-1
@@ -148,18 +148,31 @@ export function useUpdateTransaction() {
|
||||
transaction_date?: string;
|
||||
trip_id?: number | null;
|
||||
payment_method?: string | null;
|
||||
owner_id?: number;
|
||||
release?: boolean;
|
||||
}) => {
|
||||
const res = await fetch(`/api/transactions/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
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();
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["transaction"] });
|
||||
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" },
|
||||
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: () => {
|
||||
qc.invalidateQueries({ queryKey: ["statements"] });
|
||||
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