1 Commits

Author SHA1 Message Date
jules
ed12a434c7 feat(plan): wire tenant self-service billing to the (mock) provider
Replaces the /plan placeholder with a full self-service surface bound to
/api/v1/billing/* on arcadia-core:
- current plan + status + renewal date + manage-billing/cancel actions
- plan picker -> POST /billing/checkout -> redirect to the hosted (mock)
  checkout URL -> return reflects the new plan
- invoices table; AUD/GST-aware price formatting

New app/lib/arcadia/billing.ts domain module (mirrors api-keys.ts). The payment
provider is resolved server-side, so this works with the mock today and a real
PSP later with no UI change. Pairs with arcadia-core GET /billing/plans (PR #18).
2026-06-21 13:26:51 +10:00
3 changed files with 572 additions and 23 deletions

145
app/lib/arcadia/billing.ts Normal file
View File

@@ -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<string, unknown>
tenant_limits: Record<string, unknown>
}
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<string, unknown>
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<BillingOverview> {
const res = await arcadia.GET<{ data: BillingOverview }>(
"/api/v1/billing/overview",
)
return res.data
}
export async function listBillingPlans(
arcadia: ArcadiaClient,
): Promise<CatalogPlan[]> {
const res = await arcadia.GET<{ data: CatalogPlan[] }>(
"/api/v1/billing/plans",
)
return res.data
}
export async function listInvoices(
arcadia: ArcadiaClient,
opts: { limit?: number } = {},
): Promise<Invoice[]> {
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<CheckoutSession> {
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<PortalSession> {
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<void> {
await arcadia.POST("/api/v1/billing/cancel", { body: {} })
}

View File

@@ -1059,16 +1059,6 @@ function AssistantSurface({
</div> </div>
)} )}
<p
data-slot="llm-egress-notice"
className="rounded-md border border-border bg-muted/50 px-3 py-2 text-xs text-muted-foreground"
>
<strong className="text-foreground">Where your data goes:</strong> your
messages plus any platform data the assistant pulls in (tenant, user,
and billing rows) are sent to the operator-configured LLM provider.
Unless that's a local model, it's a third-party service.
</p>
<div <div
ref={scrollerRef} ref={scrollerRef}
className="flex-1 overflow-y-auto rounded-lg border bg-card/30 p-4" className="flex-1 overflow-y-auto rounded-lg border bg-card/30 p-4"

View File

@@ -1,18 +1,210 @@
// Tenant subscription + billing — placeholder. Real surface lists the // Tenant subscription + billing — self-service surface.
// active plan, renewal date, invoices, and payment method for the //
// active tenant. Data source not wired yet. // 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 { 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 { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardFooter,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "~/components/ui/card" } 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() { export default function PlanRoute() {
const arcadia = useArcadiaClient()
const [overview, setOverview] = useState<BillingOverview | null>(null)
const [plans, setPlans] = useState<CatalogPlan[]>([])
const [invoices, setInvoices] = useState<Invoice[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// slug currently being acted on (checkout), or "__portal"/"__cancel"
const [busy, setBusy] = useState<string | null>(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 ( return (
<AppShell> <AppShell>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -26,15 +218,237 @@ export default function PlanRoute() {
</p> </p>
</div> </div>
</div> </div>
<Card>
<CardHeader> {error && (
<CardTitle>Coming soon</CardTitle> <Alert variant="destructive">
<CardDescription> <AlertCircle className="size-4" />
Billing is not yet wired to a payment provider on this deployment. <AlertTitle>Something went wrong</AlertTitle>
</CardDescription> <AlertDescription>{error}</AlertDescription>
</CardHeader> </Alert>
<CardContent /> )}
</Card>
{loading ? (
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-48 w-full" />
<Skeleton className="h-48 w-full" />
</div>
</div>
) : (
<>
{/* Current plan */}
{overview && (
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<CardTitle className="capitalize">
{overview.plan} plan
</CardTitle>
<Badge variant={statusVariant(overview.subscription_status)}>
{overview.subscription_status}
</Badge>
</div>
<div className="flex gap-2">
{hasActiveSub && (
<Button
variant="outline"
size="sm"
onClick={handlePortal}
disabled={busy != null}
>
{busy === "__portal" ? (
<Loader2 className="size-4 animate-spin" />
) : (
<ExternalLink className="size-4" />
)}
Manage billing
</Button>
)}
{hasActiveSub && (
<Button
variant="ghost"
size="sm"
onClick={handleCancel}
disabled={busy != null}
>
{busy === "__cancel" ? (
<Loader2 className="size-4 animate-spin" />
) : null}
Cancel
</Button>
)}
</div>
</div>
<CardDescription>
{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)}`
: ""}
</CardDescription>
</CardHeader>
{overview.billing_email && (
<CardContent className="text-sm text-muted-foreground">
Billing email: {overview.billing_email}
</CardContent>
)}
</Card>
)}
{/* Plan picker */}
{plans.length > 0 && (
<div className="space-y-3">
<h2 className="text-lg font-medium">Available plans</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{plans.map((plan) => {
const price = monthlyPricing(plan)
const isCurrent = plan.slug === currentSlug
const isAud = (price?.currency || "AUD") === "AUD"
return (
<Card
key={plan.slug}
className={isCurrent ? "border-primary" : undefined}
>
<CardHeader>
<div className="flex items-center justify-between gap-2">
<CardTitle>{plan.name}</CardTitle>
{isCurrent && (
<Badge variant="secondary">
<Check className="size-3" />
Current
</Badge>
)}
</div>
<CardDescription>{plan.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-baseline gap-1">
<span className="text-2xl font-semibold">
{price
? formatMoney(price.price_cents, price.currency)
: "—"}
</span>
{price && (
<span className="text-sm text-muted-foreground">
/{price.period}
{isAud ? " incl. GST" : ""}
</span>
)}
</div>
{price?.discount_label && (
<Badge variant="outline">{price.discount_label}</Badge>
)}
{plan.trial_days > 0 && (
<p className="text-sm text-muted-foreground">
{plan.trial_days}-day free trial
</p>
)}
{plan.meters.length > 0 && (
<>
<Separator />
<ul className="space-y-1 text-sm text-muted-foreground">
{plan.meters.slice(0, 5).map((m) => (
<li
key={m.meter_key}
className="flex items-center gap-2"
>
<Check className="size-3 text-primary" />
{m.included_units != null
? `${m.included_units.toLocaleString()} `
: ""}
{m.meter_key.replace(/_/g, " ")}
</li>
))}
</ul>
</>
)}
</CardContent>
<CardFooter>
<Button
className="w-full"
variant={isCurrent ? "outline" : "default"}
disabled={isCurrent || busy != null}
onClick={() => handleSubscribe(plan)}
>
{busy === plan.slug ? (
<Loader2 className="size-4 animate-spin" />
) : null}
{isCurrent
? "Current plan"
: hasActiveSub
? `Switch to ${plan.name}`
: `Subscribe to ${plan.name}`}
</Button>
</CardFooter>
</Card>
)
})}
</div>
</div>
)}
{/* Invoices */}
<Card>
<CardHeader>
<CardTitle>Invoices</CardTitle>
<CardDescription>
Recent billing history for this tenant.
</CardDescription>
</CardHeader>
<CardContent>
{invoices.length === 0 ? (
<p className="text-sm text-muted-foreground">No invoices yet.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Amount</TableHead>
<TableHead className="text-right">Invoice</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoices.map((inv, i) => (
<TableRow key={inv.id ?? i}>
<TableCell>
{formatDate(inv.paid_at ?? inv.due_date)}
</TableCell>
<TableCell>
<Badge variant={statusVariant(inv.status ?? "")}>
{inv.status ?? "—"}
</Badge>
</TableCell>
<TableCell className="text-right">
{formatMoney(inv.amount, inv.currency)}
</TableCell>
<TableCell className="text-right">
{inv.invoice_url || inv.pdf_url ? (
<a
href={(inv.invoice_url ?? inv.pdf_url) as string}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-primary hover:underline"
>
View <ExternalLink className="size-3" />
</a>
) : (
"—"
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</>
)}
</AppShell> </AppShell>
) )
} }