feat: existing bill matching in recommendations, feedback tracking, broad-merchant rejection, annual price detection

This commit is contained in:
null 2026-06-06 21:15:08 -05:00
parent 422d8550bb
commit a1e6a308cf
6 changed files with 553 additions and 35 deletions

View File

@ -229,12 +229,23 @@ export const api = {
// Subscriptions
subscriptions: () => get('/subscriptions'),
confirmTransactionMatch: (transactionId, billId) => post('/matches/confirm', { transaction_id: transactionId, bill_id: billId }),
matchRecommendationToBill: (transactionIds, billId, merchant) => post('/subscriptions/recommendations/match-bill', { transaction_ids: transactionIds, bill_id: billId, merchant }),
matchRecommendationToBill: (transactionIds, billId, merchant, catalogId, confidence) => post('/subscriptions/recommendations/match-bill', {
transaction_ids: transactionIds,
bill_id: billId,
merchant,
catalog_id: catalogId,
confidence,
}),
subscriptionRecommendations: () => get('/subscriptions/recommendations'),
subscriptionTransactionMatches: (params = {}) => get(`/subscriptions/transaction-matches${queryString(params)}`),
updateSubscription: (id, data) => _fetch('PATCH', `/subscriptions/${id}`, data),
createSubscriptionFromRecommendation: (data) => post('/subscriptions/recommendations/create', data),
declineRecommendation: (declineKey) => post('/subscriptions/recommendations/decline', { decline_key: declineKey }),
declineRecommendation: (recommendation) => post('/subscriptions/recommendations/decline', {
decline_key: recommendation?.decline_key || recommendation,
catalog_id: recommendation?.catalog_match?.id,
merchant: recommendation?.merchant,
confidence: recommendation?.confidence,
}),
subscriptionCatalog: () => get('/subscriptions/catalog'),
updateSubscriptionCatalogLink:(id, catalogId) => _fetch('PUT', `/subscriptions/${id}/catalog-link`, { catalog_id: catalogId }),
addCatalogDescriptor: (catalogId, d) => post(`/subscriptions/catalog/${catalogId}/descriptors`, { descriptor: d }),

View File

@ -285,12 +285,13 @@ function TxResultRow({ tx, onTrack }) {
);
}
function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, onMatch, onDetails, busy }) {
function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, onMatch, onQuickLink, onDetails, busy }) {
const identity = recommendation.evidence?.identity;
const amount = recommendation.evidence?.amount;
const cadence = recommendation.evidence?.cadence;
const amountRange = recommendation.evidence?.amount_range;
const ambiguity = recommendation.evidence?.ambiguity;
const existingBill = recommendation.existing_bill_match;
return (
<div className="rounded-lg border border-primary/20 bg-primary/[0.05] p-4">
@ -314,6 +315,11 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o
{recommendation.accounts.length > 1 ? 'Accounts' : 'Account'}: {recommendation.accounts.join(', ')}
</p>
)}
{existingBill && (
<p className="mt-1 truncate text-xs font-semibold text-primary">
Link to existing bill: {existingBill.name}
</p>
)}
</div>
<div className="shrink-0 text-right">
<p className="tracker-number text-base font-semibold text-foreground">{fmt(recommendation.expected_amount)}</p>
@ -356,6 +362,11 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o
Review
</Badge>
)}
{existingBill && (
<Badge variant="outline" className="border-primary/25 bg-primary/10 text-primary">
Existing bill
</Badge>
)}
{amountRange && amountRange.min !== amountRange.max && (
<span className="rounded-md border border-border/60 bg-background/60 px-2 py-1 text-[11px] font-medium text-muted-foreground">
Range {fmt(amountRange.min)}-{fmt(amountRange.max)}
@ -394,23 +405,24 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o
<Button
type="button"
size="sm"
variant="outline"
variant={existingBill ? 'default' : 'outline'}
className="gap-1.5"
disabled={busy}
onClick={() => onMatch(recommendation)}
onClick={() => existingBill ? onQuickLink(recommendation, existingBill.id) : onMatch(recommendation)}
>
<Link2 className="h-3.5 w-3.5" />
Link to bill
{existingBill ? 'Link existing' : 'Link to bill'}
</Button>
<Button
type="button"
size="sm"
variant={existingBill ? 'outline' : 'default'}
className="gap-2"
disabled={busy}
onClick={() => onAccept({ ...recommendation, category_id: categoryId })}
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
Track
{existingBill ? 'Track new' : 'Track'}
</Button>
</div>
</div>
@ -433,7 +445,7 @@ function EvidenceItem({ label, value, tone = 'default' }) {
);
}
function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose, onAccept, onDecline, onMatch, busy }) {
function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose, onAccept, onDecline, onMatch, onQuickLink, busy }) {
if (!recommendation) return null;
const identity = recommendation.evidence?.identity;
@ -441,6 +453,7 @@ function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose
const cadence = recommendation.evidence?.cadence;
const amountRange = recommendation.evidence?.amount_range;
const ambiguity = recommendation.evidence?.ambiguity;
const existingBill = recommendation.existing_bill_match;
const transactions = recommendation.transactions || [];
const handleAccept = async () => {
await onAccept({ ...recommendation, category_id: categoryId });
@ -454,6 +467,11 @@ function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose
onMatch(recommendation);
onClose();
};
const handleQuickLink = async () => {
if (!existingBill) return;
await onQuickLink(recommendation, existingBill.id);
onClose();
};
return (
<Dialog open={open} onOpenChange={value => { if (!value) onClose(); }}>
@ -470,6 +488,11 @@ function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose
Review
</Badge>
)}
{existingBill && (
<Badge variant="outline" className="border-primary/25 bg-primary/10 text-primary">
Existing bill
</Badge>
)}
</DialogTitle>
<p className="text-sm text-muted-foreground">
{TYPE_LABELS[recommendation.subscription_type] || 'Other'} · {recommendation.occurrence_count} charge{recommendation.occurrence_count !== 1 ? 's' : ''} · last seen {fmtDate(recommendation.last_seen_date)}
@ -518,6 +541,22 @@ function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose
</div>
)}
{existingBill && (
<div className="rounded-lg border border-primary/25 bg-primary/10 px-3 py-2">
<p className="text-sm font-semibold text-primary">Recommended action: link existing bill</p>
<p className="mt-1 text-sm text-muted-foreground">
{existingBill.name} · {existingBill.expected_amount ? fmt(existingBill.expected_amount) : 'No amount'} · due day {existingBill.due_day || 'not set'}
</p>
{existingBill.reasons?.length > 0 && (
<div className="mt-2 space-y-1 text-sm text-muted-foreground">
{existingBill.reasons.map(reason => (
<p key={reason}>{reason}</p>
))}
</div>
)}
</div>
)}
{recommendation.reasons?.length > 0 && (
<div className="flex flex-wrap gap-2">
{recommendation.reasons.map(reason => (
@ -558,13 +597,13 @@ function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
Decline
</Button>
<Button variant="outline" onClick={handleMatch} disabled={busy} className="gap-1.5">
<Button variant={existingBill ? 'default' : 'outline'} onClick={existingBill ? handleQuickLink : handleMatch} disabled={busy} className="gap-1.5">
<Link2 className="h-3.5 w-3.5" />
Link to bill
{existingBill ? 'Link existing' : 'Link to bill'}
</Button>
<Button onClick={handleAccept} disabled={busy} className="gap-2">
<Button variant={existingBill ? 'outline' : 'default'} onClick={handleAccept} disabled={busy} className="gap-2">
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
Track
{existingBill ? 'Track new' : 'Track'}
</Button>
</DialogFooter>
</DialogContent>
@ -689,7 +728,7 @@ export default function SubscriptionsPage() {
if (!recommendation.decline_key) return;
setBusyId(`dec-${recommendation.id}`);
try {
await api.declineRecommendation(recommendation.decline_key);
await api.declineRecommendation(recommendation);
setRecommendations(prev => prev.filter(r => r.id !== recommendation.id));
} catch (err) {
toast.error(err.message || 'Could not dismiss recommendation.');
@ -698,14 +737,20 @@ export default function SubscriptionsPage() {
}
}
async function matchRecommendationToBill(billId) {
if (!matchTarget || !billId) return;
setBusyId(`match-${matchTarget.id}`);
async function linkRecommendationToBill(recommendation, billId) {
if (!recommendation || !billId) return;
setBusyId(`match-${recommendation.id}`);
try {
const result = await api.matchRecommendationToBill(matchTarget.transaction_ids, billId, matchTarget.merchant);
const result = await api.matchRecommendationToBill(
recommendation.transaction_ids,
billId,
recommendation.merchant,
recommendation.catalog_match?.id,
recommendation.confidence,
);
toast.success(`Linked ${result.matched_count} transaction${result.matched_count !== 1 ? 's' : ''} to "${result.bill_name}".`);
setMatchTarget(null);
setRecommendations(prev => prev.filter(r => r.id !== matchTarget.id));
setRecommendations(prev => prev.filter(r => r.id !== recommendation.id));
} catch (err) {
toast.error(err.message || 'Could not link recommendation to bill.');
} finally {
@ -713,6 +758,10 @@ export default function SubscriptionsPage() {
}
}
async function matchRecommendationToBill(billId) {
await linkRecommendationToBill(matchTarget, billId);
}
function openManualSubscription() {
setModal({
bill: null,
@ -992,6 +1041,7 @@ export default function SubscriptionsPage() {
onAccept={acceptRecommendation}
onDecline={declineRecommendation}
onMatch={rec => setMatchTarget(rec)}
onQuickLink={linkRecommendationToBill}
onDetails={setDetailsTarget}
/>
))
@ -1098,6 +1148,7 @@ export default function SubscriptionsPage() {
onAccept={acceptRecommendation}
onDecline={declineRecommendation}
onMatch={rec => setMatchTarget(rec)}
onQuickLink={linkRecommendationToBill}
busy={detailsTarget ? (
busyId === `rec-${detailsTarget.id}`
|| busyId === `dec-${detailsTarget.id}`

View File

@ -3244,6 +3244,33 @@ function runMigrations() {
console.log(`[v0.96] catalog_id added to bills; ${backfilled}/${subBills.length} subscriptions backfilled`);
}
},
{
version: 'v0.97',
description: 'subscription recommendation feedback: per-user learning signals',
run() {
db.exec(`
CREATE TABLE IF NOT EXISTS subscription_recommendation_feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
catalog_id INTEGER REFERENCES subscription_catalog(id) ON DELETE SET NULL,
bill_id INTEGER REFERENCES bills(id) ON DELETE SET NULL,
merchant TEXT,
action TEXT NOT NULL,
confidence INTEGER,
descriptor TEXT,
metadata_json TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_srf_user_catalog
ON subscription_recommendation_feedback(user_id, catalog_id);
CREATE INDEX IF NOT EXISTS idx_srf_user_merchant
ON subscription_recommendation_feedback(user_id, merchant);
CREATE INDEX IF NOT EXISTS idx_srf_user_action
ON subscription_recommendation_feedback(user_id, action);
`);
console.log('[v0.97] subscription recommendation feedback table ensured');
}
},
];
// ── users: notification columns ───────────────────────────────────────────
@ -3588,6 +3615,12 @@ function getDbPath() {
// Rollback SQL definitions
const ROLLBACK_SQL_MAP = {
'v0.97': {
description: 'subscription recommendation feedback: per-user learning signals',
sql: [
'DROP TABLE IF EXISTS subscription_recommendation_feedback',
]
},
'v0.96': {
description: 'bills: catalog_id FK; user_catalog_descriptors',
sql: [

View File

@ -10,6 +10,7 @@ const {
getSubscriptionSummary,
getSubscriptions,
monthlyEquivalent,
recordSubscriptionFeedback,
searchSubscriptionTransactions,
} = require('../services/subscriptionService');
const { addMerchantRule, applyMerchantRules } = require('../services/billMerchantRuleService');
@ -47,7 +48,15 @@ router.post('/recommendations/decline', (req, res) => {
return res.status(400).json(standardizeError('decline_key is required', 'VALIDATION_ERROR', 'decline_key'));
}
try {
declineRecommendation(getDb(), req.user.id, decline_key);
const db = getDb();
declineRecommendation(db, req.user.id, decline_key);
recordSubscriptionFeedback(db, req.user.id, {
action: 'decline',
catalog_id: req.body?.catalog_id,
merchant: req.body?.merchant,
confidence: req.body?.confidence,
metadata: { decline_key },
});
res.json({ ok: true });
} catch (err) {
res.status(500).json(standardizeError(err.message || 'Failed to decline recommendation', 'DECLINE_ERROR'));
@ -69,8 +78,12 @@ router.post('/recommendations/match-bill', (req, res) => {
}
const db = getDb();
const bill = db.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id);
const bill = db.prepare('SELECT id, name, catalog_id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const catalogId = parseInt(req.body?.catalog_id, 10);
const catalogEntry = Number.isInteger(catalogId) && catalogId > 0
? db.prepare('SELECT id FROM subscription_catalog WHERE id = ?').get(catalogId)
: null;
const placeholders = txIds.map(() => '?').join(',');
const txRows = db.prepare(`
@ -101,6 +114,20 @@ router.post('/recommendations/match-bill', (req, res) => {
// Store merchant rule for ongoing auto-matching on future syncs
const merchant = typeof req.body?.merchant === 'string' ? req.body.merchant.trim() : '';
if (merchant) addMerchantRule(db, req.user.id, billId, merchant);
if (catalogEntry && !bill.catalog_id) {
try {
db.prepare('UPDATE bills SET catalog_id = ?, updated_at = datetime(\'now\') WHERE id = ? AND user_id = ?')
.run(catalogEntry.id, billId, req.user.id);
} catch { /* pre-v0.96 */ }
}
recordSubscriptionFeedback(db, req.user.id, {
action: 'link_existing_bill',
catalog_id: catalogEntry?.id,
bill_id: billId,
merchant,
confidence: req.body?.confidence,
metadata: { transaction_ids: txIds },
});
// Apply rules immediately to catch any unmatched transactions beyond the explicit list
const { matched: autoMatched } = applyMerchantRules(db, req.user.id);
@ -124,6 +151,14 @@ router.post('/recommendations/create', (req, res) => {
const created = createSubscriptionFromRecommendation(db, req.user.id, req.body || {});
// Store merchant rule so future SimpleFIN transactions auto-match this bill
if (req.body?.merchant) addMerchantRule(db, req.user.id, created.id, req.body.merchant);
recordSubscriptionFeedback(db, req.user.id, {
action: 'accept_track_new',
catalog_id: req.body?.catalog_match?.id,
bill_id: created.id,
merchant: req.body?.merchant,
confidence: req.body?.confidence,
metadata: { transaction_ids: req.body?.transaction_ids || [] },
});
res.status(201).json(created);
} catch (err) {
res.status(err.status || 400).json(standardizeError(err.message || 'Could not create subscription', err.status ? 'VALIDATION_ERROR' : 'SUBSCRIPTION_CREATE_ERROR', err.field || null));
@ -201,7 +236,7 @@ router.put('/:id/catalog-link', (req, res) => {
}
const bill = db.prepare(
'SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL'
'SELECT id, name, catalog_id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL'
).get(billId, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
@ -210,6 +245,12 @@ router.put('/:id/catalog-link', (req, res) => {
if (rawCatalogId === null || rawCatalogId === undefined) {
db.prepare("UPDATE bills SET catalog_id = NULL, updated_at = datetime('now') WHERE id = ? AND user_id = ?")
.run(billId, req.user.id);
recordSubscriptionFeedback(db, req.user.id, {
action: 'catalog_unlink',
catalog_id: bill.catalog_id,
bill_id: billId,
merchant: bill.name,
});
return res.json({ ok: true, catalog_id: null });
}
@ -222,6 +263,13 @@ router.put('/:id/catalog-link', (req, res) => {
db.prepare("UPDATE bills SET catalog_id = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?")
.run(catalogId, billId, req.user.id);
recordSubscriptionFeedback(db, req.user.id, {
action: 'catalog_relink',
catalog_id: catalogId,
bill_id: billId,
merchant: bill.name,
metadata: { previous_catalog_id: bill.catalog_id || null },
});
res.json({ ok: true, catalog_id: catalogId });
});
@ -256,6 +304,12 @@ router.post('/catalog/:catalogId/descriptors', (req, res) => {
const result = db.prepare(
'INSERT INTO user_catalog_descriptors (user_id, catalog_id, descriptor) VALUES (?, ?, ?)'
).run(req.user.id, catalogId, descriptor);
recordSubscriptionFeedback(db, req.user.id, {
action: 'add_descriptor',
catalog_id: catalogId,
merchant: descriptor,
descriptor,
});
res.status(201).json({ id: result.lastInsertRowid, descriptor, catalog_id: catalogId });
} catch (err) {
@ -272,12 +326,23 @@ router.delete('/catalog/descriptors/:id', (req, res) => {
}
try {
const existing = db.prepare(
'SELECT catalog_id, descriptor FROM user_catalog_descriptors WHERE id = ? AND user_id = ?'
).get(descriptorId, req.user.id);
const result = db.prepare(
'DELETE FROM user_catalog_descriptors WHERE id = ? AND user_id = ?'
).run(descriptorId, req.user.id);
if (result.changes === 0) {
return res.status(404).json(standardizeError('Descriptor not found', 'NOT_FOUND'));
}
if (existing) {
recordSubscriptionFeedback(db, req.user.id, {
action: 'delete_descriptor',
catalog_id: existing.catalog_id,
merchant: existing.descriptor,
descriptor: existing.descriptor,
});
}
res.json({ ok: true });
} catch (err) {
res.status(500).json(standardizeError(err.message || 'Failed to delete descriptor', 'DESCRIPTOR_DELETE_ERROR'));

View File

@ -390,15 +390,20 @@ function ambiguityEvidence({ merchant, catalogEntry, identityInfo, amountInfo, c
const descriptorContainsShortTransaction = identityInfo?.containment === 'descriptor_contains_transaction'
&& compactMerchant.length > 0
&& compactMerchant.length <= 4;
const haystack = catalogTextForAmbiguity(merchant, catalogEntry, identityInfo);
const tokens = new Set(haystack.split(/\s+/).filter(Boolean));
const hit = [...AMBIGUOUS_CATALOG_TERMS.entries()].find(([term]) => tokens.has(term));
const descriptorTokens = new Set(normalizeMerchant(identityInfo?.descriptor).split(/\s+/).filter(Boolean));
const genericBroadBankDescriptor = identityType === 'bank_descriptor'
&& !!hit
&& ['bill', 'billing', 'payment', 'payments', 'store', 'mktplace', 'marketplace'].some(term => descriptorTokens.has(term));
const weakIdentity = !['user_descriptor', 'bank_descriptor'].includes(identityType)
|| descriptorContainsShortTransaction;
|| descriptorContainsShortTransaction
|| genericBroadBankDescriptor;
if (!weakIdentity) {
return { ambiguous: false, penalty: 0, label: null, reasons: [] };
}
const haystack = catalogTextForAmbiguity(merchant, catalogEntry, identityInfo);
const tokens = new Set(haystack.split(/\s+/).filter(Boolean));
const hit = [...AMBIGUOUS_CATALOG_TERMS.entries()].find(([term]) => tokens.has(term));
const compactDescriptor = String(identityInfo?.descriptor || '').replace(/\s+/g, '');
const shortDescriptor = compactDescriptor.length > 0 && compactDescriptor.length <= 4;
@ -410,6 +415,7 @@ function ambiguityEvidence({ merchant, catalogEntry, identityInfo, amountInfo, c
const penalty = recurringStrong ? 6 : 16;
const reasons = [];
if (hit) reasons.push(hit[1]);
if (genericBroadBankDescriptor) reasons.push('The bank descriptor is a generic billing label for a broad merchant.');
if (shortDescriptor) reasons.push('The matched service text is very short, so false positives are more likely.');
if (!cadenceInfo?.recurring) reasons.push('Only one bank transaction supports this recommendation.');
if (amountInfo?.match === 'unusual') reasons.push('The amount is outside the catalog price range.');
@ -422,11 +428,36 @@ function ambiguityEvidence({ merchant, catalogEntry, identityInfo, amountInfo, c
};
}
function scoreKnownServiceRecommendation({ match, merchant, catalogEntry, amountInfo, cadenceInfo }) {
function feedbackEvidence({ feedback, merchant, catalogEntry }) {
const catalogKey = catalogEntry?.id ? String(catalogEntry.id) : null;
const merchantKey = normalizeMerchant(merchant);
const rows = [
...(catalogKey ? (feedback.byCatalog.get(catalogKey) || []) : []),
...(merchantKey ? (feedback.byMerchant.get(merchantKey) || []) : []),
];
if (!rows.length) return { score: 0, label: null, positive_count: 0, negative_count: 0 };
let positive = 0;
let negative = 0;
for (const row of rows) {
if (['accept_track_new', 'link_existing_bill', 'catalog_relink', 'add_descriptor'].includes(row.action)) positive++;
if (['decline', 'catalog_unlink', 'delete_descriptor'].includes(row.action)) negative++;
}
const score = Math.max(-18, Math.min(8, positive * 3 - negative * 8));
const label = score > 0
? 'Boosted by your past subscription matching choices'
: score < 0
? 'Reduced by your past subscription matching choices'
: null;
return { score, label, positive_count: positive, negative_count: negative };
}
function scoreKnownServiceRecommendation({ match, merchant, catalogEntry, amountInfo, cadenceInfo, feedbackInfo = null }) {
const identity = identityEvidence(match);
const ambiguity = ambiguityEvidence({ merchant, catalogEntry, identityInfo: identity, amountInfo, cadenceInfo });
const confidence = Math.min(99, Math.max(0,
identity.score + amountInfo.score + cadenceInfo.score - ambiguity.penalty
identity.score + amountInfo.score + cadenceInfo.score - ambiguity.penalty + (feedbackInfo?.score || 0)
));
return { confidence, identity, ambiguity };
}
@ -528,6 +559,159 @@ function recommendationAccountLabel(item) {
return accountName || orgName || item.data_source_name || '';
}
function loadRecommendationFeedback(db, userId) {
const empty = { byCatalog: new Map(), byMerchant: new Map() };
try {
const rows = db.prepare(`
SELECT catalog_id, merchant, action, confidence, descriptor, created_at
FROM subscription_recommendation_feedback
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 500
`).all(userId);
for (const row of rows) {
if (row.catalog_id) {
const key = String(row.catalog_id);
if (!empty.byCatalog.has(key)) empty.byCatalog.set(key, []);
empty.byCatalog.get(key).push(row);
}
const merchant = normalizeMerchant(row.merchant);
if (merchant) {
if (!empty.byMerchant.has(merchant)) empty.byMerchant.set(merchant, []);
empty.byMerchant.get(merchant).push(row);
}
}
} catch { /* pre-v0.97 */ }
return empty;
}
function recordSubscriptionFeedback(db, userId, payload = {}) {
const action = String(payload.action || '').trim();
if (!action) return;
try {
const catalogId = Number(payload.catalog_id);
const billId = Number(payload.bill_id);
const metadata = payload.metadata === undefined ? null : JSON.stringify(payload.metadata);
db.prepare(`
INSERT INTO subscription_recommendation_feedback
(user_id, catalog_id, bill_id, merchant, action, confidence, descriptor, metadata_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId,
Number.isInteger(catalogId) && catalogId > 0 ? catalogId : null,
Number.isInteger(billId) && billId > 0 ? billId : null,
payload.merchant ? normalizeMerchant(payload.merchant) : null,
action,
Number.isInteger(Number(payload.confidence)) ? Number(payload.confidence) : null,
payload.descriptor ? String(payload.descriptor).slice(0, 100) : null,
metadata,
);
} catch { /* pre-v0.97 */ }
}
function existingBillsForRecommendation(db, userId) {
return db.prepare(`
SELECT b.id, b.name, b.expected_amount, b.due_day, b.cycle_type, b.billing_cycle,
b.is_subscription, b.active, b.catalog_id, c.name AS category_name,
CASE WHEN EXISTS(
SELECT 1 FROM bill_merchant_rules r
WHERE r.bill_id = b.id AND r.user_id = b.user_id
) THEN 1 ELSE 0 END AS has_merchant_rule
FROM bills b
LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id AND c.deleted_at IS NULL
WHERE b.user_id = ?
AND b.deleted_at IS NULL
`).all(userId);
}
function dayDelta(a, b) {
const left = Math.min(Math.max(Number(a) || 1, 1), 31);
const right = Math.min(Math.max(Number(b) || 1, 1), 31);
return Math.abs(left - right);
}
function existingBillMatch(existingBills, { merchant, catalogEntry, averageAmount, lastDate }) {
const merchantKey = normalizeCatalogName(merchant);
const catalogKey = normalizeCatalogName(catalogEntry?.name);
const dueDay = Number(String(lastDate || '').slice(8, 10)) || 1;
let best = null;
for (const bill of existingBills) {
const billKey = normalizeCatalogName(bill.name);
if (!billKey) continue;
const reasons = [];
let score = 0;
if (catalogEntry?.id && Number(bill.catalog_id) === Number(catalogEntry.id)) {
score += 90;
reasons.push('Bill is already linked to this catalog service');
} else if (catalogKey && billKey === catalogKey) {
score += 72;
reasons.push('Bill name exactly matches the known service');
} else if (merchantKey && billKey === merchantKey) {
score += 66;
reasons.push('Bill name exactly matches the bank merchant');
} else if (catalogKey && (billKey.includes(catalogKey) || catalogKey.includes(billKey))) {
score += 48;
reasons.push('Bill name closely matches the known service');
} else if (merchantKey && merchantKey.length >= 4 && (billKey.includes(merchantKey) || merchantKey.includes(billKey))) {
score += 42;
reasons.push('Bill name closely matches the bank merchant');
}
if (score === 0) continue;
const expected = Number(bill.expected_amount || 0);
const amountDelta = expected ? Math.abs(expected - averageAmount) : null;
if (amountDelta !== null) {
const pct = expected ? amountDelta / expected : 1;
if (amountDelta <= 1 || pct <= 0.08) {
score += 20;
reasons.push('Bill amount matches this charge');
} else if (amountDelta <= 5 || pct <= 0.20) {
score += 8;
reasons.push('Bill amount is near this charge');
}
}
const dueDelta = dayDelta(bill.due_day, dueDay);
if (dueDelta === 0) {
score += 12;
reasons.push('Bill due day matches the latest charge date');
} else if (dueDelta <= 2) {
score += 9;
reasons.push('Bill due day is close to the latest charge date');
} else if (dueDelta <= 5) {
score += 5;
reasons.push('Bill due day is near the latest charge date');
}
if (bill.is_subscription) score += 5;
if (bill.has_merchant_rule) score += 3;
if (score >= 65 && (!best || score > best.score)) {
best = {
id: bill.id,
name: bill.name,
expected_amount: expected || null,
due_day: bill.due_day || null,
active: !!bill.active,
is_subscription: !!bill.is_subscription,
catalog_id: bill.catalog_id || null,
category_name: bill.category_name || null,
has_merchant_rule: !!bill.has_merchant_rule,
score,
strong: score >= 90 || (amountDelta !== null && amountDelta <= 1 && dueDelta <= 2),
amount_delta: amountDelta === null ? null : Math.round(amountDelta * 100) / 100,
due_day_delta: dueDelta,
reasons,
};
}
}
return best;
}
// ── Decline store ─────────────────────────────────────────────────────────────
function getDeclinedKeys(db, userId) {
@ -552,7 +736,8 @@ function declineRecommendation(db, userId, declineKey) {
function getSubscriptionRecommendations(db, userId) {
const catalog = loadCatalog(db, userId);
const catalogTypeMap = buildCatalogTypeMap(catalog);
const existingNames = existingBillNames(db, userId);
const existingBills = existingBillsForRecommendation(db, userId);
const feedback = loadRecommendationFeedback(db, userId);
const declined = getDeclinedKeys(db, userId);
const rows = db.prepare(`
@ -605,7 +790,6 @@ function getSubscriptionRecommendations(db, userId) {
const declineKey = catalogEntry ? `catalog:${catalogEntry.id}` : `merchant:${merchant}`;
if (declined.has(declineKey)) continue;
if (existingNames.some(n => n.includes(merchant) || merchant.includes(n))) continue;
const sorted = group.items
.filter(item => item.tx_date)
@ -629,15 +813,19 @@ function getSubscriptionRecommendations(db, userId) {
amountInfo = amountEvidence(averageAmount, cycleType, catalogEntry);
}
const cadenceInfo = cadenceEvidence(sorted, cycleType, 30, maxDelta, averageAmount);
const feedbackInfo = feedbackEvidence({ feedback, merchant, catalogEntry });
const scored = scoreKnownServiceRecommendation({
match: catalogIdentityMatch, merchant, catalogEntry, amountInfo, cadenceInfo,
match: catalogIdentityMatch, merchant, catalogEntry, amountInfo, cadenceInfo, feedbackInfo,
});
if (scored.confidence < 90) continue;
const billMatch = existingBillMatch(existingBills, {
merchant, catalogEntry, averageAmount, lastDate: last.tx_date,
});
recommendations.push(buildRecommendation({
merchant, catalogEntry, sorted, averageAmount, maxDelta, last,
cycleType, avgGap: 30, confidence: scored.confidence, tier: 'known_service',
declineKey, catalogTypeMap, identityInfo: scored.identity, amountInfo, cadenceInfo,
ambiguityInfo: scored.ambiguity,
ambiguityInfo: scored.ambiguity, existingBillMatch: billMatch, feedbackInfo,
}));
continue;
}
@ -664,16 +852,20 @@ function getSubscriptionRecommendations(db, userId) {
const amountInfo = amountEvidence(averageAmount, cycleType, catalogEntry);
const cadenceInfo = cadenceEvidence(sorted, cycleType, avgGap, maxDelta, averageAmount);
const feedbackInfo = feedbackEvidence({ feedback, merchant, catalogEntry });
const scored = scoreKnownServiceRecommendation({
match: catalogIdentityMatch, merchant, catalogEntry, amountInfo, cadenceInfo,
match: catalogIdentityMatch, merchant, catalogEntry, amountInfo, cadenceInfo, feedbackInfo,
});
if (scored.confidence < 90) continue;
const billMatch = existingBillMatch(existingBills, {
merchant, catalogEntry, averageAmount, lastDate: last.tx_date,
});
recommendations.push(buildRecommendation({
merchant, catalogEntry, sorted, averageAmount, maxDelta, last,
cycleType, avgGap, confidence: scored.confidence, tier: 'confirmed',
declineKey, catalogTypeMap, identityInfo: scored.identity, amountInfo, cadenceInfo,
ambiguityInfo: scored.ambiguity,
ambiguityInfo: scored.ambiguity, existingBillMatch: billMatch, feedbackInfo,
}));
}
@ -681,7 +873,12 @@ function getSubscriptionRecommendations(db, userId) {
// known service, keep only the highest-confidence one.
const seen = new Map();
const deduped = [];
for (const rec of recommendations.sort((a, b) => b.confidence - a.confidence || b.occurrence_count - a.occurrence_count)) {
for (const rec of recommendations.sort((a, b) => (
Number(!!b.existing_bill_match?.strong) - Number(!!a.existing_bill_match?.strong)
|| Number(!!b.existing_bill_match) - Number(!!a.existing_bill_match)
|| b.confidence - a.confidence
|| b.occurrence_count - a.occurrence_count
))) {
const key = rec.catalog_match ? `catalog:${rec.catalog_match.id}` : `merchant:${rec.merchant}`;
if (!seen.has(key)) {
seen.set(key, true);
@ -691,7 +888,7 @@ function getSubscriptionRecommendations(db, userId) {
return deduped.slice(0, 20);
}
function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, maxDelta, last, cycleType, avgGap, confidence, tier, declineKey, catalogTypeMap, identityInfo = null, amountInfo = null, cadenceInfo = null, ambiguityInfo = null }) {
function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, maxDelta, last, cycleType, avgGap, confidence, tier, declineKey, catalogTypeMap, identityInfo = null, amountInfo = null, cadenceInfo = null, ambiguityInfo = null, existingBillMatch = null, feedbackInfo = null }) {
const name = catalogEntry ? catalogEntry.name : titleCase(merchant);
const subscriptionType = inferType(merchant, catalogEntry, catalogTypeMap);
const accounts = Array.from(new Set(sorted
@ -704,8 +901,11 @@ function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, ma
if (amountInfo?.label) reasons.push(amountInfo.label);
if (cadenceInfo?.recurring && cadenceInfo.label) reasons.push(cadenceInfo.label);
if (ambiguityInfo?.ambiguous && ambiguityInfo.label) reasons.push(ambiguityInfo.label);
if (feedbackInfo?.label) reasons.push(feedbackInfo.label);
if (existingBillMatch) reasons.push(`Best action: link to existing bill "${existingBillMatch.name}"`);
reasons.push(`${last.currency || 'USD'} ${averageAmount.toFixed(2)} average`);
const recommendedAction = existingBillMatch ? 'link_existing_bill' : 'track_new';
return {
id: Buffer.from(`${merchant}:${Math.round(averageAmount)}:${last.tx_date}`).toString('base64url'),
name,
@ -719,6 +919,9 @@ function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, ma
occurrence_count: sorted.length,
confidence,
tier,
recommended_action: recommendedAction,
action_priority: existingBillMatch?.strong ? 'link_strong' : existingBillMatch ? 'link_possible' : 'track_new',
existing_bill_match: existingBillMatch,
catalog_match: catalogMatchPayload(catalogEntry),
evidence: {
identity: identityInfo,
@ -744,6 +947,12 @@ function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, ma
reasons: ambiguityInfo.reasons || [],
penalty: ambiguityInfo.penalty || 0,
} : { ambiguous: false, label: null, reasons: [], penalty: 0 },
feedback: feedbackInfo ? {
score: feedbackInfo.score || 0,
label: feedbackInfo.label,
positive_count: feedbackInfo.positive_count || 0,
negative_count: feedbackInfo.negative_count || 0,
} : { score: 0, label: null, positive_count: 0, negative_count: 0 },
},
transaction_ids: sorted.map(item => item.id),
transactions: sorted.map(item => ({
@ -902,5 +1111,6 @@ module.exports = {
loadCatalog,
monthlyEquivalent,
normalizeMerchant,
recordSubscriptionFeedback,
searchSubscriptionTransactions,
};

View File

@ -10,6 +10,7 @@ process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database');
const {
getSubscriptionRecommendations,
recordSubscriptionFeedback,
searchSubscriptionTransactions,
} = require('../services/subscriptionService');
@ -48,6 +49,22 @@ function createAccount(db, userId, monitored = true) {
`).run(userId, sourceId, `acct-${sourceId}`, monitored ? 1 : 0).lastInsertRowid;
}
function createBill(db, userId, overrides = {}) {
return db.prepare(`
INSERT INTO bills
(user_id, name, due_day, expected_amount, is_subscription, cycle_type, billing_cycle)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
userId,
overrides.name || 'Netflix',
overrides.due_day || 8,
overrides.expected_amount ?? 15.99,
overrides.is_subscription ?? 1,
overrides.cycle_type || 'monthly',
overrides.billing_cycle || 'monthly',
).lastInsertRowid;
}
test.after(() => {
closeDb();
for (const suffix of ['', '-wal', '-shm']) {
@ -73,6 +90,60 @@ test('known catalog services appear as high-confidence subscription recommendati
assert.equal(netflix.evidence.amount.match, 'monthly_plausible');
});
test('existing tracked bills are recommended for linking instead of tracking again', () => {
const db = getDb();
const userId = createUser(db, 'existing-bill');
const accountId = createAccount(db, userId, true);
const billId = createBill(db, userId, {
name: 'Netflix',
due_day: 12,
expected_amount: 15.99,
is_subscription: 1,
});
createTransaction(db, userId, {
account_id: accountId,
description: 'NETFLIX.COM',
payee: 'NETFLIX.COM',
amount: -1599,
posted_date: '2026-04-12',
});
const recommendations = getSubscriptionRecommendations(db, userId);
const netflix = recommendations.find(item => item.catalog_match?.name === 'Netflix');
assert.ok(netflix, 'Netflix should still be recommended when an existing bill is present');
assert.equal(netflix.recommended_action, 'link_existing_bill');
assert.equal(netflix.existing_bill_match.id, billId);
assert.equal(netflix.existing_bill_match.strong, true);
});
test('subscription recommendation feedback boosts future matching for the user', () => {
const db = getDb();
const userId = createUser(db, 'feedback-boost');
const accountId = createAccount(db, userId, true);
createTransaction(db, userId, {
account_id: accountId,
description: 'NETFLIX.COM',
payee: 'NETFLIX.COM',
amount: -1599,
posted_date: '2026-04-12',
});
const before = getSubscriptionRecommendations(db, userId).find(item => item.catalog_match?.name === 'Netflix');
assert.ok(before, 'Netflix should be recommended before feedback');
recordSubscriptionFeedback(db, userId, {
action: 'accept_track_new',
catalog_id: before.catalog_match.id,
merchant: before.merchant,
confidence: before.confidence,
});
const after = getSubscriptionRecommendations(db, userId).find(item => item.catalog_match?.name === 'Netflix');
assert.ok(after, 'Netflix should still be recommended after feedback');
assert.equal(after.confidence > before.confidence, true);
assert.equal(after.evidence.feedback.positive_count > 0, true);
});
test('weak one-off known service names stay below the recommendation threshold', () => {
const db = getDb();
const userId = createUser(db, 'weak-known');
@ -88,6 +159,83 @@ test('weak one-off known service names stay below the recommendation threshold',
assert.equal(recommendations.some(item => item.catalog_match?.name === 'Max'), false);
});
test('broad Apple, Amazon, and Google one-off purchases with odd amounts are not recommended', () => {
const db = getDb();
const userId = createUser(db, 'broad-one-offs');
const accountId = createAccount(db, userId, true);
createTransaction(db, userId, {
account_id: accountId,
description: 'APPLE.COM/BILL',
payee: 'APPLE.COM/BILL',
amount: -19999,
posted_date: '2026-04-02',
});
createTransaction(db, userId, {
account_id: accountId,
description: 'AMAZON MKTPLACE PMTS',
payee: 'AMAZON MKTPLACE PMTS',
amount: -8700,
posted_date: '2026-04-03',
});
createTransaction(db, userId, {
account_id: accountId,
description: 'GOOGLE *STORE',
payee: 'GOOGLE *STORE',
amount: -64999,
posted_date: '2026-04-04',
});
const recommendations = getSubscriptionRecommendations(db, userId);
const names = recommendations.map(item => item.catalog_match?.name || item.name).join(' ');
assert.doesNotMatch(names, /Apple|Amazon|Google/);
});
test('annual known-service charges can be recommended when catalog annual pricing matches', () => {
const db = getDb();
const userId = createUser(db, 'annual-known');
const accountId = createAccount(db, userId, true);
db.prepare("UPDATE subscription_catalog SET starting_annual_usd = 191.88 WHERE name = 'Netflix'").run();
createTransaction(db, userId, {
account_id: accountId,
description: 'NETFLIX.COM',
payee: 'NETFLIX.COM',
amount: -19188,
posted_date: '2026-04-12',
});
const recommendations = getSubscriptionRecommendations(db, userId);
const netflix = recommendations.find(item => item.catalog_match?.name === 'Netflix');
assert.ok(netflix, 'Annual Netflix charge should be recommended from exact descriptor and annual price');
assert.equal(netflix.cycle_type, 'annual');
assert.equal(netflix.evidence.amount.match, 'annual_close');
});
test('exact user descriptors produce high-confidence one-off recommendations', () => {
const db = getDb();
const userId = createUser(db, 'custom-descriptor');
const accountId = createAccount(db, userId, true);
const netflixCatalog = db.prepare("SELECT id FROM subscription_catalog WHERE name = 'Netflix'").get();
db.prepare(`
INSERT INTO user_catalog_descriptors (user_id, catalog_id, descriptor)
VALUES (?, ?, 'NFX CUSTOM BILL')
`).run(userId, netflixCatalog.id);
createTransaction(db, userId, {
account_id: accountId,
description: 'NFX CUSTOM BILL',
payee: 'NFX CUSTOM BILL',
amount: -1599,
posted_date: '2026-04-12',
});
const recommendations = getSubscriptionRecommendations(db, userId);
const netflix = recommendations.find(item => item.catalog_match?.name === 'Netflix');
assert.ok(netflix, 'Custom descriptor should produce a Netflix recommendation');
assert.equal(netflix.confidence >= 90, true);
assert.equal(netflix.evidence.identity.type, 'user_descriptor');
});
test('ambiguous recurring known service names are flagged for review', () => {
const db = getDb();
const userId = createUser(db, 'ambiguous-known');