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:
parent
392de3264f
commit
b34e21d1ba
18
HISTORY.md
18
HISTORY.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
Loading…
Reference in New Issue