0.28.0 snowball release
This commit is contained in:
parent
48fe87ea25
commit
7d2d0bf45e
|
|
@ -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() {
|
|||
<Route path="categories" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><CategoriesPage /></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="snowball" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SnowballPage /></Suspense></ErrorBoundary>} />
|
||||
<Route path="data" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><DataPage /></Suspense></ErrorBoundary>} />
|
||||
<Route path="profile" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><ProfilePage /></Suspense></ErrorBoundary>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Category</Label>
|
||||
<Select value={categoryId} onValueChange={setCategoryId}>
|
||||
<Select value={categoryId} onValueChange={handleCategoryChange}>
|
||||
<SelectTrigger className={cn(inp, 'w-full')}>
|
||||
<SelectValue placeholder="— none —" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -250,27 +277,6 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Interest Rate */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Interest rate (APR %)</Label>
|
||||
<Input
|
||||
className={cn(inp, 'font-mono', errors.interestRate && 'border-red-500 focus-visible:ring-red-500')}
|
||||
type="number" min="0" max="100" step="0.01" placeholder="Optional"
|
||||
value={interestRate}
|
||||
onChange={e => {
|
||||
setInterestRate(e.target.value);
|
||||
setTimeout(() => setErrors(prev => ({ ...prev, interestRate: validateInterestRate(e.target.value) })), 300);
|
||||
}}
|
||||
onBlur={() => handleBlur('interestRate', validateInterestRate)}
|
||||
/>
|
||||
{errors.interestRate && (
|
||||
<span className="text-[10px] text-red-500 font-medium">{errors.interestRate}</span>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground/70">
|
||||
Optional, useful for credit cards. Enter 29.99 for 29.99%.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Billing Cycle */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Billing Cycle</Label>
|
||||
|
|
@ -349,6 +355,106 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* Debt / Credit Details — collapsible */}
|
||||
<div className="col-span-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDebtSection(s => !s)}
|
||||
className="flex items-center gap-2 text-xs uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors w-full text-left py-1"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn('h-3.5 w-3.5 transition-transform duration-150', !showDebtSection && '-rotate-90')}
|
||||
/>
|
||||
Debt / Credit Details
|
||||
{isDebtCategory && (
|
||||
<span className="ml-1 text-[9px] text-emerald-400 font-semibold tracking-normal normal-case">
|
||||
· auto-detected
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showDebtSection && (
|
||||
<div className="grid sm:grid-cols-2 gap-x-5 gap-y-4 mt-3 pt-3 border-t border-border/40">
|
||||
|
||||
{/* Interest Rate */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Interest rate (APR %)</Label>
|
||||
<Input
|
||||
className={cn(inp, 'font-mono', errors.interestRate && 'border-red-500 focus-visible:ring-red-500')}
|
||||
type="number" min="0" max="100" step="0.01" placeholder="Optional"
|
||||
value={interestRate}
|
||||
onChange={e => {
|
||||
setInterestRate(e.target.value);
|
||||
setTimeout(() => setErrors(prev => ({ ...prev, interestRate: validateInterestRate(e.target.value) })), 300);
|
||||
}}
|
||||
onBlur={() => handleBlur('interestRate', validateInterestRate)}
|
||||
/>
|
||||
{errors.interestRate && (
|
||||
<span className="text-[10px] text-red-500 font-medium">{errors.interestRate}</span>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground/70">Enter 29.99 for 29.99%.</p>
|
||||
</div>
|
||||
|
||||
{/* Current Balance */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Current Balance ($)</Label>
|
||||
<Input
|
||||
className={cn(inp, 'font-mono', errors.currentBalance && 'border-red-500 focus-visible:ring-red-500')}
|
||||
type="number" min="0" step="0.01" placeholder="Optional"
|
||||
value={currentBalance}
|
||||
onChange={e => {
|
||||
setCurrentBalance(e.target.value);
|
||||
setTimeout(() => setErrors(prev => ({ ...prev, currentBalance: validateCurrentBalance(e.target.value) })), 300);
|
||||
}}
|
||||
onBlur={() => setErrors(prev => ({ ...prev, currentBalance: validateCurrentBalance(currentBalance) }))}
|
||||
/>
|
||||
{errors.currentBalance && (
|
||||
<span className="text-[10px] text-red-500 font-medium">{errors.currentBalance}</span>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground/70">Outstanding debt balance.</p>
|
||||
</div>
|
||||
|
||||
{/* Minimum Payment */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Minimum Payment ($)</Label>
|
||||
<Input
|
||||
className={cn(inp, 'font-mono', errors.minimumPayment && 'border-red-500 focus-visible:ring-red-500')}
|
||||
type="number" min="0" step="0.01" placeholder="Optional"
|
||||
value={minimumPayment}
|
||||
onChange={e => {
|
||||
setMinimumPayment(e.target.value);
|
||||
setTimeout(() => setErrors(prev => ({ ...prev, minimumPayment: validateMinimumPayment(e.target.value) })), 300);
|
||||
}}
|
||||
onBlur={() => setErrors(prev => ({ ...prev, minimumPayment: validateMinimumPayment(minimumPayment) }))}
|
||||
/>
|
||||
{errors.minimumPayment && (
|
||||
<span className="text-[10px] text-red-500 font-medium">{errors.minimumPayment}</span>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground/70">Required minimum monthly payment.</p>
|
||||
</div>
|
||||
|
||||
{/* Include in Snowball */}
|
||||
<div className="flex flex-col justify-end pb-1 space-y-1">
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={snowballInclude}
|
||||
onChange={e => setSnowballInclude(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border accent-emerald-500"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
Include in Snowball
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-[10px] text-muted-foreground/70 pl-6">
|
||||
Force this bill onto the debt snowball page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Website */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Website</Label>
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
],
|
||||
};
|
||||
|
|
@ -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 (
|
||||
<div className={cn('surface-elevated rounded-xl px-5 py-4 space-y-0.5', highlight && 'border border-emerald-500/30')}>
|
||||
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">{label}</p>
|
||||
<p className={cn('text-2xl font-semibold tabular-nums', highlight && 'text-emerald-400')}>{value}</p>
|
||||
{sub && <p className="text-xs text-muted-foreground">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="border-t border-border/40 px-5 py-3 space-y-1.5">
|
||||
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
vs. Avalanche (highest rate first)
|
||||
</p>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-sm text-muted-foreground">{avalanche.payoff_display}</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{fmt(avalanche.total_interest_paid)} interest</span>
|
||||
</div>
|
||||
{same ? (
|
||||
<p className="text-xs text-muted-foreground/70">Same result — your debts have similar rates.</p>
|
||||
) : interestDiff > 0 ? (
|
||||
<p className="text-xs text-emerald-400">
|
||||
Avalanche saves {fmt(interestDiff)} interest
|
||||
{monthDiff > 0 ? ` · ${monthDiff} month${monthDiff > 1 ? 's' : ''} faster` : ''}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-violet-400">
|
||||
Snowball finishes {Math.abs(monthDiff)} month{Math.abs(monthDiff) > 1 ? 's' : ''} faster ·
|
||||
Avalanche costs {fmt(Math.abs(interestDiff))} more
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectionPanel({ projection, projectionLoading, billCount }) {
|
||||
if (projectionLoading) {
|
||||
return (
|
||||
<div className="surface-elevated rounded-xl p-5 space-y-3">
|
||||
<Skeleton className="h-5 w-36" />
|
||||
<div className="space-y-2">{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-10" />)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<div className="surface-elevated rounded-xl overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-4 px-5 py-4 border-b border-border/40">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarCheck className="h-4 w-4 text-primary shrink-0" />
|
||||
<span className="text-sm font-semibold">Payoff Projection</span>
|
||||
</div>
|
||||
{sb.payoff_display && (
|
||||
<div className="text-right shrink-0">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Snowball · Debt-Free</p>
|
||||
<p className="text-base font-semibold text-emerald-400">{sb.payoff_display}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{sb.capped && (
|
||||
<div className="flex items-start gap-2 px-5 py-3 bg-amber-500/10 border-b border-amber-500/20 text-xs text-amber-400">
|
||||
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
Payoff exceeds 50 years. Add extra monthly budget or increase minimum payments.
|
||||
</div>
|
||||
)}
|
||||
{needsBalances && (
|
||||
<div className="px-5 py-8 text-center text-sm text-muted-foreground">
|
||||
Click any balance to enter it and see your payoff timeline.
|
||||
</div>
|
||||
)}
|
||||
{hasProjection && (
|
||||
<div className="divide-y divide-border/30">
|
||||
{sb.debts.map((d, i) => (
|
||||
<div key={d.id} className="flex items-center gap-3 px-5 py-3">
|
||||
<span className="text-xs font-bold text-muted-foreground w-5 shrink-0 tabular-nums">#{i + 1}</span>
|
||||
<span className="flex-1 text-sm font-medium truncate min-w-0">{d.name}</span>
|
||||
<div className="text-right shrink-0 space-y-0.5">
|
||||
{d.payoff_display ? (
|
||||
<>
|
||||
<p className="text-sm font-semibold">{d.payoff_display}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{d.months} mo · {fmtCompact(d.total_interest)} interest
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">unknown balance</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasProjection && (
|
||||
<div className="flex items-center justify-between px-5 py-3 border-t border-border/40 bg-muted/20">
|
||||
<span className="text-xs text-muted-foreground">Total interest paid</span>
|
||||
<span className="text-sm font-semibold tabular-nums">{fmt(sb.total_interest_paid)}</span>
|
||||
</div>
|
||||
)}
|
||||
{hasProjection && av && <AvalancheComparison snowball={sb} avalanche={av} />}
|
||||
{sb.skipped.length > 0 && hasProjection && (
|
||||
<div className="px-5 pb-3 text-[10px] text-muted-foreground/60">
|
||||
{sb.skipped.length} bill{sb.skipped.length > 1 ? 's' : ''} excluded (no balance):
|
||||
{' '}{sb.skipped.map(s => s.name).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-24 rounded-xl" />)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 rounded-xl" />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inp = 'bg-background/50 border-border/60 h-9 text-sm font-mono';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||
<TrendingDown className="h-6 w-6 text-primary" />
|
||||
Debt Snowball
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Dave Ramsey method — attack the smallest balance first, roll payments as each debt clears.
|
||||
Marking a payment automatically reduces the outstanding balance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{bills.length > 0 && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<StatCard label="Total Debt" value={fmt(totalBalance)}
|
||||
sub={unknownCount > 0 ? `+ ${unknownCount} unknown` : undefined} />
|
||||
<StatCard label="Monthly Minimums" value={fmt(totalMinPayment)} />
|
||||
<StatCard label="Extra / Month" value={extraAmt > 0 ? fmt(extraAmt) : '—'} sub="snowball accelerator" />
|
||||
<StatCard label="Total Attack" value={fmt(totalMinPayment + extraAmt)}
|
||||
sub="toward #1 target" highlight={extraAmt > 0} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toolbar */}
|
||||
{bills.length > 0 && (
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
Extra monthly budget ($)
|
||||
</Label>
|
||||
<Input
|
||||
type="number" min="0" step="1" placeholder="0.00"
|
||||
value={extraPayment}
|
||||
onChange={e => setExtraPayment(e.target.value)}
|
||||
onBlur={handleSaveExtraPayment}
|
||||
className={cn(inp, 'w-32')}
|
||||
disabled={savingSettings}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pb-0.5">
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAutoArrange} className="gap-2">
|
||||
<Zap className="h-3.5 w-3.5" /> Auto-arrange
|
||||
</Button>
|
||||
<Button type="button" size="sm" disabled={!dirty || saving} onClick={handleSaveOrder} className="gap-2">
|
||||
<Save className="h-3.5 w-3.5" /> {saving ? 'Saving…' : 'Save Order'}
|
||||
</Button>
|
||||
{dirty && <span className="text-xs text-amber-400">Unsaved changes</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{bills.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border/60 py-20 text-center gap-3">
|
||||
<TrendingDown className="h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="text-sm font-medium text-muted-foreground">No debt bills found</p>
|
||||
<p className="text-xs text-muted-foreground/70 max-w-sm">
|
||||
Bills in Credit Cards, Loans, or Mortgage categories appear here automatically.
|
||||
You can also enable "Include in Snowball" when editing any bill.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cards + projection */}
|
||||
{bills.length > 0 && (
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_340px]">
|
||||
|
||||
{/* Cards list — pointer events on the whole list so moves are tracked even outside a card */}
|
||||
<div
|
||||
className="space-y-2"
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
>
|
||||
{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 (
|
||||
<div
|
||||
key={bill.id}
|
||||
data-card
|
||||
className={cn(
|
||||
'surface-elevated rounded-xl border transition-all duration-100 select-none touch-none',
|
||||
isAttack ? 'border-emerald-500/40' : 'border-border/40',
|
||||
isTarget && isDragging && 'ring-2 ring-primary/50 scale-[0.99]',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-stretch">
|
||||
|
||||
{/* Grip handle — pointer-capture trigger */}
|
||||
<div
|
||||
data-grip
|
||||
onPointerDown={e => 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"
|
||||
>
|
||||
<GripVertical className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 py-3.5 pr-4 min-w-0">
|
||||
{/* Top row */}
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="text-xs font-bold text-muted-foreground tabular-nums w-5 shrink-0">
|
||||
#{index + 1}
|
||||
</span>
|
||||
{isAttack && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-400 shrink-0">
|
||||
<Zap className="h-2.5 w-2.5" /> Attack
|
||||
</span>
|
||||
)}
|
||||
<span className="font-semibold truncate">{bill.name}</span>
|
||||
{bill.category_name && (
|
||||
<span className="text-[10px] text-muted-foreground border border-border/50 rounded px-1.5 py-0.5 shrink-0">
|
||||
{bill.category_name}
|
||||
</span>
|
||||
)}
|
||||
{bill.snowball_include === 1 && !bill.category_name && (
|
||||
<span className="text-[10px] text-violet-400 border border-violet-500/30 rounded px-1.5 py-0.5 shrink-0">
|
||||
manual
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditBill(bill)}
|
||||
className="ml-auto text-xs text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="mt-2 flex flex-wrap gap-x-5 gap-y-1.5 text-sm items-center">
|
||||
|
||||
{/* Balance — inline editable */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">Balance</span>
|
||||
{isEditingBal ? (
|
||||
<Input
|
||||
autoFocus
|
||||
type="number" min="0" step="0.01"
|
||||
value={editingBalance.value}
|
||||
onChange={e => 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')}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startEditBalance(bill)}
|
||||
className={cn(
|
||||
'font-semibold tabular-nums rounded px-1 -mx-1 hover:bg-muted/60 transition-colors',
|
||||
isAttack && bill.current_balance != null ? 'text-emerald-400' : '',
|
||||
bill.current_balance == null && 'text-muted-foreground/60 italic text-xs',
|
||||
)}
|
||||
title="Click to update balance"
|
||||
>
|
||||
{bill.current_balance != null ? fmt(bill.current_balance) : 'enter balance'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">Min/mo </span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{bill.minimum_payment != null ? fmt(bill.minimum_payment) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isAttack && extraAmt > 0 && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">Attack </span>
|
||||
<span className="font-medium tabular-nums text-emerald-400">
|
||||
{fmt((bill.minimum_payment || 0) + extraAmt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bill.interest_rate != null && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">APR </span>
|
||||
<span className="font-medium tabular-nums">{bill.interest_rate}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">Due </span>
|
||||
<span className="font-medium">{ordinal(bill.due_day)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<p className="text-xs text-muted-foreground/50 text-center pt-1">
|
||||
Drag the grip handle to reorder · Click a balance to update it · Save Order to persist
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Projection (sticky sidebar on large screens) */}
|
||||
<div className="lg:sticky lg:top-24 lg:self-start">
|
||||
<ProjectionPanel
|
||||
projection={projection}
|
||||
projectionLoading={projectionLoading}
|
||||
billCount={bills.length}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit modal */}
|
||||
{editBill && (
|
||||
<BillModal
|
||||
bill={editBill}
|
||||
categories={categories}
|
||||
onClose={() => setEditBill(null)}
|
||||
onSave={() => { setEditBill(null); load(); loadProjection(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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']
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
Loading…
Reference in New Issue