`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).