diff --git a/client/App.jsx b/client/App.jsx index a40d7a2..d7cffd7 100644 --- a/client/App.jsx +++ b/client/App.jsx @@ -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() { }>} /> }>} /> }>} /> - }>} /> + }>} /> + }>} /> }>} /> }>} /> } /> diff --git a/client/api.js b/client/api.js index d07813c..b42fe51 100644 --- a/client/api.js +++ b/client/api.js @@ -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), diff --git a/client/components/layout/Sidebar.jsx b/client/components/layout/Sidebar.jsx index 4df2744..f20ccbc 100644 --- a/client/components/layout/Sidebar.jsx +++ b/client/components/layout/Sidebar.jsx @@ -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' }, ]; diff --git a/client/pages/SpendingPage.jsx b/client/pages/SpendingPage.jsx new file mode 100644 index 0000000..75d0a51 --- /dev/null +++ b/client/pages/SpendingPage.jsx @@ -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 ( +
+ + + {open && ( +
+ + {categories.map(cat => ( + + ))} +
+ )} +
+ ); +} + +// ── 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 ( +
+
+

{tx.payee}

+

{tx.date}

+
+ + -{fmt(tx.amount)} + + {saving + ? Saving… + : + } +
+ ); +} + +// ── 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 ( + + ); + + return ( +
+ 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" + /> + + +
+ ); +} + +// ── 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 ( +
+ Loading spending… +
+ ); + } + + const uncatEntry = summary?.by_category?.find(c => !c.category_id); + const catEntries = summary?.by_category?.filter(c => !!c.category_id) || []; + + return ( +
+
+ + {/* Header */} +
+
+

Spending

+

Unmatched bank transactions by category

+
+
+ + + {MONTH_NAMES[month - 1]} {year} + + +
+
+ + {/* Overview strip */} +
+
+

Total Spending

+

{fmt(summary?.total_spending)}

+
+
+

Uncategorized

+

{fmt(summary?.uncategorized_amount)}

+ {summary?.uncategorized_count > 0 && ( +

{summary.uncategorized_count} transaction{summary.uncategorized_count !== 1 ? 's' : ''}

+ )} +
+
+

Income Received

+

{fmt(summary?.income)}

+
+
+ + {/* Category breakdown */} +
+
+ + By Category + {activeCat !== undefined && ( + + )} +
+ + {catEntries.length === 0 && !uncatEntry ? ( +

No spending transactions found for this month.

+ ) : ( +
+ {catEntries.map(cat => { + const bar = pctBar(cat.amount, cat.budget ?? budgets[cat.category_id]); + const isActive = activeCat === cat.category_id; + return ( + + ); + })} + + {/* Uncategorized row */} + {uncatEntry && ( + + )} +
+ )} +
+ + {/* Transaction list */} +
+
+ + + Transactions + {activeCat !== undefined && ( + + — {activeCat === null ? 'Uncategorized' : catEntries.find(c => c.category_id === activeCat)?.category_name} + + )} + + {txTotal} +
+ + {txLoading ? ( +
Loading…
+ ) : transactions.length === 0 ? ( +
No transactions found.
+ ) : ( + <> +
+ {transactions.map(tx => ( + + ))} +
+ {txPages > 1 && ( +
+ + Page {txPage} of {txPages} + +
+ )} + + )} +
+ +
+
+ ); +} diff --git a/db/database.js b/db/database.js index c7753da..c7e398c 100644 --- a/db/database.js +++ b/db/database.js @@ -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'); + } } ]; diff --git a/routes/spending.js b/routes/spending.js new file mode 100644 index 0000000..8196c41 --- /dev/null +++ b/routes/spending.js @@ -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; diff --git a/server.js b/server.js index 109a887..f112bb5 100644 --- a/server.js +++ b/server.js @@ -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')); diff --git a/services/bankSyncService.js b/services/bankSyncService.js index f89050c..b4f261b 100644 --- a/services/bankSyncService.js +++ b/services/bankSyncService.js @@ -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 }; } diff --git a/services/spendingService.js b/services/spendingService.js new file mode 100644 index 0000000..4fec080 --- /dev/null +++ b/services/spendingService.js @@ -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, +};