diff --git a/src/app/api/analytics/fees/route.ts b/src/app/api/analytics/fees/route.ts new file mode 100644 index 0000000..48cc459 --- /dev/null +++ b/src/app/api/analytics/fees/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { queryRaw } from "@/lib/db"; + +export async function GET(req: NextRequest) { + const user = await getCurrentUser(req); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + + // 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 + 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] + ); + + // 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, + CASE + WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 + ELSE COALESCE(t.amount_aud, t.amount) + END::numeric(12,2) AS my_amount, + s.bank_name + FROM transactions t + LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1 + JOIN statements s ON s.id = t.statement_id + WHERE s.owner_id = $1 + AND t.transaction_type IN ('fee', 'interest') + ORDER BY t.transaction_date DESC`, + [user.id] + ); + + 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 }); +} diff --git a/src/app/api/analytics/subscriptions/route.ts b/src/app/api/analytics/subscriptions/route.ts new file mode 100644 index 0000000..d5f3208 --- /dev/null +++ b/src/app/api/analytics/subscriptions/route.ts @@ -0,0 +1,133 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { queryRaw } from "@/lib/db"; + +export async function GET(req: NextRequest) { + const user = await getCurrentUser(req); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + + const rows = await queryRaw<{ + merchant: string; + category: string; + occurrences: number; + avg_amount: string; + first_seen: string; + last_seen: string; + total_paid: string; + median_interval: string; + frequency: string | null; + }>( + `WITH merchant_txns AS ( + SELECT + COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) AS merchant, + COALESCE(o.category_override, t.category) AS category, + t.transaction_date, + CASE + WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 + ELSE COALESCE(t.amount_aud, t.amount) + END AS my_amount + 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 + JOIN statements s ON s.id = t.statement_id + WHERE s.owner_id = $1 + AND t.transaction_type IN ('debit', 'fee') + AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) IS NOT NULL + ), + merchant_with_lag AS ( + SELECT + merchant, + category, + transaction_date, + my_amount, + LAG(transaction_date) OVER (PARTITION BY merchant ORDER BY transaction_date) AS prev_date + FROM merchant_txns + ), + merchant_stats AS ( + SELECT + merchant, + MODE() WITHIN GROUP (ORDER BY category) AS category, + COUNT(*) + 1 AS occurrences, + AVG(my_amount)::numeric(12,2) AS avg_amount, + MIN(transaction_date) AS first_seen, + MAX(transaction_date) AS last_seen, + SUM(my_amount)::numeric(12,2) AS total_paid, + PERCENTILE_CONT(0.5) WITHIN GROUP ( + ORDER BY (transaction_date - prev_date)::int + ) AS median_interval, + STDDEV((transaction_date - prev_date)::int) AS stddev_interval + FROM merchant_with_lag + WHERE prev_date IS NOT NULL + GROUP BY merchant + HAVING COUNT(*) >= 2 + ), + classified AS ( + SELECT *, + CASE + WHEN median_interval BETWEEN 6 AND 8 THEN 'weekly' + WHEN median_interval BETWEEN 13 AND 16 THEN 'fortnightly' + WHEN median_interval BETWEEN 27 AND 35 THEN 'monthly' + WHEN median_interval BETWEEN 85 AND 95 THEN 'quarterly' + WHEN median_interval BETWEEN 350 AND 380 THEN 'annual' + ELSE NULL + END AS frequency + FROM merchant_stats + WHERE stddev_interval < median_interval * 0.4 + AND ( + median_interval BETWEEN 6 AND 8 OR + median_interval BETWEEN 13 AND 16 OR + median_interval BETWEEN 27 AND 35 OR + median_interval BETWEEN 85 AND 95 OR + median_interval BETWEEN 350 AND 380 + ) + ) + SELECT merchant, category, occurrences, avg_amount, first_seen, last_seen, total_paid, + median_interval::numeric(8,1), frequency + FROM classified + WHERE frequency IS NOT NULL + ORDER BY + CASE frequency + WHEN 'weekly' THEN avg_amount * 4.33 + WHEN 'fortnightly' THEN avg_amount * 2.17 + WHEN 'monthly' THEN avg_amount + WHEN 'quarterly' THEN avg_amount / 3 + WHEN 'annual' THEN avg_amount / 12 + END DESC NULLS LAST`, + [user.id] + ); + + const today = new Date(); + const subscriptions = rows.map((r) => { + const lastSeen = new Date(r.last_seen); + const daysSinceLast = Math.floor((today.getTime() - lastSeen.getTime()) / 86400000); + const medianInterval = Number(r.median_interval); + const is_active = daysSinceLast < medianInterval * 1.5; + + const avg = Number(r.avg_amount); + const monthly_equiv = + r.frequency === "weekly" ? avg * 4.33 : + r.frequency === "fortnightly" ? avg * 2.17 : + r.frequency === "quarterly" ? avg / 3 : + r.frequency === "annual" ? avg / 12 : + avg; + + return { + merchant: r.merchant, + category: r.category, + frequency: r.frequency, + avg_amount: avg, + monthly_equiv: Math.round(monthly_equiv * 100) / 100, + first_seen: r.first_seen, + last_seen: r.last_seen, + occurrences: r.occurrences, + total_paid: Number(r.total_paid), + is_active, + }; + }); + + const total_monthly_equiv = subscriptions + .filter((s) => s.is_active) + .reduce((sum, s) => sum + s.monthly_equiv, 0); + + return NextResponse.json({ subscriptions, total_monthly_equiv: Math.round(total_monthly_equiv * 100) / 100 }); +} diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx new file mode 100644 index 0000000..3e42a4f --- /dev/null +++ b/src/app/insights/page.tsx @@ -0,0 +1,267 @@ +"use client"; + +import { useMemo } from "react"; +import { + ComposedChart, Bar, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell, Legend, +} from "recharts"; +import { useMonthlyAnalytics, useSubscriptions, useFees } from "@/lib/hooks"; +import { formatCategory } from "@/lib/categories"; + +const COMMITTED_CATEGORIES = new Set(["rent", "utilities", "insurance", "subscriptions"]); + +function fmt(n: number) { + return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", maximumFractionDigits: 0 }).format(n); +} +function fmtExact(n: number) { + return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", minimumFractionDigits: 2 }).format(n); +} +function fmtDate(d: string) { + return new Date(d).toLocaleDateString("en-AU", { month: "short", year: "numeric" }); +} +function trend(values: number[]): { pct: number; dir: "up" | "down" | "flat" } { + if (values.length < 2) return { pct: 0, dir: "flat" }; + const recent = values.slice(-3).reduce((a, b) => a + b, 0) / 3; + const prior = values.slice(0, 3).reduce((a, b) => a + b, 0) / 3; + if (prior === 0) return { pct: 0, dir: "flat" }; + const pct = Math.round(((recent - prior) / prior) * 100); + return { pct: Math.abs(pct), dir: pct > 2 ? "up" : pct < -2 ? "down" : "flat" }; +} + +const FREQ_LABEL: Record = { + weekly: "Weekly", + fortnightly: "Fortnightly", + monthly: "Monthly", + quarterly: "Quarterly", + annual: "Annual", +}; + +// ─── Section wrapper ──────────────────────────────────────────────── +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +// ─── Custom tooltip ────────────────────────────────────────────────── +function CommittedTooltip({ active, payload, label }: any) { + if (!active || !payload?.length) return null; + const committed = payload.find((p: any) => p.dataKey === "committed")?.value ?? 0; + const discretionary = payload.find((p: any) => p.dataKey === "discretionary")?.value ?? 0; + return ( +
+
{label}
+
Committed{fmt(committed)}
+
Discretionary{fmt(discretionary)}
+
Total{fmt(committed + discretionary)}
+
+ ); +} + +// ─── Main page ──────────────────────────────────────────────────────── +export default function InsightsPage() { + const { data: analytics } = useMonthlyAnalytics(12); + const { data: subData } = useSubscriptions(); + const { data: feesData } = useFees(); + + // Build committed/discretionary chart data + const chartData = useMemo(() => { + if (!analytics) return []; + return [...analytics.months].reverse().map((month) => { + let committed = 0; + let discretionary = 0; + for (const row of analytics.rows) { + const spend = Number(row.spent[month] ?? 0); + if (COMMITTED_CATEGORIES.has(row.category)) committed += spend; + else discretionary += spend; + } + return { + month: month.slice(5) + "/" + month.slice(2, 4), + committed: Math.round(committed), + discretionary: Math.round(discretionary), + total: Math.round(committed + discretionary), + }; + }); + }, [analytics]); + + const committedValues = chartData.map((d) => d.committed); + const committedTrend = trend(committedValues); + const avgCommitted = committedValues.length + ? Math.round(committedValues.reduce((a, b) => a + b, 0) / committedValues.length) + : 0; + const latestCommitted = committedValues[committedValues.length - 1] ?? 0; + + const activeSubscriptions = subData?.subscriptions.filter((s) => s.is_active) ?? []; + const inactiveSubscriptions = subData?.subscriptions.filter((s) => !s.is_active) ?? []; + + return ( +
+

