fix: sync_days hard-clamped to 90 (SimpleFIN Bridge limit)

- bankSyncConfigService: SYNC_DAYS_MAX=90, getBankSyncConfig clamps on read,
  setSyncDays rejects >90 with explanation
- bankSyncService: every sync requests full sync_days window, dedup handles
  already-seen transactions
- dataSources status endpoint returns sync_days alongside enabled
- BankSyncAdminCard: input max 90, live clamp, description cites Bridge limit
- BankSyncSection: third stat tile showing History window X days
This commit is contained in:
null 2026-05-29 02:23:19 -05:00
parent 820fedd58e
commit 1d8ae4f511
6 changed files with 41 additions and 29 deletions

View File

@ -52,8 +52,8 @@ export default function BankSyncAdminCard() {
toast.error('Sync interval must be between 0.5 and 168 hours.'); toast.error('Sync interval must be between 0.5 and 168 hours.');
return; return;
} }
if (!Number.isFinite(days) || days < 1 || days > 730) { if (!Number.isFinite(days) || days < 1 || days > 90) {
toast.error('Transaction history must be between 1 and 730 days.'); toast.error('Transaction history must be between 1 and 90 days — SimpleFIN Bridge does not support longer windows.');
return; return;
} }
setSaving(true); setSaving(true);
@ -146,21 +146,21 @@ export default function BankSyncAdminCard() {
</div> </div>
{/* Transaction history lookback */} {/* Transaction history lookback */}
<div className="flex items-center justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<p className="text-sm font-medium">Transaction history</p> <p className="text-sm font-medium">Transaction history</p>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
How far back to fetch on first connect. Re-syncs only fetch recent activity. How far back to fetch transactions. Maximum 90 days this is a hard limit imposed by SimpleFIN Bridge and cannot be exceeded.
</p> </p>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<Input <Input
type="number" type="number"
min="1" min="1"
max="730" max="90"
step="1" step="1"
value={syncDays} value={syncDays}
onChange={e => setSyncDays(e.target.value)} onChange={e => setSyncDays(Math.min(90, Math.max(1, parseInt(e.target.value, 10) || 90)))}
className="w-20 text-sm text-right" className="w-20 text-sm text-right"
/> />
<span className="text-sm text-muted-foreground">days</span> <span className="text-sm text-muted-foreground">days</span>

View File

@ -184,6 +184,7 @@ function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonit
export default function BankSyncSection({ onConnectionChange }) { export default function BankSyncSection({ onConnectionChange }) {
const [enabled, setEnabled] = useState(null); const [enabled, setEnabled] = useState(null);
const [syncDays, setSyncDays] = useState(90);
const [connections, setConnections] = useState([]); const [connections, setConnections] = useState([]);
const [accountsBySource, setAccountsBySource] = useState({}); const [accountsBySource, setAccountsBySource] = useState({});
const [accountsLoading, setAccountsLoading] = useState({}); const [accountsLoading, setAccountsLoading] = useState({});
@ -220,6 +221,7 @@ export default function BankSyncSection({ onConnectionChange }) {
api.dataSources({ type: 'provider_sync' }), api.dataSources({ type: 'provider_sync' }),
]); ]);
setEnabled(status.enabled); setEnabled(status.enabled);
setSyncDays(status.sync_days ?? 90);
const conns = Array.isArray(sources) ? sources.filter(s => s.provider === 'simplefin') : []; const conns = Array.isArray(sources) ? sources.filter(s => s.provider === 'simplefin') : [];
setConnections(conns); setConnections(conns);
onConnectionChange?.(conns[0] || null); onConnectionChange?.(conns[0] || null);
@ -252,7 +254,11 @@ export default function BankSyncSection({ onConnectionChange }) {
setSyncing(id); setSyncing(id);
try { try {
const result = await api.syncDataSource(id); const result = await api.syncDataSource(id);
toast.success(`Synced — ${result.transactionsNew} new transaction(s).`); if (result.errlist) {
toast.warning(`Synced ${result.transactionsNew} new transaction(s), but some connections need attention: ${result.errlist}`);
} else {
toast.success(`Synced — ${result.transactionsNew} new transaction(s).`);
}
await load(); await load();
} catch (err) { } catch (err) {
toast.error(err.message || 'Sync failed'); toast.error(err.message || 'Sync failed');
@ -302,10 +308,11 @@ export default function BankSyncSection({ onConnectionChange }) {
} }
}; };
function isStale(conn) { function connWarning(conn) {
if (!conn.last_error) return false; if (!conn.last_error) return null;
if (!conn.last_sync_at) return true; if (conn.status === 'error') return { kind: 'error', label: 'Sync error' };
return Date.now() - new Date(conn.last_sync_at).getTime() > 24 * 60 * 60 * 1000; // Partial errlist: sync succeeded but some bank connections need attention
return { kind: 'partial', label: 'Some connections need attention' };
} }
if (enabled === null) { if (enabled === null) {
@ -338,17 +345,21 @@ export default function BankSyncSection({ onConnectionChange }) {
const accsError = accountsErrorBySource[conn.id]; const accsError = accountsErrorBySource[conn.id];
const monitoredCount = accounts.filter(a => a.monitored).length; const monitoredCount = accounts.filter(a => a.monitored).length;
const warning = connWarning(conn);
return ( return (
<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) && ( {warning && (
<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"> <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" /> <AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<span className="font-medium">Sync error</span> <span className="font-medium">{warning.label}</span>
{conn.last_error && ( <span className="ml-1 font-normal opacity-90"> {conn.last_error}</span>
<span className="ml-1 font-normal opacity-90"> {conn.last_error}</span> {warning.kind === 'partial' && (
<span className="block text-xs mt-0.5 opacity-75">
Re-authenticate the affected institutions in your SimpleFIN Bridge account to restore full sync.
</span>
)} )}
{conn.last_sync_at && ( {warning.kind === 'error' && conn.last_sync_at && (
<span className="block text-xs mt-0.5 opacity-75"> <span className="block text-xs mt-0.5 opacity-75">
Last successful sync: {fmtDate(conn.last_sync_at)} Last successful sync: {fmtDate(conn.last_sync_at)}
</span> </span>
@ -415,6 +426,10 @@ export default function BankSyncSection({ onConnectionChange }) {
{conn.last_error ? conn.last_error : (conn.status === 'active' ? 'Active' : conn.status)} {conn.last_error ? conn.last_error : (conn.status === 'active' ? 'Active' : conn.status)}
</p> </p>
</div> </div>
<div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
<p className="text-muted-foreground">History window</p>
<p className="font-medium mt-0.5">{syncDays} days</p>
</div>
</div> </div>
{/* Accounts section */} {/* Accounts section */}

View File

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

View File

@ -62,8 +62,8 @@ router.get('/', (req, res) => {
// ─── GET /api/data-sources/simplefin/status ────────────────────────────────── // ─── GET /api/data-sources/simplefin/status ──────────────────────────────────
router.get('/simplefin/status', (req, res) => { router.get('/simplefin/status', (req, res) => {
const { enabled } = getBankSyncConfig(); const { enabled, sync_days } = getBankSyncConfig();
res.json({ enabled }); res.json({ enabled, sync_days });
}); });
// ─── POST /api/data-sources/simplefin/connect ──────────────────────────────── // ─── POST /api/data-sources/simplefin/connect ────────────────────────────────

View File

@ -2,6 +2,7 @@
const { getSetting, setSetting } = require('../db/database'); const { getSetting, setSetting } = require('../db/database');
const SYNC_DAYS_MAX = 90; // SimpleFIN Bridge hard limit
const SYNC_DAYS_DEFAULT = 90; const SYNC_DAYS_DEFAULT = 90;
const SYNC_INTERVAL_DEFAULT = 4; // hours const SYNC_INTERVAL_DEFAULT = 4; // hours
@ -20,11 +21,12 @@ function getBankSyncConfig() {
const syncDaysDb = parseInt(getSetting('simplefin_sync_days') || '', 10); const syncDaysDb = parseInt(getSetting('simplefin_sync_days') || '', 10);
const syncDaysEnv = parseInt(process.env.SIMPLEFIN_SYNC_DAYS || '', 10); const syncDaysEnv = parseInt(process.env.SIMPLEFIN_SYNC_DAYS || '', 10);
const syncDays = Number.isFinite(syncDaysDb) && syncDaysDb > 0 const rawSyncDays = Number.isFinite(syncDaysDb) && syncDaysDb > 0
? syncDaysDb ? syncDaysDb
: Number.isFinite(syncDaysEnv) && syncDaysEnv > 0 : Number.isFinite(syncDaysEnv) && syncDaysEnv > 0
? syncDaysEnv ? syncDaysEnv
: SYNC_DAYS_DEFAULT; : SYNC_DAYS_DEFAULT;
const syncDays = Math.min(rawSyncDays, SYNC_DAYS_MAX);
const intervalDb = parseFloat(getSetting('simplefin_sync_interval_hours') || ''); const intervalDb = parseFloat(getSetting('simplefin_sync_interval_hours') || '');
const intervalEnv = parseFloat(process.env.SIMPLEFIN_SYNC_INTERVAL_HOURS || ''); const intervalEnv = parseFloat(process.env.SIMPLEFIN_SYNC_INTERVAL_HOURS || '');
@ -57,8 +59,8 @@ function setSyncIntervalHours(hours) {
function setSyncDays(days) { function setSyncDays(days) {
const n = parseInt(days, 10); const n = parseInt(days, 10);
if (!Number.isFinite(n) || n < 1 || n > 730) { if (!Number.isFinite(n) || n < 1 || n > SYNC_DAYS_MAX) {
throw Object.assign(new Error('sync_days must be between 1 and 730'), { status: 400 }); throw Object.assign(new Error(`sync_days must be between 1 and ${SYNC_DAYS_MAX} (SimpleFIN Bridge hard limit)`), { status: 400 });
} }
setSetting('simplefin_sync_days', String(n)); setSetting('simplefin_sync_days', String(n));
return getBankSyncConfig(); return getBankSyncConfig();

View File

@ -11,12 +11,7 @@ const {
const { getBankSyncConfig } = require('./bankSyncConfigService'); const { getBankSyncConfig } = require('./bankSyncConfigService');
const { decorateDataSource } = require('./transactionService'); const { decorateDataSource } = require('./transactionService');
function sinceEpoch(dataSource) { function sinceEpoch() {
if (dataSource.last_sync_at) {
// Overlap by 2 days to catch late-posted transactions
const ts = new Date(dataSource.last_sync_at).getTime();
if (Number.isFinite(ts)) return Math.floor((ts - 2 * 86400 * 1000) / 1000);
}
const { sync_days } = getBankSyncConfig(); const { sync_days } = getBankSyncConfig();
return Math.floor((Date.now() - sync_days * 86400 * 1000) / 1000); return Math.floor((Date.now() - sync_days * 86400 * 1000) / 1000);
} }
@ -84,7 +79,7 @@ function insertTransactionIfNew(db, txRow) {
async function runSync(db, userId, dataSource) { async function runSync(db, userId, dataSource) {
const accessUrl = decryptSecret(dataSource.encrypted_secret); const accessUrl = decryptSecret(dataSource.encrypted_secret);
const since = sinceEpoch(dataSource); const since = sinceEpoch();
const raw = await fetchAccountsAndTransactions(accessUrl, since); const raw = await fetchAccountsAndTransactions(accessUrl, since);
const accounts = Array.isArray(raw.accounts) ? raw.accounts : []; const accounts = Array.isArray(raw.accounts) ? raw.accounts : [];