diff --git a/client/pages/SubscriptionsPage.jsx b/client/pages/SubscriptionsPage.jsx index 8afedbb..abd675c 100644 --- a/client/pages/SubscriptionsPage.jsx +++ b/client/pages/SubscriptionsPage.jsx @@ -10,6 +10,8 @@ import { ArrowDown, ArrowUp, GripVertical, + AlertTriangle, + Info, Link2, Loader2, Pause, @@ -283,11 +285,12 @@ function TxResultRow({ tx, onTrack }) { ); } -function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, onMatch, busy }) { +function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, onMatch, 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; return (
@@ -347,6 +350,12 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o Recurring )} + {ambiguity?.ambiguous && ( + + + Review + + )} {amountRange && amountRange.min !== amountRange.max && ( Range {fmt(amountRange.min)}-{fmt(amountRange.max)} @@ -359,7 +368,18 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o ))}
-
+
+
+ ); +} + +function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose, onAccept, onDecline, onMatch, busy }) { + if (!recommendation) return null; + + 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 transactions = recommendation.transactions || []; + const handleAccept = async () => { + await onAccept({ ...recommendation, category_id: categoryId }); + onClose(); + }; + const handleDecline = async () => { + await onDecline(recommendation); + onClose(); + }; + const handleMatch = () => { + onMatch(recommendation); + onClose(); + }; + + return ( + { if (!value) onClose(); }}> + + + + {recommendation.name} + + {recommendation.confidence}% match + + {ambiguity?.ambiguous && ( + + + Review + + )} + +

+ {TYPE_LABELS[recommendation.subscription_type] || 'Other'} · {recommendation.occurrence_count} charge{recommendation.occurrence_count !== 1 ? 's' : ''} · last seen {fmtDate(recommendation.last_seen_date)} +

+
+ +
+ + + + + + +
+ + {amountRange && ( +
+

Amount Range

+

+ {fmt(amountRange.min)}-{fmt(amountRange.max)} across matching transactions +

+
+ )} + + {ambiguity?.ambiguous && ( +
+

+ + {ambiguity.label || 'Review before tracking'} +

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

{reason}

+ ))} +
+ )} +
+ )} + + {recommendation.reasons?.length > 0 && ( +
+ {recommendation.reasons.map(reason => ( + + {reason} + + ))} +
+ )} + + {transactions.length > 0 && ( +
+
+

+ Bank Transactions +

+
+
+ {transactions.map(tx => ( +
+
+

+ {tx.payee || tx.description || tx.memo || 'Transaction'} +

+

+ {fmtDate(tx.date)}{tx.account ? ` · ${tx.account}` : ''} +

+
+

{fmt(tx.amount)}

+
+ ))} +
+
+ )} + + + + + + +
+
+ ); +} + export default function SubscriptionsPage() { const [data, setData] = useState({ summary: {}, subscriptions: [] }); const [recommendations, setRecommendations] = useState([]); @@ -408,6 +582,7 @@ export default function SubscriptionsPage() { const [busyId, setBusyId] = useState(null); const [modal, setModal] = useState(null); const [matchTarget, setMatchTarget] = useState(null); + const [detailsTarget, setDetailsTarget] = useState(null); const [recSearch, setRecSearch] = useState(''); const [draggingId, setDraggingId] = useState(null); const [dropTargetId, setDropTargetId] = useState(null); @@ -817,6 +992,7 @@ export default function SubscriptionsPage() { onAccept={acceptRecommendation} onDecline={declineRecommendation} onMatch={rec => setMatchTarget(rec)} + onDetails={setDetailsTarget} /> )) )} @@ -914,6 +1090,21 @@ export default function SubscriptionsPage() { /> )} + setDetailsTarget(null)} + onAccept={acceptRecommendation} + onDecline={declineRecommendation} + onMatch={rec => setMatchTarget(rec)} + busy={detailsTarget ? ( + busyId === `rec-${detailsTarget.id}` + || busyId === `dec-${detailsTarget.id}` + || busyId === `match-${detailsTarget.id}` + ) : false} + /> + setMatchTarget(null)} diff --git a/services/subscriptionService.js b/services/subscriptionService.js index 12ee14f..6984633 100644 --- a/services/subscriptionService.js +++ b/services/subscriptionService.js @@ -38,6 +38,18 @@ const TYPE_KEYWORDS = [ ['security', ['nordvpn', 'expressvpn', '1password', 'dashlane', 'norton', 'mcafee', 'surfshark']], ]; +const AMBIGUOUS_CATALOG_TERMS = new Map([ + ['amazon', 'Amazon charges may be retail orders, Prime, media, Kindle, or subscriptions.'], + ['amzn', 'Amazon charges may be retail orders, Prime, media, Kindle, or subscriptions.'], + ['apple', 'Apple charges may be app purchases, iCloud, Apple One, media, or hardware.'], + ['google', 'Google charges may be storage, YouTube, app purchases, ads, or domains.'], + ['microsoft', 'Microsoft charges may be software, Xbox, cloud, or marketplace purchases.'], + ['walmart', 'Walmart charges may be retail orders, delivery, Walmart+, or pharmacy purchases.'], + ['target', 'Target charges may be retail orders, Circle, delivery, or pharmacy purchases.'], + ['disney', 'Disney charges may be streaming, parks, retail, or bundle charges.'], + ['max', 'Max is a short descriptor and can be confused with unrelated merchant text.'], +]); + // ── Catalog ─────────────────────────────────────────────────────────────────── function loadCatalog(db, userId) { @@ -149,6 +161,9 @@ function lookupCatalogMatch(catalog, merchantText) { for (const desc of (entry.bankDescs || [])) { const value = desc.value || desc; if (value.length >= 4 && (normalized.includes(value) || value.includes(normalized))) { + const containment = normalized.includes(value) + ? 'transaction_contains_descriptor' + : 'descriptor_contains_transaction'; const score = (desc.source === 'user_bank' ? 2200 : 2000) + value.length; if (score > bestScore) { best = entry; @@ -157,6 +172,7 @@ function lookupCatalogMatch(catalog, merchantText) { type: desc.source === 'user_bank' ? 'user_descriptor' : 'bank_descriptor', label: desc.source === 'user_bank' ? 'custom bank descriptor' : 'known bank descriptor', descriptor: value, + containment, }; } } @@ -267,7 +283,12 @@ function identityEvidence(match) { slang: { score: 66, label: 'Matched a known alternate name' }, unknown: { score: 60, label: 'Matched the service catalog' }, }; - return { type, descriptor: match?.descriptor || null, ...(table[type] || table.unknown) }; + return { + type, + descriptor: match?.descriptor || null, + containment: match?.containment || null, + ...(table[type] || table.unknown), + }; } function priceClose(amount, expected) { @@ -353,12 +374,61 @@ function cadenceEvidence(sorted, cycleType, avgGap, maxDelta, averageAmount) { }; } -function scoreKnownServiceRecommendation({ match, amountInfo, cadenceInfo }) { +function catalogTextForAmbiguity(merchant, catalogEntry, identityInfo) { + return normalizeMerchant([ + merchant, + catalogEntry?.name, + catalogEntry?.domain, + catalogEntry?.website, + identityInfo?.descriptor, + ].filter(Boolean).join(' ')); +} + +function ambiguityEvidence({ merchant, catalogEntry, identityInfo, amountInfo, cadenceInfo }) { + const identityType = identityInfo?.type || 'unknown'; + const compactMerchant = normalizeMerchant(merchant).replace(/\s+/g, ''); + const descriptorContainsShortTransaction = identityInfo?.containment === 'descriptor_contains_transaction' + && compactMerchant.length > 0 + && compactMerchant.length <= 4; + const weakIdentity = !['user_descriptor', 'bank_descriptor'].includes(identityType) + || descriptorContainsShortTransaction; + 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; + + if (!hit && !shortDescriptor) { + return { ambiguous: false, penalty: 0, label: null, reasons: [] }; + } + + const recurringStrong = !!cadenceInfo?.recurring && amountInfo?.score >= 8; + const penalty = recurringStrong ? 6 : 16; + const reasons = []; + if (hit) reasons.push(hit[1]); + 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.'); + + return { + ambiguous: true, + penalty, + label: 'Broad match - review before tracking', + reasons: Array.from(new Set(reasons)), + }; +} + +function scoreKnownServiceRecommendation({ match, merchant, catalogEntry, amountInfo, cadenceInfo }) { 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 + identity.score + amountInfo.score + cadenceInfo.score - ambiguity.penalty )); - return { confidence, identity }; + return { confidence, identity, ambiguity }; } function monthlyEquivalent(amount, cycleType, billingCycle) { @@ -451,6 +521,13 @@ function dollarsFromTransactionAmount(amount) { return Math.round((Math.abs(Number(amount || 0)) / 100) * 100) / 100; } +function recommendationAccountLabel(item) { + const accountName = item.account_name || ''; + const orgName = item.account_org_name || ''; + if (accountName && orgName && accountName !== orgName) return `${orgName} · ${accountName}`; + return accountName || orgName || item.data_source_name || ''; +} + // ── Decline store ───────────────────────────────────────────────────────────── function getDeclinedKeys(db, userId) { @@ -553,13 +630,14 @@ function getSubscriptionRecommendations(db, userId) { } const cadenceInfo = cadenceEvidence(sorted, cycleType, 30, maxDelta, averageAmount); const scored = scoreKnownServiceRecommendation({ - match: catalogIdentityMatch, amountInfo, cadenceInfo, sorted, + match: catalogIdentityMatch, merchant, catalogEntry, amountInfo, cadenceInfo, }); if (scored.confidence < 90) continue; 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, })); continue; } @@ -587,7 +665,7 @@ function getSubscriptionRecommendations(db, userId) { const amountInfo = amountEvidence(averageAmount, cycleType, catalogEntry); const cadenceInfo = cadenceEvidence(sorted, cycleType, avgGap, maxDelta, averageAmount); const scored = scoreKnownServiceRecommendation({ - match: catalogIdentityMatch, amountInfo, cadenceInfo, sorted, + match: catalogIdentityMatch, merchant, catalogEntry, amountInfo, cadenceInfo, }); if (scored.confidence < 90) continue; @@ -595,6 +673,7 @@ function getSubscriptionRecommendations(db, userId) { merchant, catalogEntry, sorted, averageAmount, maxDelta, last, cycleType, avgGap, confidence: scored.confidence, tier: 'confirmed', declineKey, catalogTypeMap, identityInfo: scored.identity, amountInfo, cadenceInfo, + ambiguityInfo: scored.ambiguity, })); } @@ -612,16 +691,11 @@ 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 }) { +function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, maxDelta, last, cycleType, avgGap, confidence, tier, declineKey, catalogTypeMap, identityInfo = null, amountInfo = null, cadenceInfo = null, ambiguityInfo = null }) { const name = catalogEntry ? catalogEntry.name : titleCase(merchant); const subscriptionType = inferType(merchant, catalogEntry, catalogTypeMap); const accounts = Array.from(new Set(sorted - .map(item => { - const accountName = item.account_name || ''; - const orgName = item.account_org_name || ''; - if (accountName && orgName && accountName !== orgName) return `${orgName} · ${accountName}`; - return accountName || orgName || item.data_source_name || ''; - }) + .map(recommendationAccountLabel) .filter(Boolean))); const reasons = []; @@ -629,6 +703,7 @@ function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, ma if (identityInfo?.label) reasons.push(identityInfo.label); 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); reasons.push(`${last.currency || 'USD'} ${averageAmount.toFixed(2)} average`); return { @@ -663,8 +738,24 @@ function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, ma max: Math.max(...sorted.map(item => item.amount_dollars)), max_delta: Math.round(maxDelta * 100) / 100, } : null, + ambiguity: ambiguityInfo ? { + ambiguous: !!ambiguityInfo.ambiguous, + label: ambiguityInfo.label, + reasons: ambiguityInfo.reasons || [], + penalty: ambiguityInfo.penalty || 0, + } : { ambiguous: false, label: null, reasons: [], penalty: 0 }, }, transaction_ids: sorted.map(item => item.id), + transactions: sorted.map(item => ({ + id: item.id, + date: item.tx_date, + amount: item.amount_dollars, + currency: item.currency || 'USD', + payee: item.payee || null, + description: item.description || null, + memo: item.memo || null, + account: recommendationAccountLabel(item) || null, + })), merchant, decline_key: declineKey, source: last.data_source_name || 'Transaction history', diff --git a/tests/subscriptionService.test.js b/tests/subscriptionService.test.js index c0427a0..cc57d3a 100644 --- a/tests/subscriptionService.test.js +++ b/tests/subscriptionService.test.js @@ -88,6 +88,42 @@ test('weak one-off known service names stay below the recommendation threshold', assert.equal(recommendations.some(item => item.catalog_match?.name === 'Max'), false); }); +test('ambiguous recurring known service names are flagged for review', () => { + const db = getDb(); + const userId = createUser(db, 'ambiguous-known'); + const accountId = createAccount(db, userId, true); + createTransaction(db, userId, { + account_id: accountId, + description: 'MAX', + payee: 'MAX', + amount: -1099, + posted_date: '2026-01-08', + }); + createTransaction(db, userId, { + account_id: accountId, + description: 'MAX', + payee: 'MAX', + amount: -1099, + posted_date: '2026-02-08', + }); + createTransaction(db, userId, { + account_id: accountId, + description: 'MAX', + payee: 'MAX', + amount: -1099, + posted_date: '2026-03-08', + }); + + const recommendations = getSubscriptionRecommendations(db, userId); + const max = recommendations.find(item => item.catalog_match?.name === 'Max'); + + assert.ok(max, 'Recurring Max charges should still be recommended'); + assert.equal(max.confidence >= 90, true); + assert.equal(max.evidence.ambiguity.ambiguous, true); + assert.equal(max.evidence.ambiguity.penalty > 0, true); + assert.equal(max.transactions.length, 3); +}); + test('unknown recurring patterns do not appear as known-service recommendations', () => { const db = getDb(); const userId = createUser(db, 'unknown-pattern');