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
+56
View File
@@ -0,0 +1,56 @@
// Canonical payment methods. Mirrors `transactions_payment_method_check`
// (migration 0018, which admitted 'credits') — keep them in sync.
//
// This list exists because the drift already happened: the CHECK constraint
// admitted 'credits' from 0018 onward while the PATCH route's hand-written
// allowlist and the add-transaction dropdown both still stopped at 'other'.
// A gift-card row could be written by the order pipeline but neither entered
// nor corrected by hand.
export const PAYMENT_METHODS = [
"card",
"cash",
"bank_transfer",
"credits",
"other",
] as const;
export type PaymentMethod = (typeof PAYMENT_METHODS)[number];
export const PAYMENT_METHOD_LABELS: Record<PaymentMethod, string> = {
card: "Card",
cash: "Cash",
bank_transfer: "Bank transfer",
credits: "Gift card / credits",
other: "Other",
};
export function isPaymentMethod(v: string | null | undefined): v is PaymentMethod {
return !!v && (PAYMENT_METHODS as readonly string[]).includes(v);
}
export function formatPaymentMethod(v: string | null | undefined): string {
return isPaymentMethod(v) ? PAYMENT_METHOD_LABELS[v] : "Unknown";
}
/**
* Methods held out of the reconciliation queue. The TypeScript mirror of
* `needsCardMatch()` in `queries.ts` — if one changes, change both.
*
* NULL means *unknown*, not "no card leg", so it stays a candidate.
*/
export const NON_RECONCILING: readonly PaymentMethod[] = ["cash", "credits"];
/**
* Why a method never reaches the queue. Shown at the point of choosing it,
* because the consequence is invisible afterwards: the row simply never
* appears in pending reconciliation, which reads identically to a bug.
*/
export const NON_RECONCILING_REASON: Record<string, string> = {
cash: "Cash won't be offered for reconciliation — it never appears on a statement.",
credits:
"A gift-card payment won't be offered for reconciliation — the balance already paid it, so no card line is coming. It shows as “Gift Card” rather than “Manual” in the bank column.",
};
export function reconcilesAgainstStatement(v: string | null | undefined): boolean {
return !isPaymentMethod(v) || !NON_RECONCILING.includes(v);
}