fix(bank-sync): admin config, matching, and worker updates

This commit is contained in:
null 2026-06-07 19:41:17 -05:00
parent 68aa5eff31
commit 31be51e77f
9 changed files with 136 additions and 49 deletions

View File

@ -822,29 +822,33 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
{/* Debt / Snowball Details — collapsible */} {/* Debt / Snowball Details — collapsible */}
<div className="col-span-2"> <div className="col-span-2">
<div className="flex items-center gap-3">
<div className="h-px flex-1 bg-border/40" />
<button <button
type="button" type="button"
onClick={() => setShowDebtSection(s => !s)} onClick={() => setShowDebtSection(s => !s)}
className="flex items-center gap-2 text-xs uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors w-full text-left py-1" className="flex items-center gap-1.5 text-[11px] uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors shrink-0"
> >
<ChevronDown <ChevronDown
className={cn('h-3.5 w-3.5 transition-transform duration-150', !showDebtSection && '-rotate-90')} className={cn('h-3 w-3 transition-transform duration-150', !showDebtSection && '-rotate-90')}
/> />
Debt / Snowball Details Debt / Snowball Details
{isSnowballCategory && ( {isSnowballCategory && (
<span className="ml-1 text-[9px] text-emerald-400 font-semibold tracking-normal normal-case"> <span className="text-[9px] text-emerald-400 font-semibold tracking-normal normal-case">
· snowball auto-detected · auto-detected
</span> </span>
)} )}
{!showOnSnowball && isSnowballCategory && ( {!showOnSnowball && isSnowballCategory && (
<span className="ml-1 text-[9px] text-amber-400 font-semibold tracking-normal normal-case"> <span className="text-[9px] text-amber-400 font-semibold tracking-normal normal-case">
· exempt · exempt
</span> </span>
)} )}
</button> </button>
<div className="h-px flex-1 bg-border/40" />
</div>
{showDebtSection && ( {showDebtSection && (
<div className="grid sm:grid-cols-2 gap-x-5 gap-y-4 mt-3 pt-3 border-t border-border/40"> <div className="grid sm:grid-cols-2 gap-x-5 gap-y-4 mt-3 p-3 rounded-xl bg-muted/20 border border-border/30">
{/* Interest Rate */} {/* Interest Rate */}
<div className="space-y-1.5"> <div className="space-y-1.5">

View File

@ -7,9 +7,15 @@ 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';
function parseUtc(str) {
if (!str) return null;
const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z';
return new Date(normalized);
}
function timeAgo(iso) { function timeAgo(iso) {
if (!iso) return null; 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 < 60) return 'just now';
if (secs < 3600) return `${Math.floor(secs / 60)}m ago`; if (secs < 3600) return `${Math.floor(secs / 60)}m ago`;
if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`; if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`;
@ -18,7 +24,7 @@ function timeAgo(iso) {
function timeUntil(iso) { function timeUntil(iso) {
if (!iso) return null; 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 <= 0) return 'soon';
if (secs < 60) return `${secs}s`; if (secs < 60) return `${secs}s`;
if (secs < 3600) return `${Math.floor(secs / 60)}m`; if (secs < 3600) return `${Math.floor(secs / 60)}m`;
@ -33,6 +39,7 @@ export default function BankSyncAdminCard() {
const [enabled, setEnabled] = useState(false); const [enabled, setEnabled] = useState(false);
const [syncInterval, setSyncInterval] = useState(4); const [syncInterval, setSyncInterval] = useState(4);
const [syncDays, setSyncDays] = useState(30); const [syncDays, setSyncDays] = useState(30);
const [debugLogging, setDebugLogging] = useState(false);
useEffect(() => { useEffect(() => {
api.bankSyncConfig() api.bankSyncConfig()
@ -41,6 +48,7 @@ export default function BankSyncAdminCard() {
setEnabled(!!d.enabled); setEnabled(!!d.enabled);
setSyncInterval(d.sync_interval_hours ?? 4); setSyncInterval(d.sync_interval_hours ?? 4);
setSyncDays(d.sync_days ?? 30); setSyncDays(d.sync_days ?? 30);
setDebugLogging(!!d.debug_logging);
}) })
.catch(err => setLoadError(err.message || 'Failed to load bank sync config')) .catch(err => setLoadError(err.message || 'Failed to load bank sync config'))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
@ -60,11 +68,12 @@ export default function BankSyncAdminCard() {
} }
setSaving(true); setSaving(true);
try { 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); setConfig(result);
setEnabled(!!result.enabled); setEnabled(!!result.enabled);
setSyncInterval(result.sync_interval_hours ?? 4); setSyncInterval(result.sync_interval_hours ?? 4);
setSyncDays(result.sync_days ?? 30); setSyncDays(result.sync_days ?? 30);
setDebugLogging(!!result.debug_logging);
toast.success('Bank sync settings saved.'); 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.');
@ -95,7 +104,8 @@ export default function BankSyncAdminCard() {
const changed = enabled !== !!config?.enabled const changed = enabled !== !!config?.enabled
|| parseFloat(syncInterval) !== config?.sync_interval_hours || 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 worker = config?.worker;
const seedDays = config?.seed_days ?? 44; const seedDays = config?.seed_days ?? 44;
const maxDays = config?.sync_days_max ?? 45; const maxDays = config?.sync_days_max ?? 45;
@ -243,6 +253,22 @@ export default function BankSyncAdminCard() {
</div> </div>
)} )}
{/* Debug logging toggle */}
<div className="flex items-center justify-between border-t border-border/50 pt-4">
<div>
<p className="text-sm font-medium">Debug logging</p>
<p className="text-xs text-muted-foreground mt-0.5">
Logs each account, transaction, and auto-match step to the server console.
Turn on to diagnose sync issues, then off when done.
</p>
</div>
<Toggle
checked={debugLogging}
onChange={v => setDebugLogging(v)}
label="Enable debug logging"
/>
</div>
{/* Encryption note */} {/* Encryption note */}
<p className="text-xs text-muted-foreground border-t border-border/50 pt-3"> <p className="text-xs text-muted-foreground border-t border-border/50 pt-3">
{config?.encryption_key_source === 'env' {config?.encryption_key_source === 'env'

View File

@ -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) { function fmtDate(iso) {
if (!iso) return '—'; if (!iso) return '—';
return new Date(iso).toLocaleString(undefined, { return parseUtc(iso).toLocaleString(undefined, {
month: 'short', day: 'numeric', year: 'numeric', month: 'short', day: 'numeric', year: 'numeric',
hour: '2-digit', minute: '2-digit', hour: '2-digit', minute: '2-digit',
}); });

View File

@ -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) { function timeAgo(iso) {
if (!iso) return null; 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 < 60) return 'just now';
if (secs < 3600) return `${Math.floor(secs / 60)}m ago`; if (secs < 3600) return `${Math.floor(secs / 60)}m ago`;
if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`; if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`;

View File

@ -42,9 +42,16 @@ import { TrackerBucket as Bucket } from '@/components/tracker/TrackerBucket';
import IncomeBreakdownModal from '@/components/IncomeBreakdownModal'; 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) { function fmtBalanceAge(isoStr) {
if (!isoStr) return null; 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()) { function localDateString(date = new Date()) {

View File

@ -1,7 +1,7 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { getDb, getSetting, setSetting, rollbackMigration } = require('../db/database'); 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 { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker');
const { isEnvKeyActive } = require('../services/encryptionService'); const { isEnvKeyActive } = require('../services/encryptionService');
const { hashPassword } = require('../services/authService'); const { hashPassword } = require('../services/authService');
@ -445,12 +445,13 @@ 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, sync_interval_hours, sync_days } = req.body || {}; const { enabled, sync_interval_hours, sync_days, debug_logging } = req.body || {};
try { try {
let config = getBankSyncConfig(); let config = getBankSyncConfig();
if (typeof enabled === 'boolean') config = setBankSyncEnabled(enabled); if (typeof enabled === 'boolean') config = setBankSyncEnabled(enabled);
if (sync_interval_hours !== undefined) config = setSyncIntervalHours(sync_interval_hours); if (sync_interval_hours !== undefined) config = setSyncIntervalHours(sync_interval_hours);
if (sync_days !== undefined) config = setSyncDays(sync_days); if (sync_days !== undefined) config = setSyncDays(sync_days);
if (typeof debug_logging === 'boolean') config = setDebugLogging(debug_logging);
res.json(config); 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' });

View File

@ -37,12 +37,17 @@ function getBankSyncConfig() {
? intervalEnv ? intervalEnv
: SYNC_INTERVAL_DEFAULT; : SYNC_INTERVAL_DEFAULT;
const debugDb = getSetting('simplefin_debug_logging');
const debugEnv = process.env.SIMPLEFIN_DEBUG_LOGGING;
const debugLogging = debugDb === 'true' || (!debugDb && debugEnv === 'true');
return { return {
enabled, enabled,
sync_days: syncDays, sync_days: syncDays,
seed_days: SYNC_DAYS_EFFECTIVE, // initial connect / explicit backfill window seed_days: SYNC_DAYS_EFFECTIVE,
sync_days_max: SYNC_DAYS_MAX, // SimpleFIN Bridge hard limit sync_days_max: SYNC_DAYS_MAX,
sync_interval_hours: syncIntervalHours, sync_interval_hours: syncIntervalHours,
debug_logging: debugLogging,
}; };
} }
@ -69,7 +74,12 @@ function setSyncDays(days) {
return getBankSyncConfig(); return getBankSyncConfig();
} }
function setDebugLogging(enabled) {
setSetting('simplefin_debug_logging', enabled ? 'true' : 'false');
return getBankSyncConfig();
}
module.exports = { module.exports = {
getBankSyncConfig, setBankSyncEnabled, setSyncIntervalHours, setSyncDays, getBankSyncConfig, setBankSyncEnabled, setSyncIntervalHours, setSyncDays, setDebugLogging,
SYNC_DAYS_MAX, SYNC_DAYS_EFFECTIVE, SYNC_DAYS_DEFAULT, SYNC_DAYS_MAX, SYNC_DAYS_EFFECTIVE, SYNC_DAYS_DEFAULT,
}; };

View File

@ -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 accessUrl = decryptSecret(dataSource.encrypted_secret);
const isFirstSync = !dataSource.last_sync_at; const isFirstSync = !dataSource.last_sync_at;
// Explicit `days` param (e.g. backfill) takes precedence. // 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 syncDays = days ?? (isFirstSync ? SYNC_DAYS_EFFECTIVE : (config.sync_days || SYNC_DAYS_DEFAULT));
const since = sinceEpochDays(syncDays); 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 raw = await fetchAccountsAndTransactions(accessUrl, since);
const accounts = Array.isArray(raw.accounts) ? raw.accounts : []; 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) { if (raw._errlistSummary) {
console.warn(`[bankSync] errlist for source ${dataSource.id}: ${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); const localAccount = upsertAccount(db, accountRow);
accountsUpserted += 1; 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; if (localAccount.monitored === 0) continue;
for (const rawTx of (rawAccount.transactions || [])) { for (const rawTx of txList) {
const txRow = normalizeTransaction( const txRow = normalizeTransaction(
rawTx, localAccount.id, dataSource.id, userId, rawAccount.id, rawAccount.currency, rawTx, localAccount.id, dataSource.id, userId, rawAccount.id, rawAccount.currency,
); );
const outcome = insertTransactionIfNew(db, txRow); const outcome = insertTransactionIfNew(db, txRow);
if (outcome === 'inserted') transactionsNew += 1; if (outcome === 'inserted') {
else transactionsSkip += 1; 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 = ? WHERE id = ? AND user_id = ?
`).run(partialError, dataSource.id, userId); `).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 // 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); const { matched: autoMatched, matched_bills: matchedBills, late_attributions: lateAttributions } = applyMerchantRules(db, userId);
try { applySpendingCategoryRules(db, userId); } catch { /* non-blocking */ } try { applySpendingCategoryRules(db, userId); } catch { /* non-blocking */ }
try { autoMatchForUser(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 }; 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 }; return { dataSource: decorateDataSource(fresh), ...syncResult };
} }
async function syncDataSource(db, userId, dataSourceId) { async function syncDataSource(db, userId, dataSourceId, { debug } = {}) {
assertEncryptionReady(); assertEncryptionReady();
const dataSource = db.prepare(` 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) throw Object.assign(new Error('SimpleFIN connection not found'), { status: 404 });
if (!dataSource.encrypted_secret) throw new Error('No stored credentials for this connection'); if (!dataSource.encrypted_secret) throw new Error('No stored credentials for this connection');
const useDebug = debug ?? getBankSyncConfig().debug_logging;
let syncResult; let syncResult;
try { try {
syncResult = await runSync(db, userId, dataSource); syncResult = await runSync(db, userId, dataSource, { debug: useDebug });
} catch (err) { } catch (err) {
const msg = safeErrorMessage(err); const msg = safeErrorMessage(err);
db.prepare(` db.prepare(`

View File

@ -32,8 +32,13 @@ function sleep(ms) {
async function runCycle() { async function runCycle() {
if (running) return; if (running) return;
const { enabled } = getBankSyncConfig(); const config = getBankSyncConfig();
if (!enabled) return; if (!config.enabled) {
console.log('[bankSync] Disabled — skipping cycle');
return;
}
const debug = config.debug_logging;
let db, sources; let db, sources;
try { try {
@ -48,8 +53,12 @@ async function runCycle() {
return; 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; running = true;
lastRunAt = new Date().toISOString(); lastRunAt = new Date().toISOString();
@ -61,26 +70,26 @@ async function runCycle() {
const source = sources[i]; const source = sources[i];
if (!needsSync(source)) { if (!needsSync(source)) {
if (debug) console.log(`[bankSync] Source #${source.id} (user ${source.user_id}): recently synced — skipping`);
skipped++; skipped++;
continue; continue;
} }
if (debug) console.log(`[bankSync] Source #${source.id} (user ${source.user_id}): starting sync`);
try { try {
await syncDataSource(db, source.user_id, source.id); const result = await syncDataSource(db, source.user_id, source.id, { debug });
synced++; synced++;
} catch { console.log(`[bankSync] Source #${source.id}: OK — ${result.accountsUpserted} account(s), ${result.transactionsNew} new, ${result.transactionsSkip} skipped${result.errlist ? ` [partial: ${result.errlist}]` : ''}`);
// syncDataSource already writes last_error to the data_sources row } catch (err) {
failed++; failed++;
console.error(`[bankSync] Source #${source.id}: FAILED — ${err.message}`);
} }
// Stagger requests — don't fire them all simultaneously // Stagger requests — don't fire them all simultaneously
if (i < sources.length - 1) await sleep(STAGGER_DELAY_MS); if (i < sources.length - 1) await sleep(STAGGER_DELAY_MS);
} }
if (synced > 0 || failed > 0) { console.log(`[bankSync] Cycle complete — ${synced} synced, ${failed} failed, ${skipped} skipped`);
console.log(`[bankSync] Auto-sync complete: ${synced} synced, ${failed} failed, ${skipped} skipped`);
}
running = false; running = false;
} }