'use client'; import { Button } from '@/components/ui/button'; import { apiFetch } from '@/lib/api'; import { EmbeddedCheckout, EmbeddedCheckoutProvider } from '@stripe/react-stripe-js'; import { loadStripe } from '@stripe/stripe-js'; import { Loader2, X } from 'lucide-react'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; import { Suspense, useCallback, useEffect, useState } from 'react'; // Load Stripe.js once at module scope (Stripe's recommendation). Null when the // publishable key isn't baked into the build — the modal then shows a clear // "not configured" message instead of throwing. const STRIPE_PK = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY; const stripePromise = STRIPE_PK ? loadStripe(STRIPE_PK) : null; type Plan = 'hobby' | 'pro' | 'team' | 'enterprise'; type Tier = 'pro_monthly' | 'pro_yearly' | 'team_monthly' | 'team_yearly'; interface SubscriptionInfo { id: string; status: string; currentPeriodEnd: number; cancelAtPeriodEnd: boolean; priceId: string | null; amount: number | null; currency: string | null; interval: string | null; } interface Invoice { id: string; number: string | null; status: string | null; amountPaid: number; currency: string; created: number; pdfUrl: string | null; hostedUrl: string | null; } interface BillingStatus { plan: Plan; hasCustomer: boolean; hasSubscription: boolean; suspended: boolean; suspendedReason: string | null; subscription?: SubscriptionInfo; invoices?: Invoice[]; _stripeError?: boolean; } const PLAN_LABEL: Record = { hobby: 'Hobby (free)', pro: 'Pro', team: 'Team', enterprise: 'Enterprise', }; function formatMoney(amount: number | null, currency: string | null): string { if (amount === null || currency === null) return '—'; return new Intl.NumberFormat(undefined, { style: 'currency', currency: currency.toUpperCase(), }).format(amount / 100); } function BillingInner() { const router = useRouter(); const searchParams = useSearchParams(); const justSubscribed = searchParams.get('success') === 'true'; const cancelledCheckout = searchParams.get('cancelled') === 'true'; const autoUpgradeTier = searchParams.get('tier') as Tier | null; const [status, setStatus] = useState(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(null); // When set, the in-app embedded Stripe checkout modal is open. const [clientSecret, setClientSecret] = useState(null); const loadStatus = useCallback(() => { apiFetch('/v1/billing/status') .then(setStatus) .catch((e) => { const err = e as { status?: number }; if (err.status === 401) router.push('/login?returnTo=/settings/billing'); else setError((e as Error).message); }); }, [router]); useEffect(() => { loadStatus(); }, [loadStatus]); useEffect(() => { if (!justSubscribed) return; let tries = 0; const id = setInterval(() => { tries += 1; loadStatus(); if (tries >= 6) clearInterval(id); }, 1500); return () => clearInterval(id); }, [justSubscribed, loadStatus]); const startCheckout = useCallback(async (tier: Tier) => { setBusy(tier); setError(null); try { const res = await apiFetch<{ clientSecret: string }>('/v1/billing/checkout-session', { method: 'POST', body: JSON.stringify({ tier }), }); // Open the embedded checkout in-app instead of redirecting to Stripe. setClientSecret(res.clientSecret); } catch (e) { const detail = (e as { detail?: { detail?: string; error?: string } }).detail; setError(detail?.detail ?? detail?.error ?? (e as Error).message); } finally { setBusy(null); } }, []); useEffect(() => { if (!autoUpgradeTier || !status) return; if (status.hasSubscription) return; void startCheckout(autoUpgradeTier); }, [autoUpgradeTier, status, startCheckout]); async function changePlan(tier: Tier) { if (!confirm(`Switch to ${tier.replace('_', ' ')}? Prorated charges apply immediately.`)) return; setBusy(`change-${tier}`); setError(null); try { await apiFetch('/v1/billing/change-plan', { method: 'POST', body: JSON.stringify({ tier }), }); // Webhook will update plan asynchronously; poll briefly. let tries = 0; const id = setInterval(() => { tries += 1; loadStatus(); if (tries >= 6) clearInterval(id); }, 1500); } catch (e) { const detail = (e as { detail?: { detail?: string; error?: string } }).detail; setError(detail?.detail ?? detail?.error ?? (e as Error).message); } finally { setBusy(null); } } async function cancelSubscription() { if (!confirm('Cancel subscription at the end of the current billing period?')) return; setBusy('cancel'); setError(null); try { await apiFetch('/v1/billing/cancel', { method: 'POST', body: '{}' }); loadStatus(); } catch (e) { setError((e as Error).message); } finally { setBusy(null); } } async function reactivateSubscription() { setBusy('reactivate'); setError(null); try { await apiFetch('/v1/billing/reactivate', { method: 'POST', body: '{}' }); loadStatus(); } catch (e) { setError((e as Error).message); } finally { setBusy(null); } } if (!status && !error) { return (
); } const sub = status?.subscription; const hasSub = Boolean(status?.hasSubscription && sub); const planValue = status?.plan ?? 'hobby'; const currentPlanIsPro = planValue === 'pro'; const currentPlanIsTeam = planValue === 'team'; return (
{clientSecret && ( { setClientSecret(null); setBusy(null); }} /> )}

