feat: spending tracking page with category breakdowns
This commit is contained in:
parent
92f292dcee
commit
ac5d6c6625
|
|
@ -43,8 +43,9 @@ const RoadmapPage = lazy(() => import('@/pages/RoadmapPage'));
|
||||||
const DataPage = lazy(() => import('@/pages/DataPage'));
|
const DataPage = lazy(() => import('@/pages/DataPage'));
|
||||||
const ProfilePage = lazy(() => import('@/pages/ProfilePage'));
|
const ProfilePage = lazy(() => import('@/pages/ProfilePage'));
|
||||||
const SnowballPage = lazy(() => import('@/pages/SnowballPage'));
|
const SnowballPage = lazy(() => import('@/pages/SnowballPage'));
|
||||||
const HealthPage = lazy(() => import('@/pages/HealthPage'));
|
const HealthPage = lazy(() => import('@/pages/HealthPage'));
|
||||||
const PayoffPage = lazy(() => import('@/pages/PayoffPage'));
|
const PayoffPage = lazy(() => import('@/pages/PayoffPage'));
|
||||||
|
const SpendingPage = lazy(() => import('@/pages/SpendingPage'));
|
||||||
|
|
||||||
function RequireAuth({ children, role }) {
|
function RequireAuth({ children, role }) {
|
||||||
const { user, singleUserMode } = useAuth();
|
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="analytics" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><AnalyticsPage /></Suspense></ErrorBoundary>} />
|
||||||
<Route path="settings" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SettingsPage /></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="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="data" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><DataPage /></Suspense></ErrorBoundary>} />
|
||||||
<Route path="profile" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><ProfilePage /></Suspense></ErrorBoundary>} />
|
<Route path="profile" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><ProfilePage /></Suspense></ErrorBoundary>} />
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
|
|
|
||||||
|
|
@ -33,11 +33,6 @@ async function _fetch(method, path, body) {
|
||||||
return data;
|
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 = {}) {
|
function queryString(params = {}) {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
|
@ -47,6 +42,12 @@ function queryString(params = {}) {
|
||||||
return value ? `?${value}` : '';
|
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) {
|
function filenameFromDisposition(value) {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const match = value.match(/filename="?([^"]+)"?/i);
|
const match = value.match(/filename="?([^"]+)"?/i);
|
||||||
|
|
@ -64,6 +65,16 @@ export const api = {
|
||||||
acknowledgePrivacy: () => post('/auth/acknowledge-privacy'),
|
acknowledgePrivacy: () => post('/auth/acknowledge-privacy'),
|
||||||
acknowledgeVersion: () => post('/auth/acknowledge-version'),
|
acknowledgeVersion: () => post('/auth/acknowledge-version'),
|
||||||
loginHistory: () => get('/auth/login-history'),
|
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'),
|
totpStatus: () => get('/auth/totp/status'),
|
||||||
totpSetup: () => get('/auth/totp/setup'),
|
totpSetup: () => get('/auth/totp/setup'),
|
||||||
totpEnable: (data) => post('/auth/totp/enable', data),
|
totpEnable: (data) => post('/auth/totp/enable', data),
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Activity, BarChart3, Calculator, CalendarDays, ChevronDown, ClipboardCheck, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt,
|
Activity, BarChart3, Calculator, CalendarDays, ChevronDown, ClipboardCheck, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt,
|
||||||
Search, Settings, ShieldCheck, Tag, TrendingDown, User, X,
|
Search, Settings, ShieldCheck, Tag, TrendingDown, User, X,
|
||||||
Repeat,
|
Repeat, ShoppingCart,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
|
@ -41,6 +41,7 @@ const trackerItems = [
|
||||||
{ to: '/subscriptions', icon: Repeat, label: 'Subscriptions' },
|
{ to: '/subscriptions', icon: Repeat, label: 'Subscriptions' },
|
||||||
{ to: '/categories', icon: Tag, label: 'Categories' },
|
{ to: '/categories', icon: Tag, label: 'Categories' },
|
||||||
{ to: '/health', icon: ClipboardCheck, label: 'Health' },
|
{ to: '/health', icon: ClipboardCheck, label: 'Health' },
|
||||||
|
{ to: '/spending', icon: ShoppingCart, label: 'Spending' },
|
||||||
{ to: '/snowball', icon: TrendingDown, label: 'Snowball' },
|
{ to: '/snowball', icon: TrendingDown, label: 'Snowball' },
|
||||||
{ to: '/payoff', icon: Calculator, label: 'Payoff' },
|
{ to: '/payoff', icon: Calculator, label: 'Payoff' },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2785,6 +2785,56 @@ function runMigrations() {
|
||||||
`);
|
`);
|
||||||
console.log('[v0.86] users: TOTP columns + totp_challenges table');
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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/summary', csrfMiddleware, requireAuth, requireUser, require('./routes/summary'));
|
||||||
app.use('/api/monthly-starting-amounts', csrfMiddleware, requireAuth, requireUser, require('./routes/monthly-starting-amounts'));
|
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/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/snowball', csrfMiddleware, requireAuth, requireUser, require('./routes/snowball'));
|
||||||
app.use('/api/notifications', csrfMiddleware, requireAuth, require('./routes/notifications'));
|
app.use('/api/notifications', csrfMiddleware, requireAuth, require('./routes/notifications'));
|
||||||
app.use('/api/status', csrfMiddleware, requireAuth, requireAdmin, require('./routes/status'));
|
app.use('/api/status', csrfMiddleware, requireAuth, requireAdmin, require('./routes/status'));
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ const {
|
||||||
const { getBankSyncConfig } = require('./bankSyncConfigService');
|
const { getBankSyncConfig } = require('./bankSyncConfigService');
|
||||||
const { decorateDataSource } = require('./transactionService');
|
const { decorateDataSource } = require('./transactionService');
|
||||||
const { applyMerchantRules } = require('./billMerchantRuleService');
|
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 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
|
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 = ?
|
WHERE id = ? AND user_id = ?
|
||||||
`).run(partialError, dataSource.id, userId);
|
`).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);
|
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 };
|
return { accountsUpserted, transactionsNew, transactionsSkip, autoMatched, matched_bills: matchedBills || [], late_attributions: lateAttributions || [], errlist: raw._errlistSummary || null };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue