import React, { useState, useEffect, useCallback } from 'react'; import { toast } from 'sonner'; 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, } from '@/components/ui/alert-dialog'; import { SectionCard } from './dataShared'; 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 }) { 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 => ( ))}
Date Payee / Description Amount Status
{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 [syncDays, setSyncDays] = useState(90); 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(''); try { const [status, sources] = await Promise.all([ api.simplefinStatus(), api.dataSources({ type: 'provider_sync' }), ]); setEnabled(status.enabled); setSyncDays(status.sync_days ?? 90); 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]); 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).`); } await load(); } catch (err) { toast.error(err.message || 'Sync failed'); await load(); } finally { setSyncing(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} /> )) )}
); })} {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} />
)} )}
{ 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'} ); }