feat: bill rules manager page, merchant re-normalization, match suggestion scoring fix, cleanup pruning

This commit is contained in:
null 2026-06-04 20:45:11 -05:00
parent 743379fc94
commit 910febae63
9 changed files with 216 additions and 9 deletions

View File

@ -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 }),

View File

@ -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 (
<div className="rounded-xl border border-border/60 bg-card/80 overflow-hidden">
<button
type="button"
onClick={() => setOpen(v => !v)}
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-muted/20 transition-colors"
>
<Settings2 className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium flex-1">Bill Matching Rules</span>
{!open && rules.length > 0 && (
<Badge variant="secondary" className="text-xs">{rules.length}</Badge>
)}
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="border-t border-border/40">
{loading ? (
<p className="text-sm text-muted-foreground text-center py-6">Loading</p>
) : groups.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6 px-4">
No rules saved yet. Open a bill and add merchant matching rules to auto-match bank transactions.
</p>
) : (
<div className="divide-y divide-border/30 max-h-96 overflow-y-auto">
{groups.map(group => (
<div key={group.bill_id}>
<div className="px-4 py-2 bg-muted/20">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide truncate">
{group.bill_name}
</p>
</div>
{group.rules.map(rule => (
<div key={rule.id} className="flex items-center gap-3 px-4 py-2.5 hover:bg-muted/10 transition-colors">
<span className="font-mono text-xs flex-1 truncate">{rule.merchant}</span>
<button
type="button"
title={rule.auto_attribute_late ? 'Auto-apply late attribution — click to disable' : 'Enable auto late attribution'}
onClick={() => handleToggleAutoLate(group.bill_id, rule.id, !!rule.auto_attribute_late)}
className={`shrink-0 transition-colors ${rule.auto_attribute_late ? 'text-primary' : 'text-muted-foreground/40 hover:text-muted-foreground'}`}
>
{rule.auto_attribute_late
? <ToggleRight className="h-4 w-4" />
: <ToggleLeft className="h-4 w-4" />
}
</button>
<button
type="button"
onClick={() => handleDelete(group.bill_id, rule.id, rule.merchant)}
className="shrink-0 text-muted-foreground hover:text-destructive transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
))}
</div>
)}
<p className="px-4 py-2 text-[11px] text-muted-foreground border-t border-border/30">
Merchant patterns are matched with word-boundary rules. Toggle icon = auto-apply late month attribution.
</p>
</div>
)}
</div>
);
}

View File

@ -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.',
}}
/>
<BillRulesManager />
<ImportTransactionCsvSection
onHistoryRefresh={handleTransactionImportComplete}
cardProps={{

View File

@ -2884,6 +2884,44 @@ function runMigrations() {
}
console.log(`[v0.89] spending defaults seeded for users missing them (${seeded} categories inserted)`);
}
},
{
version: 'v0.90',
description: 're-normalize merchant rules after & fix; ensure rejection expiry column',
dependsOn: ['v0.89'],
run: function() {
const { normalizeMerchant } = require('../services/subscriptionService');
// Re-normalize bill_merchant_rules stored under old normalization ("at t" → "att")
const rules = db.prepare('SELECT id, merchant FROM bill_merchant_rules').all();
const updBill = db.prepare('UPDATE bill_merchant_rules SET merchant=? WHERE id=?');
let billFixed = 0;
for (const r of rules) {
const fixed = normalizeMerchant(r.merchant);
if (fixed !== r.merchant) { updBill.run(fixed, r.id); billFixed++; }
}
// Re-normalize spending_category_rules
try {
const srules = db.prepare('SELECT id, merchant FROM spending_category_rules').all();
const updSpend = db.prepare('UPDATE spending_category_rules SET merchant=? WHERE id=?');
let spendFixed = 0;
for (const r of srules) {
const fixed = normalizeMerchant(r.merchant);
if (fixed !== r.merchant) { updSpend.run(fixed, r.id); spendFixed++; }
}
if (spendFixed) console.log(`[v0.90] spending_category_rules: ${spendFixed} re-normalized`);
} catch { /* spending_category_rules may not exist on legacy DBs */ }
// Ensure match_suggestion_rejections has created_at for expiry queries
const rejCols = db.prepare('PRAGMA table_info(match_suggestion_rejections)').all().map(c => 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`);
}
}
];

View File

@ -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) => {

View File

@ -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) {

View File

@ -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));

View File

@ -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)));
}

View File

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