From 262d7789db89ebf0e5814ca9ba68b56198b10b95 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 29 May 2026 01:06:20 -0500 Subject: [PATCH] feat: account monitoring, expanded sync UI, match filtering, error toasts Backend: - v0.64 migration: monitored column on financial_accounts - GET/PUT data-sources accounts endpoints for monitored toggle + tx listing - matchSuggestionService: excludes unmonitored accounts from match scoring Frontend: - BankSyncSection rebuild: accounts panel with monitored switch, expand for last 50 transactions, match status badges, optimistic toggle - TransactionMatchingSection: toast on bills load failure - DataPage: toast on import history load failure - ProfilePage: toast on both login history fetch failures --- client/api.js | 2 + client/components/data/BankSyncSection.jsx | 371 ++++++++++++++---- .../data/TransactionMatchingSection.jsx | 3 +- client/pages/DataPage.jsx | 4 +- client/pages/ProfilePage.jsx | 10 +- db/database.js | 27 ++ package.json | 2 +- routes/dataSources.js | 76 ++++ services/matchSuggestionService.js | 1 + 9 files changed, 417 insertions(+), 79 deletions(-) diff --git a/client/api.js b/client/api.js index d92a427..c5c11fb 100644 --- a/client/api.js +++ b/client/api.js @@ -314,6 +314,8 @@ export const api = { connectSimplefin: (setupToken) => post('/data-sources/simplefin/connect', { setupToken }), syncDataSource: (id) => post(`/data-sources/${id}/sync`), deleteDataSource: (id) => del(`/data-sources/${id}`), + dataSourceAccounts: (sourceId) => get(`/data-sources/${sourceId}/accounts`), + setAccountMonitored: (sourceId, accountId, monitored) => put(`/data-sources/${sourceId}/accounts/${accountId}`, { monitored }), // 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 17f242e..5b0deda 100644 --- a/client/components/data/BankSyncSection.jsx +++ b/client/components/data/BankSyncSection.jsx @@ -1,10 +1,14 @@ import React, { useState, useEffect, useCallback } from 'react'; import { toast } from 'sonner'; -import { AlertTriangle, Building2, Eye, EyeOff, ExternalLink, Link2Off, Loader2, RefreshCw } from 'lucide-react'; +import { + AlertTriangle, Building2, ChevronDown, ChevronRight, + Eye, EyeOff, ExternalLink, Link2Off, Loader2, RefreshCw, +} from 'lucide-react'; import { api } from '@/api'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, @@ -46,15 +50,167 @@ function TokenInput({ value, onChange, disabled }) { ); } +function fmtDate(iso) { + if (!iso) return '—'; + return new Date(iso).toLocaleString(undefined, { + month: 'short', day: 'numeric', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }); +} + +function fmtShortDate(date) { + if (!date) return '—'; + const d = new Date(`${date}T00:00:00`); + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); +} + +function fmtDollars(cents) { + if (cents == null) return '—'; + const abs = Math.abs(cents) / 100; + const sign = cents < 0 ? '-' : ''; + return `${sign}$${abs.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +} + +function MatchBadge({ status }) { + if (status === 'matched') { + return matched; + } + if (status === 'ignored') { + return ignored; + } + return unmatched; +} + +function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonitored, toggling }) { + const txDate = account.transactions?.[0]?.posted_date || account.transactions?.[0]?.transacted_at?.slice(0, 10); + + return ( +
+
+ e.stopPropagation()}> + {expanded + ? + : } + + + {/* Monitored toggle */} +
e.stopPropagation()}> + onToggleMonitored(account.id, v)} + disabled={toggling} + aria-label={`Monitor ${account.name}`} + /> +
+ + {/* Account name */} +
+

+ {account.name} +

+ {account.org_name && ( +

{account.org_name}

+ )} +
+ + {/* Balance */} +
+

+ {fmtDollars(account.balance)} +

+ {txDate && ( +

last tx {fmtShortDate(txDate)}

+ )} +
+ + {/* Tx count */} +
+

+ {account.transaction_count} tx +

