diff --git a/adminsite/Dockerfile b/adminsite/Dockerfile index a11705a..1b38eee 100644 --- a/adminsite/Dockerfile +++ b/adminsite/Dockerfile @@ -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 diff --git a/adminsite/app/(customer)/purchase/PurchaseForm.tsx b/adminsite/app/(customer)/purchase/PurchaseForm.tsx new file mode 100644 index 0000000..d731d77 --- /dev/null +++ b/adminsite/app/(customer)/purchase/PurchaseForm.tsx @@ -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(null); + const [uuid, setUuid] = useState(""); + const [error, setError] = useState(null); + const [choice, setChoice] = useState({ + 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 ( +
{ + e.preventDefault(); + setError(null); + if (name.trim()) create.mutate(); + }} + > + setName(e.target.value)} + placeholder="Northgate Systems" + required + error={error ?? undefined} + /> + + + ); + } + + // Step 2 + 3 — configure & pay, then link. + return ( +
+
+

Configure

+ {options.data ? ( + + ) : ( +

Loading plans…

+ )} +
+ +
+
+ +
+

Link your install

+

+ After payment, paste the instance ID your Vantage install reports (Settings → + Licence). Your licence is issued the moment it is linked. +

+
+ setUuid(e.target.value)} + placeholder="00000000-0000-0000-0000-000000000000" + error={error ?? undefined} + /> + +
+
+
+ ); +} diff --git a/adminsite/app/(customer)/purchase/page.tsx b/adminsite/app/(customer)/purchase/page.tsx new file mode 100644 index 0000000..bba6635 --- /dev/null +++ b/adminsite/app/(customer)/purchase/page.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/adminsite/components/CheckoutButton.tsx b/adminsite/components/CheckoutButton.tsx new file mode 100644 index 0000000..b0645fb --- /dev/null +++ b/adminsite/components/CheckoutButton.tsx @@ -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 ( + + ); +} diff --git a/adminsite/components/InstanceRecord.tsx b/adminsite/components/InstanceRecord.tsx index 30366a9..5115c9f 100644 --- a/adminsite/components/InstanceRecord.tsx +++ b/adminsite/components/InstanceRecord.tsx @@ -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"} )} + + {cloud && } diff --git a/adminsite/components/ManageBillingButton.tsx b/adminsite/components/ManageBillingButton.tsx new file mode 100644 index 0000000..601b926 --- /dev/null +++ b/adminsite/components/ManageBillingButton.tsx @@ -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(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 ( + + + {note && {note}} + + ); +} diff --git a/adminsite/lib/api.ts b/adminsite/lib/api.ts index ea0f878..d649314 100644 --- a/adminsite/lib/api.ts +++ b/adminsite/lib/api.ts @@ -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("/api/subscriptions"), + entitlement: (id: string) => + req<{ entitlement: Entitlement; pending: boolean }>(`/api/instances/${id}/entitlement`), + checkoutOptions: () => req("/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("/api/account/users"), invite: (email: string, role: AccountRole) => post<{ invited: boolean }>("/api/account/users", { email, role }), diff --git a/adminsite/lib/paddle.ts b/adminsite/lib/paddle.ts new file mode 100644 index 0000000..246f5d8 --- /dev/null +++ b/adminsite/lib/paddle.ts @@ -0,0 +1,17 @@ +import { initializePaddle, type Paddle } from "@paddle/paddle-js"; + +let cached: Promise | 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 { + 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; +} diff --git a/adminsite/package-lock.json b/adminsite/package-lock.json index c36db0b..6a082c7 100644 --- a/adminsite/package-lock.json +++ b/adminsite/package-lock.json @@ -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", diff --git a/adminsite/package.json b/adminsite/package.json index a980161..2fa322b 100644 --- a/adminsite/package.json +++ b/adminsite/package.json @@ -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",