import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import { toast } from 'sonner'; import { Bell, CalendarDays, CheckCircle2, CheckCircle, Cloud, ArrowDown, ArrowUp, GripVertical, AlertTriangle, Info, Link2, Loader2, MoreHorizontal, Pause, Plus, RefreshCw, Repeat, Search, Sparkles, X, } from 'lucide-react'; import { api } from '@/api'; import { cn, fmt, fmtDate } from '@/lib/utils'; import { scheduleLabel } from '@/lib/billingSchedule'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import BillModal from '@/components/BillModal'; import BillHistoricalImportDialog from '@/components/BillHistoricalImportDialog'; import { getLinkImportPref } from '@/pages/SettingsPage'; import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder'; 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 SUBSCRIPTION_SORT_KEY = 'subscriptions_sort_mode'; const CADENCE_ORDER = ['weekly', 'biweekly', 'monthly', 'quarterly', 'annual', 'other']; const CADENCE_LABELS = { weekly: 'Weekly', biweekly: 'Biweekly', monthly: 'Monthly', quarterly: 'Quarterly', annual: 'Yearly', other: 'Other', }; const SUBSCRIPTION_MONTHLY_FACTORS = { weekly: 52 / 12, biweekly: 26 / 12, monthly: 1, quarterly: 1 / 3, annual: 1 / 12, annually: 1 / 12, }; function normalizedCadence(item) { const raw = String(item?.cycle_type || item?.billing_cycle || '').toLowerCase(); if (raw.includes('week') && raw.includes('bi')) return 'biweekly'; if (raw === 'biweekly') return 'biweekly'; if (raw.includes('week')) return 'weekly'; if (raw.includes('quarter')) return 'quarterly'; if (raw.includes('annual') || raw.includes('year')) return 'annual'; if (raw.includes('month') || !raw) return 'monthly'; return 'other'; } function subscriptionMonthlyEquivalent(item) { const key = String(item?.cycle_type || item?.billing_cycle || 'monthly').toLowerCase(); const fallback = String(item?.billing_cycle || '').toLowerCase() === 'quarterly' ? 'quarterly' : String(item?.billing_cycle || '').toLowerCase() === 'annually' ? 'annual' : key; const factor = SUBSCRIPTION_MONTHLY_FACTORS[key] ?? SUBSCRIPTION_MONTHLY_FACTORS[fallback] ?? 1; return Math.round(Number(item?.expected_amount || 0) * factor * 100) / 100; } function subscriptionNextDueDate(item, now = new Date()) { const dueDay = Math.min(Math.max(Number(item?.due_day) || 1, 1), 31); const cycle = String(item?.cycle_type || item?.billing_cycle || 'monthly').toLowerCase(); let date = new Date(now.getFullYear(), now.getMonth(), dueDay); if (date < new Date(now.getFullYear(), now.getMonth(), now.getDate())) { date = new Date(now.getFullYear(), now.getMonth() + 1, dueDay); } if (cycle === 'quarterly' || cycle === 'annual' || cycle === 'annually') { const startMonth = Math.min(Math.max(Number(item?.cycle_day) || 1, 1), 12) - 1; const step = cycle === 'quarterly' ? 3 : 12; date = new Date(now.getFullYear(), startMonth, dueDay); while (date < new Date(now.getFullYear(), now.getMonth(), now.getDate())) { date = new Date(date.getFullYear(), date.getMonth() + step, dueDay); } } return date.toISOString().slice(0, 10); } function decorateSavedSubscriptionBill(bill, categories) { const monthly = subscriptionMonthlyEquivalent(bill); const category = categories.find(item => Number(item.id) === Number(bill.category_id)); return { ...bill, active: !!bill.active, is_subscription: !!bill.is_subscription, category_name: bill.category_name || category?.name || null, monthly_equivalent: monthly, yearly_equivalent: Math.round(monthly * 12 * 100) / 100, next_due_date: subscriptionNextDueDate(bill), subscription_type: bill.subscription_type || 'other', }; } function subscriptionSummaryFromList(subscriptions) { const active = subscriptions.filter(item => item.active); const monthlyTotal = active.reduce((sum, item) => sum + Number(item.monthly_equivalent || 0), 0); const typeTotals = new Map(); for (const item of active) { const type = item.subscription_type || 'other'; typeTotals.set(type, (typeTotals.get(type) || 0) + Number(item.monthly_equivalent || 0)); } const topType = [...typeTotals.entries()].sort((a, b) => b[1] - a[1])[0] || null; return { active_count: active.length, paused_count: subscriptions.length - active.length, monthly_total: Math.round(monthlyTotal * 100) / 100, yearly_total: Math.round(monthlyTotal * 12 * 100) / 100, top_type: topType ? { type: topType[0], monthly_total: Math.round(topType[1] * 100) / 100 } : null, }; } function cadenceIndex(item) { const index = CADENCE_ORDER.indexOf(normalizedCadence(item)); return index >= 0 ? index : CADENCE_ORDER.length - 1; } function sortSubscriptionsByCadence(items) { return [...items].sort((a, b) => ( cadenceIndex(a) - cadenceIndex(b) || String(a.next_due_date || '').localeCompare(String(b.next_due_date || '')) || (Number(a.due_day) || 0) - (Number(b.due_day) || 0) || String(a.name || '').localeCompare(String(b.name || '')) )); } function SortModeButton({ active, children, onClick }) { return ( ); } function cycleLabel(item) { return scheduleLabel(item); } function StatCard({ icon: Icon, label, value, hint }) { return (
{label}

