diff --git a/client/api.js b/client/api.js index c64915e..a99f741 100644 --- a/client/api.js +++ b/client/api.js @@ -197,6 +197,7 @@ export const api = { 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`), + allBillMerchantRules: () => get('/bills/merchant-rules'), 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 }), diff --git a/client/components/BillRulesManager.jsx b/client/components/BillRulesManager.jsx new file mode 100644 index 0000000..1cc99dd --- /dev/null +++ b/client/components/BillRulesManager.jsx @@ -0,0 +1,123 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { toast } from 'sonner'; +import { ChevronDown, Trash2, ToggleLeft, ToggleRight, Settings2 } from 'lucide-react'; +import { api } from '@/api'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; + +export default function BillRulesManager() { + const [open, setOpen] = useState(false); + const [rules, setRules] = useState([]); + const [loading, setLoading] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + const d = await api.allBillMerchantRules(); + setRules(d.rules || []); + } catch (err) { + toast.error(err.message || 'Failed to load bill matching rules'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { if (open) load(); }, [open, load]); + + const handleDelete = async (billId, ruleId, merchant) => { + try { + await api.deleteMerchantRule(billId, ruleId); + setRules(prev => prev.filter(r => r.id !== ruleId)); + toast.success(`Rule "${merchant}" removed`); + } catch (err) { + toast.error(err.message || 'Failed to delete rule'); + } + }; + + const handleToggleAutoLate = async (billId, ruleId, current) => { + try { + await api.toggleRuleAutoAttribute(billId, ruleId, !current); + setRules(prev => prev.map(r => + r.id === ruleId ? { ...r, auto_attribute_late: current ? 0 : 1 } : r + )); + } catch (err) { + toast.error(err.message || 'Failed to update rule'); + } + }; + + // Group rules by bill + const byBill = rules.reduce((acc, r) => { + const key = r.bill_id; + if (!acc[key]) acc[key] = { bill_name: r.bill_name, bill_id: r.bill_id, rules: [] }; + acc[key].rules.push(r); + return acc; + }, {}); + const groups = Object.values(byBill); + + return ( +
+ + + {open && ( +
+ {loading ? ( +

Loading…

+ ) : groups.length === 0 ? ( +

+ No rules saved yet. Open a bill and add merchant matching rules to auto-match bank transactions. +

+ ) : ( +
+ {groups.map(group => ( +
+
+

+ {group.bill_name} +

+
+ {group.rules.map(rule => ( +
+ {rule.merchant} + + +
+ ))} +
+ ))} +
+ )} +

+ Merchant patterns are matched with word-boundary rules. Toggle icon = auto-apply late month attribution. +

+
+ )} +
+ ); +} diff --git a/client/pages/DataPage.jsx b/client/pages/DataPage.jsx index 907496b..1c227af 100644 --- a/client/pages/DataPage.jsx +++ b/client/pages/DataPage.jsx @@ -3,6 +3,7 @@ import { toast } from 'sonner'; import { api } from '@/api'; import { cn } from '@/lib/utils'; import BankSyncSection from '@/components/data/BankSyncSection'; +import BillRulesManager from '@/components/BillRulesManager'; import ImportTransactionCsvSection from '@/components/data/ImportTransactionCsvSection'; import TransactionMatchingSection from '@/components/data/TransactionMatchingSection'; import ImportSpreadsheetSection from '@/components/data/ImportSpreadsheetSection'; @@ -198,6 +199,7 @@ export default function DataPage() { summary: 'Review transaction matches and create bill payments.', }} /> + c.name); + if (!rejCols.includes('created_at')) { + // Static default — existing rejections get a past date so they expire immediately on next cleanup + db.exec("ALTER TABLE match_suggestion_rejections ADD COLUMN created_at TEXT NOT NULL DEFAULT '2000-01-01'"); + } + + console.log(`[v0.90] merchant rules re-normalized (${billFixed} bill rules updated), rejection expiry column ensured`); + } } ]; diff --git a/routes/bills.js b/routes/bills.js index fd27a01..927bbbf 100644 --- a/routes/bills.js +++ b/routes/bills.js @@ -102,6 +102,24 @@ router.get('/drift-report', (req, res) => { } }); +// GET /api/bills/merchant-rules — all rules for this user across all bills +router.get('/merchant-rules', (req, res) => { + try { + const rules = getDb().prepare(` + SELECT bmr.id, bmr.merchant, bmr.auto_attribute_late, bmr.created_at, + b.id AS bill_id, b.name AS bill_name + FROM bill_merchant_rules bmr + JOIN bills b ON b.id = bmr.bill_id AND b.deleted_at IS NULL + WHERE bmr.user_id = ? + ORDER BY b.name COLLATE NOCASE ASC, LENGTH(bmr.merchant) DESC, bmr.merchant ASC + `).all(req.user.id); + res.json({ rules }); + } catch (err) { + console.error('[bills/merchant-rules GET]', err.message); + res.status(500).json({ error: 'Failed to load merchant rules' }); + } +}); + // ── POST /api/bills/:id/snooze-drift ───────────────────────────────────────── // Registered early (before /:id) but path has suffix so no conflict router.post('/:id/snooze-drift', (req, res) => { diff --git a/services/billMerchantRuleService.js b/services/billMerchantRuleService.js index 3a59035..73e103a 100644 --- a/services/billMerchantRuleService.js +++ b/services/billMerchantRuleService.js @@ -37,11 +37,10 @@ function addMerchantRule(db, userId, billId, merchant) { function applyMerchantRules(db, userId) { // Detects when a payment posted just after month end but the bill was due in the prior month. // Grace window: up to LATE_ATTR_DAYS days into the new month. - const LATE_ATTR_DAYS = 5; - function lateAttributionCandidate(paidDateStr, dueDayOfMonth) { + function lateAttributionCandidate(paidDateStr, dueDayOfMonth, graceDays = 5) { const paid = new Date(paidDateStr + 'T00:00:00'); const dayOfMonth = paid.getDate(); - if (dayOfMonth > LATE_ATTR_DAYS) return null; + if (dayOfMonth > graceDays) return null; const prevMonthLastDay = new Date(paid.getFullYear(), paid.getMonth(), 0); if (dueDayOfMonth > prevMonthLastDay.getDate()) return null; return prevMonthLastDay.toISOString().slice(0, 10); // suggested prior-month date @@ -61,6 +60,9 @@ function applyMerchantRules(db, userId) { if (rules.length === 0) return { matched: 0, matched_bills: [] }; + // Longer (more specific) rules win when multiple could match the same transaction + rules.sort((a, b) => b.merchant.length - a.merchant.length); + let txRows; try { txRows = db.prepare(` @@ -126,7 +128,8 @@ function applyMerchantRules(db, userId) { matchedBills.set(rule.bill_id, rule.bill_name || `Bill #${rule.bill_id}`); // Check if this payment just missed the previous month's window - const suggestedDate = lateAttributionCandidate(paidDate, rule.due_day); + const effectiveGrace = Math.max(5, globalGraceDays); + const suggestedDate = lateAttributionCandidate(paidDate, rule.due_day, effectiveGrace); if (suggestedDate) { const dayOfMonth = new Date(paidDate + 'T00:00:00').getDate(); // Auto-apply if: per-rule toggle is on OR global grace window covers this day @@ -173,6 +176,7 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) { rules = db.prepare(` SELECT merchant FROM bill_merchant_rules WHERE user_id = ? AND bill_id = ? + ORDER BY LENGTH(merchant) DESC `).all(userId, billId).map(r => r.merchant); } catch { return { added: 0 }; @@ -251,10 +255,12 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) { added++; // Check for late attribution (payment just crossed month boundary) - const suggestedDate = billMeta ? lateAttributionCandidate(paidDate, billMeta.due_day) : null; + const globalDays2 = (() => { try { return parseInt(getUserSettings(userId).bank_late_attribution_days, 10) || 0; } catch { return 0; } })(); + const effectiveGrace2 = Math.max(5, globalDays2); + const suggestedDate = billMeta ? lateAttributionCandidate(paidDate, billMeta.due_day, effectiveGrace2) : null; if (suggestedDate) { const dayOfMonth = new Date(paidDate + 'T00:00:00').getDate(); - const globalDays = (() => { try { return parseInt(getUserSettings(userId).bank_late_attribution_days, 10) || 0; } catch { return 0; } })(); + const globalDays = globalDays2; const autoApply = (globalDays > 0 && dayOfMonth <= globalDays); if (autoApply) { diff --git a/services/cleanupService.js b/services/cleanupService.js index 9534d2f..4f958e9 100644 --- a/services/cleanupService.js +++ b/services/cleanupService.js @@ -215,6 +215,14 @@ async function runAllCleanup() { tasks.soft_deleted_records = pruneSoftDeletedFinancialRecords(30); + // Prune match suggestion rejections older than 90 days + try { + const { changes } = getDb().prepare( + "DELETE FROM match_suggestion_rejections WHERE created_at <= datetime('now', '-90 days')" + ).run(); + tasks.suggestion_rejections = { pruned: changes }; + } catch { tasks.suggestion_rejections = { pruned: 0 }; } + const ran_at = new Date().toISOString(); setSetting('cleanup_last_run_at', ran_at); setSetting('cleanup_last_result', JSON.stringify(tasks)); diff --git a/services/matchSuggestionService.js b/services/matchSuggestionService.js index c2e513e..1e2a926 100644 --- a/services/matchSuggestionService.js +++ b/services/matchSuggestionService.js @@ -115,6 +115,15 @@ function addDateScore(score, reasons, transaction, bill) { return score; } +// Word-boundary comparison — same logic as billMerchantRuleService.merchantMatches() +function wordBoundaryIncludes(a, b) { + if (!a || !b) return false; + if (a === b) return true; + const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const wb = s => new RegExp(`(^|\\s)${esc(s)}(\\s|$)`); + return wb(b).test(a) || wb(a).test(b); +} + function addNameScore(score, reasons, transaction, bill) { const billName = textKey(bill.name); if (!billName) return score; @@ -123,15 +132,15 @@ function addNameScore(score, reasons, transaction, bill) { const description = textKey(transaction.description); const memo = textKey(transaction.memo); - if (payee && (payee.includes(billName) || billName.includes(payee))) { + if (payee && wordBoundaryIncludes(payee, billName)) { reasons.push('payee contains bill name'); score += 22; } - if (description && (description.includes(billName) || billName.includes(description))) { + if (description && wordBoundaryIncludes(description, billName)) { reasons.push('description contains bill name'); score += 18; } - if (memo && (memo.includes(billName) || billName.includes(memo))) { + if (memo && wordBoundaryIncludes(memo, billName)) { reasons.push('memo contains bill name'); score += 8; } @@ -219,6 +228,7 @@ function loadRejections(db, userId) { SELECT transaction_id, bill_id FROM match_suggestion_rejections WHERE user_id = ? + AND created_at > datetime('now', '-90 days') `).all(userId); return new Set(rows.map(row => suggestionId(row.transaction_id, row.bill_id))); } diff --git a/services/subscriptionService.js b/services/subscriptionService.js index d8990b3..de54019 100644 --- a/services/subscriptionService.js +++ b/services/subscriptionService.js @@ -137,6 +137,7 @@ function normalizeMerchant(value) { return String(value || '') .toLowerCase() .replace(/\+/g, ' plus ') // preserve "+" so "WALMART+" matches catalog "Walmart+" → "walmart plus" + .replace(/&/g, '') // "&" joins words — "AT&T" → "att", not "at t" .replace(/[^a-z0-9\s]/g, ' ') .replace(/\b(pos|debit|card|payment|purchase|recurring|online|inc|llc|co|www)\b/g, ' ') .replace(/\s+/g, ' ')