diff --git a/.env.example b/.env.example
index a43ea32..16db6c0 100644
--- a/.env.example
+++ b/.env.example
@@ -37,6 +37,19 @@ NODE_ENV=production
# DB_PATH=/opt/bill-tracker/data/db/bills.db
# BACKUP_PATH=/opt/bill-tracker/data/backups
+# ── Bank Sync (SimpleFIN) ─────────────────────────────────────────────────────
+# Optional. Disabled by default. Requires a SimpleFIN Bridge account.
+# Users connect their own SimpleFIN Bridge — BillTracker never stores bank credentials.
+#
+# BANK_SYNC_ENABLED=false
+#
+# Required when BANK_SYNC_ENABLED=true. Must be at least 32 characters.
+# Used to encrypt the SimpleFIN Access URL at rest.
+# TOKEN_ENCRYPTION_KEY=replace-with-a-long-random-secret-at-least-32-chars
+#
+# How many days back to fetch transactions on first sync (default: 90).
+# SIMPLEFIN_SYNC_DAYS=90
+
# ── First-run admin account ────────────────────────────────────────────────────
# Set BOTH on first start to create the admin account automatically.
# Remove or comment out after the server has started once — they are not
diff --git a/client/api.js b/client/api.js
index ee062e1..ba84dd3 100644
--- a/client/api.js
+++ b/client/api.js
@@ -302,6 +302,13 @@ export const api = {
matchSuggestions: (params = {}) => get(`/matches/suggestions${queryString(params)}`),
rejectMatchSuggestion: (id) => post(`/matches/${encodeURIComponent(id)}/reject`),
+ // Data sources & SimpleFIN bank sync
+ dataSources: (params = {}) => get(`/data-sources${queryString(params)}`),
+ simplefinStatus: () => get('/data-sources/simplefin/status'),
+ connectSimplefin: (setupToken) => post('/data-sources/simplefin/connect', { setupToken }),
+ syncDataSource: (id) => post(`/data-sources/${id}/sync`),
+ deleteDataSource: (id) => del(`/data-sources/${id}`),
+
// User SQLite import
previewUserDbImport: async (file) => {
const res = await fetch('/api/import/user-db/preview', {
diff --git a/client/pages/SettingsPage.jsx b/client/pages/SettingsPage.jsx
index a87b17f..b335c54 100644
--- a/client/pages/SettingsPage.jsx
+++ b/client/pages/SettingsPage.jsx
@@ -1,11 +1,15 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
-import { Sun, Moon, Users, AlertCircle, RefreshCw } from 'lucide-react';
+import { AlertCircle, Building2, Link2Off, Loader2, Moon, RefreshCw, Sun, Users } 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 {
+ AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
+ AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
@@ -208,6 +212,239 @@ function SettingsSkeleton() {
);
}
+// ─── Bank Sync Section ────────────────────────────────────────────────────────
+
+function BankSyncSection() {
+ const [enabled, setEnabled] = useState(null); // null = loading
+ const [connections, setConnections] = useState([]);
+ const [loadError, setLoadError] = useState('');
+ const [setupToken, setSetupToken] = useState('');
+ const [connecting, setConnecting] = useState(false);
+ const [syncing, setSyncing] = useState(null); // id being synced
+ const [disconnectTarget, setDisconnectTarget] = useState(null);
+ const [disconnecting, setDisconnecting] = useState(false);
+
+ const load = useCallback(async () => {
+ setLoadError('');
+ try {
+ const [status, sources] = await Promise.all([
+ api.simplefinStatus(),
+ api.dataSources({ type: 'provider_sync' }),
+ ]);
+ setEnabled(status.enabled);
+ setConnections(Array.isArray(sources) ? sources.filter(s => s.provider === 'simplefin') : []);
+ } catch (err) {
+ setEnabled(false);
+ setLoadError(err.message || 'Failed to load bank sync status');
+ }
+ }, []);
+
+ 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);
+ 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);
+ }
+ };
+
+ function fmtDate(iso) {
+ if (!iso) return '—';
+ return new Date(iso).toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' });
+ }
+
+ if (enabled === null) {
+ return (
+
+
+
+ Loading…
+
+
+ );
+ }
+
+ return (
+ <>
+
+ {!enabled ? (
+
+ Bank sync is not enabled on this server.
+ Set BANK_SYNC_ENABLED=true and
+ a TOKEN_ENCRYPTION_KEY in your
+ environment to enable SimpleFIN.
+
+ ) : (
+ <>
+ {loadError && (
+ {loadError}
+ )}
+
+ {/* Connected accounts */}
+ {connections.length > 0 && connections.map(conn => (
+
+
+
+
+
+
{conn.name}
+
+ {conn.account_count} account{conn.account_count !== 1 ? 's' : ''} ·{' '}
+ {conn.transaction_count} transaction{conn.transaction_count !== 1 ? 's' : ''}
+
+
+
+
+
+
+
+
+
+
+
+
Last sync
+
{fmtDate(conn.last_sync_at)}
+
+
+
Status
+
+ {conn.last_error ? conn.last_error : (conn.status === 'active' ? 'Active' : conn.status)}
+
+
+
+
+ ))}
+
+ {/* Connect form */}
+ {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.
+
+
+ setSetupToken(e.target.value)}
+ placeholder="Paste SimpleFIN setup token…"
+ className="flex-1 font-mono text-xs"
+ />
+
+
+
+ )}
+
+ {/* Add another connection — only show if already connected */}
+ {connections.length > 0 && (
+
+
Add another connection
+
+ setSetupToken(e.target.value)}
+ placeholder="Paste SimpleFIN setup token…"
+ className="flex-1 font-mono text-xs"
+ />
+
+
+
+ )}
+ >
+ )}
+
+
+ { 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'}
+
+
+
+
+ >
+ );
+}
+
// ─── SettingsPage ─────────────────────────────────────────────────────────────
export default function SettingsPage() {
@@ -339,6 +576,9 @@ export default function SettingsPage() {
+ {/* Bank Sync */}
+
+
{/* Save button — right-aligned below all cards */}