// Tenant subscription + billing — self-service surface. // // Wired to /api/v1/billing/* on arcadia-core. The payment provider is resolved // server-side (mock in dev), so "Subscribe" creates a hosted checkout session // and redirects the browser to the returned URL; on return the overview // reflects the new plan. import { useCallback, useEffect, useState } from "react" import { AlertCircle, Check, CreditCard, ExternalLink, Loader2, } from "lucide-react" import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client" import { AppShell } from "~/components/layout/app-shell" import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert" import { Badge } from "~/components/ui/badge" import { Button } from "~/components/ui/button" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "~/components/ui/card" import { Separator } from "~/components/ui/separator" import { Skeleton } from "~/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "~/components/ui/table" import { type BillingOverview, type CatalogPlan, type Invoice, type PlanPricing, cancelSubscription, createCheckoutSession, createPortalSession, getBillingOverview, listBillingPlans, listInvoices, } from "~/lib/arcadia/billing" // --------------------------------------------------------------------------- // Formatting helpers // --------------------------------------------------------------------------- function monthlyPricing(plan: CatalogPlan): PlanPricing | null { if (!plan.pricing.length) return null return plan.pricing.find((p) => p.period === "monthly") ?? plan.pricing[0] } function formatMoney(cents: number | null, currency: string | null): string { if (cents == null) return "—" const cur = currency || "AUD" try { return new Intl.NumberFormat(undefined, { style: "currency", currency: cur, }).format(cents / 100) } catch { return `${(cents / 100).toFixed(2)} ${cur}` } } function formatDate(iso: string | null): string { if (!iso) return "—" const d = new Date(iso) return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric", }) } function statusVariant( status: string, ): "default" | "secondary" | "destructive" | "outline" { switch (status) { case "active": return "default" case "trialing": return "secondary" case "past_due": case "unpaid": case "canceled": case "cancelled": return "destructive" default: return "outline" } } // --------------------------------------------------------------------------- // Route // --------------------------------------------------------------------------- export default function PlanRoute() { const arcadia = useArcadiaClient() const [overview, setOverview] = useState(null) const [plans, setPlans] = useState([]) const [invoices, setInvoices] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) // slug currently being acted on (checkout), or "__portal"/"__cancel" const [busy, setBusy] = useState(null) const load = useCallback(async () => { setError(null) try { const [ov, pls, invs] = await Promise.all([ getBillingOverview(arcadia), listBillingPlans(arcadia).catch(() => [] as CatalogPlan[]), listInvoices(arcadia, { limit: 12 }).catch(() => [] as Invoice[]), ]) setOverview(ov) setPlans(pls) setInvoices(invs) } catch (e) { setError( e instanceof ArcadiaError || e instanceof Error ? e.message : "Failed to load billing details.", ) } finally { setLoading(false) } }, [arcadia]) useEffect(() => { void load() }, [load]) const returnUrl = typeof window !== "undefined" ? window.location.href : undefined async function handleSubscribe(plan: CatalogPlan) { setBusy(plan.slug) setError(null) try { const { url } = await createCheckoutSession(arcadia, { plan: plan.slug, successUrl: returnUrl, cancelUrl: returnUrl, }) window.location.href = url } catch (e) { setError(e instanceof Error ? e.message : "Could not start checkout.") setBusy(null) } } async function handlePortal() { setBusy("__portal") setError(null) try { const { url } = await createPortalSession(arcadia, { returnUrl }) window.location.href = url } catch (e) { setError( e instanceof Error ? e.message : "Could not open the billing portal.", ) setBusy(null) } } async function handleCancel() { if ( typeof window !== "undefined" && !window.confirm( "Cancel your subscription at the end of the current period?", ) ) { return } setBusy("__cancel") setError(null) try { await cancelSubscription(arcadia) await load() } catch (e) { setError( e instanceof Error ? e.message : "Could not cancel the subscription.", ) } finally { setBusy(null) } } const currentSlug = overview?.plan const hasActiveSub = overview != null && overview.plan !== "free" && ["active", "trialing", "past_due"].includes(overview.subscription_status) return (

Plan

Your tenant's subscription, billing details, and invoice history.

{error && ( Something went wrong {error} )} {loading ? (
) : ( <> {/* Current plan */} {overview && (
{overview.plan} plan {overview.subscription_status}
{hasActiveSub && ( )} {hasActiveSub && ( )}
{overview.current_period_end ? `Renews ${formatDate(overview.current_period_end)}` : "No active billing period."} {overview.trial_ends_at ? ` · Trial ends ${formatDate(overview.trial_ends_at)}` : ""}
{overview.billing_email && ( Billing email: {overview.billing_email} )}
)} {/* Plan picker */} {plans.length > 0 && (

Available plans

{plans.map((plan) => { const price = monthlyPricing(plan) const isCurrent = plan.slug === currentSlug const isAud = (price?.currency || "AUD") === "AUD" return (
{plan.name} {isCurrent && ( Current )}
{plan.description}
{price ? formatMoney(price.price_cents, price.currency) : "—"} {price && ( /{price.period} {isAud ? " incl. GST" : ""} )}
{price?.discount_label && ( {price.discount_label} )} {plan.trial_days > 0 && (

{plan.trial_days}-day free trial

)} {plan.meters.length > 0 && ( <>
    {plan.meters.slice(0, 5).map((m) => (
  • {m.included_units != null ? `${m.included_units.toLocaleString()} ` : ""} {m.meter_key.replace(/_/g, " ")}
  • ))}
)}
) })}
)} {/* Invoices */} Invoices Recent billing history for this tenant. {invoices.length === 0 ? (

No invoices yet.

) : ( Date Status Amount Invoice {invoices.map((inv, i) => ( {formatDate(inv.paid_at ?? inv.due_date)} {inv.status ?? "—"} {formatMoney(inv.amount, inv.currency)} {inv.invoice_url || inv.pdf_url ? ( View ) : ( "—" )} ))}
)}
)}
) }