This commit is contained in:
null 2026-05-16 10:56:56 -05:00
parent 59d9d21d4c
commit 88c1374d97
11 changed files with 368 additions and 14 deletions

View File

@ -40,6 +40,7 @@ const RoadmapPage = lazy(() => import('@/pages/RoadmapPage'));
const DataPage = lazy(() => import('@/pages/DataPage')); const DataPage = lazy(() => import('@/pages/DataPage'));
const ProfilePage = lazy(() => import('@/pages/ProfilePage')); const ProfilePage = lazy(() => import('@/pages/ProfilePage'));
const SnowballPage = lazy(() => import('@/pages/SnowballPage')); const SnowballPage = lazy(() => import('@/pages/SnowballPage'));
const HealthPage = lazy(() => import('@/pages/HealthPage'));
function RequireAuth({ children, role }) { function RequireAuth({ children, role }) {
const { user, singleUserMode } = useAuth(); const { user, singleUserMode } = useAuth();
@ -186,6 +187,7 @@ export default function App() {
<Route path="summary" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SummaryPage /></Suspense></ErrorBoundary>} /> <Route path="summary" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SummaryPage /></Suspense></ErrorBoundary>} />
<Route path="bills" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><BillsPage /></Suspense></ErrorBoundary>} /> <Route path="bills" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><BillsPage /></Suspense></ErrorBoundary>} />
<Route path="categories" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><CategoriesPage /></Suspense></ErrorBoundary>} /> <Route path="categories" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><CategoriesPage /></Suspense></ErrorBoundary>} />
<Route path="health" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><HealthPage /></Suspense></ErrorBoundary>} />
<Route path="analytics" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><AnalyticsPage /></Suspense></ErrorBoundary>} /> <Route path="analytics" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><AnalyticsPage /></Suspense></ErrorBoundary>} />
<Route path="settings" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SettingsPage /></Suspense></ErrorBoundary>} /> <Route path="settings" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SettingsPage /></Suspense></ErrorBoundary>} />
<Route path="snowball" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SnowballPage /></Suspense></ErrorBoundary>} /> <Route path="snowball" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SnowballPage /></Suspense></ErrorBoundary>} />

View File

