transactions: let a gift card be a payment method you can pick
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).
This commit is contained in:
2026-08-17 18:14:58 +10:00
parent 4f307e9517
commit 9f1be74330
7 changed files with 217 additions and 15 deletions
+16 -8
View File
@@ -3,6 +3,11 @@
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"];
@@ -35,7 +40,8 @@ export function AddTransactionModal({
const [type, setType] = useState(prefill?.transaction_type ?? "debit");
const [merchant, setMerchant] = useState(prefill?.merchant_normalized ?? "");
const [category, setCategory] = useState(prefill?.category ?? "");
// Cash is excluded from reconciliation — it will never appear on a statement.
// 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 }[]>(
@@ -163,21 +169,23 @@ export function AddTransactionModal({
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Paid by</label>
{/* "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>
<option value="card">Card</option>
<option value="cash">Cash</option>
<option value="bank_transfer">Bank transfer</option>
<option value="other">Other</option>
{PAYMENT_METHODS.map((m) => (
<option key={m} value={m}>{PAYMENT_METHOD_LABELS[m]}</option>
))}
</select>
{paymentMethod === "cash" && (
{NON_RECONCILING_REASON[paymentMethod] && (
<p className="text-[11px] text-zinc-500 mt-1">
Cash won&apos;t be offered for reconciliation — it never appears on a statement.
{NON_RECONCILING_REASON[paymentMethod]}
</p>
)}
</div>