-
-
-
{conn.name}
-
- {conn.account_count} account{conn.account_count !== 1 ? 's' : ''} ·{' '}
- {conn.transaction_count} transaction{conn.transaction_count !== 1 ? 's' : ''}
+
+ {/* Sync status grid */}
+
+
+
Last sync
+
{fmtDate(conn.last_sync_at)}
+
+
+
Status
+
+ {conn.last_error ? conn.last_error : (conn.status === 'active' ? 'Active' : conn.status)}
-
-
-
-
-
-
-
-
Last sync
-
{fmtDate(conn.last_sync_at)}
-
-
-
Status
-
- {conn.last_error ? conn.last_error : (conn.status === 'active' ? 'Active' : conn.status)}
-
+ {/* Accounts section */}
+
+
+
+ Accounts
+
+
+ Toggle to include / exclude from bill matching
+
+
+
+ {accsLoading ? (
+
+
+ Loading accounts…
+
+ ) : accsError ? (
+
{accsError}
+ ) : accounts.length === 0 ? (
+
No accounts found.
+ ) : (
+ accounts.map(account => (
+
setExpandedAccount(prev => prev === account.id ? null : account.id)}
+ onToggleMonitored={(accountId, monitored) => handleToggleMonitored(conn.id, accountId, monitored)}
+ toggling={togglingAccount === account.id}
+ />
+ ))
+ )}
-
- ))}
+ );
+ })}
{connections.length === 0 && (
diff --git a/client/components/data/TransactionMatchingSection.jsx b/client/components/data/TransactionMatchingSection.jsx
index 00a96ed..465d6be 100644
--- a/client/components/data/TransactionMatchingSection.jsx
+++ b/client/components/data/TransactionMatchingSection.jsx
@@ -352,8 +352,9 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn }
try {
const data = await api.bills();
setBills(data || []);
- } catch {
+ } catch (err) {
setBills([]);
+ toast.error(err.message || 'Failed to load bills for matching.');
} finally {
setBillsLoading(false);
}
diff --git a/client/pages/DataPage.jsx b/client/pages/DataPage.jsx
index 33eee63..1ca8620 100644
--- a/client/pages/DataPage.jsx
+++ b/client/pages/DataPage.jsx
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react';
+import { toast } from 'sonner';
import { api } from '@/api';
import BankSyncSection from '@/components/data/BankSyncSection';
import ImportTransactionCsvSection from '@/components/data/ImportTransactionCsvSection';
@@ -20,8 +21,9 @@ export default function DataPage() {
try {
const { history } = await api.importHistory();
setHistory(history);
- } catch {
+ } catch (err) {
setHistory([]);
+ toast.error(err.message || 'Failed to load import history.');
} finally {
setHistoryLoading(false);
}
diff --git a/client/pages/ProfilePage.jsx b/client/pages/ProfilePage.jsx
index c4e6193..a6d703f 100644
--- a/client/pages/ProfilePage.jsx
+++ b/client/pages/ProfilePage.jsx
@@ -108,7 +108,10 @@ function LoginHistoryModal({ history: providedHistory, open, onClose, onLoaded }
setHistory(rows);
onLoaded?.(rows);
})
- .catch(() => setHistory([]))
+ .catch(err => {
+ setHistory([]);
+ toast.error(err.message || 'Failed to load login history.');
+ })
.finally(() => setLoading(false));
}, [open, providedHistory, onLoaded]);
@@ -241,7 +244,10 @@ function ProfileSummary({ profile, loading }) {
setHistoryLoading(true);
api.loginHistory()
.then(d => setLoginHistory(d.history ?? []))
- .catch(() => setLoginHistory([]))
+ .catch(err => {
+ setLoginHistory([]);
+ toast.error(err.message || 'Failed to load login history.');
+ })
.finally(() => setHistoryLoading(false));
}, [loading]);
diff --git a/db/database.js b/db/database.js
index e5b1927..e5c7d85 100644
--- a/db/database.js
+++ b/db/database.js
@@ -1085,6 +1085,21 @@ function reconcileLegacyMigrations() {
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_subscription ON bills(user_id, is_subscription, active)');
console.log('[migration] bills: subscription metadata columns added');
}
+ },
+ {
+ version: 'v0.64',
+ description: 'financial_accounts: monitored flag for bill matching',
+ check: function() {
+ const cols = db.prepare('PRAGMA table_info(financial_accounts)').all().map(c => c.name);
+ return cols.includes('monitored');
+ },
+ run: function() {
+ const cols = db.prepare('PRAGMA table_info(financial_accounts)').all().map(c => c.name);
+ if (!cols.includes('monitored')) {
+ db.exec('ALTER TABLE financial_accounts ADD COLUMN monitored INTEGER NOT NULL DEFAULT 1');
+ console.log('[migration] financial_accounts: monitored column added');
+ }
+ }
}
];
@@ -1848,6 +1863,18 @@ function runMigrations() {
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_subscription ON bills(user_id, is_subscription, active)');
console.log('[migration] bills: subscription metadata columns added');
}
+ },
+ {
+ version: 'v0.64',
+ description: 'financial_accounts: monitored flag for bill matching',
+ dependsOn: ['v0.63'],
+ run: function() {
+ const cols = db.prepare('PRAGMA table_info(financial_accounts)').all().map(c => c.name);
+ if (!cols.includes('monitored')) {
+ db.exec('ALTER TABLE financial_accounts ADD COLUMN monitored INTEGER NOT NULL DEFAULT 1');
+ console.log('[migration] financial_accounts: monitored column added');
+ }
+ }
}
];
diff --git a/package.json b/package.json
index 41e7540..31bbc2c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "bill-tracker",
- "version": "0.32.0",
+ "version": "0.33.0",
"description": "Monthly bill tracking system",
"main": "server.js",
"scripts": {
diff --git a/routes/dataSources.js b/routes/dataSources.js
index 3f4d73a..ec0cd1f 100644
--- a/routes/dataSources.js
+++ b/routes/dataSources.js
@@ -88,6 +88,82 @@ router.post('/simplefin/connect', async (req, res) => {
}
});
+// ─── GET /api/data-sources/:sourceId/accounts ────────────────────────────────
+
+router.get('/:sourceId/accounts', (req, res) => {
+ const sourceId = parseInt(req.params.sourceId, 10);
+ if (!Number.isInteger(sourceId) || sourceId < 1) {
+ return res.status(400).json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'sourceId'));
+ }
+
+ try {
+ const db = getDb();
+
+ const source = db.prepare('SELECT id FROM data_sources WHERE id = ? AND user_id = ?').get(sourceId, req.user.id);
+ if (!source) return res.status(404).json(standardizeError('Data source not found', 'NOT_FOUND'));
+
+ const accounts = db.prepare(`
+ SELECT
+ fa.id, fa.provider_account_id, fa.name, fa.org_name, fa.account_type,
+ fa.balance, fa.available_balance, fa.currency, fa.monitored,
+ fa.created_at, fa.updated_at,
+ COUNT(t.id) AS transaction_count
+ FROM financial_accounts fa
+ LEFT JOIN transactions t ON t.account_id = fa.id AND t.user_id = fa.user_id
+ WHERE fa.data_source_id = ? AND fa.user_id = ?
+ GROUP BY fa.id
+ ORDER BY fa.name COLLATE NOCASE ASC
+ `).all(sourceId, req.user.id);
+
+ const txStmt = db.prepare(`
+ SELECT id, posted_date, transacted_at, amount, currency, payee, description, memo, match_status, ignored
+ FROM transactions
+ WHERE account_id = ? AND user_id = ?
+ ORDER BY COALESCE(posted_date, substr(transacted_at, 1, 10), created_at) DESC, id DESC
+ LIMIT 50
+ `);
+
+ const result = accounts.map(acc => ({
+ ...acc,
+ monitored: acc.monitored === 1,
+ transactions: txStmt.all(acc.id, req.user.id),
+ }));
+
+ res.json(result);
+ } catch (err) {
+ res.status(500).json(standardizeError(err.message || 'Failed to load accounts', 'DB_ERROR'));
+ }
+});
+
+// ─── PUT /api/data-sources/:sourceId/accounts/:accountId ─────────────────────
+
+router.put('/:sourceId/accounts/:accountId', (req, res) => {
+ const sourceId = parseInt(req.params.sourceId, 10);
+ const accountId = parseInt(req.params.accountId, 10);
+ if (!Number.isInteger(sourceId) || sourceId < 1 || !Number.isInteger(accountId) || accountId < 1) {
+ return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR'));
+ }
+ if (typeof req.body?.monitored !== 'boolean') {
+ return res.status(400).json(standardizeError('monitored must be a boolean', 'VALIDATION_ERROR', 'monitored'));
+ }
+
+ try {
+ const db = getDb();
+ const result = db.prepare(`
+ UPDATE financial_accounts
+ SET monitored = ?, updated_at = datetime('now')
+ WHERE id = ? AND data_source_id = ? AND user_id = ?
+ `).run(req.body.monitored ? 1 : 0, accountId, sourceId, req.user.id);
+
+ if (result.changes === 0) return res.status(404).json(standardizeError('Account not found', 'NOT_FOUND'));
+
+ const account = db.prepare('SELECT id, name, monitored FROM financial_accounts WHERE id = ?').get(accountId);
+ res.json({ ...account, monitored: account.monitored === 1 });
+ } catch (err) {
+ res.status(500).json(standardizeError(err.message || 'Failed to update account', 'DB_ERROR'));
+ }
+});
+
// ─── POST /api/data-sources/:id/sync ─────────────────────────────────────────
router.post('/:id/sync', async (req, res) => {
diff --git a/services/matchSuggestionService.js b/services/matchSuggestionService.js
index e80fa67..c2e513e 100644
--- a/services/matchSuggestionService.js
+++ b/services/matchSuggestionService.js
@@ -174,6 +174,7 @@ function loadCandidateTransactions(db, userId, transactionId = null) {
't.user_id = ?',
't.ignored = 0',
"t.match_status = 'unmatched'",
+ '(t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)',
];
if (transactionId) {
where.push('t.id = ?');