diff --git a/app/lib/arcadia/billing.ts b/app/lib/arcadia/billing.ts new file mode 100644 index 0000000..879e866 --- /dev/null +++ b/app/lib/arcadia/billing.ts @@ -0,0 +1,145 @@ +// Tenant self-service billing helpers — wraps /api/v1/billing/* on arcadia-core. +// +// The provider is resolved server-side (mock in dev, a real PSP in prod), so +// these helpers are provider-agnostic: `createCheckoutSession` returns a hosted +// URL to redirect the browser to, whatever the backend provider is. +// Shapes mirror `ArcadiaWeb.API.BillingJSON`. + +import type { ArcadiaClient } from "@crema/arcadia-client" + +// ============================================================================ +// Wire types +// ============================================================================ + +export interface BillingOverview { + billing_model: string | null + plan: string + subscription_status: string + current_period_start: string | null + current_period_end: string | null + trial_ends_at: string | null + billing_email: string | null + plan_limits: Record + tenant_limits: Record +} + +export interface PlanPricing { + period: string + price_cents: number + currency: string + discount_label: string | null +} + +export interface PlanMeter { + meter_key: string + included_units: number | null + overage_price_cents: number | null +} + +export interface CatalogPlan { + slug: string + name: string + description: string | null + billing_track: string + trial_days: number + metadata: Record + pricing: PlanPricing[] + meters: PlanMeter[] +} + +export interface Invoice { + id: string | null + amount: number | null + currency: string | null + status: string | null + due_date: string | null + paid_at: string | null + invoice_url: string | null + pdf_url: string | null +} + +export interface CheckoutSession { + url: string +} + +export interface PortalSession { + url: string +} + +// ============================================================================ +// Reads +// ============================================================================ + +export async function getBillingOverview( + arcadia: ArcadiaClient, +): Promise { + const res = await arcadia.GET<{ data: BillingOverview }>( + "/api/v1/billing/overview", + ) + return res.data +} + +export async function listBillingPlans( + arcadia: ArcadiaClient, +): Promise { + const res = await arcadia.GET<{ data: CatalogPlan[] }>( + "/api/v1/billing/plans", + ) + return res.data +} + +export async function listInvoices( + arcadia: ArcadiaClient, + opts: { limit?: number } = {}, +): Promise { + const qs = opts.limit ? `?limit=${opts.limit}` : "" + const res = await arcadia.GET<{ data: Invoice[] }>( + `/api/v1/billing/invoices${qs}`, + ) + return res.data +} + +// ============================================================================ +// Actions +// ============================================================================ + +/** + * Create a hosted checkout session for `plan` and return the URL to redirect + * the browser to. `successUrl`/`cancelUrl` are where the provider returns the + * user (default: back to this page). + */ +export async function createCheckoutSession( + arcadia: ArcadiaClient, + input: { plan: string; successUrl?: string; cancelUrl?: string }, +): Promise { + const res = await arcadia.POST<{ data: CheckoutSession }>( + "/api/v1/billing/checkout", + { + body: { + plan: input.plan, + success_url: input.successUrl, + cancel_url: input.cancelUrl, + }, + }, + ) + return res.data +} + +/** Create a billing-portal session for self-service payment management. */ +export async function createPortalSession( + arcadia: ArcadiaClient, + input: { returnUrl?: string } = {}, +): Promise { + const res = await arcadia.POST<{ data: PortalSession }>( + "/api/v1/billing/portal", + { body: { return_url: input.returnUrl } }, + ) + return res.data +} + +/** Cancel the active subscription (at period end). */ +export async function cancelSubscription( + arcadia: ArcadiaClient, +): Promise { + await arcadia.POST("/api/v1/billing/cancel", { body: {} }) +} diff --git a/app/routes/plan.tsx b/app/routes/plan.tsx index d31bcfc..3ef51c0 100644 --- a/app/routes/plan.tsx +++ b/app/routes/plan.tsx @@ -1,18 +1,210 @@ -// Tenant subscription + billing — placeholder. Real surface lists the -// active plan, renewal date, invoices, and payment method for the -// active tenant. Data source not wired yet. +// 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 { CreditCard } from "lucide-react" +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 (
@@ -26,15 +218,237 @@ export default function PlanRoute() {

- - - Coming soon - - Billing is not yet wired to a payment provider on this deployment. - - - - + + {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 + + ) : ( + "—" + )} + + + ))} + +
+ )} +
+
+ + )}
) }