feat: SimpleFIN bank budget tracking with live balance, pending payments, bank tracking mode
- Opt-in Bank Budget Tracking mode replaces manual starting amounts with live bank balance - Calendar money map shows Balance / Pending / Unpaid Bills / After Bills in bank mode - Pending badge (amber) on tracker rows within configurable pending window (0-7 days) - New GET /api/data-sources/accounts/all endpoint for account picker - Tracker starting-amounts card shows account name and live balance hint
This commit is contained in:
parent
44320a7613
commit
690a86611a
|
|
@ -16,10 +16,16 @@
|
||||||
|
|
||||||
### ✨ Features
|
### ✨ Features
|
||||||
|
|
||||||
|
- **SimpleFIN bank budget tracking** — A new opt-in mode replaces manually-entered starting amounts with the live balance of a connected bank account. When enabled from the Bank Sync settings page (Data → Bank Sync → Bank Budget Tracking), the user selects which financial account to track and configures a pending payment window (0–7 days, default 3). Budget remaining is calculated as: `bank balance − pending payments − unpaid bills this month`. Bills already marked paid are not double-counted — the bank balance already reflects them. Payments made within the pending window appear in the tracker with an amber **Pending** badge, flagging that the bank may not have processed the debit yet. The CalendarPage Monthly Money Map switches to four live metrics (Balance / Pending / Unpaid Bills / After Bills) when bank mode is active. The TrackerPage starting-amounts card shows the account name and "live balance" hint; the manual-edit button is hidden since there is nothing to manually set. Implementation: three new `user_settings` keys (`bank_tracking_enabled`, `bank_tracking_account_id`, `bank_tracking_pending_days`), a new `GET /api/data-sources/accounts/all` endpoint for the account picker, `buildBankTrackingSummary()` in both `summary.js` and `trackerService.js`, and `pending_cleared` flag on tracker rows.
|
||||||
|
|
||||||
- **404 page** — Unknown routes previously silently redirected to `/` with no feedback. Replaced both catch-all routes (`path="*"` inside the auth layout and at the top level) with a dedicated `NotFoundPage`. The page is standalone (no sidebar), theme-aware, and works for authenticated and unauthenticated users alike. Design features: a glitch counter that cycles each digit of "404" through random numbers before snapping to value (staggered by 180 ms per digit), a CSS mesh gradient background with primary-color radial glows, a 48 px grid overlay faded with a radial mask, a gradient clip-text `404` that scales from `6rem` to `14rem` via `clamp()`, and smart CTAs — "Go back" only appears when browser history exists, and the home button adapts to auth state. The bad path is shown inline in a `<code>` tag so the user knows what they typed.
|
- **404 page** — Unknown routes previously silently redirected to `/` with no feedback. Replaced both catch-all routes (`path="*"` inside the auth layout and at the top level) with a dedicated `NotFoundPage`. The page is standalone (no sidebar), theme-aware, and works for authenticated and unauthenticated users alike. Design features: a glitch counter that cycles each digit of "404" through random numbers before snapping to value (staggered by 180 ms per digit), a CSS mesh gradient background with primary-color radial glows, a 48 px grid overlay faded with a radial mask, a gradient clip-text `404` that scales from `6rem` to `14rem` via `clamp()`, and smart CTAs — "Go back" only appears when browser history exists, and the home button adapts to auth state. The bad path is shown inline in a `<code>` tag so the user knows what they typed.
|
||||||
|
|
||||||
### 🐛 Fixed
|
### 🐛 Fixed
|
||||||
|
|
||||||
|
- **Snowball order PATCH validates all rows before writing** — `PATCH /api/snowball/order` previously iterated through the submitted array with a `continue` on invalid entries, silently skipping bad rows and always returning `{ success: true }`. Any item with a non-integer or negative `id`/`snowball_order` now immediately returns `400` with the specific index and value that failed. The transaction only runs after all items pass validation. Response now includes `updated` count. Soft-deleted bills are also excluded from the UPDATE (`deleted_at IS NULL`), which simultaneously closes issue #53.
|
||||||
|
|
||||||
|
- **`isRamseyMode()` called once per request** — `getDebtBills()` previously hid the `isRamseyMode()` DB query inside itself. Routes that also needed the mode value (or called `getDebtBills` alongside other `isRamseyMode` calls) triggered multiple identical queries per request. `getDebtBills` now accepts an optional pre-fetched `ramseyMode` parameter; the `GET /`, `GET /projection`, and `POST /plans` routes call `isRamseyMode` once and pass the result in. `PATCH /settings` uses the body value directly when `ramsey_mode` was part of the request, falling back to a DB read only when it wasn't.
|
||||||
|
|
||||||
- **Month navigation brackets the month name** — In TrackerPage the month navigation pill previously showed `< Today >` — the arrows flanked a "Today" button rather than the current month. The pill now shows `< MAY 2026 >` with the month and year as a static label between the arrows, and "Today" promoted to a standalone `variant="outline"` button beside the pill. In CalendarPage the pill already had the correct structure (`< MONTH YEAR >`) but `min-w-40 px-3` (160 px minimum + 24 px of padding) made the label too wide, leaving the arrows visually disconnected from the text. Reduced to `min-w-[8rem] px-1` so the arrows bracket the text tightly. Both labels gain `tabular-nums` (prevents width jitter on month change) and `select-none` (prevents accidental text selection when clicking arrows quickly).
|
- **Month navigation brackets the month name** — In TrackerPage the month navigation pill previously showed `< Today >` — the arrows flanked a "Today" button rather than the current month. The pill now shows `< MAY 2026 >` with the month and year as a static label between the arrows, and "Today" promoted to a standalone `variant="outline"` button beside the pill. In CalendarPage the pill already had the correct structure (`< MONTH YEAR >`) but `min-w-40 px-3` (160 px minimum + 24 px of padding) made the label too wide, leaving the arrows visually disconnected from the text. Reduced to `min-w-[8rem] px-1` so the arrows bracket the text tightly. Both labels gain `tabular-nums` (prevents width jitter on month change) and `select-none` (prevents accidental text selection when clicking arrows quickly).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -344,6 +344,7 @@ export const api = {
|
||||||
deleteDataSource: (id) => del(`/data-sources/${id}`),
|
deleteDataSource: (id) => del(`/data-sources/${id}`),
|
||||||
dataSourceAccounts: (sourceId) => get(`/data-sources/${sourceId}/accounts`),
|
dataSourceAccounts: (sourceId) => get(`/data-sources/${sourceId}/accounts`),
|
||||||
setAccountMonitored: (sourceId, accountId, monitored) => put(`/data-sources/${sourceId}/accounts/${accountId}`, { monitored }),
|
setAccountMonitored: (sourceId, accountId, monitored) => put(`/data-sources/${sourceId}/accounts/${accountId}`, { monitored }),
|
||||||
|
allFinancialAccounts: () => get('/data-sources/accounts/all'),
|
||||||
|
|
||||||
// Admin — bank sync feature flag
|
// Admin — bank sync feature flag
|
||||||
bankSyncConfig: () => get('/admin/bank-sync-config'),
|
bankSyncConfig: () => get('/admin/bank-sync-config'),
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
AlertTriangle, Building2, ChevronDown, ChevronRight,
|
AlertTriangle, Building2, ChevronDown, ChevronRight,
|
||||||
Eye, EyeOff, ExternalLink, History, Link2Off, Loader2, RefreshCw, Unlink,
|
Eye, EyeOff, ExternalLink, History, Landmark, Link2Off, Loader2, RefreshCw, Unlink,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
@ -16,6 +16,8 @@ import {
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { SectionCard } from './dataShared';
|
import { SectionCard } from './dataShared';
|
||||||
|
|
||||||
function TokenInput({ value, onChange, disabled }) {
|
function TokenInput({ value, onChange, disabled }) {
|
||||||
|
|
@ -305,6 +307,13 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
||||||
const [matchTarget, setMatchTarget] = useState(null); // { sourceId, tx }
|
const [matchTarget, setMatchTarget] = useState(null); // { sourceId, tx }
|
||||||
const [matchingTxId, setMatchingTxId] = useState(null);
|
const [matchingTxId, setMatchingTxId] = useState(null);
|
||||||
|
|
||||||
|
// Bank tracking state
|
||||||
|
const [btEnabled, setBtEnabled] = useState(false);
|
||||||
|
const [btAccountId, setBtAccountId] = useState('');
|
||||||
|
const [btPendingDays, setBtPendingDays] = useState(3);
|
||||||
|
const [btAccounts, setBtAccounts] = useState([]);
|
||||||
|
const [btSaving, setBtSaving] = useState(false);
|
||||||
|
|
||||||
const loadAccounts = useCallback(async (conns) => {
|
const loadAccounts = useCallback(async (conns) => {
|
||||||
for (const conn of conns) {
|
for (const conn of conns) {
|
||||||
setAccountsLoading(prev => ({ ...prev, [conn.id]: true }));
|
setAccountsLoading(prev => ({ ...prev, [conn.id]: true }));
|
||||||
|
|
@ -320,6 +329,42 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Load bank tracking settings and available accounts
|
||||||
|
const loadBankTracking = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [settings, accounts] = await Promise.all([
|
||||||
|
api.getSettings(),
|
||||||
|
api.allFinancialAccounts().catch(() => []),
|
||||||
|
]);
|
||||||
|
setBtEnabled(settings.bank_tracking_enabled === 'true');
|
||||||
|
setBtAccountId(settings.bank_tracking_account_id || '');
|
||||||
|
setBtPendingDays(parseInt(settings.bank_tracking_pending_days, 10) || 3);
|
||||||
|
setBtAccounts(Array.isArray(accounts) ? accounts : []);
|
||||||
|
} catch {
|
||||||
|
// non-fatal — bank tracking section just won't populate
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBtSave = useCallback(async (patch) => {
|
||||||
|
setBtSaving(true);
|
||||||
|
try {
|
||||||
|
const next = {
|
||||||
|
bank_tracking_enabled: String(patch.enabled ?? btEnabled),
|
||||||
|
bank_tracking_account_id: String(patch.accountId ?? btAccountId),
|
||||||
|
bank_tracking_pending_days: String(patch.pendingDays ?? btPendingDays),
|
||||||
|
};
|
||||||
|
await api.saveSettings(next);
|
||||||
|
if (patch.enabled !== undefined) setBtEnabled(patch.enabled);
|
||||||
|
if (patch.accountId !== undefined) setBtAccountId(patch.accountId);
|
||||||
|
if (patch.pendingDays !== undefined) setBtPendingDays(patch.pendingDays);
|
||||||
|
toast.success('Bank tracking settings saved');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || 'Failed to save bank tracking settings');
|
||||||
|
} finally {
|
||||||
|
setBtSaving(false);
|
||||||
|
}
|
||||||
|
}, [btEnabled, btAccountId, btPendingDays]);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoadError('');
|
setLoadError('');
|
||||||
try {
|
try {
|
||||||
|
|
@ -340,6 +385,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
||||||
}, [onConnectionChange, loadAccounts]);
|
}, [onConnectionChange, loadAccounts]);
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
useEffect(() => { loadBankTracking(); }, [loadBankTracking]);
|
||||||
|
|
||||||
// Load bills once when connections become available (for the match picker)
|
// Load bills once when connections become available (for the match picker)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -723,6 +769,109 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* ── Bank Budget Tracking ── */}
|
||||||
|
{enabled && connections.length > 0 && (
|
||||||
|
<SectionCard
|
||||||
|
title="Bank Budget Tracking"
|
||||||
|
subtitle="Use your live bank balance as the starting point for your monthly budget instead of manually-entered amounts."
|
||||||
|
{...cardProps}
|
||||||
|
>
|
||||||
|
<div className="px-6 py-5 space-y-5">
|
||||||
|
|
||||||
|
{/* Toggle */}
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="bt-toggle" className="text-sm font-medium">
|
||||||
|
Use bank balance for budget
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Replaces manual starting amounts. Remaining = bank balance − pending payments − unpaid bills.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="bt-toggle"
|
||||||
|
checked={btEnabled}
|
||||||
|
disabled={btSaving}
|
||||||
|
onCheckedChange={v => handleBtSave({ enabled: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{btEnabled && (
|
||||||
|
<>
|
||||||
|
{/* Account picker */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Tracking account
|
||||||
|
</Label>
|
||||||
|
{btAccounts.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">No bank accounts found. Sync your SimpleFIN connection first.</p>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={btAccountId}
|
||||||
|
onValueChange={v => handleBtSave({ accountId: v })}
|
||||||
|
disabled={btSaving}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue placeholder="Select a checking account…" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{btAccounts.map(acc => (
|
||||||
|
<SelectItem key={acc.id} value={String(acc.id)}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Landmark className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span>{acc.org_name ? `${acc.org_name} — ` : ''}{acc.name}</span>
|
||||||
|
{acc.balance_dollars !== null && (
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||||
|
${acc.balance_dollars.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pending window */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Pending payment window
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Payments you mark as paid within this many days are shown as <em>pending</em> —
|
||||||
|
the money may not have cleared your bank yet, so they're subtracted from your effective balance.
|
||||||
|
</p>
|
||||||
|
<Select
|
||||||
|
value={String(btPendingDays)}
|
||||||
|
onValueChange={v => handleBtSave({ pendingDays: parseInt(v, 10) })}
|
||||||
|
disabled={btSaving}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 w-40 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="0">No pending (instant)</SelectItem>
|
||||||
|
<SelectItem value="1">1 day</SelectItem>
|
||||||
|
<SelectItem value="2">2 days</SelectItem>
|
||||||
|
<SelectItem value="3">3 days (recommended)</SelectItem>
|
||||||
|
<SelectItem value="5">5 days</SelectItem>
|
||||||
|
<SelectItem value="7">7 days</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info callout */}
|
||||||
|
<div className="rounded-lg border border-border/60 bg-muted/30 px-4 py-3 text-xs text-muted-foreground space-y-1">
|
||||||
|
<p><span className="font-semibold text-foreground">How it works:</span> Your live bank balance is fetched every time your data syncs. Bills you've already marked paid are not double-counted — your bank balance reflects them. Only unpaid bills still due this month are subtracted.</p>
|
||||||
|
<p>Bills marked paid within the pending window show a <span className="font-semibold">Pending</span> badge in the tracker, since the bank may not have processed them yet.</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
|
||||||
<AlertDialog open={!!disconnectTarget} onOpenChange={open => { if (!open) setDisconnectTarget(null); }}>
|
<AlertDialog open={!!disconnectTarget} onOpenChange={open => { if (!open) setDisconnectTarget(null); }}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
|
|
|
||||||
|
|
@ -456,15 +456,25 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
||||||
|
|
||||||
{/* Status — uses effectiveStatus (accounts for skipped + threshold) */}
|
{/* Status — uses effectiveStatus (accounts for skipped + threshold) */}
|
||||||
<TableCell className="w-[9%] py-3">
|
<TableCell className="w-[9%] py-3">
|
||||||
<StatusBadge
|
<div className="flex flex-col items-start gap-1">
|
||||||
status={effectiveStatus}
|
<StatusBadge
|
||||||
clickable
|
status={effectiveStatus}
|
||||||
onClick={() => {
|
clickable
|
||||||
if (effectiveStatus === 'skipped') return;
|
onClick={() => {
|
||||||
handleTogglePaid();
|
if (effectiveStatus === 'skipped') return;
|
||||||
}}
|
handleTogglePaid();
|
||||||
loading={loading}
|
}}
|
||||||
/>
|
loading={loading}
|
||||||
|
/>
|
||||||
|
{row.pending_cleared && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded-full border border-amber-400/40 bg-amber-500/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400"
|
||||||
|
title="Paid in tracker but may not have cleared your bank account yet"
|
||||||
|
>
|
||||||
|
Pending
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,9 @@ function MoneyMap({ summaryData, loading }) {
|
||||||
|
|
||||||
const starting = summaryData?.starting_amounts || {};
|
const starting = summaryData?.starting_amounts || {};
|
||||||
const summary = summaryData?.summary || {};
|
const summary = summaryData?.summary || {};
|
||||||
const available = Number(starting.combined_amount || 0);
|
const bt = summaryData?.bank_tracking;
|
||||||
|
const bankMode = bt?.enabled === true;
|
||||||
|
const available = bankMode ? Number(bt.effective_balance || 0) : Number(starting.combined_amount || 0);
|
||||||
const assigned = Number(summary.expense_total || 0);
|
const assigned = Number(summary.expense_total || 0);
|
||||||
const paid = Number(summary.paid_total || 0);
|
const paid = Number(summary.paid_total || 0);
|
||||||
const remaining = Number(summary.result || 0);
|
const remaining = Number(summary.result || 0);
|
||||||
|
|
@ -102,44 +104,75 @@ function MoneyMap({ summaryData, loading }) {
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base">Monthly Money Map</CardTitle>
|
<CardTitle className="text-base">Monthly Money Map</CardTitle>
|
||||||
<CardDescription>Available money, extra income, assigned bills, and what remains.</CardDescription>
|
<CardDescription>
|
||||||
|
{bankMode
|
||||||
|
? `Live bank balance · ${bt.account_name}`
|
||||||
|
: 'Available money, extra income, assigned bills, and what remains.'}
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild variant="outline" size="sm" className="w-full gap-2 sm:w-auto">
|
{!bankMode && (
|
||||||
<Link to="/summary">
|
<Button asChild variant="outline" size="sm" className="w-full gap-2 sm:w-auto">
|
||||||
<WalletCards className="h-4 w-4" />
|
<Link to="/summary">
|
||||||
Edit money plan
|
<WalletCards className="h-4 w-4" />
|
||||||
</Link>
|
Edit money plan
|
||||||
</Button>
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<MoneyMetric icon={Banknote} label="Available" value={available} hint="1st + 15th + extra" />
|
{bankMode ? (
|
||||||
<MoneyMetric icon={PiggyBank} label="Extra Income" value={extraIncome} hint="Extra beyond paychecks" valueClassName={extraIncome > 0 ? 'text-teal-600 dark:text-teal-300' : ''} />
|
<>
|
||||||
<MoneyMetric icon={CalendarDays} label="Assigned Bills" value={assigned} hint={`${summary.expense_count || 0} active bills`} />
|
<MoneyMetric icon={Banknote} label="Bank Balance" value={Number(bt.balance || 0)} hint={`as of last sync`} />
|
||||||
<MoneyMetric
|
<MoneyMetric icon={PiggyBank} label="Pending" value={Number(bt.pending_payments || 0)} hint={`paid, not yet cleared (${bt.pending_days}d window)`} valueClassName="text-amber-600 dark:text-amber-400" />
|
||||||
icon={CircleDollarSign}
|
<MoneyMetric icon={CalendarDays} label="Unpaid Bills" value={Number(bt.unpaid_this_month || 0)} hint={`${summary.expense_count || 0} active bills`} />
|
||||||
label="After Bills"
|
<MoneyMetric
|
||||||
value={remaining}
|
icon={CircleDollarSign}
|
||||||
hint={`${fmt(paid)} already paid`}
|
label="After Bills"
|
||||||
valueClassName={remaining >= 0 ? 'text-emerald-600 dark:text-emerald-300' : 'text-destructive'}
|
value={Number(bt.remaining || 0)}
|
||||||
/>
|
hint="effective balance − unpaid"
|
||||||
|
valueClassName={Number(bt.remaining || 0) >= 0 ? 'text-emerald-600 dark:text-emerald-300' : 'text-destructive'}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<MoneyMetric icon={Banknote} label="Available" value={available} hint="1st + 15th + extra" />
|
||||||
|
<MoneyMetric icon={PiggyBank} label="Extra Income" value={extraIncome} hint="Extra beyond paychecks" valueClassName={extraIncome > 0 ? 'text-teal-600 dark:text-teal-300' : ''} />
|
||||||
|
<MoneyMetric icon={CalendarDays} label="Assigned Bills" value={assigned} hint={`${summary.expense_count || 0} active bills`} />
|
||||||
|
<MoneyMetric
|
||||||
|
icon={CircleDollarSign}
|
||||||
|
label="After Bills"
|
||||||
|
value={remaining}
|
||||||
|
hint={`${fmt(paid)} already paid`}
|
||||||
|
valueClassName={remaining >= 0 ? 'text-emerald-600 dark:text-emerald-300' : 'text-destructive'}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2 text-sm md:grid-cols-3">
|
{!bankMode && (
|
||||||
<div className="flex items-center justify-between gap-3 rounded-md bg-muted/45 px-3 py-2">
|
<div className="grid gap-2 text-sm md:grid-cols-3">
|
||||||
<span className="text-muted-foreground">1st available</span>
|
<div className="flex items-center justify-between gap-3 rounded-md bg-muted/45 px-3 py-2">
|
||||||
<span className="tracker-number font-semibold">{fmt(starting.first_amount)}</span>
|
<span className="text-muted-foreground">1st available</span>
|
||||||
|
<span className="tracker-number font-semibold">{fmt(starting.first_amount)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3 rounded-md bg-muted/45 px-3 py-2">
|
||||||
|
<span className="text-muted-foreground">15th available</span>
|
||||||
|
<span className="tracker-number font-semibold">{fmt(starting.fifteenth_amount)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3 rounded-md bg-muted/45 px-3 py-2">
|
||||||
|
<span className="text-muted-foreground">Monthly income</span>
|
||||||
|
<span className="tracker-number truncate font-semibold">{fmt(summaryData?.income?.amount)}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-3 rounded-md bg-muted/45 px-3 py-2">
|
)}
|
||||||
<span className="text-muted-foreground">15th available</span>
|
|
||||||
<span className="tracker-number font-semibold">{fmt(starting.fifteenth_amount)}</span>
|
{bankMode && bt.last_updated && (
|
||||||
</div>
|
<p className="text-xs text-muted-foreground">
|
||||||
<div className="flex items-center justify-between gap-3 rounded-md bg-muted/45 px-3 py-2">
|
Balance last updated: {new Date(bt.last_updated).toLocaleString()}
|
||||||
<span className="text-muted-foreground">Monthly income</span>
|
</p>
|
||||||
<span className="tracker-number truncate font-semibold">{fmt(summaryData?.income?.amount)}</span>
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -113,8 +113,9 @@ export default function TrackerPage() {
|
||||||
updateParams({ year: n.getFullYear(), month: n.getMonth() + 1 });
|
updateParams({ year: n.getFullYear(), month: n.getMonth() + 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = orderedRows || data?.rows || [];
|
const rows = orderedRows || data?.rows || [];
|
||||||
const summary = data?.summary || {};
|
const summary = data?.summary || {};
|
||||||
|
const bankTracking = data?.bank_tracking;
|
||||||
const toggleFilter = (key) => {
|
const toggleFilter = (key) => {
|
||||||
const paramMap = { autopay: 'ap', firstBucket: 'b1', fifteenthBucket: 'b2', unpaid: 'un', overdue: 'ov', debt: 'de' };
|
const paramMap = { autopay: 'ap', firstBucket: 'b1', fifteenthBucket: 'b2', unpaid: 'un', overdue: 'ov', debt: 'de' };
|
||||||
updateParams({ [paramMap[key]]: !filters[key] });
|
updateParams({ [paramMap[key]]: !filters[key] });
|
||||||
|
|
@ -339,8 +340,12 @@ export default function TrackerPage() {
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
type="starting"
|
type="starting"
|
||||||
value={summary.total_starting}
|
value={summary.total_starting}
|
||||||
hint={!summary.has_starting_amounts ? 'Set monthly starting cash' : ''}
|
hint={
|
||||||
onEdit={() => setEditStartingOpen(true)}
|
bankTracking?.enabled
|
||||||
|
? `${bankTracking.account_name} · live balance`
|
||||||
|
: !summary.has_starting_amounts ? 'Set monthly starting cash' : ''
|
||||||
|
}
|
||||||
|
onEdit={bankTracking?.enabled ? undefined : () => setEditStartingOpen(true)}
|
||||||
/>
|
/>
|
||||||
<SummaryCard type="paid" value={summary.total_paid} />
|
<SummaryCard type="paid" value={summary.total_paid} />
|
||||||
<SummaryCard type="overdue" value={summary.overdue} />
|
<SummaryCard type="overdue" value={summary.overdue} />
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,34 @@ function safeError(err, fallback) {
|
||||||
return { msg, status };
|
return { msg, status };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/data-sources/accounts/all ──────────────────────────────────────
|
||||||
|
// Returns all financial accounts for the user across all sources.
|
||||||
|
// Used by the bank tracking account picker.
|
||||||
|
|
||||||
|
router.get('/accounts/all', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const accounts = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
fa.id, fa.name, fa.org_name, fa.account_type,
|
||||||
|
fa.balance, fa.available_balance, fa.currency,
|
||||||
|
fa.monitored, ds.id AS source_id
|
||||||
|
FROM financial_accounts fa
|
||||||
|
JOIN data_sources ds ON ds.id = fa.data_source_id
|
||||||
|
WHERE fa.user_id = ?
|
||||||
|
ORDER BY fa.org_name COLLATE NOCASE ASC, fa.name COLLATE NOCASE ASC
|
||||||
|
`).all(req.user.id);
|
||||||
|
|
||||||
|
res.json(accounts.map(a => ({
|
||||||
|
...a,
|
||||||
|
monitored: a.monitored === 1,
|
||||||
|
balance_dollars: a.balance !== null ? a.balance / 100 : null,
|
||||||
|
})));
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json(standardizeError(err.message || 'Failed to load accounts', 'DB_ERROR'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ─── GET /api/data-sources ────────────────────────────────────────────────────
|
// ─── GET /api/data-sources ────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,90 @@ const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const { getCycleRange } = require('../services/statusService');
|
const { getCycleRange } = require('../services/statusService');
|
||||||
|
const { getUserSettings } = require('../services/userSettings');
|
||||||
|
|
||||||
const DEFAULT_INCOME_LABEL = 'Salary';
|
const DEFAULT_INCOME_LABEL = 'Salary';
|
||||||
|
const DEFAULT_PENDING_DAYS = 3;
|
||||||
|
|
||||||
|
// Build bank tracking summary when the user has enabled SimpleFIN bank tracking.
|
||||||
|
// Returns null when disabled, the account isn't found, or bank sync isn't set up.
|
||||||
|
function buildBankTrackingSummary(db, userId, year, month) {
|
||||||
|
const settings = getUserSettings(userId);
|
||||||
|
if (settings.bank_tracking_enabled !== 'true') return null;
|
||||||
|
|
||||||
|
const accountId = parseInt(settings.bank_tracking_account_id, 10);
|
||||||
|
if (!Number.isInteger(accountId) || accountId < 1) return null;
|
||||||
|
|
||||||
|
const account = db.prepare(`
|
||||||
|
SELECT id, name, org_name, account_type, balance, available_balance, updated_at
|
||||||
|
FROM financial_accounts
|
||||||
|
WHERE id = ? AND user_id = ?
|
||||||
|
`).get(accountId, userId);
|
||||||
|
|
||||||
|
if (!account || account.balance === null) return null;
|
||||||
|
|
||||||
|
const pendingDays = parseInt(settings.bank_tracking_pending_days, 10);
|
||||||
|
const effectivePendingDays = Number.isInteger(pendingDays) && pendingDays >= 0
|
||||||
|
? pendingDays : DEFAULT_PENDING_DAYS;
|
||||||
|
|
||||||
|
// Payments made in the tracker recently that may not have cleared the bank yet
|
||||||
|
const pendingRow = effectivePendingDays > 0
|
||||||
|
? db.prepare(`
|
||||||
|
SELECT COALESCE(SUM(p.amount), 0) AS pending_total
|
||||||
|
FROM payments p
|
||||||
|
JOIN bills b ON b.id = p.bill_id
|
||||||
|
WHERE b.user_id = ?
|
||||||
|
AND p.paid_date >= date('now', '-' || ? || ' days')
|
||||||
|
AND p.paid_date <= date('now')
|
||||||
|
AND b.deleted_at IS NULL
|
||||||
|
`).get(userId, effectivePendingDays)
|
||||||
|
: { pending_total: 0 };
|
||||||
|
|
||||||
|
// Unpaid bills remaining this month (not skipped, not yet paid)
|
||||||
|
const { start, end } = getCycleRange(year, month);
|
||||||
|
const unpaidRow = db.prepare(`
|
||||||
|
SELECT COALESCE(SUM(
|
||||||
|
CASE
|
||||||
|
WHEN m.actual_amount IS NOT NULL THEN m.actual_amount
|
||||||
|
ELSE COALESCE(b.expected_amount, 0)
|
||||||
|
END
|
||||||
|
), 0) AS unpaid_total
|
||||||
|
FROM bills b
|
||||||
|
LEFT JOIN monthly_bill_state m ON m.bill_id = b.id AND m.year = ? AND m.month = ?
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT bill_id, SUM(amount) AS paid_sum
|
||||||
|
FROM payments
|
||||||
|
WHERE paid_date BETWEEN ? AND ?
|
||||||
|
GROUP BY bill_id
|
||||||
|
) pay ON pay.bill_id = b.id
|
||||||
|
WHERE b.user_id = ?
|
||||||
|
AND b.active = 1
|
||||||
|
AND b.deleted_at IS NULL
|
||||||
|
AND COALESCE(m.is_skipped, 0) = 0
|
||||||
|
AND COALESCE(pay.paid_sum, 0) = 0
|
||||||
|
`).get(year, month, start, end, userId);
|
||||||
|
|
||||||
|
const balanceDollars = money(account.balance / 100);
|
||||||
|
const pendingDollars = money(pendingRow.pending_total);
|
||||||
|
const effectiveDollars = money(balanceDollars - pendingDollars);
|
||||||
|
const unpaidDollars = money(unpaidRow.unpaid_total);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
account_id: account.id,
|
||||||
|
account_name: account.name,
|
||||||
|
org_name: account.org_name,
|
||||||
|
account_type: account.account_type,
|
||||||
|
balance: balanceDollars,
|
||||||
|
available_balance: account.available_balance !== null ? money(account.available_balance / 100) : null,
|
||||||
|
last_updated: account.updated_at,
|
||||||
|
pending_payments: pendingDollars,
|
||||||
|
pending_days: effectivePendingDays,
|
||||||
|
effective_balance: effectiveDollars,
|
||||||
|
unpaid_this_month: unpaidDollars,
|
||||||
|
remaining: money(effectiveDollars - unpaidDollars),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function parseYearMonth(source) {
|
function parseYearMonth(source) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
@ -211,7 +293,11 @@ function buildSummary(db, userId, year, month) {
|
||||||
const paidTotal = money(countedExpenses.reduce((sum, expense) => sum + expense.paid_amount, 0));
|
const paidTotal = money(countedExpenses.reduce((sum, expense) => sum + expense.paid_amount, 0));
|
||||||
const paidExpenseCount = countedExpenses.filter(expense => expense.is_paid).length;
|
const paidExpenseCount = countedExpenses.filter(expense => expense.is_paid).length;
|
||||||
const starting_amounts = buildStartingAmountsSummary(db, userId, year, month);
|
const starting_amounts = buildStartingAmountsSummary(db, userId, year, month);
|
||||||
const planBaseTotal = money(starting_amounts.combined_amount);
|
const bank_tracking = buildBankTrackingSummary(db, userId, year, month);
|
||||||
|
// When bank tracking is on, drive the "plan base" from the effective bank balance
|
||||||
|
const planBaseTotal = bank_tracking
|
||||||
|
? bank_tracking.effective_balance
|
||||||
|
: money(starting_amounts.combined_amount);
|
||||||
const result = money(planBaseTotal - expenseTotal);
|
const result = money(planBaseTotal - expenseTotal);
|
||||||
|
|
||||||
// Previous month context
|
// Previous month context
|
||||||
|
|
@ -246,6 +332,7 @@ function buildSummary(db, userId, year, month) {
|
||||||
income,
|
income,
|
||||||
expenses,
|
expenses,
|
||||||
starting_amounts,
|
starting_amounts,
|
||||||
|
bank_tracking: bank_tracking ?? { enabled: false },
|
||||||
previous_month,
|
previous_month,
|
||||||
summary: {
|
summary: {
|
||||||
income_total: incomeTotal,
|
income_total: incomeTotal,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,70 @@ const { getUserSettings } = require('./userSettings');
|
||||||
const { computeBalanceDelta } = require('./billsService');
|
const { computeBalanceDelta } = require('./billsService');
|
||||||
const { computeAmountSuggestion } = require('./amountSuggestionService');
|
const { computeAmountSuggestion } = require('./amountSuggestionService');
|
||||||
|
|
||||||
|
const DEFAULT_PENDING_DAYS = 3;
|
||||||
|
|
||||||
|
function buildBankTracking(db, userId, year, month) {
|
||||||
|
const settings = getUserSettings(userId);
|
||||||
|
if (settings.bank_tracking_enabled !== 'true') return { enabled: false };
|
||||||
|
|
||||||
|
const accountId = parseInt(settings.bank_tracking_account_id, 10);
|
||||||
|
if (!Number.isInteger(accountId) || accountId < 1) return { enabled: false };
|
||||||
|
|
||||||
|
const account = db.prepare(`
|
||||||
|
SELECT id, name, org_name, balance, available_balance, updated_at
|
||||||
|
FROM financial_accounts WHERE id = ? AND user_id = ?
|
||||||
|
`).get(accountId, userId);
|
||||||
|
if (!account || account.balance === null) return { enabled: false };
|
||||||
|
|
||||||
|
const pendingDays = parseInt(settings.bank_tracking_pending_days, 10);
|
||||||
|
const days = Number.isInteger(pendingDays) && pendingDays >= 0 ? pendingDays : DEFAULT_PENDING_DAYS;
|
||||||
|
|
||||||
|
const pendingRow = days > 0
|
||||||
|
? db.prepare(`
|
||||||
|
SELECT COALESCE(SUM(p.amount), 0) AS pending_total
|
||||||
|
FROM payments p JOIN bills b ON b.id = p.bill_id
|
||||||
|
WHERE b.user_id = ?
|
||||||
|
AND p.paid_date >= date('now', '-' || ? || ' days')
|
||||||
|
AND p.paid_date <= date('now') AND b.deleted_at IS NULL
|
||||||
|
`).get(userId, days)
|
||||||
|
: { pending_total: 0 };
|
||||||
|
|
||||||
|
const { start, end } = getCycleRange(year, month);
|
||||||
|
const unpaidRow = db.prepare(`
|
||||||
|
SELECT COALESCE(SUM(
|
||||||
|
CASE WHEN m.actual_amount IS NOT NULL THEN m.actual_amount
|
||||||
|
ELSE COALESCE(b.expected_amount, 0) END
|
||||||
|
), 0) AS unpaid_total
|
||||||
|
FROM bills b
|
||||||
|
LEFT JOIN monthly_bill_state m ON m.bill_id = b.id AND m.year = ? AND m.month = ?
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT bill_id, SUM(amount) AS paid_sum FROM payments
|
||||||
|
WHERE paid_date BETWEEN ? AND ? GROUP BY bill_id
|
||||||
|
) pay ON pay.bill_id = b.id
|
||||||
|
WHERE b.user_id = ? AND b.active = 1 AND b.deleted_at IS NULL
|
||||||
|
AND COALESCE(m.is_skipped, 0) = 0 AND COALESCE(pay.paid_sum, 0) = 0
|
||||||
|
`).get(year, month, start, end, userId);
|
||||||
|
|
||||||
|
const balance = roundMoney(account.balance / 100);
|
||||||
|
const pending = roundMoney(pendingRow.pending_total);
|
||||||
|
const effective = roundMoney(balance - pending);
|
||||||
|
const unpaid = roundMoney(unpaidRow.unpaid_total);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
account_id: account.id,
|
||||||
|
account_name: account.name,
|
||||||
|
org_name: account.org_name,
|
||||||
|
balance,
|
||||||
|
pending_payments: pending,
|
||||||
|
pending_days: days,
|
||||||
|
effective_balance: effective,
|
||||||
|
unpaid_this_month: unpaid,
|
||||||
|
remaining: roundMoney(effective - unpaid),
|
||||||
|
last_updated: account.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function validateTrackerMonth(query = {}, now = new Date()) {
|
function validateTrackerMonth(query = {}, now = new Date()) {
|
||||||
const year = parseInt(query.year || now.getFullYear(), 10);
|
const year = parseInt(query.year || now.getFullYear(), 10);
|
||||||
const month = parseInt(query.month || now.getMonth() + 1, 10);
|
const month = parseInt(query.month || now.getMonth() + 1, 10);
|
||||||
|
|
@ -295,8 +359,11 @@ function getTracker(userId, query = {}, now = new Date()) {
|
||||||
: (startingAmounts?.fifteenth_amount || 0);
|
: (startingAmounts?.fifteenth_amount || 0);
|
||||||
const periodLabel = activeRemainingPeriod === '1st' ? '1st balance' : '15th balance';
|
const periodLabel = activeRemainingPeriod === '1st' ? '1st balance' : '15th balance';
|
||||||
|
|
||||||
const totalStarting = startingAmounts?.combined_amount || 0;
|
const bankTracking = buildBankTracking(db, userId, year, month);
|
||||||
const hasStartingAmounts = !!startingAmounts;
|
const totalStarting = bankTracking.enabled
|
||||||
|
? bankTracking.effective_balance
|
||||||
|
: (startingAmounts?.combined_amount || 0);
|
||||||
|
const hasStartingAmounts = bankTracking.enabled || !!startingAmounts;
|
||||||
const activeTotalPaid = roundMoney(activeRows.reduce((s, r) => s + r.total_paid, 0));
|
const activeTotalPaid = roundMoney(activeRows.reduce((s, r) => s + r.total_paid, 0));
|
||||||
const activePaidTowardDue = roundMoney(activeRows.reduce((s, r) => s + rowPaidTowardDue(r), 0));
|
const activePaidTowardDue = roundMoney(activeRows.reduce((s, r) => s + rowPaidTowardDue(r), 0));
|
||||||
const activeTotalExpected = roundMoney(activeRows.reduce((s, r) => s + rowDueAmount(r), 0));
|
const activeTotalExpected = roundMoney(activeRows.reduce((s, r) => s + rowDueAmount(r), 0));
|
||||||
|
|
@ -331,7 +398,19 @@ function getTracker(userId, query = {}, now = new Date()) {
|
||||||
previous_month_total: previousMonthTotal,
|
previous_month_total: previousMonthTotal,
|
||||||
trend: buildThreeMonthTrend(db, userId, year, month, end, activeTotalPaid),
|
trend: buildThreeMonthTrend(db, userId, year, month, end, activeTotalPaid),
|
||||||
},
|
},
|
||||||
rows,
|
bank_tracking: bankTracking,
|
||||||
|
rows: bankTracking.enabled
|
||||||
|
? rows.map(r => {
|
||||||
|
// Flag recently-paid rows as pending-cleared when bank tracking is on
|
||||||
|
if (r.status === 'paid' && r.last_paid_date) {
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setDate(cutoff.getDate() - bankTracking.pending_days);
|
||||||
|
const paidAt = new Date(r.last_paid_date);
|
||||||
|
return { ...r, pending_cleared: paidAt >= cutoff };
|
||||||
|
}
|
||||||
|
return { ...r, pending_cleared: false };
|
||||||
|
})
|
||||||
|
: rows,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ const USER_SETTING_KEYS = [
|
||||||
'grace_period_days',
|
'grace_period_days',
|
||||||
'notify_days_before',
|
'notify_days_before',
|
||||||
'drift_threshold_pct',
|
'drift_threshold_pct',
|
||||||
|
'bank_tracking_enabled',
|
||||||
|
'bank_tracking_account_id',
|
||||||
|
'bank_tracking_pending_days',
|
||||||
];
|
];
|
||||||
|
|
||||||
function defaultUserSettings() {
|
function defaultUserSettings() {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue