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, ' ')