diff --git a/client/api.js b/client/api.js
index 6954bf8..b3c9399 100644
--- a/client/api.js
+++ b/client/api.js
@@ -235,6 +235,10 @@ export const api = {
updateSubscription: (id, data) => _fetch('PATCH', `/subscriptions/${id}`, data),
createSubscriptionFromRecommendation: (data) => post('/subscriptions/recommendations/create', data),
declineRecommendation: (declineKey) => post('/subscriptions/recommendations/decline', { decline_key: declineKey }),
+ subscriptionCatalog: () => get('/subscriptions/catalog'),
+ updateSubscriptionCatalogLink:(id, catalogId) => _fetch('PUT', `/subscriptions/${id}/catalog-link`, { catalog_id: catalogId }),
+ addCatalogDescriptor: (catalogId, d) => post(`/subscriptions/catalog/${catalogId}/descriptors`, { descriptor: d }),
+ deleteCatalogDescriptor: (id) => _fetch('DELETE', `/subscriptions/catalog/descriptors/${id}`),
// Payments
quickPay: (data) => post('/payments/quick', data),
diff --git a/client/components/SubscriptionCatalogSection.jsx b/client/components/SubscriptionCatalogSection.jsx
new file mode 100644
index 0000000..95f282e
--- /dev/null
+++ b/client/components/SubscriptionCatalogSection.jsx
@@ -0,0 +1,714 @@
+'use strict';
+
+import { useEffect, useMemo, useState } from 'react';
+import {
+ CheckCircle2, ChevronDown, ExternalLink, Link2, Link2Off,
+ Loader2, Pencil, Plus, Search, X,
+} from 'lucide-react';
+import { toast } from 'sonner';
+import { api } from '@/api';
+import { cn, fmt } from '@/lib/utils';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Checkbox } from '@/components/ui/checkbox';
+import {
+ Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
+} from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+
+const TYPE_LABELS = {
+ streaming: 'Streaming', software: 'Software', cloud: 'Cloud',
+ music: 'Music', news: 'News', fitness: 'Fitness', gaming: 'Gaming',
+ utilities: 'Utilities', insurance: 'Insurance', food: 'Food',
+ education: 'Education', shopping: 'Shopping', security: 'Security', other: 'Other',
+};
+
+const CHIPS = [
+ { value: null, label: 'All' },
+ { value: 'streaming', label: 'Streaming' },
+ { value: 'music', label: 'Music' },
+ { value: 'gaming', label: 'Gaming' },
+ { value: 'news', label: 'News' },
+ { value: 'fitness', label: 'Fitness' },
+ { value: 'software', label: 'Software' },
+ { value: 'security', label: 'Security' },
+ { value: 'food', label: 'Food' },
+ { value: 'cloud', label: 'Cloud' },
+ { value: 'shopping', label: 'Shopping' },
+ { value: 'education', label: 'Education' },
+];
+
+// Returns 'match' | 'above' | 'below' | null
+function priceDrift(monthly, catalogStarting) {
+ if (!catalogStarting || !monthly) return null;
+ const pct = (monthly - catalogStarting) / catalogStarting;
+ if (Math.abs(pct) <= 0.05) return 'match';
+ return pct > 0 ? 'above' : 'below';
+}
+
+// ── Descriptor editor inline inside a matched card ─────────────────────────
+function DescriptorEditor({ catalogId, descriptors, onAdd, onDelete }) {
+ const [open, setOpen] = useState(false);
+ const [value, setValue] = useState('');
+ const [busy, setBusy] = useState(false);
+
+ async function handleAdd() {
+ const d = value.trim();
+ if (!d) return;
+ setBusy(true);
+ try {
+ await onAdd(catalogId, d);
+ setValue('');
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
+
+ {open && (
+
+ {descriptors.map(d => (
+
+
+ {d.descriptor}
+
+
+
+ ))}
+
+
+
setValue(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter' && value.trim()) handleAdd(); }}
+ className="h-7 font-mono text-xs"
+ />
+
+
+
+ Add the exact payee text your bank uses to improve auto-matching.
+
+
+ )}
+
+ );
+}
+
+// ── Card for an already-tracked catalog entry ──────────────────────────────
+function MatchedCard({ entry, onEdit, onRelink, onAddDescriptor, onDeleteDescriptor }) {
+ const { matched_bill: bill, user_descriptors: descs = [] } = entry;
+ const drift = priceDrift(bill?.monthly_equivalent, entry.starting_monthly_usd);
+
+ return (
+
+
+
+
+ {entry.name}
+
+ {TYPE_LABELS[entry.subscription_type] || 'Other'}
+
+ {bill && !bill.active && (
+
+ Paused
+
+ )}
+
+
+
+ {bill && (
+
{fmt(bill.monthly_equivalent)}/mo
+ )}
+ {drift === 'match' && entry.starting_monthly_usd && (
+
+
+ matches catalog price
+
+ )}
+ {drift === 'above' && (
+
+ catalog from ${entry.starting_monthly_usd.toFixed(2)}/mo
+
+ )}
+ {drift === 'below' && entry.starting_monthly_usd && (
+
+ catalog from ${entry.starting_monthly_usd.toFixed(2)}/mo
+
+ )}
+ {!entry.starting_monthly_usd && entry.website && (
+
+ website
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+// ── Card for an untracked catalog entry ───────────────────────────────────
+function UnmatchedCard({ entry, selected, onToggleSelect, onTrack, trackingBusy }) {
+ return (
+
+
onToggleSelect(entry.id, !!checked)}
+ aria-label={`Select ${entry.name}`}
+ />
+
+
{entry.name}
+
+ {entry.category}
+ {entry.starting_monthly_usd && (
+ from ${entry.starting_monthly_usd.toFixed(2)}/mo
+ )}
+
+
+ {entry.website && (
+
+
+
+ )}
+
+
+ );
+}
+
+// ── Re-link dialog ─────────────────────────────────────────────────────────
+function ReLinkDialog({ open, relinkEntry, allEntries, onClose, onConfirm, busy }) {
+ const [search, setSearch] = useState('');
+ const [selected, setSelected] = useState(null);
+
+ useEffect(() => {
+ if (open) { setSearch(''); setSelected(null); }
+ }, [open]);
+
+ const filtered = useMemo(() => {
+ const q = search.toLowerCase();
+ return allEntries.filter(e =>
+ !q || e.name.toLowerCase().includes(q) || (e.category || '').toLowerCase().includes(q)
+ );
+ }, [allEntries, search]);
+
+ const bill = relinkEntry?.matched_bill;
+
+ return (
+
+ );
+}
+
+// ── Main component ─────────────────────────────────────────────────────────
+export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete }) {
+ const [catalog, setCatalog] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [showUnmatched, setShowUnmatched] = useState(false);
+ const [activeType, setActiveType] = useState(null);
+ const [search, setSearch] = useState('');
+ const [selectedIds, setSelectedIds] = useState(new Set());
+ const [relinkEntry, setRelinkEntry] = useState(null);
+ const [relinkBusy, setRelinkBusy] = useState(false);
+ const [trackBusyId, setTrackBusyId] = useState(null);
+ const [bulkBusy, setBulkBusy] = useState(false);
+
+ async function loadCatalog() {
+ setLoading(true);
+ setError(null);
+ try {
+ const data = await api.subscriptionCatalog();
+ setCatalog(data.catalog || []);
+ } catch (err) {
+ setError(err.message || 'Failed to load catalog');
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ useEffect(() => { loadCatalog(); }, []);
+
+ // Filter by type chip + search
+ const filtered = useMemo(() => {
+ const q = search.toLowerCase().trim();
+ return catalog.filter(e => {
+ if (activeType && e.subscription_type !== activeType) return false;
+ if (q) return e.name.toLowerCase().includes(q) || (e.category || '').toLowerCase().includes(q);
+ return true;
+ });
+ }, [catalog, activeType, search]);
+
+ const matched = filtered.filter(e => e.matched_bill !== null);
+ const unmatched = filtered.filter(e => e.matched_bill === null);
+
+ // ── Handlers ─────────────────────────────────────────────────────────────
+
+ async function handleTrack(entry) {
+ setTrackBusyId(entry.id);
+ try {
+ await api.createSubscriptionFromRecommendation({
+ name: entry.name,
+ due_day: 1,
+ expected_amount: entry.starting_monthly_usd || 0,
+ cycle_type: 'monthly',
+ subscription_type: entry.subscription_type || 'other',
+ catalog_match: { id: entry.id, name: entry.name, subscription_type: entry.subscription_type },
+ });
+ toast.success(`${entry.name} added to your subscriptions`, {
+ description: 'Open it with Edit to set the correct amount and due date.',
+ });
+ await loadCatalog();
+ onTrackComplete?.();
+ } catch (err) {
+ toast.error(err.message || `Failed to add ${entry.name}`);
+ } finally {
+ setTrackBusyId(null);
+ }
+ }
+
+ async function handleBulkTrack() {
+ if (selectedIds.size === 0) return;
+ setBulkBusy(true);
+ const toTrack = unmatched.filter(e => selectedIds.has(e.id));
+ let succeeded = 0;
+ let failed = 0;
+ for (const entry of toTrack) {
+ try {
+ await api.createSubscriptionFromRecommendation({
+ name: entry.name,
+ due_day: 1,
+ expected_amount: entry.starting_monthly_usd || 0,
+ cycle_type: 'monthly',
+ subscription_type: entry.subscription_type || 'other',
+ catalog_match: { id: entry.id, name: entry.name, subscription_type: entry.subscription_type },
+ });
+ succeeded++;
+ } catch {
+ failed++;
+ }
+ }
+ if (succeeded > 0) {
+ toast.success(`${succeeded} subscription${succeeded !== 1 ? 's' : ''} added`);
+ setSelectedIds(new Set());
+ await loadCatalog();
+ onTrackComplete?.();
+ }
+ if (failed > 0) {
+ toast.error(`${failed} subscription${failed !== 1 ? 's' : ''} could not be added`);
+ }
+ setBulkBusy(false);
+ }
+
+ async function handleRelink(billId, catalogId) {
+ setRelinkBusy(true);
+ try {
+ await api.updateSubscriptionCatalogLink(billId, catalogId);
+ toast.success(catalogId ? 'Catalog link updated' : 'Catalog link removed');
+ setRelinkEntry(null);
+ await loadCatalog();
+ } catch (err) {
+ toast.error(err.message || 'Failed to update catalog link');
+ } finally {
+ setRelinkBusy(false);
+ }
+ }
+
+ async function handleAddDescriptor(catalogId, descriptor) {
+ try {
+ const newDesc = await api.addCatalogDescriptor(catalogId, descriptor);
+ setCatalog(prev => prev.map(e =>
+ e.id === catalogId
+ ? { ...e, user_descriptors: [...(e.user_descriptors || []), newDesc] }
+ : e
+ ));
+ toast.success('Descriptor added');
+ return true;
+ } catch (err) {
+ toast.error(err.message || 'Failed to add descriptor');
+ return false;
+ }
+ }
+
+ async function handleDeleteDescriptor(catalogId, descriptorId) {
+ try {
+ await api.deleteCatalogDescriptor(descriptorId);
+ setCatalog(prev => prev.map(e =>
+ e.id === catalogId
+ ? { ...e, user_descriptors: (e.user_descriptors || []).filter(d => d.id !== descriptorId) }
+ : e
+ ));
+ toast.success('Descriptor removed');
+ } catch (err) {
+ toast.error(err.message || 'Failed to remove descriptor');
+ }
+ }
+
+ function toggleSelect(id, checked) {
+ setSelectedIds(prev => {
+ const next = new Set(prev);
+ checked ? next.add(id) : next.delete(id);
+ return next;
+ });
+ }
+
+ // ── Render ────────────────────────────────────────────────────────────────
+
+ return (
+
+
+ Known Services
+
+ Popular subscriptions and services. Ones you're already tracking appear at the top.
+
+
+ {/* Category filter chips */}
+
+ {CHIPS.map(chip => (
+
+ ))}
+
+
+ {/* Search */}
+
+
+ setSearch(e.target.value)}
+ className="h-9 pl-8 text-sm"
+ />
+
+
+
+
+ {loading ? (
+
+
+ Loading catalog…
+
+ ) : error ? (
+
+ ) : (
+
+
+ {/* ── Matched group ───────────────────────────────────────── */}
+ {matched.length > 0 && (
+
+
+ Tracking ({matched.length})
+
+
+ {matched.map(entry => (
+ handleDeleteDescriptor(entry.id, descriptorId)}
+ />
+ ))}
+
+
+ )}
+
+ {/* ── Unmatched toggle + group ─────────────────────────── */}
+
+
+
+ {showUnmatched && (
+
+ {unmatched.length === 0 ? (
+
+ {search || activeType ? 'No services match your filter.' : 'All known services are already being tracked.'}
+
+ ) : (
+
+ {unmatched.map(entry => (
+
+ ))}
+
+ )}
+
+ )}
+
+
+
+ )}
+
+
+ {/* ── Re-link dialog ─────────────────────────────────────────────── */}
+ setRelinkEntry(null)}
+ onConfirm={handleRelink}
+ busy={relinkBusy}
+ />
+
+ {/* ── Bulk action bar ────────────────────────────────────────────── */}
+ {selectedIds.size > 0 && (
+
+
+
+ {selectedIds.size}
+
+
+ {selectedIds.size === 1 ? 'service' : 'services'} selected
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/client/pages/SubscriptionsPage.jsx b/client/pages/SubscriptionsPage.jsx
index e9630d8..5151566 100644
--- a/client/pages/SubscriptionsPage.jsx
+++ b/client/pages/SubscriptionsPage.jsx
@@ -30,6 +30,7 @@ import {
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import BillModal from '@/components/BillModal';
+import SubscriptionCatalogSection from '@/components/SubscriptionCatalogSection';
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
const TYPE_LABELS = {
@@ -827,6 +828,14 @@ export default function SubscriptionsPage() {
)}
+ {
+ const bill = subscriptions.find(s => s.id === billId) || { id: billId };
+ setModal({ bill });
+ }}
+ onTrackComplete={refreshAll}
+ />
+
{modal && (
c.name);
+ if (!cols.includes('subcategory')) db.exec('ALTER TABLE subscription_catalog ADD COLUMN subcategory TEXT');
+ if (!cols.includes('starting_monthly_usd')) db.exec('ALTER TABLE subscription_catalog ADD COLUMN starting_monthly_usd REAL');
+ if (!cols.includes('starting_annual_usd')) db.exec('ALTER TABLE subscription_catalog ADD COLUMN starting_annual_usd REAL');
+ if (!cols.includes('price_notes')) db.exec('ALTER TABLE subscription_catalog ADD COLUMN price_notes TEXT');
+
+ // 2. Create descriptors table (bank statement strings + slang/nicknames per service)
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS subscription_catalog_descriptors (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ catalog_id INTEGER NOT NULL REFERENCES subscription_catalog(id) ON DELETE CASCADE,
+ descriptor TEXT NOT NULL,
+ descriptor_type TEXT NOT NULL CHECK(descriptor_type IN ('bank', 'slang'))
+ );
+ CREATE INDEX IF NOT EXISTS idx_scd_catalog_id ON subscription_catalog_descriptors(catalog_id);
+ `);
+
+ // 3. Load researched JSON — path is relative to this file's directory (db/)
+ const path = require('path');
+ // eslint-disable-next-line global-require
+ const { subscriptions } = require(path.join(__dirname, '../docs/top_200_us_subscriptions_researched_2026-06-06.json'));
+
+ // Map rich category labels from the JSON to our internal subscription_type values
+ const CATEGORY_TYPE = {
+ 'Video Streaming': 'streaming', 'Sports Streaming': 'streaming',
+ 'Live TV Streaming': 'streaming', 'Sports Media': 'streaming',
+ 'Music & Audio': 'music', 'Podcasts': 'music',
+ 'Gaming': 'gaming',
+ 'News & Magazines': 'news',
+ 'Fitness & Wellness': 'fitness', 'Meditation & Wellness': 'fitness',
+ 'Sleep & Wellness': 'fitness',
+ 'Software & Productivity': 'software', 'Software & Design': 'software',
+ 'Developer Tools': 'software', 'Finance Software': 'software',
+ 'AI': 'software', 'Writing & AI': 'software',
+ 'Cloud & Storage': 'cloud',
+ 'Security': 'security',
+ 'Food & Meal Kits': 'food', 'Prepared Meals': 'food',
+ 'Food Delivery': 'food', 'Food & Rides': 'food',
+ 'Coffee & Tea': 'food', 'Snacks': 'food',
+ 'Grocery & Delivery': 'shopping', 'Shopping & Delivery': 'shopping',
+ 'Retail Memberships': 'shopping', 'Warehouse Clubs': 'shopping',
+ 'Pet Retail': 'shopping',
+ 'Education': 'education', 'Audiobooks': 'education',
+ 'Audiobooks & Ebooks': 'education', 'Ebooks & Audiobooks': 'education',
+ 'Ebooks': 'education', 'Documents & Ebooks': 'education',
+ 'Books & Learning': 'education', 'Books & Subscription Boxes': 'education',
+ 'Creator & Social': 'other', 'Creator Media': 'other',
+ 'Dating': 'other', 'Career & Social': 'other',
+ };
+
+ const getByName = db.prepare('SELECT id FROM subscription_catalog WHERE name = ? LIMIT 1');
+ const updateCatalog = db.prepare(`
+ UPDATE subscription_catalog
+ SET rank = ?, category = ?, subcategory = ?, subscription_type = ?,
+ website = ?, domain = ?, starting_monthly_usd = ?, starting_annual_usd = ?, price_notes = ?
+ WHERE id = ?
+ `);
+ const insertCatalog = db.prepare(`
+ INSERT INTO subscription_catalog
+ (rank, name, category, subcategory, subscription_type, website, domain, starting_monthly_usd, starting_annual_usd, price_notes)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `);
+ const clearDescs = db.prepare('DELETE FROM subscription_catalog_descriptors WHERE catalog_id = ?');
+ const insertDesc = db.prepare('INSERT INTO subscription_catalog_descriptors (catalog_id, descriptor, descriptor_type) VALUES (?, ?, ?)');
+
+ let nUpdated = 0, nInserted = 0, nDescs = 0;
+
+ db.transaction(() => {
+ for (const sub of subscriptions) {
+ const subType = CATEGORY_TYPE[sub.category] || 'other';
+
+ let domain = null;
+ try { domain = new URL(sub.website || '').hostname.replace(/^www\./, ''); } catch {}
+
+ const existing = getByName.get(sub.service);
+ let catalogId;
+
+ if (existing) {
+ updateCatalog.run(
+ sub.rank, sub.category, sub.subcategory || null, subType,
+ sub.website || null, domain,
+ sub.starting_monthly_usd ?? null, sub.starting_annual_usd ?? null,
+ sub.price_notes || null,
+ existing.id,
+ );
+ catalogId = existing.id;
+ nUpdated++;
+ } else {
+ const r = insertCatalog.run(
+ sub.rank, sub.service, sub.category, sub.subcategory || null, subType,
+ sub.website || null, domain,
+ sub.starting_monthly_usd ?? null, sub.starting_annual_usd ?? null,
+ sub.price_notes || null,
+ );
+ catalogId = r.lastInsertRowid;
+ nInserted++;
+ }
+
+ // Replace all descriptors for this entry
+ clearDescs.run(catalogId);
+ for (const d of (sub.bank_statement_name_variables || [])) {
+ if (String(d).trim().length >= 3) { insertDesc.run(catalogId, String(d).trim(), 'bank'); nDescs++; }
+ }
+ for (const d of (sub.known_names_and_slang || [])) {
+ if (String(d).trim().length >= 2) { insertDesc.run(catalogId, String(d).trim(), 'slang'); nDescs++; }
+ }
+ }
+ })();
+
+ console.log(`[v0.95] catalog: ${nUpdated} updated, ${nInserted} inserted, ${nDescs} descriptors added`);
+ }
+ },
+ {
+ version: 'v0.96',
+ description: 'bills: catalog_id FK; user_catalog_descriptors for custom bank descriptors',
+ run() {
+ // 1. Add catalog_id to bills
+ const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
+ if (!billCols.includes('catalog_id')) {
+ db.exec('ALTER TABLE bills ADD COLUMN catalog_id INTEGER REFERENCES subscription_catalog(id)');
+ }
+
+ // 2. Create per-user custom descriptor table
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS user_catalog_descriptors (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ catalog_id INTEGER NOT NULL REFERENCES subscription_catalog(id) ON DELETE CASCADE,
+ descriptor TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+ CREATE INDEX IF NOT EXISTS idx_ucd_user_catalog ON user_catalog_descriptors(user_id, catalog_id);
+ `);
+
+ // 3. Backfill catalog_id for existing subscription bills using name normalization
+ function normSimple(s) {
+ return String(s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
+ }
+ const catalogEntries = db.prepare('SELECT id, name FROM subscription_catalog').all();
+ const subBills = db.prepare(
+ "SELECT id, name FROM bills WHERE is_subscription = 1 AND deleted_at IS NULL AND catalog_id IS NULL"
+ ).all();
+ const updateBillCatalog = db.prepare('UPDATE bills SET catalog_id = ? WHERE id = ?');
+
+ let backfilled = 0;
+ db.transaction(() => {
+ for (const bill of subBills) {
+ const billNorm = normSimple(bill.name);
+ if (billNorm.length < 3) continue;
+ let best = null;
+ let bestScore = 0;
+ for (const cat of catalogEntries) {
+ const catNorm = normSimple(cat.name);
+ if (catNorm.length < 3) continue;
+ let score = 0;
+ if (billNorm === catNorm) score = 2000 + catNorm.length;
+ else if (billNorm.includes(catNorm) || catNorm.includes(billNorm))
+ score = 1000 + Math.min(billNorm.length, catNorm.length);
+ if (score > bestScore) { best = cat; bestScore = score; }
+ }
+ if (best) { updateBillCatalog.run(best.id, bill.id); backfilled++; }
+ }
+ })();
+
+ console.log(`[v0.96] catalog_id added to bills; ${backfilled}/${subBills.length} subscriptions backfilled`);
+ }
+ },
];
// ── users: notification columns ───────────────────────────────────────────
@@ -3416,6 +3588,19 @@ function getDbPath() {
// Rollback SQL definitions
const ROLLBACK_SQL_MAP = {
+ 'v0.96': {
+ description: 'bills: catalog_id FK; user_catalog_descriptors',
+ sql: [
+ 'DROP TABLE IF EXISTS user_catalog_descriptors',
+ 'ALTER TABLE bills DROP COLUMN IF EXISTS catalog_id',
+ ]
+ },
+ 'v0.95': {
+ description: 'subscription_catalog: bank descriptors + pricing',
+ sql: [
+ 'DROP TABLE IF EXISTS subscription_catalog_descriptors',
+ ]
+ },
'v0.94': {
description: 'security: session token hashing + geolocation opt-in setting',
sql: [
diff --git a/routes/subscriptions.js b/routes/subscriptions.js
index 825f9c0..4528174 100644
--- a/routes/subscriptions.js
+++ b/routes/subscriptions.js
@@ -9,6 +9,7 @@ const {
getSubscriptionRecommendations,
getSubscriptionSummary,
getSubscriptions,
+ monthlyEquivalent,
searchSubscriptionTransactions,
} = require('../services/subscriptionService');
const { addMerchantRule, applyMerchantRules } = require('../services/billMerchantRuleService');
@@ -129,6 +130,160 @@ router.post('/recommendations/create', (req, res) => {
}
});
+// ── Catalog browser ───────────────────────────────────────────────────────────
+
+router.get('/catalog', (req, res) => {
+ const db = getDb();
+ try {
+ const catalogEntries = db.prepare(`
+ SELECT id, rank, name, category, subcategory, subscription_type,
+ website, starting_monthly_usd, starting_annual_usd
+ FROM subscription_catalog
+ ORDER BY rank ASC
+ `).all();
+
+ if (!catalogEntries.length) return res.json({ catalog: [] });
+
+ // User's subscription bills that are linked to a catalog entry
+ const matchedBills = db.prepare(`
+ SELECT b.id, b.name, b.expected_amount, b.active, b.catalog_id,
+ b.cycle_type, b.billing_cycle
+ FROM bills b
+ WHERE b.user_id = ?
+ AND b.is_subscription = 1
+ AND b.deleted_at IS NULL
+ AND b.catalog_id IS NOT NULL
+ `).all(req.user.id);
+
+ const billByCatalogId = new Map(matchedBills.map(b => [b.catalog_id, b]));
+
+ // User's custom descriptors
+ let userDescriptors = [];
+ try {
+ userDescriptors = db.prepare(
+ 'SELECT id, catalog_id, descriptor FROM user_catalog_descriptors WHERE user_id = ?'
+ ).all(req.user.id);
+ } catch { /* pre-v0.96 */ }
+
+ const userDescsByCatalogId = new Map();
+ for (const d of userDescriptors) {
+ if (!userDescsByCatalogId.has(d.catalog_id)) userDescsByCatalogId.set(d.catalog_id, []);
+ userDescsByCatalogId.get(d.catalog_id).push({ id: d.id, descriptor: d.descriptor });
+ }
+
+ const catalog = catalogEntries.map(entry => {
+ const bill = billByCatalogId.get(entry.id) ?? null;
+ return {
+ ...entry,
+ matched_bill: bill ? {
+ id: bill.id,
+ name: bill.name,
+ expected_amount: bill.expected_amount,
+ active: !!bill.active,
+ monthly_equivalent: monthlyEquivalent(bill.expected_amount, bill.cycle_type, bill.billing_cycle),
+ } : null,
+ user_descriptors: userDescsByCatalogId.get(entry.id) ?? [],
+ };
+ });
+
+ res.json({ catalog });
+ } catch (err) {
+ res.status(500).json(standardizeError(err.message || 'Failed to load catalog', 'CATALOG_ERROR'));
+ }
+});
+
+// Update which catalog entry a bill is linked to (or unlink with null)
+router.put('/:id/catalog-link', (req, res) => {
+ const db = getDb();
+ const billId = parseInt(req.params.id, 10);
+ if (!Number.isInteger(billId) || billId < 1) {
+ return res.status(400).json(standardizeError('Invalid bill ID', 'VALIDATION_ERROR'));
+ }
+
+ const bill = db.prepare(
+ 'SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL'
+ ).get(billId, req.user.id);
+ if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
+
+ const rawCatalogId = req.body?.catalog_id;
+
+ if (rawCatalogId === null || rawCatalogId === undefined) {
+ db.prepare("UPDATE bills SET catalog_id = NULL, updated_at = datetime('now') WHERE id = ? AND user_id = ?")
+ .run(billId, req.user.id);
+ return res.json({ ok: true, catalog_id: null });
+ }
+
+ const catalogId = parseInt(rawCatalogId, 10);
+ if (!Number.isInteger(catalogId) || catalogId < 1) {
+ return res.status(400).json(standardizeError('catalog_id must be a positive integer or null', 'VALIDATION_ERROR', 'catalog_id'));
+ }
+ const catalogEntry = db.prepare('SELECT id FROM subscription_catalog WHERE id = ?').get(catalogId);
+ if (!catalogEntry) return res.status(404).json(standardizeError('Catalog entry not found', 'NOT_FOUND', 'catalog_id'));
+
+ db.prepare("UPDATE bills SET catalog_id = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?")
+ .run(catalogId, billId, req.user.id);
+ res.json({ ok: true, catalog_id: catalogId });
+});
+
+// Add a custom bank descriptor for a catalog entry (per-user)
+router.post('/catalog/:catalogId/descriptors', (req, res) => {
+ const db = getDb();
+ const catalogId = parseInt(req.params.catalogId, 10);
+ if (!Number.isInteger(catalogId) || catalogId < 1) {
+ return res.status(400).json(standardizeError('Invalid catalog ID', 'VALIDATION_ERROR'));
+ }
+
+ const catalogEntry = db.prepare('SELECT id FROM subscription_catalog WHERE id = ?').get(catalogId);
+ if (!catalogEntry) return res.status(404).json(standardizeError('Catalog entry not found', 'NOT_FOUND'));
+
+ const descriptor = String(req.body?.descriptor ?? '').trim();
+ if (!descriptor) {
+ return res.status(400).json(standardizeError('descriptor is required', 'VALIDATION_ERROR', 'descriptor'));
+ }
+ if (descriptor.length > 100) {
+ return res.status(400).json(standardizeError('descriptor must be 100 characters or less', 'VALIDATION_ERROR', 'descriptor'));
+ }
+
+ try {
+ // Check for case-insensitive duplicate
+ const exists = db.prepare(
+ 'SELECT id FROM user_catalog_descriptors WHERE user_id = ? AND catalog_id = ? AND LOWER(descriptor) = LOWER(?)'
+ ).get(req.user.id, catalogId, descriptor);
+ if (exists) {
+ return res.status(409).json(standardizeError('Descriptor already exists for this service', 'DUPLICATE_ERROR', 'descriptor'));
+ }
+
+ const result = db.prepare(
+ 'INSERT INTO user_catalog_descriptors (user_id, catalog_id, descriptor) VALUES (?, ?, ?)'
+ ).run(req.user.id, catalogId, descriptor);
+
+ res.status(201).json({ id: result.lastInsertRowid, descriptor, catalog_id: catalogId });
+ } catch (err) {
+ res.status(500).json(standardizeError(err.message || 'Failed to add descriptor', 'DESCRIPTOR_ADD_ERROR'));
+ }
+});
+
+// Delete a user-added catalog descriptor
+router.delete('/catalog/descriptors/:id', (req, res) => {
+ const db = getDb();
+ const descriptorId = parseInt(req.params.id, 10);
+ if (!Number.isInteger(descriptorId) || descriptorId < 1) {
+ return res.status(400).json(standardizeError('Invalid descriptor ID', 'VALIDATION_ERROR'));
+ }
+
+ try {
+ const result = db.prepare(
+ 'DELETE FROM user_catalog_descriptors WHERE id = ? AND user_id = ?'
+ ).run(descriptorId, req.user.id);
+ if (result.changes === 0) {
+ return res.status(404).json(standardizeError('Descriptor not found', 'NOT_FOUND'));
+ }
+ res.json({ ok: true });
+ } catch (err) {
+ res.status(500).json(standardizeError(err.message || 'Failed to delete descriptor', 'DESCRIPTOR_DELETE_ERROR'));
+ }
+});
+
router.patch('/:id', (req, res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
diff --git a/services/subscriptionService.js b/services/subscriptionService.js
index f95e3ed..6a01778 100644
--- a/services/subscriptionService.js
+++ b/services/subscriptionService.js
@@ -40,9 +40,42 @@ const TYPE_KEYWORDS = [
// ── Catalog ───────────────────────────────────────────────────────────────────
-function loadCatalog(db) {
+function loadCatalog(db, userId) {
try {
- return db.prepare('SELECT id, rank, name, category, subscription_type, domain, website FROM subscription_catalog ORDER BY rank ASC').all();
+ const catalog = db.prepare(
+ 'SELECT id, rank, name, category, subscription_type, domain, website, starting_monthly_usd FROM subscription_catalog ORDER BY rank ASC'
+ ).all();
+ if (!catalog.length) return [];
+
+ // Attach bank descriptors and slang terms if the table exists (v0.95+)
+ let descriptors = [];
+ try {
+ descriptors = db.prepare(
+ 'SELECT catalog_id, descriptor, descriptor_type FROM subscription_catalog_descriptors'
+ ).all();
+ } catch { /* pre-v0.95 */ }
+
+ // Merge user-specific custom descriptors (v0.96+)
+ if (userId) {
+ try {
+ const userDescs = db.prepare(
+ 'SELECT catalog_id, descriptor FROM user_catalog_descriptors WHERE user_id = ?'
+ ).all(userId);
+ for (const d of userDescs) {
+ descriptors.push({ catalog_id: d.catalog_id, descriptor: d.descriptor, descriptor_type: 'bank' });
+ }
+ } catch { /* pre-v0.96 */ }
+ }
+
+ const byId = new Map(catalog.map(c => [c.id, { ...c, bankDescs: [], slangTerms: [] }]));
+ for (const d of descriptors) {
+ const entry = byId.get(d.catalog_id);
+ if (!entry) continue;
+ const normalized = normalizeMerchant(d.descriptor);
+ if (d.descriptor_type === 'bank') entry.bankDescs.push(normalized);
+ else entry.slangTerms.push(normalized);
+ }
+ return [...byId.values()];
} catch {
return [];
}
@@ -98,19 +131,31 @@ function normalizeCatalogName(value) {
}
// Given a normalized merchant string, find the best matching catalog entry.
-// Matches on service name (normalized) or domain (dot replaced with space).
+// Priority: (1) bank descriptor substring match, (2) name/domain fuzzy match.
function lookupCatalog(catalog, merchantText) {
if (!catalog.length || !merchantText) return null;
let best = null;
let bestScore = 0;
- const merchantCompact = compactCatalogKey(merchantText);
+ const normalized = normalizeMerchant(merchantText);
+ const compact = compactCatalogKey(merchantText);
+
for (const entry of catalog) {
+ // 1. Bank statement descriptors — normalized against our own normalizeMerchant so
+ // "NETFLIX.COM LOS GATOS CA" and raw payee "netflix" both reduce to comparable forms.
+ for (const desc of (entry.bankDescs || [])) {
+ if (desc.length >= 4 && (normalized.includes(desc) || desc.includes(normalized))) {
+ const score = 2000 + desc.length;
+ if (score > bestScore) { best = entry; bestScore = score; }
+ }
+ }
+
+ // 2. Name / domain fuzzy match (original logic, unchanged)
const nameKey = normalizeCatalogName(entry.name);
const nameCompact = compactCatalogKey(entry.name);
const nameScore = 1000 + nameKey.length;
if (
nameKey.length >= 3
- && (merchantText.includes(nameKey) || (nameCompact.length >= 5 && merchantCompact.includes(nameCompact)))
+ && (normalized.includes(nameKey) || (nameCompact.length >= 5 && compact.includes(nameCompact)))
&& nameScore > bestScore
) {
best = entry;
@@ -120,7 +165,7 @@ function lookupCatalog(catalog, merchantText) {
const domainCompact = domainKey.replace(/\s+/g, '');
const domainScore = 500 + domainKey.length;
if (
- (merchantText.includes(domainKey) || (domainCompact.length >= 5 && merchantCompact.includes(domainCompact)))
+ (normalized.includes(domainKey) || (domainCompact.length >= 5 && compact.includes(domainCompact)))
&& domainScore > bestScore
) {
best = entry;
@@ -174,6 +219,7 @@ function catalogMatchPayload(catalogEntry) {
category: catalogEntry.category,
subscription_type: catalogEntry.subscription_type || 'other',
website: catalogEntry.website || null,
+ starting_monthly_usd: catalogEntry.starting_monthly_usd ?? null,
} : null;
}
@@ -221,7 +267,7 @@ function decorateSubscription(bill) {
function getSubscriptions(db, userId) {
return db.prepare(`
- SELECT b.*, c.name AS category_name,
+ SELECT b.*, b.catalog_id, c.name AS category_name,
CASE WHEN EXISTS(
SELECT 1 FROM bill_merchant_rules WHERE bill_id = b.id AND user_id = b.user_id
) THEN 1 ELSE 0 END AS has_merchant_rule
@@ -289,7 +335,7 @@ function declineRecommendation(db, userId, declineKey) {
// ── Recommendations ───────────────────────────────────────────────────────────
function getSubscriptionRecommendations(db, userId) {
- const catalog = loadCatalog(db);
+ const catalog = loadCatalog(db, userId);
const catalogTypeMap = buildCatalogTypeMap(catalog);
const existingNames = existingBillNames(db, userId);
const declined = getDeclinedKeys(db, userId);
@@ -457,7 +503,7 @@ function searchSubscriptionTransactions(db, userId, query = {}) {
if (q.length < 2) return [];
const limit = Math.max(1, Math.min(parseInt(query.limit || '50', 10) || 50, 100));
const like = `%${q}%`;
- const catalog = loadCatalog(db);
+ const catalog = loadCatalog(db, userId);
const rows = db.prepare(`
SELECT
@@ -534,6 +580,16 @@ function createSubscriptionFromRecommendation(db, userId, payload = {}) {
}
const created = insertBill(db, userId, validation.normalized);
+
+ // Persist catalog link so the catalog browser can show this bill as "matched"
+ const catalogId = payload.catalog_match?.id;
+ if (catalogId) {
+ try {
+ db.prepare('UPDATE bills SET catalog_id = ? WHERE id = ?').run(catalogId, created.id);
+ created.catalog_id = catalogId;
+ } catch { /* catalog_id column may not exist pre-v0.96 — safe to ignore */ }
+ }
+
const ids = Array.isArray(payload.transaction_ids)
? payload.transaction_ids.map(id => Number(id)).filter(Number.isInteger).slice(0, 50)
: [];