feat: dedicated subscription catalog page, evidence badges, price display in recommendations

This commit is contained in:
null 2026-06-06 20:44:54 -05:00
parent 3a034ddeb7
commit b2f8f5ef66
7 changed files with 443 additions and 45 deletions

View File

@ -32,6 +32,7 @@ const CalendarPage = lazy(() => import('@/pages/CalendarPage'));
const SummaryPage = lazy(() => import('@/pages/SummaryPage')); const SummaryPage = lazy(() => import('@/pages/SummaryPage'));
const BillsPage = lazy(() => import('@/pages/BillsPage')); const BillsPage = lazy(() => import('@/pages/BillsPage'));
const SubscriptionsPage = lazy(() => import('@/pages/SubscriptionsPage')); const SubscriptionsPage = lazy(() => import('@/pages/SubscriptionsPage'));
const SubscriptionCatalogPage = lazy(() => import('@/pages/SubscriptionCatalogPage'));
const CategoriesPage = lazy(() => import('@/pages/CategoriesPage')); const CategoriesPage = lazy(() => import('@/pages/CategoriesPage'));
const SettingsPage = lazy(() => import('@/pages/SettingsPage')); const SettingsPage = lazy(() => import('@/pages/SettingsPage'));
const StatusPage = lazy(() => import('@/pages/StatusPage')); const StatusPage = lazy(() => import('@/pages/StatusPage'));
@ -207,6 +208,7 @@ export default function App() {
<Route path="summary" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SummaryPage /></Suspense></ErrorBoundary>} /> <Route path="summary" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SummaryPage /></Suspense></ErrorBoundary>} />
<Route path="bills" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><BillsPage /></Suspense></ErrorBoundary>} /> <Route path="bills" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><BillsPage /></Suspense></ErrorBoundary>} />
<Route path="subscriptions" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SubscriptionsPage /></Suspense></ErrorBoundary>} /> <Route path="subscriptions" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SubscriptionsPage /></Suspense></ErrorBoundary>} />
<Route path="subscriptions/catalog" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SubscriptionCatalogPage /></Suspense></ErrorBoundary>} />
<Route path="categories" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><CategoriesPage /></Suspense></ErrorBoundary>} /> <Route path="categories" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><CategoriesPage /></Suspense></ErrorBoundary>} />
<Route path="health" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><HealthPage /></Suspense></ErrorBoundary>} /> <Route path="health" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><HealthPage /></Suspense></ErrorBoundary>} />
<Route path="analytics" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><AnalyticsPage /></Suspense></ErrorBoundary>} /> <Route path="analytics" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><AnalyticsPage /></Suspense></ErrorBoundary>} />

View File

@ -394,7 +394,9 @@ export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete
const data = await api.subscriptionCatalog(); const data = await api.subscriptionCatalog();
setCatalog(data.catalog || []); setCatalog(data.catalog || []);
} catch (err) { } catch (err) {
setError(err.message || 'Failed to load catalog'); const message = err.message || 'Failed to load catalog';
setError(message);
toast.error(message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -530,9 +532,9 @@ export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete
return ( return (
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<CardHeader className="px-4 pb-3 sm:px-6"> <CardHeader className="px-4 pb-3 sm:px-6">
<CardTitle className="text-base">Known Services</CardTitle> <CardTitle className="text-base">Known Service Catalog</CardTitle>
<CardDescription> <CardDescription>
Popular subscriptions and services. Ones you're already tracking appear at the top. Popular services, linked bills, and custom bank descriptors used to improve matching.
</CardDescription> </CardDescription>
{/* Category filter chips */} {/* Category filter chips */}

View File

@ -0,0 +1,107 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { ArrowLeft, Link2, RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
import BillModal from '@/components/BillModal';
import SubscriptionCatalogSection from '@/components/SubscriptionCatalogSection';
export default function SubscriptionCatalogPage() {
const [catalogKey, setCatalogKey] = useState(0);
const [subscriptions, setSubscriptions] = useState([]);
const [categories, setCategories] = useState([]);
const [modal, setModal] = useState(null);
const refreshSubscriptions = useCallback(async () => {
try {
const [subscriptionData, categoryData] = await Promise.all([
api.subscriptions(),
api.categories(),
]);
setSubscriptions(subscriptionData?.subscriptions || []);
setCategories(Array.isArray(categoryData) ? categoryData : []);
} catch (err) {
toast.error(err.message || 'Subscriptions could not be refreshed.');
}
}, []);
useEffect(() => {
refreshSubscriptions();
}, [refreshSubscriptions]);
function openBillEditor(billId) {
const bill = subscriptions.find(item => item.id === billId) || { id: billId };
setModal({ bill });
}
return (
<div className="mx-auto w-full max-w-6xl space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0">
<p className="mb-1 text-[11px] font-semibold uppercase tracking-[0.15em] text-muted-foreground">
Matching Tools
</p>
<h1 className="text-3xl font-semibold tracking-tight">Service Catalog</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Link tracked subscriptions to known services and tune bank descriptors so future recommendations are more accurate.
</p>
</div>
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
<Button asChild type="button" variant="outline" className="gap-2">
<Link to="/subscriptions">
<ArrowLeft className="h-4 w-4" />
Subscriptions
</Link>
</Button>
<Button
type="button"
variant="outline"
className="gap-2"
onClick={() => setCatalogKey(key => key + 1)}
>
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
</div>
</div>
<div className="rounded-lg border border-border/70 bg-card/80 p-4">
<div className="flex items-start gap-3">
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
<Link2 className="h-4 w-4" />
</span>
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">This page improves matching, not discovery.</p>
<p className="mt-1 text-sm text-muted-foreground">
Recommendations on the Subscriptions page come from bank transactions. Use this catalog when a service needs a better descriptor or an existing bill should be linked to a known service.
</p>
</div>
</div>
</div>
<SubscriptionCatalogSection
key={catalogKey}
onEditBill={openBillEditor}
onTrackComplete={async () => {
await refreshSubscriptions();
setCatalogKey(key => key + 1);
}}
/>
{modal && (
<BillModal
key={modal.bill?.id ? `catalog-edit-${modal.bill.id}` : 'catalog-edit'}
bill={modal.bill}
categories={categories}
onClose={() => setModal(null)}
onSave={async () => {
setModal(null);
await refreshSubscriptions();
setCatalogKey(key => key + 1);
}}
/>
)}
</div>
);
}

View File

@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
Bell, Bell,
@ -30,7 +31,6 @@ import {
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import BillModal from '@/components/BillModal'; import BillModal from '@/components/BillModal';
import SubscriptionCatalogSection from '@/components/SubscriptionCatalogSection';
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder'; import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
const TYPE_LABELS = { const TYPE_LABELS = {
@ -284,6 +284,11 @@ function TxResultRow({ tx, onTrack }) {
} }
function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, onMatch, busy }) { function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, onMatch, busy }) {
const identity = recommendation.evidence?.identity;
const amount = recommendation.evidence?.amount;
const cadence = recommendation.evidence?.cadence;
const amountRange = recommendation.evidence?.amount_range;
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">
<div className="space-y-3"> <div className="space-y-3">
@ -293,6 +298,14 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o
<p className="mt-1 text-xs font-medium text-muted-foreground"> <p className="mt-1 text-xs font-medium text-muted-foreground">
{TYPE_LABELS[recommendation.subscription_type] || 'Other'} · {recommendation.occurrence_count} charges · last seen {fmtDate(recommendation.last_seen_date)} {TYPE_LABELS[recommendation.subscription_type] || 'Other'} · {recommendation.occurrence_count} charges · last seen {fmtDate(recommendation.last_seen_date)}
</p> </p>
{recommendation.catalog_match?.starting_monthly_usd && (
<p className="mt-1 text-xs font-medium text-muted-foreground">
Catalog starts at {fmt(recommendation.catalog_match.starting_monthly_usd)} / mo
{recommendation.catalog_match.starting_annual_usd
? ` or ${fmt(recommendation.catalog_match.starting_annual_usd)} / yr`
: ''}
</p>
)}
{recommendation.accounts?.length > 0 && ( {recommendation.accounts?.length > 0 && (
<p className="mt-1 truncate text-xs font-medium text-muted-foreground"> <p className="mt-1 truncate text-xs font-medium text-muted-foreground">
{recommendation.accounts.length > 1 ? 'Accounts' : 'Account'}: {recommendation.accounts.join(', ')} {recommendation.accounts.length > 1 ? 'Accounts' : 'Account'}: {recommendation.accounts.join(', ')}
@ -311,6 +324,34 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o
<Badge variant="outline" className="border-primary/25 bg-primary/10 text-primary"> <Badge variant="outline" className="border-primary/25 bg-primary/10 text-primary">
{recommendation.confidence}% match {recommendation.confidence}% match
</Badge> </Badge>
{identity?.label && (
<Badge variant="outline" className="border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300">
{identity.label}
</Badge>
)}
{amount?.label && (
<Badge
variant="outline"
className={cn(
'text-[11px]',
amount.match === 'unusual'
? 'border-amber-500/25 bg-amber-500/10 text-amber-500'
: 'border-sky-500/25 bg-sky-500/10 text-sky-600 dark:text-sky-300',
)}
>
{amount.match === 'unusual' ? 'Unusual amount' : 'Price checked'}
</Badge>
)}
{cadence?.recurring && (
<Badge variant="outline" className="border-violet-500/25 bg-violet-500/10 text-violet-600 dark:text-violet-300">
Recurring
</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)}
</span>
)}
{recommendation.reasons?.map(reason => ( {recommendation.reasons?.map(reason => (
<span key={reason} className="max-w-full rounded-md border border-border/60 bg-background/60 px-2 py-1 text-[11px] font-medium text-muted-foreground"> <span key={reason} className="max-w-full rounded-md border border-border/60 bg-background/60 px-2 py-1 text-[11px] font-medium text-muted-foreground">
{reason} {reason}
@ -413,7 +454,12 @@ export default function SubscriptionsPage() {
useEffect(() => { useEffect(() => {
load(); load();
loadRecommendations(); loadRecommendations();
api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(err => console.error('[SubscriptionsPage] failed to load bills', err)); api.allBills()
.then(b => setBills(Array.isArray(b) ? b : []))
.catch(err => {
console.error('[SubscriptionsPage] failed to load bills', err);
toast.error(err.message || 'Bills could not be loaded for subscription linking.');
});
}, [load, loadRecommendations]); }, [load, loadRecommendations]);
useEffect(() => { useEffect(() => {
@ -425,7 +471,10 @@ export default function SubscriptionsPage() {
try { try {
const result = await api.subscriptionTransactionMatches({ q, limit: 50 }); const result = await api.subscriptionTransactionMatches({ q, limit: 50 });
setTxResults(Array.isArray(result) ? result : (result?.transactions ?? [])); setTxResults(Array.isArray(result) ? result : (result?.transactions ?? []));
} catch { setTxResults([]); } } catch (err) {
setTxResults([]);
toast.error(err.message || 'Transaction search failed.');
}
finally { setTxSearching(false); } finally { setTxSearching(false); }
}, 300); }, 300);
return () => clearTimeout(txDebounce.current); return () => clearTimeout(txDebounce.current);
@ -621,7 +670,7 @@ export default function SubscriptionsPage() {
const MIN_CONFIDENCE = 90; const MIN_CONFIDENCE = 90;
const highConfidenceRecs = useMemo( const highConfidenceRecs = useMemo(
() => recommendations.filter(r => (r.confidence ?? 0) >= MIN_CONFIDENCE), () => recommendations.filter(r => r.catalog_match && (r.confidence ?? 0) >= MIN_CONFIDENCE),
[recommendations], [recommendations],
); );
const filteredRecs = useMemo(() => { const filteredRecs = useMemo(() => {
@ -717,7 +766,7 @@ export default function SubscriptionsPage() {
</span> </span>
)} )}
</div> </div>
<CardDescription>Recurring charges from your accounts with 90%+ confidence.</CardDescription> <CardDescription>Known subscription services found in your bank transactions with 90%+ confidence.</CardDescription>
{!recommendationsLoading && highConfidenceRecs.length > 0 && ( {!recommendationsLoading && highConfidenceRecs.length > 0 && (
<div className="relative mt-2"> <div className="relative mt-2">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground pointer-events-none" /> <Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground pointer-events-none" />
@ -742,8 +791,8 @@ export default function SubscriptionsPage() {
<p className="mt-3 text-sm font-medium">No high-confidence recommendations.</p> <p className="mt-3 text-sm font-medium">No high-confidence recommendations.</p>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
{recommendations.length > 0 {recommendations.length > 0
? `${recommendations.length} low-confidence pattern${recommendations.length !== 1 ? 's' : ''} found — more account activity will improve accuracy.` ? 'More account activity or stronger descriptors will improve accuracy.'
: 'Sync your accounts after a few recurring charges appear.'} : 'Sync your accounts after charges from known subscription services appear.'}
</p> </p>
</div> </div>
) : filteredRecs.length === 0 ? ( ) : filteredRecs.length === 0 ? (
@ -828,13 +877,28 @@ export default function SubscriptionsPage() {
)} )}
</Card> </Card>
<SubscriptionCatalogSection <Card className="overflow-hidden">
onEditBill={billId => { <CardHeader className="px-4 pb-3 sm:px-6">
const bill = subscriptions.find(s => s.id === billId) || { id: billId }; <div className="flex items-center gap-2">
setModal({ bill }); <Link2 className="h-4 w-4 text-primary" />
}} <CardTitle className="text-base">Improve Matching</CardTitle>
onTrackComplete={refreshAll} </div>
/> <CardDescription>
Manage known services, catalog links, and custom bank descriptors on a dedicated page.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3 px-4 pb-4 sm:flex-row sm:items-center sm:justify-between sm:px-6">
<p className="text-sm text-muted-foreground">
Use the service catalog when a recommendation names the wrong service, a bill needs a catalog link, or your bank uses a custom descriptor.
</p>
<Button asChild type="button" variant="outline" className="shrink-0 gap-2">
<Link to="/subscriptions/catalog">
<Link2 className="h-4 w-4" />
Service Catalog
</Link>
</Button>
</CardContent>
</Card>
{modal && ( {modal && (
<BillModal <BillModal

View File

@ -43,7 +43,7 @@ const TYPE_KEYWORDS = [
function loadCatalog(db, userId) { function loadCatalog(db, userId) {
try { try {
const catalog = db.prepare( const catalog = db.prepare(
'SELECT id, rank, name, category, subscription_type, domain, website, starting_monthly_usd FROM subscription_catalog ORDER BY rank ASC' 'SELECT id, rank, name, category, subscription_type, domain, website, starting_monthly_usd, starting_annual_usd, price_notes FROM subscription_catalog ORDER BY rank ASC'
).all(); ).all();
if (!catalog.length) return []; if (!catalog.length) return [];
@ -62,7 +62,7 @@ function loadCatalog(db, userId) {
'SELECT catalog_id, descriptor FROM user_catalog_descriptors WHERE user_id = ?' 'SELECT catalog_id, descriptor FROM user_catalog_descriptors WHERE user_id = ?'
).all(userId); ).all(userId);
for (const d of userDescs) { for (const d of userDescs) {
descriptors.push({ catalog_id: d.catalog_id, descriptor: d.descriptor, descriptor_type: 'bank' }); descriptors.push({ catalog_id: d.catalog_id, descriptor: d.descriptor, descriptor_type: 'user_bank' });
} }
} catch { /* pre-v0.96 */ } } catch { /* pre-v0.96 */ }
} }
@ -72,7 +72,9 @@ function loadCatalog(db, userId) {
const entry = byId.get(d.catalog_id); const entry = byId.get(d.catalog_id);
if (!entry) continue; if (!entry) continue;
const normalized = normalizeMerchant(d.descriptor); const normalized = normalizeMerchant(d.descriptor);
if (d.descriptor_type === 'bank') entry.bankDescs.push(normalized); if (d.descriptor_type === 'bank' || d.descriptor_type === 'user_bank') {
entry.bankDescs.push({ value: normalized, source: d.descriptor_type });
}
else entry.slangTerms.push(normalized); else entry.slangTerms.push(normalized);
} }
return [...byId.values()]; return [...byId.values()];
@ -130,12 +132,14 @@ function normalizeCatalogName(value) {
.trim(); .trim();
} }
// Given a normalized merchant string, find the best matching catalog entry. // Given a normalized merchant string, find the best matching catalog entry and
// Priority: (1) bank descriptor substring match, (2) name/domain fuzzy match. // the kind of evidence that won. Identity confidence is handled separately so
function lookupCatalog(catalog, merchantText) { // weak name/domain matches do not look as trustworthy as statement descriptors.
function lookupCatalogMatch(catalog, merchantText) {
if (!catalog.length || !merchantText) return null; if (!catalog.length || !merchantText) return null;
let best = null; let best = null;
let bestScore = 0; let bestScore = 0;
let bestMatch = null;
const normalized = normalizeMerchant(merchantText); const normalized = normalizeMerchant(merchantText);
const compact = compactCatalogKey(merchantText); const compact = compactCatalogKey(merchantText);
@ -143,13 +147,22 @@ function lookupCatalog(catalog, merchantText) {
// 1. Bank statement descriptors — normalized against our own normalizeMerchant so // 1. Bank statement descriptors — normalized against our own normalizeMerchant so
// "NETFLIX.COM LOS GATOS CA" and raw payee "netflix" both reduce to comparable forms. // "NETFLIX.COM LOS GATOS CA" and raw payee "netflix" both reduce to comparable forms.
for (const desc of (entry.bankDescs || [])) { for (const desc of (entry.bankDescs || [])) {
if (desc.length >= 4 && (normalized.includes(desc) || desc.includes(normalized))) { const value = desc.value || desc;
const score = 2000 + desc.length; if (value.length >= 4 && (normalized.includes(value) || value.includes(normalized))) {
if (score > bestScore) { best = entry; bestScore = score; } const score = (desc.source === 'user_bank' ? 2200 : 2000) + value.length;
if (score > bestScore) {
best = entry;
bestScore = score;
bestMatch = {
type: desc.source === 'user_bank' ? 'user_descriptor' : 'bank_descriptor',
label: desc.source === 'user_bank' ? 'custom bank descriptor' : 'known bank descriptor',
descriptor: value,
};
}
} }
} }
// 2. Name / domain fuzzy match (original logic, unchanged) // 2. Name / domain / slang match.
const nameKey = normalizeCatalogName(entry.name); const nameKey = normalizeCatalogName(entry.name);
const nameCompact = compactCatalogKey(entry.name); const nameCompact = compactCatalogKey(entry.name);
const nameScore = 1000 + nameKey.length; const nameScore = 1000 + nameKey.length;
@ -160,6 +173,7 @@ function lookupCatalog(catalog, merchantText) {
) { ) {
best = entry; best = entry;
bestScore = nameScore; bestScore = nameScore;
bestMatch = { type: 'name', label: 'service name', descriptor: nameKey };
} }
for (const domainKey of catalogDomainKeys(entry)) { for (const domainKey of catalogDomainKeys(entry)) {
const domainCompact = domainKey.replace(/\s+/g, ''); const domainCompact = domainKey.replace(/\s+/g, '');
@ -170,10 +184,28 @@ function lookupCatalog(catalog, merchantText) {
) { ) {
best = entry; best = entry;
bestScore = domainScore; bestScore = domainScore;
bestMatch = { type: 'domain', label: 'service domain', descriptor: domainKey };
}
}
for (const slang of (entry.slangTerms || [])) {
const slangCompact = slang.replace(/\s+/g, '');
const slangScore = 300 + slang.length;
if (
slang.length >= 4
&& (normalized.includes(slang) || (slangCompact.length >= 5 && compact.includes(slangCompact)))
&& slangScore > bestScore
) {
best = entry;
bestScore = slangScore;
bestMatch = { type: 'slang', label: 'known alternate name', descriptor: slang };
} }
} }
} }
return best; return best ? { entry: best, match: bestMatch || { type: 'unknown', label: 'catalog match' } } : null;
}
function lookupCatalog(catalog, merchantText) {
return lookupCatalogMatch(catalog, merchantText)?.entry || null;
} }
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
@ -220,9 +252,115 @@ function catalogMatchPayload(catalogEntry) {
subscription_type: catalogEntry.subscription_type || 'other', subscription_type: catalogEntry.subscription_type || 'other',
website: catalogEntry.website || null, website: catalogEntry.website || null,
starting_monthly_usd: catalogEntry.starting_monthly_usd ?? null, starting_monthly_usd: catalogEntry.starting_monthly_usd ?? null,
starting_annual_usd: catalogEntry.starting_annual_usd ?? null,
price_notes: catalogEntry.price_notes || null,
} : null; } : null;
} }
function identityEvidence(match) {
const type = match?.type || 'unknown';
const table = {
user_descriptor: { score: 84, label: 'Matched your custom bank descriptor' },
bank_descriptor: { score: 82, label: 'Matched a known bank descriptor' },
name: { score: 74, label: 'Matched the service name' },
domain: { score: 70, label: 'Matched the service domain' },
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) };
}
function priceClose(amount, expected) {
if (!amount || !expected) return null;
const delta = Math.abs(amount - expected);
const pct = delta / expected;
return { delta, pct };
}
function amountEvidence(amount, cycleType, catalogEntry) {
const monthly = Number(catalogEntry?.starting_monthly_usd || 0);
const annual = Number(catalogEntry?.starting_annual_usd || 0) || (monthly ? monthly * 12 : 0);
if (!amount || (!monthly && !annual)) {
return { score: 0, label: null, match: 'unknown', inferred_cycle_type: null };
}
const monthlyClose = priceClose(amount, monthly);
const annualClose = priceClose(amount, annual);
const annualLike = annual && annualClose && (annualClose.delta <= 2 || annualClose.pct <= 0.12);
const monthlyLike = monthly && monthlyClose && (monthlyClose.delta <= 1 || monthlyClose.pct <= 0.08);
const plausibleMonthly = monthly && amount >= monthly * 0.70 && amount <= Math.max(monthly * 4, monthly + 25);
const plausibleAnnual = annual && amount >= annual * 0.70 && amount <= annual * 1.35;
if (cycleType === 'annual' || annualLike || (!monthlyLike && plausibleAnnual && amount >= monthly * 8)) {
if (annualLike) {
return {
score: 13,
label: `Amount aligns with catalog annual pricing near $${annual.toFixed(2)}`,
match: 'annual_close',
inferred_cycle_type: 'annual',
};
}
if (plausibleAnnual) {
return {
score: 9,
label: `Amount is plausible for annual pricing near $${annual.toFixed(2)}`,
match: 'annual_plausible',
inferred_cycle_type: 'annual',
};
}
}
if (monthlyLike) {
return {
score: 12,
label: `Amount aligns with catalog pricing from $${monthly.toFixed(2)}/mo`,
match: 'monthly_close',
inferred_cycle_type: 'monthly',
};
}
if (plausibleMonthly) {
return {
score: 8,
label: `Amount is plausible for catalog pricing from $${monthly.toFixed(2)}/mo`,
match: 'monthly_plausible',
inferred_cycle_type: null,
};
}
return {
score: -10,
label: `Amount is unusual for catalog pricing from ${monthly ? `$${monthly.toFixed(2)}/mo` : `$${annual.toFixed(2)}/yr`}`,
match: 'unusual',
inferred_cycle_type: null,
};
}
function cadenceEvidence(sorted, cycleType, avgGap, maxDelta, averageAmount) {
if (sorted.length < 2) {
return {
score: 0,
label: 'One matching bank transaction',
stable: true,
recurring: false,
};
}
const stable = maxDelta <= Math.max(1, averageAmount * 0.08);
const score = 8 + Math.min(15, sorted.length * 3) + (stable ? 8 : 0) + (cycleType !== 'weekly' ? 8 : 0);
return {
score,
label: `${sorted.length} similar charges about ${Math.round(avgGap)} days apart`,
stable,
recurring: true,
};
}
function scoreKnownServiceRecommendation({ match, amountInfo, cadenceInfo }) {
const identity = identityEvidence(match);
const confidence = Math.min(99, Math.max(0,
identity.score + amountInfo.score + cadenceInfo.score
));
return { confidence, identity };
}
function monthlyEquivalent(amount, cycleType, billingCycle) { function monthlyEquivalent(amount, cycleType, billingCycle) {
const key = String(cycleType || billingCycle || 'monthly').toLowerCase(); const key = String(cycleType || billingCycle || 'monthly').toLowerCase();
const fallback = String(billingCycle || '').toLowerCase() === 'quarterly' const fallback = String(billingCycle || '').toLowerCase() === 'quarterly'
@ -373,17 +511,20 @@ function getSubscriptionRecommendations(db, userId) {
if (amount < 1) continue; if (amount < 1) continue;
const key = `${merchant}:${Math.round(amount)}`; const key = `${merchant}:${Math.round(amount)}`;
if (!groups.has(key)) { if (!groups.has(key)) {
groups.set(key, { merchant, items: [], catalogEntry: null }); groups.set(key, { merchant, items: [], catalogMatch: null });
} }
const group = groups.get(key); const group = groups.get(key);
group.items.push({ ...tx, amount_dollars: amount }); group.items.push({ ...tx, amount_dollars: amount });
if (!group.catalogEntry) group.catalogEntry = lookupCatalog(catalog, merchant); if (!group.catalogMatch) group.catalogMatch = lookupCatalogMatch(catalog, merchant);
} }
const recommendations = []; const recommendations = [];
for (const group of groups.values()) { for (const group of groups.values()) {
const { merchant, catalogEntry } = group; const { merchant } = group;
const catalogEntry = group.catalogMatch?.entry || null;
const catalogIdentityMatch = group.catalogMatch?.match || null;
if (!catalogEntry) continue;
const declineKey = catalogEntry ? `catalog:${catalogEntry.id}` : `merchant:${merchant}`; const declineKey = catalogEntry ? `catalog:${catalogEntry.id}` : `merchant:${merchant}`;
if (declined.has(declineKey)) continue; if (declined.has(declineKey)) continue;
@ -400,11 +541,25 @@ function getSubscriptionRecommendations(db, userId) {
: 0; : 0;
const last = sorted[sorted.length - 1]; const last = sorted[sorted.length - 1];
// Tier 1: catalog match with 1 occurrence // Tier 1: known-service match with 1 occurrence. Exact bank descriptors can
// still be 90+, but weaker name/domain hits need recurrence or stronger amount
// evidence before they appear as recommendations.
if (catalogEntry && sorted.length === 1) { if (catalogEntry && sorted.length === 1) {
let cycleType = 'monthly';
let amountInfo = amountEvidence(averageAmount, cycleType, catalogEntry);
if (amountInfo.inferred_cycle_type) {
cycleType = amountInfo.inferred_cycle_type;
amountInfo = amountEvidence(averageAmount, cycleType, catalogEntry);
}
const cadenceInfo = cadenceEvidence(sorted, cycleType, 30, maxDelta, averageAmount);
const scored = scoreKnownServiceRecommendation({
match: catalogIdentityMatch, amountInfo, cadenceInfo, sorted,
});
if (scored.confidence < 90) continue;
recommendations.push(buildRecommendation({ recommendations.push(buildRecommendation({
merchant, catalogEntry, sorted, averageAmount, maxDelta, last, merchant, catalogEntry, sorted, averageAmount, maxDelta, last,
cycleType: 'monthly', avgGap: 30, confidence: 90, tier: 'known_service', declineKey, catalogTypeMap, cycleType, avgGap: 30, confidence: scored.confidence, tier: 'known_service',
declineKey, catalogTypeMap, identityInfo: scored.identity, amountInfo, cadenceInfo,
})); }));
continue; continue;
} }
@ -429,17 +584,17 @@ function getSubscriptionRecommendations(db, userId) {
if (cycleType === 'weekly') continue; if (cycleType === 'weekly') continue;
if (maxDelta > Math.max(3, averageAmount * 0.18)) continue; if (maxDelta > Math.max(3, averageAmount * 0.18)) continue;
let confidence; const amountInfo = amountEvidence(averageAmount, cycleType, catalogEntry);
if (catalogEntry) { const cadenceInfo = cadenceEvidence(sorted, cycleType, avgGap, maxDelta, averageAmount);
confidence = Math.min(99, 68 + sorted.length * 8 + (maxDelta <= 1 ? 8 : 0)); const scored = scoreKnownServiceRecommendation({
} else { match: catalogIdentityMatch, amountInfo, cadenceInfo, sorted,
confidence = Math.min(96, 58 + sorted.length * 9 + (maxDelta <= 1 ? 10 : 0)); });
} if (scored.confidence < 90) continue;
const tier = catalogEntry ? 'confirmed' : 'pattern';
recommendations.push(buildRecommendation({ recommendations.push(buildRecommendation({
merchant, catalogEntry, sorted, averageAmount, maxDelta, last, merchant, catalogEntry, sorted, averageAmount, maxDelta, last,
cycleType, avgGap, confidence, tier, declineKey, catalogTypeMap, cycleType, avgGap, confidence: scored.confidence, tier: 'confirmed',
declineKey, catalogTypeMap, identityInfo: scored.identity, amountInfo, cadenceInfo,
})); }));
} }
@ -457,7 +612,7 @@ 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 }) { function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, maxDelta, last, cycleType, avgGap, confidence, tier, declineKey, catalogTypeMap, identityInfo = null, amountInfo = null, cadenceInfo = 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
@ -471,8 +626,9 @@ function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, ma
const reasons = []; const reasons = [];
if (catalogEntry) reasons.push(`Matches known service: ${catalogEntry.name}`); if (catalogEntry) reasons.push(`Matches known service: ${catalogEntry.name}`);
if (sorted.length > 1) reasons.push(`${sorted.length} similar charges`); if (identityInfo?.label) reasons.push(identityInfo.label);
if (sorted.length > 1) reasons.push(`About ${Math.round(avgGap)} days apart`); if (amountInfo?.label) reasons.push(amountInfo.label);
if (cadenceInfo?.recurring && cadenceInfo.label) reasons.push(cadenceInfo.label);
reasons.push(`${last.currency || 'USD'} ${averageAmount.toFixed(2)} average`); reasons.push(`${last.currency || 'USD'} ${averageAmount.toFixed(2)} average`);
return { return {
@ -489,6 +645,25 @@ function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, ma
confidence, confidence,
tier, tier,
catalog_match: catalogMatchPayload(catalogEntry), catalog_match: catalogMatchPayload(catalogEntry),
evidence: {
identity: identityInfo,
amount: amountInfo ? {
match: amountInfo.match,
label: amountInfo.label,
score: amountInfo.score,
} : null,
cadence: cadenceInfo ? {
recurring: cadenceInfo.recurring,
stable: cadenceInfo.stable,
label: cadenceInfo.label,
score: cadenceInfo.score,
} : null,
amount_range: sorted.length > 1 ? {
min: Math.min(...sorted.map(item => item.amount_dollars)),
max: Math.max(...sorted.map(item => item.amount_dollars)),
max_delta: Math.round(maxDelta * 100) / 100,
} : null,
},
transaction_ids: sorted.map(item => item.id), transaction_ids: sorted.map(item => item.id),
merchant, merchant,
decline_key: declineKey, decline_key: declineKey,

View File

@ -69,6 +69,53 @@ test('known catalog services appear as high-confidence subscription recommendati
assert.equal(netflix.confidence >= 90, true); assert.equal(netflix.confidence >= 90, true);
assert.deepEqual(netflix.accounts, ['Checking']); assert.deepEqual(netflix.accounts, ['Checking']);
assert.match(netflix.reasons.join(' '), /Matches known service: Netflix/); assert.match(netflix.reasons.join(' '), /Matches known service: Netflix/);
assert.equal(netflix.evidence.identity.type, 'bank_descriptor');
assert.equal(netflix.evidence.amount.match, 'monthly_plausible');
});
test('weak one-off known service names stay below the recommendation threshold', () => {
const db = getDb();
const userId = createUser(db, 'weak-known');
const accountId = createAccount(db, userId, true);
createTransaction(db, userId, {
account_id: accountId,
description: 'MAX',
payee: 'MAX',
amount: -35000,
});
const recommendations = getSubscriptionRecommendations(db, userId);
assert.equal(recommendations.some(item => item.catalog_match?.name === 'Max'), false);
});
test('unknown recurring patterns do not appear as known-service recommendations', () => {
const db = getDb();
const userId = createUser(db, 'unknown-pattern');
const accountId = createAccount(db, userId, true);
createTransaction(db, userId, {
account_id: accountId,
description: 'LOCAL CLUB MEMBERSHIP',
payee: 'LOCAL CLUB MEMBERSHIP',
amount: -2500,
posted_date: '2026-01-05',
});
createTransaction(db, userId, {
account_id: accountId,
description: 'LOCAL CLUB MEMBERSHIP',
payee: 'LOCAL CLUB MEMBERSHIP',
amount: -2500,
posted_date: '2026-02-05',
});
createTransaction(db, userId, {
account_id: accountId,
description: 'LOCAL CLUB MEMBERSHIP',
payee: 'LOCAL CLUB MEMBERSHIP',
amount: -2500,
posted_date: '2026-03-05',
});
const recommendations = getSubscriptionRecommendations(db, userId);
assert.equal(recommendations.some(item => item.merchant === 'local club membership'), false);
}); });
test('subscription transaction search annotates known catalog matches', () => { test('subscription transaction search annotates known catalog matches', () => {

View File

@ -8,6 +8,7 @@ import { createRequire } from 'module';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const pkg = require('./package.json'); const pkg = require('./package.json');
const apiPort = process.env.API_PORT || process.env.PORT || 3000;
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
@ -56,7 +57,7 @@ export default defineConfig({
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {
'/api': { target: 'http://localhost:3000', changeOrigin: true }, '/api': { target: `http://localhost:${apiPort}`, changeOrigin: true },
}, },
}, },
build: { build: {