Files
finance-app/src/app/api/transactions/route.ts
T
siddharthd 9f1be74330
ci / lint-test (push) Successful in 43s
transactions: let a gift card be a payment method you can pick
`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).
2026-08-17 18:14:58 +10:00

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 });
}