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