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
+3
View File
@@ -1,7 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { getMerchantSuggestions, getBankNames } from "@/lib/queries";
import { getCurrentUser } from "@/lib/auth";
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const search = req.nextUrl.searchParams.get("search");
const type = req.nextUrl.searchParams.get("type");
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { queryRaw } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
interface BalanceRow {
participant_id: number;
@@ -9,9 +10,11 @@ interface BalanceRow {
}
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;
const rows = await queryRaw<BalanceRow>(
+9 -2
View File
@@ -151,11 +151,18 @@ export async function POST(req: NextRequest) {
if (actions.apply_split?.length) {
if (splitFrom && tx.transaction_date < splitFrom) continue;
await queryRaw(`DELETE FROM transaction_splits WHERE transaction_id = $1`, [tx.id]);
// Remove only participants no longer in the rule's split, and upsert the
// rest — a plain delete+reinsert would reset settled flags on every run.
await queryRaw(
`DELETE FROM transaction_splits WHERE transaction_id = $1 AND participant_id != ALL($2::int[])`,
[tx.id, actions.apply_split.map((s) => s.participant_id)]
);
for (const s of actions.apply_split) {
await queryRaw(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
VALUES ($1, $2, $3)
ON CONFLICT (transaction_id, participant_id)
DO UPDATE SET share_percent = EXCLUDED.share_percent`,
[tx.id, s.participant_id, s.share_percent]
);
}
+29 -13
View File
@@ -1,29 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { queryRaw } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
// A split may be settled by the transaction's effective owner or by the
// participant the split belongs to.
const SCOPE = `
AND EXISTS (
SELECT 1 FROM transactions t
LEFT JOIN statements s ON s.id = t.statement_id
WHERE t.id = transaction_splits.transaction_id
AND (COALESCE(t.owner_id, s.owner_id) = $2 OR transaction_splits.participant_id = $2)
)`;
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 { participant_id, split_ids } = body as {
participant_id?: number;
split_ids?: number[];
};
const now = new Date();
if (participant_id) {
const result = await prisma.transaction_splits.updateMany({
where: { participant_id, settled: false },
data: { settled: true, settled_at: now },
});
return NextResponse.json({ settled: result.count });
const rows = await queryRaw<{ id: number }>(
`UPDATE transaction_splits SET settled = true, settled_at = NOW()
WHERE participant_id = $1 AND settled = false ${SCOPE}
RETURNING id`,
[participant_id, user.id]
);
return NextResponse.json({ settled: rows.length });
}
if (split_ids?.length) {
const result = await prisma.transaction_splits.updateMany({
where: { id: { in: split_ids }, settled: false },
data: { settled: true, settled_at: now },
});
return NextResponse.json({ settled: result.count });
const rows = await queryRaw<{ id: number }>(
`UPDATE transaction_splits SET settled = true, settled_at = NOW()
WHERE id = ANY($1::int[]) AND settled = false ${SCOPE}
RETURNING id`,
[split_ids, user.id]
);
return NextResponse.json({ settled: rows.length });
}
return NextResponse.json({ error: "participant_id or split_ids required" }, { status: 400 });
+7 -2
View File
@@ -1,12 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { getStatementById } from "@/lib/queries";
import { getCurrentUser } from "@/lib/auth";
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;
const stmt = await getStatementById(Number(id));
if (!stmt) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!stmt || stmt.owner_id !== user.id) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(stmt);
}
+4 -1
View File
@@ -1,7 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { queryRaw } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
export async function DELETE(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;
await queryRaw(`DELETE FROM tags WHERE id = $1`, [Number(id)]);
return NextResponse.json({ ok: true });
+6 -1
View File
@@ -1,13 +1,18 @@
import { NextRequest, NextResponse } from "next/server";
import { getTags } from "@/lib/queries";
import { queryRaw } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
export async function GET() {
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const tags = await getTags();
return NextResponse.json(tags);
}
export async function POST(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const { name, color } = await req.json();
if (!name?.trim()) {
return NextResponse.json({ error: "name required" }, { status: 400 });
+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 {
+13 -1
View File
@@ -1,6 +1,8 @@
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;
@@ -19,10 +21,15 @@ interface SplitRow {
}
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 splits = await queryRaw<SplitRow>(
`SELECT ts.*, p.name
FROM transaction_splits ts
@@ -38,8 +45,13 @@ 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) {
@@ -1,8 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { queryRaw } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
import { canAccessTransactions } from "@/lib/queries";
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;
if (!(await canAccessTransactions(user.id, [Number(id)]))) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const { tag_id } = await req.json();
if (!tag_id) return NextResponse.json({ error: "tag_id required" }, { status: 400 });
await queryRaw(
@@ -13,7 +20,12 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
}
export async function DELETE(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 { tag_id } = await req.json();
if (!tag_id) return NextResponse.json({ error: "tag_id required" }, { status: 400 });
await queryRaw(
+8 -1
View File
@@ -1,8 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma, queryRaw } from "@/lib/db";
import { assignTransactionsToTrip } from "@/lib/queries";
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;
@@ -17,6 +20,10 @@ export async function POST(req: NextRequest) {
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({