feat: advisory non-bill transaction filter system (batch 0.33.8.0)

- Migration v0.68: seeds advisory_non_bill_filters (5k+ patterns) and
  advisory_bill_like_overrides (83 override terms) on first startup.
  Idempotent — skips if already seeded.
- advisoryFilterService.js: lazy in-memory cache checks override terms
  first, then scans patterns. Returns null | {confidence, category, rationale}.
- Transaction list: each row gets advisory_filter from the server.
- High-confidence unmatched transactions: show 'Probably not a bill'
  italic text instead of 'No bill linked'.
- MatchBillDialog high confidence: 'Create Bill' replaced with
  'Probably not a bill · create anyway' text link for manual override.
- MatchBillDialog medium confidence: Create Bill button renders muted.
- Same logic in empty-state CTA when search returns no results.
- BillModal onSave now returns the saved bill so callers can auto-match.
- Bump v0.33.7.3 -> v0.33.8.0
This commit is contained in:
null 2026-05-29 18:06:12 -05:00
parent 392de3264f
commit b34e21d1ba
10 changed files with 96672 additions and 14 deletions

View File

@ -1,5 +1,23 @@
# Bill Tracker — Changelog # Bill Tracker — Changelog
## v0.33.8.0
### 🚀 Features
- **Advisory non-bill filter system** — New `advisoryFilterService.js` with lazy in-memory cache checks transaction titles against 5,000+ advisory patterns and 83 bill-like override terms. High-confidence matches suppress "Create Bill" in favor of "Probably not a bill · create anyway" text link. Medium confidence mutes the Create Bill button. Lazy-cached on first use, seeded on startup via migration v0.68.
### 🐛 Bug Fixes
- **BillModal onSave now returns saved bill**`onSave` callback receives the saved/updated bill object, enabling downstream actions like auto-matching a transaction after bill creation.
- **Transaction list includes advisory_filter** — Each row returns `advisory_filter: null | { confidence, category, rationale }` from the server.
### 🛠 Internal
- Migration `v0.68` — seeds `advisory_non_bill_filters` and `advisory_bill_like_overrides` from `docs/advisory_non_bill_transaction_filters_us_ms_5000.json`. Idempotent (skips if already seeded).
- New tables: `advisory_non_bill_filters` (pattern, confidence, category, rationale) and `advisory_bill_like_overrides` (override terms).
---
## v0.33.7.3 ## v0.33.7.3
### 🐛 Bug Fixes ### 🐛 Bug Fixes

View File