{value}

{hint &&

{hint}

}
); } function SubscriptionRowActions({ item, onEdit, onToggle, busy }) { return (
onToggle(item)}> {item.active ? : } {item.active ? 'Pause' : 'Resume'}
); } function SubscriptionRow({ item, onEdit, onToggle, moveControls, dragProps, busy }) { return (
{TYPE_LABELS[item.subscription_type] || 'Other'} {!item.active && ( Paused )}
{item.category_name || 'Uncategorized'} Due {fmtDate(item.next_due_date)} {cycleLabel(item)} {item.reminder_days_before ?? 3}d reminder

Per cycle

{fmt(item.expected_amount)}

Monthly

{fmt(item.monthly_equivalent)}

); } function BillPickerDialog({ open, onClose, recommendation, bills, onConfirm, busy }) { const [search, setSearch] = useState(''); const [selectedId, setSelectedId] = useState(null); useEffect(() => { if (open) { setSearch(''); setSelectedId(null); } }, [open]); const filtered = useMemo(() => { const q = search.toLowerCase(); return (bills || []).filter(b => !q || b.name.toLowerCase().includes(q)); }, [bills, search]); return ( { if (!v) onClose(); }}> Link to existing bill

{recommendation?.name} {recommendation && ( {recommendation.occurrence_count} charge{recommendation.occurrence_count !== 1 ? 's' : ''} · {fmt(recommendation.expected_amount)} )}

The matching transactions will be marked as paid under the selected bill.

setSearch(e.target.value)} className="text-sm" />
{filtered.length === 0 ? (

No bills found.

) : filtered.map(bill => ( ))}
); } function TxResultRow({ tx, onTrack }) { const dollars = (Math.abs(tx.amount) / 100).toFixed(2); const label = tx.payee || tx.description || tx.memo || '—'; const account = tx.account_name || tx.data_source_name || null; const isMatched = tx.match_status === 'matched'; const catalogMatch = tx.catalog_match; return (
{label} {isMatched ? ( {tx.matched_bill_name || 'Matched'} ) : ( Unmatched )} {catalogMatch && ( Known: {catalogMatch.name} )}

{tx.posted_date}{account ? ` · ${account}` : ''} {catalogMatch ? ` · ${TYPE_LABELS[catalogMatch.subscription_type] || 'Other'}` : ''}

${dollars} {!isMatched && ( )}
); } function RecommendationIconButton({ label, icon: Icon, onClick, disabled, className, variant = 'outline' }) { return ( {label} ); } function RecommendationMoreMenu({ recommendation, existingBill, busy, onAccept, onDecline, onMatch, categoryId }) { return ( {existingBill && ( onAccept({ ...recommendation, category_id: categoryId })}> Track as new )} onMatch(recommendation)}> {existingBill ? 'Choose different bill' : 'Link existing bill'} onDecline(recommendation)}> Dismiss ); } function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, onMatch, onQuickLink, onDetails, busy }) { const identity = recommendation.evidence?.identity; const amount = recommendation.evidence?.amount; const cadence = recommendation.evidence?.cadence; const amountRange = recommendation.evidence?.amount_range; const ambiguity = recommendation.evidence?.ambiguity; const existingBill = recommendation.existing_bill_match; return (

{recommendation.name}

{TYPE_LABELS[recommendation.subscription_type] || 'Other'} · {recommendation.occurrence_count} charges · last seen {fmtDate(recommendation.last_seen_date)}

{recommendation.catalog_match?.starting_monthly_usd && (

Catalog starts at {fmt(recommendation.catalog_match.starting_monthly_usd)} / mo {recommendation.catalog_match.starting_annual_usd ? ` or ${fmt(recommendation.catalog_match.starting_annual_usd)} / yr` : ''}

)} {recommendation.accounts?.length > 0 && (

{recommendation.accounts.length > 1 ? 'Accounts' : 'Account'}: {recommendation.accounts.join(', ')}

)} {existingBill && (

Link to existing bill: {existingBill.name}

)}

{fmt(recommendation.expected_amount)}

{fmt(recommendation.monthly_equivalent)} / mo

{recommendation.confidence}% match {identity?.label && ( {identity.label} )} {amount?.label && ( {amount.match === 'unusual' ? 'Unusual amount' : 'Price checked'} )} {cadence?.recurring && ( Recurring )} {ambiguity?.ambiguous && ( Review )} {existingBill && ( Existing bill )} {amountRange && amountRange.min !== amountRange.max && ( Range {fmt(amountRange.min)}-{fmt(amountRange.max)} )}
onDetails(recommendation)} />
); } function EvidenceItem({ label, value, tone = 'default' }) { if (!value) return null; return (

{label}

{value}

); } function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose, onAccept, onDecline, onMatch, onQuickLink, busy }) { if (!recommendation) return null; const identity = recommendation.evidence?.identity; const amount = recommendation.evidence?.amount; const cadence = recommendation.evidence?.cadence; const amountRange = recommendation.evidence?.amount_range; const ambiguity = recommendation.evidence?.ambiguity; const existingBill = recommendation.existing_bill_match; const transactions = recommendation.transactions || []; const handleAccept = async () => { await onAccept({ ...recommendation, category_id: categoryId }); onClose(); }; const handleDecline = async () => { await onDecline(recommendation); onClose(); }; const handleMatch = () => { onMatch(recommendation); onClose(); }; const handleQuickLink = async () => { if (!existingBill) return; await onQuickLink(recommendation, existingBill.id); onClose(); }; return ( { if (!value) onClose(); }}> {recommendation.name} {recommendation.confidence}% match {ambiguity?.ambiguous && ( Review )} {existingBill && ( Existing bill )}

{TYPE_LABELS[recommendation.subscription_type] || 'Other'} · {recommendation.occurrence_count} charge{recommendation.occurrence_count !== 1 ? 's' : ''} · last seen {fmtDate(recommendation.last_seen_date)}

{amountRange && (

Amount Range

{fmt(amountRange.min)}-{fmt(amountRange.max)} across matching transactions

)} {ambiguity?.ambiguous && (

{ambiguity.label || 'Review before tracking'}

{ambiguity.reasons?.length > 0 && (
{ambiguity.reasons.map(reason => (

{reason}

))}
)}
)} {existingBill && (

Recommended action: link existing bill

{existingBill.name} · {existingBill.expected_amount ? fmt(existingBill.expected_amount) : 'No amount'} · due day {existingBill.due_day || 'not set'}

{existingBill.reasons?.length > 0 && (
{existingBill.reasons.map(reason => (

{reason}

))}
)}
)} {recommendation.reasons?.length > 0 && (
{recommendation.reasons.map(reason => ( {reason} ))}
)} {transactions.length > 0 && (

Bank Transactions

{transactions.map(tx => (

{tx.payee || tx.description || tx.memo || 'Transaction'}

{fmtDate(tx.date)}{tx.account ? ` · ${tx.account}` : ''}

{fmt(tx.amount)}

))}
)}
{existingBill && ( )}
); } export default function SubscriptionsPage() { const [data, setData] = useState({ summary: {}, subscriptions: [] }); const [recommendations, setRecommendations] = useState([]); const [categories, setCategories] = useState([]); const [bills, setBills] = useState([]); const [loading, setLoading] = useState(true); const [recommendationsLoading, setRecommendationsLoading] = useState(true); const [busyId, setBusyId] = useState(null); const [modal, setModal] = useState(null); const [matchTarget, setMatchTarget] = useState(null); const [detailsTarget, setDetailsTarget] = useState(null); const [recSearch, setRecSearch] = useState(''); const [subscriptionSort, setSubscriptionSort] = useState(() => ( localStorage.getItem(SUBSCRIPTION_SORT_KEY) === 'cadence' ? 'cadence' : 'custom' )); const [draggingId, setDraggingId] = useState(null); const [dropTargetId, setDropTargetId] = useState(null); const [movingBillId, setMovingBillId] = useState(null); const [txQuery, setTxQuery] = useState(''); const [txResults, setTxResults] = useState([]); const [txSearching, setTxSearching] = useState(false); const [importDialog, setImportDialog] = useState(null); // { billId, billName } const txDebounce = useRef(null); const subscriptionCategoryId = useMemo(() => { const match = categories.find(category => /subscrip/i.test(category.name)); return match?.id || null; }, [categories]); const load = useCallback(async () => { setLoading(true); try { const [subscriptionData, categoryData] = await Promise.all([ api.subscriptions(), api.categories(), ]); setData(subscriptionData); setCategories(categoryData || []); } catch (err) { toast.error(err.message || 'Failed to load subscriptions.'); } finally { setLoading(false); } }, []); const loadRecommendations = useCallback(async () => { setRecommendationsLoading(true); try { const result = await api.subscriptionRecommendations(); setRecommendations(result.recommendations || []); } catch (err) { toast.error(err.message || 'Failed to scan subscription recommendations.'); } finally { setRecommendationsLoading(false); } }, []); useEffect(() => { load(); loadRecommendations(); api.allBills() .then(b => setBills(Array.isArray(b) ? b : [])) .catch(err => { console.error('[SubscriptionsPage] failed to load bills', err); toast.error(err.message || 'Bills could not be loaded for subscription linking.'); }); }, [load, loadRecommendations]); useEffect(() => { localStorage.setItem(SUBSCRIPTION_SORT_KEY, subscriptionSort); }, [subscriptionSort]); useEffect(() => { clearTimeout(txDebounce.current); const q = txQuery.trim(); if (!q) { setTxResults([]); return; } txDebounce.current = setTimeout(async () => { setTxSearching(true); try { const result = await api.subscriptionTransactionMatches({ q, limit: 50 }); setTxResults(Array.isArray(result) ? result : (result?.transactions ?? [])); } catch (err) { setTxResults([]); toast.error(err.message || 'Transaction search failed.'); } finally { setTxSearching(false); } }, 300); return () => clearTimeout(txDebounce.current); }, [txQuery]); async function refreshAll() { await Promise.all([load(), loadRecommendations()]); } async function toggleSubscription(item) { setBusyId(`toggle-${item.id}`); try { await api.updateSubscription(item.id, { active: !item.active }); toast.success(item.active ? 'Subscription paused' : 'Subscription resumed'); await load(); } catch (err) { toast.error(err.message || 'Subscription could not be updated.'); } finally { setBusyId(null); } } async function acceptRecommendation(recommendation) { setBusyId(`rec-${recommendation.id}`); try { const created = await api.createSubscriptionFromRecommendation(recommendation); toast.success(`${recommendation.name} is now tracked.`); await refreshAll(); if (getLinkImportPref() && recommendation.merchant && created?.id) { setImportDialog({ billId: created.id, billName: created.name || recommendation.name }); } } catch (err) { toast.error(err.message || 'Could not create subscription.'); } finally { setBusyId(null); } } async function declineRecommendation(recommendation) { if (!recommendation.decline_key) return; setBusyId(`dec-${recommendation.id}`); try { await api.declineRecommendation(recommendation); setRecommendations(prev => prev.filter(r => r.id !== recommendation.id)); } catch (err) { toast.error(err.message || 'Could not dismiss recommendation.'); } finally { setBusyId(null); } } async function linkRecommendationToBill(recommendation, billId) { if (!recommendation || !billId) return; setBusyId(`match-${recommendation.id}`); try { const result = await api.matchRecommendationToBill( recommendation.transaction_ids, billId, recommendation.merchant, recommendation.catalog_match?.id, recommendation.confidence, ); toast.success(`Linked ${result.matched_count} transaction${result.matched_count !== 1 ? 's' : ''} to "${result.bill_name}".`); setMatchTarget(null); setRecommendations(prev => prev.filter(r => r.id !== recommendation.id)); } catch (err) { toast.error(err.message || 'Could not link recommendation to bill.'); } finally { setBusyId(null); } } async function matchRecommendationToBill(billId) { await linkRecommendationToBill(matchTarget, billId); } function applySavedBillToPage(savedBill) { if (!savedBill?.id) return; const decorated = decorateSavedSubscriptionBill(savedBill, categories); setBills(prev => { const current = Array.isArray(prev) ? prev : []; const exists = current.some(item => Number(item.id) === Number(savedBill.id)); return exists ? current.map(item => Number(item.id) === Number(savedBill.id) ? { ...item, ...savedBill } : item) : [...current, savedBill]; }); setData(prev => { const current = prev.subscriptions || []; const exists = current.some(item => Number(item.id) === Number(savedBill.id)); const nextSubscriptions = decorated.is_subscription ? exists ? current.map(item => Number(item.id) === Number(savedBill.id) ? { ...item, ...decorated } : item) : [...current, decorated] : current.filter(item => Number(item.id) !== Number(savedBill.id)); return { ...prev, subscriptions: nextSubscriptions, summary: subscriptionSummaryFromList(nextSubscriptions), }; }); } function handleBillModalSave(savedBill) { if (!savedBill?.id) return; applySavedBillToPage(savedBill); } function openManualSubscription() { setModal({ bill: null, initialBill: { name: '', category_id: subscriptionCategoryId, due_day: new Date().getDate(), expected_amount: '', billing_cycle: 'monthly', cycle_type: 'monthly', cycle_day: String(new Date().getDate()), is_subscription: 1, subscription_type: 'other', reminder_days_before: 3, }, }); } function openFromTransaction(tx) { const catalogMatch = tx.catalog_match; const label = catalogMatch?.name || tx.payee || tx.description || tx.memo || ''; const dollars = Math.abs(tx.amount ?? 0) / 100; const rawDay = tx.posted_date ? new Date(tx.posted_date + 'T12:00:00').getDate() : NaN; const dueDay = Number.isInteger(rawDay) && rawDay >= 1 && rawDay <= 31 ? rawDay : new Date().getDate(); setModal({ bill: null, initialBill: { name: label, category_id: subscriptionCategoryId, due_day: dueDay, expected_amount: dollars > 0 ? dollars.toFixed(2) : '', cycle_type: 'monthly', is_subscription: 1, subscription_type: catalogMatch?.subscription_type || 'other', website: catalogMatch?.website || undefined, notes: catalogMatch ? `Matched known subscription catalog entry: ${catalogMatch.name}` : undefined, reminder_days_before: 3, }, }); } const summary = data.summary || {}; const subscriptions = data.subscriptions || []; const active = subscriptions.filter(item => item.active); const paused = subscriptions.filter(item => !item.active); const sortedActive = useMemo( () => subscriptionSort === 'cadence' ? sortSubscriptionsByCadence(active) : active, [active, subscriptionSort], ); const sortedPaused = useMemo( () => subscriptionSort === 'cadence' ? sortSubscriptionsByCadence(paused) : paused, [paused, subscriptionSort], ); const reorderEnabled = !loading && bills.length > 0 && subscriptionSort === 'custom'; async function persistSubscriptionOrder(nextSubscriptions, nextBills, movedId) { setData(prev => ({ ...prev, subscriptions: nextSubscriptions })); setBills(nextBills); setMovingBillId(movedId); try { await api.reorderBills(reorderPayload(nextBills)); toast.success('Subscription order saved'); await load(); api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(err => console.error('[SubscriptionsPage] failed to refresh bills after reorder', err)); } catch (err) { toast.error(err.message || 'Failed to save subscription order'); await load(); } finally { setMovingBillId(null); } } function reorderSubscriptionGroup(activeState, orderedGroup) { const sourceGroup = subscriptions.filter(item => !!item.active === activeState); const replacements = [...orderedGroup]; const nextSubscriptions = subscriptions.map(item => ( !!item.active === activeState ? replacements.shift() : item )); const sourceBills = bills.length ? bills : subscriptions; const affectedIds = new Set(sourceGroup.map(item => item.id)); const billById = new Map(sourceBills.map(item => [item.id, item])); const orderedBills = orderedGroup.map(item => ({ ...(billById.get(item.id) || item), ...item })); const nextBills = sourceBills.map(bill => ( affectedIds.has(bill.id) ? orderedBills.shift() : bill )); persistSubscriptionOrder(nextSubscriptions, nextBills, movedItemId(sourceGroup, orderedGroup)); } function moveControlsForGroup(group, activeState) { return (item, index) => ({ enabled: reorderEnabled, moving: movingBillId === item.id, canMoveUp: index > 0, canMoveDown: index < group.length - 1, onMoveUp: () => reorderSubscriptionGroup(activeState, moveInArray(group, index, index - 1)), onMoveDown: () => reorderSubscriptionGroup(activeState, moveInArray(group, index, index + 1)), }); } function dragPropsForGroup(group, activeState) { return (item, index) => { if (!reorderEnabled) return { draggable: false }; return { draggable: true, isDragging: draggingId === item.id, isDropTarget: dropTargetId === item.id && draggingId !== item.id, onDragStart: (event) => { setDraggingId(item.id); event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', String(item.id)); }, onDragEnter: () => { if (draggingId && draggingId !== item.id) setDropTargetId(item.id); }, onDragOver: (event) => { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; if (draggingId && draggingId !== item.id) setDropTargetId(item.id); }, onDrop: (event) => { event.preventDefault(); const sourceId = Number(event.dataTransfer.getData('text/plain') || draggingId); const fromIndex = group.findIndex(row => row.id === sourceId); if (fromIndex >= 0) reorderSubscriptionGroup(activeState, moveInArray(group, fromIndex, index)); setDraggingId(null); setDropTargetId(null); }, onDragEnd: () => { setDraggingId(null); setDropTargetId(null); }, }; }; } const MIN_CONFIDENCE = 90; const highConfidenceRecs = useMemo( () => recommendations.filter(r => r.catalog_match && (r.confidence ?? 0) >= MIN_CONFIDENCE), [recommendations], ); const filteredRecs = useMemo(() => { const q = recSearch.trim().toLowerCase(); return q ? highConfidenceRecs.filter(r => r.name?.toLowerCase().includes(q)) : highConfidenceRecs; }, [highConfidenceRecs, recSearch]); function renderSubscriptionRows(group, activeState) { if (subscriptionSort !== 'cadence') { return group.map((item, index) => ( setModal({ bill })} onToggle={toggleSubscription} busy={busyId === `toggle-${item.id}`} moveControls={moveControlsForGroup(group, activeState)(item, index)} dragProps={dragPropsForGroup(group, activeState)(item, index)} /> )); } return CADENCE_ORDER.flatMap(cadence => { const cadenceItems = group.filter(item => normalizedCadence(item) === cadence); if (cadenceItems.length === 0) return []; return [

{CADENCE_LABELS[cadence]}

{cadenceItems.length}
, ...cadenceItems.map((item, index) => ( setModal({ bill })} onToggle={toggleSubscription} busy={busyId === `toggle-${item.id}`} moveControls={moveControlsForGroup(cadenceItems, activeState)(item, index)} dragProps={dragPropsForGroup(cadenceItems, activeState)(item, index)} /> )), ]; }); } return (

Recurring Services

Subscriptions

Track manual subscriptions and review recurring SimpleFIN charges.

Tracked Subscriptions Subscriptions are bills with recurring-service metadata.
setSubscriptionSort('custom')} > Custom setSubscriptionSort('cadence')} > Cadence
{loading ? (
{Array.from({ length: 4 }).map((_, index) => (
))}
) : subscriptions.length === 0 ? (

No subscriptions tracked yet.

Add one manually or accept a SimpleFIN recommendation.

) : ( <> {renderSubscriptionRows(sortedActive, true)} {renderSubscriptionRows(sortedPaused, false)} )}
Recommendations {!recommendationsLoading && highConfidenceRecs.length > 0 && ( {filteredRecs.length} of {highConfidenceRecs.length} )}
Known subscription services found in your bank transactions with 90%+ confidence. {!recommendationsLoading && highConfidenceRecs.length > 0 && (
setRecSearch(e.target.value)} className="pl-8 h-8 text-sm" />
)}
{recommendationsLoading ? (
Scanning transactions…
) : highConfidenceRecs.length === 0 ? (

No high-confidence recommendations.

{recommendations.length > 0 ? 'More account activity or stronger descriptors will improve accuracy.' : 'Sync your accounts after charges from known subscription services appear.'}

) : filteredRecs.length === 0 ? (

No matches for "{recSearch}"

) : ( filteredRecs.map(recommendation => ( setMatchTarget(rec)} onQuickLink={linkRecommendationToBill} onDetails={setDetailsTarget} /> )) )}
{/* Transaction search */}
Search Bank Transactions
Search all account charges — matched and unmatched — to find subscriptions the algorithm may have missed.
setTxQuery(e.target.value)} className="pl-8 h-9 text-sm" autoComplete="off" />
{(txQuery.trim() || txSearching) && ( {txSearching ? (
Searching transactions…
) : txResults.length === 0 ? (

No transactions found for "{txQuery}"

Try a different merchant name or description.

) : (

{txResults.length} result{txResults.length !== 1 ? 's' : ''} {txResults.length === 50 ? ' (showing first 50)' : ''}

{txResults.map(tx => ( ))}
)}
)}
Improve Matching
Manage known services, catalog links, and custom bank descriptors on a dedicated page.

Use the service catalog when a recommendation names the wrong service, a bill needs a catalog link, or your bank uses a custom descriptor.

{modal && ( setModal(null)} onSave={handleBillModalSave} /> )} setDetailsTarget(null)} onAccept={acceptRecommendation} onDecline={declineRecommendation} onMatch={rec => setMatchTarget(rec)} onQuickLink={linkRecommendationToBill} busy={detailsTarget ? ( busyId === `rec-${detailsTarget.id}` || busyId === `dec-${detailsTarget.id}` || busyId === `match-${detailsTarget.id}` ) : false} /> setMatchTarget(null)} recommendation={matchTarget} bills={bills} onConfirm={matchRecommendationToBill} busy={!!busyId?.startsWith('match-')} /> setImportDialog(null)} onImported={() => { setImportDialog(null); refreshAll(); }} />
); }