feat: spending tracking page with category breakdowns

This commit is contained in:
null 2026-06-04 04:31:25 -05:00
parent 92f292dcee
commit ac5d6c6625
9 changed files with 894 additions and 10 deletions

View File

@ -43,8 +43,9 @@ const RoadmapPage = lazy(() => import('@/pages/RoadmapPage'));
const DataPage = lazy(() => import('@/pages/DataPage'));
const ProfilePage = lazy(() => import('@/pages/ProfilePage'));
const SnowballPage = lazy(() => import('@/pages/SnowballPage'));
const HealthPage = lazy(() => import('@/pages/HealthPage'));
const PayoffPage = lazy(() => import('@/pages/PayoffPage'));
const HealthPage = lazy(() => import('@/pages/HealthPage'));
const PayoffPage = lazy(() => import('@/pages/PayoffPage'));
const SpendingPage = lazy(() => import('@/pages/SpendingPage'));
function RequireAuth({ children, role }) {
const { user, singleUserMode } = useAuth();
@ -211,7 +212,8 @@ export default function App() {
<Route path="analytics" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><AnalyticsPage /></Suspense></ErrorBoundary>} />
<Route path="settings" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SettingsPage /></Suspense></ErrorBoundary>} />
<Route path="snowball" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SnowballPage /></Suspense></ErrorBoundary>} />
<Route path="payoff" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><PayoffPage /></Suspense></ErrorBoundary>} />
<Route path="payoff" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><PayoffPage /></Suspense></ErrorBoundary>} />
<Route path="spending" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SpendingPage /></Suspense></ErrorBoundary>} />
<Route path="data" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><DataPage /></Suspense></ErrorBoundary>} />
<Route path="profile" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><ProfilePage /></Suspense></ErrorBoundary>} />
<Route path="*" element={<NotFoundPage />} />

View File

@ -33,11 +33,6 @@ async function _fetch(method, path, body) {
return data;
}
const get = (path) => _fetch('GET', path);
const post = (path, body) => _fetch('POST', path, body);
const put = (path, body) => _fetch('PUT', path, body);
const del = (path) => _fetch('DELETE', path);
function queryString(params = {}) {
const qs = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
@ -47,6 +42,12 @@ function queryString(params = {}) {
return value ? `?${value}` : '';
}
const get = (path, params) => _fetch('GET', path + (params ? queryString(params) : ''));
const post = (path, body) => _fetch('POST', path, body);
const put = (path, body) => _fetch('PUT', path, body);
const patch = (path, body) => _fetch('PATCH', path, body);
const del = (path) => _fetch('DELETE', path);
function filenameFromDisposition(value) {
if (!value) return null;
const match = value.match(/filename="?([^"]+)"?/i);
@ -64,6 +65,16 @@ export const api = {
acknowledgePrivacy: () => post('/auth/acknowledge-privacy'),
acknowledgeVersion: () => post('/auth/acknowledge-version'),
loginHistory: () => get('/auth/login-history'),
// Spending
spendingSummary: (p) => get('/spending/summary', p),
spendingTransactions:(p) => get('/spending/transactions', p),
categorizeTransaction: (id, d) => patch(`/spending/transactions/${id}/category`, d),
spendingBudgets: (p) => get('/spending/budgets', p),
setSpendingBudget: (d) => put('/spending/budgets', d),
spendingCategoryRules: () => get('/spending/category-rules'),
addSpendingRule: (d) => post('/spending/category-rules', d),
deleteSpendingRule: (id) => del(`/spending/category-rules/${id}`),
totpStatus: () => get('/auth/totp/status'),
totpSetup: () => get('/auth/totp/setup'),
totpEnable: (data) => post('/auth/totp/enable', data),

View File

@ -3,7 +3,7 @@ import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import {
Activity, BarChart3, Calculator, CalendarDays, ChevronDown, ClipboardCheck, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt,
Search, Settings, ShieldCheck, Tag, TrendingDown, User, X,
Repeat,
Repeat, ShoppingCart,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/hooks/useAuth';
@ -41,6 +41,7 @@ const trackerItems = [
{ to: '/subscriptions', icon: Repeat, label: 'Subscriptions' },
{ to: '/categories', icon: Tag, label: 'Categories' },
{ to: '/health', icon: ClipboardCheck, label: 'Health' },
{ to: '/spending', icon: ShoppingCart, label: 'Spending' },
{ to: '/snowball', icon: TrendingDown, label: 'Snowball' },
{ to: '/payoff', icon: Calculator, label: 'Payoff' },
];

View File

@ -0,0 +1,423 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { toast } from 'sonner';
import { ChevronLeft, ChevronRight, Tag, ReceiptText, TrendingDown, CircleDollarSign, Pencil, Check, X } from 'lucide-react';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function fmt(n) {
return Number(n || 0).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
}
function pctBar(amount, budget) {
if (!budget) return null;
const pct = Math.min(100, Math.round((amount / budget) * 100));
const over = amount > budget;
return { pct, over };
}
// Category picker dropdown
function CategoryPicker({ categories, current, onSelect }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const close = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', close);
return () => document.removeEventListener('mousedown', close);
}, [open]);
const currentCat = categories.find(c => c.id === current);
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen(v => !v)}
className="flex items-center gap-1.5 rounded-md border border-border/60 bg-background px-2 py-1 text-xs text-muted-foreground hover:border-primary/40 hover:text-foreground transition-colors"
>
<Tag className="h-3 w-3 shrink-0" />
<span className="max-w-[100px] truncate">{currentCat?.name ?? 'Uncategorized'}</span>
</button>
{open && (
<div className="absolute right-0 top-full z-50 mt-1 w-44 rounded-lg border border-border/60 bg-popover shadow-lg overflow-hidden">
<button
type="button"
onMouseDown={e => { e.preventDefault(); onSelect(null, false); setOpen(false); }}
className="w-full px-3 py-2 text-left text-xs text-muted-foreground hover:bg-muted/50 transition-colors"
>
Uncategorized
</button>
{categories.map(cat => (
<button
key={cat.id}
type="button"
onMouseDown={e => { e.preventDefault(); onSelect(cat.id, false); setOpen(false); }}
className={`w-full px-3 py-2 text-left text-xs hover:bg-muted/50 transition-colors ${cat.id === current ? 'text-primary font-medium' : ''}`}
>
{cat.name}
</button>
))}
</div>
)}
</div>
);
}
// Transaction row
function TxRow({ tx, categories, onCategorize }) {
const [saving, setSaving] = useState(false);
const handleSelect = async (categoryId, saveRule) => {
setSaving(true);
try {
await api.categorizeTransaction(tx.id, { category_id: categoryId, save_rule: saveRule });
onCategorize(tx.id, categoryId, categories.find(c => c.id === categoryId)?.name ?? null);
} catch (err) {
toast.error(err.message || 'Failed to categorize');
} finally {
setSaving(false);
}
};
return (
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-border/30 last:border-0 hover:bg-muted/20 transition-colors">
<div className="min-w-0 flex-1">
<p className="text-sm truncate font-medium">{tx.payee}</p>
<p className="text-xs text-muted-foreground">{tx.date}</p>
</div>
<span className="text-sm font-mono font-semibold text-destructive shrink-0">
-{fmt(tx.amount)}
</span>
{saving
? <span className="text-xs text-muted-foreground w-28 text-center">Saving</span>
: <CategoryPicker categories={categories} current={tx.spending_category_id} onSelect={handleSelect} />
}
</div>
);
}
// Budget edit inline
function BudgetEditor({ categoryId, year, month, initial, onSaved }) {
const [editing, setEditing] = useState(false);
const [val, setVal] = useState(initial ?? '');
const save = async () => {
const amount = val === '' ? null : parseFloat(val);
if (val !== '' && (isNaN(amount) || amount < 0)) { toast.error('Enter a valid amount'); return; }
try {
await api.setSpendingBudget({ category_id: categoryId, year, month, amount });
onSaved(categoryId, amount);
setEditing(false);
} catch { toast.error('Failed to save budget'); }
};
if (!editing) return (
<button
type="button"
onClick={() => setEditing(true)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{initial != null ? `Budget: ${fmt(initial)}` : 'Set budget'}
<Pencil className="h-3 w-3" />
</button>
);
return (
<div className="flex items-center gap-1">
<input
type="number"
value={val}
onChange={e => setVal(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') setEditing(false); }}
placeholder="0.00"
autoFocus
className="w-20 rounded border border-border/60 bg-background px-1.5 py-0.5 text-xs font-mono focus:outline-none focus:ring-1 focus:ring-ring"
/>
<button type="button" onClick={save} className="text-emerald-500 hover:text-emerald-400"><Check className="h-3.5 w-3.5" /></button>
<button type="button" onClick={() => setEditing(false)} className="text-muted-foreground hover:text-foreground"><X className="h-3.5 w-3.5" /></button>
</div>
);
}
// Main page
export default function SpendingPage() {
const now = new Date();
const [year, setYear] = useState(now.getFullYear());
const [month, setMonth] = useState(now.getMonth() + 1);
const [summary, setSummary] = useState(null);
const [transactions, setTransactions] = useState([]);
const [txTotal, setTxTotal] = useState(0);
const [txPage, setTxPage] = useState(1);
const [txPages, setTxPages] = useState(1);
const [categories, setCategories] = useState([]);
const [activeCat, setActiveCat] = useState(undefined); // undefined = all
const [loading, setLoading] = useState(true);
const [txLoading, setTxLoading] = useState(false);
const [budgets, setBudgets] = useState({}); // categoryId amount
const loadCategories = useCallback(async () => {
try {
const d = await api.categories();
setCategories((d.categories || d || []).filter(c => !c.deleted_at));
} catch {}
}, []);
const loadSummary = useCallback(async () => {
setLoading(true);
try {
const d = await api.spendingSummary({ year, month });
setSummary(d);
const bmap = {};
(d.by_category || []).forEach(c => { if (c.category_id && c.budget != null) bmap[c.category_id] = c.budget; });
setBudgets(bmap);
} catch (err) {
toast.error(err.message || 'Failed to load spending summary');
} finally {
setLoading(false);
}
}, [year, month]);
const loadTransactions = useCallback(async (page = 1) => {
setTxLoading(true);
try {
const params = { year, month, page, limit: 50 };
if (activeCat === null) params.category_id = 'null';
else if (activeCat !== undefined) params.category_id = activeCat;
const d = await api.spendingTransactions(params);
setTransactions(d.transactions || []);
setTxTotal(d.total || 0);
setTxPages(d.pages || 1);
setTxPage(page);
} catch (err) {
toast.error(err.message || 'Failed to load transactions');
} finally {
setTxLoading(false);
}
}, [year, month, activeCat]);
useEffect(() => { loadCategories(); }, [loadCategories]);
useEffect(() => { loadSummary(); loadTransactions(1); }, [loadSummary, loadTransactions]);
const navMonth = (dir) => {
let m = month + dir, y = year;
if (m > 12) { m = 1; y++; }
if (m < 1) { m = 12; y--; }
setMonth(m); setYear(y); setActiveCat(undefined); setTxPage(1);
};
const handleCategorize = (txId, categoryId, categoryName) => {
setTransactions(prev => prev.map(t =>
t.id === txId ? { ...t, spending_category_id: categoryId, spending_category_name: categoryName } : t
));
loadSummary();
};
const handleBudgetSaved = (categoryId, amount) => {
setBudgets(prev => ({ ...prev, [categoryId]: amount }));
setSummary(prev => {
if (!prev) return prev;
return {
...prev,
by_category: prev.by_category.map(c =>
c.category_id === categoryId ? { ...c, budget: amount } : c
),
};
});
};
const selectCat = (catId) => {
setActiveCat(prev => prev === catId ? undefined : catId);
setTxPage(1);
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh] text-muted-foreground text-sm">
Loading spending
</div>
);
}
const uncatEntry = summary?.by_category?.find(c => !c.category_id);
const catEntries = summary?.by_category?.filter(c => !!c.category_id) || [];
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top_right,oklch(var(--primary)/0.05),transparent_30rem)] pb-16">
<div className="mx-auto max-w-4xl px-4 py-6 sm:px-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold">Spending</h1>
<p className="text-sm text-muted-foreground">Unmatched bank transactions by category</p>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => navMonth(-1)}>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm font-medium w-24 text-center">
{MONTH_NAMES[month - 1]} {year}
</span>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => navMonth(1)}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
{/* Overview strip */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div className="rounded-xl border border-border/60 bg-card/80 px-4 py-3">
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wide">Total Spending</p>
<p className="text-2xl font-bold mt-1">{fmt(summary?.total_spending)}</p>
</div>
<div className="rounded-xl border border-border/60 bg-card/80 px-4 py-3">
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wide">Uncategorized</p>
<p className="text-2xl font-bold mt-1">{fmt(summary?.uncategorized_amount)}</p>
{summary?.uncategorized_count > 0 && (
<p className="text-xs text-muted-foreground">{summary.uncategorized_count} transaction{summary.uncategorized_count !== 1 ? 's' : ''}</p>
)}
</div>
<div className="rounded-xl border border-border/60 bg-card/80 px-4 py-3 col-span-2 sm:col-span-1">
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wide">Income Received</p>
<p className="text-2xl font-bold mt-1 text-emerald-500">{fmt(summary?.income)}</p>
</div>
</div>
{/* Category breakdown */}
<div className="rounded-xl border border-border/60 bg-card/80 overflow-hidden">
<div className="px-4 py-3 border-b border-border/40 flex items-center gap-2">
<TrendingDown className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">By Category</span>
{activeCat !== undefined && (
<button type="button" onClick={() => setActiveCat(undefined)}
className="ml-auto text-xs text-muted-foreground hover:text-foreground underline-offset-4 hover:underline">
Show all
</button>
)}
</div>
{catEntries.length === 0 && !uncatEntry ? (
<p className="text-sm text-muted-foreground text-center py-8">No spending transactions found for this month.</p>
) : (
<div className="divide-y divide-border/30">
{catEntries.map(cat => {
const bar = pctBar(cat.amount, cat.budget ?? budgets[cat.category_id]);
const isActive = activeCat === cat.category_id;
return (
<button
key={cat.category_id}
type="button"
onClick={() => selectCat(cat.category_id)}
className={`w-full text-left px-4 py-3 transition-colors ${isActive ? 'bg-primary/8' : 'hover:bg-muted/30'}`}
>
<div className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium truncate">{cat.category_name}</span>
<span className="text-sm font-mono font-semibold shrink-0">{fmt(cat.amount)}</span>
</div>
{bar && (
<div className="mt-1.5 h-1.5 w-full rounded-full bg-muted/50">
<div
className={`h-full rounded-full transition-all ${bar.over ? 'bg-destructive' : 'bg-primary'}`}
style={{ width: `${bar.pct}%` }}
/>
</div>
)}
<div className="flex items-center justify-between mt-1">
<span className="text-[11px] text-muted-foreground">{cat.tx_count} transaction{cat.tx_count !== 1 ? 's' : ''}</span>
<span onClick={e => e.stopPropagation()}>
<BudgetEditor
categoryId={cat.category_id}
year={year} month={month}
initial={budgets[cat.category_id] ?? null}
onSaved={handleBudgetSaved}
/>
</span>
</div>
</div>
</div>
</button>
);
})}
{/* Uncategorized row */}
{uncatEntry && (
<button
type="button"
onClick={() => selectCat(null)}
className={`w-full text-left px-4 py-3 transition-colors ${activeCat === null ? 'bg-primary/8' : 'hover:bg-muted/30'}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Tag className="h-3.5 w-3.5" /> Uncategorized
</span>
<span className="text-sm font-mono font-semibold text-muted-foreground">{fmt(uncatEntry.amount)}</span>
</div>
<p className="text-[11px] text-muted-foreground mt-0.5">{uncatEntry.tx_count} transaction{uncatEntry.tx_count !== 1 ? 's' : ''}</p>
</button>
)}
</div>
)}
</div>
{/* Transaction list */}
<div className="rounded-xl border border-border/60 bg-card/80 overflow-hidden">
<div className="px-4 py-3 border-b border-border/40 flex items-center gap-2">
<ReceiptText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
Transactions
{activeCat !== undefined && (
<span className="ml-1.5 text-muted-foreground font-normal">
{activeCat === null ? 'Uncategorized' : catEntries.find(c => c.category_id === activeCat)?.category_name}
</span>
)}
</span>
<Badge variant="secondary" className="ml-auto text-xs">{txTotal}</Badge>
</div>
{txLoading ? (
<div className="py-10 text-center text-sm text-muted-foreground">Loading</div>
) : transactions.length === 0 ? (
<div className="py-10 text-center text-sm text-muted-foreground">No transactions found.</div>
) : (
<>
<div>
{transactions.map(tx => (
<TxRow
key={tx.id}
tx={tx}
categories={categories}
onCategorize={handleCategorize}
/>
))}
</div>
{txPages > 1 && (
<div className="flex items-center justify-center gap-2 px-4 py-3 border-t border-border/30">
<Button variant="ghost" size="sm" disabled={txPage <= 1} onClick={() => loadTransactions(txPage - 1)}>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-xs text-muted-foreground">Page {txPage} of {txPages}</span>
<Button variant="ghost" size="sm" disabled={txPage >= txPages} onClick={() => loadTransactions(txPage + 1)}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
</>
)}
</div>
</div>
</div>
);
}

View File

@ -2785,6 +2785,56 @@ function runMigrations() {
`);
console.log('[v0.86] users: TOTP columns + totp_challenges table');
}
},
{
version: 'v0.87',
description: 'spending: category assignment on transactions + rules + budgets + default categories',
dependsOn: ['v0.86'],
run: function() {
// spending_category_id on transactions
const txCols = db.prepare('PRAGMA table_info(transactions)').all().map(c => c.name);
if (!txCols.includes('spending_category_id'))
db.exec('ALTER TABLE transactions ADD COLUMN spending_category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL');
// spending category rules (merchant → category)
db.exec(`
CREATE TABLE IF NOT EXISTS spending_category_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
merchant TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, merchant)
)
`);
// monthly spending budgets
db.exec(`
CREATE TABLE IF NOT EXISTS spending_budgets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
year INTEGER NOT NULL,
month INTEGER NOT NULL,
amount REAL NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, category_id, year, month)
)
`);
// Seed default spending categories for each user that has none yet
const DEFAULTS = ['Groceries','Dining','Fuel & Transport','Shopping','Entertainment','Health','Travel','Other'];
const users = db.prepare("SELECT id FROM users WHERE role='user' AND active=1").all();
const insert = db.prepare("INSERT OR IGNORE INTO categories (user_id, name, sort_order, is_seeded) VALUES (?, ?, ?, 1)");
for (const user of users) {
const existing = db.prepare("SELECT COUNT(*) AS n FROM categories WHERE user_id=? AND deleted_at IS NULL").get(user.id);
if ((existing?.n ?? 0) === 0) {
DEFAULTS.forEach((name, i) => insert.run(user.id, name, 100 + i));
}
}
console.log('[v0.87] spending: transactions.spending_category_id, spending_category_rules, spending_budgets');
}
}
];

117
routes/spending.js Normal file
View File

@ -0,0 +1,117 @@
'use strict';
const express = require('express');
const router = express.Router();
const { getDb } = require('../db/database');
const {
getSpendingSummary, getSpendingTransactions, categorizeTransaction,
getSpendingBudgets, setSpendingBudget,
getSpendingCategoryRules, addSpendingCategoryRule, deleteSpendingCategoryRule,
} = require('../services/spendingService');
function parseYM(source) {
const now = new Date();
const year = parseInt(source.year || now.getFullYear(), 10);
const month = parseInt(source.month || now.getMonth() + 1, 10);
if (isNaN(year) || year < 2000 || year > 2100) return { error: 'Invalid year' };
if (isNaN(month) || month < 1 || month > 12) return { error: 'Invalid month' };
return { year, month };
}
// GET /api/spending/summary?year=&month=
router.get('/summary', (req, res) => {
const ym = parseYM(req.query);
if (ym.error) return res.status(400).json({ error: ym.error });
try {
res.json(getSpendingSummary(getDb(), req.user.id, ym.year, ym.month));
} catch (err) {
console.error('[spending/summary]', err.message);
res.status(500).json({ error: 'Failed to load spending summary' });
}
});
// GET /api/spending/transactions?year=&month=&category_id=&page=&limit=
router.get('/transactions', (req, res) => {
const ym = parseYM(req.query);
if (ym.error) return res.status(400).json({ error: ym.error });
const { category_id, page, limit } = req.query;
const categoryId = category_id === 'null' ? null
: category_id !== undefined ? parseInt(category_id, 10)
: undefined;
try {
res.json(getSpendingTransactions(getDb(), req.user.id, ym.year, ym.month, {
categoryId,
uncategorizedOnly: category_id === 'null',
page: parseInt(page || '1', 10),
limit: Math.min(parseInt(limit || '50', 10), 200),
}));
} catch (err) {
console.error('[spending/transactions]', err.message);
res.status(500).json({ error: 'Failed to load transactions' });
}
});
// PATCH /api/spending/transactions/:id/category
router.patch('/transactions/:id/category', (req, res) => {
const txId = parseInt(req.params.id, 10);
if (isNaN(txId)) return res.status(400).json({ error: 'Invalid transaction ID' });
const { category_id, save_rule } = req.body || {};
const categoryId = category_id === null || category_id === undefined ? null : parseInt(category_id, 10);
try {
categorizeTransaction(getDb(), req.user.id, txId, categoryId, !!save_rule);
res.json({ ok: true });
} catch (err) {
res.status(err.status || 500).json({ error: err.message || 'Failed to categorize transaction' });
}
});
// GET /api/spending/budgets?year=&month=
router.get('/budgets', (req, res) => {
const ym = parseYM(req.query);
if (ym.error) return res.status(400).json({ error: ym.error });
res.json({ budgets: getSpendingBudgets(getDb(), req.user.id, ym.year, ym.month) });
});
// PUT /api/spending/budgets — { category_id, year, month, amount }
router.put('/budgets', (req, res) => {
const { category_id, year, month, amount } = req.body || {};
if (!category_id) return res.status(400).json({ error: 'category_id required' });
const ym = parseYM({ year, month });
if (ym.error) return res.status(400).json({ error: ym.error });
try {
setSpendingBudget(getDb(), req.user.id, parseInt(category_id, 10), ym.year, ym.month, amount ?? null);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Failed to save budget' });
}
});
// GET /api/spending/category-rules
router.get('/category-rules', (req, res) => {
res.json({ rules: getSpendingCategoryRules(getDb(), req.user.id) });
});
// POST /api/spending/category-rules — { category_id, merchant }
router.post('/category-rules', (req, res) => {
const { category_id, merchant } = req.body || {};
if (!category_id || !merchant) return res.status(400).json({ error: 'category_id and merchant required' });
try {
addSpendingCategoryRule(getDb(), req.user.id, parseInt(category_id, 10), merchant);
res.json({ ok: true });
} catch (err) {
res.status(err.status || 500).json({ error: err.message || 'Failed to save rule' });
}
});
// DELETE /api/spending/category-rules/:id
router.delete('/category-rules/:id', (req, res) => {
deleteSpendingCategoryRule(getDb(), req.user.id, parseInt(req.params.id, 10));
res.json({ ok: true });
});
module.exports = router;

View File

@ -94,6 +94,7 @@ app.use('/api/calendar', csrfMiddleware, requireAuth, requireUser, require(
app.use('/api/summary', csrfMiddleware, requireAuth, requireUser, require('./routes/summary'));
app.use('/api/monthly-starting-amounts', csrfMiddleware, requireAuth, requireUser, require('./routes/monthly-starting-amounts'));
app.use('/api/analytics', csrfMiddleware, requireAuth, requireUser, require('./routes/analytics'));
app.use('/api/spending', csrfMiddleware, requireAuth, requireUser, require('./routes/spending'));
app.use('/api/snowball', csrfMiddleware, requireAuth, requireUser, require('./routes/snowball'));
app.use('/api/notifications', csrfMiddleware, requireAuth, require('./routes/notifications'));
app.use('/api/status', csrfMiddleware, requireAuth, requireAdmin, require('./routes/status'));

View File

@ -11,6 +11,7 @@ const {
const { getBankSyncConfig } = require('./bankSyncConfigService');
const { decorateDataSource } = require('./transactionService');
const { applyMerchantRules } = require('./billMerchantRuleService');
const { applySpendingCategoryRules } = require('./spendingService');
const SEED_SYNC_DAYS = 44; // Initial connect / explicit backfill (SimpleFIN Bridge 45-day cap, 1-day buffer)
const ROUTINE_SYNC_DAYS = 30; // Fallback if admin config is missing
@ -129,8 +130,9 @@ async function runSync(db, userId, dataSource, { days } = {}) {
WHERE id = ? AND user_id = ?
`).run(partialError, dataSource.id, userId);
// Apply any stored merchant→bill rules to newly synced transactions
// Apply stored merchant→bill rules, then spending category rules
const { matched: autoMatched, matched_bills: matchedBills, late_attributions: lateAttributions } = applyMerchantRules(db, userId);
try { applySpendingCategoryRules(db, userId); } catch { /* non-blocking */ }
return { accountsUpserted, transactionsNew, transactionsSkip, autoMatched, matched_bills: matchedBills || [], late_attributions: lateAttributions || [], errlist: raw._errlistSummary || null };
}

277
services/spendingService.js Normal file
View File

@ -0,0 +1,277 @@
'use strict';
const { normalizeMerchant } = require('./subscriptionService');
// Spending = unmatched outflows (amount < 0) that haven't been ignored.
// Bill-matched transactions are excluded so there's no double-counting.
const SPENDING_WHERE = `
t.amount < 0
AND t.ignored = 0
AND t.match_status != 'matched'
AND t.user_id = ?
`;
function monthRange(year, month) {
const start = `${year}-${String(month).padStart(2, '0')}-01`;
const end = new Date(year, month, 0).toISOString().slice(0, 10); // last day
return { start, end };
}
function cents(raw) {
return Math.abs(Number(raw)) / 100;
}
// ── Summary ──────────────────────────────────────────────────────────────────
function getSpendingSummary(db, userId, year, month) {
const { start, end } = monthRange(year, month);
// Spending by category
const rows = db.prepare(`
SELECT
c.id AS category_id,
c.name AS category_name,
SUM(ABS(t.amount)) AS total_cents,
COUNT(t.id) AS tx_count
FROM transactions t
LEFT JOIN categories c ON c.id = t.spending_category_id AND c.deleted_at IS NULL
WHERE ${SPENDING_WHERE}
AND (t.posted_date BETWEEN ? AND ? OR (t.posted_date IS NULL AND t.transacted_at BETWEEN ? AND ?))
GROUP BY c.id
ORDER BY total_cents DESC
`).all(userId, start, end, start + 'T00:00:00', end + 'T23:59:59');
const budgets = db.prepare(`
SELECT category_id, amount FROM spending_budgets
WHERE user_id = ? AND year = ? AND month = ?
`).all(userId, year, month);
const budgetMap = new Map(budgets.map(b => [b.category_id, b.amount]));
let totalCents = 0;
const byCategory = rows.map(r => {
totalCents += r.total_cents;
return {
category_id: r.category_id,
category_name: r.category_name ?? '(Uncategorized)',
amount: r.total_cents / 100,
tx_count: r.tx_count,
budget: r.category_id ? (budgetMap.get(r.category_id) ?? null) : null,
};
});
// Attach pct_of_total
byCategory.forEach(c => {
c.pct_of_total = totalCents > 0 ? Math.round(c.amount / (totalCents / 100) * 100) / 100 : 0;
});
const uncatRow = byCategory.find(c => !c.category_id);
const uncategorized_amount = uncatRow?.amount ?? 0;
const uncategorized_count = uncatRow?.tx_count ?? 0;
// Income (positive unmatched transactions this month)
const incomeRow = db.prepare(`
SELECT COALESCE(SUM(t.amount), 0) AS total FROM transactions t
WHERE t.user_id = ? AND t.ignored = 0 AND t.amount > 0
AND t.match_status != 'matched'
AND (t.posted_date BETWEEN ? AND ? OR (t.posted_date IS NULL AND t.transacted_at BETWEEN ? AND ?))
`).get(userId, start, end, start + 'T00:00:00', end + 'T23:59:59');
return {
year, month,
total_spending: totalCents / 100,
uncategorized_amount,
uncategorized_count,
by_category: byCategory,
income: (incomeRow?.total ?? 0) / 100,
};
}
// ── Transactions ─────────────────────────────────────────────────────────────
function getSpendingTransactions(db, userId, year, month, {
categoryId = undefined,
uncategorizedOnly = false,
page = 1,
limit = 50,
} = {}) {
const { start, end } = monthRange(year, month);
const offset = (Math.max(1, page) - 1) * limit;
let filter = '';
const params = [userId, start, end, start + 'T00:00:00', end + 'T23:59:59'];
if (uncategorizedOnly) {
filter = 'AND t.spending_category_id IS NULL';
} else if (categoryId !== undefined) {
if (categoryId === null) {
filter = 'AND t.spending_category_id IS NULL';
} else {
filter = 'AND t.spending_category_id = ?';
params.push(categoryId);
}
}
const rows = db.prepare(`
SELECT
t.id, t.amount, t.payee, t.description, t.memo,
t.posted_date, t.transacted_at, t.spending_category_id,
c.name AS category_name
FROM transactions t
LEFT JOIN categories c ON c.id = t.spending_category_id AND c.deleted_at IS NULL
WHERE ${SPENDING_WHERE} ${filter}
AND (t.posted_date BETWEEN ? AND ? OR (t.posted_date IS NULL AND t.transacted_at BETWEEN ? AND ?))
ORDER BY COALESCE(t.posted_date, DATE(t.transacted_at)) DESC, t.id DESC
LIMIT ? OFFSET ?
`).all(...params, limit, offset);
const total = db.prepare(`
SELECT COUNT(*) AS n FROM transactions t
WHERE ${SPENDING_WHERE} ${filter}
AND (t.posted_date BETWEEN ? AND ? OR (t.posted_date IS NULL AND t.transacted_at BETWEEN ? AND ?))
`).get(...params).n;
return {
transactions: rows.map(r => ({
id: r.id,
amount: cents(r.amount),
payee: r.payee || r.description || r.memo || '(Unknown)',
date: r.posted_date || (r.transacted_at ? String(r.transacted_at).slice(0, 10) : null),
spending_category_id: r.spending_category_id,
spending_category_name: r.category_name ?? null,
})),
total,
page,
pages: Math.ceil(total / limit),
};
}
// ── Categorize ───────────────────────────────────────────────────────────────
function categorizeTransaction(db, userId, txId, categoryId, saveMerchantRule = false) {
const tx = db.prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ?').get(txId, userId);
if (!tx) throw Object.assign(new Error('Transaction not found'), { status: 404 });
db.prepare("UPDATE transactions SET spending_category_id=?, updated_at=datetime('now') WHERE id=? AND user_id=?")
.run(categoryId ?? null, txId, userId);
if (saveMerchantRule && categoryId) {
const merchant = normalizeMerchant(tx.payee || tx.description || tx.memo || '');
if (merchant && merchant.length >= 3) {
try {
db.prepare(`
INSERT INTO spending_category_rules (user_id, category_id, merchant)
VALUES (?, ?, ?)
ON CONFLICT(user_id, merchant) DO UPDATE SET category_id=excluded.category_id
`).run(userId, categoryId, merchant);
// Apply this rule to all existing matching transactions
applySpendingCategoryRules(db, userId, merchant);
} catch { /* safe to ignore */ }
}
}
}
// ── Auto-categorization ──────────────────────────────────────────────────────
function applySpendingCategoryRules(db, userId, onlyMerchant = null) {
let rules;
try {
const q = onlyMerchant
? db.prepare('SELECT merchant, category_id FROM spending_category_rules WHERE user_id=? AND merchant=?')
.all(userId, onlyMerchant)
: db.prepare('SELECT merchant, category_id FROM spending_category_rules WHERE user_id=?')
.all(userId);
rules = q;
} catch { return 0; }
if (!rules.length) return 0;
let applied = 0;
const update = db.prepare("UPDATE transactions SET spending_category_id=?, updated_at=datetime('now') WHERE id=? AND user_id=?");
const txRows = db.prepare(`
SELECT id, payee, description, memo FROM transactions
WHERE user_id=? AND amount<0 AND ignored=0 AND match_status!='matched' AND spending_category_id IS NULL
`).all(userId);
db.transaction(() => {
for (const tx of txRows) {
const merchant = normalizeMerchant(tx.payee || tx.description || tx.memo || '');
if (!merchant) continue;
const rule = rules.find(r => merchantMatches(merchant, r.merchant));
if (rule) { update.run(rule.category_id, tx.id, userId); applied++; }
}
})();
return applied;
}
function merchantMatches(txMerchant, ruleMerchant) {
if (!txMerchant || !ruleMerchant) return false;
if (txMerchant === ruleMerchant) return true;
const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const wb = s => new RegExp(`(^|\\s)${esc(s)}(\\s|$)`);
return wb(ruleMerchant).test(txMerchant) || wb(txMerchant).test(ruleMerchant);
}
// ── Budgets ──────────────────────────────────────────────────────────────────
function getSpendingBudgets(db, userId, year, month) {
return db.prepare(`
SELECT sb.category_id, sb.amount, c.name AS category_name
FROM spending_budgets sb
JOIN categories c ON c.id = sb.category_id AND c.deleted_at IS NULL
WHERE sb.user_id=? AND sb.year=? AND sb.month=?
`).all(userId, year, month);
}
function setSpendingBudget(db, userId, categoryId, year, month, amount) {
if (amount === null || amount === undefined) {
db.prepare('DELETE FROM spending_budgets WHERE user_id=? AND category_id=? AND year=? AND month=?')
.run(userId, categoryId, year, month);
} else {
db.prepare(`
INSERT INTO spending_budgets (user_id, category_id, year, month, amount, updated_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(user_id, category_id, year, month) DO UPDATE SET
amount=excluded.amount, updated_at=datetime('now')
`).run(userId, categoryId, year, month, Number(amount));
}
}
// ── Category rules ───────────────────────────────────────────────────────────
function getSpendingCategoryRules(db, userId) {
return db.prepare(`
SELECT r.id, r.merchant, r.category_id, c.name AS category_name
FROM spending_category_rules r
JOIN categories c ON c.id = r.category_id AND c.deleted_at IS NULL
WHERE r.user_id=?
ORDER BY r.merchant ASC
`).all(userId);
}
function addSpendingCategoryRule(db, userId, categoryId, merchant) {
const normalized = normalizeMerchant(merchant);
if (!normalized || normalized.length < 2) throw Object.assign(new Error('Merchant name too short'), { status: 400 });
db.prepare(`
INSERT INTO spending_category_rules (user_id, category_id, merchant)
VALUES (?, ?, ?)
ON CONFLICT(user_id, merchant) DO UPDATE SET category_id=excluded.category_id
`).run(userId, categoryId, normalized);
applySpendingCategoryRules(db, userId, normalized);
}
function deleteSpendingCategoryRule(db, userId, ruleId) {
db.prepare('DELETE FROM spending_category_rules WHERE id=? AND user_id=?').run(ruleId, userId);
}
module.exports = {
getSpendingSummary,
getSpendingTransactions,
categorizeTransaction,
applySpendingCategoryRules,
getSpendingBudgets,
setSpendingBudget,
getSpendingCategoryRules,
addSpendingCategoryRule,
deleteSpendingCategoryRule,
};