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).
205 lines
8.7 KiB
TypeScript
205 lines
8.7 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { getTransactionById, canAccessTransactions } from "@/lib/queries";
|
|
import { getCurrentUser } from "@/lib/auth";
|
|
import { prisma } from "@/lib/db";
|
|
import { queryRaw } from "@/lib/db";
|
|
import { isPaymentMethod } from "@/lib/payment-methods";
|
|
|
|
const VALID_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
|
|
|
|
export async function GET(
|
|
req: NextRequest,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
const user = await getCurrentUser(req);
|
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
|
const { id } = await params;
|
|
if (!(await canAccessTransactions(user.id, [Number(id)]))) {
|
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
}
|
|
const txn = await getTransactionById(Number(id));
|
|
if (!txn) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
return NextResponse.json(txn);
|
|
}
|
|
|
|
export async function PATCH(
|
|
req: NextRequest,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
const user = await getCurrentUser(req);
|
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
|
const { id } = await params;
|
|
const transactionId = Number(id);
|
|
if (!(await canAccessTransactions(user.id, [transactionId]))) {
|
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
}
|
|
const body = await req.json();
|
|
|
|
const { category, merchant_normalized, notes, transaction_type, my_share_percent, description, amount, transaction_date, trip_id, payment_method, owner_id, release } = body as {
|
|
category?: string;
|
|
merchant_normalized?: string;
|
|
notes?: string;
|
|
transaction_type?: string;
|
|
payment_method?: string | null;
|
|
my_share_percent?: number | null;
|
|
description?: string;
|
|
amount?: number;
|
|
transaction_date?: string;
|
|
trip_id?: number | null;
|
|
owner_id?: number;
|
|
/** Acknowledge that reassigning will remove the row from my own view. */
|
|
release?: boolean;
|
|
};
|
|
|
|
if (my_share_percent !== undefined && my_share_percent !== null) {
|
|
if (typeof my_share_percent !== "number" || my_share_percent <= 0 || my_share_percent > 100) {
|
|
return NextResponse.json({ error: "my_share_percent must be between 1 and 100" }, { status: 400 });
|
|
}
|
|
}
|
|
|
|
// Direct field edits — only allowed for manual transactions (statement_id IS NULL)
|
|
const directFields = [description, amount, transaction_date].filter((v) => v !== undefined);
|
|
if (directFields.length > 0) {
|
|
const txRows = await queryRaw<{ statement_id: number | null }>(
|
|
`SELECT statement_id FROM transactions WHERE id = $1`,
|
|
[transactionId]
|
|
);
|
|
if (!txRows[0]?.statement_id) {
|
|
const setClauses: string[] = [];
|
|
const params: unknown[] = [];
|
|
let idx = 1;
|
|
if (description !== undefined) { setClauses.push(`description = $${idx++}`); params.push(description); }
|
|
if (amount !== undefined) { setClauses.push(`amount = $${idx++}`); params.push(amount); }
|
|
if (transaction_date !== undefined) { setClauses.push(`transaction_date = $${idx++}`); params.push(transaction_date); }
|
|
if (setClauses.length) {
|
|
params.push(transactionId);
|
|
await queryRaw(`UPDATE transactions SET ${setClauses.join(", ")} WHERE id = $${idx}`, params);
|
|
}
|
|
}
|
|
}
|
|
|
|
// transaction_type is a direct correction on the transactions table
|
|
if (transaction_type !== undefined) {
|
|
if (!VALID_TYPES.includes(transaction_type)) {
|
|
return NextResponse.json({ error: "Invalid transaction_type" }, { status: 400 });
|
|
}
|
|
await queryRaw(
|
|
`UPDATE transactions SET transaction_type = $1 WHERE id = $2`,
|
|
[transaction_type, transactionId]
|
|
);
|
|
}
|
|
|
|
// payment_method is a property of the transaction itself, not a user override
|
|
// 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 !== null && !isPaymentMethod(payment_method)) {
|
|
return NextResponse.json({ error: "Invalid payment_method" }, { status: 400 });
|
|
}
|
|
await queryRaw(
|
|
`UPDATE transactions SET payment_method = $1 WHERE id = $2`,
|
|
[payment_method, transactionId]
|
|
);
|
|
}
|
|
|
|
// Owner — who actually paid. A direct column, not an override: it decides
|
|
// whose account the money left, which every balance and every spend analytic
|
|
// scopes on (MY_SPEND_SCOPE / OWNER_SCOPE).
|
|
//
|
|
// Handled here rather than in the direct-fields block above because it has
|
|
// its own rules, and because getting it wrong is not a cosmetic error: it
|
|
// moves money between two people's ledgers.
|
|
if (owner_id !== undefined) {
|
|
if (typeof owner_id !== "number" || !Number.isInteger(owner_id)) {
|
|
return NextResponse.json({ error: "owner_id must be a participant id" }, { status: 400 });
|
|
}
|
|
|
|
const txRows = await queryRaw<{ statement_id: number | null; owner_id: number | null }>(
|
|
`SELECT statement_id, owner_id FROM transactions WHERE id = $1`,
|
|
[transactionId]
|
|
);
|
|
const tx = txRows[0];
|
|
if (!tx) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
|
|
// A statement row's owner is the statement's owner — the effective owner is
|
|
// COALESCE(t.owner_id, s.owner_id), so setting it here would either be a
|
|
// no-op or would silently detach one row from the account it was extracted
|
|
// from. Reassigning the statement is the correct move and moves its rows
|
|
// with it.
|
|
if (tx.statement_id) {
|
|
return NextResponse.json(
|
|
{
|
|
error:
|
|
"This row came from a statement, so its owner is the statement's owner. Change the statement's owner instead — that moves every row on it.",
|
|
code: "statement_owned",
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const known = await queryRaw(`SELECT id FROM participants WHERE id = $1`, [owner_id]);
|
|
if (!known.length) {
|
|
return NextResponse.json({ error: "Unknown participant" }, { status: 400 });
|
|
}
|
|
|
|
// The one-way door. Access is "owner OR holds a split"
|
|
// (canAccessTransactions), so handing a row to someone else while holding
|
|
// no split on it removes it from the caller's list and 404s every route
|
|
// that could put it back — only the new owner can undo it. Splitting first
|
|
// keeps the row reachable AND is what makes the balance correct, so the
|
|
// refusal points at the step that was skipped rather than just blocking.
|
|
if (owner_id !== user.id && !release) {
|
|
const mine = await queryRaw(
|
|
`SELECT 1 FROM transaction_splits WHERE transaction_id = $1 AND participant_id = $2`,
|
|
[transactionId, user.id]
|
|
);
|
|
if (!mine.length) {
|
|
return NextResponse.json(
|
|
{
|
|
error:
|
|
"You hold no split on this transaction, so reassigning it would remove it from your view for good — only the new owner could change it back. Add your split first, or confirm you are giving it away entirely.",
|
|
code: "would_lose_access",
|
|
},
|
|
{ status: 409 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// Existing splits are deliberately left alone. They record shares, not
|
|
// direction: getParticipantBalances derives who owes whom from ownership,
|
|
// so a 50/50 row flips from "they owe me" to "I owe them" with no rewrite.
|
|
await queryRaw(`UPDATE transactions SET owner_id = $1 WHERE id = $2`, [owner_id, transactionId]);
|
|
}
|
|
|
|
// category/merchant/notes/my_share_percent/trip_id go through the overrides table
|
|
const hasOverride = category !== undefined || merchant_normalized !== undefined || notes !== undefined || my_share_percent !== undefined || trip_id !== undefined;
|
|
if (!hasOverride) {
|
|
return NextResponse.json({ ok: true });
|
|
}
|
|
|
|
const data: Record<string, unknown> = { updated_at: new Date() };
|
|
if (category !== undefined) data.category_override = category;
|
|
if (merchant_normalized !== undefined) data.merchant_normalized = merchant_normalized;
|
|
if (notes !== undefined) data.notes = notes;
|
|
if (my_share_percent !== undefined) data.my_share_percent = my_share_percent;
|
|
if (trip_id !== undefined) data.trip_id = trip_id;
|
|
|
|
const override = await prisma.transaction_overrides.upsert({
|
|
where: { transaction_id: transactionId },
|
|
update: data,
|
|
create: {
|
|
transaction_id: transactionId,
|
|
category_override: category || null,
|
|
merchant_normalized: merchant_normalized || null,
|
|
notes: notes || null,
|
|
my_share_percent: my_share_percent != null ? String(my_share_percent) : null,
|
|
trip_id: trip_id ?? null,
|
|
},
|
|
});
|
|
|
|
return NextResponse.json(override);
|
|
}
|