feat: dedicated subscription catalog page, evidence badges, price display in recommendations
This commit is contained in:
parent
3a034ddeb7
commit
b2f8f5ef66
|
|
@ -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>} />
|
||||||
|
|
|
||||||
|
|
@ -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 */}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue