Files
finance-app/src/app/api/transactions/bulk/route.ts
T
siddharthd cb7665ded1
ci / lint-test (push) Successful in 52s
Let everyone on a trip see it, and give payments their scope back
Trips were scoped to trips.owner_id, so Sonu saw no trips at all — despite
having paid for 104 of the tagged rows herself. Her own spending was invisible
on the only page organised around it.

A participant is now anyone with a split on, who paid for, or whose payment is
scoped to, a transaction tagged to the trip. Derived, not stored. A
trip_participants table was designed and rejected: the expenses already carry
the fact, and two records of one fact drift apart. Deriving it also excludes
Singapore + Bangkok 2026 from Sonu for free, which a table would have to be kept
in sync to do. Siddharth 4 trips, Sonu 3, Molina 1.

Everything about a trip is shared except delete. Both trip foreign keys are
ON DELETE SET NULL, so deleting Europe 2026 untags 210 transactions and NULLs
the trip scope on 6 payments — where the hand-derived Europe-first allocation
lives, which nothing recomputes. That stays with the owner.

Trip owed now returns both directions and nets neither. An obligation lives on a
row someone else paid for, so a viewer-as-payer figure can never hold it, and
Sonu's Europe read "you are owed $2,408.24" while omitting the $8,004.04 she
owed. Collapsing the two into a signed net is the tempting next step and would
have corrupted the scope allocation: the grouped-payment allocation cleared each
trip against the one-directional gross, so redefining the debt afterwards turns
$8,004.04 already allocated into an $802.75 over-allocation with household
understated by the same amount. Verified byte-identical — Auckland $1,505.64,
Europe Molina -$816.16, Europe Sonu $0.00, Sonu + Sunny $0.00.

getTransactions gained trip_all_rows so a participant sees the whole trip. It is
opt-in and not implied by trip_id, because the same endpoint backs the main
transactions list and its trip filter must keep owner scoping. Participation is
re-checked in SQL, so passing the flag for someone else's trip returns nothing.

Payments can finally say what they settle. trip_id has existed since migration
0022 but POST never read it and GET never returned it, so every payment made in
the app landed on household and the 9 trip-scoped rows were hand-written SQL.
"Both" needs no new shape — one row per scope sharing a linked_transaction_id.

Three write paths had no authorisation at all and were reachable by any
participant: assignTransactionsToTrip checked nothing, DELETE on a payment
deleted by bare id, and POST accepted any from/to pair. All three now check.

Also fixes the test suite, which was pointing at postgres-pantry: container IPs
move on recreation and 172.22.0.47 stopped being postgres-personal. It only
failed safe because the credentials did not match — resetDB now refuses to
truncate anything not named personal_test.

22 new tests, 276 passing, build clean.
2026-08-02 19:11:19 +10:00

140 lines
5.1 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { prisma, queryRaw } from "@/lib/db";
import { assignTransactionsToTrip, canAccessTransactions } from "@/lib/queries";
import { getCurrentUser } from "@/lib/auth";
import { applyRuleActions, captureSnapshot } from "@/lib/rule-actions";
import type { Actions } from "@/lib/rules";
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();
const { action, ids, category, merchant_normalized, splits, tag_id } = body as {
action: string;
ids: number[];
category?: string;
merchant_normalized?: string;
splits?: { participant_id: number; share_percent: number }[];
tag_id?: number;
};
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return NextResponse.json({ error: "ids required" }, { status: 400 });
}
if (!(await canAccessTransactions(user.id, ids.map(Number)))) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (action === "categorize" && category) {
const ops = ids.map((id) =>
prisma.transaction_overrides.upsert({
where: { transaction_id: id },
update: { category_override: category, updated_at: new Date() },
create: { transaction_id: id, category_override: category },
})
);
await prisma.$transaction(ops);
return NextResponse.json({ updated: ids.length });
}
if (action === "normalize" && merchant_normalized) {
const ops = ids.map((id) =>
prisma.transaction_overrides.upsert({
where: { transaction_id: id },
update: { merchant_normalized, updated_at: new Date() },
create: { transaction_id: id, merchant_normalized },
})
);
await prisma.$transaction(ops);
return NextResponse.json({ updated: ids.length });
}
if (action === "split" && Array.isArray(splits) && splits.length > 0) {
const total = splits.reduce((s, x) => s + x.share_percent, 0);
if (Math.abs(total - 100) > 0.01) {
return NextResponse.json({ error: "Shares must sum to 100%" }, { status: 400 });
}
await prisma.$transaction(
ids.flatMap((id) => [
prisma.transaction_splits.deleteMany({ where: { transaction_id: id } }),
prisma.transaction_splits.createMany({
data: splits.map((s) => ({
transaction_id: id,
participant_id: s.participant_id,
share_percent: s.share_percent,
})),
}),
])
);
return NextResponse.json({ updated: ids.length });
}
if ((action === "tag" || action === "untag") && tag_id) {
if (action === "tag") {
for (const id of ids) {
await queryRaw(
`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[id, tag_id]
);
}
} else {
await queryRaw(
`DELETE FROM transaction_tags WHERE transaction_id = ANY($1::int[]) AND tag_id = $2`,
[ids, tag_id]
);
}
return NextResponse.json({ updated: ids.length });
}
// Quick action: fire a saved rule's actions at the current selection. The
// selection replaces the rule's conditions, which are not evaluated at all.
// Recorded as a rule_apply_run so it can be reverted like any other run.
if (action === "apply_rule") {
const { rule_id } = body as { rule_id?: number };
if (!rule_id) return NextResponse.json({ error: "rule_id required" }, { status: 400 });
const rules = await queryRaw<{ id: number; name: string; actions: unknown }>(
`SELECT id, name, actions FROM rules WHERE id = $1 AND owner_id = $2`,
[Number(rule_id), user.id]
);
if (!rules.length) return NextResponse.json({ error: "Rule not found" }, { status: 404 });
const actions = (typeof rules[0].actions === "string"
? JSON.parse(rules[0].actions)
: rules[0].actions) as Actions;
const snapshot = await captureSnapshot(ids.map(Number));
for (const id of ids) {
await applyRuleActions(Number(id), actions);
}
const run = await queryRaw<{ id: number }>(
`INSERT INTO rule_apply_runs (owner_id, split_from, matched, transactions_affected, snapshot,
rule_id, rule_name, source)
VALUES ($1, NULL, $2, $3, $4, $5, $6, 'selection') RETURNING id`,
[user.id, ids.length, ids.length, JSON.stringify(snapshot),
rules[0].id, rules[0].name]
);
return NextResponse.json({ updated: ids.length, run_id: run[0].id, rule: rules[0].name });
}
if (action === "assign_trip") {
const { trip_id } = body as { ids: number[]; trip_id: number | null };
try {
// `updated` is what actually moved, not what was asked for — ids the
// caller cannot see are skipped rather than silently applied.
const updated = await assignTransactionsToTrip(trip_id, ids, user.id);
return NextResponse.json({ updated, requested: ids.length });
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : "Failed to assign" },
{ status: 403 }
);
}
}
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
}