Billing

Plan, renewal, invoices and cancellation — everything in-app.

{cancelledCheckout && ( Checkout cancelled. No charge made. )} {justSubscribed && ( Subscription active. Plan updates within a few seconds — refreshing… )} {status?.suspended && ( Subscription paused — {status.suspendedReason ?? 'payment issue'}. New servers + previews are blocked until payment succeeds; existing servers keep running. )} {status?._stripeError && ( Live billing data temporarily unavailable — showing local state only. Try again in a minute. )} {error && {error}} {status && (
Current plan
{PLAN_LABEL[status.plan]} {sub?.amount !== null && sub?.amount !== undefined && ( {formatMoney(sub.amount, sub.currency)} / {sub.interval} )}
{sub && (
{sub.cancelAtPeriodEnd ? 'Cancels' : 'Renews'}
{new Date(sub.currentPeriodEnd * 1000).toLocaleDateString()}
)}
{hasSub && sub && (
{sub.cancelAtPeriodEnd ? ( <>

Scheduled to cancel on{' '} {new Date(sub.currentPeriodEnd * 1000).toLocaleDateString()} . You keep paid features until then.

) : ( )}
)}
)} {status && !status.hasSubscription && ( <>

Choose a plan

Annual saves 2 months. VAT calculated automatically based on your billing address. Cancel anytime from this page — service continues until end of period.

)} {status && status.hasSubscription && ( <>

Switch plan

Prorated immediately. The new amount is added to your next invoice.

{!currentPlanIsPro && ( changePlan('pro_monthly')} busy={busy === 'change-pro_monthly'} /> )} changePlan('pro_yearly')} busy={busy === 'change-pro_yearly'} /> {!currentPlanIsTeam && ( changePlan('team_monthly')} busy={busy === 'change-team_monthly'} /> )} changePlan('team_yearly')} busy={busy === 'change-team_yearly'} />
)} {status?.invoices && status.invoices.length > 0 && ( <>

Invoices

{status.invoices.map((inv) => (
{inv.number ?? inv.id}
{new Date(inv.created * 1000).toLocaleDateString()} · {inv.status ?? 'unknown'}
{formatMoney(inv.amountPaid, inv.currency)} {inv.pdfUrl && ( PDF )} {inv.hostedUrl && ( View → )}
))}
)}

Payment-method updates and other rare actions:{' '} {' '} ·{' '} compare plans

); } function CheckoutModal({ clientSecret, onClose, }: { clientSecret: string; onClose: () => void; }) { return (
{stripePromise ? ( ) : (
Payments aren’t configured (missing Stripe publishable key). Please contact support.
)}
); } function Alert({ tone, children, }: { tone: 'muted' | 'success' | 'warn' | 'error'; children: React.ReactNode; }) { const cls = tone === 'success' ? 'border-emerald-500/30 bg-emerald-500/10' : tone === 'warn' ? 'border-amber-500/40 bg-amber-500/10' : tone === 'error' ? 'border-[--color-danger]/40 bg-[--color-danger]/10' : 'border-[--color-border] bg-[--color-bg-subtle]'; return (
{children}
); } function TierCard({ name, monthly, yearly, features, busy, onSubscribe, monthlyTier, yearlyTier, highlight, }: { name: string; monthly: number; yearly: number; features: string[]; busy: string | null; onSubscribe: (tier: Tier) => void; monthlyTier: Tier; yearlyTier: Tier; highlight?: boolean; }) { return (
{name}
€{monthly} / month
    {features.map((f) => (
  • — {f}
  • ))}
); } function PlanSwitch({ label, onClick, busy, }: { label: string; onClick: () => void; busy: boolean; }) { return ( ); } export default function BillingPage() { return ( } > ); }