diff --git a/src/app/api/merchants/route.ts b/src/app/api/merchants/route.ts index e4d4a35..0655ab7 100644 --- a/src/app/api/merchants/route.ts +++ b/src/app/api/merchants/route.ts @@ -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"); diff --git a/src/app/api/participants/[id]/balance/route.ts b/src/app/api/participants/[id]/balance/route.ts index 1950b17..eae3ae7 100644 --- a/src/app/api/participants/[id]/balance/route.ts +++ b/src/app/api/participants/[id]/balance/route.ts @@ -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( diff --git a/src/app/api/rules/apply/route.ts b/src/app/api/rules/apply/route.ts index baad0c1..a2dd7d5 100644 --- a/src/app/api/rules/apply/route.ts +++ b/src/app/api/rules/apply/route.ts @@ -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] ); } diff --git a/src/app/api/splits/settle/route.ts b/src/app/api/splits/settle/route.ts index 1c3e706..bcfaa2b 100644 --- a/src/app/api/splits/settle/route.ts +++ b/src/app/api/splits/settle/route.ts @@ -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 }); diff --git a/src/app/api/statements/[id]/route.ts b/src/app/api/statements/[id]/route.ts index 7134646..5530c9f 100644 --- a/src/app/api/statements/[id]/route.ts +++ b/src/app/api/statements/[id]/route.ts @@ -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); } diff --git a/src/app/api/tags/[id]/route.ts b/src/app/api/tags/[id]/route.ts index 2cc9e78..aa674ff 100644 --- a/src/app/api/tags/[id]/route.ts +++ b/src/app/api/tags/[id]/route.ts @@ -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 }); diff --git a/src/app/api/tags/route.ts b/src/app/api/tags/route.ts index d3d8b7a..dc32e23 100644 --- a/src/app/api/tags/route.ts +++ b/src/app/api/tags/route.ts @@ -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 }); diff --git a/src/app/api/transactions/[id]/route.ts b/src/app/api/transactions/[id]/route.ts index a288cd2..29da865 100644 --- a/src/app/api/transactions/[id]/route.ts +++ b/src/app/api/transactions/[id]/route.ts @@ -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 { diff --git a/src/app/api/transactions/[id]/splits/route.ts b/src/app/api/transactions/[id]/splits/route.ts index f6ccda4..1326952 100644 --- a/src/app/api/transactions/[id]/splits/route.ts +++ b/src/app/api/transactions/[id]/splits/route.ts @@ -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( `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) { diff --git a/src/app/api/transactions/[id]/tags/route.ts b/src/app/api/transactions/[id]/tags/route.ts index 88c70e3..351d451 100644 --- a/src/app/api/transactions/[id]/tags/route.ts +++ b/src/app/api/transactions/[id]/tags/route.ts @@ -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( diff --git a/src/app/api/transactions/bulk/route.ts b/src/app/api/transactions/bulk/route.ts index 8b15a63..fc44180 100644 --- a/src/app/api/transactions/bulk/route.ts +++ b/src/app/api/transactions/bulk/route.ts @@ -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({ diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 6f2e452..097b82c 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -231,6 +231,22 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte return { data, total, limit, offset }; } +// A user may act on a transaction they own (directly or via the parent +// statement) or one they participate in via a split. +export async function canAccessTransactions(ownerId: number, transactionIds: number[]): Promise { + if (!transactionIds.length) return false; + const rows = await queryRaw<{ n: number }>( + `SELECT COUNT(*)::int AS n + FROM transactions t + LEFT JOIN statements s ON s.id = t.statement_id + WHERE t.id = ANY($2::int[]) + AND (COALESCE(t.owner_id, s.owner_id) = $1 + OR EXISTS (SELECT 1 FROM transaction_splits ts WHERE ts.transaction_id = t.id AND ts.participant_id = $1))`, + [ownerId, transactionIds] + ); + return rows[0]?.n === transactionIds.length; +} + export async function getTransactionById(id: number) { const sql = ` SELECT t.*, @@ -654,7 +670,7 @@ export async function getTrips(ownerId: number): Promise { SELECT t.*, COALESCE(SUM( - CASE WHEN tx.transaction_type IN ('debit','fee','interest') THEN tx.amount ELSE 0 END + CASE WHEN tx.transaction_type IN ('debit','fee','interest') THEN COALESCE(tx.amount_aud, tx.amount) ELSE 0 END ), 0)::float AS total_spend, COUNT(o.transaction_id)::int AS transaction_count FROM trips t @@ -671,7 +687,7 @@ export async function getTripById(id: number, ownerId: number): Promise(` SELECT COALESCE(o.category_override, tx.category, 'other') AS category, - SUM(tx.amount)::float AS amount, + SUM(COALESCE(tx.amount_aud, tx.amount))::float AS amount, COUNT(*)::int AS count FROM transaction_overrides o JOIN transactions tx ON tx.id = o.transaction_id @@ -704,7 +720,7 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise queryRaw<{ date: string; amount: number }>(` SELECT tx.transaction_date::text AS date, - SUM(tx.amount)::float AS amount + SUM(COALESCE(tx.amount_aud, tx.amount))::float AS amount FROM transaction_overrides o JOIN transactions tx ON tx.id = o.transaction_id WHERE o.trip_id = $1 @@ -716,7 +732,7 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise queryRaw<{ merchant: string; amount: number; count: number }>(` SELECT COALESCE(o.merchant_normalized, tx.merchant_normalized, tx.merchant_name, tx.description) AS merchant, - SUM(tx.amount)::float AS amount, + SUM(COALESCE(tx.amount_aud, tx.amount))::float AS amount, COUNT(*)::int AS count FROM transaction_overrides o JOIN transactions tx ON tx.id = o.transaction_id @@ -730,7 +746,7 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise queryRaw<{ tag_id: number; name: string; color: string; amount: number; count: number }>(` SELECT tg.id AS tag_id, tg.name, tg.color, - SUM(tx.amount)::float AS amount, + SUM(COALESCE(tx.amount_aud, tx.amount))::float AS amount, COUNT(DISTINCT tx.id)::int AS count FROM transaction_overrides o JOIN transactions tx ON tx.id = o.transaction_id @@ -746,9 +762,9 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise SELECT p.id AS participant_id, p.name, - SUM(ts.share_percent / 100.0 * tx.amount)::float AS owed, - SUM(CASE WHEN ts.settled THEN ts.share_percent / 100.0 * tx.amount ELSE 0 END)::float AS settled, - SUM(CASE WHEN NOT ts.settled THEN ts.share_percent / 100.0 * tx.amount ELSE 0 END)::float AS unsettled + SUM(ts.share_percent / 100.0 * COALESCE(tx.amount_aud, tx.amount))::float AS owed, + SUM(CASE WHEN ts.settled THEN ts.share_percent / 100.0 * COALESCE(tx.amount_aud, tx.amount) ELSE 0 END)::float AS settled, + SUM(CASE WHEN NOT ts.settled THEN ts.share_percent / 100.0 * COALESCE(tx.amount_aud, tx.amount) ELSE 0 END)::float AS unsettled FROM transaction_overrides o JOIN transactions tx ON tx.id = o.transaction_id JOIN transaction_splits ts ON ts.transaction_id = tx.id