diff --git a/src/app/api/analytics/merchants/[merchant]/route.ts b/src/app/api/analytics/merchants/[merchant]/route.ts new file mode 100644 index 0000000..adbe6ac --- /dev/null +++ b/src/app/api/analytics/merchants/[merchant]/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { queryRaw } from "@/lib/db"; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ merchant: string }> } +) { + const user = await getCurrentUser(req); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { merchant } = await params; + const decoded = decodeURIComponent(merchant); + + const transactions = await queryRaw<{ + id: number; + transaction_date: string; + description: string; + amount: number; + amount_aud: number | null; + my_amount: number; + transaction_type: string; + category: string; + bank_name: string; + statement_id: number; + }>(` + SELECT + t.id, + t.transaction_date::text, + t.description, + t.amount, + t.amount_aud, + 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(10,2) as my_amount, + t.transaction_type, + COALESCE(o.category_override, t.category) as category, + s.bank_name, + t.statement_id + FROM transactions t + JOIN statements s ON s.id = t.statement_id + 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 + WHERE s.owner_id = $1 + AND t.transaction_type IN ('debit', 'fee') + AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = $2 + ORDER BY t.transaction_date DESC + LIMIT 500 + `, [user.id, decoded]); + + return NextResponse.json({ transactions }); +} diff --git a/src/app/api/analytics/merchants/route.ts b/src/app/api/analytics/merchants/route.ts new file mode 100644 index 0000000..783386c --- /dev/null +++ b/src/app/api/analytics/merchants/route.ts @@ -0,0 +1,112 @@ +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: 401 }); + + const { searchParams } = new URL(req.url); + const months = Math.min(24, Math.max(1, Number(searchParams.get("months") || "12"))); + + const cutoff = new Date(); + cutoff.setMonth(cutoff.getMonth() - months); + const fromDate = cutoff.toISOString().slice(0, 10); + + // Merchant aggregates — split-adjusted, debit transactions only + const rows = await queryRaw<{ + merchant: string; + category: string; + transaction_count: number; + total_spend: number; + avg_transaction: number; + first_seen: string; + last_seen: string; + months_active: number; + }>(` + SELECT + COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) as merchant, + MODE() WITHIN GROUP (ORDER BY COALESCE(o.category_override, t.category)) as category, + COUNT(*)::int as transaction_count, + SUM( + 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 total_spend, + AVG( + 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(10,2) as avg_transaction, + MIN(t.transaction_date)::text as first_seen, + MAX(t.transaction_date)::text as last_seen, + COUNT(DISTINCT TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM'))::int as months_active + FROM transactions t + JOIN statements s ON s.id = t.statement_id + 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 + WHERE s.owner_id = $1 + AND t.transaction_type IN ('debit', 'fee') + AND t.transaction_date >= $2 + AND COALESCE(o.category_override, t.category) NOT IN ('transfers', 'investment') + GROUP BY 1 + HAVING SUM( + 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 + ) > 0 + ORDER BY total_spend DESC + LIMIT 200 + `, [user.id, fromDate]); + + // Monthly trend per merchant (top 50 by spend) + const topMerchants = rows.slice(0, 50).map((r) => r.merchant); + + interface TrendRow { merchant: string; month: string; total: number } + let trendRows: TrendRow[] = []; + if (topMerchants.length > 0) { + trendRows = await queryRaw(` + SELECT + COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) as merchant, + TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month, + SUM( + 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(10,2) as total + FROM transactions t + JOIN statements s ON s.id = t.statement_id + 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 + WHERE s.owner_id = $1 + AND t.transaction_type IN ('debit', 'fee') + AND t.transaction_date >= $2 + AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = ANY($3) + AND COALESCE(o.category_override, t.category) NOT IN ('transfers', 'investment') + GROUP BY 1, 2 + ORDER BY 1, 2 + `, [user.id, fromDate, topMerchants]); + } + + // Group trend data by merchant + const trendByMerchant: Record> = {}; + for (const tr of trendRows) { + if (!trendByMerchant[tr.merchant]) trendByMerchant[tr.merchant] = {}; + trendByMerchant[tr.merchant][tr.month] = Number(tr.total); + } + + const merchants = rows.map((r) => ({ + ...r, + transaction_count: Number(r.transaction_count), + total_spend: Number(r.total_spend), + avg_transaction: Number(r.avg_transaction), + months_active: Number(r.months_active), + monthly_trend: trendByMerchant[r.merchant] || {}, + })); + + return NextResponse.json({ merchants, months }); +} diff --git a/src/app/merchants/page.tsx b/src/app/merchants/page.tsx new file mode 100644 index 0000000..f9d70d8 --- /dev/null +++ b/src/app/merchants/page.tsx @@ -0,0 +1,430 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { + ScatterChart, + Scatter, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + Cell, + LineChart, + Line, + CartesianGrid, +} from "recharts"; +import { useMerchants, useMerchantTransactions, MerchantRow } from "@/lib/hooks"; +import { formatCategory } from "@/lib/categories"; + +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 + "T00:00:00").toLocaleDateString("en-AU", { + day: "numeric", + month: "short", + year: "numeric", + }); +} + +const CATEGORY_COLORS: Record = { + groceries: "#4ade80", + dining: "#fb923c", + transport: "#60a5fa", + fuel: "#facc15", + shopping: "#f472b6", + utilities: "#a78bfa", + entertainment: "#34d399", + travel: "#38bdf8", + health: "#f87171", + insurance: "#94a3b8", + subscriptions: "#c084fc", + government: "#6b7280", + education: "#fbbf24", + rent: "#e879f9", + home_goods: "#67e8f9", + home_maintenance: "#c084fc", + personal_care: "#fb7185", + pets: "#a3e635", + gifts: "#f9a8d4", + charity: "#6ee7b7", + other: "#71717a", +}; + +// Custom scatter tooltip +function ScatterTooltip({ + active, + payload, +}: { + active?: boolean; + payload?: Array<{ payload: MerchantRow }>; +}) { + if (!active || !payload?.length) return null; + const d = payload[0].payload; + return ( +
+

