import { useCallback, useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; import { Bell, CalendarDays, CheckCircle2, Cloud, Loader2, Pause, Plus, RefreshCw, Repeat, Sparkles, } from 'lucide-react'; import { api } from '@/api'; import { cn, fmt, fmtDate } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import BillModal from '@/components/BillModal'; const TYPE_LABELS = { streaming: 'Streaming', software: 'Software', cloud: 'Cloud', music: 'Music', news: 'News', fitness: 'Fitness', gaming: 'Gaming', utilities: 'Utilities', insurance: 'Insurance', other: 'Other', }; function cycleLabel(item) { const cycle = item.cycle_type || item.billing_cycle || 'monthly'; return cycle === 'annual' || cycle === 'annually' ? 'yearly' : cycle; } function StatCard({ icon: Icon, label, value, hint }) { return (
{label}

{value}

{hint &&

{hint}

}
); } function SubscriptionRow({ item, onEdit, onToggle }) { 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 RecommendationCard({ recommendation, categoryId, onAccept, busy }) { return (

{recommendation.name}

{recommendation.confidence}% match

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

{recommendation.reasons?.map(reason => ( {reason} ))}

{fmt(recommendation.expected_amount)}

{fmt(recommendation.monthly_equivalent)} / mo

); } export default function SubscriptionsPage() { const [data, setData] = useState({ summary: {}, subscriptions: [] }); const [recommendations, setRecommendations] = useState([]); const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); const [recommendationsLoading, setRecommendationsLoading] = useState(true); const [busyId, setBusyId] = useState(null); const [modal, setModal] = useState(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(); }, [load, loadRecommendations]); 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 { await api.createSubscriptionFromRecommendation(recommendation); toast.success(`${recommendation.name} is now tracked.`); await refreshAll(); } catch (err) { toast.error(err.message || 'Could not create subscription.'); } finally { setBusyId(null); } } 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, }, }); } const summary = data.summary || {}; const subscriptions = data.subscriptions || []; const active = subscriptions.filter(item => item.active); const paused = subscriptions.filter(item => !item.active); return (

Recurring Services

Subscriptions

Track manual subscriptions and review recurring SimpleFIN charges.

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

No subscriptions tracked yet.

Add one manually or accept a SimpleFIN recommendation.

) : ( <> {active.map(item => ( setModal({ bill })} onToggle={toggleSubscription} /> ))} {paused.map(item => ( setModal({ bill })} onToggle={toggleSubscription} /> ))} )}
SimpleFIN Recommendations
Recurring unmatched bank charges that look like subscriptions.
{recommendationsLoading ? (
Scanning transactions...
) : recommendations.length === 0 ? (

No recommendations right now.

Sync SimpleFIN after a few recurring charges appear.

) : ( recommendations.map(recommendation => ( )) )}
{modal && ( setModal(null)} onSave={async () => { setModal(null); await refreshAll(); }} /> )}
); }