feat: bill bank matching rules with pattern preview, conflict detection, retroactive payment sync

- Merchant rules link bills to bank transaction patterns for auto-import
- Live preview badge shows match count as user types merchant name
- Inline conflict warning if another bill already owns that pattern
- Retroactive sync on save — imports historical payments immediately
- Green Bank chip on bill list items with active rules
- New endpoints: GET/POST/DELETE merchant-rules + GET preview
This commit is contained in:
null 2026-06-03 21:21:38 -05:00
parent 690a86611a
commit a0fe7880df
6 changed files with 499 additions and 2 deletions

View File

@ -16,6 +16,8 @@
### ✨ Features
- **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 (07 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.
- **404 page** — Unknown routes previously silently redirected to `/` with no feedback. Replaced both catch-all routes (`path="*"` inside the auth layout and at the top level) with a dedicated `NotFoundPage`. The page is standalone (no sidebar), theme-aware, and works for authenticated and unauthenticated users alike. Design features: a glitch counter that cycles each digit of "404" through random numbers before snapping to value (staggered by 180 ms per digit), a CSS mesh gradient background with primary-color radial glows, a 48 px grid overlay faded with a radial mask, a gradient clip-text `404` that scales from `6rem` to `14rem` via `clamp()`, and smart CTAs — "Go back" only appears when browser history exists, and the home button adapts to auth state. The bad path is shown inline in a `<code>` tag so the user knows what they typed.

View File

@ -178,7 +178,11 @@ export const api = {
togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data),
billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`),
billTransactions: (id) => get(`/bills/${id}/transactions`),
syncBillSimplefinPayments: (id) => post(`/bills/${id}/sync-simplefin-payments`),
syncBillSimplefinPayments: (id) => post(`/bills/${id}/sync-simplefin-payments`),
billMerchantRules: (id) => get(`/bills/${id}/merchant-rules`),
previewMerchantRule: (id, merchant) => get(`/bills/${id}/merchant-rules/preview?merchant=${encodeURIComponent(merchant)}`),
addMerchantRule: (id, merchant) => post(`/bills/${id}/merchant-rules`, { merchant }),
deleteMerchantRule: (id, ruleId) => del(`/bills/${id}/merchant-rules/${ruleId}`),
billMonthlyState: (id, y, m) => get(`/bills/${id}/monthly-state?year=${y}&month=${m}`),
saveBillMonthlyState: (id, data) => put(`/bills/${id}/monthly-state`, data),
billHistoryRanges: (id) => get(`/bills/${id}/history-ranges`),

View File

@ -0,0 +1,310 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { toast } from 'sonner';
import {
AlertTriangle, Building2, CheckCircle2, Loader2, Plus, Tag, Trash2, X,
} from 'lucide-react';
import { api } from '@/api';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
// Debounce helper
function useDebounce(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(t);
}, [value, delay]);
return debounced;
}
function RuleChip({ rule, onDelete, deleting }) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full border border-primary/25 bg-primary/8 px-2.5 py-1 text-xs font-medium text-primary">
<Tag className="h-3 w-3 shrink-0" />
<span className="max-w-[12rem] truncate" title={rule.merchant}>{rule.merchant}</span>
<button
type="button"
onClick={() => onDelete(rule)}
disabled={deleting === rule.id}
className="ml-0.5 rounded-full p-0.5 opacity-60 transition-opacity hover:opacity-100 disabled:opacity-30"
aria-label={`Remove rule "${rule.merchant}"`}
>
{deleting === rule.id
? <Loader2 className="h-3 w-3 animate-spin" />
: <X className="h-3 w-3" />}
</button>
</span>
);
}
function ConflictWarning({ conflicts }) {
if (!conflicts?.length) return null;
return (
<div className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/8 px-3 py-2 text-xs text-amber-700 dark:text-amber-400">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
This pattern is already used by{' '}
{conflicts.map((c, i) => (
<span key={c.id}>
{i > 0 && ', '}
<strong>{c.name}</strong>
</span>
))}.
Transactions could match both bills consider making your pattern more specific.
</span>
</div>
);
}
function PreviewBadge({ count, loading }) {
if (loading) return <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />;
if (count === null) return null;
return (
<span className={cn(
'rounded-full border px-2 py-0.5 text-[10px] font-semibold tabular-nums',
count > 0
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-border/60 bg-muted/40 text-muted-foreground',
)}>
{count === 0 ? 'No matches' : `${count} match${count === 1 ? '' : 'es'}`}
</span>
);
}
export default function BillMerchantRules({ billId, onRulesChanged }) {
const [rules, setRules] = useState([]);
const [suggestions, setSuggestions] = useState([]);
const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState(null);
const [adding, setAdding] = useState(false);
const [input, setInput] = useState('');
const [showSuggestions, setShowSuggestions] = useState(false);
const [previewCount, setPreviewCount] = useState(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [conflicts, setConflicts] = useState([]);
const [retroFeedback, setRetroFeedback] = useState(null);
const inputRef = useRef(null);
const dropdownRef = useRef(null);
const debouncedInput = useDebounce(input.trim(), 380);
const load = useCallback(async () => {
if (!billId) return;
setLoading(true);
try {
const data = await api.billMerchantRules(billId);
setRules(data.rules || []);
setSuggestions(data.suggestions || []);
} catch {
// non-fatal
} finally {
setLoading(false);
}
}, [billId]);
useEffect(() => { load(); }, [load]);
// Preview debounced input
useEffect(() => {
if (!debouncedInput || debouncedInput.length < 2) {
setPreviewCount(null);
setConflicts([]);
return;
}
let cancelled = false;
setPreviewLoading(true);
api.previewMerchantRule(billId, debouncedInput)
.then(data => {
if (cancelled) return;
setPreviewCount(data.match_count);
setConflicts(data.conflicts || []);
})
.catch(() => {})
.finally(() => { if (!cancelled) setPreviewLoading(false); });
return () => { cancelled = true; };
}, [debouncedInput, billId]);
// Close suggestion dropdown on outside click
useEffect(() => {
function handler(e) {
if (!dropdownRef.current?.contains(e.target) && !inputRef.current?.contains(e.target)) {
setShowSuggestions(false);
}
}
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
async function handleAdd(merchantText) {
const text = (merchantText || input).trim();
if (!text) return;
setAdding(true);
setRetroFeedback(null);
try {
const result = await api.addMerchantRule(billId, text);
setRules(prev => {
if (prev.some(r => r.id === result.rule?.id)) return prev;
return [...prev, result.rule].filter(Boolean);
});
setInput('');
setPreviewCount(null);
setConflicts([]);
setShowSuggestions(false);
if (result.retroactive_matches > 0) {
setRetroFeedback(result.retroactive_matches);
toast.success(`Rule added — ${result.retroactive_matches} past payment${result.retroactive_matches === 1 ? '' : 's'} imported`);
} else {
toast.success('Rule added — will match future transactions automatically');
}
onRulesChanged?.();
} catch (err) {
toast.error(err.message || 'Failed to add rule');
} finally {
setAdding(false);
}
}
async function handleDelete(rule) {
setDeleting(rule.id);
try {
await api.deleteMerchantRule(billId, rule.id);
setRules(prev => prev.filter(r => r.id !== rule.id));
toast.success(`Rule "${rule.merchant}" removed`);
onRulesChanged?.();
} catch (err) {
toast.error(err.message || 'Failed to remove rule');
} finally {
setDeleting(null);
}
}
function pickSuggestion(s) {
setInput(s.label);
setShowSuggestions(false);
inputRef.current?.focus();
}
// Filter suggestions: not already a rule, and not already matched to something
const filteredSuggestions = suggestions.filter(s =>
!rules.some(r => r.merchant === s.normalized) &&
(input.length < 2 || s.label.toLowerCase().includes(input.toLowerCase()))
).slice(0, 8);
if (loading) {
return (
<div className="flex items-center gap-2 px-1 py-3 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Loading matching rules
</div>
);
}
return (
<div className="space-y-3">
{/* Existing rules */}
{rules.length > 0 && (
<div className="flex flex-wrap gap-2">
{rules.map(rule => (
<RuleChip
key={rule.id}
rule={rule}
onDelete={handleDelete}
deleting={deleting}
/>
))}
</div>
)}
{/* Retroactive feedback */}
{retroFeedback !== null && (
<div className="flex items-center gap-2 rounded-md border border-emerald-500/25 bg-emerald-500/8 px-3 py-2 text-xs text-emerald-700 dark:text-emerald-400">
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
{retroFeedback} existing payment{retroFeedback === 1 ? '' : 's'} imported from your transaction history.
</div>
)}
{/* Add rule input */}
<div className="relative">
<div className="flex gap-2">
<div className="relative flex-1">
<Input
ref={inputRef}
value={input}
onChange={e => { setInput(e.target.value); setShowSuggestions(true); setRetroFeedback(null); }}
onFocus={() => setShowSuggestions(true)}
onKeyDown={e => {
if (e.key === 'Enter') { e.preventDefault(); handleAdd(); }
if (e.key === 'Escape') setShowSuggestions(false);
}}
placeholder="Type merchant name or pick from recent transactions…"
className="h-8 pr-20 text-xs"
disabled={adding}
/>
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
<PreviewBadge count={previewCount} loading={previewLoading} />
</div>
</div>
<Button
type="button"
size="sm"
className="h-8 gap-1.5 px-3 text-xs"
disabled={adding || input.trim().length < 2}
onClick={() => handleAdd()}
>
{adding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
Add
</Button>
</div>
{/* Conflict warning */}
{conflicts.length > 0 && input.trim().length >= 2 && (
<div className="mt-1.5">
<ConflictWarning conflicts={conflicts} />
</div>
)}
{/* Suggestions dropdown */}
{showSuggestions && filteredSuggestions.length > 0 && (
<div
ref={dropdownRef}
className="absolute left-0 right-14 top-full z-50 mt-1 overflow-hidden rounded-lg border border-border/80 bg-card shadow-md"
>
<p className="border-b border-border/50 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Recent unmatched transactions
</p>
<div className="max-h-48 overflow-y-auto">
{filteredSuggestions.map(s => {
const amountVal = Math.abs(Number(s.amount || 0)) / 100;
return (
<button
key={s.id}
type="button"
className="flex w-full items-center gap-3 px-3 py-2 text-left text-xs hover:bg-muted/50 focus:bg-muted/50 focus:outline-none"
onClick={() => pickSuggestion(s)}
>
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-medium">{s.label}</span>
<span className="shrink-0 font-mono text-muted-foreground tabular-nums">
${amountVal.toFixed(2)}
</span>
</button>
);
})}
</div>
</div>
)}
</div>
{/* Empty state */}
{rules.length === 0 && !input && (
<p className="text-[11px] text-muted-foreground/70">
No rules yet. Type a merchant name or pick a recent transaction above to automatically import future payments for this bill.
</p>
)}
</div>
);
}

View File

@ -16,6 +16,7 @@ import {
} from '@/components/ui/select';
import { api } from '@/api';
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
import BillMerchantRules from '@/components/BillMerchantRules';
import {
BILLING_SCHEDULE_OPTIONS,
billingCycleForSchedule,
@ -1039,6 +1040,27 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
</div>
)}
{/* Bank Matching Rules */}
{!isNew && (
<div className="mt-4 rounded-lg border border-border/60 bg-background/35">
<div className="border-b border-border/50 px-3 py-2.5">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Bank matching rules</p>
<p className="mt-0.5 text-[11px] text-muted-foreground/70">
Transactions whose description contains these patterns are automatically imported as payments.
</p>
</div>
<div className="px-3 py-3">
<BillMerchantRules
billId={sourceBill?.id}
onRulesChanged={() => {
refetch?.();
loadLinkedTransactions?.();
}}
/>
</div>
</div>
)}
<div className="mt-4 rounded-lg border border-border/60 bg-background/35">
<div className="flex items-center justify-between gap-3 border-b border-border/50 px-3 py-2.5">
<div className="min-w-0">

View File

@ -119,6 +119,14 @@ function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory,
S
</span>
)}
{!!bill.has_merchant_rule && (
<span
className="shrink-0 rounded border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400"
title="Bank matching rule active — transactions import automatically"
>
Bank
</span>
)}
{hasHistory && (
<span
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-sky-500/25 bg-sky-500/10 text-sky-500"

View File

@ -13,7 +13,8 @@ const {
const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService');
const { standardizeError } = require('../middleware/errorFormatter');
const { validatePaymentInput } = require('../services/paymentValidation');
const { syncBillPaymentsFromSimplefin } = require('../services/billMerchantRuleService');
const { addMerchantRule, syncBillPaymentsFromSimplefin } = require('../services/billMerchantRuleService');
const { normalizeMerchant } = require('../services/subscriptionService');
const { decorateTransaction } = require('../services/transactionService');
// ── GET /api/bills ────────────────────────────────────────────────────────────
@ -892,4 +893,154 @@ router.patch('/:id/balance', (req, res) => {
res.json({ id: billId, current_balance: val });
});
// ── Merchant rule helpers ─────────────────────────────────────────────────────
function requireBill(db, billId, userId) {
return db.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, userId);
}
// Count unmatched transactions that would match a normalized merchant string.
function previewMatchCount(db, userId, normalized) {
if (!normalized || normalized.length < 2) return 0;
const txRows = db.prepare(`
SELECT t.payee, t.description, t.memo
FROM transactions t
LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
WHERE t.user_id = ?
AND t.match_status = 'unmatched'
AND t.ignored = 0
AND t.amount < 0
AND (t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)
`).all(userId);
return txRows.filter(tx => {
const txMerchant = normalizeMerchant(tx.payee || tx.description || tx.memo || '');
return txMerchant && (txMerchant.includes(normalized) || normalized.includes(txMerchant));
}).length;
}
// Find bills (other than this one) that already claim this merchant.
function findConflicts(db, userId, billId, normalized) {
return db.prepare(`
SELECT b.id, b.name
FROM bill_merchant_rules bmr
JOIN bills b ON b.id = bmr.bill_id AND b.user_id = bmr.user_id AND b.deleted_at IS NULL
WHERE bmr.user_id = ? AND bmr.merchant = ? AND bmr.bill_id != ?
`).all(userId, normalized, billId);
}
// ── GET /api/bills/:id/merchant-rules ────────────────────────────────────────
router.get('/:id/merchant-rules', (req, res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1)
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
if (!requireBill(db, billId, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const rules = db.prepare(`
SELECT id, merchant, created_at FROM bill_merchant_rules
WHERE user_id = ? AND bill_id = ?
ORDER BY created_at ASC
`).all(req.user.id, billId);
// Suggest recent unmatched transactions as quick-pick options
const suggestions = db.prepare(`
SELECT t.id, t.payee, t.description, t.memo, t.amount, t.posted_date, t.transacted_at
FROM transactions t
LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
WHERE t.user_id = ?
AND t.match_status = 'unmatched'
AND t.ignored = 0
AND t.amount < 0
AND (t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)
ORDER BY COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) DESC
LIMIT 30
`).all(req.user.id).map(tx => {
const raw = tx.payee || tx.description || tx.memo || '';
const normalized = normalizeMerchant(raw);
return { id: tx.id, label: raw.trim(), normalized, amount: tx.amount,
date: tx.posted_date || String(tx.transacted_at || '').slice(0, 10) };
}).filter(s => s.normalized.length >= 2);
res.json({ rules, suggestions });
});
// ── GET /api/bills/:id/merchant-rules/preview ─────────────────────────────────
router.get('/:id/merchant-rules/preview', (req, res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1)
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
if (!requireBill(db, billId, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const raw = String(req.query.merchant || '').trim();
const normalized = normalizeMerchant(raw);
if (!normalized || normalized.length < 2)
return res.json({ match_count: 0, conflicts: [], normalized: '' });
const match_count = previewMatchCount(db, req.user.id, normalized);
const conflicts = findConflicts(db, req.user.id, billId, normalized);
res.json({ match_count, conflicts, normalized });
});
// ── POST /api/bills/:id/merchant-rules ───────────────────────────────────────
router.post('/:id/merchant-rules', (req, res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1)
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
if (!requireBill(db, billId, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const raw = String(req.body?.merchant || '').trim();
const normalized = normalizeMerchant(raw);
if (!normalized || normalized.length < 2)
return res.status(400).json(standardizeError('merchant must be at least 2 characters after normalisation', 'VALIDATION_ERROR', 'merchant'));
const conflicts = findConflicts(db, req.user.id, billId, normalized);
try {
db.prepare(`
INSERT INTO bill_merchant_rules (user_id, bill_id, merchant)
VALUES (?, ?, ?)
ON CONFLICT(user_id, bill_id, merchant) DO NOTHING
`).run(req.user.id, billId, normalized);
} catch (err) {
return res.status(500).json(standardizeError('Failed to save rule', 'DB_ERROR'));
}
// Retroactively apply the new rule to existing unmatched transactions
const { added } = syncBillPaymentsFromSimplefin(db, req.user.id, billId);
const rule = db.prepare('SELECT id, merchant, created_at FROM bill_merchant_rules WHERE user_id = ? AND bill_id = ? AND merchant = ?')
.get(req.user.id, billId, normalized);
res.status(201).json({ rule, retroactive_matches: added, conflicts });
});
// ── DELETE /api/bills/:id/merchant-rules/:ruleId ──────────────────────────────
router.delete('/:id/merchant-rules/:ruleId', (req, res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
const ruleId = parseInt(req.params.ruleId, 10);
if (!Number.isInteger(billId) || billId < 1 || !Number.isInteger(ruleId) || ruleId < 1)
return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR'));
if (!requireBill(db, billId, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const changes = db.prepare('DELETE FROM bill_merchant_rules WHERE id = ? AND user_id = ? AND bill_id = ?')
.run(ruleId, req.user.id, billId).changes;
if (changes === 0)
return res.status(404).json(standardizeError('Rule not found', 'NOT_FOUND'));
res.json({ success: true });
});
module.exports = router;