Files
finance-app/src/app/api/transactions/bulk/route.ts
T
siddharthd 0b40924af0 fix(security+trips): auth/ownership on all API routes; trip analytics in AUD
Ten routes accepted requests with no getCurrentUser check (transactions/[id],
bulk, splits, tags-on-tx, splits/settle, statements/[id], tags, tags/[id],
merchants, participants/[id]/balance), and by-id routes did no ownership
check at all — any participant could read or modify another's data.

Adds canAccessTransactions() (owner via statement/direct, or split
participant), applies it to every transaction-scoped route, owner-scopes
statements/[id], and rescopes splits/settle in raw SQL so settlement only
touches splits the caller is party to.

Also: all trip analytics now sum COALESCE(amount_aud, amount) instead of raw
amount, matching every other analytics query — trip totals previously added
foreign-currency amounts to AUD ones unit-less.

And rules apply_split no longer delete+reinserts splits (which reset settled
flags on every run) — it upserts share_percent and removes only participants
no longer in the rule.
2026-07-19 20:07:09 +10:00

96 lines
3.3 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";
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 });
}
if (action === "assign_trip") {
const { trip_id } = body as { ids: number[]; trip_id: number | null };
await assignTransactionsToTrip(trip_id, ids);
return NextResponse.json({ updated: ids.length });
}
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
}