diff --git a/HISTORY.md b/HISTORY.md index 586cb5c..11aa8ba 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -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 (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. - **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 `` tag so the user knows what they typed. diff --git a/client/api.js b/client/api.js index 1525ae1..833b316 100644 --- a/client/api.js +++ b/client/api.js @@ -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`), diff --git a/client/components/BillMerchantRules.jsx b/client/components/BillMerchantRules.jsx new file mode 100644 index 0000000..dae85d2 --- /dev/null +++ b/client/components/BillMerchantRules.jsx @@ -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 ( + + + {rule.merchant} + + + ); +} + +function ConflictWarning({ conflicts }) { + if (!conflicts?.length) return null; + return ( +
+ + + This pattern is already used by{' '} + {conflicts.map((c, i) => ( + + {i > 0 && ', '} + {c.name} + + ))}. + Transactions could match both bills — consider making your pattern more specific. + +
+ ); +} + +function PreviewBadge({ count, loading }) { + if (loading) return ; + if (count === null) return null; + return ( + 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'}`} + + ); +} + +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 ( +
+ + Loading matching rules… +
+ ); + } + + return ( +
+ + {/* Existing rules */} + {rules.length > 0 && ( +
+ {rules.map(rule => ( + + ))} +
+ )} + + {/* Retroactive feedback */} + {retroFeedback !== null && ( +
+ + {retroFeedback} existing payment{retroFeedback === 1 ? '' : 's'} imported from your transaction history. +
+ )} + + {/* Add rule input */} +
+
+
+ { 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} + /> +
+ +
+
+ +
+ + {/* Conflict warning */} + {conflicts.length > 0 && input.trim().length >= 2 && ( +
+ +
+ )} + + {/* Suggestions dropdown */} + {showSuggestions && filteredSuggestions.length > 0 && ( +
+

+ Recent unmatched transactions +

+
+ {filteredSuggestions.map(s => { + const amountVal = Math.abs(Number(s.amount || 0)) / 100; + return ( + + ); + })} +
+
+ )} +
+ + {/* Empty state */} + {rules.length === 0 && !input && ( +

+ No rules yet. Type a merchant name or pick a recent transaction above to automatically import future payments for this bill. +

+ )} +
+ ); +} diff --git a/client/components/BillModal.jsx b/client/components/BillModal.jsx index 02201c5..aa06a10 100644 --- a/client/components/BillModal.jsx +++ b/client/components/BillModal.jsx @@ -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 )} + {/* Bank Matching Rules */} + {!isNew && ( +
+
+

Bank matching rules

+

+ Transactions whose description contains these patterns are automatically imported as payments. +

+
+
+ { + refetch?.(); + loadLinkedTransactions?.(); + }} + /> +
+
+ )} +
diff --git a/client/components/BillsTableInner.jsx b/client/components/BillsTableInner.jsx index 13f7cad..c71a95a 100644 --- a/client/components/BillsTableInner.jsx +++ b/client/components/BillsTableInner.jsx @@ -119,6 +119,14 @@ function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, S )} + {!!bill.has_merchant_rule && ( + + Bank + + )} {hasHistory && ( { 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;