{d.merchant}

+

{formatCategory(d.category)}

+
+

{fmt(d.total_spend)} total

+

{d.transaction_count}× transactions

+

{fmtExact(d.avg_transaction)} avg

+
+
+ ); +} + +// Quadrant labels +function QuadrantLabels({ medianX, medianY }: { medianX: number; medianY: number }) { + return ( +
+
+ high cost · frequent +
+
+ high cost · rare +
+
+ low cost · frequent +
+
+ low cost · rare +
+
+ ); +} + +// Merchant profile drawer +function MerchantProfile({ + merchant, + onClose, +}: { + merchant: MerchantRow; + onClose: () => void; +}) { + const { data } = useMerchantTransactions(merchant.merchant); + const transactions = data?.transactions ?? []; + + // Build trend chart data from monthly_trend + const trendData = useMemo(() => { + const months = Object.keys(merchant.monthly_trend).sort(); + return months.map((m) => ({ + month: m, + label: new Date(m + "-01").toLocaleDateString("en-AU", { month: "short", year: "2-digit" }), + amount: merchant.monthly_trend[m], + })); + }, [merchant.monthly_trend]); + + const color = CATEGORY_COLORS[merchant.category] || "#6366f1"; + + return ( +
+ {/* backdrop */} +
+ {/* drawer */} +
+ {/* Header */} +
+
+

{merchant.merchant}

+ + {formatCategory(merchant.category)} + +
+ +
+ + {/* Stats */} +
+
+

Total Spent

+

{fmt(merchant.total_spend)}

+
+
+

Transactions

+

{merchant.transaction_count}

+
+
+

Average

+

{fmtExact(merchant.avg_transaction)}

+
+
+ + {/* Trend chart */} + {trendData.length > 1 && ( +
+

Monthly Spend

+ + + + + `$${Math.round(v)}`} + width={45} + /> + + + + +
+ )} + + {/* Transaction history */} +
+

+ Transactions{" "} + ({transactions.length}) +

