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,
|
ArrowDown,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
|
AlertTriangle,
|
||||||
|
Info,
|
||||||
Link2,
|
Link2,
|
||||||
Loader2,
|
Loader2,
|
||||||
Pause,
|
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 identity = recommendation.evidence?.identity;
|
||||||
const amount = recommendation.evidence?.amount;
|
const amount = recommendation.evidence?.amount;
|
||||||
const cadence = recommendation.evidence?.cadence;
|
const cadence = recommendation.evidence?.cadence;
|
||||||
const amountRange = recommendation.evidence?.amount_range;
|
const amountRange = recommendation.evidence?.amount_range;
|
||||||
|
const ambiguity = recommendation.evidence?.ambiguity;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-primary/20 bg-primary/[0.05] p-4">
|
<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
|
Recurring
|
||||||
</Badge>
|
</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 && (
|
{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">
|
<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)}
|
Range {fmt(amountRange.min)}-{fmt(amountRange.max)}
|
||||||
|
|
@ -359,7 +368,18 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o
|
||||||
))}
|
))}
|
||||||
</div>
|
</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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
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() {
|
export default function SubscriptionsPage() {
|
||||||
const [data, setData] = useState({ summary: {}, subscriptions: [] });
|
const [data, setData] = useState({ summary: {}, subscriptions: [] });
|
||||||
const [recommendations, setRecommendations] = useState([]);
|
const [recommendations, setRecommendations] = useState([]);
|
||||||
|
|
@ -408,6 +582,7 @@ export default function SubscriptionsPage() {
|
||||||
const [busyId, setBusyId] = useState(null);
|
const [busyId, setBusyId] = useState(null);
|
||||||
const [modal, setModal] = useState(null);
|
const [modal, setModal] = useState(null);
|
||||||
const [matchTarget, setMatchTarget] = useState(null);
|
const [matchTarget, setMatchTarget] = useState(null);
|
||||||
|
const [detailsTarget, setDetailsTarget] = useState(null);
|
||||||
const [recSearch, setRecSearch] = useState('');
|
const [recSearch, setRecSearch] = useState('');
|
||||||
const [draggingId, setDraggingId] = useState(null);
|
const [draggingId, setDraggingId] = useState(null);
|
||||||
const [dropTargetId, setDropTargetId] = useState(null);
|
const [dropTargetId, setDropTargetId] = useState(null);
|
||||||
|
|
@ -817,6 +992,7 @@ export default function SubscriptionsPage() {
|
||||||
onAccept={acceptRecommendation}
|
onAccept={acceptRecommendation}
|
||||||
onDecline={declineRecommendation}
|
onDecline={declineRecommendation}
|
||||||
onMatch={rec => setMatchTarget(rec)}
|
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
|
<BillPickerDialog
|
||||||
open={!!matchTarget}
|
open={!!matchTarget}
|
||||||
onClose={() => setMatchTarget(null)}
|
onClose={() => setMatchTarget(null)}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,18 @@ const TYPE_KEYWORDS = [
|
||||||
['security', ['nordvpn', 'expressvpn', '1password', 'dashlane', 'norton', 'mcafee', 'surfshark']],
|
['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 ───────────────────────────────────────────────────────────────────
|
// ── Catalog ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function loadCatalog(db, userId) {
|
function loadCatalog(db, userId) {
|
||||||
|
|
@ -149,6 +161,9 @@ function lookupCatalogMatch(catalog, merchantText) {
|
||||||
for (const desc of (entry.bankDescs || [])) {
|
for (const desc of (entry.bankDescs || [])) {
|
||||||
const value = desc.value || desc;
|
const value = desc.value || desc;
|
||||||
if (value.length >= 4 && (normalized.includes(value) || value.includes(normalized))) {
|
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;
|
const score = (desc.source === 'user_bank' ? 2200 : 2000) + value.length;
|
||||||
if (score > bestScore) {
|
if (score > bestScore) {
|
||||||
best = entry;
|
best = entry;
|
||||||
|
|
@ -157,6 +172,7 @@ function lookupCatalogMatch(catalog, merchantText) {
|
||||||
type: desc.source === 'user_bank' ? 'user_descriptor' : 'bank_descriptor',
|
type: desc.source === 'user_bank' ? 'user_descriptor' : 'bank_descriptor',
|
||||||
label: desc.source === 'user_bank' ? 'custom bank descriptor' : 'known bank descriptor',
|
label: desc.source === 'user_bank' ? 'custom bank descriptor' : 'known bank descriptor',
|
||||||
descriptor: value,
|
descriptor: value,
|
||||||
|
containment,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -267,7 +283,12 @@ function identityEvidence(match) {
|
||||||
slang: { score: 66, label: 'Matched a known alternate name' },
|
slang: { score: 66, label: 'Matched a known alternate name' },
|
||||||
unknown: { score: 60, label: 'Matched the service catalog' },
|
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) {
|
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 identity = identityEvidence(match);
|
||||||
|
const ambiguity = ambiguityEvidence({ merchant, catalogEntry, identityInfo: identity, amountInfo, cadenceInfo });
|
||||||
const confidence = Math.min(99, Math.max(0,
|
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) {
|
function monthlyEquivalent(amount, cycleType, billingCycle) {
|
||||||
|
|
@ -451,6 +521,13 @@ function dollarsFromTransactionAmount(amount) {
|
||||||
return Math.round((Math.abs(Number(amount || 0)) / 100) * 100) / 100;
|
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 ─────────────────────────────────────────────────────────────
|
// ── Decline store ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function getDeclinedKeys(db, userId) {
|
function getDeclinedKeys(db, userId) {
|
||||||
|
|
@ -553,13 +630,14 @@ function getSubscriptionRecommendations(db, userId) {
|
||||||
}
|
}
|
||||||
const cadenceInfo = cadenceEvidence(sorted, cycleType, 30, maxDelta, averageAmount);
|
const cadenceInfo = cadenceEvidence(sorted, cycleType, 30, maxDelta, averageAmount);
|
||||||
const scored = scoreKnownServiceRecommendation({
|
const scored = scoreKnownServiceRecommendation({
|
||||||
match: catalogIdentityMatch, amountInfo, cadenceInfo, sorted,
|
match: catalogIdentityMatch, merchant, catalogEntry, amountInfo, cadenceInfo,
|
||||||
});
|
});
|
||||||
if (scored.confidence < 90) continue;
|
if (scored.confidence < 90) continue;
|
||||||
recommendations.push(buildRecommendation({
|
recommendations.push(buildRecommendation({
|
||||||
merchant, catalogEntry, sorted, averageAmount, maxDelta, last,
|
merchant, catalogEntry, sorted, averageAmount, maxDelta, last,
|
||||||
cycleType, avgGap: 30, confidence: scored.confidence, tier: 'known_service',
|
cycleType, avgGap: 30, confidence: scored.confidence, tier: 'known_service',
|
||||||
declineKey, catalogTypeMap, identityInfo: scored.identity, amountInfo, cadenceInfo,
|
declineKey, catalogTypeMap, identityInfo: scored.identity, amountInfo, cadenceInfo,
|
||||||
|
ambiguityInfo: scored.ambiguity,
|
||||||
}));
|
}));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -587,7 +665,7 @@ function getSubscriptionRecommendations(db, userId) {
|
||||||
const amountInfo = amountEvidence(averageAmount, cycleType, catalogEntry);
|
const amountInfo = amountEvidence(averageAmount, cycleType, catalogEntry);
|
||||||
const cadenceInfo = cadenceEvidence(sorted, cycleType, avgGap, maxDelta, averageAmount);
|
const cadenceInfo = cadenceEvidence(sorted, cycleType, avgGap, maxDelta, averageAmount);
|
||||||
const scored = scoreKnownServiceRecommendation({
|
const scored = scoreKnownServiceRecommendation({
|
||||||
match: catalogIdentityMatch, amountInfo, cadenceInfo, sorted,
|
match: catalogIdentityMatch, merchant, catalogEntry, amountInfo, cadenceInfo,
|
||||||
});
|
});
|
||||||
if (scored.confidence < 90) continue;
|
if (scored.confidence < 90) continue;
|
||||||
|
|
||||||
|
|
@ -595,6 +673,7 @@ function getSubscriptionRecommendations(db, userId) {
|
||||||
merchant, catalogEntry, sorted, averageAmount, maxDelta, last,
|
merchant, catalogEntry, sorted, averageAmount, maxDelta, last,
|
||||||
cycleType, avgGap, confidence: scored.confidence, tier: 'confirmed',
|
cycleType, avgGap, confidence: scored.confidence, tier: 'confirmed',
|
||||||
declineKey, catalogTypeMap, identityInfo: scored.identity, amountInfo, cadenceInfo,
|
declineKey, catalogTypeMap, identityInfo: scored.identity, amountInfo, cadenceInfo,
|
||||||
|
ambiguityInfo: scored.ambiguity,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -612,16 +691,11 @@ function getSubscriptionRecommendations(db, userId) {
|
||||||
return deduped.slice(0, 20);
|
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 name = catalogEntry ? catalogEntry.name : titleCase(merchant);
|
||||||
const subscriptionType = inferType(merchant, catalogEntry, catalogTypeMap);
|
const subscriptionType = inferType(merchant, catalogEntry, catalogTypeMap);
|
||||||
const accounts = Array.from(new Set(sorted
|
const accounts = Array.from(new Set(sorted
|
||||||
.map(item => {
|
.map(recommendationAccountLabel)
|
||||||
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 || '';
|
|
||||||
})
|
|
||||||
.filter(Boolean)));
|
.filter(Boolean)));
|
||||||
|
|
||||||
const reasons = [];
|
const reasons = [];
|
||||||
|
|
@ -629,6 +703,7 @@ function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, ma
|
||||||
if (identityInfo?.label) reasons.push(identityInfo.label);
|
if (identityInfo?.label) reasons.push(identityInfo.label);
|
||||||
if (amountInfo?.label) reasons.push(amountInfo.label);
|
if (amountInfo?.label) reasons.push(amountInfo.label);
|
||||||
if (cadenceInfo?.recurring && cadenceInfo.label) reasons.push(cadenceInfo.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`);
|
reasons.push(`${last.currency || 'USD'} ${averageAmount.toFixed(2)} average`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -663,8 +738,24 @@ function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, ma
|
||||||
max: Math.max(...sorted.map(item => item.amount_dollars)),
|
max: Math.max(...sorted.map(item => item.amount_dollars)),
|
||||||
max_delta: Math.round(maxDelta * 100) / 100,
|
max_delta: Math.round(maxDelta * 100) / 100,
|
||||||
} : null,
|
} : 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),
|
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,
|
merchant,
|
||||||
decline_key: declineKey,
|
decline_key: declineKey,
|
||||||
source: last.data_source_name || 'Transaction history',
|
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);
|
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', () => {
|
test('unknown recurring patterns do not appear as known-service recommendations', () => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const userId = createUser(db, 'unknown-pattern');
|
const userId = createUser(db, 'unknown-pattern');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue