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
+7
View File
@@ -19,6 +19,13 @@ ENV NEXT_PUBLIC_ADMIN_API_URL=$NEXT_PUBLIC_ADMIN_API_URL
ARG NEXT_PUBLIC_ADMIN_ENV=production
ENV NEXT_PUBLIC_ADMIN_ENV=$NEXT_PUBLIC_ADMIN_ENV
# Browser checkout. The client token and environment are baked in, never
# fetched, so a production build cannot load a sandbox token by accident.
ARG NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=
ENV NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=$NEXT_PUBLIC_PADDLE_CLIENT_TOKEN
ARG NEXT_PUBLIC_PADDLE_ENV=sandbox
ENV NEXT_PUBLIC_PADDLE_ENV=$NEXT_PUBLIC_PADDLE_ENV
RUN npm run build
FROM node:26-alpine AS runner
@@ -0,0 +1,131 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQuery } from "@tanstack/react-query";
import { ApiError, api, lineItemsFor } from "@/lib/api";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
import { CheckoutButton } from "@/components/CheckoutButton";
import PlanConfigurator, { type PlanChoice } from "@/components/PlanConfigurator";
/*
* Three visible steps: name (creates a placeholder), configure + pay, then link
* the real install UUID. The placeholder exists before payment so the webhook
* has something to attach custom_data to; the licence is only issued once the
* install's real UUID is known.
*/
export function PurchaseForm() {
const router = useRouter();
const account = useQuery({ queryKey: ["account"], queryFn: api.account });
const options = useQuery({ queryKey: ["checkout-options"], queryFn: api.checkoutOptions });
const [name, setName] = useState("");
const [placeholderId, setPlaceholderId] = useState<string | null>(null);
const [uuid, setUuid] = useState("");
const [error, setError] = useState<string | null>(null);
const [choice, setChoice] = useState<PlanChoice>({
tier: "professional",
term: "annual", // self-hosted is annual only
servers: 3,
features: [],
});
const create = useMutation({
mutationFn: () => api.createSelfHosted(name.trim()),
onSuccess: (r) => setPlaceholderId(r.instance_id),
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not start. Try again."),
});
const claim = useMutation({
mutationFn: () => api.claimLink(placeholderId!, uuid.trim()),
onSuccess: () => router.push("/"),
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not link. Try again."),
});
const accountId = account.data?.account.account_id ?? "";
const items =
options.data && placeholderId
? lineItemsFor(options.data, choice, "self_hosted")
: [];
// Step 1 — name.
if (!placeholderId) {
return (
<form
className="grid max-w-md gap-4"
onSubmit={(e) => {
e.preventDefault();
setError(null);
if (name.trim()) create.mutate();
}}
>
<Field
label="Instance name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Northgate Systems"
required
error={error ?? undefined}
/>
<Button type="submit" disabled={create.isPending || !name.trim()}>
{create.isPending ? "Starting…" : "Continue"}
</Button>
</form>
);
}
// Step 2 + 3 — configure & pay, then link.
return (
<div className="grid max-w-2xl gap-6">
<section className="rounded-lg border border-rule bg-panel p-4">
<h2 className="mb-3 text-[0.95rem] font-medium text-ink">Configure</h2>
{options.data ? (
<PlanConfigurator
deployment="self_hosted"
value={choice}
plans={options.data.plans}
catalogue={options.data.catalogue}
onChange={setChoice}
/>
) : (
<p className="text-[0.85rem] text-ink-3">Loading plans</p>
)}
<div className="mt-4">
<CheckoutButton
items={items}
customData={{ account_id: accountId, instance_id: placeholderId }}
disabled={!accountId || items.length === 0}
/>
</div>
</section>
<section className="rounded-lg border border-rule bg-panel p-4">
<h2 className="mb-1 text-[0.95rem] font-medium text-ink">Link your install</h2>
<p className="mb-3 text-[0.82rem] text-ink-3">
After payment, paste the instance ID your Vantage install reports (Settings
Licence). Your licence is issued the moment it is linked.
</p>
<div className="grid gap-3">
<Field
label="Instance ID"
value={uuid}
onChange={(e) => setUuid(e.target.value)}
placeholder="00000000-0000-0000-0000-000000000000"
error={error ?? undefined}
/>
<Button
type="button"
disabled={claim.isPending || !uuid.trim()}
onClick={() => {
setError(null);
claim.mutate();
}}
>
{claim.isPending ? "Linking…" : "Link and issue licence"}
</Button>
</div>
</section>
</div>
);
}
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { PurchaseForm } from "./PurchaseForm";
import { PageHeader } from "@/components/PageHeader";
export const metadata: Metadata = { title: "Buy a self-hosted plan" };
export default function PurchasePage() {
return (
<div className="grid gap-6">
<PageHeader
back={{ href: "/", label: "Overview" }}
title="Self-hosted plan"
subtitle="Name your instance, choose a plan, and pay. After payment, paste the ID your install reports to receive its licence. Self-hosted is billed annually."
/>
<PurchaseForm />
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
import { useState } from "react";
import { initPaddle } from "@/lib/paddle";
/* Opens the Paddle overlay with the resolved line items and custom_data. The
* items come from the configurator via catalogue pricing; custom_data is what
* lets the webhook route without a lookup table. */
export function CheckoutButton({
items,
customData,
disabled,
label = "Continue to payment",
}: {
items: { priceId: string; quantity: number }[];
customData: { account_id: string; instance_id: string };
disabled?: boolean;
label?: string;
}) {
const [busy, setBusy] = useState(false);
async function open() {
setBusy(true);
const paddle = await initPaddle();
setBusy(false);
paddle?.Checkout.open({
items: items.map((i) => ({ priceId: i.priceId, quantity: i.quantity })),
customData,
});
}
return (
<button
type="button"
disabled={disabled || busy || items.length === 0}
onClick={open}
className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40"
>
{busy ? "Opening…" : label}
</button>
);
}
+3
View File
@@ -5,6 +5,7 @@ import clsx from "clsx";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { api, type Instance, type License } from "@/lib/api";
import { ManageBillingButton } from "@/components/ManageBillingButton";
import { daysRemaining, formatDate, licenceState, limitLabel } from "@/lib/format";
import { StatePill } from "./StatePill";
import { Button, LinkButton } from "./Button";
@@ -252,6 +253,8 @@ export function InstanceRecord({
{renew.isPending ? "Renewing…" : "Renew"}
</Button>
)}
{cloud && <ManageBillingButton />}
</div>
</div>
</article>
@@ -0,0 +1,35 @@
"use client";
import { useState } from "react";
import { ApiError, api } from "@/lib/api";
import { Button } from "@/components/Button";
/* Opens Paddle's hosted customer portal in a new tab. The account learns its
* paddle_customer_id from its first paid subscription's webhook, so this reports
* a plain message rather than erroring when there is no billing account yet. */
export function ManageBillingButton() {
const [busy, setBusy] = useState(false);
const [note, setNote] = useState<string | null>(null);
async function open() {
setBusy(true);
setNote(null);
try {
const { url } = await api.billingPortal();
window.open(url, "_blank", "noopener");
} catch (e) {
setNote(e instanceof ApiError ? e.message : "Could not open billing.");
} finally {
setBusy(false);
}
}
return (
<span className="inline-flex items-center gap-2">
<Button type="button" variant="line" onClick={open} disabled={busy}>
{busy ? "Opening…" : "Manage billing"}
</Button>
{note && <span className="text-[0.78rem] text-ink-3">{note}</span>}
</span>
);
}
+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 }),
+17
View File
@@ -0,0 +1,17 @@
import { initializePaddle, type Paddle } from "@paddle/paddle-js";
let cached: Promise<Paddle | undefined> | null = null;
/* One Paddle instance for the app. The token and environment are baked into the
* build (NEXT_PUBLIC_*), never fetched, so a production build can never load a
* sandbox token by accident. */
export function initPaddle(): Promise<Paddle | undefined> {
if (!cached) {
cached = initializePaddle({
environment:
(process.env.NEXT_PUBLIC_PADDLE_ENV as "sandbox" | "production") ?? "sandbox",
token: process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN ?? "",
});
}
return cached;
}
+7
View File
@@ -8,6 +8,7 @@
"name": "vantage-adminsite",
"version": "0.1.0",
"dependencies": {
"@paddle/paddle-js": "^1.6.4",
"@tanstack/react-query": "^5.51.1",
"clsx": "^2.1.1",
"next": "16.2.9",
@@ -1309,6 +1310,12 @@
"node": ">=12.4.0"
}
},
"node_modules/@paddle/paddle-js": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/@paddle/paddle-js/-/paddle-js-1.6.4.tgz",
"integrity": "sha512-ncfnS6I8mCX6krZ3Sgz2iAYivGmhdI81yt9mT6prtPj4Ipd9J3M12LCJRUFL4FB7BYeeuV04c33RSEnbZUBCaA==",
"license": "Apache-2.0"
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
+4 -3
View File
@@ -9,11 +9,12 @@
"lint": "next lint"
},
"dependencies": {
"@paddle/paddle-js": "^1.6.4",
"@tanstack/react-query": "^5.51.1",
"clsx": "^2.1.1",
"next": "16.2.9",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"@tanstack/react-query": "^5.51.1",
"clsx": "^2.1.1"
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^20.14.11",