-
+ {/* ββ Health banner ββββββββββββββββββββββββββββββββββββββββββββββ */}
+ {!loading && data && (
+
)}
- {!loading && (
+ {/* ββ Loading skeleton βββββββββββββββββββββββββββββββββββββββββββ */}
+ {loading ? (
<>
-
-
- Operations
-
+ {[3, 5].map((count, si) => (
+
+
+
+ {Array.from({ length: count }).map((_, i) => )}
+
+
+ ))}
+ >
+ ) : (
+ <>
+
+ {/* ββ Infrastructure βββββββββββββββββββββββββββββββββββββββββββ */}
+
Infrastructure
+
+
+
+
+
+
+
+
+
+
+
+ {dbOk
+ ?
+ : }
+
+
+
+
+
+
+
+
+
-
+ {/* ββ Services βββββββββββββββββββββββββββββββββββββββββββββββββ */}
+
Services
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* ββ App Health βββββββββββββββββββββββββββββββββββββββββββββββ */}
+
App Health
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* ββ Software βββββββββββββββββββββββββββββββββββββββββββββββββ */}
+
Software
+
{updateData && (
)}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {recentErrors.length ? (
- recentErrors.slice(0, 4).map((err, index) => (
-
-
{err.source ?? err.type ?? 'Application'}
-
- {err.message ?? String(err)}
-
-
- ))
- ) : (
- <>
-
-
- >
- )}
-
+
+
+ {/* ββ Errors βββββββββββββββββββββββββββββββββββββββββββββββββββ */}
+
Errors
+
+ {recentErrors.length ? (
+ recentErrors.slice(0, 5).map((err, i) => (
+
+
+
+ {err.source ?? err.type ?? 'Application'}
+
+ {err.timestamp && (
+
+ {formatDateTime(err.timestamp)}
+
+ )}
+
+
+ {err.message ?? String(err)}
+
+
+ ))
+ ) : (
+ No recent errors recorded.
+ )}
+
+
>
)}
-
- {/* Release Notes */}
-
-
);
}
diff --git a/package.json b/package.json
index 4d26949..12bcf9b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "bill-tracker",
- "version": "0.33.8.3",
+ "version": "0.33.8.4",
"description": "Monthly bill tracking system",
"main": "server.js",
"scripts": {
diff --git a/routes/dataSources.js b/routes/dataSources.js
index ba075fc..0546f2b 100644
--- a/routes/dataSources.js
+++ b/routes/dataSources.js
@@ -4,7 +4,7 @@ const router = require('express').Router();
const { getDb } = require('../db/database');
const { standardizeError } = require('../middleware/errorFormatter');
const { decorateDataSource, ensureManualDataSource } = require('../services/transactionService');
-const { connectSimplefin, syncDataSource, disconnectDataSource } = require('../services/bankSyncService');
+const { connectSimplefin, syncDataSource, backfillDataSource, disconnectDataSource } = require('../services/bankSyncService');
const { sanitizeErrorMessage } = require('../services/simplefinService');
const { getBankSyncConfig } = require('../services/bankSyncConfigService');
@@ -189,6 +189,28 @@ router.post('/:id/sync', async (req, res) => {
}
});
+// βββ POST /api/data-sources/:id/backfill βββββββββββββββββββββββββββββββββββββ
+
+router.post('/:id/backfill', async (req, res) => {
+ if (!getBankSyncConfig().enabled) {
+ return res.status(503).json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
+ }
+
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isInteger(id) || id < 1) {
+ return res.status(400).json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'id'));
+ }
+
+ try {
+ const db = getDb();
+ const result = await backfillDataSource(db, req.user.id, id);
+ res.json(result);
+ } catch (err) {
+ const { msg, status } = safeError(err, 'Backfill failed');
+ res.status(status).json(standardizeError(msg, err?.code || 'SIMPLEFIN_ERROR'));
+ }
+});
+
// βββ DELETE /api/data-sources/:id ββββββββββββββββββββββββββββββββββββββββββββ
router.delete('/:id', (req, res) => {
diff --git a/routes/status.js b/routes/status.js
index 746ee5d..6bd2fe4 100644
--- a/routes/status.js
+++ b/routes/status.js
@@ -282,7 +282,8 @@ router.get('/', async (req, res) => {
`).get();
const errorRow = db.prepare(`
SELECT last_error FROM data_sources
- WHERE type = 'provider_sync' AND provider = 'simplefin' AND last_error IS NOT NULL
+ WHERE type = 'provider_sync' AND provider = 'simplefin'
+ AND status = 'error' AND last_error IS NOT NULL
ORDER BY updated_at DESC LIMIT 1
`).get();
bankSync = {
diff --git a/services/bankSyncService.js b/services/bankSyncService.js
index 11424c0..47fc4f6 100644
--- a/services/bankSyncService.js
+++ b/services/bankSyncService.js
@@ -12,9 +12,11 @@ const { getBankSyncConfig } = require('./bankSyncConfigService');
const { decorateDataSource } = require('./transactionService');
const { applyMerchantRules } = require('./billMerchantRuleService');
-function sinceEpoch() {
- const { sync_days } = getBankSyncConfig();
- return Math.floor((Date.now() - sync_days * 86400 * 1000) / 1000);
+const SEED_SYNC_DAYS = 89; // Initial connect / explicit backfill (SimpleFIN Bridge ~90-day cap)
+const ROUTINE_SYNC_DAYS = 30; // Auto-sync and manual "Sync Now"
+
+function sinceEpochDays(days) {
+ return Math.floor((Date.now() - days * 86400 * 1000) / 1000);
}
function safeErrorMessage(err) {
@@ -78,9 +80,11 @@ function insertTransactionIfNew(db, txRow) {
}
}
-async function runSync(db, userId, dataSource) {
+async function runSync(db, userId, dataSource, { days } = {}) {
const accessUrl = decryptSecret(dataSource.encrypted_secret);
- const since = sinceEpoch();
+ const isFirstSync = !dataSource.last_sync_at;
+ const syncDays = days ?? (isFirstSync ? SEED_SYNC_DAYS : ROUTINE_SYNC_DAYS);
+ const since = sinceEpochDays(syncDays);
const raw = await fetchAccountsAndTransactions(accessUrl, since);
const accounts = Array.isArray(raw.accounts) ? raw.accounts : [];
@@ -184,6 +188,33 @@ async function syncDataSource(db, userId, dataSourceId) {
return { dataSource: decorateDataSource(fresh), ...syncResult };
}
+async function backfillDataSource(db, userId, dataSourceId) {
+ assertEncryptionReady();
+
+ const dataSource = db.prepare(`
+ SELECT * FROM data_sources
+ WHERE id = ? AND user_id = ? AND type = 'provider_sync' AND provider = 'simplefin'
+ `).get(dataSourceId, userId);
+
+ 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');
+
+ let syncResult;
+ try {
+ syncResult = await runSync(db, userId, dataSource, { days: SEED_SYNC_DAYS });
+ } catch (err) {
+ const msg = safeErrorMessage(err);
+ db.prepare(`
+ UPDATE data_sources SET last_error = ?, status = 'error', updated_at = datetime('now')
+ WHERE id = ?
+ `).run(msg, dataSourceId);
+ throw err;
+ }
+
+ const fresh = db.prepare('SELECT * FROM data_sources WHERE id = ? AND user_id = ?').get(dataSourceId, userId);
+ return { dataSource: decorateDataSource(fresh), ...syncResult };
+}
+
function disconnectDataSource(db, userId, dataSourceId) {
const row = db.prepare(`
SELECT id FROM data_sources WHERE id = ? AND user_id = ? AND provider = 'simplefin'
@@ -195,4 +226,4 @@ function disconnectDataSource(db, userId, dataSourceId) {
db.prepare('DELETE FROM data_sources WHERE id = ? AND user_id = ?').run(dataSourceId, userId);
}
-module.exports = { connectSimplefin, syncDataSource, disconnectDataSource };
+module.exports = { connectSimplefin, syncDataSource, backfillDataSource, disconnectDataSource };