Files
finance-app/src/components/edit-transaction-modal.tsx
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

443 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import {
useUpdateTransaction,
useTags,
useAddTransactionTag,
useRemoveTransactionTag,
useTransactionSplits,
useTrips,
useParticipants,
useCurrentUser,
} from "@/lib/hooks";
import { SplitModal } from "./split-modal";
import { OrderDetails } from "./order-details";
import { CATEGORIES, formatCategory } from "@/lib/categories";
import type { TransactionRow, TagRow } from "@/lib/queries";
const TRANSACTION_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
function formatAmount(amount: number, type: string) {
const formatted = `$${Number(amount).toFixed(2)}`;
return SPEND_TYPES.has(type) ? formatted : `+${formatted}`;
}
function InlineTags({ transactionId, initialTags }: { transactionId: number; initialTags: TagRow[] }) {
const { data: allTags = [] } = useTags();
const addTag = useAddTransactionTag();
const removeTag = useRemoveTransactionTag();
const [tags, setTags] = useState<TagRow[]>(initialTags);
const [showPicker, setShowPicker] = useState(false);
const available = allTags.filter((t) => !tags.find((ct) => ct.id === t.id));
return (
<div>
<div className="flex flex-wrap gap-1 items-center">
{tags.map((tag) => (
<span
key={tag.id}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium text-white"
style={{ backgroundColor: tag.color + "99" }}
>
{tag.name}
<button
type="button"
onClick={() => {
removeTag.mutate({ transactionId, tagId: tag.id });
setTags((prev) => prev.filter((t) => t.id !== tag.id));
}}
className="ml-0.5 text-white/60 hover:text-white leading-none"
>
×
</button>
</span>
))}
{available.length > 0 && (
<button
type="button"
onClick={() => setShowPicker((v) => !v)}
className="text-xs text-zinc-500 hover:text-zinc-300 px-1.5 py-0.5 rounded hover:bg-zinc-800"
>
+ Add tag
</button>
)}
</div>
{showPicker && (
<div className="mt-1.5 flex flex-wrap gap-1">
{available.map((tag) => (
<button
key={tag.id}
type="button"
onClick={() => {
addTag.mutate({ transactionId, tagId: tag.id });
setTags((prev) => [...prev, tag]);
setShowPicker(false);
}}
className="px-2 py-0.5 rounded text-xs font-medium text-white hover:brightness-125"
style={{ backgroundColor: tag.color + "66" }}
>
{tag.name}
</button>
))}
</div>
)}
</div>
);
}
export function EditTransactionModal({
transaction,
onClose,
}: {
transaction: TransactionRow;
onClose: () => void;
}) {
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 ?? "");
const [category, setCategory] = useState(transaction.effective_category ?? "");
const [type, setType] = useState(transaction.transaction_type);
const [notes, setNotes] = useState(transaction.notes ?? "");
// Manual-only direct fields
const [date, setDate] = useState(transaction.transaction_date?.slice(0, 10) ?? "");
const [description, setDescription] = useState(transaction.description);
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);
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 };
// Override fields (always)
if (merchant !== (transaction.merchant_override ?? transaction.merchant_normalized ?? ""))
patch.merchant_normalized = merchant;
if (category !== (transaction.effective_category ?? ""))
patch.category = category;
if (type !== transaction.transaction_type)
patch.transaction_type = type;
if (notes !== (transaction.notes ?? ""))
patch.notes = notes;
// Direct fields (manual only)
if (isManual) {
if (date !== transaction.transaction_date?.slice(0, 10))
patch.transaction_date = date;
if (description !== transaction.description)
patch.description = description;
if (parseFloat(amount) !== transaction.amount)
patch.amount = parseFloat(amount);
}
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");
}
}
return (
<>
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60" onClick={onClose}>
<div
className="bg-zinc-900 border border-zinc-700 rounded-xl w-full max-w-lg mx-4 shadow-2xl flex flex-col max-h-[90vh]"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="px-6 pt-5 pb-4 border-b border-zinc-800">
<h3 className="font-semibold text-sm text-zinc-300">Edit Transaction</h3>
<p className="text-xs text-zinc-500 mt-0.5">{transaction.bank_name}</p>
</div>
<div className="overflow-y-auto flex-1 px-6 py-4 space-y-5">
{/* Core fields — read-only for statement, editable for manual */}
{isManual ? (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-zinc-500 mb-1">Date</label>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Amount</label>
<input
type="number"
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
</div>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Description</label>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
</div>
</div>
) : (
<div className="bg-zinc-800/50 rounded-lg px-3 py-2.5 space-y-1">
<p className="text-sm font-medium">{transaction.description}</p>
<p className={`text-sm font-mono ${SPEND_TYPES.has(transaction.transaction_type) ? "text-red-400" : "text-green-400"}`}>
{formatAmount(transaction.amount, transaction.transaction_type)}
</p>
<p className="text-xs text-zinc-500">
{new Date(transaction.transaction_date).toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" })}
</p>
</div>
)}
{/* Override fields */}
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-zinc-500 mb-1">Type</label>
<select
value={type}
onChange={(e) => setType(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
>
{TRANSACTION_TYPES.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Category</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
>
<option value=""> none </option>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{formatCategory(c)}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Merchant</label>
<input
value={merchant}
onChange={(e) => setMerchant(e.target.value)}
placeholder="Normalized merchant name"
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Notes</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
placeholder="Additional context about this transaction…"
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm resize-none"
/>
</div>
</div>
{/* Trip */}
<div>
<label className="block text-xs text-zinc-500 mb-1">Trip</label>
<select
value={tripId ?? ""}
onChange={(e) => setTripId(e.target.value ? Number(e.target.value) : null)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
>
<option value=""> No Trip </option>
{trips.filter((t) => !t.archived).map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
{/* Tags */}
<div>
<p className="text-xs text-zinc-500 mb-1.5">Tags</p>
<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&apos;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">
<p className="text-xs text-zinc-500">Splits</p>
<button
type="button"
onClick={() => setShowSplitModal(true)}
className="text-xs text-blue-400 hover:text-blue-300"
>
{liveSplits.length > 0 ? "Edit splits" : "Add split"}
</button>
</div>
{liveSplits.length > 0 ? (
<div className="flex flex-wrap gap-1">
{liveSplits.map((s: { participant_id: number; name: string; share_percent: number; settled: boolean }) => (
<span
key={s.participant_id}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs ${
s.settled ? "bg-zinc-800 text-zinc-500" : "bg-amber-900/40 text-amber-300"
}`}
>
{s.name} {s.share_percent}%
{s.settled && <span className="text-emerald-500"></span>}
</span>
))}
</div>
) : (
<p className="text-xs text-zinc-600 italic">No splits</p>
)}
</div>
<OrderDetails transactionId={transaction.id} currency={transaction.currency ?? null} />
</div>
{/* Footer */}
<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}
className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm"
>
Cancel
</button>
<button
type="button"
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"
>
{updateTxn.isPending ? "Saving…" : "Save"}
</button>
</div>
</div>
</div>
</div>
{showSplitModal && (
<SplitModal
transactionId={transaction.id}
amount={transaction.amount}
description={transaction.description}
merchant={transaction.effective_merchant || undefined}
onClose={() => setShowSplitModal(false)}
/>
)}
</>
);
}