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
|
||||
|
||||
- **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.
|
||||
|
||||
### 🐛 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).
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -344,6 +344,7 @@ export const api = {
|
|||
deleteDataSource: (id) => del(`/data-sources/${id}`),
|
||||
dataSourceAccounts: (sourceId) => get(`/data-sources/${sourceId}/accounts`),
|
||||
setAccountMonitored: (sourceId, accountId, monitored) => put(`/data-sources/${sourceId}/accounts/${accountId}`, { monitored }),
|
||||
allFinancialAccounts: () => get('/data-sources/accounts/all'),
|
||||
|
||||
// Admin — bank sync feature flag
|
||||
bankSyncConfig: () => get('/admin/bank-sync-config'),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
|||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertTriangle, Building2, ChevronDown, ChevronRight,
|
||||
Eye, EyeOff, ExternalLink, History, Link2Off, Loader2, RefreshCw, Unlink,
|
||||
Eye, EyeOff, ExternalLink, History, Landmark, Link2Off, Loader2, RefreshCw, Unlink,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
|
@ -16,6 +16,8 @@ import {
|
|||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { SectionCard } from './dataShared';
|
||||
|
||||
function TokenInput({ value, onChange, disabled }) {
|
||||
|
|
@ -305,6 +307,13 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
|||
const [matchTarget, setMatchTarget] = useState(null); // { sourceId, tx }
|
||||
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) => {
|
||||
for (const conn of conns) {
|
||||
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 () => {
|
||||
setLoadError('');
|
||||
try {
|
||||
|
|
@ -340,6 +385,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
|||
}, [onConnectionChange, loadAccounts]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
useEffect(() => { loadBankTracking(); }, [loadBankTracking]);
|
||||
|
||||
// Load bills once when connections become available (for the match picker)
|
||||
useEffect(() => {
|
||||
|
|
@ -723,6 +769,109 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
|||
)}
|
||||
</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); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
|
|
|
|||
|
|
@ -456,15 +456,25 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
|||
|
||||
{/* Status — uses effectiveStatus (accounts for skipped + threshold) */}
|
||||
<TableCell className="w-[9%] py-3">
|
||||
<StatusBadge
|
||||
status={effectiveStatus}
|
||||
clickable
|
||||
onClick={() => {
|
||||
if (effectiveStatus === 'skipped') return;
|
||||
handleTogglePaid();
|
||||
}}
|
||||
loading={loading}
|
||||
/>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<StatusBadge
|
||||
status={effectiveStatus}
|
||||
clickable
|
||||
onClick={() => {
|
||||
if (effectiveStatus === 'skipped') return;
|
||||
handleTogglePaid();
|
||||
}}
|
||||
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>
|
||||
|
||||
{/* Actions */}
|
||||
|
|
|
|||
|
|
@ -90,7 +90,9 @@ function MoneyMap({ summaryData, loading }) {
|
|||
|
||||
const starting = summaryData?.starting_amounts || {};
|
||||
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 paid = Number(summary.paid_total || 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>
|
||||
<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>
|
||||
<Button asChild variant="outline" size="sm" className="w-full gap-2 sm:w-auto">
|
||||
<Link to="/summary">
|
||||
<WalletCards className="h-4 w-4" />
|
||||
Edit money plan
|
||||
</Link>
|
||||
</Button>
|
||||
{!bankMode && (
|
||||
<Button asChild variant="outline" size="sm" className="w-full gap-2 sm:w-auto">
|
||||
<Link to="/summary">
|
||||
<WalletCards className="h-4 w-4" />
|
||||
Edit money plan
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-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" />
|
||||
<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'}
|
||||
/>
|
||||
{bankMode ? (
|
||||
<>
|
||||
<MoneyMetric icon={Banknote} label="Bank Balance" value={Number(bt.balance || 0)} hint={`as of last sync`} />
|
||||
<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" />
|
||||
<MoneyMetric icon={CalendarDays} label="Unpaid Bills" value={Number(bt.unpaid_this_month || 0)} hint={`${summary.expense_count || 0} active bills`} />
|
||||
<MoneyMetric
|
||||
icon={CircleDollarSign}
|
||||
label="After Bills"
|
||||
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 className="grid gap-2 text-sm md:grid-cols-3">
|
||||
<div className="flex items-center justify-between gap-3 rounded-md bg-muted/45 px-3 py-2">
|
||||
<span className="text-muted-foreground">1st available</span>
|
||||
<span className="tracker-number font-semibold">{fmt(starting.first_amount)}</span>
|
||||
{!bankMode && (
|
||||
<div className="grid gap-2 text-sm md:grid-cols-3">
|
||||
<div className="flex items-center justify-between gap-3 rounded-md bg-muted/45 px-3 py-2">
|
||||
<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 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>
|
||||
)}
|
||||
|
||||
{bankMode && bt.last_updated && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Balance last updated: {new Date(bt.last_updated).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -113,8 +113,9 @@ export default function TrackerPage() {
|
|||
updateParams({ year: n.getFullYear(), month: n.getMonth() + 1 });
|
||||
}
|
||||
|
||||
const rows = orderedRows || data?.rows || [];
|
||||
const summary = data?.summary || {};
|
||||
const rows = orderedRows || data?.rows || [];
|
||||
const summary = data?.summary || {};
|
||||
const bankTracking = data?.bank_tracking;
|
||||
const toggleFilter = (key) => {
|
||||
const paramMap = { autopay: 'ap', firstBucket: 'b1', fifteenthBucket: 'b2', unpaid: 'un', overdue: 'ov', debt: 'de' };
|
||||
updateParams({ [paramMap[key]]: !filters[key] });
|
||||
|
|
@ -339,8 +340,12 @@ export default function TrackerPage() {
|
|||
<SummaryCard
|
||||
type="starting"
|
||||
value={summary.total_starting}
|
||||
hint={!summary.has_starting_amounts ? 'Set monthly starting cash' : ''}
|
||||
onEdit={() => setEditStartingOpen(true)}
|
||||
hint={
|
||||
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="overdue" value={summary.overdue} />
|
||||
|
|
|
|||
|
|
@ -21,6 +21,34 @@ function safeError(err, fallback) {
|
|||
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 ────────────────────────────────────────────────────
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,90 @@ const express = require('express');
|
|||
const router = express.Router();
|
||||
const { getDb } = require('../db/database');
|
||||
const { getCycleRange } = require('../services/statusService');
|
||||
const { getUserSettings } = require('../services/userSettings');
|
||||
|
||||
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) {
|
||||
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 paidExpenseCount = countedExpenses.filter(expense => expense.is_paid).length;
|
||||
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);
|
||||
|
||||
// Previous month context
|
||||
|
|
@ -246,6 +332,7 @@ function buildSummary(db, userId, year, month) {
|
|||
income,
|
||||
expenses,
|
||||
starting_amounts,
|
||||
bank_tracking: bank_tracking ?? { enabled: false },
|
||||
previous_month,
|
||||
summary: {
|
||||
income_total: incomeTotal,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,70 @@ const { getUserSettings } = require('./userSettings');
|
|||
const { computeBalanceDelta } = require('./billsService');
|
||||
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()) {
|
||||
const year = parseInt(query.year || now.getFullYear(), 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);
|
||||
const periodLabel = activeRemainingPeriod === '1st' ? '1st balance' : '15th balance';
|
||||
|
||||
const totalStarting = startingAmounts?.combined_amount || 0;
|
||||
const hasStartingAmounts = !!startingAmounts;
|
||||
const bankTracking = buildBankTracking(db, userId, year, month);
|
||||
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 activePaidTowardDue = roundMoney(activeRows.reduce((s, r) => s + rowPaidTowardDue(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,
|
||||
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',
|
||||
'notify_days_before',
|
||||
'drift_threshold_pct',
|
||||
'bank_tracking_enabled',
|
||||
'bank_tracking_account_id',
|
||||
'bank_tracking_pending_days',
|
||||
];
|
||||
|
||||
function defaultUserSettings() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue