Files
finance-app/src/components/add-transaction-modal.tsx
T
siddharthd 9f1be74330
ci / lint-test (push) Successful in 43s
transactions: let a gift card be a payment method you can pick
`credits` has been a valid payment_method since migration 0018, which added it
so the order pipeline could file gift-card-funded orders. It reached the
database, `needsCardMatch()` (which holds those rows out of reconciliation) and
`bankLabel()` (which renders them "Gift Card"). It never reached the two places
a human touches it.

So entering a gift-card purchase by hand was impossible: the dropdown stopped at
"Other", the row took the NULL default, and NULL is *reconcilable* — it joined
the pending queue to be offered card matches within 3 days and 1% that cannot
exist, because the balance already paid the bill. Accepting one is the
destructive case the cash exclusion documents: reconciled manual rows are
filtered out of every query, so the spend vanishes while an unrelated card row
claims to be it.

- `src/lib/payment-methods.ts` — one canonical list, mirroring the
  `statement-types.ts` pattern, plus the TS mirror of `needsCardMatch()`.
- `PATCH /api/transactions/[id]` — the `VALID_METHODS` literal omitted
  `credits`, so the API answered 400 for a value the CHECK constraint admits
  and 372 live rows already carry. It validates against the list now.
- `POST /api/transactions` — validated at all, so a bad value is a 400 naming
  the field rather than a 500 carrying a Postgres error.
- Add modal — offers every method, and explains the consequence when you pick
  one that suppresses reconciliation. Relabelled "Paid by" → "Payment method",
  which the edit modal uses for the *owner*; they are different questions.
- Edit modal — had no payment-method field at all, so an existing row wrongly
  in the queue could not be corrected. Manual rows only: the queue is
  `statement_id IS NULL`, so it decides nothing on a statement row.

The test asserts the list against `pg_get_constraintdef` and evaluates
`needsCardMatch()` as real SQL rather than re-implementing it, so the next
migration that widens the constraint fails the suite instead of drifting for
two months. Mutation-tested by dropping `credits`.

Unit 130 pass, integration 206 pass (the integration DB needed `npm run
test:setup` first — `.env.test` still named a stale container IP).
2026-08-17 18:14:58 +10:00

