From 542ab5e38267c419730d5bdbb940a92a3f663fc0 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 29 May 2026 00:28:50 -0500 Subject: [PATCH] feat: configurable sync interval, auto-match, encryption note, admin link, SimpleFIN hyperlink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 --- client/components/admin/BankSyncAdminCard.jsx | 69 +++++++++++++++---- client/components/admin/EmailNotifCard.jsx | 6 +- client/components/admin/LoginModeCard.jsx | 6 +- client/components/data/BankSyncSection.jsx | 32 ++++++++- client/components/layout/Sidebar.jsx | 1 + client/pages/AdminPage.jsx | 16 ++++- package.json | 2 +- routes/admin.js | 16 +++-- services/bankSyncConfigService.js | 23 ++++++- services/bankSyncWorker.js | 14 ++-- services/matchSuggestionService.js | 21 ++++++ 11 files changed, 170 insertions(+), 36 deletions(-) diff --git a/client/components/admin/BankSyncAdminCard.jsx b/client/components/admin/BankSyncAdminCard.jsx index fafd0be..38e62bb 100644 --- a/client/components/admin/BankSyncAdminCard.jsx +++ b/client/components/admin/BankSyncAdminCard.jsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import { toast } from 'sonner'; import { api } from '@/api'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; import { Toggle } from './adminShared'; @@ -24,28 +25,37 @@ function timeUntil(iso) { } export default function BankSyncAdminCard() { - const [config, setConfig] = useState(null); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [enabled, setEnabled] = useState(false); + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(''); + const [saving, setSaving] = useState(false); + const [enabled, setEnabled] = useState(false); + const [syncInterval, setSyncInterval] = useState(4); useEffect(() => { api.bankSyncConfig() .then(d => { setConfig(d); setEnabled(!!d.enabled); + setSyncInterval(d.sync_interval_hours ?? 4); }) - .catch(() => {}) + .catch(err => setLoadError(err.message || 'Failed to load bank sync config')) .finally(() => setLoading(false)); }, []); const handleSave = async () => { + const hours = parseFloat(syncInterval); + if (!Number.isFinite(hours) || hours < 0.5 || hours > 168) { + toast.error('Sync interval must be between 0.5 and 168 hours.'); + return; + } setSaving(true); try { - const result = await api.setBankSyncConfig({ enabled }); + const result = await api.setBankSyncConfig({ enabled, sync_interval_hours: hours }); setConfig(result); setEnabled(!!result.enabled); - toast.success(enabled ? 'Bank sync enabled.' : 'Bank sync disabled.'); + setSyncInterval(result.sync_interval_hours ?? 4); + toast.success('Bank sync settings saved.'); } catch (err) { toast.error(err.message || 'Failed to update bank sync setting.'); } finally { @@ -63,7 +73,17 @@ export default function BankSyncAdminCard() { ); } - const changed = enabled !== !!config?.enabled; + if (loadError) { + return ( + + + {loadError} + + + ); + } + + const changed = enabled !== !!config?.enabled || parseFloat(syncInterval) !== config?.sync_interval_hours; const worker = config?.worker; return ( @@ -93,6 +113,28 @@ export default function BankSyncAdminCard() { /> + {/* Sync interval */} +
+
+

Auto-sync interval

+

+ How often the background worker checks for new transactions. +

+
+
+ setSyncInterval(e.target.value)} + className="w-20 text-sm text-right" + /> + hrs +
+
+ {/* Auto-sync worker status */} {config?.enabled && worker && (
@@ -114,13 +156,16 @@ export default function BankSyncAdminCard() {

{worker.next_run_at ? `in ${timeUntil(worker.next_run_at)}` : '—'}

-

- Syncs every {worker.interval_hours}h. Set SIMPLEFIN_SYNC_INTERVAL_HOURS to adjust. -

)} -
+ {/* Encryption note */} +

+ SimpleFIN credentials are encrypted with a key stored in your database. + Regular database backups preserve all user connections. +

+ +
diff --git a/client/components/admin/EmailNotifCard.jsx b/client/components/admin/EmailNotifCard.jsx index 81c626e..45ea319 100644 --- a/client/components/admin/EmailNotifCard.jsx +++ b/client/components/admin/EmailNotifCard.jsx @@ -23,6 +23,7 @@ export default function EmailNotifCard() { const [cfg, setCfg] = useState(DEFAULTS); const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(''); const [saving, setSaving] = useState(false); const [showPw, setShowPw] = useState(false); const [testEmail, setTestEmail] = useState(''); @@ -31,7 +32,7 @@ export default function EmailNotifCard() { useEffect(() => { api.notifAdmin() .then(d => setCfg({ ...DEFAULTS, ...d })) - .catch(() => {}) + .catch(err => setLoadError(err.message || 'Failed to load email settings')) .finally(() => setLoading(false)); }, []); // eslint-disable-line react-hooks/exhaustive-deps @@ -62,7 +63,8 @@ export default function EmailNotifCard() { } }; - if (loading) return Loading…; + if (loading) return Loading…; + if (loadError) return {loadError}; return ( diff --git a/client/components/admin/LoginModeCard.jsx b/client/components/admin/LoginModeCard.jsx index 6392937..35a75df 100644 --- a/client/components/admin/LoginModeCard.jsx +++ b/client/components/admin/LoginModeCard.jsx @@ -16,6 +16,7 @@ import { export default function LoginModeCard({ users }) { const [modeData, setModeData] = useState(null); const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(''); const [saving, setSaving] = useState(false); const [selectedUser, setSelectedUser] = useState(''); @@ -25,7 +26,7 @@ export default function LoginModeCard({ users }) { useEffect(() => { api.authModeConfig() .then(d => { setModeData(d); setSelectedUser(d.default_user_id?.toString() || ''); }) - .catch(() => {}) + .catch(err => setLoadError(err.message || 'Failed to load login mode config')) .finally(() => setLoading(false)); }, []); @@ -57,7 +58,8 @@ export default function LoginModeCard({ users }) { doSetMode('single', pendingUserId); }; - if (loading) return Loading…; + if (loading) return Loading…; + if (loadError) return {loadError}; const isMulti = !modeData || modeData.auth_mode === 'multi'; const activeUser = users?.find(u => u.id === modeData?.default_user_id); diff --git a/client/components/data/BankSyncSection.jsx b/client/components/data/BankSyncSection.jsx index 7ecce95..17f242e 100644 --- a/client/components/data/BankSyncSection.jsx +++ b/client/components/data/BankSyncSection.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useCallback } from 'react'; import { toast } from 'sonner'; -import { Building2, Eye, EyeOff, ExternalLink, Link2Off, Loader2, RefreshCw } from 'lucide-react'; +import { AlertTriangle, Building2, Eye, EyeOff, ExternalLink, Link2Off, Loader2, RefreshCw } from 'lucide-react'; import { api } from '@/api'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; @@ -125,6 +125,12 @@ export default function BankSyncSection({ onConnectionChange }) { return new Date(iso).toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' }); } + function isStale(conn) { + if (!conn.last_error) return false; + if (!conn.last_sync_at) return true; + return Date.now() - new Date(conn.last_sync_at).getTime() > 24 * 60 * 60 * 1000; + } + if (enabled === null) { return ( @@ -151,6 +157,30 @@ export default function BankSyncSection({ onConnectionChange }) { {connections.length > 0 && connections.map(conn => (
+ {isStale(conn) && ( +
+ +
+ Sync error + {conn.last_error && ( + — {conn.last_error} + )} + {conn.last_sync_at && ( + + Last successful sync: {fmtDate(conn.last_sync_at)} + + )} +
+ +
+ )}
diff --git a/client/components/layout/Sidebar.jsx b/client/components/layout/Sidebar.jsx index e1edd77..30d8b15 100644 --- a/client/components/layout/Sidebar.jsx +++ b/client/components/layout/Sidebar.jsx @@ -26,6 +26,7 @@ const userNavItems = [ ]; const adminNavItems = [ + { to: '/', icon: LayoutGrid, label: 'Tracker', end: true }, { to: '/admin', icon: ShieldCheck, label: 'Admin Panel', end: true }, { to: '/admin/status', icon: Activity, label: 'System Status' }, { to: '/roadmap', icon: Map, label: 'Roadmap' }, diff --git a/client/pages/AdminPage.jsx b/client/pages/AdminPage.jsx index 9db6eb9..2e7dcc3 100644 --- a/client/pages/AdminPage.jsx +++ b/client/pages/AdminPage.jsx @@ -17,6 +17,7 @@ export default function AdminPage() { const [me, setMe] = useState(null); const [hasUsers, setHasUsers] = useState(null); + const [loadError, setLoadError] = useState(''); const [users, setUsers] = useState([]); const loadMe = useCallback(async () => { @@ -40,7 +41,10 @@ export default function AdminPage() { const d = await api.hasUsers(); setHasUsers(d.has_users); if (d.has_users) loadUsers(); - } catch {} + } catch (err) { + setLoadError(err.message || 'Failed to load admin page'); + setHasUsers(false); + } }, [loadUsers]); useEffect(() => { @@ -53,7 +57,7 @@ export default function AdminPage() { loadUsers(); }; - if (hasUsers === null) { + if (hasUsers === null && !loadError) { return (
Loading… @@ -61,6 +65,14 @@ export default function AdminPage() { ); } + if (loadError) { + return ( +
+ {loadError} +
+ ); + } + return (
diff --git a/package.json b/package.json index 64ef000..41e7540 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bill-tracker", - "version": "0.31.0", + "version": "0.32.0", "description": "Monthly bill tracking system", "main": "server.js", "scripts": { diff --git a/routes/admin.js b/routes/admin.js index 18267a3..be5cc71 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -1,7 +1,7 @@ const express = require('express'); const router = express.Router(); const { getDb, rollbackMigration } = require('../db/database'); -const { getBankSyncConfig, setBankSyncEnabled } = require('../services/bankSyncConfigService'); +const { getBankSyncConfig, setBankSyncEnabled, setSyncIntervalHours } = require('../services/bankSyncConfigService'); const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker'); const { hashPassword } = require('../services/authService'); const { logAudit } = require('../services/auditService'); @@ -407,12 +407,16 @@ router.get('/bank-sync-config', (req, res) => { // PUT /api/admin/bank-sync-config router.put('/bank-sync-config', (req, res) => { - const enabled = req.body?.enabled; - if (typeof enabled !== 'boolean') { - return res.status(400).json({ error: 'enabled must be a boolean' }); - } + const { enabled, sync_interval_hours } = req.body || {}; try { - res.json(setBankSyncEnabled(enabled)); + let config = getBankSyncConfig(); + if (typeof enabled === 'boolean') { + config = setBankSyncEnabled(enabled); + } + if (sync_interval_hours !== undefined) { + config = setSyncIntervalHours(sync_interval_hours); + } + res.json(config); } catch (err) { res.status(err.status || 500).json({ error: err.message || 'Failed to update bank sync config' }); } diff --git a/services/bankSyncConfigService.js b/services/bankSyncConfigService.js index b4e4eb4..f96b632 100644 --- a/services/bankSyncConfigService.js +++ b/services/bankSyncConfigService.js @@ -2,7 +2,8 @@ const { getSetting, setSetting } = require('../db/database'); -const SYNC_DAYS_DEFAULT = 90; +const SYNC_DAYS_DEFAULT = 90; +const SYNC_INTERVAL_DEFAULT = 4; // hours function getBankSyncConfig() { const dbValue = getSetting('bank_sync_enabled'); @@ -25,9 +26,18 @@ function getBankSyncConfig() { ? syncDaysEnv : SYNC_DAYS_DEFAULT; + const intervalDb = parseFloat(getSetting('simplefin_sync_interval_hours') || ''); + const intervalEnv = parseFloat(process.env.SIMPLEFIN_SYNC_INTERVAL_HOURS || ''); + const syncIntervalHours = Number.isFinite(intervalDb) && intervalDb >= 0.5 + ? intervalDb + : Number.isFinite(intervalEnv) && intervalEnv >= 0.5 + ? intervalEnv + : SYNC_INTERVAL_DEFAULT; + return { enabled, sync_days: syncDays, + sync_interval_hours: syncIntervalHours, }; } @@ -36,4 +46,13 @@ function setBankSyncEnabled(enabled) { return getBankSyncConfig(); } -module.exports = { getBankSyncConfig, setBankSyncEnabled }; +function setSyncIntervalHours(hours) { + const n = parseFloat(hours); + if (!Number.isFinite(n) || n < 0.5 || n > 168) { + throw Object.assign(new Error('sync_interval_hours must be between 0.5 and 168'), { status: 400 }); + } + setSetting('simplefin_sync_interval_hours', String(Math.round(n * 10) / 10)); + return getBankSyncConfig(); +} + +module.exports = { getBankSyncConfig, setBankSyncEnabled, setSyncIntervalHours }; diff --git a/services/bankSyncWorker.js b/services/bankSyncWorker.js index e530245..9d73e92 100644 --- a/services/bankSyncWorker.js +++ b/services/bankSyncWorker.js @@ -3,8 +3,8 @@ const { getDb } = require('../db/database'); const { getBankSyncConfig } = require('./bankSyncConfigService'); const { syncDataSource } = require('./bankSyncService'); +const { autoMatchForUser } = require('./matchSuggestionService'); -const DEFAULT_INTERVAL_HOURS = 4; // Skip a source if it was synced less than this long ago (catches recent manual syncs) const MIN_SYNC_AGE_MS = 60 * 60 * 1000; // 1 hour // Pause between each source to avoid hammering SimpleFIN @@ -16,10 +16,8 @@ let lastRunAt = null; let nextRunAt = null; function intervalMs() { - const hours = parseFloat(process.env.SIMPLEFIN_SYNC_INTERVAL_HOURS); - return Number.isFinite(hours) && hours >= 0.5 - ? Math.round(hours * 3600000) - : DEFAULT_INTERVAL_HOURS * 3600000; + const { sync_interval_hours } = getBankSyncConfig(); + return Math.round(sync_interval_hours * 3600000); } function needsSync(source) { @@ -71,6 +69,7 @@ async function runCycle() { try { await syncDataSource(db, source.user_id, source.id); synced++; + try { autoMatchForUser(source.user_id); } catch { /* non-fatal */ } } catch { // syncDataSource already writes last_error to the data_sources row failed++; @@ -107,8 +106,7 @@ function scheduleNext() { function start() { if (timer) return; scheduleNext(); - const hours = intervalMs() / 3600000; - console.log(`[bankSync] Auto-sync worker started (interval: ${hours}h)`); + console.log(`[bankSync] Auto-sync worker started (interval: ${getBankSyncConfig().sync_interval_hours}h)`); } function stop() { @@ -119,7 +117,7 @@ function stop() { function getStatus() { return { running, - interval_hours: intervalMs() / 3600000, + interval_hours: getBankSyncConfig().sync_interval_hours, last_run_at: lastRunAt, next_run_at: nextRunAt, }; diff --git a/services/matchSuggestionService.js b/services/matchSuggestionService.js index d2c056d..e80fa67 100644 --- a/services/matchSuggestionService.js +++ b/services/matchSuggestionService.js @@ -326,7 +326,28 @@ function rejectMatchSuggestion(userId, id) { }; } +// Auto-apply high-confidence suggestions (score >= 80) for a user. +// Called by the background sync worker after each successful source sync. +// Score of 80+ requires at minimum exact amount + date proximity + name signal, +// so false-positive risk is low. +function autoMatchForUser(userId) { + const { matchTransactionToBill } = require('./transactionMatchService'); + const suggestions = listMatchSuggestions(userId, { limit: 50 }); + let matched = 0; + for (const s of suggestions) { + if (s.score < 80) break; // sorted descending — safe to stop early + try { + matchTransactionToBill(userId, s.transactionId, s.billId); + matched++; + } catch { + // Already matched, ignored, bill deleted, or date missing — skip silently + } + } + return matched; +} + module.exports = { + autoMatchForUser, listMatchSuggestions, parseSuggestionId, rejectMatchSuggestion,