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).
101 lines
4.0 KiB
TypeScript
101 lines
4.0 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { getCurrentUser } from "@/lib/auth";
|
|
import { getTransactions } from "@/lib/queries";
|
|
import { queryRaw } from "@/lib/db";
|
|
import { completeSplit } from "@/lib/splits";
|
|
import { isPaymentMethod } from "@/lib/payment-methods";
|
|
|
|
export async function GET(req: NextRequest) {
|
|
const user = await getCurrentUser(req);
|
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
|
|
|
const sp = req.nextUrl.searchParams;
|
|
const parseArr = (key: string) => { const v = sp.get(key); return v ? v.split(",").filter(Boolean) : undefined; };
|
|
const result = await getTransactions(user.id, {
|
|
from: sp.get("from") || undefined,
|
|
to: sp.get("to") || undefined,
|
|
categories: parseArr("categories"),
|
|
exclude_categories: parseArr("exclude_categories"),
|
|
bank_names: parseArr("bank_names"),
|
|
tag_ids: parseArr("tag_ids"),
|
|
transaction_types: parseArr("transaction_types"),
|
|
search: sp.get("search") || undefined,
|
|
statement_id: sp.get("statement_id") || undefined,
|
|
sort_by: sp.get("sort_by") || undefined,
|
|
sort_dir: sp.get("sort_dir") || undefined,
|
|
limit: sp.get("limit") ? Number(sp.get("limit")) : undefined,
|
|
offset: sp.get("offset") ? Number(sp.get("offset")) : undefined,
|
|
amount_min: sp.get("amount_min") ? Number(sp.get("amount_min")) : undefined,
|
|
amount_max: sp.get("amount_max") ? Number(sp.get("amount_max")) : undefined,
|
|
has_split: sp.get("has_split") || undefined,
|
|
trip_id: sp.get("trip_id") || undefined,
|
|
trip_all_rows: sp.get("trip_all_rows") === "1" || undefined,
|
|
});
|
|
|
|
return NextResponse.json(result);
|
|
}
|
|
|
|
export async function POST(req: NextRequest) {
|
|
const user = await getCurrentUser(req);
|
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
|
|
|
const body = await req.json() as {
|
|
date: string;
|
|
description: string;
|
|
amount: number;
|
|
transaction_type?: string;
|
|
merchant_normalized?: string;
|
|
category?: string;
|
|
payment_method?: string;
|
|
splits?: { participant_id: number; share_percent: number }[];
|
|
};
|
|
|
|
if (!body.date || !body.description || body.amount == null) {
|
|
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)
|
|
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)
|
|
VALUES (NULL, $1, $2, $3, $4, $5, $6, $7, $8, (
|
|
SELECT COALESCE(MAX(row_index), -1) + 1 FROM transactions WHERE owner_id = $1 AND statement_id IS NULL
|
|
))
|
|
RETURNING id`,
|
|
[
|
|
user.id,
|
|
body.date,
|
|
body.description,
|
|
body.amount,
|
|
body.transaction_type || "debit",
|
|
body.merchant_normalized || null,
|
|
body.category || null,
|
|
body.payment_method || null,
|
|
]
|
|
);
|
|
const transactionId = txRows[0].id;
|
|
|
|
// Insert splits if provided
|
|
if (body.splits?.length) {
|
|
for (const s of body.splits) {
|
|
await queryRaw(
|
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
|
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
|
|
[transactionId, s.participant_id, s.share_percent]
|
|
);
|
|
}
|
|
// The form lets you name just the other person and shows the total in amber
|
|
// when it is under 100 — which is how "Lawn Mowing, Sonu 50%" was saved with
|
|
// the other half nowhere. Fill in the payer's share rather than refusing:
|
|
// naming only the other person is a reasonable thing to mean.
|
|
await completeSplit(transactionId);
|
|
}
|
|
|
|
return NextResponse.json({ id: transactionId }, { status: 201 });
|
|
}
|