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 ( + { if (!v) onClose(); }}> + + + Re-link to catalog entry + {bill && ( +

+ Choose the correct service for{' '} + {bill.name}. +

+ )} +
+ + setSearch(e.target.value)} + className="text-sm" + /> + +
+ {/* Unlink option */} + + + {filtered.length === 0 && search ? ( +

No services found.

+ ) : filtered.map(e => ( + + ))} +
+ + + + + +
+
+ ); +} + +// ── 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 ? ( +
+

{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) : [];