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
This commit is contained in:
null 2026-05-29 00:28:50 -05:00
parent b03264ceb1
commit 542ab5e382
11 changed files with 170 additions and 36 deletions

View File

@ -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';
@ -26,26 +27,35 @@ function timeUntil(iso) {
export default function BankSyncAdminCard() {
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 (
<Card>
<CardContent className="py-8 text-center text-sm text-destructive">
{loadError}
</CardContent>
</Card>
);
}
const changed = enabled !== !!config?.enabled || parseFloat(syncInterval) !== config?.sync_interval_hours;
const worker = config?.worker;
return (
@ -93,6 +113,28 @@ export default function BankSyncAdminCard() {
/>
</div>
{/* Sync interval */}
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">Auto-sync interval</p>
<p className="text-xs text-muted-foreground mt-0.5">
How often the background worker checks for new transactions.
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Input
type="number"
min="0.5"
max="168"
step="0.5"
value={syncInterval}
onChange={e => setSyncInterval(e.target.value)}
className="w-20 text-sm text-right"
/>
<span className="text-sm text-muted-foreground">hrs</span>
</div>
</div>
{/* Auto-sync worker status */}
{config?.enabled && worker && (
<div className="rounded-lg border border-border/60 bg-muted/20 px-4 py-3 space-y-2">
@ -114,13 +156,16 @@ export default function BankSyncAdminCard() {
<p className="font-medium">{worker.next_run_at ? `in ${timeUntil(worker.next_run_at)}` : '—'}</p>
</div>
</div>
<p className="text-xs text-muted-foreground">
Syncs every {worker.interval_hours}h. Set <code className="font-mono">SIMPLEFIN_SYNC_INTERVAL_HOURS</code> to adjust.
</p>
</div>
)}
<div className="flex justify-end pt-2">
{/* Encryption note */}
<p className="text-xs text-muted-foreground border-t border-border/50 pt-3">
SimpleFIN credentials are encrypted with a key stored in your database.
Regular database backups preserve all user connections.
</p>
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving || !changed}>
{saving ? 'Saving…' : 'Save'}
</Button>

View File

@ -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
@ -63,6 +64,7 @@ export default function EmailNotifCard() {
};
if (loading) return <Card><CardContent className="py-8 text-center text-muted-foreground text-sm">Loading</CardContent></Card>;
if (loadError) return <Card><CardContent className="py-8 text-center text-sm text-destructive">{loadError}</CardContent></Card>;
return (
<Card>

View File

@ -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));
}, []);
@ -58,6 +59,7 @@ export default function LoginModeCard({ users }) {
};
if (loading) return <Card><CardContent className="py-8 text-center text-muted-foreground text-sm">Loading</CardContent></Card>;
if (loadError) return <Card><CardContent className="py-8 text-center text-sm text-destructive">{loadError}</CardContent></Card>;
const isMulti = !modeData || modeData.auth_mode === 'multi';
const activeUser = users?.find(u => u.id === modeData?.default_user_id);

View File

@ -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 (
<SectionCard title="Bank Sync">
@ -151,6 +157,30 @@ export default function BankSyncSection({ onConnectionChange }) {
{connections.length > 0 && connections.map(conn => (
<div key={conn.id} className="px-6 py-4 space-y-3">
{isStale(conn) && (
<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">
<span className="font-medium">Sync error</span>
{conn.last_error && (
<span className="ml-1 font-normal opacity-90"> {conn.last_error}</span>
)}
{conn.last_sync_at && (
<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>
)}
<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" />

View File

@ -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' },

View File

@ -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 (
<div className="flex items-center justify-center min-h-screen text-muted-foreground text-sm">
Loading
@ -61,6 +65,14 @@ export default function AdminPage() {
);
}
if (loadError) {
return (
<div className="flex items-center justify-center min-h-screen text-destructive text-sm">
{loadError}
</div>
);
}
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.06),transparent_34rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.18))] text-foreground">
<AppNavigation adminMode />

View File

@ -1,6 +1,6 @@
{
"name": "bill-tracker",
"version": "0.31.0",
"version": "0.32.0",
"description": "Monthly bill tracking system",
"main": "server.js",
"scripts": {

View File

@ -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' });
}

View File

@ -3,6 +3,7 @@
const { getSetting, setSetting } = require('../db/database');
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 };

View File

@ -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,
};

View File

@ -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,