v028.0
This commit is contained in:
parent
59d9d21d4c
commit
88c1374d97
|
|
@ -40,6 +40,7 @@ const RoadmapPage = lazy(() => import('@/pages/RoadmapPage'));
|
|||
const DataPage = lazy(() => import('@/pages/DataPage'));
|
||||
const ProfilePage = lazy(() => import('@/pages/ProfilePage'));
|
||||
const SnowballPage = lazy(() => import('@/pages/SnowballPage'));
|
||||
const HealthPage = lazy(() => import('@/pages/HealthPage'));
|
||||
|
||||
function RequireAuth({ children, role }) {
|
||||
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="bills" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><BillsPage /></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="settings" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SettingsPage /></Suspense></ErrorBoundary>} />
|
||||
<Route path="snowball" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SnowballPage /></Suspense></ErrorBoundary>} />
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@ export const api = {
|
|||
// Bills
|
||||
bills: () => get('/bills'),
|
||||
allBills: () => get('/bills?inactive=true'),
|
||||
billAudit: (includeInactive = false) => get(`/bills/audit${includeInactive ? '?inactive=true' : ''}`),
|
||||
bill: (id) => get(`/bills/${id}`),
|
||||
createBill: (data) => post('/bills', data),
|
||||
updateBill: (id, data) => put(`/bills/${id}`, data),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Activity, BarChart3, CalendarDays, ChevronDown, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt,
|
||||
Activity, BarChart3, CalendarDays, ChevronDown, ClipboardCheck, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt,
|
||||
Settings, ShieldCheck, Tag, TrendingDown, User, X,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
|
@ -35,6 +35,7 @@ const trackerItems = [
|
|||
{ to: '/summary', icon: ClipboardList, label: 'Summary' },
|
||||
{ to: '/bills', icon: Receipt, label: 'Bills' },
|
||||
{ to: '/categories', icon: Tag, label: 'Categories' },
|
||||
{ to: '/health', icon: ClipboardCheck, label: 'Health' },
|
||||
{ to: '/snowball', icon: TrendingDown, label: 'Snowball' },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import AppNavigation from '@/components/layout/Sidebar';
|
|||
|
||||
// ─── 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 }) {
|
||||
return <h2 className="text-base font-semibold text-foreground">{children}</h2>;
|
||||
|
|
@ -739,12 +739,6 @@ function AuthMethodsCard() {
|
|||
{/* OIDC / authentik login toggle */}
|
||||
<FieldRow label="authentik / OIDC login">
|
||||
<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
|
||||
checked={form.oidc_login_enabled}
|
||||
onChange={v => set('oidc_login_enabled', v)}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ function billPreview(names = []) {
|
|||
return `${visible}${more}`;
|
||||
}
|
||||
|
||||
function categoryBillCount(category) {
|
||||
return (category.active_bill_count || 0) + (category.inactive_bill_count || 0);
|
||||
}
|
||||
|
||||
function Chip({ value, label, tone = 'muted', details }) {
|
||||
const toneClass = {
|
||||
active: 'border-primary/25 bg-primary/10 text-primary',
|
||||
|
|
@ -230,6 +234,7 @@ export default function CategoriesPage() {
|
|||
const [newName, setNewName] = useState('');
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [expanded, setExpanded] = useState(() => new Set());
|
||||
const [showEmptyCategories, setShowEmptyCategories] = useState(false);
|
||||
const addInputRef = useRef(null);
|
||||
|
||||
const [renameTarget, setRenameTarget] = useState(null);
|
||||
|
|
@ -279,6 +284,7 @@ export default function CategoriesPage() {
|
|||
await api.createCategory({ name: trimmed });
|
||||
toast.success(`"${trimmed}" added`);
|
||||
setNewName('');
|
||||
setShowEmptyCategories(true);
|
||||
addInputRef.current?.focus();
|
||||
load();
|
||||
} catch (err) {
|
||||
|
|
@ -349,6 +355,10 @@ export default function CategoriesPage() {
|
|||
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 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 (
|
||||
<TooltipProvider delayDuration={180}>
|
||||
|
|
@ -402,13 +412,45 @@ export default function CategoriesPage() {
|
|||
<div className="table-surface overflow-hidden rounded-xl">
|
||||
{loading ? (
|
||||
<div className="py-16 text-center text-sm text-muted-foreground">Loading...</div>
|
||||
) : categories.length === 0 ? (
|
||||
<div className="py-16 text-center text-sm text-muted-foreground">
|
||||
No categories yet. Add one above.
|
||||
) : visibleCategories.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 px-4 py-16 text-center text-sm text-muted-foreground">
|
||||
<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 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 preview = billPreview(cat.bill_names);
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -81,6 +81,7 @@ export default function LoginPage() {
|
|||
const localEnabled = authMode.local_enabled !== false;
|
||||
const oidcEnabled = !!authMode.oidc_enabled && !!authMode.oidc_login_url;
|
||||
const providerName = authMode.oidc_provider_name || 'authentik';
|
||||
const isAuthentikProvider = providerName.toLowerCase().includes('authentik');
|
||||
|
||||
const handleChangePassword = async (e) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -148,6 +149,17 @@ export default function LoginPage() {
|
|||
</p>
|
||||
</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 && (
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -155,9 +167,9 @@ export default function LoginPage() {
|
|||
className="w-full gap-2"
|
||||
onClick={() => { window.location.href = authMode.oidc_login_url; }}
|
||||
>
|
||||
{providerName.toLowerCase().includes('authentik') && (
|
||||
{isAuthentikProvider && (
|
||||
<img
|
||||
src="/img/auth.svg"
|
||||
src="/img/auth.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-5 w-5 object-contain shrink-0"
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -18,9 +18,13 @@ const DEFAULT_CATEGORIES = [
|
|||
'Housing',
|
||||
'Utilities',
|
||||
'Credit Cards',
|
||||
'Food',
|
||||
'Loans',
|
||||
'Insurance',
|
||||
'Beauty',
|
||||
'Entertainment',
|
||||
'Subscriptions',
|
||||
'Pets',
|
||||
'Phone & Internet',
|
||||
'Transportation',
|
||||
'Medical',
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -6,6 +6,27 @@ const { amortizationSchedule, debtAprSnapshot } = require('../services/aprServic
|
|||
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();
|
||||
|
|
@ -26,6 +47,68 @@ router.get('/', (req, res) => {
|
|||
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= ─────────────────────────────
|
||||
router.get('/:id/monthly-state', (req, res) => {
|
||||
const db = getDb();
|
||||
|
|
|
|||
Loading…
Reference in New Issue