ci / lint-test (push) Successful in 46s
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.
443 lines
18 KiB
TypeScript
443 lines
18 KiB
TypeScript
"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'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)}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|