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.');
return;
}
if (!Number.isFinite(days) || days < 1 || days > 730) {
toast.error('Transaction history must be between 1 and 730 days.');
if (!Number.isFinite(days) || days < 1 || days > 90) {
toast.error('Transaction history must be between 1 and 90 days — SimpleFIN Bridge does not support longer windows.');
return;
}
setSaving(true);
@ -146,21 +146,21 @@ export default function BankSyncAdminCard() {
</div>
{/* Transaction history lookback */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium">Transaction history</p>
<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>
</div>
<div className="flex items-center gap-2 shrink-0">
<Input
type="number"
min="1"
max="730"
max="90"
step="1"
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"
/>
<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 }) {
const [enabled, setEnabled] = useState(null);
const [syncDays, setSyncDays] = useState(90);
const [connections, setConnections] = useState([]);
const [accountsBySource, setAccountsBySource] = useState({});
const [accountsLoading, setAccountsLoading] = useState({});
@ -220,6 +221,7 @@ export default function BankSyncSection({ onConnectionChange }) {
api.dataSources({ type: 'provider_sync' }),
]);
setEnabled(status.enabled);
setSyncDays(status.sync_days ?? 90);
const conns = Array.isArray(sources) ? sources.filter(s => s.provider === 'simplefin') : [];
setConnections(conns);
onConnectionChange?.(conns[0] || null);
@ -252,7 +254,11 @@ export default function BankSyncSection({ onConnectionChange }) {
setSyncing(id);
try {
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();
} catch (err) {
toast.error(err.message || 'Sync failed');
@ -302,10 +308,11 @@ export default function BankSyncSection({ onConnectionChange }) {
}
};
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;
function connWarning(conn) {
if (!conn.last_error) return null;
if (conn.status === 'error') return { kind: 'error', label: 'Sync error' };
// Partial errlist: sync succeeded but some bank connections need attention
return { kind: 'partial', label: 'Some connections need attention' };
}
if (enabled === null) {
@ -338,17 +345,21 @@ export default function BankSyncSection({ onConnectionChange }) {
const accsError = accountsErrorBySource[conn.id];
const monitoredCount = accounts.filter(a => a.monitored).length;
const warning = connWarning(conn);
return (
<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">
<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>
<span className="font-medium">{warning.label}</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">
Last successful sync: {fmtDate(conn.last_sync_at)}
</span>
@ -415,6 +426,10 @@ export default function BankSyncSection({ onConnectionChange }) {
{conn.last_error ? conn.last_error : (conn.status === 'active' ? 'Active' : conn.status)}
</p>
</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>
{/* Accounts section */}

View File

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

View File

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

View File

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

View File

@ -11,12 +11,7 @@ const {
const { getBankSyncConfig } = require('./bankSyncConfigService');
const { decorateDataSource } = require('./transactionService');
function sinceEpoch(dataSource) {
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);
}
function sinceEpoch() {
const { sync_days } = getBankSyncConfig();
return Math.floor((Date.now() - sync_days * 86400 * 1000) / 1000);
}
@ -84,7 +79,7 @@ function insertTransactionIfNew(db, txRow) {
async function runSync(db, userId, dataSource) {
const accessUrl = decryptSecret(dataSource.encrypted_secret);
const since = sinceEpoch(dataSource);
const since = sinceEpoch();
const raw = await fetchAccountsAndTransactions(accessUrl, since);
const accounts = Array.isArray(raw.accounts) ? raw.accounts : [];