@ -464,15 +464,16 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
}; };
setBusy(true); setBusy(true);
try { try {
let savedBill;
if (isNew) { if (isNew) {
if (data.source_bill_id) { if (data.source_bill_id) {
await api.duplicateBill(data.source_bill_id, data); savedBill = await api.duplicateBill(data.source_bill_id, data);
} else { } else {
await api.createBill(data); savedBill = await api.createBill(data);
} }
toast.success('Bill added'); toast.success('Bill added');
} else { } else {
await api.updateBill(bill.id, data); savedBill = await api.updateBill(bill.id, data);
toast.success('Bill updated'); toast.success('Bill updated');
} }
if (saveTemplate) { if (saveTemplate) {
@ -480,7 +481,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
await api.saveBillTemplate({ name: safeTemplateName, data }); await api.saveBillTemplate({ name: safeTemplateName, data });
toast.success('Template saved'); toast.success('Template saved');
} }
onSave(); onSave(savedBill);
onClose(); onClose();
} catch (err) { } catch (err) {
toast.error(err.message); toast.error(err.message);

View File

@ -2,7 +2,7 @@ import React, { useState, useEffect, useMemo } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
Sparkles, CheckCircle2, Loader2, RefreshCw, Link2, Link2Off, Sparkles, CheckCircle2, Loader2, RefreshCw, Link2, Link2Off,
XCircle, Eye, EyeOff, Search, XCircle, Eye, EyeOff, Search, Plus,
} from 'lucide-react'; } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -13,6 +13,7 @@ import {
DialogHeader, DialogTitle, DialogHeader, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { SectionCard } from './dataShared'; import { SectionCard } from './dataShared';
import BillModal from '@/components/BillModal';
const TRANSACTION_FILTERS = [ const TRANSACTION_FILTERS = [
{ id: 'open', label: 'Open', params: { ignored: 'false' } }, { id: 'open', label: 'Open', params: { ignored: 'false' } },
@ -179,7 +180,7 @@ function SuggestedMatchesPanel({ suggestions, loading, actionId, onAccept, onRej
); );
} }
function MatchBillDialog({ open, onOpenChange, transaction, bills, onConfirm, loading }) { function MatchBillDialog({ open, onOpenChange, transaction, bills, onConfirm, loading, onCreateBill }) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [selectedBillId, setSelectedBillId] = useState(''); const [selectedBillId, setSelectedBillId] = useState('');
@ -248,7 +249,42 @@ function MatchBillDialog({ open, onOpenChange, transaction, bills, onConfirm, lo
<div className="max-h-72 overflow-y-auto rounded-lg border border-border/60"> <div className="max-h-72 overflow-y-auto rounded-lg border border-border/60">
{filteredBills.length === 0 ? ( {filteredBills.length === 0 ? (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">No bills found.</p> <div className="flex flex-col items-center gap-3 px-4 py-8 text-center">
<p className="text-sm text-muted-foreground">No bills found.</p>
{onCreateBill && (() => {
const af = transaction?.advisory_filter;
const label = query.trim()
? `Create "${query.trim()}" as a new bill`
: 'Create a new bill';
if (af?.confidence === 'high') {
return (
<span className="text-xs text-muted-foreground">
Probably not a bill ·{' '}
<button
type="button"
className="underline hover:text-foreground transition-colors"
onClick={() => { onOpenChange(false); onCreateBill(transaction, query.trim() || undefined); }}
>
{label}
</button>
</span>
);
}
return (
<button
type="button"
onClick={() => { onOpenChange(false); onCreateBill(transaction, query.trim() || undefined); }}
className={cn(
'flex items-center gap-1.5 text-xs hover:underline',
af?.confidence === 'medium' ? 'text-muted-foreground' : 'text-primary',
)}
>
<Plus className="h-3.5 w-3.5" />
{label}
</button>
);
})()}
</div>
) : ( ) : (
<div className="divide-y divide-border/40"> <div className="divide-y divide-border/40">
{filteredBills.map(bill => ( {filteredBills.map(bill => (
@ -278,6 +314,39 @@ function MatchBillDialog({ open, onOpenChange, transaction, bills, onConfirm, lo
</div> </div>
<DialogFooter className="gap-2"> <DialogFooter className="gap-2">
{onCreateBill && (() => {
const af = transaction?.advisory_filter;
if (af?.confidence === 'high') {
return (
<span className="mr-auto flex items-center gap-2 text-xs text-muted-foreground">
Probably not a bill
<button
type="button"
className="underline hover:text-foreground transition-colors"
onClick={() => { onOpenChange(false); onCreateBill(transaction); }}
>
create anyway
</button>
</span>
);
}
return (
<Button
type="button"
variant="ghost"
className={cn(
'mr-auto text-xs',
af?.confidence === 'medium'
? 'text-muted-foreground/60 hover:text-muted-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
onClick={() => { onOpenChange(false); onCreateBill(transaction); }}
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Create Bill
</Button>
);
})()}
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}> <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel Cancel
</Button> </Button>
@ -314,6 +383,8 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn }
const [actionId, setActionId] = useState(null); const [actionId, setActionId] = useState(null);
const [matchOpen, setMatchOpen] = useState(false); const [matchOpen, setMatchOpen] = useState(false);
const [matchTransaction, setMatchTransaction] = useState(null); const [matchTransaction, setMatchTransaction] = useState(null);
const [categories, setCategories] = useState([]);
const [createBillSourceTx, setCreateBillSourceTx] = useState(null);
const currentFilter = TRANSACTION_FILTERS.find(item => item.id === filter) || TRANSACTION_FILTERS[0]; const currentFilter = TRANSACTION_FILTERS.find(item => item.id === filter) || TRANSACTION_FILTERS[0];
@ -363,6 +434,9 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn }
useEffect(() => { loadBills(); }, []); useEffect(() => { loadBills(); }, []);
useEffect(() => { loadTransactions(); }, [filter, refreshKey]); useEffect(() => { loadTransactions(); }, [filter, refreshKey]);
useEffect(() => { loadSuggestions(); }, [refreshKey]); useEffect(() => { loadSuggestions(); }, [refreshKey]);
useEffect(() => {
api.categories().then(data => setCategories(data || [])).catch(() => {});
}, []);
const openMatchDialog = (tx) => { const openMatchDialog = (tx) => {
setMatchTransaction(tx); setMatchTransaction(tx);
@ -370,6 +444,38 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn }
if (!bills.length && !billsLoading) loadBills(); if (!bills.length && !billsLoading) loadBills();
}; };
const openCreateBill = (tx, nameOverride) => {
const amount = Math.abs(Number(tx.amount || 0)) / 100;
const dateStr = transactionDate(tx);
const day = dateStr ? parseInt(dateStr.slice(8, 10), 10) : 1;
setCreateBillSourceTx({
tx,
initialBill: {
name: nameOverride || transactionTitle(tx),
expected_amount: amount || 0,
due_day: day >= 1 && day <= 31 ? day : 1,
billing_cycle: 'monthly',
cycle_type: 'monthly',
cycle_day: '1',
active: 1,
},
});
};
const handleBillCreated = async (newBill) => {
const tx = createBillSourceTx?.tx;
setCreateBillSourceTx(null);
if (tx && newBill?.id) {
try {
await api.matchTransaction(tx.id, newBill.id);
toast.success('Bill created and matched to transaction.');
} catch (err) {
toast.error(err.message || 'Bill created but match failed.');
}
}
await Promise.all([loadBills(), refreshTransactionWorkbench()]);
};
const runTransactionAction = async (tx, action) => { const runTransactionAction = async (tx, action) => {
setActionId(`${action}:${tx.id}`); setActionId(`${action}:${tx.id}`);
try { try {
@ -560,6 +666,8 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn }
<TransactionStatusBadge tx={tx} /> <TransactionStatusBadge tx={tx} />
{tx.matched_bill_name ? ( {tx.matched_bill_name ? (
<span className="truncate text-xs text-foreground">{tx.matched_bill_name}</span> <span className="truncate text-xs text-foreground">{tx.matched_bill_name}</span>
) : tx.advisory_filter?.confidence === 'high' ? (
<span className="text-xs text-muted-foreground italic">Probably not a bill</span>
) : ( ) : (
<span className="text-xs text-muted-foreground">No bill linked</span> <span className="text-xs text-muted-foreground">No bill linked</span>
)} )}
@ -650,7 +758,18 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn }
bills={bills} bills={bills}
loading={actionId === `match:${matchTransaction?.id}`} loading={actionId === `match:${matchTransaction?.id}`}
onConfirm={confirmMatch} onConfirm={confirmMatch}
onCreateBill={openCreateBill}
/> />
{createBillSourceTx && (
<BillModal
key={`create-from-tx-${createBillSourceTx.tx.id}`}
initialBill={createBillSourceTx.initialBill}
categories={categories}
onClose={() => setCreateBillSourceTx(null)}
onSave={handleBillCreated}
/>
)}
</SectionCard> </SectionCard>
); );
} }

View File

@ -9,6 +9,11 @@ export const RELEASE_NOTES = {
date: '2026-05-29', date: '2026-05-29',
version: APP_VERSION, version: APP_VERSION,
highlights: [ highlights: [
{
icon: '🧠',
title: 'Advisory non-bill transaction filters',
desc: '5,000+ advisory patterns with 83 bill-like override terms. High-confidence unmatched transactions show "Probably not a bill" instead of "Create Bill". Medium confidence mutes the button. Lazy in-memory cache, seeded on first startup.',
},
{ {
icon: '🔄', icon: '🔄',
title: 'SimpleFIN transaction table fix', title: 'SimpleFIN transaction table fix',
@ -34,11 +39,6 @@ export const RELEASE_NOTES = {
title: 'Private login details', title: 'Private login details',
desc: 'Your Profile now shows recent login date, IP, browser, OS, device type, and a short device ID. These details are shown only to you in the app UI.', desc: 'Your Profile now shows recent login date, IP, browser, OS, device type, and a short device ID. These details are shown only to you in the app UI.',
}, },
{
icon: '📄',
title: 'Privacy and release notes',
desc: 'A public Privacy page is available from About, release notes can render images, and this update card now resets from the backend whenever the app version changes.',
},
], ],
image: { image: {
src: '/img/doingmypart.jpg', src: '/img/doingmypart.jpg',

View File

@ -276,6 +276,55 @@ const SUBSCRIPTION_CATALOG_ROWS = [
[200,'Book of the Month','Books & Subscription Boxes','education','https://www.bookofthemonth.com/','bookofthemonth.com'], [200,'Book of the Month','Books & Subscription Boxes','education','https://www.bookofthemonth.com/','bookofthemonth.com'],
]; ];
function runAdvisoryFiltersMigration(database) {
database.exec(`
CREATE TABLE IF NOT EXISTS advisory_non_bill_filters (
id TEXT PRIMARY KEY,
pattern TEXT NOT NULL,
confidence TEXT NOT NULL CHECK(confidence IN ('high', 'medium')),
category TEXT NOT NULL,
rationale TEXT
);
CREATE INDEX IF NOT EXISTS idx_advisory_filters_confidence
ON advisory_non_bill_filters(confidence);
CREATE TABLE IF NOT EXISTS advisory_bill_like_overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
term TEXT NOT NULL UNIQUE
);
`);
const filterCount = database.prepare('SELECT COUNT(*) as n FROM advisory_non_bill_filters').get();
if (filterCount.n === 0) {
const jsonPath = path.join(__dirname, '..', 'docs', 'advisory_non_bill_transaction_filters_us_ms_5000.json');
const raw = fs.readFileSync(jsonPath, 'utf8');
const data = JSON.parse(raw);
const insertFilter = database.prepare(
'INSERT INTO advisory_non_bill_filters (id, pattern, confidence, category, rationale) VALUES (?,?,?,?,?)'
);
const insertFilters = database.transaction((rows) => {
for (const row of rows) {
insertFilter.run(row.id, row.pattern, row.confidence, row.category, row.rationale || null);
}
});
insertFilters(data.patterns || []);
console.log(`[migration] advisory_non_bill_filters: seeded ${(data.patterns || []).length} rows`);
const overrideTerms = data.bill_like_override_terms || [];
if (overrideTerms.length > 0) {
const insertOverride = database.prepare(
'INSERT OR IGNORE INTO advisory_bill_like_overrides (term) VALUES (?)'
);
const insertOverrides = database.transaction((terms) => {
for (const term of terms) insertOverride.run(term);
});
insertOverrides(overrideTerms);
console.log(`[migration] advisory_bill_like_overrides: seeded ${overrideTerms.length} rows`);
}
}
}
function runSubscriptionCatalogMigration(database) { function runSubscriptionCatalogMigration(database) {
database.exec(` database.exec(`
CREATE TABLE IF NOT EXISTS subscription_catalog ( CREATE TABLE IF NOT EXISTS subscription_catalog (
@ -2207,6 +2256,14 @@ function runMigrations() {
ON bill_merchant_rules(user_id); ON bill_merchant_rules(user_id);
`); `);
} }
},
{
version: 'v0.68',
description: 'advisory_non_bill_filters: 5k advisory patterns + bill-like override terms',
dependsOn: ['v0.67'],
run: function() {
runAdvisoryFiltersMigration(db);
}
} }
]; ];

View File

@ -252,3 +252,19 @@ CREATE TABLE IF NOT EXISTS bill_templates (
CREATE INDEX IF NOT EXISTS idx_bill_templates_user_name CREATE INDEX IF NOT EXISTS idx_bill_templates_user_name
ON bill_templates(user_id, name); ON bill_templates(user_id, name);
CREATE TABLE IF NOT EXISTS advisory_non_bill_filters (
id TEXT PRIMARY KEY,
pattern TEXT NOT NULL,
confidence TEXT NOT NULL CHECK(confidence IN ('high', 'medium')),
category TEXT NOT NULL,
rationale TEXT
);
CREATE INDEX IF NOT EXISTS idx_advisory_filters_confidence
ON advisory_non_bill_filters(confidence);
CREATE TABLE IF NOT EXISTS advisory_bill_like_overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
term TEXT NOT NULL UNIQUE
);

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "bill-tracker", "name": "bill-tracker",
"version": "0.33.7.3", "version": "0.33.8.0",
"description": "Monthly bill tracking system", "description": "Monthly bill tracking system",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {

View File

@ -6,6 +6,7 @@ const {
ensureManualDataSource, ensureManualDataSource,
getTransactionForUser, getTransactionForUser,
} = require('../services/transactionService'); } = require('../services/transactionService');
const { checkTransaction: advisoryCheck } = require('../services/advisoryFilterService');
const { const {
ignoreTransaction, ignoreTransaction,
matchTransactionToBill, matchTransactionToBill,
@ -365,7 +366,12 @@ router.get('/', (req, res) => {
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
`).all(...params, page.limit, page.offset); `).all(...params, page.limit, page.offset);
res.json(rows.map(decorateTransaction)); res.json(rows.map(row => {
const decorated = decorateTransaction(row);
const title = row.payee || row.description || row.memo || '';
decorated.advisory_filter = advisoryCheck(title);
return decorated;
}));
}); });
// POST /api/transactions/manual // POST /api/transactions/manual

View File

@ -0,0 +1,81 @@
'use strict';
const { getDb } = require('../db/database');
// Lazy-loaded in-memory cache — loaded once on first use
let _patterns = null;
let _overrideTerms = null;
function normalize(text) {
if (!text) return '';
return String(text)
.toLowerCase()
.replace(/&/g, 'and')
.replace(/\s+/g, ' ')
.trim();
}
function loadCache() {
if (_patterns !== null) return;
const db = getDb();
_patterns = db.prepare(
'SELECT pattern, confidence, category, rationale FROM advisory_non_bill_filters'
).all();
_overrideTerms = db.prepare(
'SELECT term FROM advisory_bill_like_overrides'
).all().map(r => r.term);
}
/**
* Check a transaction title against advisory filter patterns.
* Returns null if Create Bill should be shown normally, or
* { confidence: 'high'|'medium', category, rationale } if it should be suppressed.
*/
function checkTransaction(title) {
if (!title) return null;
try {
loadCache();
} catch {
return null;
}
const normalized = normalize(title);
if (!normalized) return null;
// Bill-like override terms take priority — always show Create Bill
for (const term of _overrideTerms) {
if (normalized.includes(term)) return null;
}
// Find the highest-confidence matching pattern
let highMatch = null;
let mediumMatch = null;
for (const row of _patterns) {
if (normalized.includes(row.pattern)) {
if (row.confidence === 'high') {
highMatch = row;
break; // high confidence — no need to keep looking
} else if (!mediumMatch) {
mediumMatch = row;
}
}
}
const match = highMatch || mediumMatch;
if (!match) return null;
return {
confidence: match.confidence,
category: match.category,
rationale: match.rationale || null,
};
}
/** Clear the in-memory cache (used after re-seeding in tests). */
function clearCache() {
_patterns = null;
_overrideTerms = null;
}
module.exports = { checkTransaction, clearCache };