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:
@@ -3,6 +3,11 @@
|
||||
import { useState } from "react";
|
||||
import { useCreateTransaction, useParticipants, useTags } from "@/lib/hooks";
|
||||
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"];
|
||||
|
||||
@@ -35,7 +40,8 @@ export function AddTransactionModal({
|
||||
const [type, setType] = useState(prefill?.transaction_type ?? "debit");
|
||||
const [merchant, setMerchant] = useState(prefill?.merchant_normalized ?? "");
|
||||
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 [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
|
||||
const [splits, setSplits] = useState<{ participant_id: number; share_percent: number }[]>(
|
||||
@@ -163,21 +169,23 @@ export function AddTransactionModal({
|
||||
</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
|
||||
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>
|
||||
<option value="card">Card</option>
|
||||
<option value="cash">Cash</option>
|
||||
<option value="bank_transfer">Bank transfer</option>
|
||||
<option value="other">Other</option>
|
||||
{PAYMENT_METHODS.map((m) => (
|
||||
<option key={m} value={m}>{PAYMENT_METHOD_LABELS[m]}</option>
|
||||
))}
|
||||
</select>
|
||||
{paymentMethod === "cash" && (
|
||||
{NON_RECONCILING_REASON[paymentMethod] && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
import { SplitModal } from "./split-modal";
|
||||
import { OrderDetails } from "./order-details";
|
||||
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";
|
||||
|
||||
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 [description, setDescription] = useState(transaction.description);
|
||||
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 [ownerId, setOwnerId] = useState<number | null>(transaction.owner_id ?? null);
|
||||
@@ -153,6 +161,10 @@ export function EditTransactionModal({
|
||||
patch.description = description;
|
||||
if (parseFloat(amount) !== transaction.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))
|
||||
@@ -307,6 +319,30 @@ export function EditTransactionModal({
|
||||
<InlineTags transactionId={transaction.id} initialTags={transaction.tags ?? []} />
|
||||
</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
|
||||
whose money went out and whose share it was. */}
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user