From 31be51e77fa6ba319c16ce9d9055d8511e4d2054 Mon Sep 17 00:00:00 2001 From: null Date: Sun, 7 Jun 2026 19:41:17 -0500 Subject: [PATCH] fix(bank-sync): admin config, matching, and worker updates --- client/components/BillModal.jsx | 46 ++++++++++--------- client/components/admin/BankSyncAdminCard.jsx | 34 ++++++++++++-- client/components/data/BankSyncSection.jsx | 8 +++- .../data/TransactionMatchingSection.jsx | 8 +++- client/pages/TrackerPage.jsx | 9 +++- routes/admin.js | 5 +- services/bankSyncConfigService.js | 16 +++++-- services/bankSyncService.js | 30 +++++++++--- services/bankSyncWorker.js | 29 ++++++++---- 9 files changed, 136 insertions(+), 49 deletions(-) diff --git a/client/components/BillModal.jsx b/client/components/BillModal.jsx index ad6837e..862f96f 100644 --- a/client/components/BillModal.jsx +++ b/client/components/BillModal.jsx @@ -822,29 +822,33 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa {/* Debt / Snowball Details — collapsible */}
- +
+
+ +
+
{showDebtSection && ( -
+
{/* Interest Rate */}
diff --git a/client/components/admin/BankSyncAdminCard.jsx b/client/components/admin/BankSyncAdminCard.jsx index da239ec..0106c43 100644 --- a/client/components/admin/BankSyncAdminCard.jsx +++ b/client/components/admin/BankSyncAdminCard.jsx @@ -7,9 +7,15 @@ import { Input } from '@/components/ui/input'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; import { Toggle } from './adminShared'; +function parseUtc(str) { + if (!str) return null; + const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z'; + return new Date(normalized); +} + function timeAgo(iso) { if (!iso) return null; - const secs = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); + const secs = Math.floor((Date.now() - parseUtc(iso).getTime()) / 1000); if (secs < 60) return 'just now'; if (secs < 3600) return `${Math.floor(secs / 60)}m ago`; if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`; @@ -18,7 +24,7 @@ function timeAgo(iso) { function timeUntil(iso) { if (!iso) return null; - const secs = Math.floor((new Date(iso).getTime() - Date.now()) / 1000); + const secs = Math.floor((parseUtc(iso).getTime() - Date.now()) / 1000); if (secs <= 0) return 'soon'; if (secs < 60) return `${secs}s`; if (secs < 3600) return `${Math.floor(secs / 60)}m`; @@ -33,6 +39,7 @@ export default function BankSyncAdminCard() { const [enabled, setEnabled] = useState(false); const [syncInterval, setSyncInterval] = useState(4); const [syncDays, setSyncDays] = useState(30); + const [debugLogging, setDebugLogging] = useState(false); useEffect(() => { api.bankSyncConfig() @@ -41,6 +48,7 @@ export default function BankSyncAdminCard() { setEnabled(!!d.enabled); setSyncInterval(d.sync_interval_hours ?? 4); setSyncDays(d.sync_days ?? 30); + setDebugLogging(!!d.debug_logging); }) .catch(err => setLoadError(err.message || 'Failed to load bank sync config')) .finally(() => setLoading(false)); @@ -60,11 +68,12 @@ export default function BankSyncAdminCard() { } setSaving(true); try { - const result = await api.setBankSyncConfig({ enabled, sync_interval_hours: hours, sync_days: days }); + const result = await api.setBankSyncConfig({ enabled, sync_interval_hours: hours, sync_days: days, debug_logging: debugLogging }); setConfig(result); setEnabled(!!result.enabled); setSyncInterval(result.sync_interval_hours ?? 4); setSyncDays(result.sync_days ?? 30); + setDebugLogging(!!result.debug_logging); toast.success('Bank sync settings saved.'); } catch (err) { toast.error(err.message || 'Failed to update bank sync setting.'); @@ -95,7 +104,8 @@ export default function BankSyncAdminCard() { const changed = enabled !== !!config?.enabled || parseFloat(syncInterval) !== config?.sync_interval_hours - || parseInt(syncDays, 10) !== config?.sync_days; + || parseInt(syncDays, 10) !== config?.sync_days + || debugLogging !== !!config?.debug_logging; const worker = config?.worker; const seedDays = config?.seed_days ?? 44; const maxDays = config?.sync_days_max ?? 45; @@ -243,6 +253,22 @@ export default function BankSyncAdminCard() {
)} + {/* Debug logging toggle */} +
+
+

Debug logging

+

+ Logs each account, transaction, and auto-match step to the server console. + Turn on to diagnose sync issues, then off when done. +

+
+ setDebugLogging(v)} + label="Enable debug logging" + /> +
+ {/* Encryption note */}

