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:
parent
820fedd58e
commit
1d8ae4f511
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 */}
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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 ────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 : [];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue