diff --git a/client/api.js b/client/api.js index b3c9399..7995916 100644 --- a/client/api.js +++ b/client/api.js @@ -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 }), diff --git a/client/pages/SubscriptionsPage.jsx b/client/pages/SubscriptionsPage.jsx index abd675c..02a7b5d 100644 --- a/client/pages/SubscriptionsPage.jsx +++ b/client/pages/SubscriptionsPage.jsx @@ -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 (
@@ -314,6 +315,11 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o {recommendation.accounts.length > 1 ? 'Accounts' : 'Account'}: {recommendation.accounts.join(', ')}

)} + {existingBill && ( +

+ Link to existing bill: {existingBill.name} +

+ )}

{fmt(recommendation.expected_amount)}

@@ -356,6 +362,11 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o Review )} + {existingBill && ( + + Existing bill + + )} {amountRange && amountRange.min !== amountRange.max && ( Range {fmt(amountRange.min)}-{fmt(amountRange.max)} @@ -394,23 +405,24 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o
@@ -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 ( { if (!value) onClose(); }}> @@ -470,6 +488,11 @@ function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose Review )} + {existingBill && ( + + Existing bill + + )}

{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 )} + {existingBill && ( +

+

Recommended action: link existing bill

+

+ {existingBill.name} · {existingBill.expected_amount ? fmt(existingBill.expected_amount) : 'No amount'} · due day {existingBill.due_day || 'not set'} +

+ {existingBill.reasons?.length > 0 && ( +
+ {existingBill.reasons.map(reason => ( +

{reason}

+ ))} +
+ )} +
+ )} + {recommendation.reasons?.length > 0 && (
{recommendation.reasons.map(reason => ( @@ -558,13 +597,13 @@ function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose {busy ? : } Decline - - @@ -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}` diff --git a/db/database.js b/db/database.js index 15c006e..326c130 100644 --- a/db/database.js +++ b/db/database.js @@ -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: [ diff --git a/routes/subscriptions.js b/routes/subscriptions.js index 4528174..d59509c 100644 --- a/routes/subscriptions.js +++ b/routes/subscriptions.js @@ -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')); diff --git a/services/subscriptionService.js b/services/subscriptionService.js index 6984633..7758b3d 100644 --- a/services/subscriptionService.js +++ b/services/subscriptionService.js @@ -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, }; diff --git a/tests/subscriptionService.test.js b/tests/subscriptionService.test.js index cc57d3a..78752c8 100644 --- a/tests/subscriptionService.test.js +++ b/tests/subscriptionService.test.js @@ -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');