feat: subscription catalog with bank descriptors, custom per-user descriptors, catalog→bill linking
This commit is contained in:
parent
17478ebd9c
commit
3a034ddeb7
|
|
@ -235,6 +235,10 @@ export const api = {
|
||||||
updateSubscription: (id, data) => _fetch('PATCH', `/subscriptions/${id}`, data),
|
updateSubscription: (id, data) => _fetch('PATCH', `/subscriptions/${id}`, data),
|
||||||
createSubscriptionFromRecommendation: (data) => post('/subscriptions/recommendations/create', data),
|
createSubscriptionFromRecommendation: (data) => post('/subscriptions/recommendations/create', data),
|
||||||
declineRecommendation: (declineKey) => post('/subscriptions/recommendations/decline', { decline_key: declineKey }),
|
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
|
// Payments
|
||||||
quickPay: (data) => post('/payments/quick', data),
|
quickPay: (data) => post('/payments/quick', data),
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="mt-2 border-t border-border/30 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(s => !s)}
|
||||||
|
className="flex items-center gap-1 text-[11px] text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ChevronDown className={cn('h-3 w-3 transition-transform', !open && '-rotate-90')} />
|
||||||
|
Custom bank descriptors
|
||||||
|
{descriptors.length > 0 && (
|
||||||
|
<span className="ml-1 rounded bg-muted/50 px-1 font-mono text-[10px]">{descriptors.length}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="mt-2 space-y-1.5">
|
||||||
|
{descriptors.map(d => (
|
||||||
|
<div key={d.id} className="flex items-center gap-2">
|
||||||
|
<span className="flex-1 rounded border border-border/40 bg-muted/30 px-2 py-0.5 font-mono text-[11px]">
|
||||||
|
{d.descriptor}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDelete(d.id)}
|
||||||
|
className="text-muted-foreground transition-colors hover:text-destructive"
|
||||||
|
title="Remove descriptor"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. NETFLIX DIGITAL"
|
||||||
|
value={value}
|
||||||
|
onChange={e => setValue(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' && value.trim()) handleAdd(); }}
|
||||||
|
className="h-7 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!value.trim() || busy}
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="h-7 px-2"
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground/70">
|
||||||
|
Add the exact payee text your bank uses to improve auto-matching.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<div className="rounded-lg border border-primary/20 bg-primary/5 p-3 transition-colors hover:border-primary/30">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="text-sm font-semibold text-foreground">{entry.name}</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-primary/25 bg-primary/10 text-[10px] text-primary"
|
||||||
|
>
|
||||||
|
{TYPE_LABELS[entry.subscription_type] || 'Other'}
|
||||||
|
</Badge>
|
||||||
|
{bill && !bill.active && (
|
||||||
|
<Badge variant="outline" className="border-amber-500/25 bg-amber-500/10 text-[10px] text-amber-400">
|
||||||
|
Paused
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs">
|
||||||
|
{bill && (
|
||||||
|
<span className="font-mono font-semibold text-foreground">{fmt(bill.monthly_equivalent)}/mo</span>
|
||||||
|
)}
|
||||||
|
{drift === 'match' && entry.starting_monthly_usd && (
|
||||||
|
<span className="flex items-center gap-1 text-emerald-500">
|
||||||
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
|
matches catalog price
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{drift === 'above' && (
|
||||||
|
<span className="text-amber-500">
|
||||||
|
catalog from ${entry.starting_monthly_usd.toFixed(2)}/mo
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{drift === 'below' && entry.starting_monthly_usd && (
|
||||||
|
<span className="text-sky-500">
|
||||||
|
catalog from ${entry.starting_monthly_usd.toFixed(2)}/mo
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!entry.starting_monthly_usd && entry.website && (
|
||||||
|
<a
|
||||||
|
href={entry.website}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex items-center gap-0.5 text-muted-foreground hover:text-primary"
|
||||||
|
>
|
||||||
|
website <ExternalLink className="h-2.5 w-2.5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 gap-1 text-xs"
|
||||||
|
onClick={() => onEdit(bill.id)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => onRelink(entry)}
|
||||||
|
title="Change catalog link"
|
||||||
|
>
|
||||||
|
<Link2 className="h-3 w-3" />
|
||||||
|
Re-link
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DescriptorEditor
|
||||||
|
catalogId={entry.id}
|
||||||
|
descriptors={descs}
|
||||||
|
onAdd={onAddDescriptor}
|
||||||
|
onDelete={onDeleteDescriptor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Card for an untracked catalog entry ───────────────────────────────────
|
||||||
|
function UnmatchedCard({ entry, selected, onToggleSelect, onTrack, trackingBusy }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-lg border px-3 py-2.5 transition-colors',
|
||||||
|
selected
|
||||||
|
? 'border-primary/30 bg-primary/5'
|
||||||
|
: 'border-border/60 bg-background/35 hover:border-border/80',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selected}
|
||||||
|
onCheckedChange={checked => onToggleSelect(entry.id, !!checked)}
|
||||||
|
aria-label={`Select ${entry.name}`}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-foreground">{entry.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{entry.category}
|
||||||
|
{entry.starting_monthly_usd && (
|
||||||
|
<span className="ml-2 font-mono">from ${entry.starting_monthly_usd.toFixed(2)}/mo</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{entry.website && (
|
||||||
|
<a
|
||||||
|
href={entry.website}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-primary"
|
||||||
|
title="Open website"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 shrink-0 gap-1 text-xs"
|
||||||
|
disabled={trackingBusy}
|
||||||
|
onClick={() => onTrack(entry)}
|
||||||
|
>
|
||||||
|
{trackingBusy
|
||||||
|
? <Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
: <><Plus className="h-3 w-3" />Track</>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<Dialog open={open} onOpenChange={v => { if (!v) onClose(); }}>
|
||||||
|
<DialogContent className="border-border/60 bg-card/95 backdrop-blur-xl sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base font-semibold">Re-link to catalog entry</DialogTitle>
|
||||||
|
{bill && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Choose the correct service for{' '}
|
||||||
|
<span className="font-medium text-foreground">{bill.name}</span>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
placeholder="Search services…"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="max-h-60 divide-y divide-border/40 overflow-y-auto rounded-lg border border-border/60">
|
||||||
|
{/* Unlink option */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelected('unlink')}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-muted/30',
|
||||||
|
selected === 'unlink' && 'bg-primary/5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Link2Off className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="text-sm text-muted-foreground">Unlink from catalog</span>
|
||||||
|
{selected === 'unlink' && (
|
||||||
|
<CheckCircle2 className="ml-auto h-4 w-4 shrink-0 text-primary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{filtered.length === 0 && search ? (
|
||||||
|
<p className="px-3 py-4 text-center text-sm text-muted-foreground">No services found.</p>
|
||||||
|
) : filtered.map(e => (
|
||||||
|
<button
|
||||||
|
key={e.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelected(e.id)}
|
||||||
|
disabled={e.id === relinkEntry?.id}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center justify-between gap-3 px-3 py-2 text-left transition-colors',
|
||||||
|
'hover:bg-muted/30 disabled:pointer-events-none disabled:opacity-40',
|
||||||
|
selected === e.id && 'bg-primary/5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">{e.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{e.category}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{e.starting_monthly_usd && (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
|
${e.starting_monthly_usd.toFixed(2)}/mo
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{selected === e.id && <CheckCircle2 className="h-4 w-4 text-primary" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button type="button" variant="ghost" onClick={onClose} className="text-xs">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={!selected || busy}
|
||||||
|
onClick={() => onConfirm(bill?.id, selected === 'unlink' ? null : selected)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{busy ? 'Saving…' : 'Confirm'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<CardHeader className="px-4 pb-3 sm:px-6">
|
||||||
|
<CardTitle className="text-base">Known Services</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Popular subscriptions and services. Ones you're already tracking appear at the top.
|
||||||
|
</CardDescription>
|
||||||
|
|
||||||
|
{/* Category filter chips */}
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
|
{CHIPS.map(chip => (
|
||||||
|
<button
|
||||||
|
key={String(chip.value)}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveType(chip.value)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors',
|
||||||
|
activeType === chip.value
|
||||||
|
? 'border-primary/40 bg-primary/15 text-primary'
|
||||||
|
: 'border-border/60 bg-muted/20 text-muted-foreground hover:border-border/80 hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{chip.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative mt-2">
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search services…"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
className="h-9 pl-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading catalog…
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="px-4 py-8 text-center">
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-3 text-xs"
|
||||||
|
onClick={loadCatalog}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border/40">
|
||||||
|
|
||||||
|
{/* ── Matched group ───────────────────────────────────────── */}
|
||||||
|
{matched.length > 0 && (
|
||||||
|
<div className="px-4 pb-3 pt-3 sm:px-6">
|
||||||
|
<p className="mb-2.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Tracking ({matched.length})
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{matched.map(entry => (
|
||||||
|
<MatchedCard
|
||||||
|
key={entry.id}
|
||||||
|
entry={entry}
|
||||||
|
onEdit={onEditBill}
|
||||||
|
onRelink={setRelinkEntry}
|
||||||
|
onAddDescriptor={handleAddDescriptor}
|
||||||
|
onDeleteDescriptor={(descriptorId) => handleDeleteDescriptor(entry.id, descriptorId)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Unmatched toggle + group ─────────────────────────── */}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowUnmatched(s => !s)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center justify-between px-4 py-3 text-sm font-medium text-muted-foreground',
|
||||||
|
'transition-colors hover:bg-muted/20 hover:text-foreground sm:px-6',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<ChevronDown
|
||||||
|
className={cn('h-4 w-4 transition-transform', !showUnmatched && '-rotate-90')}
|
||||||
|
/>
|
||||||
|
{showUnmatched
|
||||||
|
? `Hide ${unmatched.length} more service${unmatched.length !== 1 ? 's' : ''}`
|
||||||
|
: `Show ${unmatched.length} more service${unmatched.length !== 1 ? 's' : ''}`}
|
||||||
|
</span>
|
||||||
|
{selectedIds.size > 0 && (
|
||||||
|
<Badge variant="outline" className="border-primary/30 bg-primary/10 text-primary text-[10px]">
|
||||||
|
{selectedIds.size} selected
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showUnmatched && (
|
||||||
|
<div className="px-4 pb-4 pt-1 sm:px-6">
|
||||||
|
{unmatched.length === 0 ? (
|
||||||
|
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||||
|
{search || activeType ? 'No services match your filter.' : 'All known services are already being tracked.'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{unmatched.map(entry => (
|
||||||
|
<UnmatchedCard
|
||||||
|
key={entry.id}
|
||||||
|
entry={entry}
|
||||||
|
selected={selectedIds.has(entry.id)}
|
||||||
|
onToggleSelect={toggleSelect}
|
||||||
|
onTrack={handleTrack}
|
||||||
|
trackingBusy={trackBusyId === entry.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
{/* ── Re-link dialog ─────────────────────────────────────────────── */}
|
||||||
|
<ReLinkDialog
|
||||||
|
open={!!relinkEntry}
|
||||||
|
relinkEntry={relinkEntry}
|
||||||
|
allEntries={catalog}
|
||||||
|
onClose={() => setRelinkEntry(null)}
|
||||||
|
onConfirm={handleRelink}
|
||||||
|
busy={relinkBusy}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Bulk action bar ────────────────────────────────────────────── */}
|
||||||
|
{selectedIds.size > 0 && (
|
||||||
|
<div className="sticky bottom-0 flex items-center justify-between gap-3 border-t border-primary/20 bg-card/95 px-4 py-3 backdrop-blur-md sm:px-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="border-primary/30 bg-primary/10 font-mono text-primary">
|
||||||
|
{selectedIds.size}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{selectedIds.size === 1 ? 'service' : 'services'} selected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
onClick={() => setSelectedIds(new Set())}
|
||||||
|
disabled={bulkBusy}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
disabled={bulkBusy}
|
||||||
|
onClick={handleBulkTrack}
|
||||||
|
>
|
||||||
|
{bulkBusy
|
||||||
|
? <><Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />Adding…</>
|
||||||
|
: `Track ${selectedIds.size} selected`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,7 @@ 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 = {
|
||||||
|
|
@ -827,6 +828,14 @@ export default function SubscriptionsPage() {
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<SubscriptionCatalogSection
|
||||||
|
onEditBill={billId => {
|
||||||
|
const bill = subscriptions.find(s => s.id === billId) || { id: billId };
|
||||||
|
setModal({ bill });
|
||||||
|
}}
|
||||||
|
onTrackComplete={refreshAll}
|
||||||
|
/>
|
||||||
|
|
||||||
{modal && (
|
{modal && (
|
||||||
<BillModal
|
<BillModal
|
||||||
key={modal.bill?.id ? `subscription-edit-${modal.bill.id}` : 'subscription-new'}
|
key={modal.bill?.id ? `subscription-edit-${modal.bill.id}` : 'subscription-new'}
|
||||||
|
|
|
||||||
185
db/database.js
185
db/database.js
|
|
@ -3072,6 +3072,178 @@ function runMigrations() {
|
||||||
console.log(`[v0.94] sessions: cleared ${count} existing plaintext sessions (re-login required)`);
|
console.log(`[v0.94] sessions: cleared ${count} existing plaintext sessions (re-login required)`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
version: 'v0.95',
|
||||||
|
description: 'subscription_catalog: bank descriptors + pricing from 2026 researched dataset',
|
||||||
|
run() {
|
||||||
|
// 1. Add new columns to subscription_catalog
|
||||||
|
const cols = db.prepare('PRAGMA table_info(subscription_catalog)').all().map(c => 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 ───────────────────────────────────────────
|
// ── users: notification columns ───────────────────────────────────────────
|
||||||
|
|
@ -3416,6 +3588,19 @@ function getDbPath() {
|
||||||
|
|
||||||
// Rollback SQL definitions
|
// Rollback SQL definitions
|
||||||
const ROLLBACK_SQL_MAP = {
|
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': {
|
'v0.94': {
|
||||||
description: 'security: session token hashing + geolocation opt-in setting',
|
description: 'security: session token hashing + geolocation opt-in setting',
|
||||||
sql: [
|
sql: [
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ const {
|
||||||
getSubscriptionRecommendations,
|
getSubscriptionRecommendations,
|
||||||
getSubscriptionSummary,
|
getSubscriptionSummary,
|
||||||
getSubscriptions,
|
getSubscriptions,
|
||||||
|
monthlyEquivalent,
|
||||||
searchSubscriptionTransactions,
|
searchSubscriptionTransactions,
|
||||||
} = require('../services/subscriptionService');
|
} = require('../services/subscriptionService');
|
||||||
const { addMerchantRule, applyMerchantRules } = require('../services/billMerchantRuleService');
|
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) => {
|
router.patch('/:id', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const billId = parseInt(req.params.id, 10);
|
const billId = parseInt(req.params.id, 10);
|
||||||
|
|
|
||||||
|
|
@ -40,9 +40,42 @@ const TYPE_KEYWORDS = [
|
||||||
|
|
||||||
// ── Catalog ───────────────────────────────────────────────────────────────────
|
// ── Catalog ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function loadCatalog(db) {
|
function loadCatalog(db, userId) {
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
@ -98,19 +131,31 @@ function normalizeCatalogName(value) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Given a normalized merchant string, find the best matching catalog entry.
|
// 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) {
|
function lookupCatalog(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;
|
||||||
const merchantCompact = compactCatalogKey(merchantText);
|
const normalized = normalizeMerchant(merchantText);
|
||||||
|
const compact = compactCatalogKey(merchantText);
|
||||||
|
|
||||||
for (const entry of catalog) {
|
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 nameKey = normalizeCatalogName(entry.name);
|
||||||
const nameCompact = compactCatalogKey(entry.name);
|
const nameCompact = compactCatalogKey(entry.name);
|
||||||
const nameScore = 1000 + nameKey.length;
|
const nameScore = 1000 + nameKey.length;
|
||||||
if (
|
if (
|
||||||
nameKey.length >= 3
|
nameKey.length >= 3
|
||||||
&& (merchantText.includes(nameKey) || (nameCompact.length >= 5 && merchantCompact.includes(nameCompact)))
|
&& (normalized.includes(nameKey) || (nameCompact.length >= 5 && compact.includes(nameCompact)))
|
||||||
&& nameScore > bestScore
|
&& nameScore > bestScore
|
||||||
) {
|
) {
|
||||||
best = entry;
|
best = entry;
|
||||||
|
|
@ -120,7 +165,7 @@ function lookupCatalog(catalog, merchantText) {
|
||||||
const domainCompact = domainKey.replace(/\s+/g, '');
|
const domainCompact = domainKey.replace(/\s+/g, '');
|
||||||
const domainScore = 500 + domainKey.length;
|
const domainScore = 500 + domainKey.length;
|
||||||
if (
|
if (
|
||||||
(merchantText.includes(domainKey) || (domainCompact.length >= 5 && merchantCompact.includes(domainCompact)))
|
(normalized.includes(domainKey) || (domainCompact.length >= 5 && compact.includes(domainCompact)))
|
||||||
&& domainScore > bestScore
|
&& domainScore > bestScore
|
||||||
) {
|
) {
|
||||||
best = entry;
|
best = entry;
|
||||||
|
|
@ -174,6 +219,7 @@ function catalogMatchPayload(catalogEntry) {
|
||||||
category: catalogEntry.category,
|
category: catalogEntry.category,
|
||||||
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,
|
||||||
} : null;
|
} : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -221,7 +267,7 @@ function decorateSubscription(bill) {
|
||||||
|
|
||||||
function getSubscriptions(db, userId) {
|
function getSubscriptions(db, userId) {
|
||||||
return db.prepare(`
|
return db.prepare(`
|
||||||
SELECT b.*, c.name AS category_name,
|
SELECT b.*, b.catalog_id, c.name AS category_name,
|
||||||
CASE WHEN EXISTS(
|
CASE WHEN EXISTS(
|
||||||
SELECT 1 FROM bill_merchant_rules WHERE bill_id = b.id AND user_id = b.user_id
|
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
|
) THEN 1 ELSE 0 END AS has_merchant_rule
|
||||||
|
|
@ -289,7 +335,7 @@ function declineRecommendation(db, userId, declineKey) {
|
||||||
// ── Recommendations ───────────────────────────────────────────────────────────
|
// ── Recommendations ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function getSubscriptionRecommendations(db, userId) {
|
function getSubscriptionRecommendations(db, userId) {
|
||||||
const catalog = loadCatalog(db);
|
const catalog = loadCatalog(db, userId);
|
||||||
const catalogTypeMap = buildCatalogTypeMap(catalog);
|
const catalogTypeMap = buildCatalogTypeMap(catalog);
|
||||||
const existingNames = existingBillNames(db, userId);
|
const existingNames = existingBillNames(db, userId);
|
||||||
const declined = getDeclinedKeys(db, userId);
|
const declined = getDeclinedKeys(db, userId);
|
||||||
|
|
@ -457,7 +503,7 @@ function searchSubscriptionTransactions(db, userId, query = {}) {
|
||||||
if (q.length < 2) return [];
|
if (q.length < 2) return [];
|
||||||
const limit = Math.max(1, Math.min(parseInt(query.limit || '50', 10) || 50, 100));
|
const limit = Math.max(1, Math.min(parseInt(query.limit || '50', 10) || 50, 100));
|
||||||
const like = `%${q}%`;
|
const like = `%${q}%`;
|
||||||
const catalog = loadCatalog(db);
|
const catalog = loadCatalog(db, userId);
|
||||||
|
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -534,6 +580,16 @@ function createSubscriptionFromRecommendation(db, userId, payload = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const created = insertBill(db, userId, validation.normalized);
|
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)
|
const ids = Array.isArray(payload.transaction_ids)
|
||||||
? payload.transaction_ids.map(id => Number(id)).filter(Number.isInteger).slice(0, 50)
|
? payload.transaction_ids.map(id => Number(id)).filter(Number.isInteger).slice(0, 50)
|
||||||
: [];
|
: [];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue