feat(adminsite): self-hosted purchase, checkout overlay, billing portal, client line-item builder

Purchase flow: name -> placeholder -> configure via the shipped PlanConfigurator
-> Paddle overlay with custom_data -> paste install UUID to link and issue.
lineItemsFor mirrors the Go catalogue.LineItems/billable exactly (base included
in exactly one place). ManageBillingButton opens the hosted portal. Paddle token
and env are baked into the build, never fetched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 10:53:21 +01:00
co-authored by Claude Opus 5
parent c10f093cad
commit 8bbecd2035
10 changed files with 321 additions and 3 deletions
+59
View File
@@ -188,6 +188,51 @@ export interface EntitlementConfig {
features: string[];
}
export interface CheckoutOptions {
plans: Plan[];
catalogue: CatalogueRow[];
env: "sandbox" | "production";
}
/*
* lineItemsFor builds the Paddle checkout items for a configuration, client-side
* from the catalogue already fetched. It mirrors the Go catalogue.LineItems and
* its billable() exactly: base is quantity 1; the per-server unit's quantity is
* servers MINUS the plan's base allowance (never charge for the base — the one
* subtraction, kept here to match the server); a feature contributes an item
* only when its row has a price in this environment/term.
*/
export function lineItemsFor(
opts: CheckoutOptions,
choice: { tier: Tier; term: Term; servers: number; features: string[] },
deployment: Deployment,
): { priceId: string; quantity: number }[] {
const env = opts.env;
const plan = opts.plans.find((p) => p.deployment === deployment && p.tier === choice.tier);
if (!plan) return [];
const rows = opts.catalogue.filter(
(r) => r.deployment === deployment && r.tier === choice.tier,
);
const priceOf = (r: CatalogueRow) => r.price_ids?.[env]?.[choice.term] ?? "";
const base = plan.base_limits.max_servers;
const items: { priceId: string; quantity: number }[] = [];
for (const r of rows) {
const id = priceOf(r);
if (r.kind === "base") {
if (id) items.push({ priceId: id, quantity: 1 });
} else if (r.kind === "limit" && r.limit_key === "max_servers") {
// -1 base is unlimited: nothing metered. Otherwise charge servers over base.
const qty = base === -1 ? 0 : choice.servers - base;
if (qty > 0 && id) items.push({ priceId: id, quantity: qty });
} else if (r.kind === "feature" && r.feature_key) {
if (choice.features.includes(r.feature_key) && id) {
items.push({ priceId: id, quantity: 1 });
}
}
}
return items;
}
export interface Entitlement {
instance_id: string;
account_id: string;
@@ -269,6 +314,20 @@ export const api = {
licenseBlobUrl: (id: string) => `${API_BASE}/api/instances/${id}/license/download`,
subscriptions: () => req<Subscription[]>("/api/subscriptions"),
entitlement: (id: string) =>
req<{ entitlement: Entitlement; pending: boolean }>(`/api/instances/${id}/entitlement`),
checkoutOptions: () => req<CheckoutOptions>("/api/checkout/options"),
createSelfHosted: (name: string) =>
post<{ instance_id: string }>("/api/instances/self-hosted", { name }),
updateEntitlement: (
id: string,
body: { tier: Tier; term: Term; servers: number; features: string[] },
) => put<{ entitlement: Entitlement; pending: boolean }>(`/api/instances/${id}/entitlement`, body),
claimLink: (placeholderId: string, instance_id: string) =>
post<{ instance_id: string; warning?: string }>(
`/api/instances/${placeholderId}/claim-link`, { instance_id }),
billingPortal: () => post<{ url: string }>("/api/billing/portal"),
accountUsers: () => req<AccountUser[]>("/api/account/users"),
invite: (email: string, role: AccountRole) =>
post<{ invited: boolean }>("/api/account/users", { email, role }),