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).
64 lines
3.0 KiB
TypeScript
64 lines
3.0 KiB
TypeScript
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);
|
|
});
|
|
});
|