feat: account monitoring, expanded sync UI, match filtering, error toasts
Backend: - v0.64 migration: monitored column on financial_accounts - GET/PUT data-sources accounts endpoints for monitored toggle + tx listing - matchSuggestionService: excludes unmonitored accounts from match scoring Frontend: - BankSyncSection rebuild: accounts panel with monitored switch, expand for last 50 transactions, match status badges, optimistic toggle - TransactionMatchingSection: toast on bills load failure - DataPage: toast on import history load failure - ProfilePage: toast on both login history fetch failures
This commit is contained in:
parent
542ab5e382
commit
262d7789db
|
|
@ -314,6 +314,8 @@ export const api = {
|
|||
connectSimplefin: (setupToken) => post('/data-sources/simplefin/connect', { setupToken }),
|
||||
syncDataSource: (id) => post(`/data-sources/${id}/sync`),
|
||||
deleteDataSource: (id) => del(`/data-sources/${id}`),
|
||||
dataSourceAccounts: (sourceId) => get(`/data-sources/${sourceId}/accounts`),
|
||||
setAccountMonitored: (sourceId, accountId, monitored) => put(`/data-sources/${sourceId}/accounts/${accountId}`, { monitored }),
|
||||
|
||||
// Admin — bank sync feature flag
|
||||
bankSyncConfig: () => get('/admin/bank-sync-config'),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { AlertTriangle, Building2, Eye, EyeOff, ExternalLink, Link2Off, Loader2, RefreshCw } from 'lucide-react';
|
||||
import {
|
||||
AlertTriangle, Building2, ChevronDown, ChevronRight,
|
||||
Eye, EyeOff, ExternalLink, Link2Off, Loader2, RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||
|
|
@ -46,15 +50,167 @@ function TokenInput({ value, onChange, disabled }) {
|
|||
);
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
month: 'short', day: 'numeric', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function fmtShortDate(date) {
|
||||
if (!date) return '—';
|
||||
const d = new Date(`${date}T00:00:00`);
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
function fmtDollars(cents) {
|
||||
if (cents == null) return '—';
|
||||
const abs = Math.abs(cents) / 100;
|
||||
const sign = cents < 0 ? '-' : '';
|
||||
return `${sign}$${abs.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
function MatchBadge({ status }) {
|
||||
if (status === 'matched') {
|
||||
return <span className="text-[10px] font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 px-1.5 py-0.5 rounded-full">matched</span>;
|
||||
}
|
||||
if (status === 'ignored') {
|
||||
return <span className="text-[10px] font-medium text-amber-600 dark:text-amber-400 bg-amber-500/10 px-1.5 py-0.5 rounded-full">ignored</span>;
|
||||
}
|
||||
return <span className="text-[10px] font-medium text-muted-foreground bg-muted/60 px-1.5 py-0.5 rounded-full">unmatched</span>;
|
||||
}
|
||||
|
||||
function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonitored, toggling }) {
|
||||
const txDate = account.transactions?.[0]?.posted_date || account.transactions?.[0]?.transacted_at?.slice(0, 10);
|
||||
|
||||
return (
|
||||
<div className="border-t border-border/40 first:border-t-0">
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-2.5 hover:bg-muted/20 cursor-pointer"
|
||||
onClick={onToggleExpand}
|
||||
>
|
||||
<span className="text-muted-foreground shrink-0" onClick={e => e.stopPropagation()}>
|
||||
{expanded
|
||||
? <ChevronDown className="h-3.5 w-3.5" />
|
||||
: <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
|
||||
{/* Monitored toggle */}
|
||||
<div onClick={e => e.stopPropagation()}>
|
||||
<Switch
|
||||
checked={account.monitored}
|
||||
onCheckedChange={v => onToggleMonitored(account.id, v)}
|
||||
disabled={toggling}
|
||||
aria-label={`Monitor ${account.name}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Account name */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={cn('text-sm font-medium truncate', !account.monitored && 'text-muted-foreground')}>
|
||||
{account.name}
|
||||
</p>
|
||||
{account.org_name && (
|
||||
<p className="text-xs text-muted-foreground truncate">{account.org_name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Balance */}
|
||||
<div className="text-right shrink-0 hidden sm:block">
|
||||
<p className={cn('text-xs font-medium tabular-nums', !account.monitored && 'text-muted-foreground')}>
|
||||
{fmtDollars(account.balance)}
|
||||
</p>
|
||||
{txDate && (
|
||||
<p className="text-[10px] text-muted-foreground">last tx {fmtShortDate(txDate)}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tx count */}
|
||||
<div className="shrink-0 text-right">
|
||||
<p className="text-xs text-muted-foreground tabular-nums">
|
||||
{account.transaction_count} tx
|
||||
</p>
|
||||
{!account.monitored && (
|
||||
<p className="text-[10px] text-muted-foreground/60">skipped</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-border/30 bg-muted/10">
|
||||
{account.transactions.length === 0 ? (
|
||||
<p className="px-6 py-3 text-xs text-muted-foreground italic">No transactions synced for this account.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border/30">
|
||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground whitespace-nowrap">Date</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground">Payee / Description</th>
|
||||
<th className="px-4 py-2 text-right font-medium text-muted-foreground whitespace-nowrap">Amount</th>
|
||||
<th className="px-4 py-2 text-right font-medium text-muted-foreground whitespace-nowrap">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{account.transactions.map(tx => (
|
||||
<tr key={tx.id} className="border-b border-border/20 last:border-b-0 hover:bg-muted/20">
|
||||
<td className="px-4 py-1.5 text-muted-foreground whitespace-nowrap">
|
||||
{fmtShortDate(tx.posted_date || tx.transacted_at?.slice(0, 10))}
|
||||
</td>
|
||||
<td className="px-4 py-1.5 max-w-xs">
|
||||
<p className="truncate font-medium">{tx.payee || tx.description || '—'}</p>
|
||||
{tx.payee && tx.description && tx.payee !== tx.description && (
|
||||
<p className="truncate text-muted-foreground">{tx.description}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className={cn('px-4 py-1.5 text-right tabular-nums whitespace-nowrap font-medium', tx.amount < 0 ? 'text-destructive/80' : 'text-emerald-600 dark:text-emerald-400')}>
|
||||
{fmtDollars(tx.amount)}
|
||||
</td>
|
||||
<td className="px-4 py-1.5 text-right whitespace-nowrap">
|
||||
<MatchBadge status={tx.match_status} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BankSyncSection({ onConnectionChange }) {
|
||||
const [enabled, setEnabled] = useState(null);
|
||||
const [connections, setConnections] = useState([]);
|
||||
const [accountsBySource, setAccountsBySource] = useState({});
|
||||
const [accountsLoading, setAccountsLoading] = useState({});
|
||||
const [accountsErrorBySource, setAccountsError] = useState({});
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [setupToken, setSetupToken] = useState('');
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [syncing, setSyncing] = useState(null);
|
||||
const [disconnectTarget, setDisconnectTarget] = useState(null);
|
||||
const [disconnecting, setDisconnecting] = useState(false);
|
||||
const [expandedAccount, setExpandedAccount] = useState(null);
|
||||
const [togglingAccount, setTogglingAccount] = useState(null);
|
||||
|
||||
const loadAccounts = useCallback(async (conns) => {
|
||||
for (const conn of conns) {
|
||||
setAccountsLoading(prev => ({ ...prev, [conn.id]: true }));
|
||||
setAccountsError(prev => ({ ...prev, [conn.id]: '' }));
|
||||
try {
|
||||
const accounts = await api.dataSourceAccounts(conn.id);
|
||||
setAccountsBySource(prev => ({ ...prev, [conn.id]: accounts }));
|
||||
} catch (err) {
|
||||
setAccountsError(prev => ({ ...prev, [conn.id]: err.message || 'Failed to load accounts' }));
|
||||
} finally {
|
||||
setAccountsLoading(prev => ({ ...prev, [conn.id]: false }));
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoadError('');
|
||||
|
|
@ -67,11 +223,12 @@ export default function BankSyncSection({ onConnectionChange }) {
|
|||
const conns = Array.isArray(sources) ? sources.filter(s => s.provider === 'simplefin') : [];
|
||||
setConnections(conns);
|
||||
onConnectionChange?.(conns[0] || null);
|
||||
if (conns.length > 0) loadAccounts(conns);
|
||||
} catch (err) {
|
||||
setEnabled(false);
|
||||
setLoadError(err.message || 'Failed to load bank sync status');
|
||||
}
|
||||
}, [onConnectionChange]);
|
||||
}, [onConnectionChange, loadAccounts]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
|
|
@ -120,10 +277,30 @@ export default function BankSyncSection({ onConnectionChange }) {
|
|||
}
|
||||
};
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
const handleToggleMonitored = async (sourceId, accountId, monitored) => {
|
||||
setTogglingAccount(accountId);
|
||||
// Optimistic update
|
||||
setAccountsBySource(prev => ({
|
||||
...prev,
|
||||
[sourceId]: (prev[sourceId] || []).map(a =>
|
||||
a.id === accountId ? { ...a, monitored } : a
|
||||
),
|
||||
}));
|
||||
try {
|
||||
await api.setAccountMonitored(sourceId, accountId, monitored);
|
||||
} catch (err) {
|
||||
// Revert on failure
|
||||
setAccountsBySource(prev => ({
|
||||
...prev,
|
||||
[sourceId]: (prev[sourceId] || []).map(a =>
|
||||
a.id === accountId ? { ...a, monitored: !monitored } : a
|
||||
),
|
||||
}));
|
||||
toast.error(err.message || 'Failed to update account');
|
||||
} finally {
|
||||
setTogglingAccount(null);
|
||||
}
|
||||
};
|
||||
|
||||
function isStale(conn) {
|
||||
if (!conn.last_error) return false;
|
||||
|
|
@ -155,7 +332,13 @@ export default function BankSyncSection({ onConnectionChange }) {
|
|||
<div className="px-6 py-3 text-sm text-destructive">{loadError}</div>
|
||||
)}
|
||||
|
||||
{connections.length > 0 && connections.map(conn => (
|
||||
{connections.length > 0 && connections.map(conn => {
|
||||
const accounts = accountsBySource[conn.id] || [];
|
||||
const accsLoading = accountsLoading[conn.id];
|
||||
const accsError = accountsErrorBySource[conn.id];
|
||||
const monitoredCount = accounts.filter(a => a.monitored).length;
|
||||
|
||||
return (
|
||||
<div key={conn.id} className="px-6 py-4 space-y-3">
|
||||
{isStale(conn) && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2.5 text-sm text-amber-700 dark:text-amber-400">
|
||||
|
|
@ -181,6 +364,8 @@ export default function BankSyncSection({ onConnectionChange }) {
|
|||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header row */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Building2 className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
|
|
@ -189,6 +374,7 @@ export default function BankSyncSection({ onConnectionChange }) {
|
|||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{conn.account_count} account{conn.account_count !== 1 ? 's' : ''} ·{' '}
|
||||
{conn.transaction_count} transaction{conn.transaction_count !== 1 ? 's' : ''}
|
||||
{accounts.length > 0 && ` · ${monitoredCount} monitored`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -214,6 +400,7 @@ export default function BankSyncSection({ onConnectionChange }) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sync status grid */}
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 text-xs">
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
|
||||
<p className="text-muted-foreground">Last sync</p>
|
||||
|
|
@ -229,8 +416,44 @@ export default function BankSyncSection({ onConnectionChange }) {
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accounts section */}
|
||||
<div className="rounded-lg border border-border/60 overflow-hidden">
|
||||
<div className="px-4 py-2 bg-muted/20 border-b border-border/40 flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Accounts
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Toggle to include / exclude from bill matching
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{accsLoading ? (
|
||||
<div className="flex items-center gap-2 px-4 py-3 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Loading accounts…
|
||||
</div>
|
||||
) : accsError ? (
|
||||
<p className="px-4 py-3 text-xs text-destructive">{accsError}</p>
|
||||
) : accounts.length === 0 ? (
|
||||
<p className="px-4 py-3 text-xs text-muted-foreground italic">No accounts found.</p>
|
||||
) : (
|
||||
accounts.map(account => (
|
||||
<AccountRow
|
||||
key={account.id}
|
||||
account={account}
|
||||
sourceId={conn.id}
|
||||
expanded={expandedAccount === account.id}
|
||||
onToggleExpand={() => setExpandedAccount(prev => prev === account.id ? null : account.id)}
|
||||
onToggleMonitored={(accountId, monitored) => handleToggleMonitored(conn.id, accountId, monitored)}
|
||||
toggling={togglingAccount === account.id}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{connections.length === 0 && (
|
||||
<div className="px-6 py-5 space-y-4">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.32.0",
|
||||
"version": "0.33.0",
|
||||
"description": "Monthly bill tracking system",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 = ?');
|
||||
|
|
|
|||
Loading…
Reference in New Issue