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:
@@ -131,21 +131,41 @@ docker exec postgres-personal psql -U personal -d personal < prisma/migrations/<
|
||||
### Key Tables
|
||||
|
||||
- `statements` — one row per billing period per bank account
|
||||
- `transactions` — line items; `statement_id` is nullable (NULL = manual entry); `reconciled_with_id` links a manual tx to its matched statement tx; `payment_method` (migration 0016) is `card | cash | bank_transfer | other`, NULL = unknown; `source` / `source_ref` / `source_account` (migration 0028) identify a row that came from an account feed rather than a statement or a hand entry — `source_ref` is the provider's own id and carries a partial unique index, which is the only thing making a re-import idempotent
|
||||
- `transactions` — line items; `statement_id` is nullable (NULL = manual entry); `reconciled_with_id` links a manual tx to its matched statement tx; `payment_method` (migration 0016, `credits` added by 0018) is `card | cash | bank_transfer | credits | other`, NULL = unknown, and the TypeScript mirror is `src/lib/payment-methods.ts`; `source` / `source_ref` / `source_account` (migration 0028) identify a row that came from an account feed rather than a statement or a hand entry — `source_ref` is the provider's own id and carries a partial unique index, which is the only thing making a re-import idempotent
|
||||
|
||||
### Cash and reconciliation
|
||||
|
||||
`payment_method = 'cash'` excludes a transaction from reconciliation via the
|
||||
`notCash()` fragment in `queries.ts`. Cash never appears on a statement, so
|
||||
`payment_method` in (`cash`, `credits`) excludes a transaction from
|
||||
reconciliation via the `needsCardMatch()` fragment in `queries.ts` (named
|
||||
`notCash()` when it only knew about cash). Cash never appears on a statement, so
|
||||
without it a cash entry sits in the pending queue forever being offered matches
|
||||
within 3 days and 1% on amount — and accepting one is silently destructive:
|
||||
reconciled manual rows are filtered out of every query, so the cash spend
|
||||
disappears while the card transaction it matched claims to be that same spend.
|
||||
|
||||
Only cash is excluded on payment method. Bank transfers *do* appear on a
|
||||
`credits` is the same shape for a different reason: a gift card or store balance
|
||||
already paid the bill, so no card leg is ever coming. `bankLabel()` therefore
|
||||
renders those rows **"Gift Card"** rather than "Manual" — "Manual" reads as
|
||||
"still awaiting a card line", which is wrong here.
|
||||
|
||||
Only those two are excluded on payment method. Bank transfers *do* appear on a
|
||||
statement now that transaction accounts are imported, and NULL means unknown —
|
||||
both stay candidates, preserving the behaviour of every pre-existing row.
|
||||
|
||||
**The vocabulary lives in three places and drifted between them (fixed
|
||||
2026-08-17).** Migration 0018 admitted `credits` to the CHECK constraint for the
|
||||
order pipeline; the `VALID_METHODS` literal in `PATCH /api/transactions/[id]`
|
||||
and the add-transaction dropdown were never updated. So the database held 372
|
||||
gift-card rows carrying a value the UI could not offer and the API answered 400
|
||||
for — a hand-entered gift-card purchase silently took the NULL default, and NULL
|
||||
*is* reconcilable, so it joined the queue to be offered matches that cannot
|
||||
exist. `src/lib/payment-methods.ts` is now the single list (mirroring
|
||||
`statement-types.ts`), and both routes validate against it.
|
||||
`src/__tests__/integration/payment-methods.test.ts` asserts it against
|
||||
`pg_get_constraintdef` and evaluates `needsCardMatch()` in real SQL, so the next
|
||||
migration that widens the constraint fails the suite instead of drifting
|
||||
quietly.
|
||||
|
||||
**Account-feed rows are NOT excluded, and the removed exclusion is worth
|
||||
knowing about.** There was an `awaitsStatementLine()` predicate here, added with
|
||||
the Frollo importer on the premise that a feed row is the account's own ledger
|
||||
@@ -1112,7 +1132,15 @@ See `README.md` → **Known Gaps / TODOs** for full details.
|
||||
drone, camera). Correct in that they are excluded from spend, but it mixes
|
||||
asset disposals into the income line alongside salary.
|
||||
- **`payment_method` is not shown in the transactions list** — settable on create
|
||||
and edit only. Worth a column or filter if cash becomes routine.
|
||||
and edit only. Worth a column or filter if cash becomes routine. A `credits`
|
||||
row is at least visible indirectly: `bankLabel()` renders it "Gift Card"
|
||||
rather than "Manual", and the bank filter has a chip for it.
|
||||
- **219 manual rows carry a NULL `payment_method`**, so they sit in the pending
|
||||
queue being offered card matches. NULL means *unknown*, not *unreconcilable* —
|
||||
that is deliberate — but any of them paid by gift card or cash is being
|
||||
offered a match that cannot exist. No bulk fix, because guessing the method
|
||||
from a description would be a guess written into a column that suppresses
|
||||
reconciliation.
|
||||
- **Pantry receipts still land as yours.** `processReceiptIngestion` hardcodes
|
||||
`DEFAULT_OWNER_ID`; the ingest route takes no owner. Correctable per row now
|
||||
(see "Changing who paid"), but a "Paid by" step at capture time would stop the
|
||||
|
||||
Reference in New Issue
Block a user