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,63 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { queryRaw, queryRow } from "../../lib/db";
|
||||
import { needsCardMatch } from "../../lib/queries";
|
||||
import {
|
||||
PAYMENT_METHODS,
|
||||
NON_RECONCILING,
|
||||
reconcilesAgainstStatement,
|
||||
} from "../../lib/payment-methods";
|
||||
|
||||
/**
|
||||
* These two lists drifted, and the drift was silent for two months.
|
||||
*
|
||||
* Migration 0018 admitted 'credits' to `transactions_payment_method_check` so
|
||||
* the order pipeline could file gift-card-funded orders. Nothing propagated it
|
||||
* to the hand-written allowlist in `PATCH /api/transactions/[id]` or to the
|
||||
* add-transaction dropdown, so the database held 372 rows carrying a value the
|
||||
* UI could not enter and the API refused to set.
|
||||
*
|
||||
* The failure mode is invisible on screen — a gift-card purchase entered by
|
||||
* hand takes the NULL default, and NULL is *reconcilable*, so it joins the
|
||||
* pending queue and is offered card matches that cannot exist. Accepting one is
|
||||
* destructive: reconciled manual rows are filtered out of every query, so the
|
||||
* spend vanishes while an unrelated card row claims to be it.
|
||||
*
|
||||
* So: assert against the database, not against a second copy of the list.
|
||||
*/
|
||||
describe("payment methods mirror the database", () => {
|
||||
it("PAYMENT_METHODS equals the CHECK constraint's vocabulary", async () => {
|
||||
const row = await queryRow<{ def: string }>(
|
||||
`SELECT pg_get_constraintdef(oid) AS def FROM pg_constraint
|
||||
WHERE conname = 'transactions_payment_method_check'`
|
||||
);
|
||||
expect(row, "constraint transactions_payment_method_check is missing").not.toBeNull();
|
||||
|
||||
const inConstraint = [...row!.def.matchAll(/'([a-z_]+)'::text/g)].map((m) => m[1]);
|
||||
expect(inConstraint.length).toBeGreaterThan(0); // guard against the regex silently matching nothing
|
||||
expect([...inConstraint].sort()).toEqual([...PAYMENT_METHODS].sort());
|
||||
});
|
||||
|
||||
it("reconcilesAgainstStatement() agrees with needsCardMatch() for every method", async () => {
|
||||
// Evaluate the real SQL predicate rather than re-implementing it: this is
|
||||
// the TypeScript mirror, so the SQL is the thing being mirrored.
|
||||
const rows = await queryRaw<{ pm: string | null; sql_reconciles: boolean }>(
|
||||
`SELECT v.payment_method AS pm, ${needsCardMatch("v")} AS sql_reconciles
|
||||
FROM (SELECT unnest($1::text[]) AS payment_method) v`,
|
||||
[[...PAYMENT_METHODS, null]]
|
||||
);
|
||||
expect(rows).toHaveLength(PAYMENT_METHODS.length + 1);
|
||||
|
||||
for (const { pm, sql_reconciles } of rows) {
|
||||
expect(reconcilesAgainstStatement(pm), `payment_method = ${pm ?? "NULL"}`)
|
||||
.toBe(sql_reconciles);
|
||||
}
|
||||
});
|
||||
|
||||
it("holds cash and credits out of the queue, and nothing else", () => {
|
||||
expect([...NON_RECONCILING].sort()).toEqual(["cash", "credits"]);
|
||||
// NULL means *unknown*, never "no card leg is coming" — it must stay a
|
||||
// candidate or every pre-existing row would drop out of reconciliation.
|
||||
expect(reconcilesAgainstStatement(null)).toBe(true);
|
||||
expect(reconcilesAgainstStatement(undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user