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:
parent
b03264ceb1
commit
542ab5e382
|
|
@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
import { Toggle } from './adminShared';
|
import { Toggle } from './adminShared';
|
||||||
|
|
||||||
|
|
@ -24,28 +25,37 @@ function timeUntil(iso) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BankSyncAdminCard() {
|
export default function BankSyncAdminCard() {
|
||||||
const [config, setConfig] = useState(null);
|
const [config, setConfig] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [loadError, setLoadError] = useState('');
|
||||||
const [enabled, setEnabled] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
const [syncInterval, setSyncInterval] = useState(4);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.bankSyncConfig()
|
api.bankSyncConfig()
|
||||||
.then(d => {
|
.then(d => {
|
||||||
setConfig(d);
|
setConfig(d);
|
||||||
setEnabled(!!d.enabled);
|
setEnabled(!!d.enabled);
|
||||||
|
setSyncInterval(d.sync_interval_hours ?? 4);
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(err => setLoadError(err.message || 'Failed to load bank sync config'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSave = async () => {
|
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);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.setBankSyncConfig({ enabled });
|
const result = await api.setBankSyncConfig({ enabled, sync_interval_hours: hours });
|
||||||
setConfig(result);
|
setConfig(result);
|
||||||
setEnabled(!!result.enabled);
|
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) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to update bank sync setting.');
|
toast.error(err.message || 'Failed to update bank sync setting.');
|
||||||
} finally {
|
} 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;
|
const worker = config?.worker;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -93,6 +113,28 @@ export default function BankSyncAdminCard() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Auto-sync worker status */}
|
||||||
{config?.enabled && worker && (
|
{config?.enabled && worker && (
|
||||||
<div className="rounded-lg border border-border/60 bg-muted/20 px-4 py-3 space-y-2">
|
<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>
|
<p className="font-medium">{worker.next_run_at ? `in ${timeUntil(worker.next_run_at)}` : '—'}</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<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}>
|
<Button onClick={handleSave} disabled={saving || !changed}>
|
||||||
{saving ? 'Saving…' : 'Save'}
|
{saving ? 'Saving…' : 'Save'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export default function EmailNotifCard() {
|
||||||
|
|
||||||
const [cfg, setCfg] = useState(DEFAULTS);
|
const [cfg, setCfg] = useState(DEFAULTS);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [showPw, setShowPw] = useState(false);
|
const [showPw, setShowPw] = useState(false);
|
||||||
const [testEmail, setTestEmail] = useState('');
|
const [testEmail, setTestEmail] = useState('');
|
||||||
|
|
@ -31,7 +32,7 @@ export default function EmailNotifCard() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.notifAdmin()
|
api.notifAdmin()
|
||||||
.then(d => setCfg({ ...DEFAULTS, ...d }))
|
.then(d => setCfg({ ...DEFAULTS, ...d }))
|
||||||
.catch(() => {})
|
.catch(err => setLoadError(err.message || 'Failed to load email settings'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
|
@ -62,7 +63,8 @@ export default function EmailNotifCard() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <Card><CardContent className="py-8 text-center text-muted-foreground text-sm">Loading…</CardContent></Card>;
|
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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
export default function LoginModeCard({ users }) {
|
export default function LoginModeCard({ users }) {
|
||||||
const [modeData, setModeData] = useState(null);
|
const [modeData, setModeData] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [selectedUser, setSelectedUser] = useState('');
|
const [selectedUser, setSelectedUser] = useState('');
|
||||||
|
|
||||||
|
|
@ -25,7 +26,7 @@ export default function LoginModeCard({ users }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.authModeConfig()
|
api.authModeConfig()
|
||||||
.then(d => { setModeData(d); setSelectedUser(d.default_user_id?.toString() || ''); })
|
.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));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -57,7 +58,8 @@ export default function LoginModeCard({ users }) {
|
||||||
doSetMode('single', pendingUserId);
|
doSetMode('single', pendingUserId);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <Card><CardContent className="py-8 text-center text-muted-foreground text-sm">Loading…</CardContent></Card>;
|
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 isMulti = !modeData || modeData.auth_mode === 'multi';
|
||||||
const activeUser = users?.find(u => u.id === modeData?.default_user_id);
|
const activeUser = users?.find(u => u.id === modeData?.default_user_id);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { toast } from 'sonner';
|
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 { api } from '@/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
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' });
|
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) {
|
if (enabled === null) {
|
||||||
return (
|
return (
|
||||||
<SectionCard title="Bank Sync">
|
<SectionCard title="Bank Sync">
|
||||||
|
|
@ -151,6 +157,30 @@ export default function BankSyncSection({ onConnectionChange }) {
|
||||||
|
|
||||||
{connections.length > 0 && connections.map(conn => (
|
{connections.length > 0 && connections.map(conn => (
|
||||||
<div key={conn.id} className="px-6 py-4 space-y-3">
|
<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-start justify-between gap-3">
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<Building2 className="h-5 w-5 shrink-0 text-muted-foreground" />
|
<Building2 className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ const userNavItems = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const adminNavItems = [
|
const adminNavItems = [
|
||||||
|
{ to: '/', icon: LayoutGrid, label: 'Tracker', end: true },
|
||||||
{ to: '/admin', icon: ShieldCheck, label: 'Admin Panel', end: true },
|
{ to: '/admin', icon: ShieldCheck, label: 'Admin Panel', end: true },
|
||||||
{ to: '/admin/status', icon: Activity, label: 'System Status' },
|
{ to: '/admin/status', icon: Activity, label: 'System Status' },
|
||||||
{ to: '/roadmap', icon: Map, label: 'Roadmap' },
|
{ to: '/roadmap', icon: Map, label: 'Roadmap' },
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export default function AdminPage() {
|
||||||
|
|
||||||
const [me, setMe] = useState(null);
|
const [me, setMe] = useState(null);
|
||||||
const [hasUsers, setHasUsers] = useState(null);
|
const [hasUsers, setHasUsers] = useState(null);
|
||||||
|
const [loadError, setLoadError] = useState('');
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
|
|
||||||
const loadMe = useCallback(async () => {
|
const loadMe = useCallback(async () => {
|
||||||
|
|
@ -40,7 +41,10 @@ export default function AdminPage() {
|
||||||
const d = await api.hasUsers();
|
const d = await api.hasUsers();
|
||||||
setHasUsers(d.has_users);
|
setHasUsers(d.has_users);
|
||||||
if (d.has_users) loadUsers();
|
if (d.has_users) loadUsers();
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
setLoadError(err.message || 'Failed to load admin page');
|
||||||
|
setHasUsers(false);
|
||||||
|
}
|
||||||
}, [loadUsers]);
|
}, [loadUsers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -53,7 +57,7 @@ export default function AdminPage() {
|
||||||
loadUsers();
|
loadUsers();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (hasUsers === null) {
|
if (hasUsers === null && !loadError) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen text-muted-foreground text-sm">
|
<div className="flex items-center justify-center min-h-screen text-muted-foreground text-sm">
|
||||||
Loading…
|
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 (
|
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">
|
<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 />
|
<AppNavigation adminMode />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "bill-tracker",
|
"name": "bill-tracker",
|
||||||
"version": "0.31.0",
|
"version": "0.32.0",
|
||||||
"description": "Monthly bill tracking system",
|
"description": "Monthly bill tracking system",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { getDb, rollbackMigration } = require('../db/database');
|
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 { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker');
|
||||||
const { hashPassword } = require('../services/authService');
|
const { hashPassword } = require('../services/authService');
|
||||||
const { logAudit } = require('../services/auditService');
|
const { logAudit } = require('../services/auditService');
|
||||||
|
|
@ -407,12 +407,16 @@ router.get('/bank-sync-config', (req, res) => {
|
||||||
|
|
||||||
// PUT /api/admin/bank-sync-config
|
// PUT /api/admin/bank-sync-config
|
||||||
router.put('/bank-sync-config', (req, res) => {
|
router.put('/bank-sync-config', (req, res) => {
|
||||||
const enabled = req.body?.enabled;
|
const { enabled, sync_interval_hours } = req.body || {};
|
||||||
if (typeof enabled !== 'boolean') {
|
|
||||||
return res.status(400).json({ error: 'enabled must be a boolean' });
|
|
||||||
}
|
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
res.status(err.status || 500).json({ error: err.message || 'Failed to update bank sync config' });
|
res.status(err.status || 500).json({ error: err.message || 'Failed to update bank sync config' });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
|
|
||||||
const { getSetting, setSetting } = require('../db/database');
|
const { getSetting, setSetting } = require('../db/database');
|
||||||
|
|
||||||
const SYNC_DAYS_DEFAULT = 90;
|
const SYNC_DAYS_DEFAULT = 90;
|
||||||
|
const SYNC_INTERVAL_DEFAULT = 4; // hours
|
||||||
|
|
||||||
function getBankSyncConfig() {
|
function getBankSyncConfig() {
|
||||||
const dbValue = getSetting('bank_sync_enabled');
|
const dbValue = getSetting('bank_sync_enabled');
|
||||||
|
|
@ -25,9 +26,18 @@ function getBankSyncConfig() {
|
||||||
? syncDaysEnv
|
? syncDaysEnv
|
||||||
: SYNC_DAYS_DEFAULT;
|
: 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 {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
sync_days: syncDays,
|
sync_days: syncDays,
|
||||||
|
sync_interval_hours: syncIntervalHours,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,4 +46,13 @@ function setBankSyncEnabled(enabled) {
|
||||||
return getBankSyncConfig();
|
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 };
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const { getBankSyncConfig } = require('./bankSyncConfigService');
|
const { getBankSyncConfig } = require('./bankSyncConfigService');
|
||||||
const { syncDataSource } = require('./bankSyncService');
|
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)
|
// 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
|
const MIN_SYNC_AGE_MS = 60 * 60 * 1000; // 1 hour
|
||||||
// Pause between each source to avoid hammering SimpleFIN
|
// Pause between each source to avoid hammering SimpleFIN
|
||||||
|
|
@ -16,10 +16,8 @@ let lastRunAt = null;
|
||||||
let nextRunAt = null;
|
let nextRunAt = null;
|
||||||
|
|
||||||
function intervalMs() {
|
function intervalMs() {
|
||||||
const hours = parseFloat(process.env.SIMPLEFIN_SYNC_INTERVAL_HOURS);
|
const { sync_interval_hours } = getBankSyncConfig();
|
||||||
return Number.isFinite(hours) && hours >= 0.5
|
return Math.round(sync_interval_hours * 3600000);
|
||||||
? Math.round(hours * 3600000)
|
|
||||||
: DEFAULT_INTERVAL_HOURS * 3600000;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function needsSync(source) {
|
function needsSync(source) {
|
||||||
|
|
@ -71,6 +69,7 @@ async function runCycle() {
|
||||||
try {
|
try {
|
||||||
await syncDataSource(db, source.user_id, source.id);
|
await syncDataSource(db, source.user_id, source.id);
|
||||||
synced++;
|
synced++;
|
||||||
|
try { autoMatchForUser(source.user_id); } catch { /* non-fatal */ }
|
||||||
} catch {
|
} catch {
|
||||||
// syncDataSource already writes last_error to the data_sources row
|
// syncDataSource already writes last_error to the data_sources row
|
||||||
failed++;
|
failed++;
|
||||||
|
|
@ -107,8 +106,7 @@ function scheduleNext() {
|
||||||
function start() {
|
function start() {
|
||||||
if (timer) return;
|
if (timer) return;
|
||||||
scheduleNext();
|
scheduleNext();
|
||||||
const hours = intervalMs() / 3600000;
|
console.log(`[bankSync] Auto-sync worker started (interval: ${getBankSyncConfig().sync_interval_hours}h)`);
|
||||||
console.log(`[bankSync] Auto-sync worker started (interval: ${hours}h)`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stop() {
|
function stop() {
|
||||||
|
|
@ -119,7 +117,7 @@ function stop() {
|
||||||
function getStatus() {
|
function getStatus() {
|
||||||
return {
|
return {
|
||||||
running,
|
running,
|
||||||
interval_hours: intervalMs() / 3600000,
|
interval_hours: getBankSyncConfig().sync_interval_hours,
|
||||||
last_run_at: lastRunAt,
|
last_run_at: lastRunAt,
|
||||||
next_run_at: nextRunAt,
|
next_run_at: nextRunAt,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
module.exports = {
|
||||||
|
autoMatchForUser,
|
||||||
listMatchSuggestions,
|
listMatchSuggestions,
|
||||||
parseSuggestionId,
|
parseSuggestionId,
|
||||||
rejectMatchSuggestion,
|
rejectMatchSuggestion,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue