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).
This commit is contained in:
145
app/lib/arcadia/billing.ts
Normal file
145
app/lib/arcadia/billing.ts
Normal 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: {} })
|
||||
}
|
||||
Reference in New Issue
Block a user