transactions: let a gift card be a payment method you can pick
ci / lint-test (push) Successful in 43s
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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user