+ {transactions.length === 0 ? ( +

Loading…

+ ) : ( +
+ {transactions.map((tx) => ( +
+
+

{tx.description}

+

+ {fmtDate(tx.transaction_date)} · {tx.bank_name} +

+
+

+ {fmtExact(tx.my_amount)} +

+
+ ))} +
+ )} +
+
+
+ ); +} + +export default function MerchantsPage() { + const [months, setMonths] = useState(12); + const [selected, setSelected] = useState(null); + const [search, setSearch] = useState(""); + + const { data, isLoading } = useMerchants(months); + const merchants = data?.merchants ?? []; + + // Stats for median lines + const { medianX, medianY } = useMemo(() => { + if (merchants.length === 0) return { medianX: 0, medianY: 0 }; + const counts = [...merchants].sort((a, b) => a.transaction_count - b.transaction_count); + const spends = [...merchants].sort((a, b) => a.total_spend - b.total_spend); + const mid = Math.floor(merchants.length / 2); + return { + medianX: counts[mid]?.transaction_count ?? 0, + medianY: spends[mid]?.total_spend ?? 0, + }; + }, [merchants]); + + // Filtered merchants for the table + const filtered = useMemo(() => { + if (!search.trim()) return merchants; + const q = search.toLowerCase(); + return merchants.filter((m) => m.merchant.toLowerCase().includes(q)); + }, [merchants, search]); + + // Top merchants for the scatter (max 150 for performance) + const scatterData = useMemo(() => merchants.slice(0, 150), [merchants]); + + if (isLoading) { + return ( +
Loading merchant data…
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Merchants

+

{merchants.length} merchants · last {months} months

+
+ +
+ + {/* Scatter plot */} +
+

Spend vs Frequency

+

+ Each dot = one merchant. Click to open profile. +

+
+ + + + + + `$${Math.round(v / 1000)}k`} + width={48} + label={{ value: "Total Spend", angle: -90, position: "insideLeft", offset: 10, fill: "#52525b", fontSize: 11 }} + /> + } cursor={{ strokeDasharray: "3 3", stroke: "#52525b" }} /> + setSelected(d as unknown as MerchantRow)} + style={{ cursor: "pointer" }} + > + {scatterData.map((entry, idx) => ( + + ))} + + + +
+ {/* Category legend */} +
+ {Object.entries(CATEGORY_COLORS) + .filter(([cat]) => merchants.some((m) => m.category === cat)) + .slice(0, 12) + .map(([cat, color]) => ( + + + {formatCategory(cat)} + + ))} +
+
+ + {/* Merchant table */} +
+
+

All Merchants

+ setSearch(e.target.value)} + className="bg-zinc-800 border border-zinc-700 rounded-md text-sm text-zinc-200 placeholder-zinc-500 px-3 py-1.5 w-48" + /> +
+
+ + + + + + + + + + + + + {filtered.map((m) => { + const color = CATEGORY_COLORS[m.category] || "#6366f1"; + return ( + setSelected(m)} + className="border-b border-zinc-800 hover:bg-zinc-800 cursor-pointer transition-colors" + > + + + + + + + + ); + })} + +
MerchantCategoryTotalCountAvgLast Seen
+ {m.merchant} + + + {formatCategory(m.category)} + + + {fmt(m.total_spend)} + {m.transaction_count} + {fmtExact(m.avg_transaction)} + + {fmtDate(m.last_seen)} +
+
+
+ + {/* Merchant profile drawer */} + {selected && ( + setSelected(null)} /> + )} +
+ ); +} diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx index 6bc1897..5788275 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -9,6 +9,7 @@ const NAV_ITEMS = [ { href: "/shared", label: "Shared", icon: "users" }, { href: "/budget", label: "Analytics", icon: "bar-chart" }, { href: "/insights", label: "Insights", icon: "lightbulb" }, + { href: "/merchants", label: "Merchants", icon: "store" }, { href: "/tags", label: "Tags", icon: "tag" }, { href: "/rules", label: "Rules", icon: "settings" }, ]; @@ -50,6 +51,11 @@ const ICONS: Record = { ), + store: ( + + + + ), }; export function Sidebar() { diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index 6091e0b..8c5ec48 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -557,3 +557,49 @@ export function useFees() { }, }); } + +export interface MerchantRow { + merchant: string; + category: string; + transaction_count: number; + total_spend: number; + avg_transaction: number; + first_seen: string; + last_seen: string; + months_active: number; + monthly_trend: Record; +} + +export function useMerchants(months = 12) { + return useQuery<{ merchants: MerchantRow[]; months: number }>({ + queryKey: ["analytics", "merchants", months], + queryFn: async () => { + const res = await fetch(`/api/analytics/merchants?months=${months}`); + return res.json(); + }, + }); +} + +export interface MerchantTxnRow { + id: number; + transaction_date: string; + description: string; + amount: number; + amount_aud: number | null; + my_amount: number; + transaction_type: string; + category: string; + bank_name: string; + statement_id: number; +} + +export function useMerchantTransactions(merchant: string | null) { + return useQuery<{ transactions: MerchantTxnRow[] }>({ + queryKey: ["analytics", "merchant-txns", merchant], + queryFn: async () => { + const res = await fetch(`/api/analytics/merchants/${encodeURIComponent(merchant!)}`); + return res.json(); + }, + enabled: !!merchant, + }); +}