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).
146 lines
3.9 KiB
TypeScript
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: {} })
|
|
}
|