feat: bill rules manager page, merchant re-normalization, match suggestion scoring fix, cleanup pruning
This commit is contained in:
parent
743379fc94
commit
910febae63
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ' ')
|
||||
|
|
|
|||
Loading…
Reference in New Issue