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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user