ci / lint-test (push) Successful in 43s
`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).
297 lines
11 KiB
TypeScript
297 lines
11 KiB
TypeScript
"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>
|
||
);
|
||
}
|