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.
92 lines
2.7 KiB
TypeScript
92 lines
2.7 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { prisma } from "@/lib/db";
|
|
import { queryRaw } from "@/lib/db";
|
|
import { getCurrentUser } from "@/lib/auth";
|
|
import { canAccessTransactions } from "@/lib/queries";
|
|
|
|
interface SplitInput {
|
|
participant_id: number;
|
|
share_percent: number;
|
|
}
|
|
|
|
interface SplitRow {
|
|
id: number;
|
|
transaction_id: number;
|
|
participant_id: number;
|
|
name: string;
|
|
share_percent: number;
|
|
settled: boolean;
|
|
settled_at: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
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 splits = await queryRaw<SplitRow>(
|
|
`SELECT ts.*, p.name
|
|
FROM transaction_splits ts
|
|
JOIN participants p ON p.id = ts.participant_id
|
|
WHERE ts.transaction_id = $1
|
|
ORDER BY p.name`,
|
|
[Number(id)]
|
|
);
|
|
return NextResponse.json(splits);
|
|
}
|
|
|
|
export async function POST(
|
|
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 { splits } = (await req.json()) as { splits: SplitInput[] };
|
|
|
|
if (!splits || !Array.isArray(splits) || splits.length === 0) {
|
|
return NextResponse.json({ error: "splits array required" }, { status: 400 });
|
|
}
|
|
|
|
const total = splits.reduce((sum, s) => sum + Number(s.share_percent), 0);
|
|
if (Math.abs(total - 100) > 0.01) {
|
|
return NextResponse.json(
|
|
{ error: `Shares must sum to 100%, got ${total}%` },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Replace all splits for this transaction atomically
|
|
await prisma.$transaction([
|
|
prisma.transaction_splits.deleteMany({ where: { transaction_id: transactionId } }),
|
|
...splits.map((s) =>
|
|
prisma.transaction_splits.create({
|
|
data: {
|
|
transaction_id: transactionId,
|
|
participant_id: s.participant_id,
|
|
share_percent: s.share_percent,
|
|
},
|
|
})
|
|
),
|
|
]);
|
|
|
|
const result = await queryRaw<SplitRow>(
|
|
`SELECT ts.*, p.name FROM transaction_splits ts
|
|
JOIN participants p ON p.id = ts.participant_id
|
|
WHERE ts.transaction_id = $1 ORDER BY p.name`,
|
|
[transactionId]
|
|
);
|
|
|
|
return NextResponse.json(result);
|
|
}
|