fix(bank-sync): admin config, matching, and worker updates
This commit is contained in:
parent
68aa5eff31
commit
31be51e77f
|
|
@ -822,29 +822,33 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
|||
|
||||
{/* Debt / Snowball Details — collapsible */}
|
||||
<div className="col-span-2">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn('h-3.5 w-3.5 transition-transform duration-150', !showDebtSection && '-rotate-90')}
|
||||
/>
|
||||
Debt / Snowball Details
|
||||
{isSnowballCategory && (
|
||||
<span className="ml-1 text-[9px] text-emerald-400 font-semibold tracking-normal normal-case">
|
||||
· snowball auto-detected
|
||||
</span>
|
||||
)}
|
||||
{!showOnSnowball && isSnowballCategory && (
|
||||
<span className="ml-1 text-[9px] text-amber-400 font-semibold tracking-normal normal-case">
|
||||
· exempt
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDebtSection(s => !s)}
|
||||
className="flex items-center gap-1.5 text-[11px] uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn('h-3 w-3 transition-transform duration-150', !showDebtSection && '-rotate-90')}
|
||||
/>
|
||||
Debt / Snowball Details
|
||||
{isSnowballCategory && (
|
||||
<span className="text-[9px] text-emerald-400 font-semibold tracking-normal normal-case">
|
||||
· auto-detected
|
||||
</span>
|
||||
)}
|
||||
{!showOnSnowball && isSnowballCategory && (
|
||||
<span className="text-[9px] text-amber-400 font-semibold tracking-normal normal-case">
|
||||
· exempt
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
</div>
|
||||
|
||||
{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 */}
|
||||
<div className="space-y-1.5">
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</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 */}
|
||||
<p className="text-xs text-muted-foreground border-t border-border/50 pt-3">
|
||||
{config?.encryption_key_source === 'env'
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(`
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue