v0.28.0
This commit is contained in:
parent
8913436575
commit
b124e48ebc
|
|
@ -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={
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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?.();
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
113
db/database.js
113
db/database.js
|
|
@ -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',
|
||||||
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
231
routes/admin.js
231
routes/admin.js
|
|
@ -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 ────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
242
routes/bills.js
242
routes/bills.js
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue