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.
This commit is contained in:
2026-07-19 20:07:09 +10:00
parent 48ec151c15
commit 0b40924af0
12 changed files with 133 additions and 33 deletions
+13 -2
View File
@@ -1,15 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import { getTransactionById } from "@/lib/queries";
import { getTransactionById, canAccessTransactions } from "@/lib/queries";
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { queryRaw } from "@/lib/db";
const VALID_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
export async function GET(
_req: NextRequest,
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);
@@ -19,8 +25,13 @@ 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 } = body as {