From 690a86611af4021b6714f7c3d4b1788b2211ca35 Mon Sep 17 00:00:00 2001 From: null Date: Wed, 3 Jun 2026 21:09:26 -0500 Subject: [PATCH] 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 --- HISTORY.md | 6 + client/api.js | 1 + client/components/data/BankSyncSection.jsx | 151 ++++++++++++++++++++- client/components/tracker/TrackerRow.jsx | 28 ++-- client/pages/CalendarPage.jsx | 95 ++++++++----- client/pages/TrackerPage.jsx | 13 +- routes/dataSources.js | 28 ++++ routes/summary.js | 89 +++++++++++- services/trackerService.js | 85 +++++++++++- services/userSettings.js | 3 + 10 files changed, 450 insertions(+), 49 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index 8f1cce1..586cb5c 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -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 `` 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). --- diff --git a/client/api.js b/client/api.js index 155924f..1525ae1 100644 --- a/client/api.js +++ b/client/api.js @@ -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'), diff --git a/client/components/data/BankSyncSection.jsx b/client/components/data/BankSyncSection.jsx index 7218579..acaa35e 100644 --- a/client/components/data/BankSyncSection.jsx +++ b/client/components/data/BankSyncSection.jsx @@ -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 = {} }) )} + {/* ── Bank Budget Tracking ── */} + {enabled && connections.length > 0 && ( + +
+ + {/* Toggle */} +
+
+ +

+ Replaces manual starting amounts. Remaining = bank balance − pending payments − unpaid bills. +

+
+ handleBtSave({ enabled: v })} + /> +
+ + {btEnabled && ( + <> + {/* Account picker */} +
+ + {btAccounts.length === 0 ? ( +

No bank accounts found. Sync your SimpleFIN connection first.

+ ) : ( + + )} +
+ + {/* Pending window */} +
+ +

+ Payments you mark as paid within this many days are shown as pending — + the money may not have cleared your bank yet, so they're subtracted from your effective balance. +

+ +
+ + {/* Info callout */} +
+

How it works: 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.

+

Bills marked paid within the pending window show a Pending badge in the tracker, since the bank may not have processed them yet.

+
+ + )} +
+
+ )} + { if (!open) setDisconnectTarget(null); }}> diff --git a/client/components/tracker/TrackerRow.jsx b/client/components/tracker/TrackerRow.jsx index 9df4567..c9c2940 100644 --- a/client/components/tracker/TrackerRow.jsx +++ b/client/components/tracker/TrackerRow.jsx @@ -456,15 +456,25 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC {/* Status — uses effectiveStatus (accounts for skipped + threshold) */} - { - if (effectiveStatus === 'skipped') return; - handleTogglePaid(); - }} - loading={loading} - /> +
+ { + if (effectiveStatus === 'skipped') return; + handleTogglePaid(); + }} + loading={loading} + /> + {row.pending_cleared && ( + + Pending + + )} +
{/* Actions */} diff --git a/client/pages/CalendarPage.jsx b/client/pages/CalendarPage.jsx index 1c1c966..a4ea029 100644 --- a/client/pages/CalendarPage.jsx +++ b/client/pages/CalendarPage.jsx @@ -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 }) {
Monthly Money Map - Available money, extra income, assigned bills, and what remains. + + {bankMode + ? `Live bank balance · ${bt.account_name}` + : 'Available money, extra income, assigned bills, and what remains.'} +
- + {!bankMode && ( + + )}
- - 0 ? 'text-teal-600 dark:text-teal-300' : ''} /> - - = 0 ? 'text-emerald-600 dark:text-emerald-300' : 'text-destructive'} - /> + {bankMode ? ( + <> + + + + = 0 ? 'text-emerald-600 dark:text-emerald-300' : 'text-destructive'} + /> + + ) : ( + <> + + 0 ? 'text-teal-600 dark:text-teal-300' : ''} /> + + = 0 ? 'text-emerald-600 dark:text-emerald-300' : 'text-destructive'} + /> + + )}
-
-
- 1st available - {fmt(starting.first_amount)} + {!bankMode && ( +
+
+ 1st available + {fmt(starting.first_amount)} +
+
+ 15th available + {fmt(starting.fifteenth_amount)} +
+
+ Monthly income + {fmt(summaryData?.income?.amount)} +
-
- 15th available - {fmt(starting.fifteenth_amount)} -
-
- Monthly income - {fmt(summaryData?.income?.amount)} -
-
+ )} + + {bankMode && bt.last_updated && ( +

+ Balance last updated: {new Date(bt.last_updated).toLocaleString()} +

+ )} ); diff --git a/client/pages/TrackerPage.jsx b/client/pages/TrackerPage.jsx index 51b71b0..12536a3 100644 --- a/client/pages/TrackerPage.jsx +++ b/client/pages/TrackerPage.jsx @@ -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() { setEditStartingOpen(true)} + hint={ + bankTracking?.enabled + ? `${bankTracking.account_name} · live balance` + : !summary.has_starting_amounts ? 'Set monthly starting cash' : '' + } + onEdit={bankTracking?.enabled ? undefined : () => setEditStartingOpen(true)} /> diff --git a/routes/dataSources.js b/routes/dataSources.js index d638c31..71752b0 100644 --- a/routes/dataSources.js +++ b/routes/dataSources.js @@ -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) => { diff --git a/routes/summary.js b/routes/summary.js index edb8489..2e5ef34 100644 --- a/routes/summary.js +++ b/routes/summary.js @@ -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, diff --git a/services/trackerService.js b/services/trackerService.js index 58ba82d..125b33b 100644 --- a/services/trackerService.js +++ b/services/trackerService.js @@ -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, }; } diff --git a/services/userSettings.js b/services/userSettings.js index af0d6a3..ff4421e 100644 --- a/services/userSettings.js +++ b/services/userSettings.js @@ -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() {