2026-05-29 03:02:36 -05:00
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
2026-05-28 22:06:15 -05:00
|
|
|
|
import { toast } from 'sonner';
|
2026-05-29 01:06:20 -05:00
|
|
|
|
import {
|
|
|
|
|
|
AlertTriangle, Building2, ChevronDown, ChevronRight,
|
2026-06-03 21:09:26 -05:00
|
|
|
|
Eye, EyeOff, ExternalLink, History, Landmark, Link2Off, Loader2, RefreshCw, Unlink,
|
2026-05-29 01:06:20 -05:00
|
|
|
|
} from 'lucide-react';
|
2026-05-28 22:06:15 -05:00
|
|
|
|
import { api } from '@/api';
|
|
|
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
|
import { Input } from '@/components/ui/input';
|
2026-05-29 01:06:20 -05:00
|
|
|
|
import { Switch } from '@/components/ui/switch';
|
2026-05-28 22:06:15 -05:00
|
|
|
|
import {
|
|
|
|
|
|
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
|
|
|
|
|
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
|
|
|
|
|
} from '@/components/ui/alert-dialog';
|
2026-05-29 03:02:36 -05:00
|
|
|
|
import {
|
|
|
|
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
|
|
|
|
|
} from '@/components/ui/dialog';
|
2026-06-03 21:09:26 -05:00
|
|
|
|
import { Label } from '@/components/ui/label';
|
|
|
|
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
2026-05-28 22:06:15 -05:00
|
|
|
|
import { SectionCard } from './dataShared';
|
2026-06-06 17:34:09 -05:00
|
|
|
|
import AutoMatchReview from './AutoMatchReview';
|
2026-05-28 22:06:15 -05:00
|
|
|
|
|
2026-05-29 00:04:28 -05:00
|
|
|
|
function TokenInput({ value, onChange, disabled }) {
|
|
|
|
|
|
const [show, setShow] = useState(false);
|
|
|
|
|
|
const tail = value.slice(-4);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex-1 space-y-1">
|
|
|
|
|
|
<div className="relative">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={value}
|
|
|
|
|
|
onChange={onChange}
|
|
|
|
|
|
type={show ? 'text' : 'password'}
|
|
|
|
|
|
placeholder="Paste SimpleFIN setup token…"
|
|
|
|
|
|
className="font-mono text-xs pr-8"
|
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
|
autoComplete="off"
|
|
|
|
|
|
/>
|
|
|
|
|
|
{value && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
tabIndex={-1}
|
|
|
|
|
|
onClick={() => setShow(v => !v)}
|
|
|
|
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
|
|
|
|
|
>
|
|
|
|
|
|
{show ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{value && !show && (
|
|
|
|
|
|
<p className="text-[11px] text-muted-foreground font-mono pl-0.5 select-none">
|
|
|
|
|
|
···{tail}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 19:41:17 -05:00
|
|
|
|
function parseUtc(str) {
|
|
|
|
|
|
if (!str) return null;
|
|
|
|
|
|
const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z';
|
|
|
|
|
|
return new Date(normalized);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-29 01:06:20 -05:00
|
|
|
|
function fmtDate(iso) {
|
|
|
|
|
|
if (!iso) return '—';
|
2026-06-07 19:41:17 -05:00
|
|
|
|
return parseUtc(iso).toLocaleString(undefined, {
|
2026-05-29 01:06:20 -05:00
|
|
|
|
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 })}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-29 03:02:36 -05:00
|
|
|
|
function MatchBadge({ status, billName }) {
|
2026-05-29 01:06:20 -05:00
|
|
|
|
if (status === 'matched') {
|
2026-05-29 03:02:36 -05:00
|
|
|
|
return (
|
|
|
|
|
|
<span className="text-[10px] font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 px-1.5 py-0.5 rounded-full max-w-[120px] truncate inline-block" title={billName || 'matched'}>
|
|
|
|
|
|
{billName || 'matched'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
);
|
2026-05-29 01:06:20 -05:00
|
|
|
|
}
|
|
|
|
|
|
if (status === 'ignored') {
|
|
|
|
|
|
return <span className="text-[10px] font-medium text-amber-600 dark:text-amber-400 bg-amber-500/10 px-1.5 py-0.5 rounded-full">ignored</span>;
|
|
|
|
|
|
}
|
|
|
|
|
|
return <span className="text-[10px] font-medium text-muted-foreground bg-muted/60 px-1.5 py-0.5 rounded-full">unmatched</span>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-29 03:02:36 -05:00
|
|
|
|
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 (
|
|
|
|
|
|
<Dialog open={open} onOpenChange={v => { if (!v) onClose(); }}>
|
|
|
|
|
|
<DialogContent className="max-w-md">
|
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
|
<DialogTitle>Match to bill</DialogTitle>
|
|
|
|
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
|
|
|
|
<span className="font-medium text-foreground">{txLabel}</span>
|
|
|
|
|
|
{txDate && <span className="ml-2 text-xs">{fmtShortDate(txDate)}</span>}
|
|
|
|
|
|
<span className="ml-2 font-medium text-destructive/80">{txAmt}</span>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
|
A payment record will be created for the selected bill using this transaction's amount and date.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
|
|
<Input
|
|
|
|
|
|
autoFocus
|
|
|
|
|
|
placeholder="Search bills…"
|
|
|
|
|
|
value={search}
|
|
|
|
|
|
onChange={e => setSearch(e.target.value)}
|
|
|
|
|
|
className="text-sm"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="max-h-56 overflow-y-auto rounded-lg border border-border/60 divide-y divide-border/40">
|
|
|
|
|
|
{filtered.length === 0 ? (
|
|
|
|
|
|
<p className="px-3 py-4 text-sm text-muted-foreground text-center">No bills found.</p>
|
|
|
|
|
|
) : filtered.map(bill => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={bill.id}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => 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',
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="truncate font-medium">{bill.name}</span>
|
|
|
|
|
|
<span className="shrink-0 ml-3 text-xs text-muted-foreground tabular-nums">
|
|
|
|
|
|
${(bill.expected_amount ?? 0).toFixed(2)}/mo
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
|
<Button variant="ghost" onClick={onClose} disabled={busy}>Cancel</Button>
|
|
|
|
|
|
<Button onClick={() => onConfirm(selectedId)} disabled={!selectedId || busy}>
|
|
|
|
|
|
{busy ? <><Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />Matching…</> : 'Confirm match'}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonitored, toggling, bills, onMatch, onUnmatch, matchingTxId }) {
|
2026-05-29 01:06:20 -05:00
|
|
|
|
const txDate = account.transactions?.[0]?.posted_date || account.transactions?.[0]?.transacted_at?.slice(0, 10);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="border-t border-border/40 first:border-t-0">
|
|
|
|
|
|
<div
|
2026-05-29 03:38:48 -05:00
|
|
|
|
className={cn('flex items-center gap-3 px-4 py-2.5', account.monitored && 'hover:bg-muted/20 cursor-pointer')}
|
|
|
|
|
|
onClick={account.monitored ? onToggleExpand : undefined}
|
2026-05-29 01:06:20 -05:00
|
|
|
|
>
|
2026-05-29 03:38:48 -05:00
|
|
|
|
<span className="text-muted-foreground shrink-0 w-3.5" onClick={e => e.stopPropagation()}>
|
|
|
|
|
|
{account.monitored && (expanded
|
2026-05-29 01:06:20 -05:00
|
|
|
|
? <ChevronDown className="h-3.5 w-3.5" />
|
2026-05-29 03:38:48 -05:00
|
|
|
|
: <ChevronRight className="h-3.5 w-3.5" />)}
|
2026-05-29 01:06:20 -05:00
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Monitored toggle */}
|
|
|
|
|
|
<div onClick={e => e.stopPropagation()}>
|
|
|
|
|
|
<Switch
|
|
|
|
|
|
checked={account.monitored}
|
|
|
|
|
|
onCheckedChange={v => onToggleMonitored(account.id, v)}
|
|
|
|
|
|
disabled={toggling}
|
|
|
|
|
|
aria-label={`Monitor ${account.name}`}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Account name */}
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
|
<p className={cn('text-sm font-medium truncate', !account.monitored && 'text-muted-foreground')}>
|
|
|
|
|
|
{account.name}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{account.org_name && (
|
|
|
|
|
|
<p className="text-xs text-muted-foreground truncate">{account.org_name}</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Balance */}
|
|
|
|
|
|
<div className="text-right shrink-0 hidden sm:block">
|
|
|
|
|
|
<p className={cn('text-xs font-medium tabular-nums', !account.monitored && 'text-muted-foreground')}>
|
|
|
|
|
|
{fmtDollars(account.balance)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{txDate && (
|
|
|
|
|
|
<p className="text-[10px] text-muted-foreground">last tx {fmtShortDate(txDate)}</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Tx count */}
|
|
|
|
|
|
<div className="shrink-0 text-right">
|
|
|
|
|
|
<p className="text-xs text-muted-foreground tabular-nums">
|
|
|
|
|
|
{account.transaction_count} tx
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{!account.monitored && (
|
|
|
|
|
|
<p className="text-[10px] text-muted-foreground/60">skipped</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-29 03:38:48 -05:00
|
|
|
|
{expanded && account.monitored && (
|
2026-05-29 01:06:20 -05:00
|
|
|
|
<div className="border-t border-border/30 bg-muted/10">
|
|
|
|
|
|
{account.transactions.length === 0 ? (
|
|
|
|
|
|
<p className="px-6 py-3 text-xs text-muted-foreground italic">No transactions synced for this account.</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="overflow-x-auto">
|
|
|
|
|
|
<table className="w-full text-xs">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr className="border-b border-border/30">
|
|
|
|
|
|
<th className="px-4 py-2 text-left font-medium text-muted-foreground whitespace-nowrap">Date</th>
|
|
|
|
|
|
<th className="px-4 py-2 text-left font-medium text-muted-foreground">Payee / Description</th>
|
|
|
|
|
|
<th className="px-4 py-2 text-right font-medium text-muted-foreground whitespace-nowrap">Amount</th>
|
2026-05-29 03:02:36 -05:00
|
|
|
|
<th className="px-4 py-2 text-right font-medium text-muted-foreground whitespace-nowrap">Bill</th>
|
2026-05-29 01:06:20 -05:00
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{account.transactions.map(tx => (
|
|
|
|
|
|
<tr key={tx.id} className="border-b border-border/20 last:border-b-0 hover:bg-muted/20">
|
|
|
|
|
|
<td className="px-4 py-1.5 text-muted-foreground whitespace-nowrap">
|
|
|
|
|
|
{fmtShortDate(tx.posted_date || tx.transacted_at?.slice(0, 10))}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td className="px-4 py-1.5 max-w-xs">
|
|
|
|
|
|
<p className="truncate font-medium">{tx.payee || tx.description || '—'}</p>
|
|
|
|
|
|
{tx.payee && tx.description && tx.payee !== tx.description && (
|
|
|
|
|
|
<p className="truncate text-muted-foreground">{tx.description}</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td className={cn('px-4 py-1.5 text-right tabular-nums whitespace-nowrap font-medium', tx.amount < 0 ? 'text-destructive/80' : 'text-emerald-600 dark:text-emerald-400')}>
|
|
|
|
|
|
{fmtDollars(tx.amount)}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td className="px-4 py-1.5 text-right whitespace-nowrap">
|
2026-05-29 03:02:36 -05:00
|
|
|
|
{tx.match_status === 'matched' ? (
|
|
|
|
|
|
<div className="flex items-center justify-end gap-1.5">
|
|
|
|
|
|
<MatchBadge status="matched" billName={tx.matched_bill_name} />
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
title="Remove match"
|
|
|
|
|
|
disabled={matchingTxId === tx.id}
|
|
|
|
|
|
onClick={() => onUnmatch(tx)}
|
|
|
|
|
|
className="text-muted-foreground hover:text-destructive disabled:opacity-40 transition-colors"
|
|
|
|
|
|
>
|
|
|
|
|
|
{matchingTxId === tx.id
|
|
|
|
|
|
? <Loader2 className="h-3 w-3 animate-spin" />
|
|
|
|
|
|
: <Unlink className="h-3 w-3" />}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : tx.match_status === 'ignored' ? (
|
|
|
|
|
|
<MatchBadge status="ignored" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
disabled={matchingTxId === tx.id}
|
|
|
|
|
|
onClick={() => 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 ? <Loader2 className="h-3 w-3 animate-spin inline" /> : '+ match'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2026-05-29 01:06:20 -05:00
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-30 21:52:02 -05:00
|
|
|
|
export default function BankSyncSection({ onConnectionChange, cardProps = {} }) {
|
2026-05-28 22:06:15 -05:00
|
|
|
|
const [enabled, setEnabled] = useState(null);
|
2026-06-06 15:51:56 -05:00
|
|
|
|
const [syncDays, setSyncDays] = useState(30);
|
|
|
|
|
|
const [seedDays, setSeedDays] = useState(44);
|
2026-06-07 02:03:00 -05:00
|
|
|
|
const [serverTz, setServerTz] = useState(null);
|
2026-05-28 22:06:15 -05:00
|
|
|
|
const [connections, setConnections] = useState([]);
|
2026-05-29 01:06:20 -05:00
|
|
|
|
const [accountsBySource, setAccountsBySource] = useState({});
|
|
|
|
|
|
const [accountsLoading, setAccountsLoading] = useState({});
|
|
|
|
|
|
const [accountsErrorBySource, setAccountsError] = useState({});
|
2026-05-28 22:06:15 -05:00
|
|
|
|
const [loadError, setLoadError] = useState('');
|
|
|
|
|
|
const [setupToken, setSetupToken] = useState('');
|
|
|
|
|
|
const [connecting, setConnecting] = useState(false);
|
|
|
|
|
|
const [syncing, setSyncing] = useState(null);
|
2026-05-29 19:58:52 -05:00
|
|
|
|
const [backfilling, setBackfilling] = useState(null);
|
2026-05-28 22:06:15 -05:00
|
|
|
|
const [disconnectTarget, setDisconnectTarget] = useState(null);
|
|
|
|
|
|
const [disconnecting, setDisconnecting] = useState(false);
|
2026-05-29 01:06:20 -05:00
|
|
|
|
const [expandedAccount, setExpandedAccount] = useState(null);
|
|
|
|
|
|
const [togglingAccount, setTogglingAccount] = useState(null);
|
2026-05-29 03:02:36 -05:00
|
|
|
|
const [bills, setBills] = useState([]);
|
|
|
|
|
|
const [matchTarget, setMatchTarget] = useState(null); // { sourceId, tx }
|
|
|
|
|
|
const [matchingTxId, setMatchingTxId] = useState(null);
|
2026-06-06 17:34:09 -05:00
|
|
|
|
const [autoMatchRefreshKey, setAutoMatchRefreshKey] = useState(0);
|
2026-05-29 01:06:20 -05:00
|
|
|
|
|
2026-06-03 21:09:26 -05:00
|
|
|
|
// Bank tracking state
|
|
|
|
|
|
const [btEnabled, setBtEnabled] = useState(false);
|
|
|
|
|
|
const [btAccountId, setBtAccountId] = useState('');
|
2026-06-04 02:57:09 -05:00
|
|
|
|
const [btPendingDays, setBtPendingDays] = useState(3);
|
|
|
|
|
|
const [btLateGraceDays, setBtLateGraceDays] = useState(0);
|
2026-06-03 21:09:26 -05:00
|
|
|
|
const [btAccounts, setBtAccounts] = useState([]);
|
|
|
|
|
|
const [btSaving, setBtSaving] = useState(false);
|
|
|
|
|
|
|
2026-05-29 01:06:20 -05:00
|
|
|
|
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 }));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
2026-05-28 22:06:15 -05:00
|
|
|
|
|
2026-06-03 21:09:26 -05:00
|
|
|
|
// Load bank tracking settings and available accounts
|
|
|
|
|
|
const loadBankTracking = useCallback(async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [settings, accounts] = await Promise.all([
|
2026-06-04 01:12:25 -05:00
|
|
|
|
api.settings(),
|
2026-06-03 21:09:26 -05:00
|
|
|
|
api.allFinancialAccounts().catch(() => []),
|
|
|
|
|
|
]);
|
|
|
|
|
|
setBtEnabled(settings.bank_tracking_enabled === 'true');
|
|
|
|
|
|
setBtAccountId(settings.bank_tracking_account_id || '');
|
|
|
|
|
|
setBtPendingDays(parseInt(settings.bank_tracking_pending_days, 10) || 3);
|
2026-06-04 02:57:09 -05:00
|
|
|
|
setBtLateGraceDays(parseInt(settings.bank_late_attribution_days, 10) || 0);
|
2026-06-03 21:09:26 -05:00
|
|
|
|
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),
|
2026-06-04 02:57:09 -05:00
|
|
|
|
bank_tracking_pending_days: String(patch.pendingDays ?? btPendingDays),
|
|
|
|
|
|
bank_late_attribution_days: String(patch.lateGraceDays ?? btLateGraceDays),
|
2026-06-03 21:09:26 -05:00
|
|
|
|
};
|
|
|
|
|
|
await api.saveSettings(next);
|
|
|
|
|
|
if (patch.enabled !== undefined) setBtEnabled(patch.enabled);
|
|
|
|
|
|
if (patch.accountId !== undefined) setBtAccountId(patch.accountId);
|
2026-06-04 02:57:09 -05:00
|
|
|
|
if (patch.pendingDays !== undefined) setBtPendingDays(patch.pendingDays);
|
|
|
|
|
|
if (patch.lateGraceDays !== undefined) setBtLateGraceDays(patch.lateGraceDays);
|
2026-06-03 21:09:26 -05:00
|
|
|
|
toast.success('Bank tracking settings saved');
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message || 'Failed to save bank tracking settings');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBtSaving(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [btEnabled, btAccountId, btPendingDays]);
|
|
|
|
|
|
|
2026-05-28 22:06:15 -05:00
|
|
|
|
const load = useCallback(async () => {
|
|
|
|
|
|
setLoadError('');
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [status, sources] = await Promise.all([
|
|
|
|
|
|
api.simplefinStatus(),
|
|
|
|
|
|
api.dataSources({ type: 'provider_sync' }),
|
|
|
|
|
|
]);
|
|
|
|
|
|
setEnabled(status.enabled);
|
2026-06-06 15:51:56 -05:00
|
|
|
|
setSyncDays(status.sync_days ?? 30);
|
|
|
|
|
|
setSeedDays(status.seed_days ?? 44);
|
2026-06-07 02:03:00 -05:00
|
|
|
|
setServerTz(status.timezone || null);
|
2026-05-28 22:06:15 -05:00
|
|
|
|
const conns = Array.isArray(sources) ? sources.filter(s => s.provider === 'simplefin') : [];
|
|
|
|
|
|
setConnections(conns);
|
|
|
|
|
|
onConnectionChange?.(conns[0] || null);
|
2026-05-29 01:06:20 -05:00
|
|
|
|
if (conns.length > 0) loadAccounts(conns);
|
2026-05-28 22:06:15 -05:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setEnabled(false);
|
|
|
|
|
|
setLoadError(err.message || 'Failed to load bank sync status');
|
|
|
|
|
|
}
|
2026-05-29 01:06:20 -05:00
|
|
|
|
}, [onConnectionChange, loadAccounts]);
|
2026-05-28 22:06:15 -05:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => { load(); }, [load]);
|
2026-06-03 21:09:26 -05:00
|
|
|
|
useEffect(() => { loadBankTracking(); }, [loadBankTracking]);
|
2026-05-28 22:06:15 -05:00
|
|
|
|
|
2026-05-29 03:02:36 -05:00
|
|
|
|
// Load bills once when connections become available (for the match picker)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (connections.length > 0 && bills.length === 0) {
|
2026-05-31 15:06:10 -05:00
|
|
|
|
api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(err => console.error('[BankSyncSection] failed to load bills', err));
|
2026-05-29 03:02:36 -05:00
|
|
|
|
}
|
|
|
|
|
|
}, [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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-28 22:06:15 -05:00
|
|
|
|
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);
|
2026-05-29 02:23:19 -05:00
|
|
|
|
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).`);
|
|
|
|
|
|
}
|
2026-06-06 17:34:09 -05:00
|
|
|
|
setAutoMatchRefreshKey(k => k + 1);
|
2026-05-28 22:06:15 -05:00
|
|
|
|
await load();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message || 'Sync failed');
|
|
|
|
|
|
await load();
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSyncing(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-29 19:58:52 -05:00
|
|
|
|
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 {
|
2026-06-06 15:51:56 -05:00
|
|
|
|
toast.success(`Backfill complete — ${result.transactionsNew} new transaction(s) pulled from the last ${seedDays} days.`);
|
2026-05-29 19:58:52 -05:00
|
|
|
|
}
|
2026-06-06 17:34:09 -05:00
|
|
|
|
setAutoMatchRefreshKey(k => k + 1);
|
2026-05-29 19:58:52 -05:00
|
|
|
|
await load();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message || 'Backfill failed');
|
|
|
|
|
|
await load();
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBackfilling(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-28 22:06:15 -05:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-29 01:06:20 -05:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-05-28 22:06:15 -05:00
|
|
|
|
|
2026-05-29 02:23:19 -05:00
|
|
|
|
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' };
|
feat: configurable sync interval, auto-match, encryption note, admin link, SimpleFIN hyperlink
#1 Sync interval in admin UI:
- bankSyncConfigService: reads simplefin_sync_interval_hours from settings
(DB-first, env fallback, default 4h), setSyncIntervalHours() with validation
- bankSyncWorker: live-updates interval from getBankSyncConfig() each tick
- routes/admin: PUT accepts enabled and sync_interval_hours independently
- BankSyncAdminCard: number input (0.5 step, 0.5-168 range), dirty-checks both
#3 Auto-match after background sync:
- matchSuggestionService: autoMatchForUser() auto-applies suggestions ≥80
score (exact amount + date ±1d + name signal), lazy-requires matchTransactionToBill
- bankSyncWorker: calls autoMatchForUser after each successful sync, own try/catch
#4 Encryption note in BankSyncAdminCard below worker status panel
Also: error handling, admin link in tracker sidebar, SimpleFIN bridge hyperlink
2026-05-29 00:28:50 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 22:06:15 -05:00
|
|
|
|
if (enabled === null) {
|
|
|
|
|
|
return (
|
2026-05-30 21:52:02 -05:00
|
|
|
|
<SectionCard title="Bank Sync" {...cardProps}>
|
2026-05-28 22:06:15 -05:00
|
|
|
|
<div className="px-6 py-6 flex items-center gap-2 text-sm text-muted-foreground">
|
|
|
|
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
|
|
|
|
Loading…
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
2026-05-30 21:52:02 -05:00
|
|
|
|
<SectionCard title="Bank Sync" subtitle="Connect your SimpleFIN Bridge to sync read-only bank transactions." {...cardProps}>
|
2026-05-28 22:06:15 -05:00
|
|
|
|
{!enabled ? (
|
|
|
|
|
|
<div className="px-6 py-5 text-sm text-muted-foreground">
|
|
|
|
|
|
Bank sync is not enabled on this server. Ask your administrator to enable it in the Admin panel.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{loadError && (
|
|
|
|
|
|
<div className="px-6 py-3 text-sm text-destructive">{loadError}</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-29 01:06:20 -05:00
|
|
|
|
{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;
|
|
|
|
|
|
|
2026-05-29 02:23:19 -05:00
|
|
|
|
const warning = connWarning(conn);
|
2026-05-29 01:06:20 -05:00
|
|
|
|
return (
|
|
|
|
|
|
<div key={conn.id} className="px-6 py-4 space-y-3">
|
2026-05-29 02:23:19 -05:00
|
|
|
|
{warning && (
|
2026-05-29 01:06:20 -05:00
|
|
|
|
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2.5 text-sm text-amber-700 dark:text-amber-400">
|
|
|
|
|
|
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
2026-05-29 02:23:19 -05:00
|
|
|
|
<span className="font-medium">{warning.label}</span>
|
|
|
|
|
|
<span className="ml-1 font-normal opacity-90">— {conn.last_error}</span>
|
|
|
|
|
|
{warning.kind === 'partial' && (
|
|
|
|
|
|
<span className="block text-xs mt-0.5 opacity-75">
|
|
|
|
|
|
Re-authenticate the affected institutions in your SimpleFIN Bridge account to restore full sync.
|
|
|
|
|
|
</span>
|
2026-05-29 01:06:20 -05:00
|
|
|
|
)}
|
2026-05-29 02:23:19 -05:00
|
|
|
|
{warning.kind === 'error' && conn.last_sync_at && (
|
2026-05-29 01:06:20 -05:00
|
|
|
|
<span className="block text-xs mt-0.5 opacity-75">
|
|
|
|
|
|
Last successful sync: {fmtDate(conn.last_sync_at)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => 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'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Header row */}
|
|
|
|
|
|
<div className="flex items-start justify-between gap-3">
|
|
|
|
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
|
|
|
|
<Building2 className="h-5 w-5 shrink-0 text-muted-foreground" />
|
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
|
<p className="text-sm font-medium truncate">{conn.name}</p>
|
|
|
|
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
|
|
|
|
{conn.account_count} account{conn.account_count !== 1 ? 's' : ''} ·{' '}
|
|
|
|
|
|
{conn.transaction_count} transaction{conn.transaction_count !== 1 ? 's' : ''}
|
|
|
|
|
|
{accounts.length > 0 && ` · ${monitoredCount} monitored`}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm" variant="outline"
|
|
|
|
|
|
onClick={() => handleSync(conn.id)}
|
2026-05-29 19:58:52 -05:00
|
|
|
|
disabled={syncing === conn.id || backfilling === conn.id}
|
2026-05-29 01:06:20 -05:00
|
|
|
|
className="h-8 text-xs gap-1.5"
|
|
|
|
|
|
>
|
|
|
|
|
|
{syncing === conn.id
|
|
|
|
|
|
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Syncing…</>
|
|
|
|
|
|
: <><RefreshCw className="h-3.5 w-3.5" />Sync Now</>}
|
|
|
|
|
|
</Button>
|
2026-05-29 19:58:52 -05:00
|
|
|
|
<Button
|
|
|
|
|
|
size="sm" variant="outline"
|
|
|
|
|
|
onClick={() => handleBackfill(conn.id)}
|
|
|
|
|
|
disabled={syncing === conn.id || backfilling === conn.id}
|
|
|
|
|
|
className="h-8 text-xs gap-1.5 text-muted-foreground"
|
2026-06-06 15:51:56 -05:00
|
|
|
|
title={`Pull up to ${seedDays} days of transaction history`}
|
2026-05-29 19:58:52 -05:00
|
|
|
|
>
|
|
|
|
|
|
{backfilling === conn.id
|
|
|
|
|
|
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Backfilling…</>
|
2026-06-06 15:51:56 -05:00
|
|
|
|
: <><History className="h-3.5 w-3.5" />{seedDays}d Backfill</>}
|
2026-05-29 19:58:52 -05:00
|
|
|
|
</Button>
|
2026-05-29 01:06:20 -05:00
|
|
|
|
<Button
|
|
|
|
|
|
size="sm" variant="ghost"
|
|
|
|
|
|
onClick={() => setDisconnectTarget(conn)}
|
|
|
|
|
|
className="h-8 text-xs gap-1.5 text-muted-foreground hover:text-destructive"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Link2Off className="h-3.5 w-3.5" />
|
|
|
|
|
|
Disconnect
|
|
|
|
|
|
</Button>
|
feat: configurable sync interval, auto-match, encryption note, admin link, SimpleFIN hyperlink
#1 Sync interval in admin UI:
- bankSyncConfigService: reads simplefin_sync_interval_hours from settings
(DB-first, env fallback, default 4h), setSyncIntervalHours() with validation
- bankSyncWorker: live-updates interval from getBankSyncConfig() each tick
- routes/admin: PUT accepts enabled and sync_interval_hours independently
- BankSyncAdminCard: number input (0.5 step, 0.5-168 range), dirty-checks both
#3 Auto-match after background sync:
- matchSuggestionService: autoMatchForUser() auto-applies suggestions ≥80
score (exact amount + date ±1d + name signal), lazy-requires matchTransactionToBill
- bankSyncWorker: calls autoMatchForUser after each successful sync, own try/catch
#4 Encryption note in BankSyncAdminCard below worker status panel
Also: error handling, admin link in tracker sidebar, SimpleFIN bridge hyperlink
2026-05-29 00:28:50 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-29 01:06:20 -05:00
|
|
|
|
|
|
|
|
|
|
{/* Sync status grid */}
|
2026-06-07 02:03:00 -05:00
|
|
|
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 text-xs">
|
2026-05-29 01:06:20 -05:00
|
|
|
|
<div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
|
|
|
|
|
|
<p className="text-muted-foreground">Last sync</p>
|
|
|
|
|
|
<p className="font-medium mt-0.5">{fmtDate(conn.last_sync_at)}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className={cn(
|
|
|
|
|
|
'rounded-lg border px-3 py-2',
|
|
|
|
|
|
conn.last_error ? 'border-destructive/30 bg-destructive/5' : 'border-border/60 bg-muted/20',
|
|
|
|
|
|
)}>
|
|
|
|
|
|
<p className="text-muted-foreground">Status</p>
|
|
|
|
|
|
<p className={cn('font-medium mt-0.5', conn.last_error ? 'text-destructive' : '')}>
|
|
|
|
|
|
{conn.last_error ? conn.last_error : (conn.status === 'active' ? 'Active' : conn.status)}
|
2026-05-28 22:06:15 -05:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2026-05-29 02:23:19 -05:00
|
|
|
|
<div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
|
|
|
|
|
|
<p className="text-muted-foreground">History window</p>
|
|
|
|
|
|
<p className="font-medium mt-0.5">{syncDays} days</p>
|
|
|
|
|
|
</div>
|
2026-06-07 02:03:00 -05:00
|
|
|
|
<div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
|
|
|
|
|
|
<p className="text-muted-foreground">Server timezone</p>
|
|
|
|
|
|
<p className="font-medium mt-0.5 truncate" title={serverTz || ''}>{serverTz || '—'}</p>
|
|
|
|
|
|
</div>
|
2026-05-28 22:06:15 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-29 01:06:20 -05:00
|
|
|
|
{/* Accounts section */}
|
|
|
|
|
|
<div className="rounded-lg border border-border/60 overflow-hidden">
|
|
|
|
|
|
<div className="px-4 py-2 bg-muted/20 border-b border-border/40 flex items-center justify-between">
|
|
|
|
|
|
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
|
|
|
|
|
Accounts
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p className="text-[10px] text-muted-foreground">
|
|
|
|
|
|
Toggle to include / exclude from bill matching
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{accsLoading ? (
|
|
|
|
|
|
<div className="flex items-center gap-2 px-4 py-3 text-xs text-muted-foreground">
|
|
|
|
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
|
|
|
|
Loading accounts…
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : accsError ? (
|
|
|
|
|
|
<p className="px-4 py-3 text-xs text-destructive">{accsError}</p>
|
|
|
|
|
|
) : accounts.length === 0 ? (
|
|
|
|
|
|
<p className="px-4 py-3 text-xs text-muted-foreground italic">No accounts found.</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
accounts.map(account => (
|
|
|
|
|
|
<AccountRow
|
|
|
|
|
|
key={account.id}
|
|
|
|
|
|
account={account}
|
|
|
|
|
|
sourceId={conn.id}
|
|
|
|
|
|
expanded={expandedAccount === account.id}
|
|
|
|
|
|
onToggleExpand={() => setExpandedAccount(prev => prev === account.id ? null : account.id)}
|
|
|
|
|
|
onToggleMonitored={(accountId, monitored) => handleToggleMonitored(conn.id, accountId, monitored)}
|
|
|
|
|
|
toggling={togglingAccount === account.id}
|
2026-05-29 03:02:36 -05:00
|
|
|
|
bills={bills}
|
|
|
|
|
|
onMatch={tx => handleMatch(conn.id, tx)}
|
|
|
|
|
|
onUnmatch={tx => handleUnmatch(conn.id, tx)}
|
|
|
|
|
|
matchingTxId={matchingTxId}
|
2026-05-29 01:06:20 -05:00
|
|
|
|
/>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
2026-05-28 22:06:15 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-29 01:06:20 -05:00
|
|
|
|
);
|
|
|
|
|
|
})}
|
2026-05-28 22:06:15 -05:00
|
|
|
|
|
|
|
|
|
|
{connections.length === 0 && (
|
|
|
|
|
|
<div className="px-6 py-5 space-y-4">
|
|
|
|
|
|
<div className="rounded-lg border border-border/60 bg-muted/20 p-4 text-sm text-muted-foreground">
|
|
|
|
|
|
<p className="font-medium text-foreground mb-1">Connect a SimpleFIN Bridge account</p>
|
|
|
|
|
|
<p>Paste your SimpleFIN setup token below. BillTracker only stores an encrypted access URL — no bank credentials are saved.</p>
|
2026-05-29 00:04:28 -05:00
|
|
|
|
<p className="mt-2">
|
|
|
|
|
|
Need a token?{' '}
|
|
|
|
|
|
<a
|
|
|
|
|
|
href="https://beta-bridge.simplefin.org/"
|
|
|
|
|
|
target="_blank"
|
|
|
|
|
|
rel="noreferrer"
|
|
|
|
|
|
className="inline-flex items-center gap-1 font-medium text-primary underline-offset-4 hover:underline"
|
|
|
|
|
|
>
|
|
|
|
|
|
Open SimpleFIN Bridge
|
|
|
|
|
|
<ExternalLink className="h-3 w-3" />
|
|
|
|
|
|
</a>
|
|
|
|
|
|
</p>
|
2026-05-28 22:06:15 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex gap-2">
|
2026-05-29 00:04:28 -05:00
|
|
|
|
<TokenInput
|
2026-05-28 22:06:15 -05:00
|
|
|
|
value={setupToken}
|
|
|
|
|
|
onChange={e => setSetupToken(e.target.value)}
|
2026-05-29 00:04:28 -05:00
|
|
|
|
disabled={connecting}
|
2026-05-28 22:06:15 -05:00
|
|
|
|
/>
|
|
|
|
|
|
<Button onClick={handleConnect} disabled={connecting || !setupToken.trim()} className="shrink-0">
|
|
|
|
|
|
{connecting ? <><Loader2 className="h-4 w-4 animate-spin mr-1.5" />Connecting…</> : 'Connect'}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{connections.length > 0 && (
|
|
|
|
|
|
<div className="px-6 py-4 border-t border-border/50 space-y-2">
|
2026-05-29 00:04:28 -05:00
|
|
|
|
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
|
|
|
|
|
<p className="text-xs font-medium text-muted-foreground">Add another connection</p>
|
|
|
|
|
|
<a
|
|
|
|
|
|
href="https://beta-bridge.simplefin.org/"
|
|
|
|
|
|
target="_blank"
|
|
|
|
|
|
rel="noreferrer"
|
|
|
|
|
|
className="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-4 hover:underline"
|
|
|
|
|
|
>
|
|
|
|
|
|
Get a SimpleFIN token
|
|
|
|
|
|
<ExternalLink className="h-3 w-3" />
|
|
|
|
|
|
</a>
|
|
|
|
|
|
</div>
|
2026-05-28 22:06:15 -05:00
|
|
|
|
<div className="flex gap-2">
|
2026-05-29 00:04:28 -05:00
|
|
|
|
<TokenInput
|
2026-05-28 22:06:15 -05:00
|
|
|
|
value={setupToken}
|
|
|
|
|
|
onChange={e => setSetupToken(e.target.value)}
|
2026-05-29 00:04:28 -05:00
|
|
|
|
disabled={connecting}
|
2026-05-28 22:06:15 -05:00
|
|
|
|
/>
|
|
|
|
|
|
<Button size="sm" onClick={handleConnect} disabled={connecting || !setupToken.trim()} className="shrink-0">
|
|
|
|
|
|
{connecting ? <><Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />Connecting…</> : 'Connect'}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
2026-06-06 17:34:09 -05:00
|
|
|
|
{/* ── Auto-match review panel ── */}
|
|
|
|
|
|
{enabled && connections.length > 0 && (
|
|
|
|
|
|
<AutoMatchReview refreshKey={autoMatchRefreshKey} />
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-06-03 21:09:26 -05:00
|
|
|
|
{/* ── Bank Budget Tracking ── */}
|
|
|
|
|
|
{enabled && connections.length > 0 && (
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
title="Bank Budget Tracking"
|
|
|
|
|
|
subtitle="Use your live bank balance as the starting point for your monthly budget instead of manually-entered amounts."
|
|
|
|
|
|
{...cardProps}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="px-6 py-5 space-y-5">
|
|
|
|
|
|
|
|
|
|
|
|
{/* Toggle */}
|
|
|
|
|
|
<div className="flex items-center justify-between gap-4">
|
|
|
|
|
|
<div className="space-y-0.5">
|
|
|
|
|
|
<Label htmlFor="bt-toggle" className="text-sm font-medium">
|
|
|
|
|
|
Use bank balance for budget
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
|
Replaces manual starting amounts. Remaining = bank balance − pending payments − unpaid bills.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Switch
|
|
|
|
|
|
id="bt-toggle"
|
|
|
|
|
|
checked={btEnabled}
|
|
|
|
|
|
disabled={btSaving}
|
|
|
|
|
|
onCheckedChange={v => handleBtSave({ enabled: v })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{btEnabled && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{/* Account picker */}
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
|
Tracking account
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
{btAccounts.length === 0 ? (
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">No bank accounts found. Sync your SimpleFIN connection first.</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={btAccountId}
|
|
|
|
|
|
onValueChange={v => handleBtSave({ accountId: v })}
|
|
|
|
|
|
disabled={btSaving}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger className="h-9 text-sm">
|
|
|
|
|
|
<SelectValue placeholder="Select a checking account…" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
{btAccounts.map(acc => (
|
|
|
|
|
|
<SelectItem key={acc.id} value={String(acc.id)}>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<Landmark className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
|
|
|
|
<span>{acc.org_name ? `${acc.org_name} — ` : ''}{acc.name}</span>
|
|
|
|
|
|
{acc.balance_dollars !== null && (
|
|
|
|
|
|
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
|
|
|
|
|
${acc.balance_dollars.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Pending window */}
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
|
Pending payment window
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
|
Payments you mark as paid within this many days are shown as <em>pending</em> —
|
|
|
|
|
|
the money may not have cleared your bank yet, so they're subtracted from your effective balance.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={String(btPendingDays)}
|
|
|
|
|
|
onValueChange={v => handleBtSave({ pendingDays: parseInt(v, 10) })}
|
|
|
|
|
|
disabled={btSaving}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger className="h-9 w-40 text-sm">
|
|
|
|
|
|
<SelectValue />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="0">No pending (instant)</SelectItem>
|
|
|
|
|
|
<SelectItem value="1">1 day</SelectItem>
|
|
|
|
|
|
<SelectItem value="2">2 days</SelectItem>
|
|
|
|
|
|
<SelectItem value="3">3 days (recommended)</SelectItem>
|
|
|
|
|
|
<SelectItem value="5">5 days</SelectItem>
|
|
|
|
|
|
<SelectItem value="7">7 days</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-06-04 02:57:09 -05:00
|
|
|
|
{/* Late payment grace window */}
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
|
Late payment grace window
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
|
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.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={String(btLateGraceDays)}
|
|
|
|
|
|
onValueChange={v => handleBtSave({ lateGraceDays: parseInt(v, 10) })}
|
|
|
|
|
|
disabled={btSaving}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger className="h-9 w-48 text-sm">
|
|
|
|
|
|
<SelectValue />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="0">Off — prompt me each time</SelectItem>
|
|
|
|
|
|
<SelectItem value="1">1 day</SelectItem>
|
|
|
|
|
|
<SelectItem value="2">2 days</SelectItem>
|
|
|
|
|
|
<SelectItem value="3">3 days (recommended)</SelectItem>
|
|
|
|
|
|
<SelectItem value="5">5 days</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
{btLateGraceDays > 0 && (
|
|
|
|
|
|
<p className="text-[11px] text-emerald-600 dark:text-emerald-400">
|
|
|
|
|
|
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.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-06-03 21:09:26 -05:00
|
|
|
|
{/* Info callout */}
|
|
|
|
|
|
<div className="rounded-lg border border-border/60 bg-muted/30 px-4 py-3 text-xs text-muted-foreground space-y-1">
|
|
|
|
|
|
<p><span className="font-semibold text-foreground">How it works:</span> 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.</p>
|
|
|
|
|
|
<p>Bills marked paid within the pending window show a <span className="font-semibold">Pending</span> badge in the tracker, since the bank may not have processed them yet.</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-28 22:06:15 -05:00
|
|
|
|
<AlertDialog open={!!disconnectTarget} onOpenChange={open => { if (!open) setDisconnectTarget(null); }}>
|
|
|
|
|
|
<AlertDialogContent>
|
|
|
|
|
|
<AlertDialogHeader>
|
|
|
|
|
|
<AlertDialogTitle>Disconnect SimpleFIN?</AlertDialogTitle>
|
|
|
|
|
|
<AlertDialogDescription>
|
|
|
|
|
|
This removes the connection and deletes synced accounts. Previously synced transactions
|
|
|
|
|
|
are kept but will no longer be associated with a data source.
|
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
|
<AlertDialogFooter>
|
|
|
|
|
|
<AlertDialogCancel disabled={disconnecting}>Cancel</AlertDialogCancel>
|
|
|
|
|
|
<AlertDialogAction onClick={handleDisconnect} disabled={disconnecting}
|
|
|
|
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
|
|
|
|
|
{disconnecting ? 'Disconnecting…' : 'Disconnect'}
|
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
|
</AlertDialog>
|
2026-05-29 03:02:36 -05:00
|
|
|
|
|
|
|
|
|
|
<BillPickerDialog
|
|
|
|
|
|
open={!!matchTarget}
|
|
|
|
|
|
onClose={() => setMatchTarget(null)}
|
|
|
|
|
|
transaction={matchTarget?.tx}
|
|
|
|
|
|
bills={bills}
|
|
|
|
|
|
onConfirm={handleConfirmMatch}
|
|
|
|
|
|
busy={!!matchingTxId}
|
|
|
|
|
|
/>
|
2026-05-28 22:06:15 -05:00
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|