import { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
import {
Bell,
CalendarDays,
CheckCircle2,
Cloud,
Link2,
Loader2,
Pause,
Plus,
RefreshCw,
Repeat,
Sparkles,
X,
} 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 { Input } from '@/components/ui/input';
import {
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
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',
food: 'Food',
education: 'Education',
shopping: 'Shopping',
security: 'Security',
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 (
onEdit(item)}
className="min-w-0 max-w-full truncate text-left text-[15px] font-semibold text-foreground hover:text-primary"
>
{item.name}
{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)}
onEdit(item)}>
Edit
onToggle(item)}
>
{item.active ? 'Pause' : 'Resume'}
);
}
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 => (
setSelectedId(bill.id)}
className={cn(
'w-full flex items-center justify-between px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted/40',
selectedId === bill.id && 'bg-primary/10 text-primary',
)}
>
{bill.name}
${(bill.expected_amount ?? 0).toFixed(2)}/mo
))}
Cancel
onConfirm(selectedId)} disabled={!selectedId || busy}>
{busy ? <> Linking…> : 'Link to bill'}
);
}
function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, onMatch, busy }) {
return (
{recommendation.name}
{TYPE_LABELS[recommendation.subscription_type] || 'Other'} · {recommendation.occurrence_count} charges · last seen {fmtDate(recommendation.last_seen_date)}
{fmt(recommendation.expected_amount)}
{fmt(recommendation.monthly_equivalent)} / mo
{recommendation.confidence}% match
{recommendation.reasons?.map(reason => (
{reason}
))}
onDecline(recommendation)}
>
{busy ? : }
Decline
onMatch(recommendation)}
>
Link to bill
onAccept({ ...recommendation, category_id: categoryId })}
>
{busy ? : }
Track
);
}
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); // recommendation being linked
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.bills().then(b => setBills(Array.isArray(b) ? b : [])).catch(() => {});
}, [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);
}
}
async function declineRecommendation(recommendation) {
if (!recommendation.decline_key) return;
setBusyId(`dec-${recommendation.id}`);
try {
await api.declineRecommendation(recommendation.decline_key);
setRecommendations(prev => prev.filter(r => r.id !== recommendation.id));
} catch (err) {
toast.error(err.message || 'Could not dismiss recommendation.');
} finally {
setBusyId(null);
}
}
async function matchRecommendationToBill(billId) {
if (!matchTarget || !billId) return;
setBusyId(`match-${matchTarget.id}`);
try {
const result = await api.matchRecommendationToBill(matchTarget.transaction_ids, billId, matchTarget.merchant);
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 !== matchTarget.id));
} catch (err) {
toast.error(err.message || 'Could not link recommendation to bill.');
} 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} />
))}
>
)}
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 => (
setMatchTarget(rec)}
/>
))
)}
{modal && (
setModal(null)}
onSave={async () => {
setModal(null);
await refreshAll();
}}
/>
)}
setMatchTarget(null)}
recommendation={matchTarget}
bills={bills}
onConfirm={matchRecommendationToBill}
busy={!!busyId?.startsWith('match-')}
/>
);
}