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 AppNavigation from '@/components/layout/Sidebar';
|
||||
import { ReleaseNotesDialog } from '@/components/ReleaseNotesDialog';
|
||||
import CommandPalette from '@/components/CommandPalette';
|
||||
import LoginPage from '@/pages/LoginPage';
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
import PageLoader from '@/components/PageLoader';
|
||||
|
|
@ -81,7 +82,7 @@ function AdminShell({ children }) {
|
|||
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">
|
||||
<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}
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -96,6 +97,7 @@ export default function App() {
|
|||
<QueryClientProvider client={queryClient}>
|
||||
{/* Release notes (only for user role) */}
|
||||
{user?.role === 'user' && <ReleaseNotesDialog />}
|
||||
{user && !user.is_default_admin && <CommandPalette />}
|
||||
|
||||
{/* Skip link for keyboard users */}
|
||||
<a
|
||||
|
|
@ -138,6 +140,20 @@ export default function App() {
|
|||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/roadmap"
|
||||
element={
|
||||
<RequireAuth role="admin">
|
||||
<ErrorBoundary>
|
||||
<AdminShell>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<RoadmapPage />
|
||||
</Suspense>
|
||||
</AdminShell>
|
||||
</ErrorBoundary>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/roadmap"
|
||||
element={
|
||||
|
|
|
|||
|
|
@ -34,6 +34,15 @@ const post = (path, body) => _fetch('POST', path, body);
|
|||
const put = (path, body) => _fetch('PUT', path, body);
|
||||
const del = (path) => _fetch('DELETE', path);
|
||||
|
||||
function queryString(params = {}) {
|
||||
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) {
|
||||
if (!value) return null;
|
||||
const match = value.match(/filename="?([^"]+)"?/i);
|
||||
|
|
@ -126,7 +135,7 @@ export const api = {
|
|||
profileImportHistory: () => get('/profile/import-history'),
|
||||
|
||||
// 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}`),
|
||||
|
||||
// Calendar
|
||||
|
|
@ -139,8 +148,8 @@ export const api = {
|
|||
updateMonthlyStartingAmounts: (data) => put('/monthly-starting-amounts', data),
|
||||
|
||||
// Bills
|
||||
bills: () => get('/bills'),
|
||||
allBills: () => get('/bills?inactive=true'),
|
||||
bills: (params = {}) => get(`/bills${queryString(params)}`),
|
||||
allBills: (params = {}) => get(`/bills${queryString({ inactive: true, ...params })}`),
|
||||
billAudit: (includeInactive = false) => get(`/bills/audit${includeInactive ? '?inactive=true' : ''}`),
|
||||
bill: (id) => get(`/bills/${id}`),
|
||||
createBill: (data) => post('/bills', data),
|
||||
|
|
@ -156,6 +165,7 @@ export const api = {
|
|||
updateBillSnowball: (id, data) => _fetch('PATCH', `/bills/${id}/snowball`, data),
|
||||
deleteBill: (id) => del(`/bills/${id}`),
|
||||
restoreBill: (id) => post(`/bills/${id}/restore`),
|
||||
duplicateBill: (id, data) => post(`/bills/${id}/duplicate`, data),
|
||||
togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data),
|
||||
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}`),
|
||||
|
|
@ -164,9 +174,14 @@ export const api = {
|
|||
createBillHistoryRange: (id, data) => post(`/bills/${id}/history-ranges`, data),
|
||||
updateBillHistoryRange: (id, rangeId, data) => put(`/bills/${id}/history-ranges/${rangeId}`, data),
|
||||
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
|
||||
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),
|
||||
createPayment: (data) => post('/payments', data),
|
||||
updatePayment: (id, data) => put(`/payments/${id}`, data),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { ChevronDown, Copy } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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;
|
||||
}
|
||||
|
||||
export default function BillModal({ bill, categories, onClose, onSave }) {
|
||||
export default function BillModal({ bill, initialBill, categories, onClose, onSave, onDuplicate }) {
|
||||
const isNew = !bill;
|
||||
const sourceBill = bill || initialBill || null;
|
||||
|
||||
const [name, setName] = useState(bill?.name || '');
|
||||
const [categoryId, setCategoryId] = useState(bill?.category_id ? String(bill.category_id) : CAT_NONE);
|
||||
const [dueDay, setDueDay] = useState(String(bill?.due_day || ''));
|
||||
const [expectedAmount, setExpected] = useState(String(bill?.expected_amount || ''));
|
||||
const [interestRate, setInterestRate] = useState(bill?.interest_rate == null ? '' : String(bill.interest_rate));
|
||||
const [billingCycle, setCycle] = useState(bill?.billing_cycle || 'monthly');
|
||||
const [cycleType, setCycleType] = useState(bill?.cycle_type || 'monthly');
|
||||
const [cycleDay, setCycleDay] = useState(bill?.cycle_day || '1');
|
||||
const [autopay, setAutopay] = useState(!!bill?.autopay_enabled);
|
||||
const [has2fa, setHas2fa] = useState(!!bill?.has_2fa);
|
||||
const [website, setWebsite] = useState(bill?.website || '');
|
||||
const [username, setUsername] = useState(bill?.username || '');
|
||||
const [accountInfo, setAccountInfo] = useState(bill?.account_info || '');
|
||||
const [notes, setNotes] = useState(bill?.notes || '');
|
||||
const [currentBalance, setCurrentBalance] = useState(bill?.current_balance == null ? '' : String(bill.current_balance));
|
||||
const [minimumPayment, setMinimumPayment] = useState(bill?.minimum_payment == null ? '' : String(bill.minimum_payment));
|
||||
const [snowballInclude, setSnowballInclude] = useState(!!bill?.snowball_include);
|
||||
const [snowballExempt, setSnowballExempt] = useState(!!bill?.snowball_exempt);
|
||||
const [name, setName] = useState(sourceBill?.name || '');
|
||||
const [categoryId, setCategoryId] = useState(sourceBill?.category_id ? String(sourceBill.category_id) : CAT_NONE);
|
||||
const [dueDay, setDueDay] = useState(String(sourceBill?.due_day || ''));
|
||||
const [expectedAmount, setExpected] = useState(String(sourceBill?.expected_amount || ''));
|
||||
const [interestRate, setInterestRate] = useState(sourceBill?.interest_rate == null ? '' : String(sourceBill.interest_rate));
|
||||
const [billingCycle, setCycle] = useState(sourceBill?.billing_cycle || 'monthly');
|
||||
const [cycleType, setCycleType] = useState(sourceBill?.cycle_type || 'monthly');
|
||||
const [cycleDay, setCycleDay] = useState(sourceBill?.cycle_day || '1');
|
||||
const [autopay, setAutopay] = useState(!!sourceBill?.autopay_enabled);
|
||||
const [autodraftStatus, setAutodraftStatus] = useState(sourceBill?.autodraft_status || (sourceBill?.autopay_enabled ? 'assumed_paid' : 'none'));
|
||||
const [autoMarkPaid, setAutoMarkPaid] = useState(!!sourceBill?.auto_mark_paid);
|
||||
const [has2fa, setHas2fa] = useState(!!sourceBill?.has_2fa);
|
||||
const [website, setWebsite] = useState(sourceBill?.website || '');
|
||||
const [username, setUsername] = useState(sourceBill?.username || '');
|
||||
const [accountInfo, setAccountInfo] = useState(sourceBill?.account_info || '');
|
||||
const [notes, setNotes] = useState(sourceBill?.notes || '');
|
||||
const [currentBalance, setCurrentBalance] = useState(sourceBill?.current_balance == null ? '' : String(sourceBill.current_balance));
|
||||
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(
|
||||
() => isDebtCat(categories, bill?.category_id ? String(bill.category_id) : CAT_NONE)
|
||||
|| !!bill?.snowball_include
|
||||
|| !!bill?.snowball_exempt
|
||||
|| bill?.current_balance != null
|
||||
|| bill?.minimum_payment != null
|
||||
() => isDebtCat(categories, sourceBill?.category_id ? String(sourceBill.category_id) : CAT_NONE)
|
||||
|| !!sourceBill?.snowball_include
|
||||
|| !!sourceBill?.snowball_exempt
|
||||
|| sourceBill?.current_balance != null
|
||||
|| sourceBill?.minimum_payment != null
|
||||
);
|
||||
const [saveTemplate, setSaveTemplate] = useState(false);
|
||||
const [templateName, setTemplateName] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const isDebtCategory = isDebtCat(categories, categoryId);
|
||||
const isSnowballCategory = isSnowballCat(categories, categoryId);
|
||||
const showOnSnowball = snowballInclude || (isSnowballCategory && !snowballExempt);
|
||||
const canAutoMarkPaid = autopay && autodraftStatus === 'assumed_paid';
|
||||
|
||||
const validateName = (val) => {
|
||||
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) {
|
||||
e.preventDefault();
|
||||
|
||||
|
|
@ -180,34 +196,49 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
|
|||
}
|
||||
|
||||
const data = {
|
||||
source_bill_id: sourceBill?.source_bill_id,
|
||||
name: name.trim(),
|
||||
category_id: categoryId === CAT_NONE ? null : parseInt(categoryId, 10),
|
||||
due_day: parsedDueDay,
|
||||
override_due_date: sourceBill?.override_due_date,
|
||||
expected_amount: parseFloat(expectedAmount) || 0,
|
||||
interest_rate: parsedInterestRate,
|
||||
billing_cycle: billingCycle,
|
||||
cycle_type: cycleType,
|
||||
cycle_day: cycleDay,
|
||||
autopay_enabled: autopay,
|
||||
autodraft_status: autopay ? autodraftStatus : 'none',
|
||||
auto_mark_paid: canAutoMarkPaid && autoMarkPaid,
|
||||
has_2fa: has2fa,
|
||||
website: website || null,
|
||||
username: username || null,
|
||||
account_info: accountInfo || null,
|
||||
notes: notes || null,
|
||||
history_visibility: sourceBill?.history_visibility,
|
||||
current_balance: currentBalance === '' ? null : parseFloat(currentBalance),
|
||||
minimum_payment: minimumPayment === '' ? null : parseFloat(minimumPayment),
|
||||
snowball_order: sourceBill?.snowball_order,
|
||||
snowball_include: snowballInclude,
|
||||
snowball_exempt: snowballExempt,
|
||||
};
|
||||
setBusy(true);
|
||||
try {
|
||||
if (isNew) {
|
||||
if (data.source_bill_id) {
|
||||
await api.duplicateBill(data.source_bill_id, data);
|
||||
} else {
|
||||
await api.createBill(data);
|
||||
}
|
||||
toast.success('Bill added');
|
||||
} else {
|
||||
await api.updateBill(bill.id, data);
|
||||
toast.success('Bill updated');
|
||||
}
|
||||
if (saveTemplate) {
|
||||
const safeTemplateName = templateName.trim() || data.name;
|
||||
await api.saveBillTemplate({ name: safeTemplateName, data });
|
||||
toast.success('Template saved');
|
||||
}
|
||||
onSave();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
|
|
@ -526,13 +557,28 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
|
|||
<input
|
||||
type="checkbox"
|
||||
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"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
Autopay / Autodraft
|
||||
</span>
|
||||
</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">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -546,6 +592,21 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
|
|||
</label>
|
||||
</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 */}
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<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 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>
|
||||
</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">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form="bill-modal-form" disabled={busy} className="text-xs">
|
||||
{isNew ? 'Add Bill' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</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 { MobileBillRow } from '@/components/MobileBillRow';
|
||||
|
||||
function ordinal(n) {
|
||||
const d = Number(n);
|
||||
|
|
@ -30,7 +31,7 @@ const ALL_ON = {
|
|||
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 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" />
|
||||
</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 && (
|
||||
<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 (
|
||||
<div className="divide-y divide-border/30">
|
||||
<>
|
||||
<div className="hidden divide-y divide-border/30 sm:block">
|
||||
{bills.map(bill => (
|
||||
<BillCard
|
||||
key={bill.id}
|
||||
|
|
@ -183,8 +194,23 @@ export default function BillsTableInner({ bills, prefs = ALL_ON, onEdit, onToggl
|
|||
onToggle={onToggle}
|
||||
onDelete={onDelete}
|
||||
onHistory={onHistory}
|
||||
onDuplicate={onDuplicate}
|
||||
/>
|
||||
))}
|
||||
</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 { History } from 'lucide-react';
|
||||
import { Copy, History } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
|
|
@ -8,7 +8,7 @@ function hasHistoricalVisibility(bill) {
|
|||
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 statusClass = useMemo(() => {
|
||||
|
|
@ -94,6 +94,15 @@ export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, o
|
|||
</div>
|
||||
|
||||
<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
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState, useMemo } from 'react';
|
|||
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
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';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
|
@ -27,7 +27,7 @@ const userNavItems = [
|
|||
const adminNavItems = [
|
||||
{ to: '/admin', icon: ShieldCheck, label: 'Admin Panel', end: true },
|
||||
{ to: '/admin/status', icon: Activity, label: 'System Status' },
|
||||
{ to: '/admin/roadmap', icon: Map, label: 'Roadmap' },
|
||||
{ to: '/roadmap', icon: Map, label: 'Roadmap' },
|
||||
];
|
||||
|
||||
const trackerItems = [
|
||||
|
|
@ -147,7 +147,7 @@ function UserMenu({ adminMode = false }) {
|
|||
About
|
||||
</DropdownMenuItem>
|
||||
{user?.role === 'admin' && (
|
||||
<DropdownMenuItem onSelect={() => navigate('/admin/roadmap')}>
|
||||
<DropdownMenuItem onSelect={() => navigate('/roadmap')}>
|
||||
<Map className="h-4 w-4" />
|
||||
Roadmap
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -180,6 +180,21 @@ export default function Sidebar({ adminMode = false }) {
|
|||
</nav>
|
||||
|
||||
<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" />
|
||||
<UserMenu adminMode={adminMode} />
|
||||
<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 { Plus, ChevronRight, SlidersHorizontal } from 'lucide-react';
|
||||
import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Plus, ChevronRight, SlidersHorizontal, Search, Trash2, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
|
@ -19,6 +20,7 @@ import { api } from '@/api';
|
|||
import { cn } from '@/lib/utils';
|
||||
import BillsTableInner from '@/components/BillsTableInner';
|
||||
import BillModal from '@/components/BillModal';
|
||||
import { makeBillDraft } from '@/lib/billDrafts';
|
||||
|
||||
const VISIBILITY_OPTIONS = [
|
||||
{ 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'],
|
||||
['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
|
||||
|
||||
|
|
@ -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 }) {
|
||||
|
|
@ -430,10 +546,22 @@ function HistoryVisibilityDialog({ bill, onClose, onSaved }) {
|
|||
|
||||
// ── Bills Page ─────────────────────────────────────────────────────────────
|
||||
export default function BillsPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [bills, setBills] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [savedTemplates, setSavedTemplates] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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
|
||||
const [modal, setModal] = useState(null);
|
||||
|
|
@ -448,12 +576,14 @@ export default function BillsPage() {
|
|||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [billsRes, catRes] = await Promise.all([
|
||||
const [billsRes, catRes, templateRes] = await Promise.all([
|
||||
api.allBills(),
|
||||
api.categories(),
|
||||
api.billTemplates(),
|
||||
]);
|
||||
setBills(billsRes || []);
|
||||
setCategories(catRes || []);
|
||||
setSavedTemplates(templateRes || []);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
|
|
@ -463,6 +593,46 @@ export default function BillsPage() {
|
|||
|
||||
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) {
|
||||
try {
|
||||
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) {
|
||||
if (bill.active) {
|
||||
// Prompt confirmation before deactivating
|
||||
|
|
@ -537,8 +725,38 @@ export default function BillsPage() {
|
|||
await doToggle(bill);
|
||||
}
|
||||
|
||||
const active = bills.filter(b => b.active);
|
||||
const inactive = bills.filter(b => !b.active);
|
||||
const cycleOptions = useMemo(() => (
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -551,18 +769,38 @@ export default function BillsPage() {
|
|||
</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Bills</h1>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{active.length} active
|
||||
{inactive.length > 0 && (
|
||||
<span className="text-muted-foreground/50"> · {inactive.length} inactive</span>
|
||||
{totalActive} active
|
||||
{totalInactive > 0 && (
|
||||
<span className="text-muted-foreground/50"> · {totalInactive} inactive</span>
|
||||
)}
|
||||
</p>
|
||||
</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} />
|
||||
<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
|
||||
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" />
|
||||
Add Bill
|
||||
|
|
@ -570,7 +808,64 @@ export default function BillsPage() {
|
|||
</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 ── */}
|
||||
{!filters.inactive && (
|
||||
<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">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
|
|
@ -587,6 +882,10 @@ export default function BillsPage() {
|
|||
</div>
|
||||
) : active.length === 0 ? (
|
||||
<div className="py-16 text-center text-sm text-muted-foreground">
|
||||
{hasFilters ? (
|
||||
<>No active bills match your filters.</>
|
||||
) : (
|
||||
<>
|
||||
No active bills.{' '}
|
||||
<button
|
||||
onClick={() => setModal({ bill: null })}
|
||||
|
|
@ -594,6 +893,8 @@ export default function BillsPage() {
|
|||
>
|
||||
Add your first bill
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<BillsTableInner
|
||||
|
|
@ -602,9 +903,11 @@ export default function BillsPage() {
|
|||
onEdit={handleEdit}
|
||||
onToggle={handleToggle}
|
||||
onDelete={handleDeleteRequest}
|
||||
onDuplicate={handleDuplicateBill}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Inactive Bills ── */}
|
||||
{!loading && inactive.length > 0 && (
|
||||
|
|
@ -623,7 +926,7 @@ export default function BillsPage() {
|
|||
</span>
|
||||
</button>
|
||||
|
||||
{showInactive && (
|
||||
{(showInactive || filters.inactive) && (
|
||||
<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">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
|
|
@ -638,6 +941,7 @@ export default function BillsPage() {
|
|||
onToggle={handleToggle}
|
||||
onDelete={handleDeleteRequest}
|
||||
onHistory={setHistoryTarget}
|
||||
onDuplicate={handleDuplicateBill}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -645,13 +949,22 @@ export default function BillsPage() {
|
|||
</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 ── */}
|
||||
{modal && (
|
||||
<BillModal
|
||||
key={modal.bill?.id ? `edit-${modal.bill.id}` : `new-${modal.initialBill?.name || 'blank'}`}
|
||||
bill={modal.bill}
|
||||
initialBill={modal.initialBill}
|
||||
categories={categories}
|
||||
onClose={() => setModal(null)}
|
||||
onSave={load}
|
||||
onDuplicate={handleDuplicateBill}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1083,8 +1083,33 @@ function rowDateLabel(row) {
|
|||
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 }) {
|
||||
const { bill, rows } = group;
|
||||
const { completedCount, remainingCount } = billImportProgress(rows, importResult);
|
||||
const sorted = [...rows].sort((a, b) => {
|
||||
const da = (a.detected_year ?? 0) * 100 + (a.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
|
||||
</button>
|
||||
<span className="text-sm font-medium truncate">{bill.name}</span>
|
||||
{importResult ? (
|
||||
<div className="text-right shrink-0">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{importResult && (
|
||||
<div className="text-right">
|
||||
<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
|
||||
? `✓ ${importResult.created + importResult.updated} imported`
|
||||
: `⚠ already existed`}
|
||||
{importResult.duplicates > 0 && importResult.created + importResult.updated > 0
|
||||
{completedCount === rows.length
|
||||
? 'All imported'
|
||||
: `${completedCount} imported · ${remainingCount} remaining`}
|
||||
{importResult.duplicates > 0
|
||||
&& ` · ${importResult.duplicates} dupes`}
|
||||
</span>
|
||||
{importResult.duplicates > 0 && importResult.earliestDup && (
|
||||
|
|
@ -1117,12 +1143,12 @@ function BillDetailView({ group, onBack, onImport, isImporting, importResult })
|
|||
</p>
|
||||
)}
|
||||
</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 className="divide-y divide-border/30 max-h-80 overflow-y-auto">
|
||||
{sorted.map(row => (
|
||||
|
|
@ -1174,6 +1200,7 @@ function BillHistoryView({ previewRows, allBills, importingBillId, billImportRes
|
|||
const { bill, rows, counts } = g;
|
||||
const isImporting = importingBillId === bill.id;
|
||||
const importResult = billImportResults.get(bill.id) ?? null;
|
||||
const { completedCount, remainingCount } = billImportProgress(rows, importResult);
|
||||
|
||||
const sorted3 = [...rows]
|
||||
.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.low > 0 && <span className="text-[10px] text-muted-foreground/50">{counts.low} low</span>}
|
||||
{importResult && (() => {
|
||||
const imported = importResult.created + importResult.updated;
|
||||
const allDupes = imported === 0 && importResult.duplicates > 0;
|
||||
const allImported = completedCount === rows.length;
|
||||
const fmtDate = d => d?.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
const dupDate = importResult.earliestDup ? ` · ${fmtDate(importResult.earliestDup)}` : '';
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<span className={`text-[10px] font-medium ${allDupes ? 'text-amber-400' : 'text-emerald-500'}`}>
|
||||
{imported > 0 ? `✓ ${imported} imported` : '⚠ already existed'}
|
||||
{importResult.duplicates > 0 && imported > 0 && ` · ${importResult.duplicates} dupes`}
|
||||
<span className={`text-[10px] font-medium ${allImported ? 'text-emerald-500' : 'text-amber-400'}`}>
|
||||
{allImported ? 'All imported' : `${completedCount} imported · ${remainingCount} remaining`}
|
||||
{importResult.duplicates > 0 && ` · ${importResult.duplicates} dupes`}
|
||||
{importResult.errored > 0 && ` · ${importResult.errored} errors`}
|
||||
</span>
|
||||
{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">
|
||||
{importResult ? (
|
||||
<Button size="sm" variant="outline" onClick={() => onImportBill(g)}
|
||||
disabled={!!importingBillId} className="h-7 text-xs px-3 gap-1.5">
|
||||
Re-import
|
||||
disabled={!!importingBillId || remainingCount === 0} className="h-7 text-xs px-3 gap-1.5">
|
||||
{remainingCount === 0 ? 'All imported' : `Import ${remainingCount}`}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={() => onImportBill(g)}
|
||||
|
|
@ -1331,9 +1357,16 @@ export function ImportSpreadsheetSection({ onHistoryRefresh }) {
|
|||
const sessionId = preview.data?.import_session_id;
|
||||
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);
|
||||
try {
|
||||
const decisionsList = group.rows.map(row => ({
|
||||
const decisionsList = rowsToImport.map(row => ({
|
||||
row_id: row.row_id,
|
||||
action: 'match_existing_bill',
|
||||
bill_id: group.bill.id,
|
||||
|
|
@ -1351,11 +1384,18 @@ export function ImportSpreadsheetSection({ onHistoryRefresh }) {
|
|||
const created = result.rows_created ?? 0;
|
||||
const updated = result.rows_updated ?? 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
|
||||
// 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)
|
||||
.map(d => new Date(d.existing_created_at))
|
||||
.filter(d => !isNaN(d.getTime()))
|
||||
|
|
@ -1363,20 +1403,47 @@ export function ImportSpreadsheetSection({ onHistoryRefresh }) {
|
|||
|
||||
const earliestDup = dupDates[0] ?? 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, {
|
||||
created, updated, errored, duplicates, earliestDup, latestDup,
|
||||
}));
|
||||
for (const detail of details) {
|
||||
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 fmtDate = d => d?.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
const remainingCount = billImportProgress(group.rows, mergedResult).remainingCount;
|
||||
|
||||
if (imported === 0 && duplicates > 0) {
|
||||
const dateHint = earliestDup
|
||||
? ` (first recorded ${fmtDate(earliestDup)})`
|
||||
: '';
|
||||
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 {
|
||||
const parts = [`${imported} entr${imported === 1 ? 'y' : 'ies'} imported`];
|
||||
|
|
@ -1385,6 +1452,7 @@ export function ImportSpreadsheetSection({ onHistoryRefresh }) {
|
|||
parts.push(`${duplicates} already existed${dateHint}`);
|
||||
}
|
||||
if (errored > 0) parts.push(`${errored} error${errored > 1 ? 's' : ''}`);
|
||||
if (remainingCount > 0) parts.push(`${remainingCount} remaining`);
|
||||
toast.success(`${group.bill.name} — ${parts.join(' · ')}`);
|
||||
}
|
||||
onHistoryRefresh?.();
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { Button } from '@/components/ui/button';
|
|||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import BillModal from '@/components/BillModal';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { makeBillDraft } from '@/lib/billDrafts';
|
||||
|
||||
const FIELD_LABELS = {
|
||||
due_day: 'Due day',
|
||||
|
|
@ -122,7 +123,7 @@ export default function HealthPage() {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [includeInactive, setIncludeInactive] = useState(false);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [modalBill, setModalBill] = useState(null);
|
||||
const [modal, setModal] = useState(null);
|
||||
const [openingBillId, setOpeningBillId] = useState(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
|
|
@ -146,7 +147,7 @@ export default function HealthPage() {
|
|||
categories.length ? Promise.resolve(categories) : api.categories(),
|
||||
]);
|
||||
if (!categories.length) setCategories(cats);
|
||||
setModalBill(bill);
|
||||
setModal({ bill });
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Could not open bill.');
|
||||
} finally {
|
||||
|
|
@ -155,7 +156,7 @@ export default function HealthPage() {
|
|||
}, [categories]);
|
||||
|
||||
const handleBillSaved = useCallback(() => {
|
||||
setModalBill(null);
|
||||
setModal(null);
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
|
|
@ -251,12 +252,15 @@ export default function HealthPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{modalBill && (
|
||||
{modal && (
|
||||
<BillModal
|
||||
bill={modalBill}
|
||||
key={modal.bill?.id ? `edit-${modal.bill.id}` : `new-${modal.initialBill?.name || 'blank'}`}
|
||||
bill={modal.bill}
|
||||
initialBill={modal.initialBill}
|
||||
categories={categories}
|
||||
onClose={() => setModalBill(null)}
|
||||
onClose={() => setModal(null)}
|
||||
onSave={handleBillSaved}
|
||||
onDuplicate={bill => setModal({ bill: null, initialBill: makeBillDraft(bill, { copy: true, categories }) })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,11 +14,11 @@ import { APP_VERSION } from '@/lib/version';
|
|||
/* ─── 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: '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: '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: '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: '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: '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', 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', 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', 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', textColor: 'text-muted-foreground', badgeClass: 'bg-muted/50 text-muted-foreground border-border/50' },
|
||||
];
|
||||
|
||||
// Normalise any priority string to a lane key.
|
||||
|
|
@ -220,7 +220,7 @@ function DevLogEntry({ entry }) {
|
|||
{entry.workCompleted.map((work, idx) => (
|
||||
<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>
|
||||
{work}
|
||||
<span className="min-w-0 break-words">{work}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
@ -253,14 +253,23 @@ export default function RoadmapPage() {
|
|||
setForceKey(prev => prev + 1);
|
||||
};
|
||||
|
||||
const [isDesktop, setIsDesktop] = useState(
|
||||
typeof window !== 'undefined' ? window.matchMedia('(min-width: 1024px)').matches : true
|
||||
const getIsDesktop = () => (
|
||||
typeof window !== 'undefined'
|
||||
&& typeof window.matchMedia === 'function'
|
||||
&& window.matchMedia('(min-width: 1024px)').matches
|
||||
);
|
||||
const [isDesktop, setIsDesktop] = useState(getIsDesktop);
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return undefined;
|
||||
const mq = window.matchMedia('(min-width: 1024px)');
|
||||
const handler = (e) => setIsDesktop(e.matches);
|
||||
setIsDesktop(mq.matches);
|
||||
if (typeof mq.addEventListener === 'function') {
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}
|
||||
mq.addListener(handler);
|
||||
return () => mq.removeListener(handler);
|
||||
}, []);
|
||||
|
||||
// Fetch roadmap on mount
|
||||
|
|
@ -315,7 +324,7 @@ export default function RoadmapPage() {
|
|||
</div>
|
||||
|
||||
{/* 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">
|
||||
<TabsTrigger value="roadmap" className="gap-1.5">
|
||||
<Map className="h-3.5 w-3.5" />
|
||||
|
|
@ -353,12 +362,12 @@ export default function RoadmapPage() {
|
|||
</div>
|
||||
|
||||
{/* 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} />)}
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{grouped.filter(l => l.key === 'critical' || l.key === 'high').map(lane =>
|
||||
<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 { cn } from '@/lib/utils';
|
||||
import BillModal from '@/components/BillModal';
|
||||
import { makeBillDraft } from '@/lib/billDrafts';
|
||||
|
||||
// ── formatters ────────────────────────────────────────────────────────────────
|
||||
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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditBill(bill)}
|
||||
onClick={() => setEditBill({ bill })}
|
||||
title="Edit bill"
|
||||
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 */}
|
||||
{editBill && (
|
||||
<BillModal
|
||||
bill={editBill}
|
||||
key={editBill.bill?.id ? `edit-${editBill.bill.id}` : `new-${editBill.initialBill?.name || 'blank'}`}
|
||||
bill={editBill.bill}
|
||||
initialBill={editBill.initialBill}
|
||||
categories={categories}
|
||||
onClose={() => setEditBill(null)}
|
||||
onSave={() => { setEditBill(null); load(); loadProjection(); }}
|
||||
onDuplicate={bill => setEditBill({ bill: null, initialBill: makeBillDraft(bill, { copy: true, categories }) })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
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 { api } from '@/api.js';
|
||||
import { useTracker } from '@/hooks/useQueries';
|
||||
import BillModal from '@/components/BillModal';
|
||||
import { makeBillDraft } from '@/lib/billDrafts';
|
||||
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
|
@ -27,6 +29,7 @@ const MONTHS = [
|
|||
'January','February','March','April','May','June',
|
||||
'July','August','September','October','November','December',
|
||||
];
|
||||
const FILTER_ALL = 'all';
|
||||
|
||||
// Sentinel for the "no method" select option — empty string crashes Radix Select
|
||||
const METHOD_NONE = 'none';
|
||||
|
|
@ -64,6 +67,57 @@ const STATUS_META = {
|
|||
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 ──────────────────────────────────────────────────────────
|
||||
const CARD_DEFS = {
|
||||
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 ───────────────────────────────────────────
|
||||
// `threshold` = actual_amount ?? expected_amount for this bill/month
|
||||
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) ─────────────────────────────────────
|
||||
// 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.
|
||||
|
|
@ -825,20 +1143,24 @@ function PaymentModal({ payment, onClose, onSave }) {
|
|||
function Row({ row, year, month, refresh, index, onEditBill }) {
|
||||
const amountRef = useRef(null);
|
||||
const [editPayment, setEditPayment] = useState(null);
|
||||
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
|
||||
const [showMbs, setShowMbs] = useState(false);
|
||||
const [confirmUnpay, setConfirmUnpay] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [suggestionLoading, setSuggestionLoading] = useState(false);
|
||||
|
||||
// Effective amount threshold for this bill this month:
|
||||
// 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 defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day);
|
||||
|
||||
const isSkipped = !!row.is_skipped;
|
||||
const hasAutopaySuggestion = !!row.autopay_suggestion && !isSkipped;
|
||||
|
||||
// Paid when total payments >= effective threshold
|
||||
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 isPaid = row.status === 'paid' || (row.status === 'autodraft' && !hasAutopaySuggestion) || isPaidByThreshold;
|
||||
const summary = paymentSummary(row, threshold);
|
||||
|
||||
// Effective status to show:
|
||||
// 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; }
|
||||
try {
|
||||
await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate });
|
||||
toast.success('Marked as paid');
|
||||
toast.success('Payment added');
|
||||
refresh();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
|
|
@ -904,6 +1226,32 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
|||
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 (
|
||||
<>
|
||||
<TableRow
|
||||
|
|
@ -989,24 +1337,24 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
|||
|
||||
{/* Amount paid — mismatch now compares against threshold */}
|
||||
<TableCell className="w-[10%] py-3 text-right">
|
||||
<EditableCell
|
||||
<PaymentProgress
|
||||
row={row}
|
||||
field="amount"
|
||||
threshold={threshold}
|
||||
defaultPaymentDate={defaultPaymentDate}
|
||||
refresh={refresh}
|
||||
onOpen={() => setPaymentLedgerOpen(true)}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* Paid date */}
|
||||
<TableCell className="w-[10%] py-3">
|
||||
<EditableCell
|
||||
row={row}
|
||||
field="date"
|
||||
threshold={threshold}
|
||||
defaultPaymentDate={defaultPaymentDate}
|
||||
refresh={refresh}
|
||||
/>
|
||||
<TableCell className="w-[10%] py-3 text-sm text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentLedgerOpen(true)}
|
||||
className="rounded-md px-1.5 py-0.5 font-mono transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="View payment history"
|
||||
>
|
||||
{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>
|
||||
|
||||
{/* Status — uses effectiveStatus (accounts for skipped + threshold) */}
|
||||
|
|
@ -1025,13 +1373,21 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
|||
{/* Actions */}
|
||||
<TableCell className="w-[10%] py-3 text-right">
|
||||
<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 */}
|
||||
{!isPaid && !isSkipped && (
|
||||
{!isPaid && !isSkipped && !hasAutopaySuggestion && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
ref={amountRef}
|
||||
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"
|
||||
title="Payment amount"
|
||||
/>
|
||||
|
|
@ -1040,7 +1396,7 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
|||
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"
|
||||
>
|
||||
Pay
|
||||
Add
|
||||
</Button>
|
||||
</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 && (
|
||||
<MonthlyStateDialog
|
||||
row={row}
|
||||
|
|
@ -1100,14 +1466,17 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
|||
function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
|
||||
const amountRef = useRef(null);
|
||||
const [editPayment, setEditPayment] = useState(null);
|
||||
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
|
||||
const [showMbs, setShowMbs] = 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 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 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
|
||||
? 'skipped'
|
||||
: (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft')
|
||||
|
|
@ -1115,13 +1484,14 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
|
|||
: row.status;
|
||||
const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || '');
|
||||
const remaining = Math.max((threshold || 0) - (row.total_paid || 0), 0);
|
||||
const summary = paymentSummary(row, threshold);
|
||||
|
||||
async function handleQuickPay() {
|
||||
const val = parseFloat(amountRef.current?.value);
|
||||
if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
|
||||
try {
|
||||
await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate });
|
||||
toast.success('Marked as paid');
|
||||
toast.success('Payment added');
|
||||
refresh();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
|
|
@ -1167,6 +1537,32 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
|
|||
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 (
|
||||
<>
|
||||
<div
|
||||
|
|
@ -1253,6 +1649,10 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
|
|||
</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="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">
|
||||
|
|
@ -1261,17 +1661,33 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
|
|||
</div>
|
||||
<div className="rounded-md bg-muted/45 px-2 py-1.5">
|
||||
<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 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">
|
||||
<Input
|
||||
ref={amountRef}
|
||||
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"
|
||||
title="Payment amount"
|
||||
aria-label={`${row.name} payment amount`}
|
||||
|
|
@ -1281,7 +1697,7 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
|
|||
onClick={handleQuickPay}
|
||||
className="h-8 px-3 text-xs font-semibold"
|
||||
>
|
||||
Pay
|
||||
Add
|
||||
</Button>
|
||||
</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 && (
|
||||
<MonthlyStateDialog
|
||||
row={row}
|
||||
|
|
@ -1409,6 +1835,10 @@ function Bucket({ label, rows, year, month, refresh, onEditBill, loading }) {
|
|||
</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) => (
|
||||
<MobileTrackerRow
|
||||
|
|
@ -1469,6 +1899,12 @@ function Bucket({ label, rows, year, month, refresh, onEditBill, loading }) {
|
|||
</TableCell>
|
||||
</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) => (
|
||||
<Row
|
||||
|
|
@ -1492,6 +1928,7 @@ function Bucket({ label, rows, year, month, refresh, onEditBill, loading }) {
|
|||
|
||||
// ── Main page ──────────────────────────────────────────────────────────────
|
||||
export default function TrackerPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const now = new Date();
|
||||
const [year, setYear] = useState(now.getFullYear());
|
||||
const [month, setMonth] = useState(now.getMonth() + 1);
|
||||
|
|
@ -1499,10 +1936,26 @@ export default function TrackerPage() {
|
|||
const [editBillData, setEditBillData] = useState(null);
|
||||
// Edit Starting Amounts modal: true when open, false when closed
|
||||
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
|
||||
const { data, isLoading: loading, isError, error, refetch } = useTracker(year, month);
|
||||
|
||||
useEffect(() => {
|
||||
const querySearch = searchParams.get('search') || '';
|
||||
if (querySearch) setSearch(querySearch);
|
||||
}, [searchParams]);
|
||||
|
||||
function navigate(delta) {
|
||||
setMonth(m => {
|
||||
const nm = m + delta;
|
||||
|
|
@ -1542,8 +1995,71 @@ export default function TrackerPage() {
|
|||
|
||||
const rows = data?.rows || [];
|
||||
const summary = data?.summary || {};
|
||||
const first = rows.filter(r => r.bucket === '1st');
|
||||
const second = rows.filter(r => r.bucket === '15th');
|
||||
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.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 (
|
||||
<div className="space-y-5">
|
||||
|
|
@ -1588,6 +2104,63 @@ export default function TrackerPage() {
|
|||
</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) ── */}
|
||||
{loading ? (
|
||||
<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 */}
|
||||
{editBillData && (
|
||||
<BillModal
|
||||
key={editBillData.bill?.id ? `edit-${editBillData.bill.id}` : `new-${editBillData.initialBill?.name || 'blank'}`}
|
||||
bill={editBillData.bill}
|
||||
initialBill={editBillData.initialBill}
|
||||
categories={editBillData.categories}
|
||||
onClose={() => setEditBillData(null)}
|
||||
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');
|
||||
}
|
||||
},
|
||||
{
|
||||
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');
|
||||
}
|
||||
},
|
||||
{
|
||||
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 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')),
|
||||
autopay_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
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,
|
||||
username 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
|
||||
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 router = express.Router();
|
||||
const { getDb, getSetting, setSetting, rollbackMigration } = require('../db/database');
|
||||
const { getDb, rollbackMigration } = require('../db/database');
|
||||
const { hashPassword } = require('../services/authService');
|
||||
const {
|
||||
createBackup,
|
||||
|
|
@ -351,132 +351,12 @@ router.post('/cleanup/run', backupOperationLimiter, async (req, res) => {
|
|||
// ── Auth-mode helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const {
|
||||
getAdminOidcSettings,
|
||||
getOidcConfigStatus,
|
||||
invalidateClientCache,
|
||||
applyAuthModeSettings,
|
||||
buildAuthModeStatus,
|
||||
buildSubmittedOidcConfig,
|
||||
testOidcConfiguration,
|
||||
} = 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
|
||||
router.get('/auth-mode', (req, res) => {
|
||||
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.
|
||||
// Validates lockout protection before saving.
|
||||
router.put('/auth-mode', (req, res) => {
|
||||
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,
|
||||
} = 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);
|
||||
try {
|
||||
res.json(applyAuthModeSettings(req.body || {}));
|
||||
} catch (err) {
|
||||
res.status(err.status || 500).json({ error: err.status ? err.message : 'Failed to update authentication settings' });
|
||||
}
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,288 +1,12 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../db/database');
|
||||
const { standardizeError } = require('../middleware/errorFormatter');
|
||||
|
||||
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 };
|
||||
}
|
||||
const { getAnalyticsSummary } = require('../services/analyticsService');
|
||||
|
||||
router.get('/summary', (req, res) => {
|
||||
const parsed = validateSummaryQuery(req.query);
|
||||
if (parsed.error) return res.status(400).json(standardizeError(parsed.error, 'VALIDATION_ERROR', 'month'));
|
||||
|
||||
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(),
|
||||
});
|
||||
const result = getAnalyticsSummary(req.user.id, req.query);
|
||||
if (result.error) return res.status(400).json(standardizeError(result.error, 'VALIDATION_ERROR', 'month'));
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
242
routes/bills.js
242
routes/bills.js
|
|
@ -1,32 +1,19 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
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 { standardizeError } = require('../middleware/errorFormatter');
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
router.get('/', (req, res) => {
|
||||
const db = getDb();
|
||||
|
|
@ -52,61 +39,106 @@ router.get('/audit', (req, res) => {
|
|||
const db = getDb();
|
||||
ensureUserDefaultCategories(req.user.id);
|
||||
const includeInactive = req.query.inactive === 'true';
|
||||
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
|
||||
res.json(auditBillsForUser(db, req.user.id, includeInactive));
|
||||
});
|
||||
|
||||
// ── GET /api/bills/templates ─────────────────────────────────────────────────
|
||||
router.get('/templates', (req, res) => {
|
||||
const db = getDb();
|
||||
const rows = db.prepare(`
|
||||
SELECT id, name, data, created_at, updated_at
|
||||
FROM bill_templates
|
||||
WHERE user_id = ?
|
||||
ORDER BY name COLLATE NOCASE ASC
|
||||
`).all(req.user.id);
|
||||
|
||||
const auditedBills = bills.map((bill) => {
|
||||
const issues = [];
|
||||
const dueDay = Number(bill.due_day);
|
||||
const debt = isDebtBill(bill);
|
||||
const balance = Number(bill.current_balance);
|
||||
res.json(rows.map(row => ({
|
||||
...row,
|
||||
data: parseTemplateData(row.data),
|
||||
})));
|
||||
});
|
||||
|
||||
if (!Number.isInteger(dueDay) || dueDay < 1 || dueDay > 31) {
|
||||
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.'));
|
||||
}
|
||||
if (!bill.category_id || !bill.category_name) {
|
||||
issues.push(issue(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(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%.'));
|
||||
// ── POST /api/bills/templates ────────────────────────────────────────────────
|
||||
router.post('/templates', (req, res) => {
|
||||
const db = getDb();
|
||||
const name = String(req.body.name || '').trim();
|
||||
if (name.length < 2) {
|
||||
return res.status(400).json(standardizeError('Template name must be at least 2 characters', 'VALIDATION_ERROR', 'name'));
|
||||
}
|
||||
|
||||
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 data = sanitizeTemplateData(req.body.data || {});
|
||||
if (Object.keys(data).length === 0) {
|
||||
return res.status(400).json(standardizeError('Template data is required', 'VALIDATION_ERROR', 'data'));
|
||||
}
|
||||
const validation = validateBillData(data);
|
||||
if (validation.errors.length > 0) {
|
||||
const firstError = validation.errors[0];
|
||||
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.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,
|
||||
},
|
||||
});
|
||||
res.status(201).json(insertBill(db, req.user.id, normalized));
|
||||
});
|
||||
|
||||
// ── GET /api/bills/:id/monthly-state?year=&month= ─────────────────────────────
|
||||
|
|
@ -208,14 +240,25 @@ router.get('/:id', (req, res) => {
|
|||
// ── POST /api/bills ───────────────────────────────────────────────────────────
|
||||
router.post('/', (req, res) => {
|
||||
const db = getDb();
|
||||
const {
|
||||
name, category_id, due_day, override_due_date, expected_amount, interest_rate,
|
||||
billing_cycle, autopay_enabled, autodraft_status, website, username,
|
||||
account_info, has_2fa, notes, history_visibility, cycle_type, cycle_day,
|
||||
} = req.body;
|
||||
const body = req.body || {};
|
||||
let payload = body;
|
||||
|
||||
if (body.source_bill_id !== undefined && body.source_bill_id !== null && body.source_bill_id !== '') {
|
||||
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
|
||||
const validation = validateBillData(req.body);
|
||||
const validation = validateBillData(payload);
|
||||
if (validation.errors.length > 0) {
|
||||
const firstError = validation.errors[0];
|
||||
return res.status(400).json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field));
|
||||
|
|
@ -224,46 +267,11 @@ router.post('/', (req, res) => {
|
|||
const { normalized } = validation;
|
||||
|
||||
// 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'));
|
||||
}
|
||||
|
||||
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, 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);
|
||||
res.status(201).json(insertBill(db, req.user.id, normalized));
|
||||
});
|
||||
|
||||
// ── PUT /api/bills/:id ────────────────────────────────────────────────────────
|
||||
|
|
@ -282,14 +290,14 @@ router.put('/:id', (req, res) => {
|
|||
const { normalized } = validation;
|
||||
|
||||
// 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'));
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE bills SET
|
||||
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 = ?,
|
||||
history_visibility = ?, cycle_type = ?, cycle_day = ?,
|
||||
current_balance = ?, minimum_payment = ?, snowball_order = ?, snowball_include = ?, snowball_exempt = ?,
|
||||
|
|
@ -306,6 +314,7 @@ router.put('/:id', (req, res) => {
|
|||
normalized.billing_cycle,
|
||||
normalized.autopay_enabled,
|
||||
normalized.autodraft_status,
|
||||
normalized.auto_mark_paid,
|
||||
normalized.website,
|
||||
normalized.username,
|
||||
normalized.account_info,
|
||||
|
|
@ -392,7 +401,7 @@ router.post('/:id/toggle-paid', (req, res) => {
|
|||
const billId = parseInt(req.params.id, 10);
|
||||
|
||||
// Get bill - always scope to the requesting user
|
||||
const bill = db.prepare('SELECT id, expected_amount, user_id, due_day, 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'));
|
||||
|
||||
|
|
@ -478,7 +487,6 @@ router.post('/:id/toggle-paid', (req, res) => {
|
|||
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
|
||||
.run(balCalc.new_balance, billId);
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
isPaid: true,
|
||||
|
|
|
|||
|
|
@ -4,9 +4,47 @@ const router = require('express').Router();
|
|||
const { getDb } = require('../db/database');
|
||||
const { computeBalanceDelta } = require('../services/billsService');
|
||||
const { validatePaymentInput } = require('../services/paymentValidation');
|
||||
const { resolveDueDate } = require('../services/statusService');
|
||||
|
||||
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=
|
||||
router.get('/', (req, res) => {
|
||||
const db = getDb();
|
||||
|
|
@ -66,12 +104,19 @@ router.post('/', (req, res) => {
|
|||
}
|
||||
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'));
|
||||
|
||||
const balCalc = computeBalanceDelta(bill, payment.amount);
|
||||
const result = db.prepare(
|
||||
'INSERT INTO payments (bill_id, amount, paid_date, method, notes) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(payment.bill_id, payment.amount, payment.paid_date, method || null, notes || null);
|
||||
'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, 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));
|
||||
});
|
||||
|
|
@ -113,11 +158,99 @@ router.post('/quick', (req, res) => {
|
|||
.run(balCalc.new_balance, bill.id);
|
||||
}
|
||||
|
||||
if (bill.autopay_enabled) {
|
||||
db.prepare("UPDATE bills SET autodraft_status='confirmed', updated_at=datetime('now') WHERE id=?").run(bill.id);
|
||||
res.status(201).json(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid));
|
||||
});
|
||||
|
||||
// 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
|
||||
|
|
@ -217,16 +350,39 @@ router.put('/:id', (req, res) => {
|
|||
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(`
|
||||
UPDATE payments SET
|
||||
amount = ?, paid_date = ?, method = ?, notes = ?,
|
||||
amount = ?, paid_date = ?, method = ?, notes = ?, balance_delta = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
validation.normalized.amount ?? existing.amount,
|
||||
validation.normalized.paid_date ?? existing.paid_date,
|
||||
nextAmount,
|
||||
nextPaidDate,
|
||||
method !== undefined ? (method || null) : existing.method,
|
||||
notes !== undefined ? (notes || null) : existing.notes,
|
||||
nextBalanceDelta,
|
||||
req.params.id,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,319 +1,17 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../db/database');
|
||||
const { buildTrackerRow, getCycleRange, resolveDueDate } = require('../services/statusService');
|
||||
const { getUserSettings } = require('../services/userSettings');
|
||||
const { getTracker, getUpcomingBills } = require('../services/trackerService');
|
||||
|
||||
// GET /api/tracker?year=2026&month=5
|
||||
router.get('/', (req, res) => {
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
const year = parseInt(req.query.year || now.getFullYear(), 10);
|
||||
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,
|
||||
});
|
||||
const result = getTracker(req.user.id, req.query);
|
||||
if (result.error) return res.status(result.status || 400).json({ error: result.error });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// 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) => {
|
||||
const db = getDb();
|
||||
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 });
|
||||
res.json(getUpcomingBills(req.user.id, req.query));
|
||||
});
|
||||
|
||||
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 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
|
||||
function getDefaultCycleDay(cycleType) {
|
||||
switch (cycleType) {
|
||||
|
|
@ -124,6 +270,9 @@ function validateBillData(data, existingBill = null) {
|
|||
// autodraft_status
|
||||
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
|
||||
normalized.website = data.website !== undefined ? (data.website || null) : (existingBill?.website || null);
|
||||
|
||||
|
|
@ -274,11 +423,17 @@ function computeBalanceDelta(bill, paymentAmount) {
|
|||
|
||||
module.exports = {
|
||||
VALID_VISIBILITY,
|
||||
TEMPLATE_FIELDS,
|
||||
auditBillsForUser,
|
||||
categoryBelongsToUser,
|
||||
getValidCycleTypes,
|
||||
getDefaultCycleDay,
|
||||
insertBill,
|
||||
parseTemplateData,
|
||||
validateCycleDay,
|
||||
parseDueDay,
|
||||
parseInterestRate,
|
||||
sanitizeTemplateData,
|
||||
validateBillData,
|
||||
validateCycleDayOnly,
|
||||
computeBalanceDelta,
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@
|
|||
const crypto = require('crypto');
|
||||
const { Issuer } = require('openid-client');
|
||||
|
||||
const { getDb, getSetting } = require('../db/database');
|
||||
const { getDb, getSetting, setSetting } = require('../db/database');
|
||||
|
||||
// ── 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.
|
||||
*/
|
||||
|
|
@ -492,6 +703,10 @@ async function findOrProvisionUser(claims, config) {
|
|||
}
|
||||
|
||||
module.exports = {
|
||||
applyAuthModeSettings,
|
||||
buildAuthModeStatus,
|
||||
buildSubmittedOidcConfig,
|
||||
computeSubmittedOidcConfigured,
|
||||
getOidcConfig,
|
||||
getOidcConfigStatus,
|
||||
getAdminOidcSettings,
|
||||
|
|
|
|||
|
|
@ -1362,6 +1362,25 @@ function resolveMonth(decision, previewRow, sessionData) {
|
|||
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) {
|
||||
const existing = db.prepare(`
|
||||
SELECT id, actual_amount, notes, is_skipped
|
||||
|
|
@ -1377,8 +1396,8 @@ function upsertMonthlyState(db, billId, year, month, amount, notes, isSkipped, a
|
|||
return { result: 'created' };
|
||||
}
|
||||
|
||||
const amountConflict = (amount !== null && existing.actual_amount !== null && existing.actual_amount !== amount);
|
||||
const notesConflict = (notes !== null && existing.notes !== null && existing.notes !== notes);
|
||||
const amountConflict = (amount != null && existing.actual_amount !== null && !amountsEqual(existing.actual_amount, amount));
|
||||
const notesConflict = (notes != null && existing.notes !== null && nullableString(existing.notes) !== nullableString(notes));
|
||||
|
||||
if ((amountConflict || notesConflict) && !allowOverwrite) {
|
||||
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 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(`
|
||||
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);
|
||||
|
||||
if (st.result === 'skipped_conflict') { summary.skipped++; }
|
||||
else if (st.result === 'created') { summary.created++; }
|
||||
else { summary.updated++; }
|
||||
if (st.result === 'skipped_conflict') {
|
||||
summary.skipped++;
|
||||
} 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 };
|
||||
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 totalPaid = safePayments.reduce((sum, p) => sum + p.amount, 0);
|
||||
|
||||
// A recorded payment is the user's confirmation that this cycle is handled.
|
||||
// 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 (totalPaid >= bill.expected_amount) return 'paid';
|
||||
|
||||
if (bill.autopay_enabled && bill.autodraft_status === 'assumed_paid') {
|
||||
return 'autodraft';
|
||||
|
|
@ -107,7 +104,11 @@ function buildTrackerRow(bill, payments, year, month, todayStr, options = {}) {
|
|||
status,
|
||||
autopay_enabled: !!bill.autopay_enabled,
|
||||
autodraft_status: bill.autodraft_status,
|
||||
auto_mark_paid: !!bill.auto_mark_paid,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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