import { NextRequest, NextResponse } from "next/server"; import { getCurrentUser } from "@/lib/auth"; import { queryRaw } from "@/lib/db"; import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_RECONCILED_SOURCE, mySplitOf, toDateStr } from "@/lib/analytics-sql"; /** * Fees and interest over an explicit window. * * This used to aggregate every statement ever imported with no date filter, and * the UI printed the result with no period label — so a lifetime-to-date total * read as a current-period one, and grew forever. `months=0` asks for all time * deliberately, which is a different claim from asking for it by accident. */ export async function GET(req: NextRequest) { const user = await getCurrentUser(req); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); const { searchParams } = new URL(req.url); const monthsParam = Number(searchParams.get("months") ?? "12"); const months = Number.isFinite(monthsParam) ? Math.min(Math.max(monthsParam, 0), 120) : 12; const allTime = months === 0; const now = new Date(); const from = new Date(now.getFullYear(), now.getMonth() - months + 1, 1); const fromStr = toDateStr(from); const toStr = toDateStr(new Date(now.getFullYear(), now.getMonth() + 1, 1)); // A statement is dated by the period it covers, not by when it was imported. const stmtWindow = allTime ? "" : `AND billing_end_date >= $2 AND billing_end_date < $3`; const txnWindow = allTime ? "" : `AND t.transaction_date >= $2 AND t.transaction_date < $3`; const windowParams = allTime ? [] : [fromStr, toStr]; // Statement-level fees and interest (aggregated by Gemini from the PDF) const stmtRows = await queryRaw<{ bank_name: string; fees: string; interest: string; }>( `SELECT bank_name, SUM(COALESCE(fees_charged, 0))::numeric(12,2) AS fees, SUM(COALESCE(interest_charged, 0))::numeric(12,2) AS interest FROM statements WHERE owner_id = $1 ${stmtWindow} GROUP BY bank_name HAVING SUM(COALESCE(fees_charged, 0)) + SUM(COALESCE(interest_charged, 0)) > 0 ORDER BY (SUM(COALESCE(fees_charged, 0)) + SUM(COALESCE(interest_charged, 0))) DESC`, [user.id, ...windowParams] ); // Transaction-level fee and interest line items (split-adjusted) const txnRows = await queryRaw<{ id: number; transaction_date: string; description: string; merchant_name: string | null; transaction_type: string; my_amount: string; bank_name: string; }>( `SELECT t.id, t.transaction_date, t.description, t.merchant_name, t.transaction_type, ${mySplitOf(`COALESCE(t.amount_aud, t.amount)`)}::numeric(12,2) AS my_amount, COALESCE(s.bank_name, 'Manual') AS bank_name FROM transactions t LEFT JOIN transaction_overrides o ON o.transaction_id = t.id LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1 ${STATEMENTS_JOIN} WHERE ${OWNER_SCOPE} = $1 AND t.transaction_type IN ('fee', 'interest') AND ${EXCLUDE_RECONCILED_SOURCE} ${txnWindow} ORDER BY t.transaction_date DESC`, [user.id, ...windowParams] ); const by_bank = stmtRows.map((r) => ({ bank_name: r.bank_name, fees: Number(r.fees), interest: Number(r.interest), total: Number(r.fees) + Number(r.interest), })); const transactions = txnRows.map((r) => ({ ...r, my_amount: Number(r.my_amount), })); // Totals from statement-level data (more complete — Gemini reads the statement summary) const total_fees = by_bank.reduce((s, r) => s + r.fees, 0); const total_interest = by_bank.reduce((s, r) => s + r.interest, 0); return NextResponse.json({ by_bank, transactions, total_fees, total_interest, // The period is part of the answer — the client must be able to say what // window these totals cover rather than implying "now". period: { months, from: allTime ? null : fromStr, to: allTime ? null : toStr, all_time: allTime }, }); }