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:
null 2026-06-03 21:09:26 -05:00
parent 44320a7613
commit 690a86611a
10 changed files with 450 additions and 49 deletions

View File

@ -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 (07 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).
---

View File

@ -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'),

View File

@ -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&nbsp;= 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>

View File

@ -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 */}

View File

@ -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>
);

View File

@ -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} />

View File

@ -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) => {

View File

@ -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,

View File

@ -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,
};
}

View File

@ -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() {