+ {!account.monitored && ( +

skipped

+ )} +
+
+ + {expanded && ( +
+ {account.transactions.length === 0 ? ( +

No transactions synced for this account.

+ ) : ( +
+ + + + + + + + + + + {account.transactions.map(tx => ( + + + + + + + ))} + +
DatePayee / DescriptionAmountStatus
+ {fmtShortDate(tx.posted_date || tx.transacted_at?.slice(0, 10))} + +

{tx.payee || tx.description || '—'}

+ {tx.payee && tx.description && tx.payee !== tx.description && ( +

{tx.description}

+ )} +
+ {fmtDollars(tx.amount)} + + +
+
+ )} +
+ )} +
+ ); +} + export default function BankSyncSection({ onConnectionChange }) { const [enabled, setEnabled] = useState(null); const [connections, setConnections] = useState([]); + const [accountsBySource, setAccountsBySource] = useState({}); + const [accountsLoading, setAccountsLoading] = useState({}); + const [accountsErrorBySource, setAccountsError] = useState({}); const [loadError, setLoadError] = useState(''); const [setupToken, setSetupToken] = useState(''); const [connecting, setConnecting] = useState(false); const [syncing, setSyncing] = useState(null); const [disconnectTarget, setDisconnectTarget] = useState(null); const [disconnecting, setDisconnecting] = useState(false); + const [expandedAccount, setExpandedAccount] = useState(null); + const [togglingAccount, setTogglingAccount] = useState(null); + + const loadAccounts = useCallback(async (conns) => { + for (const conn of conns) { + setAccountsLoading(prev => ({ ...prev, [conn.id]: true })); + setAccountsError(prev => ({ ...prev, [conn.id]: '' })); + try { + const accounts = await api.dataSourceAccounts(conn.id); + setAccountsBySource(prev => ({ ...prev, [conn.id]: accounts })); + } catch (err) { + setAccountsError(prev => ({ ...prev, [conn.id]: err.message || 'Failed to load accounts' })); + } finally { + setAccountsLoading(prev => ({ ...prev, [conn.id]: false })); + } + } + }, []); const load = useCallback(async () => { setLoadError(''); @@ -67,11 +223,12 @@ export default function BankSyncSection({ onConnectionChange }) { const conns = Array.isArray(sources) ? sources.filter(s => s.provider === 'simplefin') : []; setConnections(conns); onConnectionChange?.(conns[0] || null); + if (conns.length > 0) loadAccounts(conns); } catch (err) { setEnabled(false); setLoadError(err.message || 'Failed to load bank sync status'); } - }, [onConnectionChange]); + }, [onConnectionChange, loadAccounts]); useEffect(() => { load(); }, [load]); @@ -120,10 +277,30 @@ export default function BankSyncSection({ onConnectionChange }) { } }; - function fmtDate(iso) { - if (!iso) return '—'; - return new Date(iso).toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' }); - } + const handleToggleMonitored = async (sourceId, accountId, monitored) => { + setTogglingAccount(accountId); + // Optimistic update + setAccountsBySource(prev => ({ + ...prev, + [sourceId]: (prev[sourceId] || []).map(a => + a.id === accountId ? { ...a, monitored } : a + ), + })); + try { + await api.setAccountMonitored(sourceId, accountId, monitored); + } catch (err) { + // Revert on failure + setAccountsBySource(prev => ({ + ...prev, + [sourceId]: (prev[sourceId] || []).map(a => + a.id === accountId ? { ...a, monitored: !monitored } : a + ), + })); + toast.error(err.message || 'Failed to update account'); + } finally { + setTogglingAccount(null); + } + }; function isStale(conn) { if (!conn.last_error) return false; @@ -155,82 +332,128 @@ export default function BankSyncSection({ onConnectionChange }) {
{loadError}
)} - {connections.length > 0 && connections.map(conn => ( -
- {isStale(conn) && ( -
- -
- Sync error - {conn.last_error && ( - — {conn.last_error} - )} - {conn.last_sync_at && ( - - Last successful sync: {fmtDate(conn.last_sync_at)} - - )} + {connections.length > 0 && connections.map(conn => { + const accounts = accountsBySource[conn.id] || []; + const accsLoading = accountsLoading[conn.id]; + const accsError = accountsErrorBySource[conn.id]; + const monitoredCount = accounts.filter(a => a.monitored).length; + + return ( +
+ {isStale(conn) && ( +
+ +
+ Sync error + {conn.last_error && ( + — {conn.last_error} + )} + {conn.last_sync_at && ( + + Last successful sync: {fmtDate(conn.last_sync_at)} + + )} +
+ +
+ )} + + {/* Header row */} +
+
+ +
+

{conn.name}

+

+ {conn.account_count} account{conn.account_count !== 1 ? 's' : ''} ·{' '} + {conn.transaction_count} transaction{conn.transaction_count !== 1 ? 's' : ''} + {accounts.length > 0 && ` · ${monitoredCount} monitored`} +

+
+
+
+ +
-
- )} -
-
- -
-

{conn.name}

-

- {conn.account_count} account{conn.account_count !== 1 ? 's' : ''} ·{' '} - {conn.transaction_count} transaction{conn.transaction_count !== 1 ? 's' : ''} + + {/* Sync status grid */} +

+
+

Last sync

+

{fmtDate(conn.last_sync_at)}

+
+
+

Status

+

+ {conn.last_error ? conn.last_error : (conn.status === 'active' ? 'Active' : conn.status)}

-
- - -
-
-
-
-

Last sync

-

{fmtDate(conn.last_sync_at)}

-
-
-

Status

-

- {conn.last_error ? conn.last_error : (conn.status === 'active' ? 'Active' : conn.status)} -

+ {/* Accounts section */} +
+
+

+ Accounts +

+

+ Toggle to include / exclude from bill matching +

+
+ + {accsLoading ? ( +
+ + Loading accounts… +
+ ) : accsError ? ( +

{accsError}

+ ) : accounts.length === 0 ? ( +

No accounts found.

+ ) : ( + accounts.map(account => ( + setExpandedAccount(prev => prev === account.id ? null : account.id)} + onToggleMonitored={(accountId, monitored) => handleToggleMonitored(conn.id, accountId, monitored)} + toggling={togglingAccount === account.id} + /> + )) + )}
-
- ))} + ); + })} {connections.length === 0 && (
diff --git a/client/components/data/TransactionMatchingSection.jsx b/client/components/data/TransactionMatchingSection.jsx index 00a96ed..465d6be 100644 --- a/client/components/data/TransactionMatchingSection.jsx +++ b/client/components/data/TransactionMatchingSection.jsx @@ -352,8 +352,9 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn } try { const data = await api.bills(); setBills(data || []); - } catch { + } catch (err) { setBills([]); + toast.error(err.message || 'Failed to load bills for matching.'); } finally { setBillsLoading(false); } diff --git a/client/pages/DataPage.jsx b/client/pages/DataPage.jsx index 33eee63..1ca8620 100644 --- a/client/pages/DataPage.jsx +++ b/client/pages/DataPage.jsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useCallback } from 'react'; +import { toast } from 'sonner'; import { api } from '@/api'; import BankSyncSection from '@/components/data/BankSyncSection'; import ImportTransactionCsvSection from '@/components/data/ImportTransactionCsvSection'; @@ -20,8 +21,9 @@ export default function DataPage() { try { const { history } = await api.importHistory(); setHistory(history); - } catch { + } catch (err) { setHistory([]); + toast.error(err.message || 'Failed to load import history.'); } finally { setHistoryLoading(false); } diff --git a/client/pages/ProfilePage.jsx b/client/pages/ProfilePage.jsx index c4e6193..a6d703f 100644 --- a/client/pages/ProfilePage.jsx +++ b/client/pages/ProfilePage.jsx @@ -108,7 +108,10 @@ function LoginHistoryModal({ history: providedHistory, open, onClose, onLoaded } setHistory(rows); onLoaded?.(rows); }) - .catch(() => setHistory([])) + .catch(err => { + setHistory([]); + toast.error(err.message || 'Failed to load login history.'); + }) .finally(() => setLoading(false)); }, [open, providedHistory, onLoaded]); @@ -241,7 +244,10 @@ function ProfileSummary({ profile, loading }) { setHistoryLoading(true); api.loginHistory() .then(d => setLoginHistory(d.history ?? [])) - .catch(() => setLoginHistory([])) + .catch(err => { + setLoginHistory([]); + toast.error(err.message || 'Failed to load login history.'); + }) .finally(() => setHistoryLoading(false)); }, [loading]); diff --git a/db/database.js b/db/database.js index e5b1927..e5c7d85 100644 --- a/db/database.js +++ b/db/database.js @@ -1085,6 +1085,21 @@ function reconcileLegacyMigrations() { db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_subscription ON bills(user_id, is_subscription, active)'); console.log('[migration] bills: subscription metadata columns added'); } + }, + { + version: 'v0.64', + description: 'financial_accounts: monitored flag for bill matching', + check: function() { + const cols = db.prepare('PRAGMA table_info(financial_accounts)').all().map(c => c.name); + return cols.includes('monitored'); + }, + run: function() { + const cols = db.prepare('PRAGMA table_info(financial_accounts)').all().map(c => c.name); + if (!cols.includes('monitored')) { + db.exec('ALTER TABLE financial_accounts ADD COLUMN monitored INTEGER NOT NULL DEFAULT 1'); + console.log('[migration] financial_accounts: monitored column added'); + } + } } ]; @@ -1848,6 +1863,18 @@ function runMigrations() { db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_subscription ON bills(user_id, is_subscription, active)'); console.log('[migration] bills: subscription metadata columns added'); } + }, + { + version: 'v0.64', + description: 'financial_accounts: monitored flag for bill matching', + dependsOn: ['v0.63'], + run: function() { + const cols = db.prepare('PRAGMA table_info(financial_accounts)').all().map(c => c.name); + if (!cols.includes('monitored')) { + db.exec('ALTER TABLE financial_accounts ADD COLUMN monitored INTEGER NOT NULL DEFAULT 1'); + console.log('[migration] financial_accounts: monitored column added'); + } + } } ]; diff --git a/package.json b/package.json index 41e7540..31bbc2c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bill-tracker", - "version": "0.32.0", + "version": "0.33.0", "description": "Monthly bill tracking system", "main": "server.js", "scripts": { diff --git a/routes/dataSources.js b/routes/dataSources.js index 3f4d73a..ec0cd1f 100644 --- a/routes/dataSources.js +++ b/routes/dataSources.js @@ -88,6 +88,82 @@ router.post('/simplefin/connect', async (req, res) => { } }); +// ─── GET /api/data-sources/:sourceId/accounts ──────────────────────────────── + +router.get('/:sourceId/accounts', (req, res) => { + const sourceId = parseInt(req.params.sourceId, 10); + if (!Number.isInteger(sourceId) || sourceId < 1) { + return res.status(400).json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'sourceId')); + } + + try { + const db = getDb(); + + const source = db.prepare('SELECT id FROM data_sources WHERE id = ? AND user_id = ?').get(sourceId, req.user.id); + if (!source) return res.status(404).json(standardizeError('Data source not found', 'NOT_FOUND')); + + const accounts = db.prepare(` + SELECT + fa.id, fa.provider_account_id, fa.name, fa.org_name, fa.account_type, + fa.balance, fa.available_balance, fa.currency, fa.monitored, + fa.created_at, fa.updated_at, + COUNT(t.id) AS transaction_count + FROM financial_accounts fa + LEFT JOIN transactions t ON t.account_id = fa.id AND t.user_id = fa.user_id + WHERE fa.data_source_id = ? AND fa.user_id = ? + GROUP BY fa.id + ORDER BY fa.name COLLATE NOCASE ASC + `).all(sourceId, req.user.id); + + const txStmt = db.prepare(` + SELECT id, posted_date, transacted_at, amount, currency, payee, description, memo, match_status, ignored + FROM transactions + WHERE account_id = ? AND user_id = ? + ORDER BY COALESCE(posted_date, substr(transacted_at, 1, 10), created_at) DESC, id DESC + LIMIT 50 + `); + + const result = accounts.map(acc => ({ + ...acc, + monitored: acc.monitored === 1, + transactions: txStmt.all(acc.id, req.user.id), + })); + + res.json(result); + } catch (err) { + res.status(500).json(standardizeError(err.message || 'Failed to load accounts', 'DB_ERROR')); + } +}); + +// ─── PUT /api/data-sources/:sourceId/accounts/:accountId ───────────────────── + +router.put('/:sourceId/accounts/:accountId', (req, res) => { + const sourceId = parseInt(req.params.sourceId, 10); + const accountId = parseInt(req.params.accountId, 10); + if (!Number.isInteger(sourceId) || sourceId < 1 || !Number.isInteger(accountId) || accountId < 1) { + return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR')); + } + if (typeof req.body?.monitored !== 'boolean') { + return res.status(400).json(standardizeError('monitored must be a boolean', 'VALIDATION_ERROR', 'monitored')); + } + + try { + const db = getDb(); + const result = db.prepare(` + UPDATE financial_accounts + SET monitored = ?, updated_at = datetime('now') + WHERE id = ? AND data_source_id = ? AND user_id = ? + `).run(req.body.monitored ? 1 : 0, accountId, sourceId, req.user.id); + + if (result.changes === 0) return res.status(404).json(standardizeError('Account not found', 'NOT_FOUND')); + + const account = db.prepare('SELECT id, name, monitored FROM financial_accounts WHERE id = ?').get(accountId); + res.json({ ...account, monitored: account.monitored === 1 }); + } catch (err) { + res.status(500).json(standardizeError(err.message || 'Failed to update account', 'DB_ERROR')); + } +}); + // ─── POST /api/data-sources/:id/sync ───────────────────────────────────────── router.post('/:id/sync', async (req, res) => { diff --git a/services/matchSuggestionService.js b/services/matchSuggestionService.js index e80fa67..c2e513e 100644 --- a/services/matchSuggestionService.js +++ b/services/matchSuggestionService.js @@ -174,6 +174,7 @@ function loadCandidateTransactions(db, userId, transactionId = null) { 't.user_id = ?', 't.ignored = 0', "t.match_status = 'unmatched'", + '(t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)', ]; if (transactionId) { where.push('t.id = ?');