feat: recommendation detail dialog with evidence, ambiguity badges, transaction list
This commit is contained in:
parent
b2f8f5ef66
commit
422d8550bb
|
|
@ -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 (
|
||||
<div className="rounded-lg border border-primary/20 bg-primary/[0.05] p-4">
|
||||
|
|
@ -347,6 +350,12 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o
|
|||
Recurring
|
||||
</Badge>
|
||||
)}
|
||||
{ambiguity?.ambiguous && (
|
||||
<Badge variant="outline" className="gap-1 border-amber-500/25 bg-amber-500/10 text-amber-600 dark:text-amber-300">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Review
|
||||
</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)}
|
||||
|
|
@ -359,7 +368,18 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o
|
|||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 min-[380px]:grid-cols-3">
|
||||
<div className="grid grid-cols-2 gap-2 min-[520px]:grid-cols-4">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5"
|
||||
disabled={busy}
|
||||
onClick={() => onDetails(recommendation)}
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
Details
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
|
@ -398,6 +418,160 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o
|
|||
);
|
||||
}
|
||||
|
||||
function EvidenceItem({ label, value, tone = 'default' }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className={cn(
|
||||
'rounded-lg border px-3 py-2',
|
||||
tone === 'warn'
|
||||
? 'border-amber-500/25 bg-amber-500/10'
|
||||
: 'border-border/60 bg-muted/20',
|
||||
)}>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-sm font-medium text-foreground">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={value => { if (!value) onClose(); }}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex flex-wrap items-center gap-2">
|
||||
<span>{recommendation.name}</span>
|
||||
<Badge variant="outline" className="border-primary/25 bg-primary/10 text-primary">
|
||||
{recommendation.confidence}% match
|
||||
</Badge>
|
||||
{ambiguity?.ambiguous && (
|
||||
<Badge variant="outline" className="gap-1 border-amber-500/25 bg-amber-500/10 text-amber-600 dark:text-amber-300">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Review
|
||||
</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)}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<EvidenceItem label="Expected" value={`${fmt(recommendation.expected_amount)} ${recommendation.cycle_type || 'monthly'}`} />
|
||||
<EvidenceItem label="Monthly" value={`${fmt(recommendation.monthly_equivalent)} / mo`} />
|
||||
<EvidenceItem label="Identity" value={identity?.label} />
|
||||
<EvidenceItem label="Price" value={amount?.label} tone={amount?.match === 'unusual' ? 'warn' : 'default'} />
|
||||
<EvidenceItem label="Cadence" value={cadence?.label} />
|
||||
<EvidenceItem
|
||||
label="Catalog"
|
||||
value={recommendation.catalog_match
|
||||
? [
|
||||
recommendation.catalog_match.name,
|
||||
recommendation.catalog_match.starting_monthly_usd ? `from ${fmt(recommendation.catalog_match.starting_monthly_usd)} / mo` : null,
|
||||
].filter(Boolean).join(' · ')
|
||||
: null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{amountRange && (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Amount Range</p>
|
||||
<p className="mt-1 text-sm font-medium text-foreground">
|
||||
{fmt(amountRange.min)}-{fmt(amountRange.max)} across matching transactions
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ambiguity?.ambiguous && (
|
||||
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-3 py-2">
|
||||
<p className="flex items-center gap-1.5 text-sm font-semibold text-amber-700 dark:text-amber-300">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{ambiguity.label || 'Review before tracking'}
|
||||
</p>
|
||||
{ambiguity.reasons?.length > 0 && (
|
||||
<div className="mt-2 space-y-1 text-sm text-muted-foreground">
|
||||
{ambiguity.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 => (
|
||||
<span key={reason} className="rounded-md border border-border/60 bg-background/60 px-2 py-1 text-[11px] font-medium text-muted-foreground">
|
||||
{reason}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{transactions.length > 0 && (
|
||||
<div className="overflow-hidden rounded-lg border border-border/60">
|
||||
<div className="border-b border-border/50 bg-muted/20 px-3 py-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Bank Transactions
|
||||
</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border/40">
|
||||
{transactions.map(tx => (
|
||||
<div key={tx.id} className="grid gap-1 px-3 py-2.5 sm:grid-cols-[1fr_auto] sm:items-center">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{tx.payee || tx.description || tx.memo || 'Transaction'}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{fmtDate(tx.date)}{tx.account ? ` · ${tx.account}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<p className="tracker-number text-sm font-semibold text-foreground">{fmt(tx.amount)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="ghost" onClick={handleDecline} disabled={busy} className="gap-1.5 text-muted-foreground hover:text-destructive">
|
||||
{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">
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
Link to bill
|
||||
</Button>
|
||||
<Button 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
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
|||
/>
|
||||
)}
|
||||
|
||||
<RecommendationDetailsDialog
|
||||
open={!!detailsTarget}
|
||||
recommendation={detailsTarget}
|
||||
categoryId={subscriptionCategoryId}
|
||||
onClose={() => 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}
|
||||
/>
|
||||
|
||||
<BillPickerDialog
|
||||
open={!!matchTarget}
|
||||
onClose={() => setMatchTarget(null)}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue