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 (
+
+ );
+}
+
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');