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,
} 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 { SectionCard } from './dataShared';
function TokenInput({ value, onChange, disabled }) {
const [show, setShow] = useState(false);
const tail = value.slice(-4);
return (
{value && (
setShow(v => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
>
{show ? : }
)}
{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 => (
setSelectedId(bill.id)}
className={cn(
'w-full flex items-center justify-between px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted/40',
selectedId === bill.id && 'bg-primary/10 text-primary',
)}
>
{bill.name}
${(bill.expected_amount ?? 0).toFixed(2)}/mo
))}
Cancel
onConfirm(selectedId)} disabled={!selectedId || busy}>
{busy ? <> Matching…> : 'Confirm match'}
);
}
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.
) : (
Date
Payee / Description
Amount
Bill
{account.transactions.map(tx => (
{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' ? (
onUnmatch(tx)}
className="text-muted-foreground hover:text-destructive disabled:opacity-40 transition-colors"
>
{matchingTxId === tx.id
?
: }
) : tx.match_status === 'ignored' ? (
) : (
onMatch(tx)}
className="text-[10px] font-medium text-primary/70 hover:text-primary bg-primary/5 hover:bg-primary/10 px-1.5 py-0.5 rounded-full transition-colors disabled:opacity-40"
>
{matchingTxId === tx.id ? : '+ match'}
)}
))}
)}
)}
);
}
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 [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 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]);
// 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(() => {});
}
}, [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).`);
}
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 90 days.`);
}
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)}
)}
handleSync(conn.id)}
disabled={syncing === conn.id}
className="shrink-0 text-xs font-medium underline-offset-2 hover:underline disabled:opacity-50"
>
{syncing === conn.id ? 'Syncing…' : 'Retry'}
)}
{/* 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`}
handleSync(conn.id)}
disabled={syncing === conn.id || backfilling === conn.id}
className="h-8 text-xs gap-1.5"
>
{syncing === conn.id
? <> Syncing…>
: <> Sync Now>}
handleBackfill(conn.id)}
disabled={syncing === conn.id || backfilling === conn.id}
className="h-8 text-xs gap-1.5 text-muted-foreground"
title="Pull up to 90 days of transaction history"
>
{backfilling === conn.id
? <> Backfilling…>
: <> 90d Backfill>}
setDisconnectTarget(conn)}
className="h-8 text-xs gap-1.5 text-muted-foreground hover:text-destructive"
>
Disconnect
{/* 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}
/>
{connecting ? <> Connecting…> : 'Connect'}
)}
{connections.length > 0 && (
setSetupToken(e.target.value)}
disabled={connecting}
/>
{connecting ? <> Connecting…> : 'Connect'}
)}
>
)}
{ 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}
/>
>
);
}