Files
arcadia-admin/app/lib/arcadia/billing.ts
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

146 lines
3.9 KiB
TypeScript

// 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: {} })
}