@ -141,6 +141,7 @@ export const api = {
// Bills // Bills
bills: () => get('/bills'), bills: () => get('/bills'),
allBills: () => get('/bills?inactive=true'), allBills: () => get('/bills?inactive=true'),
billAudit: (includeInactive = false) => get(`/bills/audit${includeInactive ? '?inactive=true' : ''}`),
bill: (id) => get(`/bills/${id}`), bill: (id) => get(`/bills/${id}`),
createBill: (data) => post('/bills', data), createBill: (data) => post('/bills', data),
updateBill: (id, data) => put(`/bills/${id}`, data), updateBill: (id, data) => put(`/bills/${id}`, data),

View File

@ -1,7 +1,7 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { NavLink, useLocation, useNavigate } from 'react-router-dom'; import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import { import {
Activity, BarChart3, CalendarDays, ChevronDown, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt, Activity, BarChart3, CalendarDays, ChevronDown, ClipboardCheck, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt,
Settings, ShieldCheck, Tag, TrendingDown, User, X, Settings, ShieldCheck, Tag, TrendingDown, User, X,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -35,6 +35,7 @@ const trackerItems = [
{ to: '/summary', icon: ClipboardList, label: 'Summary' }, { to: '/summary', icon: ClipboardList, label: 'Summary' },
{ to: '/bills', icon: Receipt, label: 'Bills' }, { to: '/bills', icon: Receipt, label: 'Bills' },
{ to: '/categories', icon: Tag, label: 'Categories' }, { to: '/categories', icon: Tag, label: 'Categories' },
{ to: '/health', icon: ClipboardCheck, label: 'Health' },
{ to: '/snowball', icon: TrendingDown, label: 'Snowball' }, { to: '/snowball', icon: TrendingDown, label: 'Snowball' },
]; ];

View File

@ -23,7 +23,7 @@ import AppNavigation from '@/components/layout/Sidebar';
// Helpers // Helpers
const AUTHENTIK_ICON_URL = 'https://gate.originalsinners.org/static/dist/assets/icons/icon.png'; const AUTHENTIK_ICON_URL = '/img/auth.png';
function SectionHeading({ children }) { function SectionHeading({ children }) {
return <h2 className="text-base font-semibold text-foreground">{children}</h2>; return <h2 className="text-base font-semibold text-foreground">{children}</h2>;
@ -739,12 +739,6 @@ function AuthMethodsCard() {
{/* OIDC / authentik login toggle */} {/* OIDC / authentik login toggle */}
<FieldRow label="authentik / OIDC login"> <FieldRow label="authentik / OIDC login">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<img
src={AUTHENTIK_ICON_URL}
alt=""
aria-hidden="true"
className="h-5 w-5 shrink-0 object-contain"
/>
<Toggle <Toggle
checked={form.oidc_login_enabled} checked={form.oidc_login_enabled}
onChange={v => set('oidc_login_enabled', v)} onChange={v => set('oidc_login_enabled', v)}

View File

@ -28,6 +28,10 @@ function billPreview(names = []) {
return `${visible}${more}`; return `${visible}${more}`;
} }
function categoryBillCount(category) {
return (category.active_bill_count || 0) + (category.inactive_bill_count || 0);
}
function Chip({ value, label, tone = 'muted', details }) { function Chip({ value, label, tone = 'muted', details }) {
const toneClass = { const toneClass = {
active: 'border-primary/25 bg-primary/10 text-primary', active: 'border-primary/25 bg-primary/10 text-primary',
@ -230,6 +234,7 @@ export default function CategoriesPage() {
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [expanded, setExpanded] = useState(() => new Set()); const [expanded, setExpanded] = useState(() => new Set());
const [showEmptyCategories, setShowEmptyCategories] = useState(false);
const addInputRef = useRef(null); const addInputRef = useRef(null);
const [renameTarget, setRenameTarget] = useState(null); const [renameTarget, setRenameTarget] = useState(null);
@ -279,6 +284,7 @@ export default function CategoriesPage() {
await api.createCategory({ name: trimmed }); await api.createCategory({ name: trimmed });
toast.success(`"${trimmed}" added`); toast.success(`"${trimmed}" added`);
setNewName(''); setNewName('');
setShowEmptyCategories(true);
addInputRef.current?.focus(); addInputRef.current?.focus();
load(); load();
} catch (err) { } catch (err) {
@ -349,6 +355,10 @@ export default function CategoriesPage() {
const activeBills = categories.reduce((sum, cat) => sum + (cat.active_bill_count || 0), 0); const activeBills = categories.reduce((sum, cat) => sum + (cat.active_bill_count || 0), 0);
const inactiveBills = categories.reduce((sum, cat) => sum + (cat.inactive_bill_count || 0), 0); const inactiveBills = categories.reduce((sum, cat) => sum + (cat.inactive_bill_count || 0), 0);
const paymentCount = categories.reduce((sum, cat) => sum + (cat.payment_count || 0), 0); const paymentCount = categories.reduce((sum, cat) => sum + (cat.payment_count || 0), 0);
const emptyCategories = categories.filter(cat => categoryBillCount(cat) === 0);
const visibleCategories = showEmptyCategories
? categories
: categories.filter(cat => categoryBillCount(cat) > 0);
return ( return (
<TooltipProvider delayDuration={180}> <TooltipProvider delayDuration={180}>
@ -402,13 +412,45 @@ export default function CategoriesPage() {
<div className="table-surface overflow-hidden rounded-xl"> <div className="table-surface overflow-hidden rounded-xl">
{loading ? ( {loading ? (
<div className="py-16 text-center text-sm text-muted-foreground">Loading...</div> <div className="py-16 text-center text-sm text-muted-foreground">Loading...</div>
) : categories.length === 0 ? ( ) : visibleCategories.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground"> <div className="flex flex-col items-center justify-center gap-3 px-4 py-16 text-center text-sm text-muted-foreground">
No categories yet. Add one above. <span>
{categories.length === 0
? 'No categories yet. Add one above.'
: 'No categories with bills yet.'}
</span>
{emptyCategories.length > 0 && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowEmptyCategories(true)}
>
Show Empty Categories
</Button>
)}
</div> </div>
) : ( ) : (
<div className="divide-y divide-border/50"> <div className="divide-y divide-border/50">
{categories.map((cat) => { {emptyCategories.length > 0 && (
<div className="flex flex-col gap-2 bg-muted/20 px-4 py-3 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between sm:px-5">
<span>
{showEmptyCategories
? `Showing ${plural(emptyCategories.length, 'empty category')}.`
: `${plural(emptyCategories.length, 'empty category')} hidden until a bill uses them.`}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-fit px-2 text-xs"
onClick={() => setShowEmptyCategories(value => !value)}
>
{showEmptyCategories ? 'Hide Empty' : 'Show Empty'}
</Button>
</div>
)}
{visibleCategories.map((cat) => {
const isExpanded = expanded.has(cat.id); const isExpanded = expanded.has(cat.id);
const preview = billPreview(cat.bill_names); const preview = billPreview(cat.bill_names);
return ( return (

215
client/pages/HealthPage.jsx Normal file
View File

@ -0,0 +1,215 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { toast } from 'sonner';
import {
AlertTriangle,
CheckCircle2,
ClipboardCheck,
Loader2,
RefreshCw,
Receipt,
} from 'lucide-react';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
const FIELD_LABELS = {
due_day: 'Due day',
category_id: 'Category',
minimum_payment: 'Minimum payment',
autopay_enabled: 'Autopay details',
interest_rate: 'APR',
};
function severityClass(severity) {
return severity === 'error'
? 'border-destructive/25 bg-destructive/10 text-destructive'
: 'border-amber-500/25 bg-amber-500/10 text-amber-700 dark:text-amber-300';
}
function severityWeight(severity) {
return severity === 'error' ? 0 : 1;
}
function issueSummary(issues = []) {
const errors = issues.filter(issue => issue.severity === 'error').length;
const warnings = issues.length - errors;
if (errors && warnings) return `${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'}`;
if (errors) return `${errors} error${errors === 1 ? '' : 's'}`;
return `${warnings} warning${warnings === 1 ? '' : 's'}`;
}
function StatCard({ label, value, tone = 'default' }) {
return (
<div className={cn(
'rounded-xl border px-4 py-3 shadow-sm',
tone === 'error' && 'border-destructive/20 bg-destructive/10',
tone === 'warning' && 'border-amber-500/20 bg-amber-500/10',
tone === 'ok' && 'border-emerald-500/20 bg-emerald-500/10',
tone === 'default' && 'border-border/70 bg-card/80',
)}>
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</p>
<p className="mt-1 font-mono text-xl font-bold text-foreground">{value}</p>
</div>
);
}
function IssuePill({ issue }) {
return (
<span className={cn(
'inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide',
severityClass(issue.severity),
)}>
{issue.severity}
</span>
);
}
function BillIssueCard({ bill }) {
const sortedIssues = [...bill.issues].sort((a, b) => severityWeight(a.severity) - severityWeight(b.severity));
return (
<Card className="overflow-hidden">
<CardHeader className="pb-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<CardTitle className="flex min-w-0 flex-wrap items-center gap-2 text-base">
<Receipt className="h-4 w-4 shrink-0 text-primary" />
<span className="truncate">{bill.name}</span>
<span className="rounded-full border border-border bg-muted/55 px-2 py-0.5 text-[11px] font-semibold text-muted-foreground">
{issueSummary(bill.issues)}
</span>
</CardTitle>
<CardDescription className="mt-1">
{bill.category_name || 'No category'} - {bill.due_day ? `Due day ${bill.due_day}` : 'No due day'}
{!bill.active && ' - Inactive'}
</CardDescription>
</div>
<Button asChild variant="outline" size="sm" className="shrink-0">
<Link to="/bills">Open Bills</Link>
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{sortedIssues.map(issue => (
<div key={`${bill.id}-${issue.field}`} className="rounded-lg border border-border/70 bg-background/65 p-3">
<div className="flex flex-wrap items-center gap-2">
<IssuePill issue={issue} />
<span className="text-sm font-semibold text-foreground">
{FIELD_LABELS[issue.field] || issue.field}
</span>
</div>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{issue.suggestion}</p>
</div>
))}
</CardContent>
</Card>
);
}
export default function HealthPage() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [includeInactive, setIncludeInactive] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
setData(await api.billAudit(includeInactive));
} catch (err) {
toast.error(err.message || 'Could not run bill health check.');
} finally {
setLoading(false);
}
}, [includeInactive]);
useEffect(() => { load(); }, [load]);
const summary = data?.summary || {};
const bills = data?.bills || [];
const sortedBills = useMemo(() => [...bills].sort((a, b) => {
const aErrors = a.issues.filter(issue => issue.severity === 'error').length;
const bErrors = b.issues.filter(issue => issue.severity === 'error').length;
if (aErrors !== bErrors) return bErrors - aErrors;
if (a.issues.length !== b.issues.length) return b.issues.length - a.issues.length;
return a.name.localeCompare(b.name);
}), [bills]);
const hasIssues = sortedBills.length > 0;
const healthTone = useMemo(() => {
if ((summary.error_count || 0) > 0) return 'error';
if ((summary.warning_count || 0) > 0) return 'warning';
return 'ok';
}, [summary.error_count, summary.warning_count]);
return (
<div className="mx-auto w-full max-w-5xl space-y-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/70 bg-card shadow-sm">
<ClipboardCheck className="h-4 w-4 text-primary" />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight">Bill Health</h1>
<p className="mt-0.5 text-sm text-muted-foreground">Find setup gaps before they skew tracker and snowball results.</p>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant={includeInactive ? 'default' : 'outline'}
size="sm"
aria-pressed={includeInactive}
onClick={() => setIncludeInactive(value => !value)}
>
{includeInactive ? 'Including Inactive' : 'Active Bills Only'}
</Button>
<Button type="button" variant="outline" size="sm" onClick={load} disabled={loading}>
{loading ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="mr-1.5 h-3.5 w-3.5" />}
Refresh
</Button>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<StatCard label="Audited bills" value={summary.audited_bills ?? '-'} />
<StatCard label="Issues" value={summary.issue_count ?? '-'} tone={healthTone} />
<StatCard label="Errors" value={summary.error_count ?? '-'} tone={(summary.error_count || 0) > 0 ? 'error' : 'default'} />
<StatCard label="Warnings" value={summary.warning_count ?? '-'} tone={(summary.warning_count || 0) > 0 ? 'warning' : 'default'} />
</div>
{loading ? (
<Card>
<CardContent className="flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Running health check...
</CardContent>
</Card>
) : !hasIssues ? (
<Card className="border-emerald-500/20 bg-emerald-500/5">
<CardContent className="flex flex-col items-center gap-3 px-4 py-16 text-center">
<div className="flex h-11 w-11 items-center justify-center rounded-full border border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300">
<CheckCircle2 className="h-5 w-5" />
</div>
<div>
<p className="font-semibold text-foreground">No bill setup issues found.</p>
<p className="mt-1 text-sm text-muted-foreground">Your audited bills have the core fields needed for tracking and snowball projections.</p>
</div>
</CardContent>
</Card>
) : (
<div className="space-y-3">
<div className="flex items-start gap-2 rounded-xl border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<p>Fix errors first; warnings are cleanup items that improve confidence and projections.</p>
</div>
<div className="grid gap-3">
{sortedBills.map(bill => <BillIssueCard key={bill.id} bill={bill} />)}
</div>
</div>
)}
</div>
);
}

View File

@ -81,6 +81,7 @@ export default function LoginPage() {
const localEnabled = authMode.local_enabled !== false; const localEnabled = authMode.local_enabled !== false;
const oidcEnabled = !!authMode.oidc_enabled && !!authMode.oidc_login_url; const oidcEnabled = !!authMode.oidc_enabled && !!authMode.oidc_login_url;
const providerName = authMode.oidc_provider_name || 'authentik'; const providerName = authMode.oidc_provider_name || 'authentik';
const isAuthentikProvider = providerName.toLowerCase().includes('authentik');
const handleChangePassword = async (e) => { const handleChangePassword = async (e) => {
e.preventDefault(); e.preventDefault();
@ -148,6 +149,17 @@ export default function LoginPage() {
</p> </p>
</div> </div>
{oidcEnabled && isAuthentikProvider && (
<div className="flex justify-center rounded-lg border border-border/70 bg-background/55 px-4 py-3">
<img
src="/img/auth.png"
alt="authentik"
className="h-16 w-16 object-contain"
loading="eager"
/>
</div>
)}
{oidcEnabled && ( {oidcEnabled && (
<Button <Button
type="button" type="button"
@ -155,9 +167,9 @@ export default function LoginPage() {
className="w-full gap-2" className="w-full gap-2"
onClick={() => { window.location.href = authMode.oidc_login_url; }} onClick={() => { window.location.href = authMode.oidc_login_url; }}
> >
{providerName.toLowerCase().includes('authentik') && ( {isAuthentikProvider && (
<img <img
src="/img/auth.svg" src="/img/auth.png"
alt="" alt=""
aria-hidden="true" aria-hidden="true"
className="h-5 w-5 object-contain shrink-0" className="h-5 w-5 object-contain shrink-0"

BIN
client/public/img/auth.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -18,9 +18,13 @@ const DEFAULT_CATEGORIES = [
'Housing', 'Housing',
'Utilities', 'Utilities',
'Credit Cards', 'Credit Cards',
'Food',
'Loans', 'Loans',
'Insurance', 'Insurance',
'Beauty',
'Entertainment',
'Subscriptions', 'Subscriptions',
'Pets',
'Phone & Internet', 'Phone & Internet',
'Transportation', 'Transportation',
'Medical', 'Medical',

BIN
img/auth.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -6,6 +6,27 @@ const { amortizationSchedule, debtAprSnapshot } = require('../services/aprServic
const { standardizeError } = require('../middleware/errorFormatter'); const { standardizeError } = require('../middleware/errorFormatter');
const { validatePaymentInput } = require('../services/paymentValidation'); const { validatePaymentInput } = require('../services/paymentValidation');
function hasText(value) {
return typeof value === 'string' && value.trim().length > 0;
}
function isDebtBill(bill) {
const category = String(bill.category_name || '').toLowerCase();
return Number(bill.current_balance) > 0
|| bill.minimum_payment != null
|| ['credit card', 'credit cards', 'loan', 'loans', 'debt'].some(token => category.includes(token));
}
function issue(bill, field, severity, suggestion) {
return {
bill_id: bill.id,
bill_name: bill.name,
field,
severity,
suggestion,
};
}
// ── GET /api/bills ──────────────────────────────────────────────────────────── // ── GET /api/bills ────────────────────────────────────────────────────────────
router.get('/', (req, res) => { router.get('/', (req, res) => {
const db = getDb(); const db = getDb();
@ -26,6 +47,68 @@ router.get('/', (req, res) => {
res.json(bills); res.json(bills);
}); });
// ── GET /api/bills/audit?inactive=true ───────────────────────────────────────
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
`).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);
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%.'));
}
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);
res.json({
bills: auditedBills.filter(bill => bill.issues.length > 0),
summary: {
audited_bills: bills.length,
issue_count: issues.length,
error_count: issues.filter(item => item.severity === 'error').length,
warning_count: issues.filter(item => item.severity === 'warning').length,
},
});
});
// ── GET /api/bills/:id/monthly-state?year=&month= ───────────────────────────── // ── GET /api/bills/:id/monthly-state?year=&month= ─────────────────────────────
router.get('/:id/monthly-state', (req, res) => { router.get('/:id/monthly-state', (req, res) => {
const db = getDb(); const db = getDb();