{config?.encryption_key_source === 'env' diff --git a/client/components/data/BankSyncSection.jsx b/client/components/data/BankSyncSection.jsx index 19af440..2922533 100644 --- a/client/components/data/BankSyncSection.jsx +++ b/client/components/data/BankSyncSection.jsx @@ -56,9 +56,15 @@ function TokenInput({ value, onChange, disabled }) { ); } +function parseUtc(str) { + if (!str) return null; + const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z'; + return new Date(normalized); +} + function fmtDate(iso) { if (!iso) return '—'; - return new Date(iso).toLocaleString(undefined, { + return parseUtc(iso).toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit', }); diff --git a/client/components/data/TransactionMatchingSection.jsx b/client/components/data/TransactionMatchingSection.jsx index 3bb3e0d..e470fb6 100644 --- a/client/components/data/TransactionMatchingSection.jsx +++ b/client/components/data/TransactionMatchingSection.jsx @@ -363,9 +363,15 @@ function MatchBillDialog({ open, onOpenChange, transaction, bills, onConfirm, lo ); } +function parseUtc(str) { + if (!str) return null; + const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z'; + return new Date(normalized); +} + function timeAgo(iso) { if (!iso) return null; - const secs = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); + const secs = Math.floor((Date.now() - parseUtc(iso).getTime()) / 1000); if (secs < 60) return 'just now'; if (secs < 3600) return `${Math.floor(secs / 60)}m ago`; if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`; diff --git a/client/pages/TrackerPage.jsx b/client/pages/TrackerPage.jsx index 9310980..363fc75 100644 --- a/client/pages/TrackerPage.jsx +++ b/client/pages/TrackerPage.jsx @@ -42,9 +42,16 @@ import { TrackerBucket as Bucket } from '@/components/tracker/TrackerBucket'; import IncomeBreakdownModal from '@/components/IncomeBreakdownModal'; +function parseUtc(str) { + if (!str) return null; + // SQLite datetime('now') returns "2026-06-07 23:40:15" — no T, no Z. Treat as UTC. + const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z'; + return new Date(normalized); +} + function fmtBalanceAge(isoStr) { if (!isoStr) return null; - return new Date(isoStr).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); + return parseUtc(isoStr).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); } function localDateString(date = new Date()) { diff --git a/routes/admin.js b/routes/admin.js index 20fe9d6..71fd12c 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -1,7 +1,7 @@ const express = require('express'); const router = express.Router(); const { getDb, getSetting, setSetting, rollbackMigration } = require('../db/database'); -const { getBankSyncConfig, setBankSyncEnabled, setSyncIntervalHours, setSyncDays } = require('../services/bankSyncConfigService'); +const { getBankSyncConfig, setBankSyncEnabled, setSyncIntervalHours, setSyncDays, setDebugLogging } = require('../services/bankSyncConfigService'); const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker'); const { isEnvKeyActive } = require('../services/encryptionService'); const { hashPassword } = require('../services/authService'); @@ -445,12 +445,13 @@ router.get('/bank-sync-config', (req, res) => { // PUT /api/admin/bank-sync-config router.put('/bank-sync-config', (req, res) => { - const { enabled, sync_interval_hours, sync_days } = req.body || {}; + const { enabled, sync_interval_hours, sync_days, debug_logging } = req.body || {}; try { let config = getBankSyncConfig(); if (typeof enabled === 'boolean') config = setBankSyncEnabled(enabled); if (sync_interval_hours !== undefined) config = setSyncIntervalHours(sync_interval_hours); if (sync_days !== undefined) config = setSyncDays(sync_days); + if (typeof debug_logging === 'boolean') config = setDebugLogging(debug_logging); res.json(config); } catch (err) { res.status(err.status || 500).json({ error: err.message || 'Failed to update bank sync config' }); diff --git a/services/bankSyncConfigService.js b/services/bankSyncConfigService.js index 3e85e6e..c49b6a3 100644 --- a/services/bankSyncConfigService.js +++ b/services/bankSyncConfigService.js @@ -37,12 +37,17 @@ function getBankSyncConfig() { ? intervalEnv : SYNC_INTERVAL_DEFAULT; + const debugDb = getSetting('simplefin_debug_logging'); + const debugEnv = process.env.SIMPLEFIN_DEBUG_LOGGING; + const debugLogging = debugDb === 'true' || (!debugDb && debugEnv === 'true'); + return { enabled, sync_days: syncDays, - seed_days: SYNC_DAYS_EFFECTIVE, // initial connect / explicit backfill window - sync_days_max: SYNC_DAYS_MAX, // SimpleFIN Bridge hard limit + seed_days: SYNC_DAYS_EFFECTIVE, + sync_days_max: SYNC_DAYS_MAX, sync_interval_hours: syncIntervalHours, + debug_logging: debugLogging, }; } @@ -69,7 +74,12 @@ function setSyncDays(days) { return getBankSyncConfig(); } +function setDebugLogging(enabled) { + setSetting('simplefin_debug_logging', enabled ? 'true' : 'false'); + return getBankSyncConfig(); +} + module.exports = { - getBankSyncConfig, setBankSyncEnabled, setSyncIntervalHours, setSyncDays, + getBankSyncConfig, setBankSyncEnabled, setSyncIntervalHours, setSyncDays, setDebugLogging, SYNC_DAYS_MAX, SYNC_DAYS_EFFECTIVE, SYNC_DAYS_DEFAULT, }; diff --git a/services/bankSyncService.js b/services/bankSyncService.js index 1e3533d..1779b39 100644 --- a/services/bankSyncService.js +++ b/services/bankSyncService.js @@ -79,7 +79,7 @@ function insertTransactionIfNew(db, txRow) { } } -async function runSync(db, userId, dataSource, { days } = {}) { +async function runSync(db, userId, dataSource, { days, debug = false } = {}) { const accessUrl = decryptSecret(dataSource.encrypted_secret); const isFirstSync = !dataSource.last_sync_at; // Explicit `days` param (e.g. backfill) takes precedence. @@ -89,9 +89,13 @@ async function runSync(db, userId, dataSource, { days } = {}) { const syncDays = days ?? (isFirstSync ? SYNC_DAYS_EFFECTIVE : (config.sync_days || SYNC_DAYS_DEFAULT)); const since = sinceEpochDays(syncDays); + if (debug) console.log(`[bankSync:debug] Source #${dataSource.id} user ${userId}: fetching ${syncDays} days from SimpleFIN (since epoch ${since})`); + const raw = await fetchAccountsAndTransactions(accessUrl, since); const accounts = Array.isArray(raw.accounts) ? raw.accounts : []; + if (debug) console.log(`[bankSync:debug] Source #${dataSource.id}: SimpleFIN returned ${accounts.length} account(s)`); + if (raw._errlistSummary) { console.warn(`[bankSync] errlist for source ${dataSource.id}: ${raw._errlistSummary}`); } @@ -105,15 +109,23 @@ async function runSync(db, userId, dataSource, { days } = {}) { const localAccount = upsertAccount(db, accountRow); accountsUpserted += 1; + const txList = rawAccount.transactions || []; + if (debug) console.log(`[bankSync:debug] Account "${rawAccount.name}" (monitored=${localAccount.monitored}): ${txList.length} transaction(s)`); + if (localAccount.monitored === 0) continue; - for (const rawTx of (rawAccount.transactions || [])) { + for (const rawTx of txList) { const txRow = normalizeTransaction( rawTx, localAccount.id, dataSource.id, userId, rawAccount.id, rawAccount.currency, ); const outcome = insertTransactionIfNew(db, txRow); - if (outcome === 'inserted') transactionsNew += 1; - else transactionsSkip += 1; + if (outcome === 'inserted') { + transactionsNew += 1; + if (debug) console.log(`[bankSync:debug] tx ${txRow.provider_transaction_id}: inserted (${rawTx.description || rawTx.payee || '—'}, ${txRow.amount}¢)`); + } else { + transactionsSkip += 1; + if (debug) console.log(`[bankSync:debug] tx ${txRow.provider_transaction_id}: duplicate — skipped`); + } } } @@ -128,11 +140,15 @@ async function runSync(db, userId, dataSource, { days } = {}) { WHERE id = ? AND user_id = ? `).run(partialError, dataSource.id, userId); + if (debug) console.log(`[bankSync:debug] Source #${dataSource.id}: applying merchant rules + auto-match`); + // Apply stored merchant→bill rules, then spending category rules, then score-based auto-match const { matched: autoMatched, matched_bills: matchedBills, late_attributions: lateAttributions } = applyMerchantRules(db, userId); try { applySpendingCategoryRules(db, userId); } catch { /* non-blocking */ } try { autoMatchForUser(userId); } catch { /* non-blocking */ } + if (debug) console.log(`[bankSync:debug] Source #${dataSource.id}: auto-matched ${autoMatched} transaction(s)`); + return { accountsUpserted, transactionsNew, transactionsSkip, autoMatched, matched_bills: matchedBills || [], late_attributions: lateAttributions || [], errlist: raw._errlistSummary || null }; } @@ -167,7 +183,7 @@ async function connectSimplefin(db, userId, setupToken) { return { dataSource: decorateDataSource(fresh), ...syncResult }; } -async function syncDataSource(db, userId, dataSourceId) { +async function syncDataSource(db, userId, dataSourceId, { debug } = {}) { assertEncryptionReady(); const dataSource = db.prepare(` @@ -178,9 +194,11 @@ async function syncDataSource(db, userId, dataSourceId) { if (!dataSource) throw Object.assign(new Error('SimpleFIN connection not found'), { status: 404 }); if (!dataSource.encrypted_secret) throw new Error('No stored credentials for this connection'); + const useDebug = debug ?? getBankSyncConfig().debug_logging; + let syncResult; try { - syncResult = await runSync(db, userId, dataSource); + syncResult = await runSync(db, userId, dataSource, { debug: useDebug }); } catch (err) { const msg = safeErrorMessage(err); db.prepare(` diff --git a/services/bankSyncWorker.js b/services/bankSyncWorker.js index 5b6eebd..43c52a1 100644 --- a/services/bankSyncWorker.js +++ b/services/bankSyncWorker.js @@ -32,8 +32,13 @@ function sleep(ms) { async function runCycle() { if (running) return; - const { enabled } = getBankSyncConfig(); - if (!enabled) return; + const config = getBankSyncConfig(); + if (!config.enabled) { + console.log('[bankSync] Disabled — skipping cycle'); + return; + } + + const debug = config.debug_logging; let db, sources; try { @@ -48,8 +53,12 @@ async function runCycle() { return; } - if (sources.length === 0) return; + if (sources.length === 0) { + if (debug) console.log('[bankSync] No SimpleFIN sources configured — skipping cycle'); + return; + } + console.log(`[bankSync] Cycle starting — ${sources.length} source(s)`); running = true; lastRunAt = new Date().toISOString(); @@ -61,26 +70,26 @@ async function runCycle() { const source = sources[i]; if (!needsSync(source)) { + if (debug) console.log(`[bankSync] Source #${source.id} (user ${source.user_id}): recently synced — skipping`); skipped++; continue; } + if (debug) console.log(`[bankSync] Source #${source.id} (user ${source.user_id}): starting sync`); try { - await syncDataSource(db, source.user_id, source.id); + const result = await syncDataSource(db, source.user_id, source.id, { debug }); synced++; - } catch { - // syncDataSource already writes last_error to the data_sources row + console.log(`[bankSync] Source #${source.id}: OK — ${result.accountsUpserted} account(s), ${result.transactionsNew} new, ${result.transactionsSkip} skipped${result.errlist ? ` [partial: ${result.errlist}]` : ''}`); + } catch (err) { failed++; + console.error(`[bankSync] Source #${source.id}: FAILED — ${err.message}`); } // Stagger requests — don't fire them all simultaneously if (i < sources.length - 1) await sleep(STAGGER_DELAY_MS); } - if (synced > 0 || failed > 0) { - console.log(`[bankSync] Auto-sync complete: ${synced} synced, ${failed} failed, ${skipped} skipped`); - } - + console.log(`[bankSync] Cycle complete — ${synced} synced, ${failed} failed, ${skipped} skipped`); running = false; }