This commit is contained in:
null 2026-05-16 15:38:28 -05:00
parent 8913436575
commit b124e48ebc
29 changed files with 3093 additions and 1106 deletions

View File

@ -5,6 +5,7 @@ import { useAuth } from '@/hooks/useAuth';
import Layout from '@/components/layout/Layout'; import Layout from '@/components/layout/Layout';
import AppNavigation from '@/components/layout/Sidebar'; import AppNavigation from '@/components/layout/Sidebar';
import { ReleaseNotesDialog } from '@/components/ReleaseNotesDialog'; import { ReleaseNotesDialog } from '@/components/ReleaseNotesDialog';
import CommandPalette from '@/components/CommandPalette';
import LoginPage from '@/pages/LoginPage'; import LoginPage from '@/pages/LoginPage';
import ErrorBoundary from '@/components/ErrorBoundary'; import ErrorBoundary from '@/components/ErrorBoundary';
import PageLoader from '@/components/PageLoader'; import PageLoader from '@/components/PageLoader';
@ -81,7 +82,7 @@ function AdminShell({ children }) {
return ( return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.10),transparent_34rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.28))] text-foreground"> <div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.10),transparent_34rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.28))] text-foreground">
<AppNavigation adminMode /> <AppNavigation adminMode />
<main className="mx-auto max-w-5xl px-4 py-6 sm:px-6 lg:px-8 lg:py-8"> <main className="mx-auto w-full max-w-[1500px] px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
{children} {children}
</main> </main>
</div> </div>
@ -96,6 +97,7 @@ export default function App() {
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
{/* Release notes (only for user role) */} {/* Release notes (only for user role) */}
{user?.role === 'user' && <ReleaseNotesDialog />} {user?.role === 'user' && <ReleaseNotesDialog />}
{user && !user.is_default_admin && <CommandPalette />}
{/* Skip link for keyboard users */} {/* Skip link for keyboard users */}
<a <a
@ -138,6 +140,20 @@ export default function App() {
</RequireAuth> </RequireAuth>
} }
/> />
<Route
path="/roadmap"
element={
<RequireAuth role="admin">
<ErrorBoundary>
<AdminShell>
<Suspense fallback={<PageLoader />}>
<RoadmapPage />
</Suspense>
</AdminShell>
</ErrorBoundary>
</RequireAuth>
}
/>
<Route <Route
path="/admin/roadmap" path="/admin/roadmap"
element={ element={

View File

@ -34,6 +34,15 @@ const post = (path, body) => _fetch('POST', path, body);
const put = (path, body) => _fetch('PUT', path, body); const put = (path, body) => _fetch('PUT', path, body);
const del = (path) => _fetch('DELETE', path); const del = (path) => _fetch('DELETE', path);
function queryString(params = {}) {
const qs = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') qs.set(key, String(value));
});
const value = qs.toString();
return value ? `?${value}` : '';
}
function filenameFromDisposition(value) { function filenameFromDisposition(value) {
if (!value) return null; if (!value) return null;
const match = value.match(/filename="?([^"]+)"?/i); const match = value.match(/filename="?([^"]+)"?/i);
@ -126,7 +135,7 @@ export const api = {
profileImportHistory: () => get('/profile/import-history'), profileImportHistory: () => get('/profile/import-history'),
// Tracker // Tracker
tracker: (y, m) => get(`/tracker?year=${y}&month=${m}`), tracker: (y, m, params = {}) => get(`/tracker${queryString({ year: y, month: m, ...params })}`),
upcomingBills: (days = 30) => get(`/tracker/upcoming?days=${days}`), upcomingBills: (days = 30) => get(`/tracker/upcoming?days=${days}`),
// Calendar // Calendar
@ -139,8 +148,8 @@ export const api = {
updateMonthlyStartingAmounts: (data) => put('/monthly-starting-amounts', data), updateMonthlyStartingAmounts: (data) => put('/monthly-starting-amounts', data),
// Bills // Bills
bills: () => get('/bills'), bills: (params = {}) => get(`/bills${queryString(params)}`),
allBills: () => get('/bills?inactive=true'), allBills: (params = {}) => get(`/bills${queryString({ inactive: true, ...params })}`),
billAudit: (includeInactive = false) => get(`/bills/audit${includeInactive ? '?inactive=true' : ''}`), billAudit: (includeInactive = false) => get(`/bills/audit${includeInactive ? '?inactive=true' : ''}`),
bill: (id) => get(`/bills/${id}`), bill: (id) => get(`/bills/${id}`),
createBill: (data) => post('/bills', data), createBill: (data) => post('/bills', data),
@ -156,6 +165,7 @@ export const api = {
updateBillSnowball: (id, data) => _fetch('PATCH', `/bills/${id}/snowball`, data), updateBillSnowball: (id, data) => _fetch('PATCH', `/bills/${id}/snowball`, data),
deleteBill: (id) => del(`/bills/${id}`), deleteBill: (id) => del(`/bills/${id}`),
restoreBill: (id) => post(`/bills/${id}/restore`), restoreBill: (id) => post(`/bills/${id}/restore`),
duplicateBill: (id, data) => post(`/bills/${id}/duplicate`, data),
togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data), togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data),
billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`), billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`),
billMonthlyState: (id, y, m) => get(`/bills/${id}/monthly-state?year=${y}&month=${m}`), billMonthlyState: (id, y, m) => get(`/bills/${id}/monthly-state?year=${y}&month=${m}`),
@ -164,9 +174,14 @@ export const api = {
createBillHistoryRange: (id, data) => post(`/bills/${id}/history-ranges`, data), createBillHistoryRange: (id, data) => post(`/bills/${id}/history-ranges`, data),
updateBillHistoryRange: (id, rangeId, data) => put(`/bills/${id}/history-ranges/${rangeId}`, data), updateBillHistoryRange: (id, rangeId, data) => put(`/bills/${id}/history-ranges/${rangeId}`, data),
deleteBillHistoryRange: (id, rangeId) => del(`/bills/${id}/history-ranges/${rangeId}`), deleteBillHistoryRange: (id, rangeId) => del(`/bills/${id}/history-ranges/${rangeId}`),
billTemplates: () => get('/bills/templates'),
saveBillTemplate: (data) => post('/bills/templates', data),
deleteBillTemplate: (id) => del(`/bills/templates/${id}`),
// Payments // Payments
quickPay: (data) => post('/payments/quick', data), quickPay: (data) => post('/payments/quick', data),
confirmAutopaySuggestion: (billId, data) => post(`/payments/autopay-suggestions/${billId}/confirm`, data),
dismissAutopaySuggestion: (billId, data) => post(`/payments/autopay-suggestions/${billId}/dismiss`, data),
bulkPay: (items) => post('/payments/bulk', items), bulkPay: (items) => post('/payments/bulk', items),
createPayment: (data) => post('/payments', data), createPayment: (data) => post('/payments', data),
updatePayment: (id, data) => put(`/payments/${id}`, data), updatePayment: (id, data) => put(`/payments/${id}`, data),

View File

@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { ChevronDown } from 'lucide-react'; import { ChevronDown, Copy } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@ -40,40 +40,46 @@ function isSnowballCat(categories, catId) {
return cat ? SNOWBALL_KEYWORDS.some(kw => cat.name.toLowerCase().includes(kw)) : false; return cat ? SNOWBALL_KEYWORDS.some(kw => cat.name.toLowerCase().includes(kw)) : false;
} }
export default function BillModal({ bill, categories, onClose, onSave }) { export default function BillModal({ bill, initialBill, categories, onClose, onSave, onDuplicate }) {
const isNew = !bill; const isNew = !bill;
const sourceBill = bill || initialBill || null;
const [name, setName] = useState(bill?.name || ''); const [name, setName] = useState(sourceBill?.name || '');
const [categoryId, setCategoryId] = useState(bill?.category_id ? String(bill.category_id) : CAT_NONE); const [categoryId, setCategoryId] = useState(sourceBill?.category_id ? String(sourceBill.category_id) : CAT_NONE);
const [dueDay, setDueDay] = useState(String(bill?.due_day || '')); const [dueDay, setDueDay] = useState(String(sourceBill?.due_day || ''));
const [expectedAmount, setExpected] = useState(String(bill?.expected_amount || '')); const [expectedAmount, setExpected] = useState(String(sourceBill?.expected_amount || ''));
const [interestRate, setInterestRate] = useState(bill?.interest_rate == null ? '' : String(bill.interest_rate)); const [interestRate, setInterestRate] = useState(sourceBill?.interest_rate == null ? '' : String(sourceBill.interest_rate));
const [billingCycle, setCycle] = useState(bill?.billing_cycle || 'monthly'); const [billingCycle, setCycle] = useState(sourceBill?.billing_cycle || 'monthly');
const [cycleType, setCycleType] = useState(bill?.cycle_type || 'monthly'); const [cycleType, setCycleType] = useState(sourceBill?.cycle_type || 'monthly');
const [cycleDay, setCycleDay] = useState(bill?.cycle_day || '1'); const [cycleDay, setCycleDay] = useState(sourceBill?.cycle_day || '1');
const [autopay, setAutopay] = useState(!!bill?.autopay_enabled); const [autopay, setAutopay] = useState(!!sourceBill?.autopay_enabled);
const [has2fa, setHas2fa] = useState(!!bill?.has_2fa); const [autodraftStatus, setAutodraftStatus] = useState(sourceBill?.autodraft_status || (sourceBill?.autopay_enabled ? 'assumed_paid' : 'none'));
const [website, setWebsite] = useState(bill?.website || ''); const [autoMarkPaid, setAutoMarkPaid] = useState(!!sourceBill?.auto_mark_paid);
const [username, setUsername] = useState(bill?.username || ''); const [has2fa, setHas2fa] = useState(!!sourceBill?.has_2fa);
const [accountInfo, setAccountInfo] = useState(bill?.account_info || ''); const [website, setWebsite] = useState(sourceBill?.website || '');
const [notes, setNotes] = useState(bill?.notes || ''); const [username, setUsername] = useState(sourceBill?.username || '');
const [currentBalance, setCurrentBalance] = useState(bill?.current_balance == null ? '' : String(bill.current_balance)); const [accountInfo, setAccountInfo] = useState(sourceBill?.account_info || '');
const [minimumPayment, setMinimumPayment] = useState(bill?.minimum_payment == null ? '' : String(bill.minimum_payment)); const [notes, setNotes] = useState(sourceBill?.notes || '');
const [snowballInclude, setSnowballInclude] = useState(!!bill?.snowball_include); const [currentBalance, setCurrentBalance] = useState(sourceBill?.current_balance == null ? '' : String(sourceBill.current_balance));
const [snowballExempt, setSnowballExempt] = useState(!!bill?.snowball_exempt); const [minimumPayment, setMinimumPayment] = useState(sourceBill?.minimum_payment == null ? '' : String(sourceBill.minimum_payment));
const [snowballInclude, setSnowballInclude] = useState(!!sourceBill?.snowball_include);
const [snowballExempt, setSnowballExempt] = useState(!!sourceBill?.snowball_exempt);
const [showDebtSection, setShowDebtSection] = useState( const [showDebtSection, setShowDebtSection] = useState(
() => isDebtCat(categories, bill?.category_id ? String(bill.category_id) : CAT_NONE) () => isDebtCat(categories, sourceBill?.category_id ? String(sourceBill.category_id) : CAT_NONE)
|| !!bill?.snowball_include || !!sourceBill?.snowball_include
|| !!bill?.snowball_exempt || !!sourceBill?.snowball_exempt
|| bill?.current_balance != null || sourceBill?.current_balance != null
|| bill?.minimum_payment != null || sourceBill?.minimum_payment != null
); );
const [saveTemplate, setSaveTemplate] = useState(false);
const [templateName, setTemplateName] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const isDebtCategory = isDebtCat(categories, categoryId); const isDebtCategory = isDebtCat(categories, categoryId);
const isSnowballCategory = isSnowballCat(categories, categoryId); const isSnowballCategory = isSnowballCat(categories, categoryId);
const showOnSnowball = snowballInclude || (isSnowballCategory && !snowballExempt); const showOnSnowball = snowballInclude || (isSnowballCategory && !snowballExempt);
const canAutoMarkPaid = autopay && autodraftStatus === 'assumed_paid';
const validateName = (val) => { const validateName = (val) => {
if (!val || val.trim() === '') return 'Name is required'; if (!val || val.trim() === '') return 'Name is required';
@ -158,6 +164,16 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
} }
}; };
const handleAutopayChange = (checked) => {
setAutopay(checked);
if (checked) {
setAutodraftStatus(prev => (prev && prev !== 'none' ? prev : 'assumed_paid'));
} else {
setAutodraftStatus('none');
setAutoMarkPaid(false);
}
};
async function handleSubmit(e) { async function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
@ -180,34 +196,49 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
} }
const data = { const data = {
source_bill_id: sourceBill?.source_bill_id,
name: name.trim(), name: name.trim(),
category_id: categoryId === CAT_NONE ? null : parseInt(categoryId, 10), category_id: categoryId === CAT_NONE ? null : parseInt(categoryId, 10),
due_day: parsedDueDay, due_day: parsedDueDay,
override_due_date: sourceBill?.override_due_date,
expected_amount: parseFloat(expectedAmount) || 0, expected_amount: parseFloat(expectedAmount) || 0,
interest_rate: parsedInterestRate, interest_rate: parsedInterestRate,
billing_cycle: billingCycle, billing_cycle: billingCycle,
cycle_type: cycleType, cycle_type: cycleType,
cycle_day: cycleDay, cycle_day: cycleDay,
autopay_enabled: autopay, autopay_enabled: autopay,
autodraft_status: autopay ? autodraftStatus : 'none',
auto_mark_paid: canAutoMarkPaid && autoMarkPaid,
has_2fa: has2fa, has_2fa: has2fa,
website: website || null, website: website || null,
username: username || null, username: username || null,
account_info: accountInfo || null, account_info: accountInfo || null,
notes: notes || null, notes: notes || null,
history_visibility: sourceBill?.history_visibility,
current_balance: currentBalance === '' ? null : parseFloat(currentBalance), current_balance: currentBalance === '' ? null : parseFloat(currentBalance),
minimum_payment: minimumPayment === '' ? null : parseFloat(minimumPayment), minimum_payment: minimumPayment === '' ? null : parseFloat(minimumPayment),
snowball_order: sourceBill?.snowball_order,
snowball_include: snowballInclude, snowball_include: snowballInclude,
snowball_exempt: snowballExempt, snowball_exempt: snowballExempt,
}; };
setBusy(true); setBusy(true);
try { try {
if (isNew) { if (isNew) {
if (data.source_bill_id) {
await api.duplicateBill(data.source_bill_id, data);
} else {
await api.createBill(data); await api.createBill(data);
}
toast.success('Bill added'); toast.success('Bill added');
} else { } else {
await api.updateBill(bill.id, data); await api.updateBill(bill.id, data);
toast.success('Bill updated'); toast.success('Bill updated');
} }
if (saveTemplate) {
const safeTemplateName = templateName.trim() || data.name;
await api.saveBillTemplate({ name: safeTemplateName, data });
toast.success('Template saved');
}
onSave(); onSave();
onClose(); onClose();
} catch (err) { } catch (err) {
@ -526,13 +557,28 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
<input <input
type="checkbox" type="checkbox"
checked={autopay} checked={autopay}
onChange={e => setAutopay(e.target.checked)} onChange={e => handleAutopayChange(e.target.checked)}
className="h-4 w-4 rounded border-border accent-emerald-500" className="h-4 w-4 rounded border-border accent-emerald-500"
/> />
<span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors"> <span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">
Autopay / Autodraft Autopay / Autodraft
</span> </span>
</label> </label>
<label
className={cn('flex items-center gap-2.5 group', canAutoMarkPaid ? 'cursor-pointer' : 'cursor-not-allowed opacity-50')}
title={canAutoMarkPaid ? undefined : 'Auto-mark requires Autodraft Status set to Assumed paid'}
>
<input
type="checkbox"
checked={canAutoMarkPaid && autoMarkPaid}
onChange={e => setAutoMarkPaid(e.target.checked)}
disabled={!canAutoMarkPaid}
className="h-4 w-4 rounded border-border accent-sky-500"
/>
<span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">
Auto-mark paid on due date
</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer group"> <label className="flex items-center gap-2.5 cursor-pointer group">
<input <input
type="checkbox" type="checkbox"
@ -546,6 +592,21 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
</label> </label>
</div> </div>
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Autodraft Status</Label>
<Select value={autodraftStatus} onValueChange={setAutodraftStatus} disabled={!autopay}>
<SelectTrigger className={cn(inp, !autopay && 'opacity-60')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="assumed_paid">Assumed paid</SelectItem>
<SelectItem value="confirmed">Confirmed</SelectItem>
</SelectContent>
</Select>
</div>
{/* Notes */} {/* Notes */}
<div className="col-span-2 space-y-1.5"> <div className="col-span-2 space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Notes</Label> <Label className="text-xs uppercase tracking-wider text-muted-foreground">Notes</Label>
@ -562,16 +623,55 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
/> />
</div> </div>
<div className="col-span-2 rounded-lg border border-border/60 bg-muted/20 p-3">
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={saveTemplate}
onChange={e => setSaveTemplate(e.target.checked)}
className="h-4 w-4 rounded border-border accent-primary"
/>
<span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">
Save this setup as a reusable template
</span>
</label>
{saveTemplate && (
<div className="mt-2 space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Template Name</Label>
<Input
className={inp}
value={templateName}
onChange={e => setTemplateName(e.target.value)}
placeholder={name || 'My bill template'}
/>
</div>
)}
</div>
</div> </div>
</form> </form>
<DialogFooter className="mt-2"> <DialogFooter className="mt-2 gap-2 sm:justify-between">
{!isNew && onDuplicate && (
<Button
type="button"
variant="outline"
disabled={busy}
onClick={() => onDuplicate(bill)}
className="gap-2 text-xs"
>
<Copy className="h-3.5 w-3.5" />
Duplicate
</Button>
)}
<div className="flex gap-2">
<Button type="button" variant="ghost" disabled={busy} onClick={onClose} className="text-xs"> <Button type="button" variant="ghost" disabled={busy} onClick={onClose} className="text-xs">
Cancel Cancel
</Button> </Button>
<Button type="submit" form="bill-modal-form" disabled={busy} className="text-xs"> <Button type="submit" form="bill-modal-form" disabled={busy} className="text-xs">
{isNew ? 'Add Bill' : 'Save Changes'} {isNew ? 'Add Bill' : 'Save Changes'}
</Button> </Button>
</div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -1,5 +1,6 @@
import { PenLine, EyeOff, Eye, Clock, Trash2, Zap } from 'lucide-react'; import { Copy, PenLine, EyeOff, Eye, Clock, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { MobileBillRow } from '@/components/MobileBillRow';
function ordinal(n) { function ordinal(n) {
const d = Number(n); const d = Number(n);
@ -30,7 +31,7 @@ const ALL_ON = {
showApr: true, showBalance: true, showMinPayment: true, showAutopay: true, show2fa: true, showApr: true, showBalance: true, showMinPayment: true, showAutopay: true, show2fa: true,
}; };
function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory }) { function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, onDuplicate }) {
const isDebt = bill.current_balance != null || bill.minimum_payment != null; const isDebt = bill.current_balance != null || bill.minimum_payment != null;
const hasHistory = hasHistoricalVisibility(bill); const hasHistory = hasHistoricalVisibility(bill);
@ -132,6 +133,15 @@ function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory
<PenLine className="h-3.5 w-3.5" /> <PenLine className="h-3.5 w-3.5" />
</button> </button>
<button
type="button"
onClick={() => onDuplicate?.(bill)}
title="Duplicate"
className="p-1.5 rounded-md text-muted-foreground/40 hover:text-primary hover:bg-primary/10 transition-colors"
>
<Copy className="h-3.5 w-3.5" />
</button>
{!bill.active && onHistory && ( {!bill.active && onHistory && (
<button <button
type="button" type="button"
@ -171,9 +181,10 @@ function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory
); );
} }
export default function BillsTableInner({ bills, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory }) { export default function BillsTableInner({ bills, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, onDuplicate }) {
return ( return (
<div className="divide-y divide-border/30"> <>
<div className="hidden divide-y divide-border/30 sm:block">
{bills.map(bill => ( {bills.map(bill => (
<BillCard <BillCard
key={bill.id} key={bill.id}
@ -183,8 +194,23 @@ export default function BillsTableInner({ bills, prefs = ALL_ON, onEdit, onToggl
onToggle={onToggle} onToggle={onToggle}
onDelete={onDelete} onDelete={onDelete}
onHistory={onHistory} onHistory={onHistory}
onDuplicate={onDuplicate}
/> />
))} ))}
</div> </div>
<div className="space-y-3 p-3 sm:hidden">
{bills.map(bill => (
<MobileBillRow
key={bill.id}
bill={bill}
onEdit={onEdit}
onToggle={onToggle}
onDelete={onDelete}
onHistory={onHistory}
onDuplicate={onDuplicate}
/>
))}
</div>
</>
); );
} }

View File

@ -0,0 +1,216 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Loader2, Receipt, Search, X } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
import { cn, fmt } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
function amountSearchText(...values) {
return values
.filter(value => value !== null && value !== undefined && Number.isFinite(Number(value)))
.flatMap(value => {
const num = Number(value);
return [String(num), num.toFixed(2), `$${num.toFixed(2)}`];
})
.join(' ');
}
function billSearchText(bill) {
return [
bill.name,
bill.category_name,
bill.notes,
bill.billing_cycle,
bill.bucket,
bill.website,
amountSearchText(
bill.expected_amount,
bill.current_balance,
bill.minimum_payment,
bill.interest_rate,
),
].filter(Boolean).join(' ').toLowerCase();
}
function shortcutLabel() {
if (typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)) {
return 'Cmd K';
}
return 'Ctrl K';
}
export default function CommandPalette() {
const navigate = useNavigate();
const inputRef = useRef(null);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [bills, setBills] = useState([]);
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
const openPalette = () => setOpen(true);
const onKeyDown = (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
event.preventDefault();
setOpen(value => !value);
}
};
window.addEventListener('command-palette:open', openPalette);
window.addEventListener('keydown', onKeyDown);
return () => {
window.removeEventListener('command-palette:open', openPalette);
window.removeEventListener('keydown', onKeyDown);
};
}, []);
useEffect(() => {
if (!open) return;
window.setTimeout(() => inputRef.current?.focus(), 0);
if (loaded || loading) return;
setLoading(true);
api.allBills({ inactive: true })
.then(rows => {
setBills(Array.isArray(rows) ? rows : []);
setLoaded(true);
})
.catch(err => {
toast.error(err.message || 'Failed to load bills');
})
.finally(() => setLoading(false));
}, [loaded, loading, open]);
const results = useMemo(() => {
const q = query.trim().toLowerCase();
const sorted = [...bills].sort((a, b) => {
if (!!a.active !== !!b.active) return a.active ? -1 : 1;
return String(a.name || '').localeCompare(String(b.name || ''));
});
if (!q) return sorted.slice(0, 8);
return sorted
.filter(bill => billSearchText(bill).includes(q))
.slice(0, 12);
}, [bills, query]);
const close = () => {
setOpen(false);
setQuery('');
};
const openBills = (bill) => {
const params = new URLSearchParams({ search: bill.name });
if (!bill.active) params.set('inactive', '1');
navigate(`/bills?${params.toString()}`);
close();
};
const openTracker = (bill) => {
const params = new URLSearchParams({ search: bill.name });
navigate(`/?${params.toString()}`);
close();
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-2xl">
<DialogHeader className="sr-only">
<DialogTitle>Command palette</DialogTitle>
</DialogHeader>
<div className="flex items-center gap-2 border-b border-border/70 px-4 py-3">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<Input
ref={inputRef}
value={query}
onChange={event => setQuery(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter' && results[0]) openBills(results[0]);
}}
placeholder="Find a bill by name, category, notes, or amount"
className="h-9 border-0 bg-transparent px-0 text-base shadow-none focus-visible:ring-0"
/>
{query && (
<Button
type="button"
size="icon"
variant="ghost"
className="h-7 w-7 shrink-0"
onClick={() => setQuery('')}
aria-label="Clear search"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<div className="max-h-[min(28rem,70svh)] overflow-y-auto p-2">
{loading ? (
<div className="flex items-center justify-center gap-2 px-4 py-12 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading bills...
</div>
) : results.length > 0 ? (
<div className="space-y-1">
{results.map(bill => (
<div
key={bill.id}
className={cn(
'group grid gap-2 rounded-lg border border-transparent p-2 transition-colors',
'hover:border-border/70 hover:bg-accent/65 sm:grid-cols-[1fr_auto]',
)}
>
<button
type="button"
onClick={() => openBills(bill)}
className="flex min-w-0 items-center gap-3 rounded-md text-left focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
<Receipt className="h-4 w-4" />
</span>
<span className="min-w-0">
<span className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-semibold text-foreground">{bill.name}</span>
{!bill.active && (
<span className="shrink-0 rounded-full border border-border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
Inactive
</span>
)}
</span>
<span className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
<span>{bill.category_name || 'Uncategorized'}</span>
<span>Due {bill.due_day}</span>
<span>{fmt(bill.expected_amount || 0)}</span>
</span>
</span>
</button>
<div className="flex items-center justify-end gap-1 pl-12 sm:pl-0">
<Button type="button" size="sm" variant="ghost" className="h-8 px-2.5 text-xs" onClick={() => openTracker(bill)}>
Tracker
</Button>
<Button type="button" size="sm" variant="default" className="h-8 px-2.5 text-xs" onClick={() => openBills(bill)}>
Bills
</Button>
</div>
</div>
))}
</div>
) : (
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
No bills found.
</div>
)}
</div>
<div className="flex items-center justify-between border-t border-border/70 px-4 py-2 text-xs text-muted-foreground">
<span>Enter opens Bills</span>
<span>{shortcutLabel()}</span>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -1,5 +1,5 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { History } from 'lucide-react'; import { Copy, History } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -8,7 +8,7 @@ function hasHistoricalVisibility(bill) {
return !!bill.has_history_ranges || (visibility && visibility !== 'default'); return !!bill.has_history_ranges || (visibility && visibility !== 'default');
} }
export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, onToggle, onDelete, onHistory }) { export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, onToggle, onDelete, onHistory, onDuplicate }) {
const hasHistory = useMemo(() => hasHistoricalVisibility(bill), [bill]); const hasHistory = useMemo(() => hasHistoricalVisibility(bill), [bill]);
const statusClass = useMemo(() => { const statusClass = useMemo(() => {
@ -94,6 +94,15 @@ export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, o
</div> </div>
<div className="mt-3 flex flex-wrap items-center justify-end gap-1.5"> <div className="mt-3 flex flex-wrap items-center justify-end gap-1.5">
<Button
variant="ghost"
size="sm"
className="h-8 gap-1.5 px-2.5 text-xs text-muted-foreground hover:text-primary"
onClick={() => onDuplicate?.(bill)}
>
<Copy className="h-3.5 w-3.5" />
Duplicate
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"

View File

@ -2,7 +2,7 @@ import { useState, useMemo } from 'react';
import { NavLink, useLocation, useNavigate } from 'react-router-dom'; import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import { import {
Activity, BarChart3, CalendarDays, ChevronDown, ClipboardCheck, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt, Activity, BarChart3, CalendarDays, ChevronDown, ClipboardCheck, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt,
Settings, ShieldCheck, Tag, TrendingDown, User, X, Search, Settings, ShieldCheck, Tag, TrendingDown, User, X,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
@ -27,7 +27,7 @@ const userNavItems = [
const adminNavItems = [ const adminNavItems = [
{ to: '/admin', icon: ShieldCheck, label: 'Admin Panel', end: true }, { to: '/admin', icon: ShieldCheck, label: 'Admin Panel', end: true },
{ to: '/admin/status', icon: Activity, label: 'System Status' }, { to: '/admin/status', icon: Activity, label: 'System Status' },
{ to: '/admin/roadmap', icon: Map, label: 'Roadmap' }, { to: '/roadmap', icon: Map, label: 'Roadmap' },
]; ];
const trackerItems = [ const trackerItems = [
@ -147,7 +147,7 @@ function UserMenu({ adminMode = false }) {
About About
</DropdownMenuItem> </DropdownMenuItem>
{user?.role === 'admin' && ( {user?.role === 'admin' && (
<DropdownMenuItem onSelect={() => navigate('/admin/roadmap')}> <DropdownMenuItem onSelect={() => navigate('/roadmap')}>
<Map className="h-4 w-4" /> <Map className="h-4 w-4" />
Roadmap Roadmap
</DropdownMenuItem> </DropdownMenuItem>
@ -180,6 +180,21 @@ export default function Sidebar({ adminMode = false }) {
</nav> </nav>
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
{!adminMode && (
<Button
type="button"
variant="outline"
className="hidden h-9 gap-2 rounded-full bg-muted px-3 text-muted-foreground shadow-sm md:inline-flex"
onClick={() => window.dispatchEvent(new Event('command-palette:open'))}
title="Find a bill"
>
<Search className="h-4 w-4" />
<span className="text-sm">Find</span>
<kbd className="rounded border border-border bg-background px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground">
Ctrl K
</kbd>
</Button>
)}
<ThemeToggle className="rounded-full border border-border/80 bg-muted shadow-sm" /> <ThemeToggle className="rounded-full border border-border/80 bg-muted shadow-sm" />
<UserMenu adminMode={adminMode} /> <UserMenu adminMode={adminMode} />
<Button <Button

40
client/lib/billDrafts.js Normal file
View File

@ -0,0 +1,40 @@
function categoryForTemplate(template, categories = []) {
const keywords = template?.categoryKeywords || [];
const match = categories.find(category => {
const name = String(category.name || '').toLowerCase();
return keywords.some(keyword => name.includes(keyword));
});
return match?.id ?? null;
}
function categoryIdOrFallback(categoryId, template, categories = []) {
if (categoryId && categories.some(category => String(category.id) === String(categoryId))) {
return categoryId;
}
return template ? categoryForTemplate(template, categories) : null;
}
export function makeBillDraft(source, { copy = false, template = null, categories = [] } = {}) {
const data = source || {};
return {
...data,
id: undefined,
source_bill_id: copy && data.id != null ? data.id : undefined,
active: 1,
name: copy
? `${data.name || 'Bill'} (Copy)`
: (data.name || template?.name || ''),
category_id: categoryIdOrFallback(data.category_id, template, categories),
due_day: data.due_day || 1,
expected_amount: data.expected_amount ?? 0,
billing_cycle: data.billing_cycle || 'monthly',
cycle_type: data.cycle_type || 'monthly',
cycle_day: String(data.cycle_day || '1'),
autopay_enabled: !!data.autopay_enabled,
autodraft_status: data.autodraft_status || (data.autopay_enabled ? 'assumed_paid' : 'none'),
auto_mark_paid: !!data.auto_mark_paid,
has_2fa: !!data.has_2fa,
snowball_include: !!data.snowball_include,
snowball_exempt: !!data.snowball_exempt,
};
}

View File

@ -1,5 +1,6 @@
import React, { useEffect, useState, useCallback, useRef } from 'react'; import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { Plus, ChevronRight, SlidersHorizontal } from 'lucide-react'; import { useSearchParams } from 'react-router-dom';
import { Plus, ChevronRight, SlidersHorizontal, Search, Trash2, X } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@ -19,6 +20,7 @@ import { api } from '@/api';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import BillsTableInner from '@/components/BillsTableInner'; import BillsTableInner from '@/components/BillsTableInner';
import BillModal from '@/components/BillModal'; import BillModal from '@/components/BillModal';
import { makeBillDraft } from '@/lib/billDrafts';
const VISIBILITY_OPTIONS = [ const VISIBILITY_OPTIONS = [
{ value: 'default', label: 'Default', description: 'Use the app default behavior for inactive bill history.' }, { value: 'default', label: 'Default', description: 'Use the app default behavior for inactive bill history.' },
@ -30,6 +32,86 @@ const MONTH_OPTIONS = [
['1', 'Jan'], ['2', 'Feb'], ['3', 'Mar'], ['4', 'Apr'], ['5', 'May'], ['6', 'Jun'], ['1', 'Jan'], ['2', 'Feb'], ['3', 'Mar'], ['4', 'Apr'], ['5', 'May'], ['6', 'Jun'],
['7', 'Jul'], ['8', 'Aug'], ['9', 'Sep'], ['10', 'Oct'], ['11', 'Nov'], ['12', 'Dec'], ['7', 'Jul'], ['8', 'Aug'], ['9', 'Sep'], ['10', 'Oct'], ['11', 'Nov'], ['12', 'Dec'],
]; ];
const FILTER_ALL = 'all';
const BUILT_IN_TEMPLATES = [
{
id: 'builtin-utility',
name: 'Utility',
data: {
name: 'Utility Bill',
due_day: 15,
expected_amount: 0,
billing_cycle: 'monthly',
cycle_type: 'monthly',
cycle_day: '1',
autopay_enabled: false,
autodraft_status: 'none',
has_2fa: false,
notes: 'Utility service bill',
},
categoryKeywords: ['utility', 'utilities', 'electric', 'water'],
},
{
id: 'builtin-credit-card',
name: 'Credit Card',
data: {
name: 'Credit Card',
due_day: 20,
expected_amount: 0,
billing_cycle: 'monthly',
cycle_type: 'monthly',
cycle_day: '1',
autopay_enabled: false,
autodraft_status: 'none',
has_2fa: true,
current_balance: 0,
minimum_payment: 0,
interest_rate: 0,
snowball_include: true,
notes: 'Credit card minimum payment',
},
categoryKeywords: ['credit card', 'credit cards', 'debt'],
},
{
id: 'builtin-subscription',
name: 'Subscription',
data: {
name: 'Subscription',
due_day: 1,
expected_amount: 0,
billing_cycle: 'monthly',
cycle_type: 'monthly',
cycle_day: '1',
autopay_enabled: true,
autodraft_status: 'assumed_paid',
has_2fa: false,
notes: 'Recurring subscription',
},
categoryKeywords: ['subscription', 'subscriptions', 'entertainment'],
},
{
id: 'builtin-loan',
name: 'Loan',
data: {
name: 'Loan Payment',
due_day: 1,
expected_amount: 0,
billing_cycle: 'monthly',
cycle_type: 'monthly',
cycle_day: '1',
autopay_enabled: false,
autodraft_status: 'none',
has_2fa: false,
current_balance: 0,
minimum_payment: 0,
interest_rate: 0,
snowball_include: true,
notes: 'Installment loan payment',
},
categoryKeywords: ['loan', 'loans', 'debt'],
},
];
// BillModal is imported from @/components/BillModal // BillModal is imported from @/components/BillModal
@ -199,6 +281,40 @@ function DisplayPrefsPanel({ prefs, onToggle }) {
); );
} }
function amountSearchText(...values) {
return values
.filter(value => value !== null && value !== undefined && Number.isFinite(Number(value)))
.flatMap(value => {
const num = Number(value);
return [String(num), num.toFixed(2), `$${num.toFixed(2)}`];
})
.join(' ');
}
function billIsDebt(bill) {
const category = String(bill.category_name || '').toLowerCase();
return Number(bill.current_balance) > 0
|| bill.minimum_payment != null
|| ['credit card', 'credit cards', 'loan', 'loans', 'debt', 'mortgage'].some(token => category.includes(token));
}
function FilterChip({ active, children, onClick }) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'h-8 rounded-full border px-3 text-xs font-medium transition-colors',
active
? 'border-primary/50 bg-primary/15 text-primary'
: 'border-border/70 bg-card/70 text-muted-foreground hover:bg-accent hover:text-foreground',
)}
>
{children}
</button>
);
}
// //
function HistoryVisibilityDialog({ bill, onClose, onSaved }) { function HistoryVisibilityDialog({ bill, onClose, onSaved }) {
@ -430,10 +546,22 @@ function HistoryVisibilityDialog({ bill, onClose, onSaved }) {
// Bills Page // Bills Page
export default function BillsPage() { export default function BillsPage() {
const [searchParams] = useSearchParams();
const [bills, setBills] = useState([]); const [bills, setBills] = useState([]);
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
const [savedTemplates, setSavedTemplates] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showInactive, setShowInactive] = useState(false); const [showInactive, setShowInactive] = useState(false);
const [search, setSearch] = useState('');
const [filters, setFilters] = useState({
category: FILTER_ALL,
cycle: FILTER_ALL,
autopay: false,
firstBucket: false,
fifteenthBucket: false,
debt: false,
inactive: false,
});
// modal state: null = closed, { bill: null } = add new, { bill: {...} } = edit // modal state: null = closed, { bill: null } = add new, { bill: {...} } = edit
const [modal, setModal] = useState(null); const [modal, setModal] = useState(null);
@ -448,12 +576,14 @@ export default function BillsPage() {
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
const [billsRes, catRes] = await Promise.all([ const [billsRes, catRes, templateRes] = await Promise.all([
api.allBills(), api.allBills(),
api.categories(), api.categories(),
api.billTemplates(),
]); ]);
setBills(billsRes || []); setBills(billsRes || []);
setCategories(catRes || []); setCategories(catRes || []);
setSavedTemplates(templateRes || []);
} catch (err) { } catch (err) {
toast.error(err.message); toast.error(err.message);
} finally { } finally {
@ -463,6 +593,46 @@ export default function BillsPage() {
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
useEffect(() => {
const querySearch = searchParams.get('search') || '';
const includeInactive = searchParams.get('inactive') === '1' || searchParams.get('inactive') === 'true';
if (querySearch) setSearch(querySearch);
if (includeInactive) {
setShowInactive(true);
setFilters(prev => ({ ...prev, inactive: true }));
}
}, [searchParams]);
useEffect(() => {
if (filters.inactive) setShowInactive(true);
}, [filters.inactive]);
const toggleFilter = (key) => setFilters(prev => ({ ...prev, [key]: !prev[key] }));
const setFilterValue = (key, value) => setFilters(prev => ({ ...prev, [key]: value }));
const hasFilters = !!(
search.trim()
|| filters.category !== FILTER_ALL
|| filters.cycle !== FILTER_ALL
|| filters.autopay
|| filters.firstBucket
|| filters.fifteenthBucket
|| filters.debt
|| filters.inactive
);
const resetFilters = () => {
setSearch('');
setFilters({
category: FILTER_ALL,
cycle: FILTER_ALL,
autopay: false,
firstBucket: false,
fifteenthBucket: false,
debt: false,
inactive: false,
});
};
async function handleEdit(billId) { async function handleEdit(billId) {
try { try {
const bill = await api.bill(billId); const bill = await api.bill(billId);
@ -472,6 +642,24 @@ export default function BillsPage() {
} }
} }
function handleDuplicateBill(bill) {
setModal({ bill: null, initialBill: makeBillDraft(bill, { copy: true, categories }) });
}
function handleTemplateSelect(value) {
if (!value || value === 'placeholder') return;
const builtIn = BUILT_IN_TEMPLATES.find(template => template.id === value);
if (builtIn) {
setModal({ bill: null, initialBill: makeBillDraft(builtIn.data, { template: builtIn, categories }) });
return;
}
const saved = savedTemplates.find(template => `saved-${template.id}` === value);
if (saved) {
setModal({ bill: null, initialBill: makeBillDraft(saved.data, { template: saved, categories }) });
}
}
function handleToggle(bill) { function handleToggle(bill) {
if (bill.active) { if (bill.active) {
// Prompt confirmation before deactivating // Prompt confirmation before deactivating
@ -537,8 +725,38 @@ export default function BillsPage() {
await doToggle(bill); await doToggle(bill);
} }
const active = bills.filter(b => b.active); const cycleOptions = useMemo(() => (
const inactive = bills.filter(b => !b.active); Array.from(new Set(bills.map(b => b.billing_cycle || 'monthly'))).sort()
), [bills]);
const filteredBills = useMemo(() => {
const q = search.trim().toLowerCase();
return bills.filter(bill => {
if (filters.inactive && bill.active) return false;
if (filters.category !== FILTER_ALL && String(bill.category_id ?? '') !== filters.category) return false;
if (filters.cycle !== FILTER_ALL && String(bill.billing_cycle || 'monthly') !== filters.cycle) return false;
if (filters.autopay && !bill.autopay_enabled) return false;
if (filters.debt && !billIsDebt(bill)) return false;
if (filters.firstBucket && !filters.fifteenthBucket && bill.bucket !== '1st') return false;
if (filters.fifteenthBucket && !filters.firstBucket && bill.bucket !== '15th') return false;
if (!q) return true;
const haystack = [
bill.name,
bill.category_name,
bill.notes,
bill.billing_cycle,
bill.bucket,
amountSearchText(bill.expected_amount, bill.current_balance, bill.minimum_payment, bill.interest_rate),
].filter(Boolean).join(' ').toLowerCase();
return haystack.includes(q);
});
}, [bills, filters, search]);
const active = filteredBills.filter(b => b.active);
const inactive = filteredBills.filter(b => !b.active);
const totalActive = bills.filter(b => b.active).length;
const totalInactive = bills.filter(b => !b.active).length;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -551,18 +769,38 @@ export default function BillsPage() {
</p> </p>
<h1 className="text-2xl font-bold tracking-tight">Bills</h1> <h1 className="text-2xl font-bold tracking-tight">Bills</h1>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{active.length} active {totalActive} active
{inactive.length > 0 && ( {totalInactive > 0 && (
<span className="text-muted-foreground/50"> · {inactive.length} inactive</span> <span className="text-muted-foreground/50"> · {totalInactive} inactive</span>
)} )}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:justify-end">
<DisplayPrefsPanel prefs={prefs} onToggle={togglePref} /> <DisplayPrefsPanel prefs={prefs} onToggle={togglePref} />
<Select value="placeholder" onValueChange={handleTemplateSelect}>
<SelectTrigger className="h-9 min-w-[160px] flex-1 bg-card sm:w-[180px] sm:flex-none">
<SelectValue placeholder="Use template" />
</SelectTrigger>
<SelectContent>
<SelectItem value="placeholder">Use template...</SelectItem>
{BUILT_IN_TEMPLATES.map(template => (
<SelectItem key={template.id} value={template.id}>{template.name}</SelectItem>
))}
{savedTemplates.length > 0 && (
<>
{savedTemplates.map(template => (
<SelectItem key={template.id} value={`saved-${template.id}`}>
{template.name}
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
<Button <Button
onClick={() => setModal({ bill: null })} onClick={() => setModal({ bill: null })}
className="h-9 px-4 gap-2 text-sm font-medium" className="h-9 flex-1 gap-2 px-4 text-sm font-medium sm:flex-none"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add Bill Add Bill
@ -570,7 +808,64 @@ export default function BillsPage() {
</div> </div>
</div> </div>
<div className="surface-elevated rounded-xl p-4 space-y-3">
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_220px_180px_auto] lg:items-center">
<label className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search bills by name, category, notes, or amount"
className="h-10 pl-9"
/>
</label>
<Select value={filters.category} onValueChange={value => setFilterValue('category', value)}>
<SelectTrigger className="h-10">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value={FILTER_ALL}>All categories</SelectItem>
{categories.map(category => (
<SelectItem key={category.id} value={String(category.id)}>{category.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filters.cycle} onValueChange={value => setFilterValue('cycle', value)}>
<SelectTrigger className="h-10 capitalize">
<SelectValue placeholder="Billing cycle" />
</SelectTrigger>
<SelectContent>
<SelectItem value={FILTER_ALL}>All cycles</SelectItem>
{cycleOptions.map(cycle => (
<SelectItem key={cycle} value={cycle}>{cycle.replace(/_/g, ' ')}</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
disabled={!hasFilters}
onClick={resetFilters}
className="h-10 justify-center gap-2 text-xs"
>
<X className="h-3.5 w-3.5" />
Clear
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<FilterChip active={filters.autopay} onClick={() => toggleFilter('autopay')}>Autopay</FilterChip>
<FilterChip active={filters.firstBucket} onClick={() => toggleFilter('firstBucket')}>1st bucket</FilterChip>
<FilterChip active={filters.fifteenthBucket} onClick={() => toggleFilter('fifteenthBucket')}>15th bucket</FilterChip>
<FilterChip active={filters.debt} onClick={() => toggleFilter('debt')}>Debt</FilterChip>
<FilterChip active={filters.inactive} onClick={() => toggleFilter('inactive')}>Inactive</FilterChip>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{filteredBills.length} of {bills.length} shown
</span>
</div>
</div>
{/* ── Active Bills ── */} {/* ── Active Bills ── */}
{!filters.inactive && (
<div className="surface-elevated rounded-xl overflow-hidden"> <div className="surface-elevated rounded-xl overflow-hidden">
<div className="flex items-center justify-between gap-3 px-5 py-3 border-b border-border/40"> <div className="flex items-center justify-between gap-3 px-5 py-3 border-b border-border/40">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> <span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
@ -587,6 +882,10 @@ export default function BillsPage() {
</div> </div>
) : active.length === 0 ? ( ) : active.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground"> <div className="py-16 text-center text-sm text-muted-foreground">
{hasFilters ? (
<>No active bills match your filters.</>
) : (
<>
No active bills.{' '} No active bills.{' '}
<button <button
onClick={() => setModal({ bill: null })} onClick={() => setModal({ bill: null })}
@ -594,6 +893,8 @@ export default function BillsPage() {
> >
Add your first bill Add your first bill
</button> </button>
</>
)}
</div> </div>
) : ( ) : (
<BillsTableInner <BillsTableInner
@ -602,9 +903,11 @@ export default function BillsPage() {
onEdit={handleEdit} onEdit={handleEdit}
onToggle={handleToggle} onToggle={handleToggle}
onDelete={handleDeleteRequest} onDelete={handleDeleteRequest}
onDuplicate={handleDuplicateBill}
/> />
)} )}
</div> </div>
)}
{/* ── Inactive Bills ── */} {/* ── Inactive Bills ── */}
{!loading && inactive.length > 0 && ( {!loading && inactive.length > 0 && (
@ -623,7 +926,7 @@ export default function BillsPage() {
</span> </span>
</button> </button>
{showInactive && ( {(showInactive || filters.inactive) && (
<div className="surface-elevated rounded-xl overflow-hidden"> <div className="surface-elevated rounded-xl overflow-hidden">
<div className="flex items-center justify-between gap-3 px-5 py-3 border-b border-border/40"> <div className="flex items-center justify-between gap-3 px-5 py-3 border-b border-border/40">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> <span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
@ -638,6 +941,7 @@ export default function BillsPage() {
onToggle={handleToggle} onToggle={handleToggle}
onDelete={handleDeleteRequest} onDelete={handleDeleteRequest}
onHistory={setHistoryTarget} onHistory={setHistoryTarget}
onDuplicate={handleDuplicateBill}
/> />
</div> </div>
)} )}
@ -645,13 +949,22 @@ export default function BillsPage() {
</div> </div>
)} )}
{!loading && filters.inactive && inactive.length === 0 && (
<div className="surface-elevated rounded-xl py-12 text-center text-sm text-muted-foreground">
No inactive bills match your filters.
</div>
)}
{/* ── Bill Modal ── */} {/* ── Bill Modal ── */}
{modal && ( {modal && (
<BillModal <BillModal
key={modal.bill?.id ? `edit-${modal.bill.id}` : `new-${modal.initialBill?.name || 'blank'}`}
bill={modal.bill} bill={modal.bill}
initialBill={modal.initialBill}
categories={categories} categories={categories}
onClose={() => setModal(null)} onClose={() => setModal(null)}
onSave={load} onSave={load}
onDuplicate={handleDuplicateBill}
/> />
)} )}

View File

@ -1083,8 +1083,33 @@ function rowDateLabel(row) {
return row.detected_paid_date ?? '—'; return row.detected_paid_date ?? '—';
} }
function billImportProgress(rows, importResult) {
const completedRowIds = importResult?.completedRowIds ?? new Set();
const remainingRows = rows.filter(row => !completedRowIds.has(row.row_id));
return {
completedCount: rows.length - remainingRows.length,
remainingRows,
remainingCount: remainingRows.length,
};
}
function detailImportedAnything(detail) {
return ['created', 'updated', 'overwritten', 'imported'].includes(detail?.result)
|| detail?.payment === 'created';
}
function detailCompletesImport(detail) {
if (!detail?.row_id) return false;
if (['error', 'ambiguous', 'skipped_conflict'].includes(detail.result)) return false;
if (detail.result === 'skipped') return false;
return detailImportedAnything(detail)
|| detail.result === 'skipped_duplicate'
|| detail.payment === 'skipped_duplicate';
}
function BillDetailView({ group, onBack, onImport, isImporting, importResult }) { function BillDetailView({ group, onBack, onImport, isImporting, importResult }) {
const { bill, rows } = group; const { bill, rows } = group;
const { completedCount, remainingCount } = billImportProgress(rows, importResult);
const sorted = [...rows].sort((a, b) => { const sorted = [...rows].sort((a, b) => {
const da = (a.detected_year ?? 0) * 100 + (a.detected_month ?? 0); const da = (a.detected_year ?? 0) * 100 + (a.detected_month ?? 0);
const db = (b.detected_year ?? 0) * 100 + (b.detected_month ?? 0); const db = (b.detected_year ?? 0) * 100 + (b.detected_month ?? 0);
@ -1099,15 +1124,16 @@ function BillDetailView({ group, onBack, onImport, isImporting, importResult })
<ChevronLeft className="h-3.5 w-3.5" /> All bills <ChevronLeft className="h-3.5 w-3.5" /> All bills
</button> </button>
<span className="text-sm font-medium truncate">{bill.name}</span> <span className="text-sm font-medium truncate">{bill.name}</span>
{importResult ? ( <div className="flex items-center gap-2 shrink-0">
<div className="text-right shrink-0"> {importResult && (
<div className="text-right">
<span className={`text-xs font-medium ${ <span className={`text-xs font-medium ${
importResult.created + importResult.updated === 0 ? 'text-amber-400' : 'text-emerald-500' completedCount === rows.length ? 'text-emerald-500' : 'text-amber-400'
}`}> }`}>
{importResult.created + importResult.updated > 0 {completedCount === rows.length
? `${importResult.created + importResult.updated} imported` ? 'All imported'
: `⚠ already existed`} : `${completedCount} imported · ${remainingCount} remaining`}
{importResult.duplicates > 0 && importResult.created + importResult.updated > 0 {importResult.duplicates > 0
&& ` · ${importResult.duplicates} dupes`} && ` · ${importResult.duplicates} dupes`}
</span> </span>
{importResult.duplicates > 0 && importResult.earliestDup && ( {importResult.duplicates > 0 && importResult.earliestDup && (
@ -1117,12 +1143,12 @@ function BillDetailView({ group, onBack, onImport, isImporting, importResult })
</p> </p>
)} )}
</div> </div>
) : (
<Button size="sm" onClick={onImport} disabled={isImporting} className="h-7 text-xs px-3 shrink-0 gap-1.5">
{isImporting && <Loader2 className="h-3 w-3 animate-spin" />}
Import all {rows.length}
</Button>
)} )}
<Button size="sm" onClick={onImport} disabled={isImporting || remainingCount === 0} className="h-7 text-xs px-3 shrink-0 gap-1.5">
{isImporting && <Loader2 className="h-3 w-3 animate-spin" />}
{remainingCount === 0 ? 'All imported' : `Import all ${remainingCount}`}
</Button>
</div>
</div> </div>
<div className="divide-y divide-border/30 max-h-80 overflow-y-auto"> <div className="divide-y divide-border/30 max-h-80 overflow-y-auto">
{sorted.map(row => ( {sorted.map(row => (
@ -1174,6 +1200,7 @@ function BillHistoryView({ previewRows, allBills, importingBillId, billImportRes
const { bill, rows, counts } = g; const { bill, rows, counts } = g;
const isImporting = importingBillId === bill.id; const isImporting = importingBillId === bill.id;
const importResult = billImportResults.get(bill.id) ?? null; const importResult = billImportResults.get(bill.id) ?? null;
const { completedCount, remainingCount } = billImportProgress(rows, importResult);
const sorted3 = [...rows] const sorted3 = [...rows]
.sort((a, b) => { .sort((a, b) => {
@ -1195,15 +1222,14 @@ function BillHistoryView({ previewRows, allBills, importingBillId, billImportRes
{counts.medium > 0 && <span className="text-[10px] text-amber-500">{counts.medium} med</span>} {counts.medium > 0 && <span className="text-[10px] text-amber-500">{counts.medium} med</span>}
{counts.low > 0 && <span className="text-[10px] text-muted-foreground/50">{counts.low} low</span>} {counts.low > 0 && <span className="text-[10px] text-muted-foreground/50">{counts.low} low</span>}
{importResult && (() => { {importResult && (() => {
const imported = importResult.created + importResult.updated; const allImported = completedCount === rows.length;
const allDupes = imported === 0 && importResult.duplicates > 0;
const fmtDate = d => d?.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); const fmtDate = d => d?.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
const dupDate = importResult.earliestDup ? ` · ${fmtDate(importResult.earliestDup)}` : ''; const dupDate = importResult.earliestDup ? ` · ${fmtDate(importResult.earliestDup)}` : '';
return ( return (
<div className="space-y-0.5"> <div className="space-y-0.5">
<span className={`text-[10px] font-medium ${allDupes ? 'text-amber-400' : 'text-emerald-500'}`}> <span className={`text-[10px] font-medium ${allImported ? 'text-emerald-500' : 'text-amber-400'}`}>
{imported > 0 ? `${imported} imported` : '⚠ already existed'} {allImported ? 'All imported' : `${completedCount} imported · ${remainingCount} remaining`}
{importResult.duplicates > 0 && imported > 0 && ` · ${importResult.duplicates} dupes`} {importResult.duplicates > 0 && ` · ${importResult.duplicates} dupes`}
{importResult.errored > 0 && ` · ${importResult.errored} errors`} {importResult.errored > 0 && ` · ${importResult.errored} errors`}
</span> </span>
{importResult.duplicates > 0 && ( {importResult.duplicates > 0 && (
@ -1240,8 +1266,8 @@ function BillHistoryView({ previewRows, allBills, importingBillId, billImportRes
<div className="flex flex-col gap-1 shrink-0 pt-0.5"> <div className="flex flex-col gap-1 shrink-0 pt-0.5">
{importResult ? ( {importResult ? (
<Button size="sm" variant="outline" onClick={() => onImportBill(g)} <Button size="sm" variant="outline" onClick={() => onImportBill(g)}
disabled={!!importingBillId} className="h-7 text-xs px-3 gap-1.5"> disabled={!!importingBillId || remainingCount === 0} className="h-7 text-xs px-3 gap-1.5">
Re-import {remainingCount === 0 ? 'All imported' : `Import ${remainingCount}`}
</Button> </Button>
) : ( ) : (
<Button size="sm" onClick={() => onImportBill(g)} <Button size="sm" onClick={() => onImportBill(g)}
@ -1331,9 +1357,16 @@ export function ImportSpreadsheetSection({ onHistoryRefresh }) {
const sessionId = preview.data?.import_session_id; const sessionId = preview.data?.import_session_id;
if (!sessionId || importingBillId) return; if (!sessionId || importingBillId) return;
const previousResult = billImportResults.get(group.bill.id) ?? null;
const rowsToImport = billImportProgress(group.rows, previousResult).remainingRows;
if (rowsToImport.length === 0) {
toast.info(`All rows for "${group.bill.name}" have already been imported.`);
return;
}
setImportingBillId(group.bill.id); setImportingBillId(group.bill.id);
try { try {
const decisionsList = group.rows.map(row => ({ const decisionsList = rowsToImport.map(row => ({
row_id: row.row_id, row_id: row.row_id,
action: 'match_existing_bill', action: 'match_existing_bill',
bill_id: group.bill.id, bill_id: group.bill.id,
@ -1351,11 +1384,18 @@ export function ImportSpreadsheetSection({ onHistoryRefresh }) {
const created = result.rows_created ?? 0; const created = result.rows_created ?? 0;
const updated = result.rows_updated ?? 0; const updated = result.rows_updated ?? 0;
const errored = result.rows_errored ?? 0; const errored = result.rows_errored ?? 0;
const duplicates = result.rows_duplicates ?? 0; const details = result.details ?? [];
const duplicateRowIds = new Set(
details
.filter(d => d.result === 'skipped_duplicate' || d.payment === 'skipped_duplicate')
.map(d => d.row_id)
.filter(Boolean),
);
const duplicates = duplicateRowIds.size || (result.rows_duplicates ?? 0);
// Collect created_at dates from duplicate detail entries so we can show // Collect created_at dates from duplicate detail entries so we can show
// when the existing payments were originally recorded. // when the existing payments were originally recorded.
const dupDates = (result.details ?? []) const dupDates = details
.filter(d => (d.result === 'skipped_duplicate' || d.payment === 'skipped_duplicate') && d.existing_created_at) .filter(d => (d.result === 'skipped_duplicate' || d.payment === 'skipped_duplicate') && d.existing_created_at)
.map(d => new Date(d.existing_created_at)) .map(d => new Date(d.existing_created_at))
.filter(d => !isNaN(d.getTime())) .filter(d => !isNaN(d.getTime()))
@ -1363,20 +1403,47 @@ export function ImportSpreadsheetSection({ onHistoryRefresh }) {
const earliestDup = dupDates[0] ?? null; const earliestDup = dupDates[0] ?? null;
const latestDup = dupDates.at(-1) ?? null; const latestDup = dupDates.at(-1) ?? null;
const completedRowIds = new Set(previousResult?.completedRowIds ?? []);
const erroredRowIds = new Set(previousResult?.erroredRowIds ?? []);
setBillImportResults(prev => new Map(prev).set(group.bill.id, { for (const detail of details) {
created, updated, errored, duplicates, earliestDup, latestDup, if (detailCompletesImport(detail)) {
})); completedRowIds.add(detail.row_id);
erroredRowIds.delete(detail.row_id);
} else if (['error', 'ambiguous', 'skipped_conflict'].includes(detail?.result)) {
erroredRowIds.add(detail.row_id);
}
}
const mergedResult = {
created: (previousResult?.created ?? 0) + created,
updated: (previousResult?.updated ?? 0) + updated,
errored: erroredRowIds.size,
duplicates: (previousResult?.duplicates ?? 0) + duplicates,
earliestDup: previousResult?.earliestDup && earliestDup
? (previousResult.earliestDup < earliestDup ? previousResult.earliestDup : earliestDup)
: (previousResult?.earliestDup ?? earliestDup),
latestDup: previousResult?.latestDup && latestDup
? (previousResult.latestDup > latestDup ? previousResult.latestDup : latestDup)
: (previousResult?.latestDup ?? latestDup),
completedRowIds,
erroredRowIds,
};
setBillImportResults(prev => new Map(prev).set(group.bill.id, mergedResult));
const imported = created + updated; const imported = created + updated;
const fmtDate = d => d?.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); const fmtDate = d => d?.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
const remainingCount = billImportProgress(group.rows, mergedResult).remainingCount;
if (imported === 0 && duplicates > 0) { if (imported === 0 && duplicates > 0) {
const dateHint = earliestDup const dateHint = earliestDup
? ` (first recorded ${fmtDate(earliestDup)})` ? ` (first recorded ${fmtDate(earliestDup)})`
: ''; : '';
toast.warning( toast.warning(
`All ${duplicates} row${duplicates > 1 ? 's' : ''} for "${group.bill.name}" already exist${dateHint} — nothing new imported.`, remainingCount === 0
? `All rows for "${group.bill.name}" are now imported${dateHint}.`
: `${duplicates} row${duplicates > 1 ? 's' : ''} for "${group.bill.name}" already exist${dateHint}. ${remainingCount} remaining.`,
); );
} else { } else {
const parts = [`${imported} entr${imported === 1 ? 'y' : 'ies'} imported`]; const parts = [`${imported} entr${imported === 1 ? 'y' : 'ies'} imported`];
@ -1385,6 +1452,7 @@ export function ImportSpreadsheetSection({ onHistoryRefresh }) {
parts.push(`${duplicates} already existed${dateHint}`); parts.push(`${duplicates} already existed${dateHint}`);
} }
if (errored > 0) parts.push(`${errored} error${errored > 1 ? 's' : ''}`); if (errored > 0) parts.push(`${errored} error${errored > 1 ? 's' : ''}`);
if (remainingCount > 0) parts.push(`${remainingCount} remaining`);
toast.success(`${group.bill.name}${parts.join(' · ')}`); toast.success(`${group.bill.name}${parts.join(' · ')}`);
} }
onHistoryRefresh?.(); onHistoryRefresh?.();

View File

@ -13,6 +13,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import BillModal from '@/components/BillModal'; import BillModal from '@/components/BillModal';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { makeBillDraft } from '@/lib/billDrafts';
const FIELD_LABELS = { const FIELD_LABELS = {
due_day: 'Due day', due_day: 'Due day',
@ -122,7 +123,7 @@ export default function HealthPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [includeInactive, setIncludeInactive] = useState(false); const [includeInactive, setIncludeInactive] = useState(false);
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
const [modalBill, setModalBill] = useState(null); const [modal, setModal] = useState(null);
const [openingBillId, setOpeningBillId] = useState(null); const [openingBillId, setOpeningBillId] = useState(null);
const load = useCallback(async () => { const load = useCallback(async () => {
@ -146,7 +147,7 @@ export default function HealthPage() {
categories.length ? Promise.resolve(categories) : api.categories(), categories.length ? Promise.resolve(categories) : api.categories(),
]); ]);
if (!categories.length) setCategories(cats); if (!categories.length) setCategories(cats);
setModalBill(bill); setModal({ bill });
} catch (err) { } catch (err) {
toast.error(err.message || 'Could not open bill.'); toast.error(err.message || 'Could not open bill.');
} finally { } finally {
@ -155,7 +156,7 @@ export default function HealthPage() {
}, [categories]); }, [categories]);
const handleBillSaved = useCallback(() => { const handleBillSaved = useCallback(() => {
setModalBill(null); setModal(null);
load(); load();
}, [load]); }, [load]);
@ -251,12 +252,15 @@ export default function HealthPage() {
</div> </div>
)} )}
{modalBill && ( {modal && (
<BillModal <BillModal
bill={modalBill} key={modal.bill?.id ? `edit-${modal.bill.id}` : `new-${modal.initialBill?.name || 'blank'}`}
bill={modal.bill}
initialBill={modal.initialBill}
categories={categories} categories={categories}
onClose={() => setModalBill(null)} onClose={() => setModal(null)}
onSave={handleBillSaved} onSave={handleBillSaved}
onDuplicate={bill => setModal({ bill: null, initialBill: makeBillDraft(bill, { copy: true, categories }) })}
/> />
)} )}
</div> </div>

View File

@ -14,11 +14,11 @@ import { APP_VERSION } from '@/lib/version';
/* ─── Priority lanes ────────────────────────────────────────────────────────── */ /* ─── Priority lanes ────────────────────────────────────────────────────────── */
const PRIORITY_LANES = [ const PRIORITY_LANES = [
{ key: 'critical', emoji: '🔴', label: 'CRITICAL', borderColor: 'border-t-red-500', bgColor: 'bg-red-500/10', textColor: 'text-red-500', badgeClass: 'bg-red-500/15 text-red-500 border-red-500/20' }, { key: 'critical', emoji: '🔴', label: 'CRITICAL', borderColor: 'border-t-red-500', textColor: 'text-red-500', badgeClass: 'bg-red-500/15 text-red-500 border-red-500/20' },
{ key: 'high', emoji: '🟠', label: 'HIGH', borderColor: 'border-t-orange-500', bgColor: 'bg-orange-500/10', textColor: 'text-orange-500', badgeClass: 'bg-orange-500/15 text-orange-500 border-orange-500/20' }, { key: 'high', emoji: '🟠', label: 'HIGH', borderColor: 'border-t-orange-500', textColor: 'text-orange-500', badgeClass: 'bg-orange-500/15 text-orange-500 border-orange-500/20' },
{ key: 'medium', emoji: '🟡', label: 'MEDIUM', borderColor: 'border-t-yellow-500', bgColor: 'bg-yellow-500/10', textColor: 'text-yellow-500', badgeClass: 'bg-yellow-500/15 text-yellow-500 border-yellow-500/20' }, { key: 'medium', emoji: '🟡', label: 'MEDIUM', borderColor: 'border-t-yellow-500', textColor: 'text-yellow-500', badgeClass: 'bg-yellow-500/15 text-yellow-500 border-yellow-500/20' },
{ key: 'low', emoji: '🔵', label: 'LOW', borderColor: 'border-t-blue-500', bgColor: 'bg-blue-500/10', textColor: 'text-blue-500', badgeClass: 'bg-blue-500/15 text-blue-500 border-blue-500/20' }, { key: 'low', emoji: '🔵', label: 'LOW', borderColor: 'border-t-blue-500', textColor: 'text-blue-500', badgeClass: 'bg-blue-500/15 text-blue-500 border-blue-500/20' },
{ key: 'niceToHave', emoji: '💭', label: 'NICE TO HAVE', borderColor: 'border-t-border', bgColor: 'bg-muted/30', textColor: 'text-muted-foreground', badgeClass: 'bg-muted/50 text-muted-foreground border-border/50' }, { key: 'niceToHave', emoji: '💭', label: 'NICE TO HAVE', borderColor: 'border-t-border', textColor: 'text-muted-foreground', badgeClass: 'bg-muted/50 text-muted-foreground border-border/50' },
]; ];
// Normalise any priority string to a lane key. // Normalise any priority string to a lane key.
@ -220,7 +220,7 @@ function DevLogEntry({ entry }) {
{entry.workCompleted.map((work, idx) => ( {entry.workCompleted.map((work, idx) => (
<li key={idx} className="text-sm text-muted-foreground flex items-start gap-1.5"> <li key={idx} className="text-sm text-muted-foreground flex items-start gap-1.5">
<span className="text-emerald-500 mt-0.5 shrink-0"></span> <span className="text-emerald-500 mt-0.5 shrink-0"></span>
{work} <span className="min-w-0 break-words">{work}</span>
</li> </li>
))} ))}
</ul> </ul>
@ -253,14 +253,23 @@ export default function RoadmapPage() {
setForceKey(prev => prev + 1); setForceKey(prev => prev + 1);
}; };
const [isDesktop, setIsDesktop] = useState( const getIsDesktop = () => (
typeof window !== 'undefined' ? window.matchMedia('(min-width: 1024px)').matches : true typeof window !== 'undefined'
&& typeof window.matchMedia === 'function'
&& window.matchMedia('(min-width: 1024px)').matches
); );
const [isDesktop, setIsDesktop] = useState(getIsDesktop);
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return undefined;
const mq = window.matchMedia('(min-width: 1024px)'); const mq = window.matchMedia('(min-width: 1024px)');
const handler = (e) => setIsDesktop(e.matches); const handler = (e) => setIsDesktop(e.matches);
setIsDesktop(mq.matches);
if (typeof mq.addEventListener === 'function') {
mq.addEventListener('change', handler); mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler); return () => mq.removeEventListener('change', handler);
}
mq.addListener(handler);
return () => mq.removeListener(handler);
}, []); }, []);
// Fetch roadmap on mount // Fetch roadmap on mount
@ -315,7 +324,7 @@ export default function RoadmapPage() {
</div> </div>
{/* Tabs */} {/* Tabs */}
<Tabs defaultValue="roadmap" onValueChange={v => { if (v === 'activity') fetchDevLog(); }}> <Tabs defaultValue="roadmap" onValueChange={v => { if (v === 'activity') fetchDevLog(); }} className="min-w-0">
<TabsList className="grid w-full grid-cols-2 sm:inline-flex sm:w-auto"> <TabsList className="grid w-full grid-cols-2 sm:inline-flex sm:w-auto">
<TabsTrigger value="roadmap" className="gap-1.5"> <TabsTrigger value="roadmap" className="gap-1.5">
<Map className="h-3.5 w-3.5" /> <Map className="h-3.5 w-3.5" />
@ -353,12 +362,12 @@ export default function RoadmapPage() {
</div> </div>
{/* Wide desktop: full five-lane view */} {/* Wide desktop: full five-lane view */}
<div className="hidden 2xl:grid 2xl:grid-cols-5 gap-4"> <div className="hidden min-[1400px]:grid min-[1400px]:grid-cols-5 gap-4">
{grouped.map(lane => <PriorityLane key={lane.key} lane={lane} items={lane.items} {...laneProps} />)} {grouped.map(lane => <PriorityLane key={lane.key} lane={lane} items={lane.items} {...laneProps} />)}
</div> </div>
{/* Desktop: balanced three-column view for admin shell widths */} {/* Desktop: balanced three-column view for admin shell widths */}
<div className="hidden lg:grid lg:grid-cols-3 2xl:hidden gap-4"> <div className="hidden lg:grid lg:grid-cols-3 min-[1400px]:hidden gap-4">
<div className="space-y-4"> <div className="space-y-4">
{grouped.filter(l => l.key === 'critical' || l.key === 'high').map(lane => {grouped.filter(l => l.key === 'critical' || l.key === 'high').map(lane =>
<PriorityLane key={lane.key} lane={lane} items={lane.items} {...laneProps} /> <PriorityLane key={lane.key} lane={lane} items={lane.items} {...laneProps} />

View File

@ -9,6 +9,7 @@ import { Switch } from '@/components/ui/switch';
import { Skeleton } from '@/components/ui/Skeleton'; import { Skeleton } from '@/components/ui/Skeleton';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import BillModal from '@/components/BillModal'; import BillModal from '@/components/BillModal';
import { makeBillDraft } from '@/lib/billDrafts';
// formatters // formatters
function fmt(val) { function fmt(val) {
@ -960,7 +961,7 @@ export default function SnowballPage() {
<div className="flex flex-col items-center justify-center gap-1 px-3 shrink-0"> <div className="flex flex-col items-center justify-center gap-1 px-3 shrink-0">
<button <button
type="button" type="button"
onClick={() => setEditBill(bill)} onClick={() => setEditBill({ bill })}
title="Edit bill" title="Edit bill"
className="p-1.5 rounded-md text-muted-foreground/40 hover:text-foreground hover:bg-muted/60 transition-colors" className="p-1.5 rounded-md text-muted-foreground/40 hover:text-foreground hover:bg-muted/60 transition-colors"
> >
@ -1002,10 +1003,13 @@ export default function SnowballPage() {
{/* Edit modal */} {/* Edit modal */}
{editBill && ( {editBill && (
<BillModal <BillModal
bill={editBill} key={editBill.bill?.id ? `edit-${editBill.bill.id}` : `new-${editBill.initialBill?.name || 'blank'}`}
bill={editBill.bill}
initialBill={editBill.initialBill}
categories={categories} categories={categories}
onClose={() => setEditBill(null)} onClose={() => setEditBill(null)}
onSave={() => { setEditBill(null); load(); loadProjection(); }} onSave={() => { setEditBill(null); load(); loadProjection(); }}
onDuplicate={bill => setEditBill({ bill: null, initialBill: makeBillDraft(bill, { copy: true, categories }) })}
/> />
)} )}
</div> </div>

View File

@ -1,9 +1,11 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { ChevronLeft, ChevronRight, Pencil, TrendingUp, AlertCircle, Clock, CheckCircle2, Settings2, Loader2 } from 'lucide-react'; import { useSearchParams } from 'react-router-dom';
import { ChevronLeft, ChevronRight, Pencil, TrendingUp, AlertCircle, Clock, CheckCircle2, Settings2, Loader2, Plus, Search, X } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api.js'; import { api } from '@/api.js';
import { useTracker } from '@/hooks/useQueries'; import { useTracker } from '@/hooks/useQueries';
import BillModal from '@/components/BillModal'; import BillModal from '@/components/BillModal';
import { makeBillDraft } from '@/lib/billDrafts';
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils'; import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@ -27,6 +29,7 @@ const MONTHS = [
'January','February','March','April','May','June', 'January','February','March','April','May','June',
'July','August','September','October','November','December', 'July','August','September','October','November','December',
]; ];
const FILTER_ALL = 'all';
// Sentinel for the "no method" select option empty string crashes Radix Select // Sentinel for the "no method" select option empty string crashes Radix Select
const METHOD_NONE = 'none'; const METHOD_NONE = 'none';
@ -64,6 +67,57 @@ const STATUS_META = {
skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' }, skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' },
}; };
function amountSearchText(...values) {
return values
.filter(value => value !== null && value !== undefined && Number.isFinite(Number(value)))
.flatMap(value => {
const num = Number(value);
return [String(num), num.toFixed(2), `$${num.toFixed(2)}`];
})
.join(' ');
}
function rowThreshold(row) {
return row.actual_amount != null ? row.actual_amount : row.expected_amount;
}
function rowEffectiveStatus(row) {
if (row.is_skipped) return 'skipped';
const threshold = rowThreshold(row);
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
return (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft') ? 'paid' : row.status;
}
function rowIsPaid(row) {
const status = rowEffectiveStatus(row);
if (row.autopay_suggestion && status === 'autodraft') return false;
return status === 'paid' || status === 'autodraft';
}
function rowIsDebt(row) {
const category = String(row.category_name || '').toLowerCase();
return Number(row.current_balance) > 0
|| row.minimum_payment != null
|| ['credit card', 'credit cards', 'loan', 'loans', 'debt', 'mortgage'].some(token => category.includes(token));
}
function FilterChip({ active, children, onClick }) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'h-8 rounded-full border px-3 text-xs font-medium transition-colors',
active
? 'border-primary/50 bg-primary/15 text-primary'
: 'border-border/70 bg-card/70 text-muted-foreground hover:bg-accent hover:text-foreground',
)}
>
{children}
</button>
);
}
// Summary cards // Summary cards
const CARD_DEFS = { const CARD_DEFS = {
starting: { starting: {
@ -237,6 +291,54 @@ const StatusBadge = React.memo(function StatusBadge({ status, clickable, onClick
); );
}); });
function AutopaySuggestionActions({ row, loading, onConfirm, onDismiss, compact = false }) {
const suggestion = row.autopay_suggestion;
if (!suggestion) return null;
const title = `${fmt(suggestion.amount)} due ${fmtDate(suggestion.paid_date)}`;
return (
<div className={cn(
'flex items-center gap-1.5',
compact ? 'w-full flex-wrap justify-end sm:w-auto' : 'justify-end',
)}>
<span
className={cn(
'inline-flex min-w-0 items-center gap-1.5 rounded-md border border-sky-500/20 bg-sky-500/10',
'px-2 py-1 text-xs font-medium text-sky-600 dark:text-sky-300',
compact ? 'mr-auto' : 'max-w-32',
)}
title={`Autopay suggested: ${title}`}
>
<Clock className="h-3 w-3 shrink-0" />
<span className="truncate">{compact ? `Suggested ${fmt(suggestion.amount)}` : 'Suggested'}</span>
</span>
<Button
size="icon"
variant="ghost"
disabled={loading}
onClick={onDismiss}
className="h-8 w-8 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
title={`Dismiss suggested autopay payment for ${title}`}
aria-label={`Dismiss suggested autopay payment for ${row.name}`}
>
<X className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="default"
disabled={loading}
onClick={onConfirm}
className="h-8 w-8"
title={`Confirm suggested autopay payment for ${title}`}
aria-label={`Confirm suggested autopay payment for ${row.name}`}
>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
</Button>
</div>
);
}
// Inline-editable payment cell // Inline-editable payment cell
// `threshold` = actual_amount ?? expected_amount for this bill/month // `threshold` = actual_amount ?? expected_amount for this bill/month
function EditableCell({ row, field, threshold, defaultPaymentDate, refresh }) { function EditableCell({ row, field, threshold, defaultPaymentDate, refresh }) {
@ -323,6 +425,222 @@ function EditableCell({ row, field, threshold, defaultPaymentDate, refresh }) {
); );
} }
function paymentSummary(row, threshold) {
const target = Number(threshold) || 0;
const paid = Number(row.total_paid) || 0;
const remaining = Math.max(target - paid, 0);
const percent = target > 0 ? Math.min(100, Math.round((paid / target) * 100)) : 0;
return {
target,
paid,
remaining,
percent,
count: Array.isArray(row.payments) ? row.payments.length : 0,
partial: paid > 0 && remaining > 0,
};
}
function PaymentProgress({ row, threshold, onOpen, compact = false }) {
const summary = paymentSummary(row, threshold);
const barTone = summary.remaining === 0
? 'bg-emerald-500'
: summary.paid > 0
? 'bg-amber-500'
: 'bg-muted-foreground/40';
return (
<button
type="button"
onClick={onOpen}
className={cn(
'w-full rounded-md text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
compact ? 'p-2' : 'px-2 py-1.5',
)}
title="View payment history"
>
<div className="flex items-center justify-between gap-2 text-xs">
<span className={cn('font-mono', summary.paid > 0 ? 'text-emerald-500' : 'text-muted-foreground')}>
{summary.paid > 0 ? `${fmt(summary.paid)} of ${fmt(summary.target)}` : `Paid ${fmt(0)} of ${fmt(summary.target)}`}
</span>
{summary.count > 1 && (
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{summary.count} payments
</span>
)}
</div>
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted">
<div
className={cn('h-full rounded-full transition-all', barTone)}
style={{ width: `${summary.percent}%` }}
/>
</div>
<div className="mt-1 flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
<span>{summary.percent}%</span>
<span>{summary.remaining > 0 ? `${fmt(summary.remaining)} remaining` : 'Paid in full'}</span>
</div>
</button>
);
}
function PaymentLedgerDialog({ row, threshold, defaultPaymentDate, onClose, onSaved }) {
const summary = paymentSummary(row, threshold);
const [amount, setAmount] = useState(String(summary.remaining || summary.target || ''));
const [date, setDate] = useState(defaultPaymentDate);
const [method, setMethod] = useState(METHOD_NONE);
const [notes, setNotes] = useState('');
const [busy, setBusy] = useState(false);
const [editPayment, setEditPayment] = useState(null);
const payments = [...(row.payments || [])].sort((a, b) => String(b.paid_date).localeCompare(String(a.paid_date)));
async function handleAdd(e) {
e.preventDefault();
const parsedAmount = parseFloat(amount);
if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
toast.error('Enter a positive payment amount');
return;
}
if (!date) {
toast.error('Choose a payment date');
return;
}
setBusy(true);
try {
await api.createPayment({
bill_id: row.id,
amount: parsedAmount,
paid_date: date,
method: method === METHOD_NONE ? null : method,
notes: notes || null,
});
toast.success('Partial payment added');
onSaved?.();
onClose?.();
} catch (err) {
toast.error(err.message || 'Failed to add payment');
} finally {
setBusy(false);
}
}
return (
<>
<Dialog open onOpenChange={value => { if (!value) onClose(); }}>
<DialogContent className="max-h-[92svh] overflow-y-auto border-border/60 bg-card/95 backdrop-blur-xl sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="text-base font-semibold tracking-tight">{row.name} Payments</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-lg border border-border/60 bg-muted/25 p-3">
<PaymentProgress row={row} threshold={threshold} onOpen={() => {}} />
</div>
<div className="grid gap-3 md:grid-cols-[1fr_1fr]">
<div className="rounded-lg border border-border/60 bg-background/45 p-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Payment History</p>
{payments.length > 0 ? (
<div className="space-y-2">
{payments.map(payment => (
<div key={payment.id} className="flex items-center justify-between gap-3 rounded-md border border-border/50 bg-card/60 px-3 py-2">
<div className="min-w-0">
<p className="font-mono text-sm font-semibold text-foreground">{fmt(payment.amount)}</p>
<p className="truncate text-xs text-muted-foreground">
{fmtDate(payment.paid_date)}
{payment.method ? ` · ${payment.method}` : ''}
</p>
{payment.notes && (
<p className="mt-0.5 truncate text-xs text-muted-foreground/80">{payment.notes}</p>
)}
</div>
<Button type="button" size="sm" variant="ghost" className="h-8 px-2.5 text-xs" onClick={() => setEditPayment(payment)}>
Edit
</Button>
</div>
))}
</div>
) : (
<p className="rounded-md border border-dashed border-border/70 px-3 py-8 text-center text-sm text-muted-foreground">
No payments recorded for this month.
</p>
)}
</div>
<form onSubmit={handleAdd} className="rounded-lg border border-border/60 bg-background/45 p-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Add Partial Payment</p>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor={`partial-amount-${row.id}`} className="text-xs uppercase tracking-wider text-muted-foreground">Amount</Label>
<Input
id={`partial-amount-${row.id}`}
type="number"
min="0"
step="0.01"
value={amount}
onChange={e => setAmount(e.target.value)}
className="font-mono bg-background/70 border-border/60"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`partial-date-${row.id}`} className="text-xs uppercase tracking-wider text-muted-foreground">Paid Date</Label>
<Input
id={`partial-date-${row.id}`}
type="date"
value={date}
onChange={e => setDate(e.target.value)}
className="font-mono bg-background/70 border-border/60"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Method</Label>
<Select value={method} onValueChange={setMethod}>
<SelectTrigger className="bg-background/70 border-border/60">
<SelectValue placeholder="—" />
</SelectTrigger>
<SelectContent>
<SelectItem value={METHOD_NONE}></SelectItem>
<SelectItem value="bank">Bank Transfer</SelectItem>
<SelectItem value="card">Card</SelectItem>
<SelectItem value="autopay">Autopay</SelectItem>
<SelectItem value="check">Check</SelectItem>
<SelectItem value="cash">Cash</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor={`partial-notes-${row.id}`} className="text-xs uppercase tracking-wider text-muted-foreground">Notes</Label>
<Input
id={`partial-notes-${row.id}`}
value={notes}
onChange={e => setNotes(e.target.value)}
className="bg-background/70 border-border/60"
/>
</div>
<Button type="submit" disabled={busy} className="w-full gap-2">
<Plus className="h-4 w-4" />
{busy ? 'Adding...' : 'Add Payment'}
</Button>
</div>
</form>
</div>
</div>
</DialogContent>
</Dialog>
{editPayment && (
<PaymentModal
payment={editPayment}
onClose={() => setEditPayment(null)}
onSave={() => {
onSaved?.();
setEditPayment(null);
}}
/>
)}
</>
);
}
// Notes cell (monthly state notes) // Notes cell (monthly state notes)
// Shows the monthly state notes for this bill in the current month. // Shows the monthly state notes for this bill in the current month.
// Notes are per-month, not per-bill - each month has its own notes field. // Notes are per-month, not per-bill - each month has its own notes field.
@ -825,20 +1143,24 @@ function PaymentModal({ payment, onClose, onSave }) {
function Row({ row, year, month, refresh, index, onEditBill }) { function Row({ row, year, month, refresh, index, onEditBill }) {
const amountRef = useRef(null); const amountRef = useRef(null);
const [editPayment, setEditPayment] = useState(null); const [editPayment, setEditPayment] = useState(null);
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
const [showMbs, setShowMbs] = useState(false); const [showMbs, setShowMbs] = useState(false);
const [confirmUnpay, setConfirmUnpay] = useState(false); const [confirmUnpay, setConfirmUnpay] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [suggestionLoading, setSuggestionLoading] = useState(false);
// Effective amount threshold for this bill this month: // Effective amount threshold for this bill this month:
// actual_amount (if set by monthly override) takes priority over the template expected_amount. // actual_amount (if set by monthly override) takes priority over the template expected_amount.
const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount; const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount;
const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day); const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day);
const isSkipped = !!row.is_skipped;
const hasAutopaySuggestion = !!row.autopay_suggestion && !isSkipped;
// Paid when total payments >= effective threshold // Paid when total payments >= effective threshold
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold; const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
const isPaid = row.status === 'paid' || row.status === 'autodraft' || isPaidByThreshold; const isPaid = row.status === 'paid' || (row.status === 'autodraft' && !hasAutopaySuggestion) || isPaidByThreshold;
const summary = paymentSummary(row, threshold);
const isSkipped = !!row.is_skipped;
// Effective status to show: // Effective status to show:
// skipped > paid (threshold-based) > backend status // skipped > paid (threshold-based) > backend status
@ -855,7 +1177,7 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
if (!val || val <= 0) { toast.error('Enter a payment amount'); return; } if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
try { try {
await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate }); await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate });
toast.success('Marked as paid'); toast.success('Payment added');
refresh(); refresh();
} catch (err) { } catch (err) {
toast.error(err.message); toast.error(err.message);
@ -904,6 +1226,32 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
performTogglePaid(); performTogglePaid();
} }
async function handleConfirmSuggestion() {
setSuggestionLoading(true);
try {
const result = await api.confirmAutopaySuggestion(row.id, { year, month });
toast.success(result.created ? 'Autopay payment confirmed' : 'Autopay already recorded');
refresh?.();
} catch (err) {
toast.error(err.message || 'Failed to confirm autopay suggestion');
} finally {
setSuggestionLoading(false);
}
}
async function handleDismissSuggestion() {
setSuggestionLoading(true);
try {
await api.dismissAutopaySuggestion(row.id, { year, month });
toast.success('Autopay suggestion dismissed');
refresh?.();
} catch (err) {
toast.error(err.message || 'Failed to dismiss autopay suggestion');
} finally {
setSuggestionLoading(false);
}
}
return ( return (
<> <>
<TableRow <TableRow
@ -989,24 +1337,24 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
{/* Amount paid — mismatch now compares against threshold */} {/* Amount paid — mismatch now compares against threshold */}
<TableCell className="w-[10%] py-3 text-right"> <TableCell className="w-[10%] py-3 text-right">
<EditableCell <PaymentProgress
row={row} row={row}
field="amount"
threshold={threshold} threshold={threshold}
defaultPaymentDate={defaultPaymentDate} onOpen={() => setPaymentLedgerOpen(true)}
refresh={refresh}
/> />
</TableCell> </TableCell>
{/* Paid date */} {/* Paid date */}
<TableCell className="w-[10%] py-3"> <TableCell className="w-[10%] py-3 text-sm text-muted-foreground">
<EditableCell <button
row={row} type="button"
field="date" onClick={() => setPaymentLedgerOpen(true)}
threshold={threshold} className="rounded-md px-1.5 py-0.5 font-mono transition-colors hover:bg-accent hover:text-foreground"
defaultPaymentDate={defaultPaymentDate} title="View payment history"
refresh={refresh} >
/> {row.last_paid_date ? fmtDate(row.last_paid_date) : '—'}
{summary.count > 1 && <span className="ml-1 text-[10px] text-muted-foreground">({summary.count})</span>}
</button>
</TableCell> </TableCell>
{/* Status — uses effectiveStatus (accounts for skipped + threshold) */} {/* Status — uses effectiveStatus (accounts for skipped + threshold) */}
@ -1025,13 +1373,21 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
{/* Actions */} {/* Actions */}
<TableCell className="w-[10%] py-3 text-right"> <TableCell className="w-[10%] py-3 text-right">
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
{hasAutopaySuggestion && (
<AutopaySuggestionActions
row={row}
loading={suggestionLoading}
onConfirm={handleConfirmSuggestion}
onDismiss={handleDismissSuggestion}
/>
)}
{/* Quick pay — hidden for skipped bills */} {/* Quick pay — hidden for skipped bills */}
{!isPaid && !isSkipped && ( {!isPaid && !isSkipped && !hasAutopaySuggestion && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Input <Input
ref={amountRef} ref={amountRef}
type="number" min="0" step="0.01" type="number" min="0" step="0.01"
defaultValue={threshold} defaultValue={summary.remaining || threshold}
className="h-7 w-20 text-right font-mono text-sm bg-background/50 border-border/50" className="h-7 w-20 text-right font-mono text-sm bg-background/50 border-border/50"
title="Payment amount" title="Payment amount"
/> />
@ -1040,7 +1396,7 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
onClick={handleQuickPay} onClick={handleQuickPay}
className="h-7 px-2.5 text-xs font-semibold text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:text-emerald-400 dark:hover:text-emerald-300 dark:hover:bg-emerald-500/10" className="h-7 px-2.5 text-xs font-semibold text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:text-emerald-400 dark:hover:text-emerald-300 dark:hover:bg-emerald-500/10"
> >
Pay Add
</Button> </Button>
</div> </div>
)} )}
@ -1062,6 +1418,16 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
/> />
)} )}
{paymentLedgerOpen && (
<PaymentLedgerDialog
row={row}
threshold={threshold}
defaultPaymentDate={defaultPaymentDate}
onClose={() => setPaymentLedgerOpen(false)}
onSaved={refresh}
/>
)}
{showMbs && ( {showMbs && (
<MonthlyStateDialog <MonthlyStateDialog
row={row} row={row}
@ -1100,14 +1466,17 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) { function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
const amountRef = useRef(null); const amountRef = useRef(null);
const [editPayment, setEditPayment] = useState(null); const [editPayment, setEditPayment] = useState(null);
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
const [showMbs, setShowMbs] = useState(false); const [showMbs, setShowMbs] = useState(false);
const [confirmUnpay, setConfirmUnpay] = useState(false); const [confirmUnpay, setConfirmUnpay] = useState(false);
const [suggestionLoading, setSuggestionLoading] = useState(false);
const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount; const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount;
const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day); const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day);
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
const isPaid = row.status === 'paid' || row.status === 'autodraft' || isPaidByThreshold;
const isSkipped = !!row.is_skipped; const isSkipped = !!row.is_skipped;
const hasAutopaySuggestion = !!row.autopay_suggestion && !isSkipped;
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
const isPaid = row.status === 'paid' || (row.status === 'autodraft' && !hasAutopaySuggestion) || isPaidByThreshold;
const effectiveStatus = isSkipped const effectiveStatus = isSkipped
? 'skipped' ? 'skipped'
: (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft') : (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft')
@ -1115,13 +1484,14 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
: row.status; : row.status;
const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || ''); const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || '');
const remaining = Math.max((threshold || 0) - (row.total_paid || 0), 0); const remaining = Math.max((threshold || 0) - (row.total_paid || 0), 0);
const summary = paymentSummary(row, threshold);
async function handleQuickPay() { async function handleQuickPay() {
const val = parseFloat(amountRef.current?.value); const val = parseFloat(amountRef.current?.value);
if (!val || val <= 0) { toast.error('Enter a payment amount'); return; } if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
try { try {
await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate }); await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate });
toast.success('Marked as paid'); toast.success('Payment added');
refresh(); refresh();
} catch (err) { } catch (err) {
toast.error(err.message); toast.error(err.message);
@ -1167,6 +1537,32 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
performTogglePaid(); performTogglePaid();
} }
async function handleConfirmSuggestion() {
setSuggestionLoading(true);
try {
const result = await api.confirmAutopaySuggestion(row.id, { year, month });
toast.success(result.created ? 'Autopay payment confirmed' : 'Autopay already recorded');
refresh();
} catch (err) {
toast.error(err.message || 'Failed to confirm autopay suggestion');
} finally {
setSuggestionLoading(false);
}
}
async function handleDismissSuggestion() {
setSuggestionLoading(true);
try {
await api.dismissAutopaySuggestion(row.id, { year, month });
toast.success('Autopay suggestion dismissed');
refresh();
} catch (err) {
toast.error(err.message || 'Failed to dismiss autopay suggestion');
} finally {
setSuggestionLoading(false);
}
}
return ( return (
<> <>
<div <div
@ -1253,6 +1649,10 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
</div> </div>
</div> </div>
<div className="rounded-md border border-border/50 bg-muted/20">
<PaymentProgress row={row} threshold={threshold} onOpen={() => setPaymentLedgerOpen(true)} compact />
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="grid grid-cols-2 gap-2 text-xs sm:flex sm:items-center"> <div className="grid grid-cols-2 gap-2 text-xs sm:flex sm:items-center">
<div className="rounded-md bg-muted/45 px-2 py-1.5"> <div className="rounded-md bg-muted/45 px-2 py-1.5">
@ -1261,17 +1661,33 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
</div> </div>
<div className="rounded-md bg-muted/45 px-2 py-1.5"> <div className="rounded-md bg-muted/45 px-2 py-1.5">
<span className="text-muted-foreground">Date </span> <span className="text-muted-foreground">Date </span>
<span className="font-mono text-foreground">{row.last_paid_date ? fmtDate(row.last_paid_date) : '—'}</span> <button
type="button"
onClick={() => setPaymentLedgerOpen(true)}
className="rounded font-mono text-foreground underline-offset-2 hover:underline"
>
{row.last_paid_date ? fmtDate(row.last_paid_date) : '—'}
{summary.count > 1 && <span className="ml-1 text-[10px] text-muted-foreground">({summary.count})</span>}
</button>
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center justify-end gap-1.5"> <div className="flex flex-wrap items-center justify-end gap-1.5">
{!isPaid && !isSkipped && ( {hasAutopaySuggestion && (
<AutopaySuggestionActions
row={row}
loading={suggestionLoading}
onConfirm={handleConfirmSuggestion}
onDismiss={handleDismissSuggestion}
compact
/>
)}
{!isPaid && !isSkipped && !hasAutopaySuggestion && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Input <Input
ref={amountRef} ref={amountRef}
type="number" min="0" step="0.01" type="number" min="0" step="0.01"
defaultValue={threshold} defaultValue={summary.remaining || threshold}
className="h-8 w-24 text-right font-mono text-sm bg-background/70 border-border/60" className="h-8 w-24 text-right font-mono text-sm bg-background/70 border-border/60"
title="Payment amount" title="Payment amount"
aria-label={`${row.name} payment amount`} aria-label={`${row.name} payment amount`}
@ -1281,7 +1697,7 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
onClick={handleQuickPay} onClick={handleQuickPay}
className="h-8 px-3 text-xs font-semibold" className="h-8 px-3 text-xs font-semibold"
> >
Pay Add
</Button> </Button>
</div> </div>
)} )}
@ -1302,6 +1718,16 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
/> />
)} )}
{paymentLedgerOpen && (
<PaymentLedgerDialog
row={row}
threshold={threshold}
defaultPaymentDate={defaultPaymentDate}
onClose={() => setPaymentLedgerOpen(false)}
onSaved={refresh}
/>
)}
{showMbs && ( {showMbs && (
<MonthlyStateDialog <MonthlyStateDialog
row={row} row={row}
@ -1409,6 +1835,10 @@ function Bucket({ label, rows, year, month, refresh, onEditBill, loading }) {
</div> </div>
</div> </div>
)) ))
) : rows.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/70 bg-background/40 px-4 py-8 text-center text-sm text-muted-foreground">
No bills match this bucket and filter set.
</div>
) : ( ) : (
rows.map((r, i) => ( rows.map((r, i) => (
<MobileTrackerRow <MobileTrackerRow
@ -1469,6 +1899,12 @@ function Bucket({ label, rows, year, month, refresh, onEditBill, loading }) {
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
) : rows.length === 0 ? (
<TableRow className="border-border/50">
<TableCell colSpan={9} className="py-10 text-center text-sm text-muted-foreground">
No bills match this bucket and filter set.
</TableCell>
</TableRow>
) : ( ) : (
rows.map((r, i) => ( rows.map((r, i) => (
<Row <Row
@ -1492,6 +1928,7 @@ function Bucket({ label, rows, year, month, refresh, onEditBill, loading }) {
// Main page // Main page
export default function TrackerPage() { export default function TrackerPage() {
const [searchParams] = useSearchParams();
const now = new Date(); const now = new Date();
const [year, setYear] = useState(now.getFullYear()); const [year, setYear] = useState(now.getFullYear());
const [month, setMonth] = useState(now.getMonth() + 1); const [month, setMonth] = useState(now.getMonth() + 1);
@ -1499,10 +1936,26 @@ export default function TrackerPage() {
const [editBillData, setEditBillData] = useState(null); const [editBillData, setEditBillData] = useState(null);
// Edit Starting Amounts modal: true when open, false when closed // Edit Starting Amounts modal: true when open, false when closed
const [editStartingOpen, setEditStartingOpen] = useState(false); const [editStartingOpen, setEditStartingOpen] = useState(false);
const [search, setSearch] = useState('');
const [filters, setFilters] = useState({
category: FILTER_ALL,
cycle: FILTER_ALL,
autopay: false,
firstBucket: false,
fifteenthBucket: false,
unpaid: false,
overdue: false,
debt: false,
});
// Use React Query for data fetching // Use React Query for data fetching
const { data, isLoading: loading, isError, error, refetch } = useTracker(year, month); const { data, isLoading: loading, isError, error, refetch } = useTracker(year, month);
useEffect(() => {
const querySearch = searchParams.get('search') || '';
if (querySearch) setSearch(querySearch);
}, [searchParams]);
function navigate(delta) { function navigate(delta) {
setMonth(m => { setMonth(m => {
const nm = m + delta; const nm = m + delta;
@ -1542,8 +1995,71 @@ export default function TrackerPage() {
const rows = data?.rows || []; const rows = data?.rows || [];
const summary = data?.summary || {}; const summary = data?.summary || {};
const first = rows.filter(r => r.bucket === '1st'); const toggleFilter = (key) => setFilters(prev => ({ ...prev, [key]: !prev[key] }));
const second = rows.filter(r => r.bucket === '15th'); const setFilterValue = (key, value) => setFilters(prev => ({ ...prev, [key]: value }));
const hasFilters = !!(
search.trim()
|| filters.category !== FILTER_ALL
|| filters.cycle !== FILTER_ALL
|| filters.autopay
|| filters.firstBucket
|| filters.fifteenthBucket
|| filters.unpaid
|| filters.overdue
|| filters.debt
);
const resetFilters = () => {
setSearch('');
setFilters({
category: FILTER_ALL,
cycle: FILTER_ALL,
autopay: false,
firstBucket: false,
fifteenthBucket: false,
unpaid: false,
overdue: false,
debt: false,
});
};
const categoryOptions = useMemo(() => {
const map = new Map();
rows.forEach(row => {
if (row.category_id && row.category_name) map.set(String(row.category_id), row.category_name);
});
return Array.from(map, ([id, name]) => ({ id, name })).sort((a, b) => a.name.localeCompare(b.name));
}, [rows]);
const cycleOptions = useMemo(() => (
Array.from(new Set(rows.map(row => row.billing_cycle || 'monthly'))).sort()
), [rows]);
const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase();
return rows.filter(row => {
const effectiveStatus = rowEffectiveStatus(row);
if (filters.category !== FILTER_ALL && String(row.category_id ?? '') !== filters.category) return false;
if (filters.cycle !== FILTER_ALL && String(row.billing_cycle || 'monthly') !== filters.cycle) return false;
if (filters.autopay && !row.autopay_enabled) return false;
if (filters.debt && !rowIsDebt(row)) return false;
if (filters.unpaid && (row.is_skipped || rowIsPaid(row))) return false;
if (filters.overdue && !(effectiveStatus === 'late' || effectiveStatus === 'missed')) return false;
if (filters.firstBucket && !filters.fifteenthBucket && row.bucket !== '1st') return false;
if (filters.fifteenthBucket && !filters.firstBucket && row.bucket !== '15th') return false;
if (!q) return true;
const haystack = [
row.name,
row.category_name,
row.notes,
row.monthly_notes,
row.billing_cycle,
row.bucket,
row.status,
amountSearchText(row.expected_amount, row.actual_amount, row.total_paid, row.balance, row.current_balance, row.minimum_payment),
].filter(Boolean).join(' ').toLowerCase();
return haystack.includes(q);
});
}, [filters, rows, search]);
const first = filteredRows.filter(r => r.bucket === '1st');
const second = filteredRows.filter(r => r.bucket === '15th');
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@ -1588,6 +2104,63 @@ export default function TrackerPage() {
</div> </div>
</div> </div>
<div className="rounded-xl border border-border bg-card p-4 space-y-3">
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_220px_180px_auto] lg:items-center">
<label className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search this month by bill, category, notes, or amount"
className="h-10 pl-9"
/>
</label>
<Select value={filters.category} onValueChange={value => setFilterValue('category', value)}>
<SelectTrigger className="h-10">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value={FILTER_ALL}>All categories</SelectItem>
{categoryOptions.map(category => (
<SelectItem key={category.id} value={category.id}>{category.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filters.cycle} onValueChange={value => setFilterValue('cycle', value)}>
<SelectTrigger className="h-10 capitalize">
<SelectValue placeholder="Billing cycle" />
</SelectTrigger>
<SelectContent>
<SelectItem value={FILTER_ALL}>All cycles</SelectItem>
{cycleOptions.map(cycle => (
<SelectItem key={cycle} value={cycle}>{cycle.replace(/_/g, ' ')}</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
disabled={!hasFilters}
onClick={resetFilters}
className="h-10 justify-center gap-2 text-xs"
>
<X className="h-3.5 w-3.5" />
Clear
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<FilterChip active={filters.unpaid} onClick={() => toggleFilter('unpaid')}>Unpaid</FilterChip>
<FilterChip active={filters.overdue} onClick={() => toggleFilter('overdue')}>Overdue</FilterChip>
<FilterChip active={filters.autopay} onClick={() => toggleFilter('autopay')}>Autopay</FilterChip>
<FilterChip active={filters.firstBucket} onClick={() => toggleFilter('firstBucket')}>1st bucket</FilterChip>
<FilterChip active={filters.fifteenthBucket} onClick={() => toggleFilter('fifteenthBucket')}>15th bucket</FilterChip>
<FilterChip active={filters.debt} onClick={() => toggleFilter('debt')}>Debt</FilterChip>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{filteredRows.length} of {rows.length} shown
</span>
</div>
</div>
{/* ── Summary cards (backend already excludes skipped from totals) ── */} {/* ── Summary cards (backend already excludes skipped from totals) ── */}
{loading ? ( {loading ? (
<div className="grid grid-cols-2 gap-3 lg:flex" aria-busy="true"> <div className="grid grid-cols-2 gap-3 lg:flex" aria-busy="true">
@ -1671,10 +2244,17 @@ export default function TrackerPage() {
{/* Edit Bill modal — opened by clicking a bill name in any tracker row */} {/* Edit Bill modal — opened by clicking a bill name in any tracker row */}
{editBillData && ( {editBillData && (
<BillModal <BillModal
key={editBillData.bill?.id ? `edit-${editBillData.bill.id}` : `new-${editBillData.initialBill?.name || 'blank'}`}
bill={editBillData.bill} bill={editBillData.bill}
initialBill={editBillData.initialBill}
categories={editBillData.categories} categories={editBillData.categories}
onClose={() => setEditBillData(null)} onClose={() => setEditBillData(null)}
onSave={() => { setEditBillData(null); refetch(); }} onSave={() => { setEditBillData(null); refetch(); }}
onDuplicate={bill => setEditBillData({
bill: null,
initialBill: makeBillDraft(bill, { copy: true, categories: editBillData.categories }),
categories: editBillData.categories,
})}
/> />
)} )}

View File

@ -796,6 +796,58 @@ function reconcileLegacyMigrations() {
} }
console.log('[migration] bills/categories deleted_at columns added'); console.log('[migration] bills/categories deleted_at columns added');
} }
},
{
version: 'v0.57',
description: 'autopay: suggestions and auto-mark paid',
check: function() {
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
const hasDismissals = !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='autopay_suggestion_dismissals'").get();
return billCols.includes('auto_mark_paid') && hasDismissals;
},
run: function() {
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
if (!billCols.includes('auto_mark_paid')) {
db.exec('ALTER TABLE bills ADD COLUMN auto_mark_paid INTEGER NOT NULL DEFAULT 0');
}
db.exec(`
CREATE TABLE IF NOT EXISTS autopay_suggestion_dismissals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
dismissed_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, bill_id, year, month)
);
CREATE INDEX IF NOT EXISTS idx_autopay_suggestion_dismissals_user_month
ON autopay_suggestion_dismissals(user_id, year, month);
`);
console.log('[migration] autopay auto_mark_paid and suggestion dismissals ensured');
}
},
{
version: 'v0.58',
description: 'bills: saved bill templates',
check: function() {
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='bill_templates'").get();
},
run: function() {
db.exec(`
CREATE TABLE IF NOT EXISTS bill_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
data TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(user_id, name)
);
CREATE INDEX IF NOT EXISTS idx_bill_templates_user_name
ON bill_templates(user_id, name);
`);
console.log('[migration] bill_templates table ensured');
}
} }
]; ];
@ -1441,6 +1493,52 @@ function runMigrations() {
} }
console.log('[migration] bills/categories deleted_at columns added'); console.log('[migration] bills/categories deleted_at columns added');
} }
},
{
version: 'v0.57',
description: 'autopay: suggestions and auto-mark paid',
dependsOn: ['v0.56'],
run: function() {
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
if (!billCols.includes('auto_mark_paid')) {
db.exec('ALTER TABLE bills ADD COLUMN auto_mark_paid INTEGER NOT NULL DEFAULT 0');
}
db.exec(`
CREATE TABLE IF NOT EXISTS autopay_suggestion_dismissals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
dismissed_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, bill_id, year, month)
);
CREATE INDEX IF NOT EXISTS idx_autopay_suggestion_dismissals_user_month
ON autopay_suggestion_dismissals(user_id, year, month);
`);
console.log('[migration] autopay auto_mark_paid and suggestion dismissals ensured');
}
},
{
version: 'v0.58',
description: 'bills: saved bill templates',
dependsOn: ['v0.57'],
run: function() {
db.exec(`
CREATE TABLE IF NOT EXISTS bill_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
data TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(user_id, name)
);
CREATE INDEX IF NOT EXISTS idx_bill_templates_user_name
ON bill_templates(user_id, name);
`);
console.log('[migration] bill_templates table ensured');
}
} }
]; ];
@ -1861,6 +1959,21 @@ const ROLLBACK_SQL_MAP = {
'ALTER TABLE categories DROP COLUMN deleted_at', 'ALTER TABLE categories DROP COLUMN deleted_at',
'ALTER TABLE bills DROP COLUMN deleted_at', 'ALTER TABLE bills DROP COLUMN deleted_at',
] ]
},
'v0.57': {
description: 'autopay suggestions and auto-mark paid',
sql: [
'DROP INDEX IF EXISTS idx_autopay_suggestion_dismissals_user_month',
'DROP TABLE IF EXISTS autopay_suggestion_dismissals',
'ALTER TABLE bills DROP COLUMN auto_mark_paid',
]
},
'v0.58': {
description: 'saved bill templates',
sql: [
'DROP INDEX IF EXISTS idx_bill_templates_user_name',
'DROP TABLE IF EXISTS bill_templates',
]
} }
}; };

View File

@ -23,6 +23,7 @@ CREATE TABLE IF NOT EXISTS bills (
billing_cycle TEXT DEFAULT 'monthly' CHECK(billing_cycle IN ('monthly', 'quarterly', 'annually', 'irregular')), billing_cycle TEXT DEFAULT 'monthly' CHECK(billing_cycle IN ('monthly', 'quarterly', 'annually', 'irregular')),
autopay_enabled INTEGER NOT NULL DEFAULT 0, autopay_enabled INTEGER NOT NULL DEFAULT 0,
autodraft_status TEXT NOT NULL DEFAULT 'none' CHECK(autodraft_status IN ('none', 'pending', 'assumed_paid', 'confirmed')), autodraft_status TEXT NOT NULL DEFAULT 'none' CHECK(autodraft_status IN ('none', 'pending', 'assumed_paid', 'confirmed')),
auto_mark_paid INTEGER NOT NULL DEFAULT 0,
website TEXT, website TEXT,
username TEXT, username TEXT,
account_info TEXT, account_info TEXT,
@ -131,3 +132,29 @@ CREATE TABLE IF NOT EXISTS monthly_bill_state (
CREATE INDEX IF NOT EXISTS idx_monthly_bill_state_lookup CREATE INDEX IF NOT EXISTS idx_monthly_bill_state_lookup
ON monthly_bill_state(bill_id, year, month); ON monthly_bill_state(bill_id, year, month);
CREATE TABLE IF NOT EXISTS autopay_suggestion_dismissals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
dismissed_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, bill_id, year, month)
);
CREATE INDEX IF NOT EXISTS idx_autopay_suggestion_dismissals_user_month
ON autopay_suggestion_dismissals(user_id, year, month);
CREATE TABLE IF NOT EXISTS bill_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
data TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(user_id, name)
);
CREATE INDEX IF NOT EXISTS idx_bill_templates_user_name
ON bill_templates(user_id, name);

View File

@ -1,6 +1,6 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { getDb, getSetting, setSetting, rollbackMigration } = require('../db/database'); const { getDb, rollbackMigration } = require('../db/database');
const { hashPassword } = require('../services/authService'); const { hashPassword } = require('../services/authService');
const { const {
createBackup, createBackup,
@ -351,132 +351,12 @@ router.post('/cleanup/run', backupOperationLimiter, async (req, res) => {
// ── Auth-mode helpers ───────────────────────────────────────────────────────── // ── Auth-mode helpers ─────────────────────────────────────────────────────────
const { const {
getAdminOidcSettings, applyAuthModeSettings,
getOidcConfigStatus, buildAuthModeStatus,
invalidateClientCache, buildSubmittedOidcConfig,
testOidcConfiguration, testOidcConfiguration,
} = require('../services/oidcService'); } = require('../services/oidcService');
function trimOrEmpty(value) {
if (value === undefined || value === null) return '';
return String(value).trim();
}
function boolSetting(value, fallback) {
if (value === undefined) return fallback;
if (typeof value === 'string') return value === 'true';
return !!value;
}
function computeSubmittedOidcConfigured(body) {
const current = getAdminOidcSettings();
const next = {
issuer: body.oidc_issuer_url !== undefined
? trimOrEmpty(body.oidc_issuer_url)
: current.oidc_issuer_url,
clientId: body.oidc_client_id !== undefined
? trimOrEmpty(body.oidc_client_id)
: current.oidc_client_id,
redirectUri: body.oidc_redirect_uri !== undefined
? trimOrEmpty(body.oidc_redirect_uri)
: current.oidc_redirect_uri,
clientSecret: current.oidc_client_secret_set ? 'set' : '',
};
if (body.oidc_client_secret_clear === true) {
next.clientSecret = process.env.OIDC_CLIENT_SECRET ? 'set' : '';
}
if (trimOrEmpty(body.oidc_client_secret)) {
next.clientSecret = 'set';
}
return !!(next.issuer && next.clientId && next.clientSecret && next.redirectUri);
}
function buildSubmittedOidcConfig(body) {
const current = getAdminOidcSettings();
const status = getOidcConfigStatus();
const issuerUrl = body.oidc_issuer_url !== undefined
? trimOrEmpty(body.oidc_issuer_url)
: current.oidc_issuer_url;
const clientId = body.oidc_client_id !== undefined
? trimOrEmpty(body.oidc_client_id)
: current.oidc_client_id;
const redirectUri = body.oidc_redirect_uri !== undefined
? trimOrEmpty(body.oidc_redirect_uri)
: current.oidc_redirect_uri;
const tokenAuthMethod = body.oidc_token_auth_method !== undefined
? trimOrEmpty(body.oidc_token_auth_method)
: current.oidc_token_auth_method;
const scopes = body.oidc_scopes !== undefined
? trimOrEmpty(body.oidc_scopes)
: current.oidc_scopes;
const providerName = body.oidc_provider_name !== undefined
? trimOrEmpty(body.oidc_provider_name)
: current.oidc_provider_name;
let clientSecret = status.oidc_client_secret_set ? '__saved__' : '';
if (body.oidc_client_secret_clear === true) clientSecret = process.env.OIDC_CLIENT_SECRET || '';
if (trimOrEmpty(body.oidc_client_secret)) clientSecret = trimOrEmpty(body.oidc_client_secret);
if (!issuerUrl || !clientId || !clientSecret || !redirectUri) return null;
return {
enabled: true,
issuerUrl,
clientId,
clientSecret: clientSecret === '__saved__'
? (getSetting('oidc_client_secret') || process.env.OIDC_CLIENT_SECRET || '')
: clientSecret,
tokenEndpointAuthMethod: tokenAuthMethod === 'client_secret_post'
? 'client_secret_post'
: 'client_secret_basic',
redirectUri,
scopes: (scopes || 'openid email profile groups').split(/\s+/).filter(Boolean),
adminGroup: body.oidc_admin_group !== undefined ? trimOrEmpty(body.oidc_admin_group) : current.oidc_admin_group,
defaultRole: 'user',
autoProvision: body.oidc_auto_provision !== undefined ? !!body.oidc_auto_provision : current.oidc_auto_provision,
providerName: providerName || 'authentik',
};
}
function buildAuthModeStatus() {
const oidcConfigured = getOidcConfigStatus().oidc_configured;
const localEnabled = getSetting('local_login_enabled') !== 'false';
const oidcEnabled = getSetting('oidc_login_enabled') === 'true';
const oidcAdminGroup = getAdminOidcSettings().oidc_admin_group;
// Disabling local is only safe if OIDC is configured, enabled, and has an admin path.
const canDisableLocal = oidcConfigured && oidcEnabled && !!oidcAdminGroup;
const warnings = [];
if (!localEnabled && !oidcConfigured) {
warnings.push('Local login is disabled but OIDC is not configured; users may be locked out.');
}
if (!localEnabled && !oidcEnabled) {
warnings.push('No login method is enabled. Re-enable local login or configure OIDC.');
}
if (oidcEnabled && !oidcConfigured) {
warnings.push('authentik/OIDC login is enabled but configuration is incomplete, so the login button will stay hidden.');
}
if (!localEnabled && !oidcAdminGroup) {
warnings.push('Local login is disabled but no OIDC admin group is configured.');
}
return {
auth_mode: getSetting('auth_mode') || 'multi',
default_user_id: getSetting('default_user_id') || null,
local_login_enabled: localEnabled,
oidc_login_enabled: oidcEnabled,
oidc_configured: oidcConfigured,
...getOidcConfigStatus(),
...getAdminOidcSettings(),
can_disable_local: canDisableLocal,
warnings,
};
}
// GET /api/admin/auth-mode // GET /api/admin/auth-mode
router.get('/auth-mode', (req, res) => { router.get('/auth-mode', (req, res) => {
res.json(buildAuthModeStatus()); res.json(buildAuthModeStatus());
@ -495,106 +375,11 @@ router.post('/auth-mode/oidc-test', async (req, res) => {
// Accepts legacy auth_mode/default_user_id fields plus new auth method settings. // Accepts legacy auth_mode/default_user_id fields plus new auth method settings.
// Validates lockout protection before saving. // Validates lockout protection before saving.
router.put('/auth-mode', (req, res) => { router.put('/auth-mode', (req, res) => {
const { try {
auth_mode, default_user_id, res.json(applyAuthModeSettings(req.body || {}));
local_login_enabled, oidc_login_enabled, oidc_enabled, } catch (err) {
oidc_provider_name, oidc_issuer_url, oidc_client_id, oidc_client_secret, res.status(err.status || 500).json({ error: err.status ? err.message : 'Failed to update authentication settings' });
oidc_client_secret_clear, oidc_token_auth_method, oidc_redirect_uri, oidc_scopes,
oidc_auto_provision, oidc_admin_group, oidc_default_role,
} = req.body;
// ── Legacy single/multi mode (unchanged behavior) ─────────────────────────
if (auth_mode !== undefined) {
if (!['multi', 'single'].includes(auth_mode))
return res.status(400).json({ error: 'auth_mode must be "multi" or "single"' });
if (auth_mode === 'single') {
if (!default_user_id) return res.status(400).json({ error: 'default_user_id is required for single mode' });
const u = getDb().prepare("SELECT id FROM users WHERE id=? AND role='user'").get(default_user_id);
if (!u) return res.status(404).json({ error: 'User not found or not a regular user' });
setSetting('default_user_id', default_user_id);
} }
setSetting('auth_mode', auth_mode);
}
// ── Auth method toggles ───────────────────────────────────────────────────
const oidcConfigured = computeSubmittedOidcConfigured(req.body || {});
const nextLocal = boolSetting(local_login_enabled, getSetting('local_login_enabled') !== 'false');
const requestedOidc = oidc_login_enabled !== undefined ? oidc_login_enabled : oidc_enabled;
const nextOidc = boolSetting(requestedOidc, getSetting('oidc_login_enabled') === 'true');
const nextAdminGroup = oidc_admin_group !== undefined
? trimOrEmpty(oidc_admin_group)
: getAdminOidcSettings().oidc_admin_group;
// Lockout protection: cannot disable both login methods
if (!nextLocal && !nextOidc) {
return res.status(400).json({ error: 'Cannot disable all login methods. At least one must remain enabled.' });
}
// Lockout protection: cannot disable local login unless OIDC has a working admin path.
if (!nextLocal && !oidcConfigured) {
return res.status(400).json({
error: 'Cannot disable local login until authentik/OIDC is fully configured.',
});
}
if (!nextLocal && !nextOidc) {
return res.status(400).json({
error: 'Cannot disable local login without OIDC login enabled.',
});
}
if (!nextLocal && !nextAdminGroup) {
return res.status(400).json({
error: 'Cannot disable local login until an OIDC admin group is configured.',
});
}
// Cannot enable OIDC login if required provider settings are incomplete
if (nextOidc && !oidcConfigured) {
return res.status(400).json({
error: 'Cannot enable OIDC login until issuer URL, client ID, client secret, and redirect URI are configured.',
});
}
if (local_login_enabled !== undefined) setSetting('local_login_enabled', nextLocal ? 'true' : 'false');
if (oidc_login_enabled !== undefined) setSetting('oidc_login_enabled', nextOidc ? 'true' : 'false');
// OIDC provider settings. Client secret is write-only from the Admin API.
if (oidc_provider_name !== undefined) {
const name = String(oidc_provider_name).slice(0, 100).trim();
if (name) setSetting('oidc_provider_name', name);
}
if (oidc_issuer_url !== undefined) setSetting('oidc_issuer_url', trimOrEmpty(oidc_issuer_url).slice(0, 500));
if (oidc_client_id !== undefined) setSetting('oidc_client_id', trimOrEmpty(oidc_client_id).slice(0, 500));
if (oidc_token_auth_method !== undefined) {
const method = oidc_token_auth_method === 'client_secret_post' ? 'client_secret_post' : 'client_secret_basic';
setSetting('oidc_token_auth_method', method);
}
if (oidc_redirect_uri !== undefined) setSetting('oidc_redirect_uri', trimOrEmpty(oidc_redirect_uri).slice(0, 500));
if (oidc_scopes !== undefined) {
const scopes = trimOrEmpty(oidc_scopes).split(/\s+/).filter(Boolean).join(' ') || 'openid email profile groups';
setSetting('oidc_scopes', scopes.slice(0, 500));
}
if (oidc_client_secret_clear === true) setSetting('oidc_client_secret', '');
if (trimOrEmpty(oidc_client_secret)) {
setSetting('oidc_client_secret', trimOrEmpty(oidc_client_secret).slice(0, 1000));
}
if (oidc_auto_provision !== undefined) setSetting('oidc_auto_provision', !!oidc_auto_provision ? 'true' : 'false');
if (oidc_admin_group !== undefined) setSetting('oidc_admin_group', String(oidc_admin_group).slice(0, 200).trim());
if (oidc_default_role !== undefined) {
setSetting('oidc_default_role', 'user');
}
if (
oidc_issuer_url !== undefined ||
oidc_client_id !== undefined ||
oidc_client_secret !== undefined ||
oidc_client_secret_clear === true ||
oidc_token_auth_method !== undefined ||
oidc_redirect_uri !== undefined
) {
invalidateClientCache();
}
res.json({ success: true, ...buildAuthModeStatus() });
}); });
// ── Migration Rollback ──────────────────────────────────────────────────────── // ── Migration Rollback ────────────────────────────────────────────────────────

View File

@ -1,288 +1,12 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { getDb } = require('../db/database');
const { standardizeError } = require('../middleware/errorFormatter'); const { standardizeError } = require('../middleware/errorFormatter');
const { getAnalyticsSummary } = require('../services/analyticsService');
function parseInteger(value, fallback) {
if (value === undefined || value === null || value === '') return fallback;
const parsed = Number(value);
return Number.isInteger(parsed) ? parsed : NaN;
}
function monthKey(year, month) {
return `${year}-${String(month).padStart(2, '0')}`;
}
function monthLabel(year, month) {
return new Date(Date.UTC(year, month - 1, 1)).toLocaleString('en-US', {
month: 'short',
year: '2-digit',
timeZone: 'UTC',
});
}
function addMonths(year, month, delta) {
const date = new Date(Date.UTC(year, month - 1 + delta, 1));
return { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 };
}
function monthEndDate(year, month) {
const day = new Date(Date.UTC(year, month, 0)).getUTCDate();
return `${monthKey(year, month)}-${String(day).padStart(2, '0')}`;
}
function buildMonths(endYear, endMonth, count) {
return Array.from({ length: count }, (_, index) => {
const value = addMonths(endYear, endMonth, index - count + 1);
return {
...value,
key: monthKey(value.year, value.month),
label: monthLabel(value.year, value.month),
start: `${monthKey(value.year, value.month)}-01`,
end: monthEndDate(value.year, value.month),
};
});
}
function validateSummaryQuery(query) {
const now = new Date();
const year = parseInteger(query.year, now.getFullYear());
const month = parseInteger(query.month, now.getMonth() + 1);
const months = parseInteger(query.months, 12);
const categoryId = parseInteger(query.category_id, null);
const billId = parseInteger(query.bill_id, null);
const includeInactive = query.include_inactive === 'true';
const includeSkipped = query.include_skipped !== 'false';
if (!Number.isInteger(year) || year < 2000 || year > 2100) {
return { error: 'year must be a 4-digit integer between 2000 and 2100' };
}
if (!Number.isInteger(month) || month < 1 || month > 12) {
return { error: 'month must be an integer between 1 and 12' };
}
if (!Number.isInteger(months) || months < 1 || months > 36) {
return { error: 'months must be an integer between 1 and 36' };
}
if (categoryId !== null && (!Number.isInteger(categoryId) || categoryId < 1)) {
return { error: 'category_id must be a positive integer' };
}
if (billId !== null && (!Number.isInteger(billId) || billId < 1)) {
return { error: 'bill_id must be a positive integer' };
}
return { year, month, months, categoryId, billId, includeInactive, includeSkipped };
}
function isMonthInPast(year, month) {
const now = new Date();
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const targetMonthStart = new Date(year, month - 1, 1);
return targetMonthStart < currentMonthStart;
}
function buildBillWhere({ userId, categoryId, billId, includeInactive }) {
const clauses = ['b.user_id = ?', 'b.deleted_at IS NULL'];
const params = [userId];
if (!includeInactive) clauses.push('b.active = 1');
if (categoryId) {
clauses.push('b.category_id = ?');
params.push(categoryId);
}
if (billId) {
clauses.push('b.id = ?');
params.push(billId);
}
return { where: clauses.join(' AND '), params };
}
router.get('/summary', (req, res) => { router.get('/summary', (req, res) => {
const parsed = validateSummaryQuery(req.query); const result = getAnalyticsSummary(req.user.id, req.query);
if (parsed.error) return res.status(400).json(standardizeError(parsed.error, 'VALIDATION_ERROR', 'month')); if (result.error) return res.status(400).json(standardizeError(result.error, 'VALIDATION_ERROR', 'month'));
res.json(result);
const db = getDb();
const userId = req.user.id;
const rangeMonths = buildMonths(parsed.year, parsed.month, parsed.months);
const startDate = rangeMonths[0].start;
const endDate = rangeMonths[rangeMonths.length - 1].end;
const billWhere = buildBillWhere({ ...parsed, userId });
const categories = db.prepare(`
SELECT id, name
FROM categories
WHERE user_id = ?
AND deleted_at IS NULL
ORDER BY name COLLATE NOCASE
`).all(userId);
const bills = db.prepare(`
SELECT b.id, b.name, b.category_id, b.expected_amount, b.active, b.created_at,
c.name AS category_name
FROM bills b
LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id AND c.deleted_at IS NULL
WHERE ${billWhere.where}
ORDER BY b.name COLLATE NOCASE
`).all(...billWhere.params);
if (!bills.length) {
return res.json({
range: { year: parsed.year, month: parsed.month, months: parsed.months, start: startDate, end: endDate },
filters: {
category_id: parsed.categoryId,
bill_id: parsed.billId,
include_inactive: parsed.includeInactive,
include_skipped: parsed.includeSkipped,
},
categories,
bills: [],
monthly_spending: [],
expected_vs_actual: [],
category_spend: [],
heatmap: { months: rangeMonths.map(({ key, label, year, month }) => ({ key, label, year, month })), rows: [] },
generated_at: new Date().toISOString(),
});
}
const billIds = bills.map(b => b.id);
const placeholders = billIds.map(() => '?').join(',');
// Batch fetch all payments for the date range
let paymentRows = [];
if (billIds.length > 0) {
paymentRows = db.prepare(`
SELECT p.bill_id,
substr(p.paid_date, 1, 7) AS month_key,
SUM(p.amount) AS total
FROM payments p
JOIN bills b ON b.id = p.bill_id
WHERE b.user_id = ?
AND b.deleted_at IS NULL
AND p.bill_id IN (${placeholders})
AND p.paid_date BETWEEN ? AND ?
AND p.deleted_at IS NULL
GROUP BY p.bill_id, substr(p.paid_date, 1, 7)
`).all(userId, ...billIds, startDate, endDate);
}
// Batch fetch all monthly bill states for the date range
let stateRows = [];
if (billIds.length > 0) {
stateRows = db.prepare(`
SELECT m.bill_id, m.year, m.month, m.actual_amount, m.is_skipped
FROM monthly_bill_state m
JOIN bills b ON b.id = m.bill_id
WHERE b.user_id = ?
AND b.deleted_at IS NULL
AND m.bill_id IN (${placeholders})
AND (m.year * 100 + m.month) BETWEEN ? AND ?
`).all(
userId,
...billIds,
rangeMonths[0].year * 100 + rangeMonths[0].month,
rangeMonths[rangeMonths.length - 1].year * 100 + rangeMonths[rangeMonths.length - 1].month,
);
}
const paymentByBillMonth = new Map(paymentRows.map(row => [`${row.bill_id}:${row.month_key}`, Number(row.total) || 0]));
const stateByBillMonth = new Map(stateRows.map(row => [`${row.bill_id}:${monthKey(row.year, row.month)}`, row]));
const monthly_spending = rangeMonths.map(m => {
const total = bills.reduce((sum, bill) => sum + (paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0), 0);
return { month: m.key, label: m.label, total: Number(total.toFixed(2)) };
}).filter(row => row.total > 0);
const expected_vs_actual = rangeMonths.map(m => {
let expected = 0;
let actual = 0;
let skipped_count = 0;
for (const bill of bills) {
const state = stateByBillMonth.get(`${bill.id}:${m.key}`);
const skipped = !!state?.is_skipped;
if (skipped) skipped_count += 1;
if (!skipped || parsed.includeSkipped) {
actual += paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0;
}
if (!skipped) {
expected += state?.actual_amount ?? bill.expected_amount ?? 0;
}
}
return {
month: m.key,
label: m.label,
expected: Number(expected.toFixed(2)),
actual: Number(actual.toFixed(2)),
skipped_count,
};
}).filter(row => row.expected > 0 || row.actual > 0 || row.skipped_count > 0);
const categoryMap = new Map();
for (const bill of bills) {
const categoryId = bill.category_id || null;
const key = categoryId == null ? 'uncategorized' : String(categoryId);
const existing = categoryMap.get(key) || {
category_id: categoryId,
category_name: bill.category_name || 'Uncategorized',
total: 0,
};
for (const m of rangeMonths) {
existing.total += paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0;
}
categoryMap.set(key, existing);
}
const category_spend = Array.from(categoryMap.values())
.map(row => ({ ...row, total: Number(row.total.toFixed(2)) }))
.filter(row => row.total > 0)
.sort((a, b) => b.total - a.total);
const heatmapRows = bills.map(bill => {
const cells = rangeMonths.map(m => {
const paid = (paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0) > 0;
const state = stateByBillMonth.get(`${bill.id}:${m.key}`);
const skipped = !!state?.is_skipped;
let status = 'no_data';
if (skipped) status = 'skipped';
else if (paid) status = 'paid';
else if (isMonthInPast(m.year, m.month)) status = 'missed';
return {
month: m.key,
label: m.label,
status,
amount_paid: Number((paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0).toFixed(2)),
};
});
return {
bill_id: bill.id,
bill_name: bill.name,
category_name: bill.category_name || 'Uncategorized',
active: !!bill.active,
cells: parsed.includeSkipped ? cells : cells.filter(cell => cell.status !== 'skipped'),
};
});
res.json({
range: { year: parsed.year, month: parsed.month, months: parsed.months, start: startDate, end: endDate },
filters: {
category_id: parsed.categoryId,
bill_id: parsed.billId,
include_inactive: parsed.includeInactive,
include_skipped: parsed.includeSkipped,
},
categories,
bills: bills.map(b => ({
id: b.id,
name: b.name,
category_id: b.category_id,
category_name: b.category_name || 'Uncategorized',
active: !!b.active,
})),
monthly_spending,
expected_vs_actual,
category_spend,
heatmap: {
months: rangeMonths.map(({ key, label, year, month }) => ({ key, label, year, month })),
rows: heatmapRows,
},
generated_at: new Date().toISOString(),
});
}); });
module.exports = router; module.exports = router;

View File

@ -1,32 +1,19 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { getDb, ensureUserDefaultCategories } = require('../db/database'); const { getDb, ensureUserDefaultCategories } = require('../db/database');
const { VALID_VISIBILITY, getValidCycleTypes, parseDueDay, parseInterestRate, validateCycleDay, validateBillData, computeBalanceDelta } = require('../services/billsService'); const {
auditBillsForUser,
categoryBelongsToUser,
insertBill,
parseTemplateData,
sanitizeTemplateData,
validateBillData,
computeBalanceDelta,
} = require('../services/billsService');
const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService'); const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService');
const { standardizeError } = require('../middleware/errorFormatter'); const { standardizeError } = require('../middleware/errorFormatter');
const { validatePaymentInput } = require('../services/paymentValidation'); const { validatePaymentInput } = require('../services/paymentValidation');
function hasText(value) {
return typeof value === 'string' && value.trim().length > 0;
}
function isDebtBill(bill) {
const category = String(bill.category_name || '').toLowerCase();
return Number(bill.current_balance) > 0
|| bill.minimum_payment != null
|| ['credit card', 'credit cards', 'loan', 'loans', 'debt'].some(token => category.includes(token));
}
function issue(bill, field, severity, suggestion) {
return {
bill_id: bill.id,
bill_name: bill.name,
field,
severity,
suggestion,
};
}
// ── GET /api/bills ──────────────────────────────────────────────────────────── // ── GET /api/bills ────────────────────────────────────────────────────────────
router.get('/', (req, res) => { router.get('/', (req, res) => {
const db = getDb(); const db = getDb();
@ -52,61 +39,106 @@ router.get('/audit', (req, res) => {
const db = getDb(); const db = getDb();
ensureUserDefaultCategories(req.user.id); ensureUserDefaultCategories(req.user.id);
const includeInactive = req.query.inactive === 'true'; const includeInactive = req.query.inactive === 'true';
const bills = db.prepare(` res.json(auditBillsForUser(db, req.user.id, includeInactive));
SELECT b.id, b.name, b.category_id, b.due_day, b.active, b.autopay_enabled, });
b.website, b.username, b.account_info, b.current_balance,
b.minimum_payment, b.interest_rate, c.name AS category_name // ── GET /api/bills/templates ─────────────────────────────────────────────────
FROM bills b router.get('/templates', (req, res) => {
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL const db = getDb();
WHERE b.user_id = ? const rows = db.prepare(`
AND b.deleted_at IS NULL SELECT id, name, data, created_at, updated_at
${includeInactive ? '' : 'AND b.active = 1'} FROM bill_templates
ORDER BY b.active DESC, b.due_day ASC, b.name ASC WHERE user_id = ?
ORDER BY name COLLATE NOCASE ASC
`).all(req.user.id); `).all(req.user.id);
const auditedBills = bills.map((bill) => { res.json(rows.map(row => ({
const issues = []; ...row,
const dueDay = Number(bill.due_day); data: parseTemplateData(row.data),
const debt = isDebtBill(bill); })));
const balance = Number(bill.current_balance); });
if (!Number.isInteger(dueDay) || dueDay < 1 || dueDay > 31) { // ── POST /api/bills/templates ────────────────────────────────────────────────
issues.push(issue(bill, 'due_day', 'error', 'Add a due day between 1 and 31 so tracker periods and reminders can place this bill correctly.')); router.post('/templates', (req, res) => {
} const db = getDb();
if (!bill.category_id || !bill.category_name) { const name = String(req.body.name || '').trim();
issues.push(issue(bill, 'category_id', 'warning', 'Choose a category so summaries, filters, and snowball debt detection stay accurate.')); if (name.length < 2) {
} return res.status(400).json(standardizeError('Template name must be at least 2 characters', 'VALIDATION_ERROR', 'name'));
if (debt && !(Number(bill.minimum_payment) > 0)) {
issues.push(issue(bill, 'minimum_payment', 'error', 'Add the required minimum payment so debt snowball projections can calculate payoff order and dates.'));
}
if (bill.autopay_enabled && !hasText(bill.website) && !hasText(bill.account_info)) {
issues.push(issue(bill, 'autopay_enabled', 'warning', 'Add a website or account note so autopay bills still have enough reference information when something needs attention.'));
}
if (Number.isFinite(balance) && balance > 0 && bill.interest_rate == null) {
issues.push(issue(bill, 'interest_rate', 'warning', 'Add the APR so snowball and amortization estimates include interest instead of assuming 0%.'));
} }
return { const data = sanitizeTemplateData(req.body.data || {});
id: bill.id, if (Object.keys(data).length === 0) {
name: bill.name, return res.status(400).json(standardizeError('Template data is required', 'VALIDATION_ERROR', 'data'));
active: !!bill.active, }
category_name: bill.category_name, const validation = validateBillData(data);
due_day: bill.due_day, if (validation.errors.length > 0) {
is_debt: debt, const firstError = validation.errors[0];
issues, return res.status(400).json(standardizeError(firstError.message, 'VALIDATION_ERROR', `data.${firstError.field}`));
}
if (!categoryBelongsToUser(db, validation.normalized.category_id, req.user.id)) {
return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'data.category_id'));
}
const normalizedData = sanitizeTemplateData(validation.normalized);
const result = db.prepare(`
INSERT INTO bill_templates (user_id, name, data, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(user_id, name) DO UPDATE SET
data = excluded.data,
updated_at = datetime('now')
`).run(req.user.id, name, JSON.stringify(normalizedData));
const template = db.prepare(`
SELECT id, name, data, created_at, updated_at
FROM bill_templates
WHERE user_id = ? AND name = ?
`).get(req.user.id, name);
res.status(result.changes > 0 ? 201 : 200).json({
...template,
data: parseTemplateData(template.data),
});
});
// ── DELETE /api/bills/templates/:templateId ──────────────────────────────────
router.delete('/templates/:templateId', (req, res) => {
const db = getDb();
const templateId = parseInt(req.params.templateId, 10);
if (!Number.isInteger(templateId)) {
return res.status(400).json(standardizeError('template_id must be an integer', 'VALIDATION_ERROR', 'template_id'));
}
const result = db.prepare('DELETE FROM bill_templates WHERE id = ? AND user_id = ?').run(templateId, req.user.id);
if (result.changes === 0) return res.status(404).json(standardizeError('Template not found', 'NOT_FOUND', 'template_id'));
res.json({ success: true });
});
// ── POST /api/bills/:id/duplicate ────────────────────────────────────────────
router.post('/:id/duplicate', (req, res) => {
const db = getDb();
const body = req.body || {};
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId)) {
return res.status(400).json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
}
const source = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id);
if (!source) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const draft = {
...sanitizeTemplateData(source),
...sanitizeTemplateData(body),
name: String(body.name || `${source.name} (Copy)`).trim(),
}; };
}); const validation = validateBillData(draft);
if (validation.errors.length > 0) {
const firstError = validation.errors[0];
return res.status(400).json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field));
}
const { normalized } = validation;
if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) {
return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'));
}
const issues = auditedBills.flatMap(bill => bill.issues); res.status(201).json(insertBill(db, req.user.id, normalized));
res.json({
bills: auditedBills.filter(bill => bill.issues.length > 0),
summary: {
audited_bills: bills.length,
issue_count: issues.length,
error_count: issues.filter(item => item.severity === 'error').length,
warning_count: issues.filter(item => item.severity === 'warning').length,
},
});
}); });
// ── GET /api/bills/:id/monthly-state?year=&month= ───────────────────────────── // ── GET /api/bills/:id/monthly-state?year=&month= ─────────────────────────────
@ -208,14 +240,25 @@ router.get('/:id', (req, res) => {
// ── POST /api/bills ─────────────────────────────────────────────────────────── // ── POST /api/bills ───────────────────────────────────────────────────────────
router.post('/', (req, res) => { router.post('/', (req, res) => {
const db = getDb(); const db = getDb();
const { const body = req.body || {};
name, category_id, due_day, override_due_date, expected_amount, interest_rate, let payload = body;
billing_cycle, autopay_enabled, autodraft_status, website, username,
account_info, has_2fa, notes, history_visibility, cycle_type, cycle_day, if (body.source_bill_id !== undefined && body.source_bill_id !== null && body.source_bill_id !== '') {
} = req.body; const sourceBillId = parseInt(body.source_bill_id, 10);
if (!Number.isInteger(sourceBillId)) {
return res.status(400).json(standardizeError('source_bill_id must be an integer', 'VALIDATION_ERROR', 'source_bill_id'));
}
const source = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(sourceBillId, req.user.id);
if (!source) return res.status(404).json(standardizeError('Source bill not found', 'NOT_FOUND', 'source_bill_id'));
payload = {
...sanitizeTemplateData(source),
...sanitizeTemplateData(body),
name: String(body.name || `${source.name} (Copy)`).trim(),
};
}
// Validate and normalize bill data // Validate and normalize bill data
const validation = validateBillData(req.body); const validation = validateBillData(payload);
if (validation.errors.length > 0) { if (validation.errors.length > 0) {
const firstError = validation.errors[0]; const firstError = validation.errors[0];
return res.status(400).json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field)); return res.status(400).json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field));
@ -224,46 +267,11 @@ router.post('/', (req, res) => {
const { normalized } = validation; const { normalized } = validation;
// Validate category_id exists for this user // Validate category_id exists for this user
if (normalized.category_id && !db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(normalized.category_id, req.user.id)) { if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) {
return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id')); return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'));
} }
const result = db.prepare(` res.status(201).json(insertBill(db, req.user.id, normalized));
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,
current_balance, minimum_payment, snowball_order, snowball_include, snowball_exempt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)
`).run(
req.user.id,
normalized.name,
normalized.category_id,
normalized.due_day,
normalized.override_due_date,
normalized.bucket,
normalized.expected_amount,
normalized.interest_rate,
normalized.billing_cycle,
normalized.autopay_enabled,
normalized.autodraft_status,
normalized.website,
normalized.username,
normalized.account_info,
normalized.has_2fa,
normalized.notes,
normalized.history_visibility,
normalized.cycle_type,
normalized.cycle_day,
normalized.current_balance,
normalized.minimum_payment,
normalized.snowball_order,
normalized.snowball_include,
normalized.snowball_exempt,
);
const created = db.prepare('SELECT * FROM bills WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(created);
}); });
// ── PUT /api/bills/:id ──────────────────────────────────────────────────────── // ── PUT /api/bills/:id ────────────────────────────────────────────────────────
@ -282,14 +290,14 @@ router.put('/:id', (req, res) => {
const { normalized } = validation; const { normalized } = validation;
// Validate category_id exists for this user if changed // Validate category_id exists for this user if changed
if (normalized.category_id && !db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(normalized.category_id, req.user.id)) { if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) {
return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id')); return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'));
} }
db.prepare(` db.prepare(`
UPDATE bills SET UPDATE bills SET
name = ?, category_id = ?, due_day = ?, override_due_date = ?, bucket = ?, name = ?, category_id = ?, due_day = ?, override_due_date = ?, bucket = ?,
expected_amount = ?, interest_rate = ?, billing_cycle = ?, autopay_enabled = ?, autodraft_status = ?, expected_amount = ?, interest_rate = ?, billing_cycle = ?, autopay_enabled = ?, autodraft_status = ?, auto_mark_paid = ?,
website = ?, username = ?, account_info = ?, has_2fa = ?, notes = ?, active = ?, website = ?, username = ?, account_info = ?, has_2fa = ?, notes = ?, active = ?,
history_visibility = ?, cycle_type = ?, cycle_day = ?, history_visibility = ?, cycle_type = ?, cycle_day = ?,
current_balance = ?, minimum_payment = ?, snowball_order = ?, snowball_include = ?, snowball_exempt = ?, current_balance = ?, minimum_payment = ?, snowball_order = ?, snowball_include = ?, snowball_exempt = ?,
@ -306,6 +314,7 @@ router.put('/:id', (req, res) => {
normalized.billing_cycle, normalized.billing_cycle,
normalized.autopay_enabled, normalized.autopay_enabled,
normalized.autodraft_status, normalized.autodraft_status,
normalized.auto_mark_paid,
normalized.website, normalized.website,
normalized.username, normalized.username,
normalized.account_info, normalized.account_info,
@ -392,7 +401,7 @@ router.post('/:id/toggle-paid', (req, res) => {
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
// Get bill - always scope to the requesting user // Get bill - always scope to the requesting user
const bill = db.prepare('SELECT id, expected_amount, user_id, due_day, current_balance, interest_rate FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id); const bill = db.prepare('SELECT id, expected_amount, user_id, due_day, current_balance, interest_rate, autopay_enabled FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
@ -478,7 +487,6 @@ router.post('/:id/toggle-paid', (req, res) => {
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?") db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
.run(balCalc.new_balance, billId); .run(balCalc.new_balance, billId);
} }
res.status(201).json({ res.status(201).json({
success: true, success: true,
isPaid: true, isPaid: true,

View File

@ -4,9 +4,47 @@ const router = require('express').Router();
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { computeBalanceDelta } = require('../services/billsService'); const { computeBalanceDelta } = require('../services/billsService');
const { validatePaymentInput } = require('../services/paymentValidation'); const { validatePaymentInput } = require('../services/paymentValidation');
const { resolveDueDate } = require('../services/statusService');
const LIVE = 'deleted_at IS NULL'; // filter for non-deleted payments const LIVE = 'deleted_at IS NULL'; // filter for non-deleted payments
function parseYearMonth(body) {
const year = parseInt(body.year, 10);
const month = parseInt(body.month, 10);
if (!Number.isInteger(year) || year < 2000 || year > 2100) {
return { error: standardizeError('year must be a 4-digit integer between 2000 and 2100', 'VALIDATION_ERROR', 'year') };
}
if (!Number.isInteger(month) || month < 1 || month > 12) {
return { error: standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month') };
}
return { year, month };
}
function getAutopaySuggestionContext(db, userId, billId, year, month) {
const bill = db.prepare(`
SELECT *
FROM bills
WHERE id = ? AND user_id = ? AND deleted_at IS NULL
`).get(billId, userId);
if (!bill) return { error: standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'), status: 404 };
if (!bill.autopay_enabled || bill.autodraft_status !== 'assumed_paid') {
return { error: standardizeError('Bill is not eligible for autopay suggestions', 'VALIDATION_ERROR', 'bill_id'), status: 400 };
}
const state = db.prepare(`
SELECT actual_amount, is_skipped
FROM monthly_bill_state
WHERE bill_id = ? AND year = ? AND month = ?
`).get(bill.id, year, month);
if (state?.is_skipped) {
return { error: standardizeError('Skipped bills cannot be suggested for payment', 'VALIDATION_ERROR', 'bill_id'), status: 400 };
}
const dueDate = resolveDueDate(bill, year, month);
const amount = state?.actual_amount ?? bill.expected_amount;
return { bill, dueDate, amount };
}
// GET /api/payments?bill_id=&year=&month= // GET /api/payments?bill_id=&year=&month=
router.get('/', (req, res) => { router.get('/', (req, res) => {
const db = getDb(); const db = getDb();
@ -66,12 +104,19 @@ router.post('/', (req, res) => {
} }
const payment = validation.normalized; const payment = validation.normalized;
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id)) const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id);
if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const balCalc = computeBalanceDelta(bill, payment.amount);
const result = db.prepare( const result = 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 (?, ?, ?, ?, ?, ?)'
).run(payment.bill_id, payment.amount, payment.paid_date, method || null, notes || null); ).run(payment.bill_id, payment.amount, payment.paid_date, 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);
}
res.status(201).json(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)); res.status(201).json(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid));
}); });
@ -113,11 +158,99 @@ router.post('/quick', (req, res) => {
.run(balCalc.new_balance, bill.id); .run(balCalc.new_balance, bill.id);
} }
if (bill.autopay_enabled) { res.status(201).json(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid));
db.prepare("UPDATE bills SET autodraft_status='confirmed', updated_at=datetime('now') WHERE id=?").run(bill.id); });
// POST /api/payments/autopay-suggestions/:billId/confirm
router.post('/autopay-suggestions/:billId/confirm', (req, res) => {
const db = getDb();
const ym = parseYearMonth(req.body);
if (ym.error) return res.status(400).json(ym.error);
const billId = parseInt(req.params.billId, 10);
if (!Number.isInteger(billId)) {
return res.status(400).json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
} }
res.status(201).json(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)); const context = getAutopaySuggestionContext(db, req.user.id, billId, ym.year, ym.month);
if (context.error) return res.status(context.status).json(context.error);
const { bill, dueDate, amount } = context;
if (dueDate > new Date().toISOString().slice(0, 10)) {
return res.status(400).json(standardizeError('Autopay suggestion is not due yet', 'VALIDATION_ERROR', 'paid_date'));
}
const paymentValidation = validatePaymentInput(
{ amount, paid_date: dueDate },
{ requireBillId: false },
);
if (paymentValidation.error) {
return res.status(400).json(standardizeError(paymentValidation.error, 'VALIDATION_ERROR', paymentValidation.field));
}
const suggestedPayment = paymentValidation.normalized;
const existing = db.prepare(`
SELECT p.*
FROM payments p
JOIN bills b ON b.id = p.bill_id
WHERE p.bill_id = ?
AND b.user_id = ?
AND p.deleted_at IS NULL
AND strftime('%Y', p.paid_date) = ?
AND strftime('%m', p.paid_date) = ?
ORDER BY p.paid_date DESC
LIMIT 1
`).get(bill.id, req.user.id, String(ym.year), String(ym.month).padStart(2, '0'));
if (existing) {
db.prepare('DELETE FROM autopay_suggestion_dismissals WHERE user_id = ? AND bill_id = ? AND year = ? AND month = ?')
.run(req.user.id, bill.id, ym.year, ym.month);
return res.json({ created: false, payment: existing });
}
const balCalc = computeBalanceDelta(bill, suggestedPayment.amount);
const result = db.prepare(`
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
bill.id,
suggestedPayment.amount,
suggestedPayment.paid_date,
'autopay',
'Confirmed autopay suggestion',
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);
}
db.prepare('DELETE FROM autopay_suggestion_dismissals WHERE user_id = ? AND bill_id = ? AND year = ? AND month = ?')
.run(req.user.id, bill.id, ym.year, ym.month);
res.status(201).json({ created: true, payment: db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid) });
});
// POST /api/payments/autopay-suggestions/:billId/dismiss
router.post('/autopay-suggestions/:billId/dismiss', (req, res) => {
const db = getDb();
const ym = parseYearMonth(req.body);
if (ym.error) return res.status(400).json(ym.error);
const billId = parseInt(req.params.billId, 10);
if (!Number.isInteger(billId)) {
return res.status(400).json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
}
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id)) {
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
}
db.prepare(`
INSERT INTO autopay_suggestion_dismissals (user_id, bill_id, year, month, dismissed_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(user_id, bill_id, year, month)
DO UPDATE SET dismissed_at = datetime('now')
`).run(req.user.id, billId, ym.year, ym.month);
res.json({ success: true });
}); });
// POST /api/payments/bulk — record multiple payments in one request // POST /api/payments/bulk — record multiple payments in one request
@ -217,16 +350,39 @@ router.put('/:id', (req, res) => {
return res.status(400).json(standardizeError(validation.error, 'VALIDATION_ERROR', validation.field)); return res.status(400).json(standardizeError(validation.error, 'VALIDATION_ERROR', validation.field));
} }
const nextAmount = validation.normalized.amount ?? existing.amount;
const nextPaidDate = validation.normalized.paid_date ?? existing.paid_date;
let nextBalanceDelta = existing.balance_delta;
const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(existing.bill_id, req.user.id);
if (bill) {
let restoredBalance = bill.current_balance;
if (existing.balance_delta != null && bill.current_balance != null) {
restoredBalance = Math.max(0, Math.round((bill.current_balance - existing.balance_delta) * 100) / 100);
}
const balCalc = computeBalanceDelta({ ...bill, current_balance: restoredBalance }, nextAmount);
nextBalanceDelta = balCalc?.balance_delta ?? null;
if (balCalc) {
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
.run(balCalc.new_balance, existing.bill_id);
} else if (existing.balance_delta != null && restoredBalance != null) {
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
.run(restoredBalance, existing.bill_id);
}
}
db.prepare(` db.prepare(`
UPDATE payments SET UPDATE payments SET
amount = ?, paid_date = ?, method = ?, notes = ?, amount = ?, paid_date = ?, method = ?, notes = ?, balance_delta = ?,
updated_at = datetime('now') updated_at = datetime('now')
WHERE id = ? WHERE id = ?
`).run( `).run(
validation.normalized.amount ?? existing.amount, nextAmount,
validation.normalized.paid_date ?? existing.paid_date, nextPaidDate,
method !== undefined ? (method || null) : existing.method, method !== undefined ? (method || null) : existing.method,
notes !== undefined ? (notes || null) : existing.notes, notes !== undefined ? (notes || null) : existing.notes,
nextBalanceDelta,
req.params.id, req.params.id,
); );

View File

@ -1,319 +1,17 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { getDb } = require('../db/database'); const { getTracker, getUpcomingBills } = require('../services/trackerService');
const { buildTrackerRow, getCycleRange, resolveDueDate } = require('../services/statusService');
const { getUserSettings } = require('../services/userSettings');
// GET /api/tracker?year=2026&month=5 // GET /api/tracker?year=2026&month=5
router.get('/', (req, res) => { router.get('/', (req, res) => {
const db = getDb(); const result = getTracker(req.user.id, req.query);
const now = new Date(); if (result.error) return res.status(result.status || 400).json({ error: result.error });
const year = parseInt(req.query.year || now.getFullYear(), 10); res.json(result);
const month = parseInt(req.query.month || now.getMonth() + 1, 10);
if (isNaN(year) || year < 2000 || year > 2100)
return res.status(400).json({ error: 'year must be a 4-digit integer between 2000 and 2100' });
if (isNaN(month) || month < 1 || month > 12)
return res.status(400).json({ error: 'month must be an integer between 1 and 12' });
const todayStr = now.toISOString().slice(0, 10);
const userSettings = getUserSettings(req.user.id);
const rowOptions = { gracePeriodDays: userSettings.grace_period_days };
const { start, end } = getCycleRange(year, month);
// Calculate previous month (with year wrapping)
const prevMonth = month === 1 ? 12 : month - 1;
const prevYear = month === 1 ? year - 1 : year;
const prevMonthRange = getCycleRange(prevYear, prevMonth);
// Calculate 3-month range for trend analysis
const threeMonthsAgo = (() => {
let y = year, m = month - 2;
while (m <= 0) { m += 12; y -= 1; }
return { year: y, month: m };
})();
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.deleted_at IS NULL
WHERE b.active = 1 AND b.user_id = ? AND b.deleted_at IS NULL
ORDER BY b.due_day ASC, b.name ASC
`).all(req.user.id);
// Batch fetch all monthly bill states for current month
const billIds = bills.map(bill => bill.id);
const placeholders = billIds.map(() => '?').join(',');
let monthlyStates = {};
if (billIds.length > 0) {
const monthlyStateQuery = `
SELECT bill_id, actual_amount, notes, is_skipped
FROM monthly_bill_state
WHERE bill_id IN (${placeholders}) AND year = ? AND month = ?
`;
const monthlyStateRows = db.prepare(monthlyStateQuery).all(...billIds, year, month);
monthlyStates = Object.fromEntries(monthlyStateRows.map(row => [row.bill_id, row]));
}
// Batch fetch all payments for current month
let allPayments = {};
if (billIds.length > 0) {
const paymentsQuery = `
SELECT bill_id, id, amount, paid_date, method, notes, created_at, updated_at
FROM payments
WHERE bill_id IN (${placeholders}) AND paid_date BETWEEN ? AND ?
AND deleted_at IS NULL
ORDER BY paid_date DESC
`;
const paymentRows = db.prepare(paymentsQuery).all(...billIds, start, end);
// Group payments by bill_id
allPayments = {};
paymentRows.forEach(row => {
if (!allPayments[row.bill_id]) {
allPayments[row.bill_id] = [];
}
allPayments[row.bill_id].push(row);
});
}
// Batch fetch all previous month payments
let prevMonthPayments = {};
if (billIds.length > 0) {
const prevPaymentsQuery = `
SELECT bill_id, SUM(amount) as total_paid
FROM payments
WHERE bill_id IN (${placeholders}) AND paid_date BETWEEN ? AND ?
AND deleted_at IS NULL
GROUP BY bill_id
`;
const prevPaymentRows = db.prepare(prevPaymentsQuery).all(...billIds, prevMonthRange.start, prevMonthRange.end);
prevMonthPayments = Object.fromEntries(prevPaymentRows.map(row => [row.bill_id, row.total_paid]));
}
const rows = bills.map(bill => {
// Get payments for this bill
const payments = allPayments[bill.id] || [];
const row = buildTrackerRow(bill, payments, year, month, todayStr, rowOptions);
// Overlay monthly state overrides
const mbs = monthlyStates[bill.id];
row.actual_amount = mbs?.actual_amount ?? null;
row.monthly_notes = mbs?.notes ?? null;
row.is_skipped = !!(mbs?.is_skipped);
// Get previous month paid amount
row.previous_month_paid = prevMonthPayments[bill.id] || 0;
return row;
});
const totalOverdue = rows
.filter(r => !r.is_skipped && (r.status === 'late' || r.status === 'missed'))
.reduce((s, r) => s + r.balance, 0);
const activeRows = rows.filter(r => !r.is_skipped);
// Get starting amounts for this month
const startingAmounts = db.prepare(`
SELECT COALESCE(first_amount, 0) AS first_amount,
COALESCE(fifteenth_amount, 0) AS fifteenth_amount,
COALESCE(other_amount, 0) AS other_amount,
COALESCE(first_amount, 0) + COALESCE(fifteenth_amount, 0) + COALESCE(other_amount, 0) AS combined_amount
FROM monthly_starting_amounts
WHERE user_id = ? AND year = ? AND month = ?
`).get(req.user.id, year, month);
const dayOfMonth = now.getDate();
const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th';
const periodRows = activeRows.filter(r => r.bucket === activeRemainingPeriod);
const periodPaid = periodRows.reduce((s, r) => s + r.total_paid, 0);
const periodOutstandingBalance = periodRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0);
const periodStartingAmount = activeRemainingPeriod === '1st'
? (startingAmounts?.first_amount || 0)
: (startingAmounts?.fifteenth_amount || 0);
const periodLabel = activeRemainingPeriod === '1st' ? '1st balance' : '15th balance';
const totalStarting = startingAmounts?.combined_amount || 0;
const hasStartingAmounts = !!startingAmounts;
const activeTotalPaid = activeRows.reduce((s, r) => s + r.total_paid, 0);
const activeTotalExpected = activeRows.reduce((s, r) => s + r.expected_amount, 0);
const activeOutstandingBalance = activeRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0);
// Calculate previous month total
const previousMonthTotal = activeRows.reduce((s, r) => s + r.previous_month_paid, 0);
// Calculate 3-month trend data
const threeMonthStart = getCycleRange(threeMonthsAgo.year, threeMonthsAgo.month).start;
const currentMonthEnd = end;
// Get all payments for the last 3 months for this user
// Join through bills to get user_id since payments table doesn't have user_id
const threeMonthPayments = db.prepare(`
SELECT SUM(p.amount) as total_paid, strftime('%Y-%m', p.paid_date) as month_key
FROM payments p
JOIN bills b ON p.bill_id = b.id
WHERE b.user_id = ? AND b.deleted_at IS NULL AND p.paid_date BETWEEN ? AND ?
AND p.deleted_at IS NULL
GROUP BY strftime('%Y-%m', p.paid_date)
`).all(req.user.id, threeMonthStart, currentMonthEnd);
// Create a map of month payments for easier access
const monthlyPaymentsMap = new Map();
threeMonthPayments.forEach(payment => {
monthlyPaymentsMap.set(payment.month_key, payment.total_paid);
});
// Calculate payments for each of the last 3 months
const months = [];
for (let i = 2; i >= 0; i--) {
const date = new Date(year, month - 1 - i);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
months.push({
year: date.getFullYear(),
month: date.getMonth() + 1,
key: monthKey,
payment: parseFloat(monthlyPaymentsMap.get(monthKey) || 0)
});
}
// Calculate 3-month average
const threeMonthTotal = months.reduce((sum, m) => sum + m.payment, 0);
const threeMonthAvg = threeMonthTotal / 3;
// Calculate current month paid (sum of all bills)
const currentMonthPaid = activeTotalPaid;
// Calculate percentage change
let percentChange = 0;
let direction = 'flat';
if (threeMonthAvg > 0) {
percentChange = ((currentMonthPaid - threeMonthAvg) / threeMonthAvg) * 100;
// Determine direction based on percentage change
if (percentChange > 2) {
direction = 'up';
} else if (percentChange < -2) {
direction = 'down';
} else {
direction = 'flat';
}
}
// Ensure percentChange is a number with 1 decimal place
percentChange = parseFloat(percentChange.toFixed(1));
res.json({
year, month, today: todayStr,
summary: {
total_expected: activeTotalExpected,
total_starting: totalStarting,
has_starting_amounts: hasStartingAmounts,
total_paid: activeTotalPaid,
remaining: hasStartingAmounts ? periodStartingAmount - periodPaid : periodOutstandingBalance,
total_remaining: hasStartingAmounts ? totalStarting - activeTotalPaid : activeOutstandingBalance,
remaining_period: activeRemainingPeriod,
remaining_label: periodLabel,
remaining_hint: hasStartingAmounts
? `${periodLabel}: ${periodStartingAmount.toFixed(2)} starting minus ${periodPaid.toFixed(2)} paid`
: `${periodLabel}: unpaid bills due in this period`,
overdue: totalOverdue,
count_paid: activeRows.filter(r => r.status === 'paid').length,
count_upcoming: activeRows.filter(r => r.status === 'upcoming' || r.status === 'due_soon').length,
count_late: activeRows.filter(r => r.status === 'late' || r.status === 'missed').length,
count_autodraft: activeRows.filter(r => r.status === 'autodraft').length,
previous_month_total: previousMonthTotal,
trend: {
three_month_avg: parseFloat(threeMonthAvg.toFixed(2)),
current_month_paid: parseFloat(currentMonthPaid.toFixed(2)),
percent_change: percentChange,
direction: direction
}
},
rows,
});
}); });
// GET /api/tracker/upcoming?days=30 // GET /api/tracker/upcoming?days=30
// Returns active bills with a due date in the next N days, sorted by due_date asc.
router.get('/upcoming', (req, res) => { router.get('/upcoming', (req, res) => {
const db = getDb(); res.json(getUpcomingBills(req.user.id, req.query));
const days = Math.max(1, Math.min(parseInt(req.query.days || '30', 10) || 30, 365));
const now = new Date();
const todayStr = now.toISOString().slice(0, 10);
const userSettings = getUserSettings(req.user.id);
const rowOptions = { gracePeriodDays: userSettings.grace_period_days };
const year = now.getFullYear();
const month = now.getMonth() + 1;
const { start, end } = getCycleRange(year, month);
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.deleted_at IS NULL
WHERE b.active = 1 AND b.user_id = ? AND b.deleted_at IS NULL
`).all(req.user.id);
const cutoff = new Date(now);
cutoff.setDate(cutoff.getDate() + days);
const cutoffStr = cutoff.toISOString().slice(0, 10);
// Get all bill IDs for batch processing
const billIds = bills.map(bill => bill.id);
// Batch fetch all payments for all bills in the date range
let allPayments = {};
if (billIds.length > 0) {
const placeholders = billIds.map(() => '?').join(',');
const paymentsQuery = `
SELECT bill_id, id, amount, paid_date, method, notes, created_at, updated_at
FROM payments
WHERE bill_id IN (${placeholders}) AND paid_date BETWEEN ? AND ?
AND deleted_at IS NULL
ORDER BY paid_date DESC
`;
const paymentRows = db.prepare(paymentsQuery).all(...billIds, start, end);
// Group payments by bill_id
allPayments = {};
paymentRows.forEach(row => {
if (!allPayments[row.bill_id]) {
allPayments[row.bill_id] = [];
}
allPayments[row.bill_id].push(row);
});
}
const upcoming = [];
for (const bill of bills) {
const dueDate = resolveDueDate(bill, year, month);
if (dueDate < todayStr || dueDate > cutoffStr) continue;
// Get payments for this bill from the batched results
const payments = allPayments[bill.id] || [];
const row = buildTrackerRow(bill, payments, year, month, todayStr, rowOptions);
if (row.status === 'paid') continue; // skip already paid
upcoming.push({
id: bill.id,
name: bill.name,
category_name: bill.category_name,
due_date: dueDate,
expected_amount: bill.expected_amount,
status: row.status,
days_until_due: Math.floor((new Date(dueDate) - now) / 86400000),
});
}
upcoming.sort((a, b) => a.due_date.localeCompare(b.due_date));
res.json({ days, today: todayStr, upcoming });
}); });
module.exports = router; module.exports = router;

View File

@ -0,0 +1,289 @@
'use strict';
const { getDb } = require('../db/database');
function parseInteger(value, fallback) {
if (value === undefined || value === null || value === '') return fallback;
const parsed = Number(value);
return Number.isInteger(parsed) ? parsed : NaN;
}
function monthKey(year, month) {
return `${year}-${String(month).padStart(2, '0')}`;
}
function monthLabel(year, month) {
return new Date(Date.UTC(year, month - 1, 1)).toLocaleString('en-US', {
month: 'short',
year: '2-digit',
timeZone: 'UTC',
});
}
function addMonths(year, month, delta) {
const date = new Date(Date.UTC(year, month - 1 + delta, 1));
return { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 };
}
function monthEndDate(year, month) {
const day = new Date(Date.UTC(year, month, 0)).getUTCDate();
return `${monthKey(year, month)}-${String(day).padStart(2, '0')}`;
}
function buildMonths(endYear, endMonth, count) {
return Array.from({ length: count }, (_, index) => {
const value = addMonths(endYear, endMonth, index - count + 1);
return {
...value,
key: monthKey(value.year, value.month),
label: monthLabel(value.year, value.month),
start: `${monthKey(value.year, value.month)}-01`,
end: monthEndDate(value.year, value.month),
};
});
}
function validateSummaryQuery(query, now = new Date()) {
const year = parseInteger(query.year, now.getFullYear());
const month = parseInteger(query.month, now.getMonth() + 1);
const months = parseInteger(query.months, 12);
const categoryId = parseInteger(query.category_id, null);
const billId = parseInteger(query.bill_id, null);
const includeInactive = query.include_inactive === 'true';
const includeSkipped = query.include_skipped !== 'false';
if (!Number.isInteger(year) || year < 2000 || year > 2100) {
return { error: 'year must be a 4-digit integer between 2000 and 2100' };
}
if (!Number.isInteger(month) || month < 1 || month > 12) {
return { error: 'month must be an integer between 1 and 12' };
}
if (!Number.isInteger(months) || months < 1 || months > 36) {
return { error: 'months must be an integer between 1 and 36' };
}
if (categoryId !== null && (!Number.isInteger(categoryId) || categoryId < 1)) {
return { error: 'category_id must be a positive integer' };
}
if (billId !== null && (!Number.isInteger(billId) || billId < 1)) {
return { error: 'bill_id must be a positive integer' };
}
return { year, month, months, categoryId, billId, includeInactive, includeSkipped };
}
function isMonthInPast(year, month) {
const now = new Date();
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const targetMonthStart = new Date(year, month - 1, 1);
return targetMonthStart < currentMonthStart;
}
function buildBillWhere({ userId, categoryId, billId, includeInactive }) {
const clauses = ['b.user_id = ?', 'b.deleted_at IS NULL'];
const params = [userId];
if (!includeInactive) clauses.push('b.active = 1');
if (categoryId) {
clauses.push('b.category_id = ?');
params.push(categoryId);
}
if (billId) {
clauses.push('b.id = ?');
params.push(billId);
}
return { where: clauses.join(' AND '), params };
}
function emptySummary(parsed, rangeMonths, startDate, endDate, categories) {
return {
range: { year: parsed.year, month: parsed.month, months: parsed.months, start: startDate, end: endDate },
filters: {
category_id: parsed.categoryId,
bill_id: parsed.billId,
include_inactive: parsed.includeInactive,
include_skipped: parsed.includeSkipped,
},
categories,
bills: [],
monthly_spending: [],
expected_vs_actual: [],
category_spend: [],
heatmap: { months: rangeMonths.map(({ key, label, year, month }) => ({ key, label, year, month })), rows: [] },
generated_at: new Date().toISOString(),
};
}
function getAnalyticsSummary(userId, query = {}) {
const parsed = validateSummaryQuery(query);
if (parsed.error) return parsed;
const db = getDb();
const rangeMonths = buildMonths(parsed.year, parsed.month, parsed.months);
const startDate = rangeMonths[0].start;
const endDate = rangeMonths[rangeMonths.length - 1].end;
const billWhere = buildBillWhere({ ...parsed, userId });
const categories = db.prepare(`
SELECT id, name
FROM categories
WHERE user_id = ?
AND deleted_at IS NULL
ORDER BY name COLLATE NOCASE
`).all(userId);
const bills = db.prepare(`
SELECT b.id, b.name, b.category_id, b.expected_amount, b.active, b.created_at,
c.name AS category_name
FROM bills b
LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id AND c.deleted_at IS NULL
WHERE ${billWhere.where}
ORDER BY b.name COLLATE NOCASE
`).all(...billWhere.params);
if (!bills.length) {
return emptySummary(parsed, rangeMonths, startDate, endDate, categories);
}
const billIds = bills.map(b => b.id);
const placeholders = billIds.map(() => '?').join(',');
const paymentRows = db.prepare(`
SELECT p.bill_id,
substr(p.paid_date, 1, 7) AS month_key,
SUM(p.amount) AS total
FROM payments p
JOIN bills b ON b.id = p.bill_id
WHERE b.user_id = ?
AND b.deleted_at IS NULL
AND p.bill_id IN (${placeholders})
AND p.paid_date BETWEEN ? AND ?
AND p.deleted_at IS NULL
GROUP BY p.bill_id, substr(p.paid_date, 1, 7)
`).all(userId, ...billIds, startDate, endDate);
const stateRows = db.prepare(`
SELECT m.bill_id, m.year, m.month, m.actual_amount, m.is_skipped
FROM monthly_bill_state m
JOIN bills b ON b.id = m.bill_id
WHERE b.user_id = ?
AND b.deleted_at IS NULL
AND m.bill_id IN (${placeholders})
AND (m.year * 100 + m.month) BETWEEN ? AND ?
`).all(
userId,
...billIds,
rangeMonths[0].year * 100 + rangeMonths[0].month,
rangeMonths[rangeMonths.length - 1].year * 100 + rangeMonths[rangeMonths.length - 1].month,
);
const paymentByBillMonth = new Map(paymentRows.map(row => [`${row.bill_id}:${row.month_key}`, Number(row.total) || 0]));
const stateByBillMonth = new Map(stateRows.map(row => [`${row.bill_id}:${monthKey(row.year, row.month)}`, row]));
const monthly_spending = rangeMonths.map(m => {
const total = bills.reduce((sum, bill) => sum + (paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0), 0);
return { month: m.key, label: m.label, total: Number(total.toFixed(2)) };
}).filter(row => row.total > 0);
const expected_vs_actual = rangeMonths.map(m => {
let expected = 0;
let actual = 0;
let skipped_count = 0;
for (const bill of bills) {
const state = stateByBillMonth.get(`${bill.id}:${m.key}`);
const skipped = !!state?.is_skipped;
if (skipped) skipped_count += 1;
if (!skipped || parsed.includeSkipped) {
actual += paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0;
}
if (!skipped) {
expected += state?.actual_amount ?? bill.expected_amount ?? 0;
}
}
return {
month: m.key,
label: m.label,
expected: Number(expected.toFixed(2)),
actual: Number(actual.toFixed(2)),
skipped_count,
};
}).filter(row => row.expected > 0 || row.actual > 0 || row.skipped_count > 0);
const categoryMap = new Map();
for (const bill of bills) {
const categoryId = bill.category_id || null;
const key = categoryId == null ? 'uncategorized' : String(categoryId);
const existing = categoryMap.get(key) || {
category_id: categoryId,
category_name: bill.category_name || 'Uncategorized',
total: 0,
};
for (const m of rangeMonths) {
existing.total += paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0;
}
categoryMap.set(key, existing);
}
const category_spend = Array.from(categoryMap.values())
.map(row => ({ ...row, total: Number(row.total.toFixed(2)) }))
.filter(row => row.total > 0)
.sort((a, b) => b.total - a.total);
const heatmapRows = bills.map(bill => {
const cells = rangeMonths.map(m => {
const paid = (paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0) > 0;
const state = stateByBillMonth.get(`${bill.id}:${m.key}`);
const skipped = !!state?.is_skipped;
let status = 'no_data';
if (skipped) status = 'skipped';
else if (paid) status = 'paid';
else if (isMonthInPast(m.year, m.month)) status = 'missed';
return {
month: m.key,
label: m.label,
status,
amount_paid: Number((paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0).toFixed(2)),
};
});
return {
bill_id: bill.id,
bill_name: bill.name,
category_name: bill.category_name || 'Uncategorized',
active: !!bill.active,
cells: parsed.includeSkipped ? cells : cells.filter(cell => cell.status !== 'skipped'),
};
});
return {
range: { year: parsed.year, month: parsed.month, months: parsed.months, start: startDate, end: endDate },
filters: {
category_id: parsed.categoryId,
bill_id: parsed.billId,
include_inactive: parsed.includeInactive,
include_skipped: parsed.includeSkipped,
},
categories,
bills: bills.map(b => ({
id: b.id,
name: b.name,
category_id: b.category_id,
category_name: b.category_name || 'Uncategorized',
active: !!b.active,
})),
monthly_spending,
expected_vs_actual,
category_spend,
heatmap: {
months: rangeMonths.map(({ key, label, year, month }) => ({ key, label, year, month })),
rows: heatmapRows,
},
generated_at: new Date().toISOString(),
};
}
module.exports = {
addMonths,
buildMonths,
getAnalyticsSummary,
monthEndDate,
monthKey,
monthLabel,
validateSummaryQuery,
};

View File

@ -1,5 +1,151 @@
const VALID_VISIBILITY = ['default', 'all', 'ranges', 'none']; const VALID_VISIBILITY = ['default', 'all', 'ranges', 'none'];
const TEMPLATE_FIELDS = [
'name', 'category_id', 'due_day', 'override_due_date', 'bucket', 'expected_amount',
'interest_rate', 'billing_cycle', 'cycle_type', 'cycle_day', 'autopay_enabled',
'autodraft_status', 'auto_mark_paid', 'website', 'username', 'account_info',
'has_2fa', 'notes', 'current_balance', 'minimum_payment', 'snowball_order',
'snowball_include', 'snowball_exempt', 'history_visibility',
];
function hasText(value) {
return typeof value === 'string' && value.trim().length > 0;
}
function isDebtBill(bill) {
const category = String(bill.category_name || '').toLowerCase();
return Number(bill.current_balance) > 0
|| bill.minimum_payment != null
|| ['credit card', 'credit cards', 'loan', 'loans', 'debt'].some(token => category.includes(token));
}
function billAuditIssue(bill, field, severity, suggestion) {
return {
bill_id: bill.id,
bill_name: bill.name,
field,
severity,
suggestion,
};
}
function sanitizeTemplateData(data = {}) {
return TEMPLATE_FIELDS.reduce((out, field) => {
if (data[field] !== undefined) out[field] = data[field];
return out;
}, {});
}
function parseTemplateData(raw) {
try {
return sanitizeTemplateData(JSON.parse(raw || '{}'));
} catch {
return {};
}
}
function categoryBelongsToUser(db, categoryId, userId) {
if (!categoryId) return true;
return !!db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(categoryId, userId);
}
function insertBill(db, userId, normalized) {
const result = db.prepare(`
INSERT INTO bills
(user_id, name, category_id, due_day, override_due_date, bucket, expected_amount,
interest_rate, billing_cycle, autopay_enabled, autodraft_status, auto_mark_paid, website, username,
account_info, has_2fa, notes, history_visibility, active, cycle_type, cycle_day,
current_balance, minimum_payment, snowball_order, snowball_include, snowball_exempt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId,
normalized.name,
normalized.category_id,
normalized.due_day,
normalized.override_due_date,
normalized.bucket,
normalized.expected_amount,
normalized.interest_rate,
normalized.billing_cycle,
normalized.autopay_enabled,
normalized.autodraft_status,
normalized.auto_mark_paid,
normalized.website,
normalized.username,
normalized.account_info,
normalized.has_2fa,
normalized.notes,
normalized.history_visibility,
normalized.cycle_type,
normalized.cycle_day,
normalized.current_balance,
normalized.minimum_payment,
normalized.snowball_order,
normalized.snowball_include,
normalized.snowball_exempt,
);
return db.prepare('SELECT * FROM bills WHERE id = ?').get(result.lastInsertRowid);
}
function auditBillsForUser(db, userId, includeInactive = false) {
const bills = db.prepare(`
SELECT b.id, b.name, b.category_id, b.due_day, b.active, b.autopay_enabled,
b.website, b.username, b.account_info, b.current_balance,
b.minimum_payment, b.interest_rate, c.name AS category_name
FROM bills b
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
WHERE b.user_id = ?
AND b.deleted_at IS NULL
${includeInactive ? '' : 'AND b.active = 1'}
ORDER BY b.active DESC, b.due_day ASC, b.name ASC
`).all(userId);
const auditedBills = bills.map((bill) => {
const issues = [];
const dueDay = Number(bill.due_day);
const debt = isDebtBill(bill);
const balance = Number(bill.current_balance);
if (!Number.isInteger(dueDay) || dueDay < 1 || dueDay > 31) {
issues.push(billAuditIssue(bill, 'due_day', 'error', 'Add a due day between 1 and 31 so tracker periods and reminders can place this bill correctly.'));
}
if (!bill.category_id || !bill.category_name) {
issues.push(billAuditIssue(bill, 'category_id', 'warning', 'Choose a category so summaries, filters, and snowball debt detection stay accurate.'));
}
if (debt && !(Number(bill.minimum_payment) > 0)) {
issues.push(billAuditIssue(bill, 'minimum_payment', 'error', 'Add the required minimum payment so debt snowball projections can calculate payoff order and dates.'));
}
if (bill.autopay_enabled && !hasText(bill.website) && !hasText(bill.account_info)) {
issues.push(billAuditIssue(bill, 'autopay_enabled', 'warning', 'Add a website or account note so autopay bills still have enough reference information when something needs attention.'));
}
if (Number.isFinite(balance) && balance > 0 && bill.interest_rate == null) {
issues.push(billAuditIssue(bill, 'interest_rate', 'warning', 'Add the APR so snowball and amortization estimates include interest instead of assuming 0%.'));
}
return {
id: bill.id,
name: bill.name,
active: !!bill.active,
category_name: bill.category_name,
due_day: bill.due_day,
is_debt: debt,
issues,
};
});
const issues = auditedBills.flatMap(bill => bill.issues);
return {
bills: auditedBills.filter(bill => bill.issues.length > 0),
summary: {
audited_bills: bills.length,
issue_count: issues.length,
error_count: issues.filter(item => item.severity === 'error').length,
warning_count: issues.filter(item => item.severity === 'warning').length,
},
};
}
// Helper function to get default cycle day based on cycle type // Helper function to get default cycle day based on cycle type
function getDefaultCycleDay(cycleType) { function getDefaultCycleDay(cycleType) {
switch (cycleType) { switch (cycleType) {
@ -124,6 +270,9 @@ function validateBillData(data, existingBill = null) {
// autodraft_status // autodraft_status
normalized.autodraft_status = data.autodraft_status !== undefined ? (data.autodraft_status || 'none') : (existingBill?.autodraft_status || 'none'); normalized.autodraft_status = data.autodraft_status !== undefined ? (data.autodraft_status || 'none') : (existingBill?.autodraft_status || 'none');
// auto_mark_paid
normalized.auto_mark_paid = data.auto_mark_paid !== undefined ? (data.auto_mark_paid ? 1 : 0) : (existingBill?.auto_mark_paid || 0);
// website // website
normalized.website = data.website !== undefined ? (data.website || null) : (existingBill?.website || null); normalized.website = data.website !== undefined ? (data.website || null) : (existingBill?.website || null);
@ -274,11 +423,17 @@ function computeBalanceDelta(bill, paymentAmount) {
module.exports = { module.exports = {
VALID_VISIBILITY, VALID_VISIBILITY,
TEMPLATE_FIELDS,
auditBillsForUser,
categoryBelongsToUser,
getValidCycleTypes, getValidCycleTypes,
getDefaultCycleDay, getDefaultCycleDay,
insertBill,
parseTemplateData,
validateCycleDay, validateCycleDay,
parseDueDay, parseDueDay,
parseInterestRate, parseInterestRate,
sanitizeTemplateData,
validateBillData, validateBillData,
validateCycleDayOnly, validateCycleDayOnly,
computeBalanceDelta, computeBalanceDelta,

View File

@ -51,7 +51,7 @@
const crypto = require('crypto'); const crypto = require('crypto');
const { Issuer } = require('openid-client'); const { Issuer } = require('openid-client');
const { getDb, getSetting } = require('../db/database'); const { getDb, getSetting, setSetting } = require('../db/database');
// ── Configuration ───────────────────────────────────────────────────────────── // ── Configuration ─────────────────────────────────────────────────────────────
@ -155,6 +155,217 @@ function getAdminOidcSettings() {
}; };
} }
function trimOrEmpty(value) {
if (value === undefined || value === null) return '';
return String(value).trim();
}
function boolSetting(value, fallback) {
if (value === undefined) return fallback;
if (typeof value === 'string') return value === 'true';
return !!value;
}
function serviceError(message, status = 400) {
return Object.assign(new Error(message), { status });
}
function computeSubmittedOidcConfigured(body = {}) {
const current = getAdminOidcSettings();
const next = {
issuer: body.oidc_issuer_url !== undefined
? trimOrEmpty(body.oidc_issuer_url)
: current.oidc_issuer_url,
clientId: body.oidc_client_id !== undefined
? trimOrEmpty(body.oidc_client_id)
: current.oidc_client_id,
redirectUri: body.oidc_redirect_uri !== undefined
? trimOrEmpty(body.oidc_redirect_uri)
: current.oidc_redirect_uri,
clientSecret: current.oidc_client_secret_set ? 'set' : '',
};
if (body.oidc_client_secret_clear === true) {
next.clientSecret = process.env.OIDC_CLIENT_SECRET ? 'set' : '';
}
if (trimOrEmpty(body.oidc_client_secret)) {
next.clientSecret = 'set';
}
return !!(next.issuer && next.clientId && next.clientSecret && next.redirectUri);
}
function buildSubmittedOidcConfig(body = {}) {
const current = getAdminOidcSettings();
const status = getOidcConfigStatus();
const issuerUrl = body.oidc_issuer_url !== undefined
? trimOrEmpty(body.oidc_issuer_url)
: current.oidc_issuer_url;
const clientId = body.oidc_client_id !== undefined
? trimOrEmpty(body.oidc_client_id)
: current.oidc_client_id;
const redirectUri = body.oidc_redirect_uri !== undefined
? trimOrEmpty(body.oidc_redirect_uri)
: current.oidc_redirect_uri;
const tokenAuthMethod = body.oidc_token_auth_method !== undefined
? trimOrEmpty(body.oidc_token_auth_method)
: current.oidc_token_auth_method;
const scopes = body.oidc_scopes !== undefined
? trimOrEmpty(body.oidc_scopes)
: current.oidc_scopes;
const providerName = body.oidc_provider_name !== undefined
? trimOrEmpty(body.oidc_provider_name)
: current.oidc_provider_name;
let clientSecret = status.oidc_client_secret_set ? '__saved__' : '';
if (body.oidc_client_secret_clear === true) clientSecret = process.env.OIDC_CLIENT_SECRET || '';
if (trimOrEmpty(body.oidc_client_secret)) clientSecret = trimOrEmpty(body.oidc_client_secret);
if (!issuerUrl || !clientId || !clientSecret || !redirectUri) return null;
return {
enabled: true,
issuerUrl,
clientId,
clientSecret: clientSecret === '__saved__'
? (getSetting('oidc_client_secret') || process.env.OIDC_CLIENT_SECRET || '')
: clientSecret,
tokenEndpointAuthMethod: tokenAuthMethod === 'client_secret_post'
? 'client_secret_post'
: 'client_secret_basic',
redirectUri,
scopes: (scopes || 'openid email profile groups').split(/\s+/).filter(Boolean),
adminGroup: body.oidc_admin_group !== undefined ? trimOrEmpty(body.oidc_admin_group) : current.oidc_admin_group,
defaultRole: 'user',
autoProvision: body.oidc_auto_provision !== undefined ? !!body.oidc_auto_provision : current.oidc_auto_provision,
providerName: providerName || 'authentik',
};
}
function buildAuthModeStatus() {
const oidcConfigured = getOidcConfigStatus().oidc_configured;
const localEnabled = getSetting('local_login_enabled') !== 'false';
const oidcEnabled = getSetting('oidc_login_enabled') === 'true';
const oidcAdminGroup = getAdminOidcSettings().oidc_admin_group;
const canDisableLocal = oidcConfigured && oidcEnabled && !!oidcAdminGroup;
const warnings = [];
if (!localEnabled && !oidcConfigured) {
warnings.push('Local login is disabled but OIDC is not configured; users may be locked out.');
}
if (!localEnabled && !oidcEnabled) {
warnings.push('No login method is enabled. Re-enable local login or configure OIDC.');
}
if (oidcEnabled && !oidcConfigured) {
warnings.push('authentik/OIDC login is enabled but configuration is incomplete, so the login button will stay hidden.');
}
if (!localEnabled && !oidcAdminGroup) {
warnings.push('Local login is disabled but no OIDC admin group is configured.');
}
return {
auth_mode: getSetting('auth_mode') || 'multi',
default_user_id: getSetting('default_user_id') || null,
local_login_enabled: localEnabled,
oidc_login_enabled: oidcEnabled,
oidc_configured: oidcConfigured,
...getOidcConfigStatus(),
...getAdminOidcSettings(),
can_disable_local: canDisableLocal,
warnings,
};
}
function applyAuthModeSettings(body = {}) {
const {
auth_mode, default_user_id,
local_login_enabled, oidc_login_enabled, oidc_enabled,
oidc_provider_name, oidc_issuer_url, oidc_client_id, oidc_client_secret,
oidc_client_secret_clear, oidc_token_auth_method, oidc_redirect_uri, oidc_scopes,
oidc_auto_provision, oidc_admin_group, oidc_default_role,
} = body;
if (auth_mode !== undefined) {
if (!['multi', 'single'].includes(auth_mode)) {
throw serviceError('auth_mode must be "multi" or "single"');
}
if (auth_mode === 'single') {
if (!default_user_id) throw serviceError('default_user_id is required for single mode');
const u = getDb().prepare("SELECT id FROM users WHERE id=? AND role='user'").get(default_user_id);
if (!u) throw serviceError('User not found or not a regular user', 404);
}
}
const oidcConfigured = computeSubmittedOidcConfigured(body);
const nextLocal = boolSetting(local_login_enabled, getSetting('local_login_enabled') !== 'false');
const requestedOidc = oidc_login_enabled !== undefined ? oidc_login_enabled : oidc_enabled;
const nextOidc = boolSetting(requestedOidc, getSetting('oidc_login_enabled') === 'true');
const nextAdminGroup = oidc_admin_group !== undefined
? trimOrEmpty(oidc_admin_group)
: getAdminOidcSettings().oidc_admin_group;
if (!nextLocal && !nextOidc) {
throw serviceError('Cannot disable all login methods. At least one must remain enabled.');
}
if (!nextLocal && !oidcConfigured) {
throw serviceError('Cannot disable local login until authentik/OIDC is fully configured.');
}
if (!nextLocal && !nextAdminGroup) {
throw serviceError('Cannot disable local login until an OIDC admin group is configured.');
}
if (nextOidc && !oidcConfigured) {
throw serviceError('Cannot enable OIDC login until issuer URL, client ID, client secret, and redirect URI are configured.');
}
if (auth_mode !== undefined) {
if (auth_mode === 'single') setSetting('default_user_id', default_user_id);
setSetting('auth_mode', auth_mode);
}
if (local_login_enabled !== undefined) setSetting('local_login_enabled', nextLocal ? 'true' : 'false');
if (oidc_login_enabled !== undefined || oidc_enabled !== undefined) {
setSetting('oidc_login_enabled', nextOidc ? 'true' : 'false');
}
if (oidc_provider_name !== undefined) {
const name = String(oidc_provider_name).slice(0, 100).trim();
if (name) setSetting('oidc_provider_name', name);
}
if (oidc_issuer_url !== undefined) setSetting('oidc_issuer_url', trimOrEmpty(oidc_issuer_url).slice(0, 500));
if (oidc_client_id !== undefined) setSetting('oidc_client_id', trimOrEmpty(oidc_client_id).slice(0, 500));
if (oidc_token_auth_method !== undefined) {
const method = oidc_token_auth_method === 'client_secret_post' ? 'client_secret_post' : 'client_secret_basic';
setSetting('oidc_token_auth_method', method);
}
if (oidc_redirect_uri !== undefined) setSetting('oidc_redirect_uri', trimOrEmpty(oidc_redirect_uri).slice(0, 500));
if (oidc_scopes !== undefined) {
const scopes = trimOrEmpty(oidc_scopes).split(/\s+/).filter(Boolean).join(' ') || 'openid email profile groups';
setSetting('oidc_scopes', scopes.slice(0, 500));
}
if (oidc_client_secret_clear === true) setSetting('oidc_client_secret', '');
if (trimOrEmpty(oidc_client_secret)) {
setSetting('oidc_client_secret', trimOrEmpty(oidc_client_secret).slice(0, 1000));
}
if (oidc_auto_provision !== undefined) setSetting('oidc_auto_provision', !!oidc_auto_provision ? 'true' : 'false');
if (oidc_admin_group !== undefined) setSetting('oidc_admin_group', String(oidc_admin_group).slice(0, 200).trim());
if (oidc_default_role !== undefined) {
setSetting('oidc_default_role', 'user');
}
if (
oidc_issuer_url !== undefined ||
oidc_client_id !== undefined ||
oidc_client_secret !== undefined ||
oidc_client_secret_clear === true ||
oidc_token_auth_method !== undefined ||
oidc_redirect_uri !== undefined
) {
invalidateClientCache();
}
return { success: true, ...buildAuthModeStatus() };
}
/** /**
* Returns whether OIDC login is both configured and enabled by admin. * Returns whether OIDC login is both configured and enabled by admin.
*/ */
@ -492,6 +703,10 @@ async function findOrProvisionUser(claims, config) {
} }
module.exports = { module.exports = {
applyAuthModeSettings,
buildAuthModeStatus,
buildSubmittedOidcConfig,
computeSubmittedOidcConfigured,
getOidcConfig, getOidcConfig,
getOidcConfigStatus, getOidcConfigStatus,
getAdminOidcSettings, getAdminOidcSettings,

View File

@ -1362,6 +1362,25 @@ function resolveMonth(decision, previewRow, sessionData) {
return decision.month ?? previewRow?.detected_month ?? sessionData.default_month ?? null; return decision.month ?? previewRow?.detected_month ?? sessionData.default_month ?? null;
} }
function nullableString(value) {
if (value == null) return null;
const text = String(value).trim();
return text === '' ? null : text;
}
function nullableNumber(value) {
if (value == null) return null;
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
function amountsEqual(a, b) {
const left = nullableNumber(a);
const right = nullableNumber(b);
if (left == null || right == null) return left === right;
return Math.abs(left - right) < 0.005;
}
function upsertMonthlyState(db, billId, year, month, amount, notes, isSkipped, allowOverwrite) { function upsertMonthlyState(db, billId, year, month, amount, notes, isSkipped, allowOverwrite) {
const existing = db.prepare(` const existing = db.prepare(`
SELECT id, actual_amount, notes, is_skipped SELECT id, actual_amount, notes, is_skipped
@ -1377,8 +1396,8 @@ function upsertMonthlyState(db, billId, year, month, amount, notes, isSkipped, a
return { result: 'created' }; return { result: 'created' };
} }
const amountConflict = (amount !== null && existing.actual_amount !== null && existing.actual_amount !== amount); const amountConflict = (amount != null && existing.actual_amount !== null && !amountsEqual(existing.actual_amount, amount));
const notesConflict = (notes !== null && existing.notes !== null && existing.notes !== notes); const notesConflict = (notes != null && existing.notes !== null && nullableString(existing.notes) !== nullableString(notes));
if ((amountConflict || notesConflict) && !allowOverwrite) { if ((amountConflict || notesConflict) && !allowOverwrite) {
return { result: 'skipped_conflict', note: 'Monthly state already exists with different values — use overwrite:true to replace' }; return { result: 'skipped_conflict', note: 'Monthly state already exists with different values — use overwrite:true to replace' };
@ -1386,7 +1405,16 @@ function upsertMonthlyState(db, billId, year, month, amount, notes, isSkipped, a
const newAmount = allowOverwrite ? amount : (existing.actual_amount !== null ? existing.actual_amount : amount); const newAmount = allowOverwrite ? amount : (existing.actual_amount !== null ? existing.actual_amount : amount);
const newNotes = allowOverwrite ? notes : (existing.notes !== null ? existing.notes : notes); const newNotes = allowOverwrite ? notes : (existing.notes !== null ? existing.notes : notes);
const newSkipped = isSkipped !== null ? isSkipped : existing.is_skipped; const newSkipped = isSkipped != null ? isSkipped : existing.is_skipped;
const noChange =
amountsEqual(existing.actual_amount, newAmount)
&& nullableString(existing.notes) === nullableString(newNotes)
&& Number(existing.is_skipped ?? 0) === Number(newSkipped ?? 0);
if (noChange && !allowOverwrite) {
return { result: 'skipped_duplicate', note: 'Monthly state already exists with the same values' };
}
db.prepare(` db.prepare(`
UPDATE monthly_bill_state UPDATE monthly_bill_state
@ -1570,9 +1598,16 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv
const st = upsertMonthlyState(db, billId, year, month, amountToStore, noteToStore, isSkipped, allowOverwrite); const st = upsertMonthlyState(db, billId, year, month, amountToStore, noteToStore, isSkipped, allowOverwrite);
if (st.result === 'skipped_conflict') { summary.skipped++; } if (st.result === 'skipped_conflict') {
else if (st.result === 'created') { summary.created++; } summary.skipped++;
else { summary.updated++; } } else if (st.result === 'skipped_duplicate') {
summary.skipped++;
summary.duplicates++;
} else if (st.result === 'created') {
summary.created++;
} else {
summary.updated++;
}
const detail = { row_id, action, result: st.result, bill_id: billId }; const detail = { row_id, action, result: st.result, bill_id: billId };
if (st.note) detail.note = st.note; if (st.note) detail.note = st.note;

View File

@ -52,10 +52,7 @@ function calculateStatus(bill, payments, dueDate, today, options = {}) {
const safePayments = Array.isArray(payments) ? payments : []; const safePayments = Array.isArray(payments) ? payments : [];
const totalPaid = safePayments.reduce((sum, p) => sum + p.amount, 0); const totalPaid = safePayments.reduce((sum, p) => sum + p.amount, 0);
// A recorded payment is the user's confirmation that this cycle is handled. if (totalPaid >= bill.expected_amount) return 'paid';
// Expected amounts are estimates, so a lower actual payment must not leave a Pay
// button visible and invite duplicate payments.
if (safePayments.length > 0 || totalPaid >= bill.expected_amount) return 'paid';
if (bill.autopay_enabled && bill.autodraft_status === 'assumed_paid') { if (bill.autopay_enabled && bill.autodraft_status === 'assumed_paid') {
return 'autodraft'; return 'autodraft';
@ -107,7 +104,11 @@ function buildTrackerRow(bill, payments, year, month, todayStr, options = {}) {
status, status,
autopay_enabled: !!bill.autopay_enabled, autopay_enabled: !!bill.autopay_enabled,
autodraft_status: bill.autodraft_status, autodraft_status: bill.autodraft_status,
auto_mark_paid: !!bill.auto_mark_paid,
billing_cycle: bill.billing_cycle, billing_cycle: bill.billing_cycle,
current_balance: bill.current_balance ?? null,
minimum_payment: bill.minimum_payment ?? null,
interest_rate: bill.interest_rate ?? null,
payments: safePayments, payments: safePayments,
}; };
} }

366
services/trackerService.js Normal file
View File

@ -0,0 +1,366 @@
'use strict';
const { getDb } = require('../db/database');
const { buildTrackerRow, getCycleRange, resolveDueDate } = require('./statusService');
const { getUserSettings } = require('./userSettings');
const { computeBalanceDelta } = require('./billsService');
function validateTrackerMonth(query = {}, now = new Date()) {
const year = parseInt(query.year || now.getFullYear(), 10);
const month = parseInt(query.month || now.getMonth() + 1, 10);
if (Number.isNaN(year) || year < 2000 || year > 2100) {
return { error: 'year must be a 4-digit integer between 2000 and 2100', status: 400 };
}
if (Number.isNaN(month) || month < 1 || month > 12) {
return { error: 'month must be an integer between 1 and 12', status: 400 };
}
return { year, month };
}
function previousMonthFor(year, month) {
return {
year: month === 1 ? year - 1 : year,
month: month === 1 ? 12 : month - 1,
};
}
function monthOffset(year, month, offset) {
let y = year;
let m = month + offset;
while (m <= 0) { m += 12; y -= 1; }
while (m > 12) { m -= 12; y += 1; }
return { year: y, month: m };
}
function groupPaymentsByBill(paymentRows) {
const allPayments = {};
paymentRows.forEach(row => {
if (!allPayments[row.bill_id]) {
allPayments[row.bill_id] = [];
}
allPayments[row.bill_id].push(row);
});
return allPayments;
}
function fetchActiveBills(db, userId, orderBy = 'b.due_day ASC, b.name ASC') {
return db.prepare(`
SELECT b.*, c.name AS category_name
FROM bills b
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
WHERE b.active = 1 AND b.user_id = ? AND b.deleted_at IS NULL
ORDER BY ${orderBy}
`).all(userId);
}
function fetchMonthlyStates(db, billIds, year, month) {
if (billIds.length === 0) return {};
const placeholders = billIds.map(() => '?').join(',');
const rows = db.prepare(`
SELECT bill_id, actual_amount, notes, is_skipped
FROM monthly_bill_state
WHERE bill_id IN (${placeholders}) AND year = ? AND month = ?
`).all(...billIds, year, month);
return Object.fromEntries(rows.map(row => [row.bill_id, row]));
}
function fetchPaymentsByBill(db, billIds, start, end) {
if (billIds.length === 0) return {};
const placeholders = billIds.map(() => '?').join(',');
const rows = db.prepare(`
SELECT bill_id, id, amount, paid_date, method, notes, created_at, updated_at
FROM payments
WHERE bill_id IN (${placeholders}) AND paid_date BETWEEN ? AND ?
AND deleted_at IS NULL
ORDER BY paid_date DESC
`).all(...billIds, start, end);
return groupPaymentsByBill(rows);
}
function fetchPreviousMonthPaid(db, billIds, range) {
if (billIds.length === 0) return {};
const placeholders = billIds.map(() => '?').join(',');
const rows = db.prepare(`
SELECT bill_id, SUM(amount) as total_paid
FROM payments
WHERE bill_id IN (${placeholders}) AND paid_date BETWEEN ? AND ?
AND deleted_at IS NULL
GROUP BY bill_id
`).all(...billIds, range.start, range.end);
return Object.fromEntries(rows.map(row => [row.bill_id, row.total_paid]));
}
function fetchDismissedSuggestions(db, userId, billIds, year, month) {
if (billIds.length === 0) return new Set();
const rows = db.prepare(`
SELECT bill_id
FROM autopay_suggestion_dismissals
WHERE user_id = ? AND year = ? AND month = ?
`).all(userId, year, month);
return new Set(rows.map(row => row.bill_id));
}
function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr, dismissedSuggestions) {
const dueDate = resolveDueDate(bill, year, month);
const suggestedAmount = Number(mbs?.actual_amount ?? bill.expected_amount);
const hasSuggestedAmount = Number.isFinite(suggestedAmount) && suggestedAmount > 0;
const isEligible = !!(
bill.autopay_enabled &&
bill.autodraft_status === 'assumed_paid' &&
hasSuggestedAmount &&
dueDate <= todayStr &&
!mbs?.is_skipped &&
payments.length === 0
);
if (!isEligible) return null;
if (bill.auto_mark_paid) {
const existingPayment = db.prepare(`
SELECT bill_id, id, amount, paid_date, method, notes, created_at, updated_at
FROM payments
WHERE bill_id = ?
AND deleted_at IS NULL
AND strftime('%Y', paid_date) = ?
AND strftime('%m', paid_date) = ?
ORDER BY paid_date DESC
LIMIT 1
`).get(bill.id, String(year), String(month).padStart(2, '0'));
if (existingPayment) {
payments.push(existingPayment);
return null;
}
const balCalc = computeBalanceDelta(bill, suggestedAmount);
const result = db.prepare(`
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
bill.id,
suggestedAmount,
dueDate,
'autopay',
'Auto-marked paid on due date',
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);
bill.current_balance = balCalc.new_balance;
}
payments.push(db.prepare(`
SELECT bill_id, id, amount, paid_date, method, notes, created_at, updated_at
FROM payments
WHERE id = ?
`).get(result.lastInsertRowid));
return null;
}
if (dismissedSuggestions.has(bill.id)) return null;
return {
bill_id: bill.id,
amount: suggestedAmount,
paid_date: dueDate,
method: 'autopay',
};
}
function buildThreeMonthTrend(db, userId, year, month, end, currentMonthPaid) {
const threeMonthsAgo = monthOffset(year, month, -2);
const threeMonthStart = getCycleRange(threeMonthsAgo.year, threeMonthsAgo.month).start;
const rows = db.prepare(`
SELECT SUM(p.amount) as total_paid, strftime('%Y-%m', p.paid_date) as month_key
FROM payments p
JOIN bills b ON p.bill_id = b.id
WHERE b.user_id = ? AND b.deleted_at IS NULL AND p.paid_date BETWEEN ? AND ?
AND p.deleted_at IS NULL
GROUP BY strftime('%Y-%m', p.paid_date)
`).all(userId, threeMonthStart, end);
const monthlyPaymentsMap = new Map();
rows.forEach(payment => {
monthlyPaymentsMap.set(payment.month_key, payment.total_paid);
});
const months = [];
for (let i = 2; i >= 0; i--) {
const date = new Date(year, month - 1 - i);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
months.push({
year: date.getFullYear(),
month: date.getMonth() + 1,
key: monthKey,
payment: parseFloat(monthlyPaymentsMap.get(monthKey) || 0),
});
}
const threeMonthAvg = months.reduce((sum, m) => sum + m.payment, 0) / 3;
let percentChange = 0;
let direction = 'flat';
if (threeMonthAvg > 0) {
percentChange = ((currentMonthPaid - threeMonthAvg) / threeMonthAvg) * 100;
if (percentChange > 2) direction = 'up';
else if (percentChange < -2) direction = 'down';
}
return {
three_month_avg: parseFloat(threeMonthAvg.toFixed(2)),
current_month_paid: parseFloat(currentMonthPaid.toFixed(2)),
percent_change: parseFloat(percentChange.toFixed(1)),
direction,
};
}
function getTracker(userId, query = {}, now = new Date()) {
const parsed = validateTrackerMonth(query, now);
if (parsed.error) return parsed;
const db = getDb();
const { year, month } = parsed;
const todayStr = now.toISOString().slice(0, 10);
const userSettings = getUserSettings(userId);
const rowOptions = { gracePeriodDays: userSettings.grace_period_days };
const { start, end } = getCycleRange(year, month);
const previousMonth = previousMonthFor(year, month);
const prevMonthRange = getCycleRange(previousMonth.year, previousMonth.month);
const bills = fetchActiveBills(db, userId);
const billIds = bills.map(bill => bill.id);
const monthlyStates = fetchMonthlyStates(db, billIds, year, month);
const allPayments = fetchPaymentsByBill(db, billIds, start, end);
const prevMonthPayments = fetchPreviousMonthPaid(db, billIds, prevMonthRange);
const dismissedSuggestions = fetchDismissedSuggestions(db, userId, billIds, year, month);
const rows = bills.map(bill => {
const payments = allPayments[bill.id] || [];
const mbs = monthlyStates[bill.id];
const autopaySuggestion = applyAutopaySuggestions(
db,
bill,
payments,
mbs,
year,
month,
todayStr,
dismissedSuggestions,
);
const billForStatus = mbs?.actual_amount != null
? { ...bill, expected_amount: mbs.actual_amount }
: bill;
const row = buildTrackerRow(billForStatus, payments, year, month, todayStr, rowOptions);
row.expected_amount = bill.expected_amount;
row.actual_amount = mbs?.actual_amount ?? null;
row.monthly_notes = mbs?.notes ?? null;
row.is_skipped = !!(mbs?.is_skipped);
if (autopaySuggestion) row.autopay_suggestion = autopaySuggestion;
row.previous_month_paid = prevMonthPayments[bill.id] || 0;
return row;
});
const activeRows = rows.filter(r => !r.is_skipped);
const startingAmounts = db.prepare(`
SELECT COALESCE(first_amount, 0) AS first_amount,
COALESCE(fifteenth_amount, 0) AS fifteenth_amount,
COALESCE(other_amount, 0) AS other_amount,
COALESCE(first_amount, 0) + COALESCE(fifteenth_amount, 0) + COALESCE(other_amount, 0) AS combined_amount
FROM monthly_starting_amounts
WHERE user_id = ? AND year = ? AND month = ?
`).get(userId, year, month);
const dayOfMonth = now.getDate();
const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th';
const periodRows = activeRows.filter(r => r.bucket === activeRemainingPeriod);
const periodPaid = periodRows.reduce((s, r) => s + r.total_paid, 0);
const periodOutstandingBalance = periodRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0);
const periodStartingAmount = activeRemainingPeriod === '1st'
? (startingAmounts?.first_amount || 0)
: (startingAmounts?.fifteenth_amount || 0);
const periodLabel = activeRemainingPeriod === '1st' ? '1st balance' : '15th balance';
const totalStarting = startingAmounts?.combined_amount || 0;
const hasStartingAmounts = !!startingAmounts;
const activeTotalPaid = activeRows.reduce((s, r) => s + r.total_paid, 0);
const activeTotalExpected = activeRows.reduce((s, r) => s + r.expected_amount, 0);
const activeOutstandingBalance = activeRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0);
const totalOverdue = rows
.filter(r => !r.is_skipped && (r.status === 'late' || r.status === 'missed'))
.reduce((s, r) => s + r.balance, 0);
const previousMonthTotal = activeRows.reduce((s, r) => s + r.previous_month_paid, 0);
return {
year,
month,
today: todayStr,
summary: {
total_expected: activeTotalExpected,
total_starting: totalStarting,
has_starting_amounts: hasStartingAmounts,
total_paid: activeTotalPaid,
remaining: hasStartingAmounts ? periodStartingAmount - periodPaid : periodOutstandingBalance,
total_remaining: hasStartingAmounts ? totalStarting - activeTotalPaid : activeOutstandingBalance,
remaining_period: activeRemainingPeriod,
remaining_label: periodLabel,
remaining_hint: hasStartingAmounts
? `${periodLabel}: ${periodStartingAmount.toFixed(2)} starting minus ${periodPaid.toFixed(2)} paid`
: `${periodLabel}: unpaid bills due in this period`,
overdue: totalOverdue,
count_paid: activeRows.filter(r => r.status === 'paid').length,
count_upcoming: activeRows.filter(r => r.status === 'upcoming' || r.status === 'due_soon').length,
count_late: activeRows.filter(r => r.status === 'late' || r.status === 'missed').length,
count_autodraft: activeRows.filter(r => r.status === 'autodraft').length,
previous_month_total: previousMonthTotal,
trend: buildThreeMonthTrend(db, userId, year, month, end, activeTotalPaid),
},
rows,
};
}
function getUpcomingBills(userId, query = {}, now = new Date()) {
const db = getDb();
const days = Math.max(1, Math.min(parseInt(query.days || '30', 10) || 30, 365));
const todayStr = now.toISOString().slice(0, 10);
const userSettings = getUserSettings(userId);
const rowOptions = { gracePeriodDays: userSettings.grace_period_days };
const year = now.getFullYear();
const month = now.getMonth() + 1;
const { start, end } = getCycleRange(year, month);
const bills = fetchActiveBills(db, userId, 'b.id ASC');
const billIds = bills.map(bill => bill.id);
const allPayments = fetchPaymentsByBill(db, billIds, start, end);
const cutoff = new Date(now);
cutoff.setDate(cutoff.getDate() + days);
const cutoffStr = cutoff.toISOString().slice(0, 10);
const upcoming = [];
for (const bill of bills) {
const dueDate = resolveDueDate(bill, year, month);
if (dueDate < todayStr || dueDate > cutoffStr) continue;
const row = buildTrackerRow(bill, allPayments[bill.id] || [], year, month, todayStr, rowOptions);
if (row.status === 'paid') continue;
upcoming.push({
id: bill.id,
name: bill.name,
category_name: bill.category_name,
due_date: dueDate,
expected_amount: bill.expected_amount,
status: row.status,
days_until_due: Math.floor((new Date(dueDate) - now) / 86400000),
});
}
upcoming.sort((a, b) => a.due_date.localeCompare(b.due_date));
return { days, today: todayStr, upcoming };
}
module.exports = {
getTracker,
getUpcomingBills,
validateTrackerMonth,
};