Insights

+ + {/* ── 1. Committed vs Discretionary ── */} +
+
+
+
This month — committed floor
+
{fmt(latestCommitted)}
+
+
+
12-month avg committed
+
{fmt(avgCommitted)}
+
+
+
Trend (first 3 vs last 3 mo)
+
+ {committedTrend.dir === "up" ? "↑" : committedTrend.dir === "down" ? "↓" : "→"} {committedTrend.pct}% +
+
+
+
+ + + + `$${(v / 1000).toFixed(0)}k`} width={44} /> + } /> + + + + + +
+ Committed (rent, utilities, insurance, subscriptions) + Discretionary +
+
+
+ + {/* ── 2. Recurring Charges ── */} +
+ {!subData ? ( +

Loading...

+ ) : subData.subscriptions.length === 0 ? ( +

No recurring patterns detected yet — more transaction history needed.

+ ) : ( + <> +
+ {activeSubscriptions.length} active · {inactiveSubscriptions.length} inactive + {fmtExact(subData.total_monthly_equiv)}/ month committed +
+
+ + + + + + + + + + + + + + + {[...activeSubscriptions, ...inactiveSubscriptions].map((s) => ( + + + + + + + + + + + ))} + +
MerchantCategoryFrequencyMy $/mo equivAvg chargeSinceTotal paidCount
{s.merchant}{formatCategory(s.category ?? "other")} + + {FREQ_LABEL[s.frequency] ?? s.frequency} + + {fmtExact(s.monthly_equiv)}{fmtExact(s.avg_amount)}{fmtDate(s.first_seen)}{fmtExact(s.total_paid)}{s.occurrences}
+
+ + )} +
+ + {/* ── 3. Fees & Interest ── */} +
+ {!feesData ? ( +

Loading...

+ ) : feesData.by_bank.length === 0 && feesData.transactions.length === 0 ? ( +

No fees or interest recorded across your statements.

+ ) : ( +
+ {feesData.by_bank.length > 0 && ( +
+ + + + + + + + + + + {feesData.by_bank.map((r) => ( + + + + + + + ))} + + + + + + + +
BankFeesInterestTotal
{r.bank_name}{r.fees > 0 ? fmtExact(r.fees) : }{r.interest > 0 ? fmtExact(r.interest) : }{fmtExact(r.total)}
Total{fmtExact(feesData.total_fees)}{fmtExact(feesData.total_interest)}{fmtExact(feesData.total_fees + feesData.total_interest)}
+
+ )} + {feesData.transactions.length > 0 && ( +
+

Individual fee / interest transactions

+
+ + + + + + + + + + + + {feesData.transactions.map((t) => ( + + + + + + + + ))} + +
DateBankDescriptionTypeAmount
+ {new Date(t.transaction_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" })} + {t.bank_name}{t.description} + + {t.transaction_type} + + {fmtExact(t.my_amount)}
+
+
+ )} +
+ )} +
+
+ ); +} diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx index 7b6cf93..6bc1897 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -8,6 +8,7 @@ const NAV_ITEMS = [ { href: "/statements", label: "Statements", icon: "file-text" }, { href: "/shared", label: "Shared", icon: "users" }, { href: "/budget", label: "Analytics", icon: "bar-chart" }, + { href: "/insights", label: "Insights", icon: "lightbulb" }, { href: "/tags", label: "Tags", icon: "tag" }, { href: "/rules", label: "Rules", icon: "settings" }, ]; @@ -44,6 +45,11 @@ const ICONS: Record = { ), + lightbulb: ( + + + + ), }; export function Sidebar() { diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index 75d260f..6091e0b 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -502,3 +502,58 @@ export function useMonthlyAnalytics(months?: number) { }, }); } + +export interface SubscriptionRow { + merchant: string; + category: string; + frequency: string; + avg_amount: number; + monthly_equiv: number; + first_seen: string; + last_seen: string; + occurrences: number; + total_paid: number; + is_active: boolean; +} + +export function useSubscriptions() { + return useQuery<{ subscriptions: SubscriptionRow[]; total_monthly_equiv: number }>({ + queryKey: ["analytics", "subscriptions"], + queryFn: async () => { + const res = await fetch("/api/analytics/subscriptions"); + return res.json(); + }, + }); +} + +export interface FeeBankRow { + bank_name: string; + fees: number; + interest: number; + total: number; +} + +export interface FeeTxnRow { + id: number; + transaction_date: string; + description: string; + merchant_name: string | null; + transaction_type: string; + my_amount: number; + bank_name: string; +} + +export function useFees() { + return useQuery<{ + by_bank: FeeBankRow[]; + transactions: FeeTxnRow[]; + total_fees: number; + total_interest: number; + }>({ + queryKey: ["analytics", "fees"], + queryFn: async () => { + const res = await fetch("/api/analytics/fees"); + return res.json(); + }, + }); +}