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:
@@ -8,6 +8,8 @@ import {
|
||||
useRemoveTransactionTag,
|
||||
useTransactionSplits,
|
||||
useTrips,
|
||||
useParticipants,
|
||||
useCurrentUser,
|
||||
} from "@/lib/hooks";
|
||||
import { SplitModal } from "./split-modal";
|
||||
import { OrderDetails } from "./order-details";
|
||||
@@ -96,6 +98,8 @@ export function EditTransactionModal({
|
||||
const isManual = !transaction.statement_id;
|
||||
const updateTxn = useUpdateTransaction();
|
||||
const { data: trips = [] } = useTrips();
|
||||
const { data: participants = [] } = useParticipants();
|
||||
const { data: me } = useCurrentUser();
|
||||
|
||||
// Editable override fields
|
||||
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 [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
|
||||
const { data: liveSplits = [] } = useTransactionSplits(transaction.id);
|
||||
|
||||
const [showSplitModal, setShowSplitModal] = useState(false);
|
||||
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("");
|
||||
try {
|
||||
const patch: Parameters<typeof updateTxn.mutateAsync>[0] = { id: transaction.id };
|
||||
@@ -144,9 +158,18 @@ export function EditTransactionModal({
|
||||
if (tripId !== (transaction.trip_id ?? null))
|
||||
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);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
if ((e as { code?: string })?.code === "would_lose_access") {
|
||||
setReleasePrompt(true);
|
||||
}
|
||||
setError(e instanceof Error ? e.message : "Failed to save");
|
||||
}
|
||||
}
|
||||
@@ -284,6 +307,46 @@ export function EditTransactionModal({
|
||||
<InlineTags transactionId={transaction.id} initialTags={transaction.tags ?? []} />
|
||||
</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 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
@@ -320,9 +383,31 @@ export function EditTransactionModal({
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-zinc-800 flex gap-2">
|
||||
{error && <p className="text-red-400 text-xs flex-1 self-center">{error}</p>}
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<div className="px-6 py-4 border-t border-zinc-800 space-y-3">
|
||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||
{/* 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
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@@ -332,7 +417,7 @@ export function EditTransactionModal({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
onClick={() => handleSave()}
|
||||
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"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user