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); }); });