import { 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, errMessage } from '@/lib/utils'; import { formatUSD, formatCentsUSD, type Cents, type Dollars } from '@/lib/money'; 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, type SectionCardProps } from './dataShared'; import AutoMatchReview from './AutoMatchReview'; import type { Bill } from '@/types'; interface BankTx { id: number | string; posted_date?: string | null; transacted_at?: string | null; payee?: string | null; description?: string | null; amount: Cents; match_status?: string | null; matched_bill_id?: number | null; matched_bill_name?: string | null; } interface BankAccount { id: number | string; name: string; org_name?: string | null; monitored?: boolean; balance: Cents; transaction_count?: number; transactions: BankTx[]; } interface Connection { id: number | string; name?: string; provider?: string; status?: string; last_error?: string | null; last_sync_at?: string | null; account_count?: number; transaction_count?: number; } interface FinAccount { id: number | string; name: string; org_name?: string | null; balance_dollars?: Dollars | null; } interface BtPatch { enabled?: boolean; accountId?: string; pendingDays?: number; lateGraceDays?: number; } function TokenInput({ value, onChange, disabled }: { value: string; onChange: (e: React.ChangeEvent) => void; disabled?: boolean; }) { const [show, setShow] = useState(false); const tail = value.slice(-4); return (
{value && ( )}
{value && !show && (

···{tail}

)}
); } function parseUtc(str: string | null | undefined): Date | null { if (!str) return null; const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z'; return new Date(normalized); } function fmtDate(iso: string | null | undefined): string { const d = parseUtc(iso); if (!d) return '—'; return d.toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit', }); } function fmtShortDate(date: string | null | undefined): string { if (!date) return '—'; const d = new Date(`${date}T00:00:00`); return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); } function fmtDollars(cents: Cents): string { return formatCentsUSD(cents, { dash: true }); } function MatchBadge({ status, billName }: { status: string; billName?: string | null }) { if (status === 'matched') { return ( {billName || 'matched'} ); } if (status === 'ignored') { return ignored; } return unmatched; } function BillPickerDialog({ open, onClose, transaction, bills, onConfirm, busy }: { open: boolean; onClose: () => void; transaction?: BankTx | null; bills: Bill[]; onConfirm: (billId: number | string | null) => void; busy: boolean; }) { 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 => ( ))}
); } interface AccountRowProps { account: BankAccount; expanded: boolean; onToggleExpand: () => void; onToggleMonitored: (accountId: number | string, monitored: boolean) => void; toggling: boolean; onMatch: (tx: BankTx) => void; onUnmatch: (tx: BankTx) => void; matchingTxId: number | string | null; } function AccountRow({ account, expanded, onToggleExpand, onToggleMonitored, toggling, onMatch, onUnmatch, matchingTxId }: AccountRowProps) { 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 = {} }: { onConnectionChange?: (conn: Connection | null) => void; cardProps?: Partial; }) { const [enabled, setEnabled] = useState(null); const [syncDays, setSyncDays] = useState(30); const [seedDays, setSeedDays] = useState(44); const [serverTz, setServerTz] = 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 [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<{ sourceId: number | string; tx: BankTx } | null>(null); 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); // Auto-categorization state const [acmEnabled, setAcmEnabled] = useState(true); const [acmSaving, setAcmSaving] = useState(false); const loadAccounts = useCallback(async (conns: Connection[]) => { for (const conn of conns) { setAccountsLoading(prev => ({ ...prev, [conn.id]: true })); setAccountsError(prev => ({ ...prev, [conn.id]: '' })); try { const accounts = await api.dataSourceAccounts(conn.id) as BankAccount[]; setAccountsBySource(prev => ({ ...prev, [conn.id]: accounts })); } catch (err) { setAccountsError(prev => ({ ...prev, [conn.id]: errMessage(err, '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() as Promise>, 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 as FinAccount[] : []); setAcmEnabled(settings.bank_auto_categorize_merchants !== 'false'); } catch { // non-fatal — bank tracking section just won't populate } }, []); const handleBtSave = useCallback(async (patch: BtPatch) => { 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(errMessage(err, 'Failed to save bank tracking settings')); } finally { setBtSaving(false); } }, [btEnabled, btAccountId, btPendingDays, btLateGraceDays]); const handleAcmSave = useCallback(async (next: boolean) => { setAcmSaving(true); try { await api.saveSettings({ bank_auto_categorize_merchants: String(next) }); setAcmEnabled(next); toast.success('Auto-categorization setting saved'); } catch (err) { toast.error(errMessage(err, 'Failed to save auto-categorization setting')); } finally { setAcmSaving(false); } }, []); const load = useCallback(async () => { setLoadError(''); try { const [status, sources] = await Promise.all([ api.simplefinStatus() as Promise<{ enabled?: boolean; sync_days?: number; seed_days?: number; timezone?: string | null }>, api.dataSources({ type: 'provider_sync' }), ]); setEnabled(!!status.enabled); setSyncDays(status.sync_days ?? 30); setSeedDays(status.seed_days ?? 44); setServerTz(status.timezone || null); const conns = Array.isArray(sources) ? (sources as Connection[]).filter(s => s.provider === 'simplefin') : []; setConnections(conns); onConnectionChange?.(conns[0] || null); if (conns.length > 0) loadAccounts(conns); } catch (err) { setEnabled(false); setLoadError(errMessage(err, '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: number | string, txId: number | string, updates: Partial) { setAccountsBySource(prev => ({ ...prev, [sourceId]: (prev[sourceId] || []).map(acc => ({ ...acc, transactions: acc.transactions.map(tx => tx.id === txId ? { ...tx, ...updates } : tx), })), })); } const handleMatch = (sourceId: number | string, tx: BankTx) => setMatchTarget({ sourceId, tx }); const handleConfirmMatch = async (billId: number | string | null) => { if (!matchTarget || !billId) return; const { sourceId, tx } = matchTarget; setMatchingTxId(tx.id); try { const { transaction } = await api.confirmTransactionMatch(tx.id, billId) as { transaction: BankTx }; 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(errMessage(err, 'Failed to match transaction.')); } finally { setMatchingTxId(null); } }; const handleUnmatch = async (sourceId: number | string, tx: BankTx) => { 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(errMessage(err, '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) as { accountsUpserted?: number; transactionsNew?: number }; toast.success(`Connected — ${result.accountsUpserted} account(s), ${result.transactionsNew} new transaction(s).`); setSetupToken(''); await load(); } catch (err) { toast.error(errMessage(err, 'Failed to connect SimpleFIN')); } finally { setConnecting(false); } }; const handleSync = async (id: number | string) => { setSyncing(id); try { const result = await api.syncDataSource(id) as { errlist?: string; transactionsNew?: number }; 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(errMessage(err, 'Sync failed')); await load(); } finally { setSyncing(null); } }; const handleBackfill = async (id: number | string) => { setBackfilling(id); try { const result = await api.backfillDataSource(id) as { errlist?: string; transactionsNew?: number }; 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(errMessage(err, '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(errMessage(err, 'Failed to disconnect')); } finally { setDisconnecting(false); } }; const handleToggleMonitored = async (sourceId: number | string, accountId: number | string, monitored: boolean) => { 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(errMessage(err, 'Failed to update account')); } finally { setTogglingAccount(null); } }; function connWarning(conn: Connection): { kind: string; label: string } | null { 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

Server timezone

{serverTz || '—'}

{/* 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} 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.

)}
)} {enabled && connections.length > 0 && (

When new transactions sync, recognized merchants and stores are automatically assigned a spending category.

)} { 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} /> ); }