import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { toast } from 'sonner'; import { AlertTriangle, Building2, ChevronDown, ChevronRight, Eye, EyeOff, ExternalLink, History, Landmark, Link2Off, Loader2, RefreshCw, Unlink, } 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, } from '@/components/ui/alert-dialog'; 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'; import AutoMatchReview from './AutoMatchReview'; function TokenInput({ value, onChange, disabled }) { const [show, setShow] = useState(false); const tail = value.slice(-4); return (
{value && ( )}
{value && !show && (

···{tail}

)}
); } 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, billName }) { if (status === 'matched') { return ( {billName || 'matched'} ); } if (status === 'ignored') { return ignored; } return unmatched; } function BillPickerDialog({ open, onClose, transaction, bills, onConfirm, busy }) { const [search, setSearch] = useState(''); const [selectedId, setSelectedId] = useState(null); useEffect(() => { if (open) { setSearch(''); setSelectedId(null); } }, [open]); const filtered = useMemo(() => { const q = search.toLowerCase(); return (bills || []).filter(b => !q || b.name.toLowerCase().includes(q)); }, [bills, search]); const txDate = transaction?.posted_date || transaction?.transacted_at?.slice(0, 10); const txLabel = transaction?.payee || transaction?.description || '—'; const txAmt = transaction ? fmtDollars(transaction.amount) : ''; return ( { if (!v) onClose(); }}> Match to bill

{txLabel} {txDate && {fmtShortDate(txDate)}} {txAmt}

A payment record will be created for the selected bill using this transaction's amount and date.

setSearch(e.target.value)} className="text-sm" />
{filtered.length === 0 ? (

No bills found.

) : filtered.map(bill => ( ))}
); } function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonitored, toggling, bills, onMatch, onUnmatch, matchingTxId }) { const txDate = account.transactions?.[0]?.posted_date || account.transactions?.[0]?.transacted_at?.slice(0, 10); return (
e.stopPropagation()}> {account.monitored && (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.monitored && (
{account.transactions.length === 0 ? (

No transactions synced for this account.

) : (
{account.transactions.map(tx => ( ))}
Date Payee / Description Amount Bill
{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)} {tx.match_status === 'matched' ? (
) : tx.match_status === 'ignored' ? ( ) : ( )}
)}
)}
); } export default function BankSyncSection({ onConnectionChange, cardProps = {} }) { const [enabled, setEnabled] = useState(null); const [syncDays, setSyncDays] = useState(30); const [seedDays, setSeedDays] = useState(44); 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 [backfilling, setBackfilling] = useState(null); const [disconnectTarget, setDisconnectTarget] = useState(null); const [disconnecting, setDisconnecting] = useState(false); const [expandedAccount, setExpandedAccount] = useState(null); const [togglingAccount, setTogglingAccount] = useState(null); const [bills, setBills] = useState([]); const [matchTarget, setMatchTarget] = useState(null); // { sourceId, tx } const [matchingTxId, setMatchingTxId] = useState(null); const [autoMatchRefreshKey, setAutoMatchRefreshKey] = useState(0); // Bank tracking state const [btEnabled, setBtEnabled] = useState(false); const [btAccountId, setBtAccountId] = useState(''); const [btPendingDays, setBtPendingDays] = useState(3); const [btLateGraceDays, setBtLateGraceDays] = useState(0); 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 })); 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 })); } } }, []); // Load bank tracking settings and available accounts const loadBankTracking = useCallback(async () => { try { const [settings, accounts] = await Promise.all([ api.settings(), api.allFinancialAccounts().catch(() => []), ]); setBtEnabled(settings.bank_tracking_enabled === 'true'); setBtAccountId(settings.bank_tracking_account_id || ''); setBtPendingDays(parseInt(settings.bank_tracking_pending_days, 10) || 3); setBtLateGraceDays(parseInt(settings.bank_late_attribution_days, 10) || 0); 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), bank_late_attribution_days: String(patch.lateGraceDays ?? btLateGraceDays), }; 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); if (patch.lateGraceDays !== undefined) setBtLateGraceDays(patch.lateGraceDays); 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 { const [status, sources] = await Promise.all([ api.simplefinStatus(), api.dataSources({ type: 'provider_sync' }), ]); setEnabled(status.enabled); setSyncDays(status.sync_days ?? 30); setSeedDays(status.seed_days ?? 44); 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, loadAccounts]); useEffect(() => { load(); }, [load]); useEffect(() => { loadBankTracking(); }, [loadBankTracking]); // Load bills once when connections become available (for the match picker) useEffect(() => { if (connections.length > 0 && bills.length === 0) { api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(err => console.error('[BankSyncSection] failed to load bills', err)); } }, [connections, bills.length]); function updateTxInState(sourceId, txId, updates) { setAccountsBySource(prev => ({ ...prev, [sourceId]: (prev[sourceId] || []).map(acc => ({ ...acc, transactions: acc.transactions.map(tx => tx.id === txId ? { ...tx, ...updates } : tx), })), })); } const handleMatch = (sourceId, tx) => setMatchTarget({ sourceId, tx }); const handleConfirmMatch = async (billId) => { if (!matchTarget || !billId) return; const { sourceId, tx } = matchTarget; setMatchingTxId(tx.id); try { const { transaction } = await api.confirmTransactionMatch(tx.id, billId); updateTxInState(sourceId, tx.id, { match_status: transaction.match_status, matched_bill_id: transaction.matched_bill_id, matched_bill_name: transaction.matched_bill_name, }); setMatchTarget(null); toast.success(`Matched to "${transaction.matched_bill_name}" — payment recorded for ${fmtShortDate(tx.posted_date || tx.transacted_at?.slice(0, 10))}.`); } catch (err) { toast.error(err.message || 'Failed to match transaction.'); } finally { setMatchingTxId(null); } }; const handleUnmatch = async (sourceId, tx) => { setMatchingTxId(tx.id); try { await api.unmatchTransaction(tx.id); updateTxInState(sourceId, tx.id, { match_status: 'unmatched', matched_bill_id: null, matched_bill_name: null }); toast.success('Match removed.'); } catch (err) { toast.error(err.message || 'Failed to remove match.'); } finally { setMatchingTxId(null); } }; const handleConnect = async () => { const token = setupToken.trim(); if (!token) { toast.error('Paste your SimpleFIN setup token first.'); return; } setConnecting(true); try { const result = await api.connectSimplefin(token); toast.success(`Connected — ${result.accountsUpserted} account(s), ${result.transactionsNew} new transaction(s).`); setSetupToken(''); await load(); } catch (err) { toast.error(err.message || 'Failed to connect SimpleFIN'); } finally { setConnecting(false); } }; const handleSync = async (id) => { setSyncing(id); try { const result = await api.syncDataSource(id); if (result.errlist) { toast.warning(`Synced ${result.transactionsNew} new transaction(s), but some connections need attention: ${result.errlist}`); } else { toast.success(`Synced — ${result.transactionsNew} new transaction(s).`); } setAutoMatchRefreshKey(k => k + 1); await load(); } catch (err) { toast.error(err.message || 'Sync failed'); await load(); } finally { setSyncing(null); } }; const handleBackfill = async (id) => { setBackfilling(id); try { const result = await api.backfillDataSource(id); if (result.errlist) { toast.warning(`Backfill complete — ${result.transactionsNew} new transaction(s). Some connections need attention: ${result.errlist}`); } else { toast.success(`Backfill complete — ${result.transactionsNew} new transaction(s) pulled from the last ${seedDays} days.`); } setAutoMatchRefreshKey(k => k + 1); await load(); } catch (err) { toast.error(err.message || 'Backfill failed'); await load(); } finally { setBackfilling(null); } }; const handleDisconnect = async () => { if (!disconnectTarget) return; setDisconnecting(true); try { await api.deleteDataSource(disconnectTarget.id); toast.success('SimpleFIN disconnected.'); setDisconnectTarget(null); await load(); } catch (err) { toast.error(err.message || 'Failed to disconnect'); } finally { setDisconnecting(false); } }; 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 connWarning(conn) { if (!conn.last_error) return null; if (conn.status === 'error') return { kind: 'error', label: 'Sync error' }; // Partial errlist: sync succeeded but some bank connections need attention return { kind: 'partial', label: 'Some connections need attention' }; } if (enabled === null) { return (
Loading…
); } return ( <> {!enabled ? (
Bank sync is not enabled on this server. Ask your administrator to enable it in the Admin panel.
) : ( <> {loadError && (
{loadError}
)} {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; const warning = connWarning(conn); return (
{warning && (
{warning.label} — {conn.last_error} {warning.kind === 'partial' && ( Re-authenticate the affected institutions in your SimpleFIN Bridge account to restore full sync. )} {warning.kind === '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`}

{/* Sync status grid */}

Last sync

{fmtDate(conn.last_sync_at)}

Status

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

History window

{syncDays} days

{/* 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} bills={bills} onMatch={tx => handleMatch(conn.id, tx)} onUnmatch={tx => handleUnmatch(conn.id, tx)} matchingTxId={matchingTxId} /> )) )}
); })} {connections.length === 0 && (

Connect a SimpleFIN Bridge account

Paste your SimpleFIN setup token below. BillTracker only stores an encrypted access URL — no bank credentials are saved.

Need a token?{' '} Open SimpleFIN Bridge

setSetupToken(e.target.value)} disabled={connecting} />
)} {connections.length > 0 && (

Add another connection

Get a SimpleFIN token
setSetupToken(e.target.value)} disabled={connecting} />
)} )}
{/* ── Auto-match review panel ── */} {enabled && connections.length > 0 && ( )} {/* ── 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.

{/* Late payment grace window */}

If a payment posts in the first N days of a new month but the bill was due in the prior month, automatically count it for the prior month — no prompt needed.

{btLateGraceDays > 0 && (

Any payment posting on the 1st–{btLateGraceDays}{btLateGraceDays === 1 ? 'st' : btLateGraceDays === 2 ? 'nd' : btLateGraceDays === 3 ? 'rd' : 'th'} will automatically count for the prior month if the bill was due then.

)}
{/* 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); }}> Disconnect SimpleFIN? This removes the connection and deletes synced accounts. Previously synced transactions are kept but will no longer be associated with a data source. Cancel {disconnecting ? 'Disconnecting…' : 'Disconnect'} setMatchTarget(null)} transaction={matchTarget?.tx} bills={bills} onConfirm={handleConfirmMatch} busy={!!matchingTxId} /> ); }