297 lines
11 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 { useCreateTransaction, useParticipants, useTags } from "@/lib/hooks";
import { CATEGORIES, formatCategory } from "@/lib/categories";
import {
PAYMENT_METHODS,
PAYMENT_METHOD_LABELS,
NON_RECONCILING_REASON,
} from "@/lib/payment-methods";
const TRANSACTION_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
interface Prefill {
date?: string;
description?: string;
amount?: number;
transaction_type?: string;
merchant_normalized?: string;
category?: string;
splits?: { participant_id: number; share_percent: number }[];
}
export function AddTransactionModal({
prefill,
title,
onClose,
}: {
prefill?: Prefill;
title?: string;
onClose: () => void;
}) {
const createTransaction = useCreateTransaction();
const { data: participants = [] } = useParticipants();
const { data: allTags = [] } = useTags();
const [date, setDate] = useState(prefill?.date ?? new Date().toISOString().slice(0, 10));
const [description, setDescription] = useState(prefill?.description ?? "");
const [amount, setAmount] = useState(prefill?.amount != null ? String(prefill.amount) : "");
const [type, setType] = useState(prefill?.transaction_type ?? "debit");
const [merchant, setMerchant] = useState(prefill?.merchant_normalized ?? "");
const [category, setCategory] = useState(prefill?.category ?? "");
// Cash and gift-card credits are excluded from reconciliation — neither will
// ever produce a card line. See needsCardMatch() in queries.ts.
const [paymentMethod, setPaymentMethod] = useState("");
const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
const [splits, setSplits] = useState<{ participant_id: number; share_percent: number }[]>(
prefill?.splits ?? []
);
function addSplit() {
if (!participants.length) return;
setSplits([...splits, { participant_id: participants[0].id, share_percent: 50 }]);
}
function updateSplit(i: number, patch: Partial<{ participant_id: number; share_percent: number }>) {
setSplits(splits.map((s, idx) => (idx === i ? { ...s, ...patch } : s)));
}
function removeSplit(i: number) {
setSplits(splits.filter((_, idx) => idx !== i));
}
function toggleTag(id: number) {
setSelectedTagIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const result = await createTransaction.mutateAsync({
date,
description,
amount: parseFloat(amount),
transaction_type: type,
merchant_normalized: merchant || undefined,
category: category || undefined,
payment_method: paymentMethod || undefined,
splits: splits.length ? splits : undefined,
});
if (selectedTagIds.length && result?.id) {
await Promise.all(
selectedTagIds.map((tagId) =>
fetch(`/api/transactions/${result.id}/tags`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tag_id: tagId }),
})
)
);
}
onClose();
}
const splitTotal = splits.reduce((s, e) => s + (e.share_percent || 0), 0);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div
className="bg-zinc-900 border border-zinc-700 rounded-xl p-6 w-full max-w-md shadow-2xl space-y-4"
onClick={(e) => e.stopPropagation()}
>
<h3 className="font-semibold text-sm text-zinc-300">{title ?? "Add Transaction"}</h3>
<form onSubmit={handleSubmit} 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"
required
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"
required
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.00"
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
required
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="e.g. Coles Wyndham Vale"
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
</div>
<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>
{/* "Payment method", not "Paid by" — the edit modal uses that label
for the owner, and they are different questions: which instrument
the money left by, versus whose money it was. */}
<label className="block text-xs text-zinc-500 mb-1">Payment method</label>
<select
value={paymentMethod}
onChange={(e) => setPaymentMethod(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
>
<option value="">— unknown —</option>
{PAYMENT_METHODS.map((m) => (
<option key={m} value={m}>{PAYMENT_METHOD_LABELS[m]}</option>
))}
</select>
{NON_RECONCILING_REASON[paymentMethod] && (
<p className="text-[11px] text-zinc-500 mt-1">
{NON_RECONCILING_REASON[paymentMethod]}
</p>
)}
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Merchant (optional)</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>
{/* Tags */}
{allTags.length > 0 && (
<div>
<label className="block text-xs text-zinc-500 mb-1">Tags (optional)</label>
<div className="flex flex-wrap gap-1.5">
{allTags.map((tag) => {
const selected = selectedTagIds.includes(tag.id);
return (
<button
key={tag.id}
type="button"
onClick={() => toggleTag(tag.id)}
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded text-xs border transition-colors ${
selected
? "border-transparent text-white"
: "border-zinc-600 text-zinc-400 hover:border-zinc-500 hover:text-zinc-300"
}`}
style={selected ? { backgroundColor: tag.color + "cc", borderColor: tag.color } : {}}
>
<span
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
style={{ backgroundColor: tag.color }}
/>
{tag.name}
</button>
);
})}
</div>
</div>
)}
{/* Splits */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-xs text-zinc-500">
Splits (optional)
{splits.length > 0 && (
<span className={`ml-2 ${splitTotal === 100 ? "text-emerald-400" : "text-amber-400"}`}>
{splitTotal}%
</span>
)}
</label>
{participants.length > 0 && (
<button type="button" onClick={addSplit} className="text-xs text-indigo-400 hover:text-indigo-300">
+ Add
</button>
)}
</div>
{splits.map((s, i) => (
<div key={i} className="flex gap-2 mb-1.5 items-center">
<select
value={s.participant_id}
onChange={(e) => updateSplit(i, { participant_id: Number(e.target.value) })}
className="flex-1 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>
<input
type="number"
min={0}
max={100}
value={s.share_percent}
onChange={(e) => updateSplit(i, { share_percent: Number(e.target.value) })}
className="w-16 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
<span className="text-xs text-zinc-500">%</span>
<button type="button" onClick={() => removeSplit(i)} className="text-zinc-500 hover:text-red-400 text-lg leading-none">×</button>
</div>
))}
</div>
<div className="flex gap-2 pt-1">
<button
type="submit"
disabled={createTransaction.isPending}
className="flex-1 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
>
{createTransaction.isPending ? "Saving..." : "Save Transaction"}
</button>
<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>
</div>
</form>
</div>
</div>
);
}