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
|
### Key Tables
|
||||||
|
|
||||||
- `statements` — one row per billing period per bank account
|
- `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
|
### Cash and reconciliation
|
||||||
|
|
||||||
`payment_method = 'cash'` excludes a transaction from reconciliation via the
|
`payment_method` in (`cash`, `credits`) excludes a transaction from
|
||||||
`notCash()` fragment in `queries.ts`. Cash never appears on a statement, so
|
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
|
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:
|
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
|
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.
|
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 —
|
statement now that transaction accounts are imported, and NULL means unknown —
|
||||||
both stay candidates, preserving the behaviour of every pre-existing row.
|
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
|
**Account-feed rows are NOT excluded, and the removed exclusion is worth
|
||||||
knowing about.** There was an `awaitsStatementLine()` predicate here, added with
|
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
|
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
|
drone, camera). Correct in that they are excluded from spend, but it mixes
|
||||||
asset disposals into the income line alongside salary.
|
asset disposals into the income line alongside salary.
|
||||||
- **`payment_method` is not shown in the transactions list** — settable on create
|
- **`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
|
- **Pantry receipts still land as yours.** `processReceiptIngestion` hardcodes
|
||||||
`DEFAULT_OWNER_ID`; the ingest route takes no owner. Correctable per row now
|
`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
|
(see "Changing who paid"), but a "Paid by" step at capture time would stop the
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { getTransactionById, canAccessTransactions } from "@/lib/queries";
|
|||||||
import { getCurrentUser } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { queryRaw } from "@/lib/db";
|
import { queryRaw } from "@/lib/db";
|
||||||
|
import { isPaymentMethod } from "@/lib/payment-methods";
|
||||||
|
|
||||||
const VALID_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
|
const VALID_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
|
||||||
|
|
||||||
@@ -90,9 +91,12 @@ export async function PATCH(
|
|||||||
|
|
||||||
// payment_method is a property of the transaction itself, not a user override
|
// payment_method is a property of the transaction itself, not a user override
|
||||||
// of extracted data, so it lives on the transactions table.
|
// of extracted data, so it lives on the transactions table.
|
||||||
|
//
|
||||||
|
// The allowlist is PAYMENT_METHODS, not a local literal: this one omitted
|
||||||
|
// 'credits' from migration 0018 until 2026-08-17, so the DB admitted a
|
||||||
|
// gift-card row that this route then refused to set or correct.
|
||||||
if (payment_method !== undefined) {
|
if (payment_method !== undefined) {
|
||||||
const VALID_METHODS = ["card", "cash", "bank_transfer", "other"];
|
if (payment_method !== null && !isPaymentMethod(payment_method)) {
|
||||||
if (payment_method !== null && !VALID_METHODS.includes(payment_method)) {
|
|
||||||
return NextResponse.json({ error: "Invalid payment_method" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid payment_method" }, { status: 400 });
|
||||||
}
|
}
|
||||||
await queryRaw(
|
await queryRaw(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getCurrentUser } from "@/lib/auth";
|
|||||||
import { getTransactions } from "@/lib/queries";
|
import { getTransactions } from "@/lib/queries";
|
||||||
import { queryRaw } from "@/lib/db";
|
import { queryRaw } from "@/lib/db";
|
||||||
import { completeSplit } from "@/lib/splits";
|
import { completeSplit } from "@/lib/splits";
|
||||||
|
import { isPaymentMethod } from "@/lib/payment-methods";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const user = await getCurrentUser(req);
|
const user = await getCurrentUser(req);
|
||||||
@@ -53,6 +54,12 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: "date, description, amount are required" }, { status: 400 });
|
return NextResponse.json({ error: "date, description, amount are required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validated here rather than left to the CHECK constraint, which surfaces as
|
||||||
|
// a 500 with a Postgres error rather than a 400 naming the field.
|
||||||
|
if (body.payment_method && !isPaymentMethod(body.payment_method)) {
|
||||||
|
return NextResponse.json({ error: "Invalid payment_method" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
// Insert manual transaction with no statement (statement_id = NULL, owner_id set directly)
|
// Insert manual transaction with no statement (statement_id = NULL, owner_id set directly)
|
||||||
const txRows = await queryRaw<{ id: number }>(
|
const txRows = await queryRaw<{ id: number }>(
|
||||||
`INSERT INTO transactions (statement_id, owner_id, transaction_date, description, amount, transaction_type, merchant_normalized, category, payment_method, row_index)
|
`INSERT INTO transactions (statement_id, owner_id, transaction_date, description, amount, transaction_type, merchant_normalized, category, payment_method, row_index)
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useCreateTransaction, useParticipants, useTags } from "@/lib/hooks";
|
import { useCreateTransaction, useParticipants, useTags } from "@/lib/hooks";
|
||||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||||
|
import {
|
||||||
|
PAYMENT_METHODS,
|
||||||
|
PAYMENT_METHOD_LABELS,
|
||||||
|
NON_RECONCILING_REASON,
|
||||||
|
} from "@/lib/payment-methods";
|
||||||
|
|
||||||
const TRANSACTION_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
|
const TRANSACTION_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
|
||||||
|
|
||||||
@@ -35,7 +40,8 @@ export function AddTransactionModal({
|
|||||||
const [type, setType] = useState(prefill?.transaction_type ?? "debit");
|
const [type, setType] = useState(prefill?.transaction_type ?? "debit");
|
||||||
const [merchant, setMerchant] = useState(prefill?.merchant_normalized ?? "");
|
const [merchant, setMerchant] = useState(prefill?.merchant_normalized ?? "");
|
||||||
const [category, setCategory] = useState(prefill?.category ?? "");
|
const [category, setCategory] = useState(prefill?.category ?? "");
|
||||||
// Cash is excluded from reconciliation — it will never appear on a statement.
|
// Cash and gift-card credits are excluded from reconciliation — neither will
|
||||||
|
// ever produce a card line. See needsCardMatch() in queries.ts.
|
||||||
const [paymentMethod, setPaymentMethod] = useState("");
|
const [paymentMethod, setPaymentMethod] = useState("");
|
||||||
const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
|
const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
|
||||||
const [splits, setSplits] = useState<{ participant_id: number; share_percent: number }[]>(
|
const [splits, setSplits] = useState<{ participant_id: number; share_percent: number }[]>(
|
||||||
@@ -163,21 +169,23 @@ export function AddTransactionModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-zinc-500 mb-1">Paid by</label>
|
{/* "Payment method", not "Paid by" — the edit modal uses that label
|
||||||
|
for the owner, and they are different questions: which instrument
|
||||||
|
the money left by, versus whose money it was. */}
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Payment method</label>
|
||||||
<select
|
<select
|
||||||
value={paymentMethod}
|
value={paymentMethod}
|
||||||
onChange={(e) => setPaymentMethod(e.target.value)}
|
onChange={(e) => setPaymentMethod(e.target.value)}
|
||||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
>
|
>
|
||||||
<option value="">— unknown —</option>
|
<option value="">— unknown —</option>
|
||||||
<option value="card">Card</option>
|
{PAYMENT_METHODS.map((m) => (
|
||||||
<option value="cash">Cash</option>
|
<option key={m} value={m}>{PAYMENT_METHOD_LABELS[m]}</option>
|
||||||
<option value="bank_transfer">Bank transfer</option>
|
))}
|
||||||
<option value="other">Other</option>
|
|
||||||
</select>
|
</select>
|
||||||
{paymentMethod === "cash" && (
|
{NON_RECONCILING_REASON[paymentMethod] && (
|
||||||
<p className="text-[11px] text-zinc-500 mt-1">
|
<p className="text-[11px] text-zinc-500 mt-1">
|
||||||
Cash won't be offered for reconciliation — it never appears on a statement.
|
{NON_RECONCILING_REASON[paymentMethod]}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import {
|
|||||||
import { SplitModal } from "./split-modal";
|
import { SplitModal } from "./split-modal";
|
||||||
import { OrderDetails } from "./order-details";
|
import { OrderDetails } from "./order-details";
|
||||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||||
|
import {
|
||||||
|
PAYMENT_METHODS,
|
||||||
|
PAYMENT_METHOD_LABELS,
|
||||||
|
NON_RECONCILING_REASON,
|
||||||
|
} from "@/lib/payment-methods";
|
||||||
import type { TransactionRow, TagRow } from "@/lib/queries";
|
import type { TransactionRow, TagRow } from "@/lib/queries";
|
||||||
|
|
||||||
const TRANSACTION_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
|
const TRANSACTION_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
|
||||||
@@ -111,6 +116,9 @@ export function EditTransactionModal({
|
|||||||
const [date, setDate] = useState(transaction.transaction_date?.slice(0, 10) ?? "");
|
const [date, setDate] = useState(transaction.transaction_date?.slice(0, 10) ?? "");
|
||||||
const [description, setDescription] = useState(transaction.description);
|
const [description, setDescription] = useState(transaction.description);
|
||||||
const [amount, setAmount] = useState(String(transaction.amount));
|
const [amount, setAmount] = useState(String(transaction.amount));
|
||||||
|
// Manual-only, because the reconciliation queue is `statement_id IS NULL`:
|
||||||
|
// this is the only place payment_method changes what happens to a row.
|
||||||
|
const [paymentMethod, setPaymentMethod] = useState(transaction.payment_method ?? "");
|
||||||
|
|
||||||
const [tripId, setTripId] = useState<number | null>(transaction.trip_id ?? null);
|
const [tripId, setTripId] = useState<number | null>(transaction.trip_id ?? null);
|
||||||
const [ownerId, setOwnerId] = useState<number | null>(transaction.owner_id ?? null);
|
const [ownerId, setOwnerId] = useState<number | null>(transaction.owner_id ?? null);
|
||||||
@@ -153,6 +161,10 @@ export function EditTransactionModal({
|
|||||||
patch.description = description;
|
patch.description = description;
|
||||||
if (parseFloat(amount) !== transaction.amount)
|
if (parseFloat(amount) !== transaction.amount)
|
||||||
patch.amount = parseFloat(amount);
|
patch.amount = parseFloat(amount);
|
||||||
|
// "" means unknown, which is NULL — not the empty string, which the
|
||||||
|
// CHECK constraint would reject.
|
||||||
|
if (paymentMethod !== (transaction.payment_method ?? ""))
|
||||||
|
patch.payment_method = paymentMethod || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tripId !== (transaction.trip_id ?? null))
|
if (tripId !== (transaction.trip_id ?? null))
|
||||||
@@ -307,6 +319,30 @@ export function EditTransactionModal({
|
|||||||
<InlineTags transactionId={transaction.id} initialTags={transaction.tags ?? []} />
|
<InlineTags transactionId={transaction.id} initialTags={transaction.tags ?? []} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Payment method — manual rows only. A statement row is already on
|
||||||
|
a statement, so the field decides nothing there; on a manual row
|
||||||
|
it decides whether reconciliation will ever offer it a match. */}
|
||||||
|
{isManual && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Payment method</label>
|
||||||
|
<select
|
||||||
|
value={paymentMethod}
|
||||||
|
onChange={(e) => setPaymentMethod(e.target.value)}
|
||||||
|
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">— unknown —</option>
|
||||||
|
{PAYMENT_METHODS.map((m) => (
|
||||||
|
<option key={m} value={m}>{PAYMENT_METHOD_LABELS[m]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{NON_RECONCILING_REASON[paymentMethod] && (
|
||||||
|
<p className="text-[11px] text-zinc-500 mt-1">
|
||||||
|
{NON_RECONCILING_REASON[paymentMethod]}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Paid by — sits next to Splits deliberately: together they are
|
{/* Paid by — sits next to Splits deliberately: together they are
|
||||||
whose money went out and whose share it was. */}
|
whose money went out and whose share it was. */}
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -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