feat: Sync Bank button on tracker that runs merchant rule matching on all connected sources
This commit is contained in:
parent
1f1c505115
commit
37cf24f5a0
|
|
@ -20,6 +20,8 @@
|
|||
|
||||
- **Cash flow projection** — A new `CashFlowCard` on the Calendar page answers "what will I have left after all my bills clear?" — distinct from the existing remaining balance which only reflects what's already been paid. The card shows two panels in the first half of the month (by period end, by month end) and collapses to one panel in the second half since the dates converge. Progress bars are amount-based (`$420 of $650 paid`) rather than count-based so high-value bills are weighted correctly. When any projection goes negative a prominent red alert banner appears with the shortfall amount and a prompt to review unpaid bills or adjust starting amounts. The "X unpaid →" count is a live link that opens the Tracker pre-filtered to exactly those bills for that period. On TrackerPage the Starting card hint now shows `→ $1,247 projected by Jun 14` when cashflow data is available, surfacing the projection without leaving the tracker view. When bank tracking is active the projection uses the live effective bank balance as its starting point. Backend: a `cashflow` block added to the `trackerService` response containing period and month projections, amount-paid totals, paid/total counts, and an `end_label` string for the period cutoff date.
|
||||
|
||||
- **Sync Bank button on Tracker** — A "Sync Bank" button appears in the TrackerPage header toolbar when three conditions are all true: SimpleFIN Bridge is enabled (admin setting), the user has at least one connected SimpleFIN source, and the user has at least one bill merchant matching rule. Clicking it calls `POST /api/data-sources/sync-all`, which syncs every connected SimpleFIN source in sequence, aggregates the results, and after each source runs `applyMerchantRules` to auto-match new transactions. The button shows a spinning icon while syncing and toasts a specific result: "3 payments matched", "5 new transactions, no automatic matches", or "no new transactions". The tracker refetches automatically so newly matched payments appear without a page reload. `GET /api/data-sources/simplefin/status` was extended with two new fields — `has_connections` and `has_merchant_rules` — so the single status call drives the button's visibility with no additional requests.
|
||||
|
||||
- **Bill bank matching rules** — Bills can now be linked to bank transaction patterns so payments import automatically without manual matching. A new "Bank matching rules" section in the Bill Modal (Transactions tab) shows all existing patterns for a bill as removable chips and lets the user add new ones by typing a merchant name or picking from a dropdown of recent unmatched transactions. As the user types, a live preview badge shows how many existing unmatched transactions the pattern would match (debounced, updates as-you-type). If the pattern is already claimed by another bill a conflict warning appears inline with the other bill's name, prompting the user to be more specific. On save the rule is applied retroactively — `syncBillPaymentsFromSimplefin` runs immediately and a green feedback banner reports how many historical payments were imported (e.g. "3 existing payments imported from your transaction history"). Bills with at least one active rule show a green **Bank** chip in the bill list with a tooltip. Four new endpoints: `GET /api/bills/:id/merchant-rules` (list rules + suggestions), `GET /api/bills/:id/merchant-rules/preview?merchant=X` (match count + conflict check), `POST /api/bills/:id/merchant-rules` (add + retroactive apply), `DELETE /api/bills/:id/merchant-rules/:ruleId` (remove).
|
||||
|
||||
- **SimpleFIN bank budget tracking** — A new opt-in mode replaces manually-entered starting amounts with the live balance of a connected bank account. When enabled from the Bank Sync settings page (Data → Bank Sync → Bank Budget Tracking), the user selects which financial account to track and configures a pending payment window (0–7 days, default 3). Budget remaining is calculated as: `bank balance − pending payments − unpaid bills this month`. Bills already marked paid are not double-counted — the bank balance already reflects them. Payments made within the pending window appear in the tracker with an amber **Pending** badge, flagging that the bank may not have processed the debit yet. The CalendarPage Monthly Money Map switches to four live metrics (Balance / Pending / Unpaid Bills / After Bills) when bank mode is active. The TrackerPage starting-amounts card shows the account name and "live balance" hint; the manual-edit button is hidden since there is nothing to manually set. Implementation: three new `user_settings` keys (`bank_tracking_enabled`, `bank_tracking_account_id`, `bank_tracking_pending_days`), a new `GET /api/data-sources/accounts/all` endpoint for the account picker, `buildBankTrackingSummary()` in both `summary.js` and `trackerService.js`, and `pending_cleared` flag on tracker rows.
|
||||
|
|
|
|||
|
|
@ -350,6 +350,7 @@ export const api = {
|
|||
dataSourceAccounts: (sourceId) => get(`/data-sources/${sourceId}/accounts`),
|
||||
setAccountMonitored: (sourceId, accountId, monitored) => put(`/data-sources/${sourceId}/accounts/${accountId}`, { monitored }),
|
||||
allFinancialAccounts: () => get('/data-sources/accounts/all'),
|
||||
syncAllSources: () => post('/data-sources/sync-all', {}),
|
||||
|
||||
// Admin — bank sync feature flag
|
||||
bankSyncConfig: () => get('/admin/bank-sync-config'),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, X, RefreshCw } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, X, RefreshCw, Landmark } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api.js';
|
||||
import { useTracker, useDriftReport } from '@/hooks/useQueries';
|
||||
|
|
@ -61,6 +61,8 @@ export default function TrackerPage() {
|
|||
}, [setSearchParams]);
|
||||
|
||||
// Edit Bill modal: { bill, categories } when open, null when closed
|
||||
const [bankSyncStatus, setBankSyncStatus] = useState(null);
|
||||
const [bankSyncing, setBankSyncing] = useState(false);
|
||||
const [editBillData, setEditBillData] = useState(null);
|
||||
// Edit Starting Amounts modal: true when open, false when closed
|
||||
const [editStartingOpen, setEditStartingOpen] = useState(false);
|
||||
|
|
@ -79,6 +81,13 @@ export default function TrackerPage() {
|
|||
setMovingBillId(null);
|
||||
}, [dataUpdatedAt, year, month]);
|
||||
|
||||
// Load SimpleFIN status once to decide whether to show the sync button
|
||||
useEffect(() => {
|
||||
api.simplefinStatus()
|
||||
.then(setBankSyncStatus)
|
||||
.catch(() => setBankSyncStatus(null));
|
||||
}, []);
|
||||
|
||||
function navigate(delta) {
|
||||
let nm = month + delta;
|
||||
let ny = year;
|
||||
|
|
@ -87,6 +96,32 @@ export default function TrackerPage() {
|
|||
updateParams({ year: ny, month: nm });
|
||||
}
|
||||
|
||||
async function handleBankSync() {
|
||||
setBankSyncing(true);
|
||||
try {
|
||||
const result = await api.syncAllSources();
|
||||
const matched = result.auto_matched ?? 0;
|
||||
const newTx = result.transactions_new ?? 0;
|
||||
if (matched > 0) {
|
||||
toast.success(`Synced — ${matched} payment${matched === 1 ? '' : 's'} matched${newTx > matched ? `, ${newTx} new transactions` : ''}`);
|
||||
} else if (newTx > 0) {
|
||||
toast.success(`Synced — ${newTx} new transaction${newTx === 1 ? '' : 's'}, no automatic matches`);
|
||||
} else {
|
||||
toast.success('Synced — no new transactions');
|
||||
}
|
||||
refetch();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Bank sync failed');
|
||||
} finally {
|
||||
setBankSyncing(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Show sync button when SimpleFIN is enabled, connected, and user has matching rules
|
||||
const showBankSync = bankSyncStatus?.enabled &&
|
||||
bankSyncStatus?.has_connections &&
|
||||
bankSyncStatus?.has_merchant_rules;
|
||||
|
||||
async function handleOpenEditBill(row) {
|
||||
try {
|
||||
const [bill, categories] = await Promise.all([
|
||||
|
|
@ -227,6 +262,21 @@ export default function TrackerPage() {
|
|||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{showBankSync && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleBankSync}
|
||||
disabled={bankSyncing}
|
||||
className="h-9 gap-1.5 px-3"
|
||||
title="Scan bank transactions and match payments"
|
||||
>
|
||||
{bankSyncing
|
||||
? <RefreshCw className="h-4 w-4 animate-spin" />
|
||||
: <Landmark className="h-4 w-4" />}
|
||||
<span className="hidden sm:inline">{bankSyncing ? 'Syncing…' : 'Sync Bank'}</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleOpenAddBill}
|
||||
|
|
|
|||
|
|
@ -91,7 +91,17 @@ router.get('/', (req, res) => {
|
|||
|
||||
router.get('/simplefin/status', (req, res) => {
|
||||
const { enabled, sync_days } = getBankSyncConfig();
|
||||
res.json({ enabled, sync_days });
|
||||
const db = getDb();
|
||||
|
||||
const hasConnections = !!db.prepare(
|
||||
"SELECT 1 FROM data_sources WHERE user_id = ? AND type = 'provider_sync' AND provider = 'simplefin' LIMIT 1"
|
||||
).get(req.user.id);
|
||||
|
||||
const hasMerchantRules = !!db.prepare(
|
||||
'SELECT 1 FROM bill_merchant_rules WHERE user_id = ? LIMIT 1'
|
||||
).get(req.user.id);
|
||||
|
||||
res.json({ enabled, sync_days, has_connections: hasConnections, has_merchant_rules: hasMerchantRules });
|
||||
});
|
||||
|
||||
// ─── POST /api/data-sources/simplefin/connect ────────────────────────────────
|
||||
|
|
@ -217,6 +227,55 @@ router.post('/:id/sync', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// ─── POST /api/data-sources/sync-all ─────────────────────────────────────────
|
||||
// Syncs every SimpleFIN source for the current user. Returns aggregated stats.
|
||||
|
||||
router.post('/sync-all', async (req, res) => {
|
||||
if (!getBankSyncConfig().enabled) {
|
||||
return res.status(503).json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
|
||||
}
|
||||
|
||||
try {
|
||||
const db = getDb();
|
||||
const sources = db.prepare(
|
||||
"SELECT id FROM data_sources WHERE user_id = ? AND type = 'provider_sync' AND provider = 'simplefin'"
|
||||
).all(req.user.id);
|
||||
|
||||
if (sources.length === 0) {
|
||||
return res.status(404).json(standardizeError('No SimpleFIN connections found', 'NOT_FOUND'));
|
||||
}
|
||||
|
||||
let accountsUpserted = 0;
|
||||
let transactionsNew = 0;
|
||||
let transactionsSkip = 0;
|
||||
let autoMatched = 0;
|
||||
const errors = [];
|
||||
|
||||
for (const source of sources) {
|
||||
try {
|
||||
const result = await syncDataSource(db, req.user.id, source.id);
|
||||
accountsUpserted += result.accountsUpserted ?? 0;
|
||||
transactionsNew += result.transactionsNew ?? 0;
|
||||
transactionsSkip += result.transactionsSkip ?? 0;
|
||||
autoMatched += result.autoMatched ?? 0;
|
||||
} catch (err) {
|
||||
errors.push(sanitizeErrorMessage(err?.message || 'Sync failed'));
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
accounts_upserted: accountsUpserted,
|
||||
transactions_new: transactionsNew,
|
||||
transactions_skip: transactionsSkip,
|
||||
auto_matched: autoMatched,
|
||||
errors,
|
||||
});
|
||||
} catch (err) {
|
||||
const { msg, status } = safeError(err, 'Sync failed');
|
||||
res.status(status).json(standardizeError(msg, 'SIMPLEFIN_ERROR'));
|
||||
}
|
||||
});
|
||||
|
||||
// ─── POST /api/data-sources/:id/backfill ─────────────────────────────────────
|
||||
|
||||
router.post('/:id/backfill', async (req, res) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue