From 7d2d0bf45ea62689b9ba60c99de4b8ab95b2d129 Mon Sep 17 00:00:00 2001 From: null Date: Thu, 14 May 2026 02:11:54 -0500 Subject: [PATCH] 0.28.0 snowball release --- client/App.jsx | 2 + client/api.js | 8 + client/components/BillModal.jsx | 192 +++++++-- client/components/layout/Sidebar.jsx | 3 +- client/lib/version.js | 15 +- client/pages/SnowballPage.jsx | 591 +++++++++++++++++++++++++++ db/database.js | 86 ++++ routes/bills.js | 60 ++- routes/payments.js | 50 ++- routes/snowball.js | 116 ++++++ scripts/seedDemoData.js | 23 +- server.js | 1 + services/billsService.js | 78 ++++ services/snowballService.js | 158 +++++++ 14 files changed, 1309 insertions(+), 74 deletions(-) create mode 100644 client/pages/SnowballPage.jsx create mode 100644 routes/snowball.js create mode 100644 services/snowballService.js diff --git a/client/App.jsx b/client/App.jsx index 5aefb68..e673569 100644 --- a/client/App.jsx +++ b/client/App.jsx @@ -38,6 +38,7 @@ const AboutPage = lazy(() => import('@/pages/AboutPage')); const RoadmapPage = lazy(() => import('@/pages/RoadmapPage')); const DataPage = lazy(() => import('@/pages/DataPage')); const ProfilePage = lazy(() => import('@/pages/ProfilePage')); +const SnowballPage = lazy(() => import('@/pages/SnowballPage')); function RequireAuth({ children, role }) { const { user, singleUserMode } = useAuth(); @@ -185,6 +186,7 @@ export default function App() { }>} /> }>} /> }>} /> + }>} /> }>} /> }>} /> } /> diff --git a/client/api.js b/client/api.js index 16afff3..0ce2506 100644 --- a/client/api.js +++ b/client/api.js @@ -142,6 +142,7 @@ export const api = { bill: (id) => get(`/bills/${id}`), createBill: (data) => post('/bills', data), updateBill: (id, data) => put(`/bills/${id}`, data), + updateBillBalance: (id, bal) => _fetch('PATCH', `/bills/${id}/balance`, { current_balance: bal }), deleteBill: (id) => del(`/bills/${id}`), togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data), billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`), @@ -160,6 +161,13 @@ export const api = { deletePayment: (id) => del(`/payments/${id}`), restorePayment: (id) => post(`/payments/${id}/restore`), + // Snowball + snowball: () => get('/snowball'), + snowballSettings: () => get('/snowball/settings'), + saveSnowballSettings: (data) => _fetch('PATCH', '/snowball/settings', data), + saveSnowballOrder: (items) => _fetch('PATCH', '/snowball/order', items), + snowballProjection: () => get('/snowball/projection'), + // Categories categories: () => get('/categories'), createCategory: (data) => post('/categories', data), diff --git a/client/components/BillModal.jsx b/client/components/BillModal.jsx index e4cb1d1..d8d2fe6 100644 --- a/client/components/BillModal.jsx +++ b/client/components/BillModal.jsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { ChevronDown } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -12,7 +13,6 @@ import { import { api } from '@/api'; import { cn } from '@/lib/utils'; -// Helper function to get ordinal suffix (1st, 2nd, 3rd, etc.) function getOrdinalSuffix(day) { if (day > 3 && day < 21) return 'th'; switch (day % 10) { @@ -26,6 +26,14 @@ function getOrdinalSuffix(day) { // Radix Select crashes on empty string value const CAT_NONE = 'none'; +const DEBT_KEYWORDS = ['credit', 'loan', 'mortgage', 'housing', 'debt']; + +function isDebtCat(categories, catId) { + if (!catId || catId === CAT_NONE) return false; + const cat = categories.find(c => String(c.id) === catId); + return cat ? DEBT_KEYWORDS.some(kw => cat.name.toLowerCase().includes(kw)) : false; +} + export default function BillModal({ bill, categories, onClose, onSave }) { const isNew = !bill; @@ -43,12 +51,17 @@ export default function BillModal({ bill, categories, onClose, onSave }) { const [username, setUsername] = useState(bill?.username || ''); const [accountInfo, setAccountInfo] = useState(bill?.account_info || ''); const [notes, setNotes] = useState(bill?.notes || ''); + const [currentBalance, setCurrentBalance] = useState(bill?.current_balance == null ? '' : String(bill.current_balance)); + const [minimumPayment, setMinimumPayment] = useState(bill?.minimum_payment == null ? '' : String(bill.minimum_payment)); + const [snowballInclude, setSnowballInclude] = useState(!!bill?.snowball_include); + const [showDebtSection, setShowDebtSection] = useState( + () => isDebtCat(categories, bill?.category_id ? String(bill.category_id) : CAT_NONE) + ); const [busy, setBusy] = useState(false); - - // Validation state const [errors, setErrors] = useState({}); - // Real-time validation helpers + const isDebtCategory = isDebtCat(categories, categoryId); + const validateName = (val) => { if (!val || val.trim() === '') return 'Name is required'; if (val.trim().length < 2) return 'Name must be at least 2 characters'; @@ -77,44 +90,55 @@ export default function BillModal({ bill, categories, onClose, onSave }) { return ''; }; + const validateCurrentBalance = (val) => { + if (val === '' || val === null) return ''; + const num = parseFloat(val); + if (isNaN(num) || num < 0) return 'Balance must be a non-negative number'; + return ''; + }; + + const validateMinimumPayment = (val) => { + if (val === '' || val === null) return ''; + const num = parseFloat(val); + if (isNaN(num) || num < 0) return 'Min payment must be a non-negative number'; + return ''; + }; + const validateForm = () => { const newErrors = { name: validateName(name), dueDay: validateDueDay(dueDay), expectedAmount: validateExpectedAmount(expectedAmount), interestRate: validateInterestRate(interestRate), + currentBalance: validateCurrentBalance(currentBalance), + minimumPayment: validateMinimumPayment(minimumPayment), }; setErrors(newErrors); return Object.values(newErrors).every(err => err === ''); }; - // Validation on blur const handleBlur = (field, validator) => { - setErrors(prev => ({ ...prev, [field]: validator(field === 'name' ? name : field === 'dueDay' ? dueDay : field === 'expectedAmount' ? expectedAmount : interestRate) })); + setErrors(prev => ({ ...prev, [field]: validator( + field === 'name' ? name : + field === 'dueDay' ? dueDay : + field === 'expectedAmount' ? expectedAmount : + interestRate + )})); }; - // Validation on change - debounce for better UX - const handleChange = (field, value, validator) => { - if (field === 'name') setName(value); - if (field === 'dueDay') setDueDay(value); - if (field === 'expectedAmount') setExpected(value); - if (field === 'interestRate') setInterestRate(value); - // Only validate after input, not every keystroke - setTimeout(() => { - setErrors(prev => ({ ...prev, [field]: validator(value) })); - }, 300); + const handleCategoryChange = (val) => { + setCategoryId(val); + if (isDebtCat(categories, val)) setShowDebtSection(true); }; async function handleSubmit(e) { e.preventDefault(); - - // Run form validation + if (!validateForm()) { toast.error('Please fix the form errors before saving.'); return; } - // Additional server-side validation checks const parsedDueDay = Number(dueDay); if (!Number.isInteger(parsedDueDay) || parsedDueDay < 1 || parsedDueDay > 31) { toast.error('Due day must be a whole number from 1 to 31.'); @@ -143,6 +167,9 @@ export default function BillModal({ bill, categories, onClose, onSave }) { username: username || null, account_info: accountInfo || null, notes: notes || null, + current_balance: currentBalance === '' ? null : parseFloat(currentBalance), + minimum_payment: minimumPayment === '' ? null : parseFloat(minimumPayment), + snowball_include: snowballInclude, }; setBusy(true); try { @@ -198,7 +225,7 @@ export default function BillModal({ bill, categories, onClose, onSave }) { {/* Category */}
- @@ -250,27 +277,6 @@ export default function BillModal({ bill, categories, onClose, onSave }) { )}
- {/* Interest Rate */} -
- - { - setInterestRate(e.target.value); - setTimeout(() => setErrors(prev => ({ ...prev, interestRate: validateInterestRate(e.target.value) })), 300); - }} - onBlur={() => handleBlur('interestRate', validateInterestRate)} - /> - {errors.interestRate && ( - {errors.interestRate} - )} -

- Optional, useful for credit cards. Enter 29.99 for 29.99%. -

-
- {/* Billing Cycle */}
@@ -343,12 +349,112 @@ export default function BillModal({ bill, categories, onClose, onSave }) { /> )}

- {cycleType === 'monthly' ? 'Day of the month' : - cycleType === 'weekly' || cycleType === 'biweekly' ? 'Day of the week' : + {cycleType === 'monthly' ? 'Day of the month' : + cycleType === 'weekly' || cycleType === 'biweekly' ? 'Day of the week' : 'Day of the period'}

+ {/* Debt / Credit Details — collapsible */} +
+ + + {showDebtSection && ( +
+ + {/* Interest Rate */} +
+ + { + setInterestRate(e.target.value); + setTimeout(() => setErrors(prev => ({ ...prev, interestRate: validateInterestRate(e.target.value) })), 300); + }} + onBlur={() => handleBlur('interestRate', validateInterestRate)} + /> + {errors.interestRate && ( + {errors.interestRate} + )} +

Enter 29.99 for 29.99%.

+
+ + {/* Current Balance */} +
+ + { + setCurrentBalance(e.target.value); + setTimeout(() => setErrors(prev => ({ ...prev, currentBalance: validateCurrentBalance(e.target.value) })), 300); + }} + onBlur={() => setErrors(prev => ({ ...prev, currentBalance: validateCurrentBalance(currentBalance) }))} + /> + {errors.currentBalance && ( + {errors.currentBalance} + )} +

Outstanding debt balance.

+
+ + {/* Minimum Payment */} +
+ + { + setMinimumPayment(e.target.value); + setTimeout(() => setErrors(prev => ({ ...prev, minimumPayment: validateMinimumPayment(e.target.value) })), 300); + }} + onBlur={() => setErrors(prev => ({ ...prev, minimumPayment: validateMinimumPayment(minimumPayment) }))} + /> + {errors.minimumPayment && ( + {errors.minimumPayment} + )} +

Required minimum monthly payment.

+
+ + {/* Include in Snowball */} +
+ +

+ Force this bill onto the debt snowball page. +

+
+ +
+ )} +
+ {/* Website */}
diff --git a/client/components/layout/Sidebar.jsx b/client/components/layout/Sidebar.jsx index a1d21c6..1c8817f 100644 --- a/client/components/layout/Sidebar.jsx +++ b/client/components/layout/Sidebar.jsx @@ -2,7 +2,7 @@ import { useState, useMemo } from 'react'; import { NavLink, useLocation, useNavigate } from 'react-router-dom'; import { Activity, BarChart3, CalendarDays, ChevronDown, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt, - Settings, ShieldCheck, Tag, User, X, + Settings, ShieldCheck, Tag, TrendingDown, User, X, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useAuth } from '@/hooks/useAuth'; @@ -35,6 +35,7 @@ const trackerItems = [ { to: '/summary', icon: ClipboardList, label: 'Summary' }, { to: '/bills', icon: Receipt, label: 'Bills' }, { to: '/categories', icon: Tag, label: 'Categories' }, + { to: '/snowball', icon: TrendingDown, label: 'Snowball' }, ]; function TrackerMenu({ onNavigate }) { diff --git a/client/lib/version.js b/client/lib/version.js index fed3606..cd91a01 100644 --- a/client/lib/version.js +++ b/client/lib/version.js @@ -1,15 +1,14 @@ -export const APP_VERSION = '0.26.1'; +export const APP_VERSION = '0.27.0'; export const APP_NAME = 'BillTracker'; export const RELEASE_NOTES = { - version: '0.26.1', - date: '2026-05-11', + version: '0.27.0', + date: '2026-05-14', highlights: [ + { icon: '❄️', title: 'Debt Snowball', desc: 'New Snowball page: drag-and-drop debt ordering, Dave Ramsey payoff projections, avalanche method comparison, and balance update by clicking any balance figure.' }, + { icon: '💳', title: 'Debt Details on Bills', desc: 'Add current balance, minimum payment, and APR directly to any bill. Bills in Credit Cards, Loans, and Mortgage categories are auto-detected.' }, + { icon: '📉', title: 'Payment → Balance Sync', desc: 'Recording a payment on a debt bill automatically reduces its current balance (principal = payment minus one month of interest). Un-marking a payment reverses the change.' }, { icon: '📊', title: 'Dual-Column XLSX Import', desc: 'Bills due on the 1st and 15th are now both imported from dual-layout spreadsheets' }, - { icon: '🛡️', title: 'Security Review', desc: 'Bounds validation, regex safety, type checks all passed (Private_Hudson)' }, - { icon: '🗺️', title: 'Roadmap Page Redesign', desc: 'Kanban-style priority lanes with collapsible items, admin-only roadmap and activity log APIs replacing AdminDashboard' }, { icon: '🛡️', title: 'Import CSRF Fix', desc: 'XLSX, SQLite, and backup imports now include CSRF token (previously blocked with "session expired" error)' }, - { icon: '🧹', title: 'AdminDashboard Replaced', desc: 'RoadmapPage now handles admin roadmap and development log display' }, - { icon: '🐞', title: 'Dual-Column Parser Bugfixes', desc: 'Fixed header detection (repeat-field instead of gap-based), column leakage, summary row filtering, header_set_index output, and amount header pattern' }, ], -}; \ No newline at end of file +}; diff --git a/client/pages/SnowballPage.jsx b/client/pages/SnowballPage.jsx new file mode 100644 index 0000000..46d6e81 --- /dev/null +++ b/client/pages/SnowballPage.jsx @@ -0,0 +1,591 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { GripVertical, TrendingDown, Zap, Save, CalendarCheck, AlertTriangle } from 'lucide-react'; +import { toast } from 'sonner'; +import { api } from '@/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Skeleton } from '@/components/ui/Skeleton'; +import { cn } from '@/lib/utils'; +import BillModal from '@/components/BillModal'; + +// ── formatters ──────────────────────────────────────────────────────────────── +function fmt(val) { + if (val == null) return '—'; + return Number(val).toLocaleString(undefined, { + style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2, + }); +} +function fmtCompact(val) { + if (val == null || val === 0) return '—'; + return Number(val).toLocaleString(undefined, { + style: 'currency', currency: 'USD', maximumFractionDigits: 0, + }); +} +function ordinal(n) { + const d = Number(n); + if (!d) return '—'; + if (d > 3 && d < 21) return `${d}th`; + switch (d % 10) { + case 1: return `${d}st`; case 2: return `${d}nd`; case 3: return `${d}rd`; default: return `${d}th`; + } +} + +// ── StatCard ────────────────────────────────────────────────────────────────── +function StatCard({ label, value, sub, highlight }) { + return ( +
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+ ); +} + +// ── Projection panel ────────────────────────────────────────────────────────── +function AvalancheComparison({ snowball, avalanche }) { + if (!snowball.months_to_freedom || !avalanche.months_to_freedom) return null; + const monthDiff = snowball.months_to_freedom - avalanche.months_to_freedom; + const interestDiff = snowball.total_interest_paid - avalanche.total_interest_paid; + const same = Math.abs(monthDiff) < 1 && Math.abs(interestDiff) < 1; + return ( +
+

+ vs. Avalanche (highest rate first) +

+
+ {avalanche.payoff_display} + {fmt(avalanche.total_interest_paid)} interest +
+ {same ? ( +

Same result — your debts have similar rates.

+ ) : interestDiff > 0 ? ( +

+ Avalanche saves {fmt(interestDiff)} interest + {monthDiff > 0 ? ` · ${monthDiff} month${monthDiff > 1 ? 's' : ''} faster` : ''} +

+ ) : ( +

+ Snowball finishes {Math.abs(monthDiff)} month{Math.abs(monthDiff) > 1 ? 's' : ''} faster · + Avalanche costs {fmt(Math.abs(interestDiff))} more +

+ )} +
+ ); +} + +function ProjectionPanel({ projection, projectionLoading, billCount }) { + if (projectionLoading) { + return ( +
+ +
{[...Array(3)].map((_, i) => )}
+
+ ); + } + if (!projection) return null; + const sb = projection.snowball; + const av = projection.avalanche; + if (!sb) return null; + const hasProjection = sb.debts.length > 0; + const needsBalances = billCount > 0 && !hasProjection && sb.skipped.length > 0; + return ( +
+
+
+ + Payoff Projection +
+ {sb.payoff_display && ( +
+

Snowball · Debt-Free

+

{sb.payoff_display}

+
+ )} +
+ {sb.capped && ( +
+ + Payoff exceeds 50 years. Add extra monthly budget or increase minimum payments. +
+ )} + {needsBalances && ( +
+ Click any balance to enter it and see your payoff timeline. +
+ )} + {hasProjection && ( +
+ {sb.debts.map((d, i) => ( +
+ #{i + 1} + {d.name} +
+ {d.payoff_display ? ( + <> +

{d.payoff_display}

+

+ {d.months} mo · {fmtCompact(d.total_interest)} interest +

+ + ) : ( +

unknown balance

+ )} +
+
+ ))} +
+ )} + {hasProjection && ( +
+ Total interest paid + {fmt(sb.total_interest_paid)} +
+ )} + {hasProjection && av && } + {sb.skipped.length > 0 && hasProjection && ( +
+ {sb.skipped.length} bill{sb.skipped.length > 1 ? 's' : ''} excluded (no balance): + {' '}{sb.skipped.map(s => s.name).join(', ')} +
+ )} +
+ ); +} + +// ── Pointer-based drag-and-drop hook (works on touch + mouse) ───────────────── +function useSortable(items, setItems, setDirty) { + const [draggingIdx, setDraggingIdx] = useState(null); + + // Refs that live through the entire drag gesture + const state = useRef({ + fromIdx: null, // card index where the drag started + currentIdx: null, // card index currently under the pointer + startY: 0, + itemHeight: 0, + containerEl: null, + }); + + const onPointerDown = useCallback((e, index) => { + // Only trigger on the grip handle (data-grip attr) + if (!e.currentTarget.dataset.grip) return; + // Ignore right-click + if (e.button !== undefined && e.button !== 0) return; + + e.currentTarget.setPointerCapture(e.pointerId); + + const card = e.currentTarget.closest('[data-card]'); + const list = card?.parentElement; + const rect = card?.getBoundingClientRect(); + + state.current = { + fromIdx: index, + currentIdx: index, + startY: e.clientY, + itemHeight: rect?.height ?? 80, + containerEl: list ?? null, + }; + setDraggingIdx(index); + }, []); + + const onPointerMove = useCallback((e) => { + if (state.current.fromIdx === null) return; + const { containerEl, startY, itemHeight, currentIdx } = state.current; + if (!containerEl) return; + + const dy = e.clientY - startY; + const shift = Math.round(dy / itemHeight); + const newIdx = Math.max(0, Math.min(items.length - 1, state.current.fromIdx + shift)); + + if (newIdx !== currentIdx) { + state.current.currentIdx = newIdx; + setDraggingIdx(newIdx); // visual feedback on where card will land + } + }, [items.length]); + + const onPointerUp = useCallback((e) => { + const { fromIdx, currentIdx } = state.current; + state.current.fromIdx = null; + state.current.currentIdx = null; + setDraggingIdx(null); + + if (fromIdx === null || currentIdx === null || fromIdx === currentIdx) return; + setItems(prev => { + const next = [...prev]; + const [moved] = next.splice(fromIdx, 1); + next.splice(currentIdx, 0, moved); + return next; + }); + setDirty(true); + }, [setItems, setDirty]); + + return { draggingIdx, onPointerDown, onPointerMove, onPointerUp }; +} + +// ── Page ────────────────────────────────────────────────────────────────────── +export default function SnowballPage() { + const [bills, setBills] = useState([]); + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [dirty, setDirty] = useState(false); + const [editBill, setEditBill] = useState(null); + + const [extraPayment, setExtraPayment] = useState(''); + const [savingSettings, setSavingSettings] = useState(false); + const extraPaymentRef = useRef(''); + + const [projection, setProjection] = useState(null); + const [projectionLoading, setProjectionLoading] = useState(false); + + const [editingBalance, setEditingBalance] = useState({ billId: null, value: '' }); + + const { draggingIdx, onPointerDown, onPointerMove, onPointerUp } = + useSortable(bills, setBills, setDirty); + + // ── loading ─────────────────────────────────────────────────────────────── + const loadProjection = useCallback(async () => { + setProjectionLoading(true); + try { setProjection(await api.snowballProjection()); } + catch { /* non-fatal */ } + finally { setProjectionLoading(false); } + }, []); + + const load = useCallback(async () => { + setLoading(true); + try { + const [billsArr, catsArr, settings] = await Promise.all([ + api.snowball(), api.categories(), api.snowballSettings(), + ]); + setCategories(catsArr); + setBills(billsArr); + setDirty(false); + const ep = settings.extra_payment > 0 ? String(settings.extra_payment) : ''; + setExtraPayment(ep); + extraPaymentRef.current = ep; + } catch (err) { + toast.error(err.message || 'Failed to load snowball data'); + } finally { setLoading(false); } + }, []); + + useEffect(() => { Promise.all([load(), loadProjection()]); }, [load, loadProjection]); + + // ── auto-arrange ────────────────────────────────────────────────────────── + const handleAutoArrange = () => { + setBills(prev => [...prev].sort((a, b) => { + if (a.current_balance == null && b.current_balance == null) return 0; + if (a.current_balance == null) return 1; + if (b.current_balance == null) return -1; + return a.current_balance - b.current_balance; + })); + setDirty(true); + toast.success('Arranged smallest-to-largest balance'); + }; + + // ── save order ──────────────────────────────────────────────────────────── + const handleSaveOrder = async () => { + setSaving(true); + try { + await api.saveSnowballOrder(bills.map((b, i) => ({ id: b.id, snowball_order: i }))); + setDirty(false); + toast.success('Order saved'); + loadProjection(); + } catch (err) { toast.error(err.message || 'Failed to save order'); } + finally { setSaving(false); } + }; + + // ── extra payment ───────────────────────────────────────────────────────── + const handleSaveExtraPayment = async () => { + const val = extraPayment.trim(); + if (val !== '' && (isNaN(parseFloat(val)) || parseFloat(val) < 0)) { + toast.error('Extra payment must be a positive number'); return; + } + if (val === extraPaymentRef.current) return; + setSavingSettings(true); + try { + const result = await api.saveSnowballSettings({ extra_payment: val === '' ? 0 : parseFloat(val) }); + const saved = result.extra_payment > 0 ? String(result.extra_payment) : ''; + extraPaymentRef.current = saved; + setExtraPayment(saved); + toast.success('Extra payment saved'); + loadProjection(); + } catch (err) { toast.error(err.message || 'Failed to save'); } + finally { setSavingSettings(false); } + }; + + // ── inline balance edit ─────────────────────────────────────────────────── + const startEditBalance = (bill) => + setEditingBalance({ billId: bill.id, value: bill.current_balance != null ? String(bill.current_balance) : '' }); + + const commitBalance = async (billId) => { + const raw = editingBalance.value.trim(); + const num = raw === '' ? null : parseFloat(raw); + if (raw !== '' && (isNaN(num) || num < 0)) { toast.error('Balance must be a non-negative number'); return; } + const current = bills.find(b => b.id === billId); + if (num === current?.current_balance) { setEditingBalance({ billId: null, value: '' }); return; } + try { + await api.updateBillBalance(billId, num); + setBills(prev => prev.map(b => b.id === billId ? { ...b, current_balance: num } : b)); + setEditingBalance({ billId: null, value: '' }); + loadProjection(); + } catch (err) { toast.error(err.message || 'Failed to update balance'); } + }; + + // ── stats ───────────────────────────────────────────────────────────────── + const totalBalance = bills.reduce((s, b) => s + (b.current_balance || 0), 0); + const totalMinPayment = bills.reduce((s, b) => s + (b.minimum_payment || 0), 0); + const unknownCount = bills.filter(b => b.current_balance == null).length; + const extraAmt = parseFloat(extraPayment) || 0; + + // ── loading skeleton ────────────────────────────────────────────────────── + if (loading) { + return ( +
+ +
+ {[...Array(4)].map((_, i) => )} +
+
+ {[...Array(3)].map((_, i) => )} +
+
+ ); + } + + const inp = 'bg-background/50 border-border/60 h-9 text-sm font-mono'; + + return ( +
+ + {/* Header */} +
+

+ + Debt Snowball +

+

+ Dave Ramsey method — attack the smallest balance first, roll payments as each debt clears. + Marking a payment automatically reduces the outstanding balance. +

+
+ + {/* Stats */} + {bills.length > 0 && ( +
+ 0 ? `+ ${unknownCount} unknown` : undefined} /> + + 0 ? fmt(extraAmt) : '—'} sub="snowball accelerator" /> + 0} /> +
+ )} + + {/* Toolbar */} + {bills.length > 0 && ( +
+
+ + setExtraPayment(e.target.value)} + onBlur={handleSaveExtraPayment} + className={cn(inp, 'w-32')} + disabled={savingSettings} + /> +
+
+ + + {dirty && Unsaved changes} +
+
+ )} + + {/* Empty state */} + {bills.length === 0 && ( +
+ +

No debt bills found

+

+ Bills in Credit Cards, Loans, or Mortgage categories appear here automatically. + You can also enable "Include in Snowball" when editing any bill. +

+
+ )} + + {/* Cards + projection */} + {bills.length > 0 && ( +
+ + {/* Cards list — pointer events on the whole list so moves are tracked even outside a card */} +
+ {bills.map((bill, index) => { + const isAttack = index === 0; + const isEditingBal = editingBalance.billId === bill.id; + const isDragging = draggingIdx !== null; + const isTarget = draggingIdx === index; // where it will land + + return ( +
+
+ + {/* Grip handle — pointer-capture trigger */} +
onPointerDown(e, index)} + className="flex items-center px-3 text-muted-foreground/30 hover:text-muted-foreground/70 cursor-grab active:cursor-grabbing transition-colors touch-none" + aria-label="Drag to reorder" + > + +
+ + {/* Body */} +
+ {/* Top row */} +
+ + #{index + 1} + + {isAttack && ( + + Attack + + )} + {bill.name} + {bill.category_name && ( + + {bill.category_name} + + )} + {bill.snowball_include === 1 && !bill.category_name && ( + + manual + + )} + +
+ + {/* Stats row */} +
+ + {/* Balance — inline editable */} +
+ Balance + {isEditingBal ? ( + setEditingBalance(p => ({ ...p, value: e.target.value }))} + onBlur={() => commitBalance(bill.id)} + onKeyDown={e => { + if (e.key === 'Enter') e.target.blur(); + if (e.key === 'Escape') setEditingBalance({ billId: null, value: '' }); + }} + className={cn(inp, 'h-7 w-28 text-xs py-0 px-2')} + /> + ) : ( + + )} +
+ +
+ Min/mo + + {bill.minimum_payment != null ? fmt(bill.minimum_payment) : '—'} + +
+ + {isAttack && extraAmt > 0 && ( +
+ Attack + + {fmt((bill.minimum_payment || 0) + extraAmt)} + +
+ )} + + {bill.interest_rate != null && ( +
+ APR + {bill.interest_rate}% +
+ )} + +
+ Due + {ordinal(bill.due_day)} +
+
+
+
+
+ ); + })} + +

+ Drag the grip handle to reorder · Click a balance to update it · Save Order to persist +

+
+ + {/* Projection (sticky sidebar on large screens) */} +
+ +
+
+ )} + + {/* Edit modal */} + {editBill && ( + setEditBill(null)} + onSave={() => { setEditBill(null); load(); loadProjection(); }} + /> + )} +
+ ); +} diff --git a/db/database.js b/db/database.js index 41bbc61..d17a81b 100644 --- a/db/database.js +++ b/db/database.js @@ -43,6 +43,7 @@ const COLUMN_WHITELIST = new Set([ 'other_amount', // bills table columns 'history_visibility', 'interest_rate', 'user_id', + 'current_balance', 'minimum_payment', 'snowball_order', 'snowball_include', // sessions table columns 'created_at', ]); @@ -669,6 +670,37 @@ function reconcileLegacyMigrations() { db.prepare("UPDATE settings SET value = '2' WHERE key = 'backup_schedule_retention_count' AND value = '14'").run(); console.log('[migration] backup_schedule_retention_count updated from 14 to 2'); } + }, + { + version: 'v0.48', + description: 'bills: debt snowball fields (current_balance, minimum_payment, snowball_order, snowball_include)', + check: function() { + const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); + return ['current_balance', 'minimum_payment', 'snowball_order', 'snowball_include'].every(c => cols.includes(c)); + }, + run: function() { + const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); + if (!cols.includes('current_balance')) db.exec('ALTER TABLE bills ADD COLUMN current_balance REAL'); + if (!cols.includes('minimum_payment')) db.exec('ALTER TABLE bills ADD COLUMN minimum_payment REAL'); + if (!cols.includes('snowball_order')) db.exec('ALTER TABLE bills ADD COLUMN snowball_order INTEGER'); + if (!cols.includes('snowball_include')) db.exec('ALTER TABLE bills ADD COLUMN snowball_include INTEGER NOT NULL DEFAULT 0'); + console.log('[migration] bills: debt snowball columns added'); + } + }, + { + version: 'v0.49', + description: 'users: snowball_extra_payment column', + check: function() { + const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); + return cols.includes('snowball_extra_payment'); + }, + run: function() { + const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); + if (!cols.includes('snowball_extra_payment')) { + db.exec('ALTER TABLE users ADD COLUMN snowball_extra_payment REAL NOT NULL DEFAULT 0'); + } + console.log('[migration] users: snowball_extra_payment column added'); + } } ]; @@ -1152,6 +1184,43 @@ function runMigrations() { db.prepare("UPDATE settings SET value = '2' WHERE key = 'backup_schedule_retention_count' AND value = '14'").run(); console.log('[migration] backup_schedule_retention_count updated from 14 to 2'); } + }, + { + version: 'v0.48', + description: 'bills: debt snowball fields (current_balance, minimum_payment, snowball_order, snowball_include)', + dependsOn: ['v0.47'], + run: function() { + const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); + if (!cols.includes('current_balance')) db.exec('ALTER TABLE bills ADD COLUMN current_balance REAL'); + if (!cols.includes('minimum_payment')) db.exec('ALTER TABLE bills ADD COLUMN minimum_payment REAL'); + if (!cols.includes('snowball_order')) db.exec('ALTER TABLE bills ADD COLUMN snowball_order INTEGER'); + if (!cols.includes('snowball_include')) db.exec('ALTER TABLE bills ADD COLUMN snowball_include INTEGER NOT NULL DEFAULT 0'); + console.log('[migration] bills: debt snowball columns added'); + } + }, + { + version: 'v0.49', + description: 'users: snowball_extra_payment column', + dependsOn: ['v0.48'], + run: function() { + const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); + if (!cols.includes('snowball_extra_payment')) { + db.exec('ALTER TABLE users ADD COLUMN snowball_extra_payment REAL NOT NULL DEFAULT 0'); + } + console.log('[migration] users: snowball_extra_payment column added'); + } + }, + { + version: 'v0.50', + description: 'payments: balance_delta column for debt payoff tracking', + dependsOn: ['v0.49'], + run: function() { + const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name); + if (!cols.includes('balance_delta')) { + db.exec('ALTER TABLE payments ADD COLUMN balance_delta REAL'); + } + console.log('[migration] payments: balance_delta column added'); + } } ]; @@ -1521,6 +1590,23 @@ const ROLLBACK_SQL_MAP = { sql: [ "UPDATE settings SET value = '14' WHERE key = 'backup_schedule_retention_count' AND value = '2'" ] + }, + 'v0.48': { + description: 'bills: debt snowball fields', + sql: [ + 'ALTER TABLE bills DROP COLUMN snowball_include', + 'ALTER TABLE bills DROP COLUMN snowball_order', + 'ALTER TABLE bills DROP COLUMN minimum_payment', + 'ALTER TABLE bills DROP COLUMN current_balance', + ] + }, + 'v0.49': { + description: 'users: snowball extra payment field', + sql: ['ALTER TABLE users DROP COLUMN snowball_extra_payment'] + }, + 'v0.50': { + description: 'payments: balance_delta column', + sql: ['ALTER TABLE payments DROP COLUMN balance_delta'] } }; diff --git a/routes/bills.js b/routes/bills.js index 5e8908b..8dba089 100644 --- a/routes/bills.js +++ b/routes/bills.js @@ -1,7 +1,7 @@ const express = require('express'); const router = express.Router(); const { getDb, ensureUserDefaultCategories } = require('../db/database'); -const { VALID_VISIBILITY, getValidCycleTypes, parseDueDay, parseInterestRate, validateCycleDay, validateBillData } = require('../services/billsService'); +const { VALID_VISIBILITY, getValidCycleTypes, parseDueDay, parseInterestRate, validateCycleDay, validateBillData, computeBalanceDelta } = require('../services/billsService'); const { standardizeError } = require('../middleware/errorFormatter'); // ── GET /api/bills ──────────────────────────────────────────────────────────── @@ -146,8 +146,9 @@ router.post('/', (req, res) => { INSERT INTO bills (user_id, name, category_id, due_day, override_due_date, bucket, expected_amount, interest_rate, billing_cycle, autopay_enabled, autodraft_status, website, username, - account_info, has_2fa, notes, history_visibility, active, cycle_type, cycle_day) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?) + account_info, has_2fa, notes, history_visibility, active, cycle_type, cycle_day, + current_balance, minimum_payment, snowball_order, snowball_include) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?) `).run( req.user.id, normalized.name, @@ -168,6 +169,10 @@ router.post('/', (req, res) => { normalized.history_visibility, normalized.cycle_type, normalized.cycle_day, + normalized.current_balance, + normalized.minimum_payment, + normalized.snowball_order, + normalized.snowball_include, ); const created = db.prepare('SELECT * FROM bills WHERE id = ?').get(result.lastInsertRowid); @@ -200,6 +205,7 @@ router.put('/:id', (req, res) => { expected_amount = ?, interest_rate = ?, billing_cycle = ?, autopay_enabled = ?, autodraft_status = ?, website = ?, username = ?, account_info = ?, has_2fa = ?, notes = ?, active = ?, history_visibility = ?, cycle_type = ?, cycle_day = ?, + current_balance = ?, minimum_payment = ?, snowball_order = ?, snowball_include = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ? `).run( @@ -222,6 +228,10 @@ router.put('/:id', (req, res) => { normalized.history_visibility, normalized.cycle_type, normalized.cycle_day, + normalized.current_balance, + normalized.minimum_payment, + normalized.snowball_order, + normalized.snowball_include, req.params.id, req.user.id, ); @@ -286,7 +296,7 @@ router.post('/:id/toggle-paid', (req, res) => { const billId = parseInt(req.params.id, 10); // Get bill - always scope to the requesting user - const bill = db.prepare('SELECT id, expected_amount, user_id, due_day FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id); + const bill = db.prepare('SELECT id, expected_amount, user_id, due_day, current_balance, interest_rate FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id); if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); @@ -307,6 +317,14 @@ router.post('/:id/toggle-paid', (req, res) => { // If paid (has payment), remove it → Unpaid if (currentPayment) { + // Reverse any balance delta that was applied when this payment was created + if (currentPayment.balance_delta != null) { + const freshBill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId); + if (freshBill?.current_balance != null) { + const restored = Math.max(0, Math.round((freshBill.current_balance - currentPayment.balance_delta) * 100) / 100); + db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(restored, billId); + } + } db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(currentPayment.id); res.json({ success: true, @@ -339,9 +357,17 @@ router.post('/:id/toggle-paid', (req, res) => { return res.status(400).json(standardizeError('amount must be a positive number', 'VALIDATION_ERROR', 'amount')); } + // Compute balance delta for debt bills before inserting + const balCalc = computeBalanceDelta(bill, amount); + const result = db.prepare( - 'INSERT INTO payments (bill_id, amount, paid_date, method, notes) VALUES (?, ?, ?, ?, ?)' - ).run(billId, amount, paidDate, method, notes); + 'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) VALUES (?, ?, ?, ?, ?, ?)' + ).run(billId, amount, paidDate, method, notes, balCalc?.balance_delta ?? null); + + if (balCalc) { + db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?") + .run(balCalc.new_balance, billId); + } res.status(201).json({ success: true, @@ -471,4 +497,26 @@ router.delete('/:id/history-ranges/:rangeId', (req, res) => { res.json({ success: true }); }); +// ── PATCH /api/bills/:id/balance — lightweight balance-only update ──────────── +router.patch('/:id/balance', (req, res) => { + const db = getDb(); + const billId = parseInt(req.params.id, 10); + if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id)) { + return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); + } + + const raw = req.body.current_balance; + let val = null; + if (raw !== null && raw !== '' && raw !== undefined) { + val = parseFloat(raw); + if (!Number.isFinite(val) || val < 0) { + return res.status(400).json(standardizeError('current_balance must be a non-negative number', 'VALIDATION_ERROR', 'current_balance')); + } + val = Math.round(val * 100) / 100; + } + + db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(val, billId); + res.json({ id: billId, current_balance: val }); +}); + module.exports = router; diff --git a/routes/payments.js b/routes/payments.js index ec48ee5..0369a18 100644 --- a/routes/payments.js +++ b/routes/payments.js @@ -2,6 +2,7 @@ const express = require('express'); const { standardizeError } = require('../middleware/errorFormatter'); const router = require('express').Router(); const { getDb } = require('../db/database'); +const { computeBalanceDelta } = require('../services/billsService'); const LIVE = 'deleted_at IS NULL'; // filter for non-deleted payments @@ -91,9 +92,16 @@ router.post('/quick', (req, res) => { const payDate = paid_date || new Date().toISOString().slice(0, 10); + const balCalc = computeBalanceDelta(bill, payAmount); + const result = db.prepare( - 'INSERT INTO payments (bill_id, amount, paid_date, method, notes) VALUES (?, ?, ?, ?, ?)' - ).run(bill_id, payAmount, payDate, method || null, notes || null); + 'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) VALUES (?, ?, ?, ?, ?, ?)' + ).run(bill_id, payAmount, payDate, method || null, notes || null, balCalc?.balance_delta ?? null); + + if (balCalc) { + db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?") + .run(balCalc.new_balance, bill_id); + } if (bill.autopay_enabled) { db.prepare("UPDATE bills SET autodraft_status='confirmed', updated_at=datetime('now') WHERE id=?").run(bill_id); @@ -150,8 +158,10 @@ router.post('/bulk', (req, res) => { } const insert = db.prepare( - 'INSERT INTO payments (bill_id, amount, paid_date, method, notes) VALUES (?, ?, ?, ?, ?)' + 'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) VALUES (?, ?, ?, ?, ?, ?)' ); + const getBillForBalance = db.prepare('SELECT current_balance, interest_rate FROM bills WHERE id = ? AND user_id = ?'); + const applyBalance = db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?"); // Prepare statement for duplicate checking const duplicateCheckStmt = db.prepare( @@ -181,12 +191,16 @@ router.post('/bulk', (req, res) => { continue; } - if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(bill_id, req.user.id)) { + const billRow = getBillForBalance.get(bill_id, req.user.id); + if (!billRow) { errors.push({ item, error: `Bill ${bill_id} not found` }); continue; } - - const r = insert.run(bill_id, parsedAmt, paid_date, method || null, notes || null); + + const balCalc = computeBalanceDelta(billRow, parsedAmt); + const r = insert.run(bill_id, parsedAmt, paid_date, method || null, notes || null, balCalc?.balance_delta ?? null); + if (balCalc) applyBalance.run(balCalc.new_balance, bill_id); + created.push(db.prepare('SELECT * FROM payments WHERE id = ?').get(r.lastInsertRowid)); } }); @@ -222,8 +236,18 @@ router.put('/:id', (req, res) => { // DELETE /api/payments/:id — soft delete (sets deleted_at) router.delete('/:id', (req, res) => { const db = getDb(); - const payment = db.prepare(`SELECT p.id FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ?`).get(req.params.id, req.user.id); + const payment = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ?`).get(req.params.id, req.user.id); if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id')); + + // Reverse any balance delta that was stored when this payment was created + if (payment.balance_delta != null) { + const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(payment.bill_id); + if (bill?.current_balance != null) { + const restored = Math.max(0, Math.round((bill.current_balance - payment.balance_delta) * 100) / 100); + db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(restored, payment.bill_id); + } + } + db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(req.params.id); res.json({ success: true }); }); @@ -231,8 +255,18 @@ router.delete('/:id', (req, res) => { // POST /api/payments/:id/restore — undo soft delete router.post('/:id/restore', (req, res) => { const db = getDb(); - const payment = db.prepare('SELECT p.id FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.deleted_at IS NOT NULL AND b.user_id = ?').get(req.params.id, req.user.id); + const payment = db.prepare('SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.deleted_at IS NOT NULL AND b.user_id = ?').get(req.params.id, req.user.id); if (!payment) return res.status(404).json(standardizeError('Deleted payment not found', 'NOT_FOUND', 'id')); + + // Re-apply the balance delta (undo the reversal done on delete) + if (payment.balance_delta != null) { + const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(payment.bill_id); + if (bill?.current_balance != null) { + const reapplied = Math.max(0, Math.round((bill.current_balance + payment.balance_delta) * 100) / 100); + db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(reapplied, payment.bill_id); + } + } + db.prepare('UPDATE payments SET deleted_at = NULL WHERE id = ?').run(req.params.id); res.json(db.prepare('SELECT * FROM payments WHERE id = ?').get(req.params.id)); }); diff --git a/routes/snowball.js b/routes/snowball.js new file mode 100644 index 0000000..2056775 --- /dev/null +++ b/routes/snowball.js @@ -0,0 +1,116 @@ +const express = require('express'); +const router = express.Router(); +const { getDb } = require('../db/database'); +const { standardizeError } = require('../middleware/errorFormatter'); +const { calculateSnowball, calculateAvalanche } = require('../services/snowballService'); + +const DEBT_LIKE_CLAUSES = `( + b.snowball_include = 1 + OR LOWER(c.name) LIKE '%credit%' + OR LOWER(c.name) LIKE '%loan%' + OR LOWER(c.name) LIKE '%mortgage%' + OR LOWER(c.name) LIKE '%housing%' + OR LOWER(c.name) LIKE '%debt%' +)`; + +// GET /api/snowball — server-filtered debt bills, pre-sorted by snowball_order +router.get('/', (req, res) => { + const db = getDb(); + const bills = db.prepare(` + SELECT b.*, c.name AS category_name + FROM bills b + LEFT JOIN categories c ON b.category_id = c.id AND c.user_id = b.user_id + WHERE b.user_id = ? + AND b.active = 1 + AND ${DEBT_LIKE_CLAUSES} + ORDER BY + CASE WHEN b.snowball_order IS NULL THEN 1 ELSE 0 END ASC, + b.snowball_order ASC, + CASE WHEN b.current_balance IS NULL THEN 1 ELSE 0 END ASC, + b.current_balance ASC + `).all(req.user.id); + + res.json(bills); +}); + +// GET /api/snowball/settings — extra monthly payment for this user +router.get('/settings', (req, res) => { + const db = getDb(); + const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id); + res.json({ extra_payment: user?.snowball_extra_payment ?? 0 }); +}); + +// PATCH /api/snowball/settings — save extra monthly payment +router.patch('/settings', (req, res) => { + const { extra_payment } = req.body; + let val = 0; + + if (extra_payment !== undefined && extra_payment !== null && extra_payment !== '') { + val = parseFloat(extra_payment); + if (!Number.isFinite(val) || val < 0) { + return res.status(400).json(standardizeError( + 'extra_payment must be a non-negative number', + 'VALIDATION_ERROR', + 'extra_payment' + )); + } + } + + const db = getDb(); + db.prepare('UPDATE users SET snowball_extra_payment = ? WHERE id = ?').run(val, req.user.id); + res.json({ extra_payment: val }); +}); + +// GET /api/snowball/projection — payoff timeline using the snowball math service +router.get('/projection', (req, res) => { + const db = getDb(); + + const bills = db.prepare(` + SELECT b.*, c.name AS category_name + FROM bills b + LEFT JOIN categories c ON b.category_id = c.id AND c.user_id = b.user_id + WHERE b.user_id = ? + AND b.active = 1 + AND ${DEBT_LIKE_CLAUSES} + ORDER BY + CASE WHEN b.snowball_order IS NULL THEN 1 ELSE 0 END ASC, + b.snowball_order ASC, + CASE WHEN b.current_balance IS NULL THEN 1 ELSE 0 END ASC, + b.current_balance ASC + `).all(req.user.id); + + const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id); + const extraPayment = user?.snowball_extra_payment ?? 0; + + const now = new Date(); + const snowball = calculateSnowball(bills, extraPayment, now); + const avalanche = calculateAvalanche(bills, extraPayment, now); + + res.json({ snowball, avalanche }); +}); + +// PATCH /api/snowball/order — batch-save snowball_order positions +router.patch('/order', (req, res) => { + const items = req.body; + if (!Array.isArray(items)) { + return res.status(400).json(standardizeError('Request body must be an array', 'VALIDATION_ERROR')); + } + + const db = getDb(); + const userId = req.user.id; + const update = db.prepare('UPDATE bills SET snowball_order = ? WHERE id = ? AND user_id = ?'); + + db.transaction((rows) => { + for (const row of rows) { + const id = parseInt(row.id, 10); + const order = parseInt(row.snowball_order, 10); + if (!Number.isInteger(id) || id <= 0) continue; + if (!Number.isInteger(order) || order < 0) continue; + update.run(order, id, userId); + } + })(items); + + res.json({ success: true }); +}); + +module.exports = router; diff --git a/scripts/seedDemoData.js b/scripts/seedDemoData.js index 9d0e7d5..6250aaf 100644 --- a/scripts/seedDemoData.js +++ b/scripts/seedDemoData.js @@ -20,7 +20,8 @@ const CATEGORIES = [ 'Subscriptions', 'Transportation', 'Healthcare', - 'Finance', + 'Credit Cards', + 'Loans', 'Entertainment', ]; @@ -28,19 +29,19 @@ const CATEGORIES = [ const BILLS = [ { name: 'Electric Company', category: 'Utilities', amount: 85, dueDay: 15, cycle: 'monthly', autopay: true, interestRate: 0 }, { name: 'City Water Dept', category: 'Utilities', amount: 45, dueDay: 20, cycle: 'monthly', autopay: true, interestRate: 0 }, - { name: 'Rent/Mortgage', category: 'Housing', amount: 1200, dueDay: 1, cycle: 'monthly', autopay: true, interestRate: 0 }, + { name: 'Mortgage', category: 'Housing', amount: 1200, dueDay: 1, cycle: 'monthly', autopay: true, interestRate: 3.25, currentBalance: 185000, minPayment: 1200, snowballOrder: 3, snowballInclude: 0 }, { name: 'Car Insurance', category: 'Insurance', amount: 120, dueDay: 5, cycle: 'quarterly', autopay: true, interestRate: 0 }, { name: 'Netflix', category: 'Subscriptions', amount: 15.99, dueDay: 22, cycle: 'monthly', autopay: true, interestRate: 0 }, { name: 'Gym Membership', category: 'Subscriptions', amount: 45, dueDay: 10, cycle: 'monthly', autopay: true, interestRate: 0 }, { name: 'Internet Provider', category: 'Utilities', amount: 70, dueDay: 18, cycle: 'monthly', autopay: true, interestRate: 0 }, { name: 'Cell Phone', category: 'Utilities', amount: 65, dueDay: 25, cycle: 'monthly', autopay: true, interestRate: 0 }, { name: 'Health Insurance', category: 'Healthcare', amount: 200, dueDay: 1, cycle: 'quarterly', autopay: true, interestRate: 0 }, - { name: 'Credit Card', category: 'Finance', amount: 150, dueDay: 28, cycle: 'monthly', autopay: true, interestRate: 19.99 }, - { name: 'Student Loan', category: 'Finance', amount: 250, dueDay: 15, cycle: 'monthly', autopay: true, interestRate: 5.5 }, + { name: 'Credit Card', category: 'Credit Cards', amount: 150, dueDay: 28, cycle: 'monthly', autopay: true, interestRate: 19.99, currentBalance: 2800, minPayment: 75, snowballOrder: 0, snowballInclude: 1 }, + { name: 'Student Loan', category: 'Loans', amount: 250, dueDay: 15, cycle: 'monthly', autopay: true, interestRate: 5.5, currentBalance: 12500, minPayment: 150, snowballOrder: 1, snowballInclude: 1 }, { name: 'Gas Utility', category: 'Utilities', amount: 35, dueDay: 12, cycle: 'monthly', autopay: true, interestRate: 0 }, { name: 'Trash Service', category: 'Utilities', amount: 25, dueDay: 28, cycle: 'monthly', autopay: true, interestRate: 0 }, { name: 'Homeowners Insurance', category: 'Insurance', amount: 300, dueDay: 10, cycle: 'annually', autopay: false, interestRate: 0 }, - { name: 'Car Payment', category: 'Finance', amount: 350, dueDay: 22, cycle: 'monthly', autopay: true, interestRate: 4.5 }, + { name: 'Car Payment', category: 'Loans', amount: 350, dueDay: 22, cycle: 'monthly', autopay: true, interestRate: 4.5, currentBalance: 8400, minPayment: 350, snowballOrder: 2, snowballInclude: 1 }, { name: 'Spotify', category: 'Entertainment', amount: 9.99, dueDay: 14, cycle: 'monthly', autopay: true, interestRate: 0 }, { name: 'Adobe Creative Cloud', category: 'Subscriptions', amount: 54.99, dueDay: 8, cycle: 'monthly', autopay: true, interestRate: 0 }, { name: 'Amazon Prime', category: 'Subscriptions', amount: 14.99, dueDay: 1, cycle: 'annually', autopay: true, interestRate: 0 }, @@ -126,8 +127,10 @@ function seedDemoData(userId = null) { let billsCreated = 0; const insertBill = db.prepare(` INSERT INTO bills (user_id, name, category_id, due_day, billing_cycle, - expected_amount, autopay_enabled, interest_rate, active, is_seeded) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 1) + expected_amount, autopay_enabled, interest_rate, + current_balance, minimum_payment, snowball_order, snowball_include, + active, is_seeded) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1) `); for (const billData of BILLS) { @@ -145,7 +148,11 @@ function seedDemoData(userId = null) { billData.cycle || 'monthly', amount, billData.autopay !== undefined ? (billData.autopay ? 1 : 0) : Math.random() > 0.5 ? 1 : 0, - billData.interestRate || (Math.random() > 0.7 ? Math.round(Math.random() * 15 * 100) / 100 : 0) + billData.interestRate || (Math.random() > 0.7 ? Math.round(Math.random() * 15 * 100) / 100 : 0), + billData.currentBalance ?? null, + billData.minPayment ?? null, + billData.snowballOrder ?? null, + billData.snowballInclude ?? 0 ); billsCreated++; } catch (err) { diff --git a/server.js b/server.js index ec78b49..18b24ed 100644 --- a/server.js +++ b/server.js @@ -91,6 +91,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/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')); app.use('/api/about', require('./routes/about')); // public diff --git a/services/billsService.js b/services/billsService.js index 16dd503..a527857 100644 --- a/services/billsService.js +++ b/services/billsService.js @@ -173,6 +173,59 @@ function validateBillData(data, existingBill = null) { // Calculate bucket based on due_day normalized.bucket = normalized.due_day <= 14 ? '1st' : '15th'; + // current_balance — outstanding debt balance (nullable) + if (data.current_balance !== undefined) { + if (data.current_balance === null || data.current_balance === '') { + normalized.current_balance = null; + } else { + const cb = parseFloat(data.current_balance); + if (!Number.isFinite(cb) || cb < 0) { + errors.push({ field: 'current_balance', message: 'current_balance must be a non-negative number' }); + } else { + normalized.current_balance = cb; + } + } + } else { + normalized.current_balance = existingBill?.current_balance ?? null; + } + + // minimum_payment — required minimum payment for debt (nullable) + if (data.minimum_payment !== undefined) { + if (data.minimum_payment === null || data.minimum_payment === '') { + normalized.minimum_payment = null; + } else { + const mp = parseFloat(data.minimum_payment); + if (!Number.isFinite(mp) || mp < 0) { + errors.push({ field: 'minimum_payment', message: 'minimum_payment must be a non-negative number' }); + } else { + normalized.minimum_payment = mp; + } + } + } else { + normalized.minimum_payment = existingBill?.minimum_payment ?? null; + } + + // snowball_order — drag position on snowball page (nullable integer) + if (data.snowball_order !== undefined) { + if (data.snowball_order === null || data.snowball_order === '') { + normalized.snowball_order = null; + } else { + const so = parseInt(data.snowball_order, 10); + if (!Number.isInteger(so) || so < 0) { + errors.push({ field: 'snowball_order', message: 'snowball_order must be a non-negative integer' }); + } else { + normalized.snowball_order = so; + } + } + } else { + normalized.snowball_order = existingBill?.snowball_order ?? null; + } + + // snowball_include — manual override to force bill onto snowball page + normalized.snowball_include = data.snowball_include !== undefined + ? (data.snowball_include ? 1 : 0) + : (existingBill?.snowball_include ?? 0); + return { errors, normalized: { @@ -190,6 +243,30 @@ function validateCycleDayOnly(cycleType, cycleDay) { return validateCycleDay(cycleType, cycleDay); } +/** + * Computes how a payment affects a debt bill's current_balance, accounting for + * one month of interest accrual. + * + * Returns { new_balance, balance_delta } where balance_delta is negative when + * the balance was reduced (typical case). Returns null when the bill has no + * trackable balance. + */ +function computeBalanceDelta(bill, paymentAmount) { + const bal = Number(bill.current_balance); + const rate = Number(bill.interest_rate) || 0; + const amt = Number(paymentAmount); + + if (!Number.isFinite(bal) || bal <= 0) return null; + if (!Number.isFinite(amt) || amt <= 0) return null; + + const monthlyInterest = bal * (rate / 100 / 12); + const raw = bal + monthlyInterest - amt; + const newBalance = Math.round(Math.max(0, raw) * 100) / 100; + const delta = Math.round((newBalance - bal) * 100) / 100; + + return { new_balance: newBalance, balance_delta: delta }; +} + module.exports = { VALID_VISIBILITY, getValidCycleTypes, @@ -199,4 +276,5 @@ module.exports = { parseInterestRate, validateBillData, validateCycleDayOnly, + computeBalanceDelta, }; diff --git a/services/snowballService.js b/services/snowballService.js new file mode 100644 index 0000000..7b8f49f --- /dev/null +++ b/services/snowballService.js @@ -0,0 +1,158 @@ +/** + * Debt payoff calculators — Snowball and Avalanche methods. + * + * Snowball (Dave Ramsey): smallest balance first — fast psychological wins. + * Avalanche (math-optimal): highest interest rate first — minimises total interest. + * + * Both share the same month-by-month simulation loop; only the initial order differs. + */ + +// ── Private simulation engine ───────────────────────────────────────────────── + +function _simulate(orderedDebts, extraPayment, startDate) { + const extra = Math.max(0, Number(extraPayment) || 0); + + const active = []; + const skipped = []; + + for (const d of orderedDebts) { + const bal = Number(d.current_balance); + if (d.current_balance == null || !Number.isFinite(bal)) { + skipped.push({ id: d.id, name: d.name, reason: 'no_balance' }); + } else if (bal <= 0) { + skipped.push({ id: d.id, name: d.name, reason: 'zero_balance' }); + } else { + active.push({ + id: d.id, + name: d.name, + balance: bal, + minPayment: Math.max(0, Number(d.minimum_payment) || 0), + monthlyRate: Math.max(0, Number(d.interest_rate) || 0) / 100 / 12, + payoffMonth: null, + totalInterest: 0, + }); + } + } + + if (active.length === 0) { + return { + months_to_freedom: null, + total_interest_paid: 0, + payoff_date: null, + payoff_display: null, + debts: [], + skipped, + extra_payment: extra, + capped: false, + }; + } + + // ── Month-by-month loop ─────────────────────────────────────────────────── + const MAX_MONTHS = 600; // 50-year safety cap + let rollingExtra = extra; + let month = 0; + + while (active.some(d => d.balance > 0) && month < MAX_MONTHS) { + month++; + + // Attack target = first debt in the ordered list that still has a balance + const targetIdx = active.findIndex(d => d.balance > 0); + + for (let i = 0; i < active.length; i++) { + const debt = active[i]; + if (debt.balance <= 0) continue; + + // Accrue monthly interest + const interest = debt.balance * debt.monthlyRate; + debt.balance += interest; + debt.totalInterest += interest; + + // Attack target gets minimums + full snowball; others get minimums only + const payment = Math.min( + debt.balance, + i === targetIdx ? debt.minPayment + rollingExtra : debt.minPayment, + ); + debt.balance = Math.max(0, debt.balance - payment); + if (debt.balance < 0.005) debt.balance = 0; // eliminate floating-point dust + } + + // Mark any debt that just reached zero (attack target OR paid off naturally by minimums) + // and roll its freed minimum into the snowball for next month. + for (let i = 0; i < active.length; i++) { + const debt = active[i]; + if (debt.balance === 0 && debt.payoffMonth === null) { + debt.payoffMonth = month; + rollingExtra += debt.minPayment; + } + } + } + + // ── Format results ──────────────────────────────────────────────────────── + const baseYear = startDate.getFullYear(); + const baseMo = startDate.getMonth(); + + function monthLabel(m) { + const d = new Date(baseYear, baseMo + m, 1); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; + } + + function monthDisplay(m) { + const d = new Date(baseYear, baseMo + m, 1); + return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long' }); + } + + const debtResults = active.map(d => ({ + id: d.id, + name: d.name, + payoff_month: d.payoffMonth, + payoff_date: d.payoffMonth ? monthLabel(d.payoffMonth) : null, + payoff_display: d.payoffMonth ? monthDisplay(d.payoffMonth) : null, + total_interest: round2(d.totalInterest), + months: d.payoffMonth, + })); + + const maxMonth = Math.max(0, ...active.map(d => d.payoffMonth || 0)); + const totalInterest = active.reduce((s, d) => s + d.totalInterest, 0); + + return { + months_to_freedom: maxMonth || null, + total_interest_paid: round2(totalInterest), + payoff_date: maxMonth ? monthLabel(maxMonth) : null, + payoff_display: maxMonth ? monthDisplay(maxMonth) : null, + debts: debtResults, + skipped, + extra_payment: extra, + capped: month >= MAX_MONTHS, + }; +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/** + * Snowball: attack the smallest balance first (fast wins, motivational). + * Debts must already be in snowball order (sorted by current_balance ASC by the caller). + */ +function calculateSnowball(debts, extraPayment = 0, startDate = new Date()) { + return _simulate(debts, extraPayment, startDate); +} + +/** + * Avalanche: attack the highest interest rate first (minimises total interest paid). + * Re-sorts debts internally — caller does not need to pre-sort. + */ +function calculateAvalanche(debts, extraPayment = 0, startDate = new Date()) { + const sorted = [...debts].sort((a, b) => { + const ra = Number(a.interest_rate) || 0; + const rb = Number(b.interest_rate) || 0; + if (rb !== ra) return rb - ra; // highest rate first + // Tiebreak: smallest balance (clears fastest, rolling the payment sooner) + return (Number(a.current_balance) || 0) - (Number(b.current_balance) || 0); + }); + return _simulate(sorted, extraPayment, startDate); +} + +function round2(n) { + return Math.round(n * 100) / 100; +} + +module.exports = { calculateSnowball, calculateAvalanche };