From edda1162364810b9de46af63fc67bcd8b0fdab40 Mon Sep 17 00:00:00 2001
From: rcourtman <8825017+rcourtman@users.noreply.github.com>
Date: Wed, 23 Sep 2026 23:45:28 +0100
Subject: [PATCH] Show a provider-hosted platform its plan and let it buy one
The portal had no billing surface in provider-hosted mode, so a provider
on the free evaluation could not see when it expired, how many client
slots it had left, or any way to pay. Provider-hosted mode now shows the
billing section as Plan. It lists the evaluation or paid plan, its
expiry and the workspaces in use, and on an evaluation it offers the
plans above the current cap at the licence server's Stripe prices.
Owners and admins get Buy, Manage billing, and "Apply my purchase now";
read-only members are told who can buy. After Stripe returns to the
portal the panel keeps refreshing the licence until the purchase lands,
then polls until the restarted control plane serves the new plan,
without reloading the page.
Exercised on a fresh v6.4.1 provider bundle running this build against
a stand-in licence server: the tab, the evaluation expiry and slot
usage, monthly and annual pricing, Buy reaching checkout with the
platform's licence ID and portal return URL, the periodic refresh
signed with the lab's lease key, and a 390px layout with no horizontal
overflow.
---
.../v6/internal/subsystems/cloud-paid.md | 13 +-
.../cloudcp/portal/dist/build_manifest.json | 4 +-
internal/cloudcp/portal/dist/portal_app.js | 346 ++++++++++++++-
internal/cloudcp/portal/frontend/src/api.ts | 25 +-
internal/cloudcp/portal/frontend/src/app.ts | 17 +
.../portal/frontend/src/provider_plan.test.ts | 222 ++++++++++
.../portal/frontend/src/provider_plan.ts | 404 ++++++++++++++++++
.../portal/frontend/src/shell_view.test.ts | 32 ++
.../cloudcp/portal/frontend/src/shell_view.ts | 19 +-
9 files changed, 1075 insertions(+), 7 deletions(-)
create mode 100644 internal/cloudcp/portal/frontend/src/provider_plan.test.ts
create mode 100644 internal/cloudcp/portal/frontend/src/provider_plan.ts
diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md
index 654d41bf63..1b33162994 100644
--- a/docs/release-control/v6/internal/subsystems/cloud-paid.md
+++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md
@@ -3450,7 +3450,18 @@ platform's key, and installs it at `ProviderMSPRenewedLicensePath`.
evaluation on the host expires. A changed licence restarts the control plane
through the graceful shutdown path, because the plan version is read once at
load. `msp_solo` (3 client workspaces) is the first paid step above the
-2-workspace evaluation. Regression coverage:
+2-workspace evaluation.
+
+In the portal, provider-hosted mode shows the `billing` section as **Plan**
+(`internal/cloudcp/portal/frontend/src/provider_plan.ts`), never Pulse-hosted
+or self-hosted billing. The section shows the evaluation or paid plan, its
+expiry, how many client workspaces are in use, and, on an evaluation, the plans
+above the current cap with their monthly or annual price. Owners and admins
+get Buy, Manage billing and "Apply my purchase now"; read-only members are told
+who can buy. After Stripe returns to `?provider_msp_checkout=complete` the
+panel keeps refreshing the licence until the purchase lands. It then polls the
+plan until the restarted control plane serves the new plan, and it never
+reloads the page. Regression coverage:
`TestLoadConfig_ProviderHostedMSPPrefersRenewedLicense` in
`internal/cloudcp/config_test.go`,
`TestProviderMSPLicenseRefreshInstallsThePaidLicenceAndRestarts` and
diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json
index 8d9f43870c..5fc0a117d8 100644
--- a/internal/cloudcp/portal/dist/build_manifest.json
+++ b/internal/cloudcp/portal/dist/build_manifest.json
@@ -1,5 +1,5 @@
{
- "source_hash": "e65ea72fdcf30b9ab70022df92b69bea3969df7cb0377587fcb50688d1ba66bb",
+ "source_hash": "8b4817955ab28d58e1356be2dd1e96a4c66619d2b802ff49aa56f4f013427a80",
"build_inputs": [
"package.json",
"tsconfig.json",
@@ -27,6 +27,8 @@
"src/billing_view.test.ts",
"src/billing_view.ts",
"src/index.ts",
+ "src/provider_plan.test.ts",
+ "src/provider_plan.ts",
"src/runtime.test.ts",
"src/runtime.ts",
"src/shell.test.ts",
diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js
index ea2696aab0..aa610ad2c0 100644
--- a/internal/cloudcp/portal/dist/portal_app.js
+++ b/internal/cloudcp/portal/dist/portal_app.js
@@ -1143,6 +1143,28 @@
return request(accountURL(accountID, "/members/" + encodeURIComponent(userID)), {
method: "DELETE"
}, "Failed to remove member.");
+ },
+ fetchPlan: function(accountID) {
+ return request(accountURL(accountID, "/provider-msp/plan"), {
+ headers: { Accept: "application/json" }
+ }, "Your plan could not be loaded.");
+ },
+ startCheckout: function(accountID, planVersion, billingCycle) {
+ return request(accountURL(accountID, "/provider-msp/checkout"), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ plan_version: planVersion, billing_cycle: billingCycle })
+ }, "Checkout is unavailable right now.");
+ },
+ openBillingPortal: function(accountID) {
+ return request(accountURL(accountID, "/provider-msp/billing-portal"), {
+ method: "POST"
+ }, "Billing is unavailable right now.");
+ },
+ refreshLicense: function(accountID) {
+ return request(accountURL(accountID, "/provider-msp/license/refresh"), {
+ method: "POST"
+ }, "The licence could not be refreshed right now.");
}
};
}
@@ -2671,6 +2693,308 @@
});
}
+ // src/provider_plan.ts
+ var PROVIDER_PLAN_ROOT_ID = "provider-plan-root";
+ var PLAN_NAMES = {
+ msp_eval: "Free evaluation",
+ msp_solo: "Solo",
+ msp_starter: "Starter",
+ msp_growth: "Growth",
+ msp_scale: "Scale"
+ };
+ function providerPlanName(planVersion) {
+ return PLAN_NAMES[planVersion] || planVersion;
+ }
+ function formatProviderPlanPrice(option) {
+ var amount = Math.round(Number(option.unit_amount || 0)) / 100;
+ var whole = amount % 1 === 0;
+ var currency = String(option.currency || "usd").toUpperCase();
+ var symbol = currency === "USD" ? "$" : currency + " ";
+ var formatted = symbol + (whole ? amount.toFixed(0) : amount.toFixed(2)).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
+ return formatted + (option.billing_cycle === "annual" ? "/yr" : "/mo");
+ }
+ function formatDate(value) {
+ if (!value) return "";
+ var date = new Date(value);
+ if (isNaN(date.getTime())) return "";
+ return date.toLocaleDateString(void 0, { year: "numeric", month: "short", day: "numeric" });
+ }
+ function clientWorkspaces(count) {
+ return count === 1 ? "1 client workspace" : String(count) + " client workspaces";
+ }
+ function renderCurrentPlan(plan, canManage, busy, inUse) {
+ var expires = formatDate(plan.expires_at);
+ var title = providerPlanName(plan.plan_version);
+ var description;
+ var meta = [inUse >= 0 ? String(inUse) + " of " + clientWorkspaces(plan.workspace_limit) + " in use" : clientWorkspaces(plan.workspace_limit)];
+ var cta = "";
+ if (plan.evaluation) {
+ description = expires ? "Your evaluation covers " + clientWorkspaces(plan.workspace_limit) + " until " + expires + ". Buy a plan to keep your clients monitored after that and to add more." : "Your evaluation covers " + clientWorkspaces(plan.workspace_limit) + ". Buy a plan to add more.";
+ if (expires) meta.push("Expires " + expires);
+ } else {
+ description = "Up to " + clientWorkspaces(plan.workspace_limit) + ". Renews automatically while your subscription is active.";
+ if (expires) meta.push("Licence valid to " + expires);
+ if (canManage) {
+ cta = '" + (busy === "manage-billing" ? "Opening\u2026" : "Manage billing") + " ";
+ }
+ }
+ return '' + escapeText(title) + " " + escapeText(description) + '
' + escapeText(meta.join(" \u2022 ")) + "
" + (cta ? '' + cta + "
" : "") + " ";
+ }
+ function renderCycleToggle(cycle, hasAnnual) {
+ if (!hasAnnual) return "";
+ function button(value, label) {
+ var active = value === cycle;
+ return '' + label + " ";
+ }
+ return '
' + button("monthly", "Monthly") + " " + button("annual", "Annual, 2 months free") + "
";
+ }
+ function renderOffer(option, canManage, busy) {
+ var name = providerPlanName(option.plan_version);
+ var action = canManage ? '" + (busy === "buy:" + option.plan_version ? "Opening checkout\u2026" : "Buy " + escapeText(name)) + " " : "";
+ return '' + escapeText(name) + " Up to " + escapeText(clientWorkspaces(option.workspace_limit)) + '.
' + escapeText(formatProviderPlanPrice(option)) + "
" + (action ? '' + action + "
" : "") + " ";
+ }
+ function renderProviderPlanHTML(view, canManage, inUse = -1) {
+ var parts = ['
Plan '];
+ if (view.notice) {
+ parts.push('' + escapeText(view.notice) + "
");
+ }
+ if (view.loading && !view.plan) {
+ parts.push('Loading your plan\u2026
');
+ return parts.join("");
+ }
+ if (view.error && !view.plan) {
+ parts.push('' + escapeText(view.error) + "
");
+ return parts.join("");
+ }
+ var plan = view.plan;
+ if (!plan) return parts.join("");
+ parts.push(renderCurrentPlan(plan, canManage, view.busy, inUse));
+ if (plan.evaluation) {
+ if (plan.plans_error) {
+ parts.push('' + escapeText(plan.plans_error) + "
");
+ } else if (plan.purchase_available) {
+ var hasAnnual = plan.plans.some(function(option) {
+ return option.billing_cycle === "annual";
+ });
+ var cycle = hasAnnual ? view.cycle : "monthly";
+ var offers = plan.plans.filter(function(option) {
+ return option.billing_cycle === cycle && option.workspace_limit > plan.workspace_limit;
+ });
+ parts.push('Choose a plan You pay per client workspace, never per monitored system. Every client workspace is full Pulse.
');
+ parts.push(renderCycleToggle(cycle, hasAnnual));
+ parts.push(offers.map(function(option) {
+ return renderOffer(option, canManage, view.busy);
+ }).join(""));
+ if (!canManage) {
+ parts.push('Only an owner or admin of this account can buy a plan.
');
+ }
+ }
+ } else if (canManage) {
+ parts.push('Change plan, update your payment method or cancel renewal from Manage billing.
');
+ }
+ if (canManage && plan.license_id) {
+ parts.push(
+ 'Just paid? " + (view.busy === "refresh" ? "Checking\u2026" : "Apply my purchase now") + "
"
+ );
+ }
+ return parts.join("");
+ }
+ function providerAccount(accounts) {
+ var list = Array.isArray(accounts) ? accounts : [];
+ for (var i = 0; i < list.length; i += 1) {
+ if (list[i] && list[i].can_manage) return list[i];
+ }
+ return list[0] || null;
+ }
+ var CHECKOUT_RETURN_PARAM = "provider_msp_checkout";
+ var APPLY_POLL_MS = 5e3;
+ var APPLY_POLL_ATTEMPTS = 12;
+ var RESTART_SETTLE_MS = 8e3;
+ var RESTART_POLL_MS = 3e3;
+ var RESTART_POLL_ATTEMPTS = 20;
+ function installProviderPlan(deps) {
+ var navigate = deps.navigate || function(url) {
+ window.location.assign(url);
+ };
+ var later = deps.setTimeoutFn || function(fn, ms) {
+ return setTimeout(fn, ms);
+ };
+ var view = { loading: false, error: "", plan: null, cycle: "monthly", busy: "", notice: "" };
+ function account() {
+ var bootstrap = deps.store.getBootstrap();
+ if (bootstrap.provider_hosted_mode !== true || !bootstrap.authenticated) return null;
+ return providerAccount(bootstrap.accounts);
+ }
+ function render() {
+ var root = document.getElementById(PROVIDER_PLAN_ROOT_ID);
+ var current = account();
+ if (!root || !current) return;
+ var workspaces = Array.isArray(current.workspaces) ? current.workspaces : [];
+ root.innerHTML = renderProviderPlanHTML(view, current.can_manage === true, workspaces.length);
+ }
+ async function load() {
+ var current = account();
+ if (!current) return;
+ view.loading = true;
+ render();
+ try {
+ view.plan = await deps.api.fetchPlan(current.id);
+ view.error = "";
+ } catch (error) {
+ view.error = error instanceof Error && error.message ? error.message : "Your plan could not be loaded.";
+ } finally {
+ view.loading = false;
+ render();
+ }
+ }
+ function waitForAppliedPlan(previousPlan, attempt) {
+ var current = account();
+ if (!current) return;
+ deps.api.fetchPlan(current.id).then(function(plan) {
+ if (plan.plan_version !== previousPlan) {
+ view.plan = plan;
+ view.error = "";
+ view.notice = "Your " + providerPlanName(plan.plan_version) + " plan is active.";
+ render();
+ return;
+ }
+ throw new Error("plan not applied yet");
+ }).catch(function() {
+ if (attempt + 1 >= RESTART_POLL_ATTEMPTS) {
+ view.notice = "Your plan should be active now. Open this page again if it still shows the old plan.";
+ render();
+ return;
+ }
+ later(function() {
+ waitForAppliedPlan(previousPlan, attempt + 1);
+ }, RESTART_POLL_MS);
+ });
+ }
+ function applyRestart() {
+ var previousPlan = view.plan ? view.plan.plan_version : "";
+ view.notice = "Your plan is being applied. This takes a few seconds.";
+ render();
+ later(function() {
+ waitForAppliedPlan(previousPlan, 0);
+ }, RESTART_SETTLE_MS);
+ }
+ async function refresh(manual) {
+ var current = account();
+ if (!current) return false;
+ view.busy = "refresh";
+ render();
+ try {
+ var result = await deps.api.refreshLicense(current.id);
+ if (result.restart_scheduled) {
+ applyRestart();
+ return true;
+ }
+ if (manual) {
+ deps.showToast(result.status === "active" ? "Your plan is already up to date." : "No payment has reached this platform yet. It can take a minute after checkout.");
+ }
+ return false;
+ } catch (error) {
+ if (manual) deps.showToast(error instanceof Error ? error.message : "Refresh failed.", true);
+ return false;
+ } finally {
+ view.busy = "";
+ render();
+ }
+ }
+ function pollAfterCheckout(attempt) {
+ void refresh(false).then(function(applied) {
+ if (applied) return;
+ if (attempt + 1 >= APPLY_POLL_ATTEMPTS) {
+ view.notice = "Payment received. If your plan has not changed in a few minutes, use Apply my purchase now.";
+ render();
+ return;
+ }
+ later(function() {
+ pollAfterCheckout(attempt + 1);
+ }, APPLY_POLL_MS);
+ });
+ }
+ document.addEventListener("click", function(event) {
+ var target = event.target instanceof HTMLElement ? event.target.closest("[data-provider-plan-action]") : null;
+ if (!target) return;
+ var current = account();
+ if (!current) return;
+ event.preventDefault();
+ var action = target.getAttribute("data-provider-plan-action") || "";
+ switch (action) {
+ case "select-cycle":
+ view.cycle = target.getAttribute("data-provider-plan-cycle") === "annual" ? "annual" : "monthly";
+ render();
+ return;
+ case "buy": {
+ var planVersion = target.getAttribute("data-provider-plan-version") || "";
+ var cycle = target.getAttribute("data-provider-plan-cycle") || "monthly";
+ view.busy = "buy:" + planVersion;
+ render();
+ deps.api.startCheckout(current.id, planVersion, cycle).then(function(response) {
+ if (response && response.url) {
+ navigate(response.url);
+ return;
+ }
+ throw new Error("Checkout is unavailable right now.");
+ }).catch(function(error) {
+ view.busy = "";
+ render();
+ deps.showToast(error instanceof Error ? error.message : "Checkout is unavailable right now.", true);
+ });
+ return;
+ }
+ case "manage-billing":
+ view.busy = "manage-billing";
+ render();
+ deps.api.openBillingPortal(current.id).then(function(response) {
+ if (response && response.url) {
+ navigate(response.url);
+ return;
+ }
+ throw new Error("Billing is unavailable right now.");
+ }).catch(function(error) {
+ view.busy = "";
+ render();
+ deps.showToast(error instanceof Error ? error.message : "Billing is unavailable right now.", true);
+ });
+ return;
+ case "refresh":
+ void refresh(true);
+ return;
+ }
+ });
+ deps.store.subscribeBootstrap(function() {
+ render();
+ });
+ var search = deps.locationSearch ? deps.locationSearch() : window.location.search;
+ var params = new URLSearchParams(search || "");
+ var returned = params.get(CHECKOUT_RETURN_PARAM);
+ if (returned && account()) {
+ params.delete(CHECKOUT_RETURN_PARAM);
+ var remaining = params.toString();
+ if (deps.replaceSearch) {
+ deps.replaceSearch(remaining ? "?" + remaining : "");
+ } else if (window.history && typeof window.history.replaceState === "function") {
+ window.history.replaceState(null, "", window.location.pathname + (remaining ? "?" + remaining : "") + window.location.hash);
+ }
+ deps.store.setActiveShellSection("billing");
+ if (returned === "complete") {
+ view.notice = "Payment received. Applying your plan\u2026";
+ pollAfterCheckout(0);
+ } else {
+ view.notice = "Checkout was cancelled. Nothing was charged.";
+ }
+ }
+ return {
+ load,
+ render,
+ view: function() {
+ return view;
+ }
+ };
+ }
+
// src/shell_view.ts
function escapeHTML(value) {
return String(value || "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'");
@@ -2897,11 +3221,17 @@
sections.push({ section: "workspaces", title: accountsUseClientLanguage(accounts) ? "Clients" : "Workspaces" });
sections.push({ section: "access", title: "Access" });
}
- if (hasHostedBilling || showSelfHostedCommercial) {
+ if (providerHostedPlanShown(bootstrap)) {
+ sections.push({ section: "billing", title: "Plan" });
+ } else if (hasHostedBilling || showSelfHostedCommercial) {
sections.push({ section: "billing", title: "Billing" });
}
return sections;
}
+ function providerHostedPlanShown(bootstrap) {
+ var accounts = Array.isArray(bootstrap.accounts) ? bootstrap.accounts : [];
+ return bootstrap.provider_hosted_mode === true && accounts.length > 0;
+ }
function utilityShellSections(_bootstrap) {
return [{ section: "support", title: "Support" }];
}
@@ -3405,7 +3735,7 @@
'Payment-card data stays with Stripe. For Stripe deletion support, contact
' + escapeHTML(context.bootstrap.support_email || "") + " .
"
);
}
- return '' + renderIdentityBar(accounts, showSelfHostedCommercial) + renderTabBar(context.bootstrap, activeSection) + (hosted ? '
' + workspaceSummaryContent + workspacesContent + ' " : "") + (showBillingPanel ? '
' + (hosted && hasHostedBilling ? renderHostedBillingCards(accounts, showSelfHostedCommercial) : "") + (showSelfHostedBillingShell ? 'Self-hosted billing ' + escapeHTML(selfHostedBillingLeadCopy) + '
' + selfHostedBillingActionsHTML + '
Support ' + selfHostedBillingEscalationCopy + '
' + selfHostedBillingPanelsHTML + "
" : "") + " " : "") + '
' + renderSupportSection(context) + " ";
+ return '' + renderIdentityBar(accounts, showSelfHostedCommercial) + renderTabBar(context.bootstrap, activeSection) + (hosted ? '
' + workspaceSummaryContent + workspacesContent + ' " : "") + (providerHostedPlanShown(context.bootstrap) ? '
' : "") + (showBillingPanel && !providerHostedPlanShown(context.bootstrap) ? '
' + (hosted && hasHostedBilling ? renderHostedBillingCards(accounts, showSelfHostedCommercial) : "") + (showSelfHostedBillingShell ? 'Self-hosted billing ' + escapeHTML(selfHostedBillingLeadCopy) + '
' + selfHostedBillingActionsHTML + '
Support ' + selfHostedBillingEscalationCopy + '
' + selfHostedBillingPanelsHTML + "
" : "") + " " : "") + '
' + renderSupportSection(context) + " ";
}
function renderAuthScopeRow(title, copy) {
return '' + escapeHTML(title) + " " + escapeHTML(copy) + "
";
@@ -3822,9 +4152,13 @@
refreshBootstrap,
showToast
});
+ var providerPlan = null;
installShell({
store: deps.store,
onSectionChange: function(section) {
+ if (section === "billing" && providerPlan) {
+ void providerPlan.load();
+ }
if (section === "access") {
var accounts = deps.store.getBootstrap().accounts || [];
for (var i = 0; i < accounts.length; i += 1) {
@@ -3837,6 +4171,14 @@
api,
store: deps.store
});
+ providerPlan = installProviderPlan({
+ api,
+ store: deps.store,
+ showToast
+ });
+ if (deps.store.getBootstrap().provider_hosted_mode === true && deps.store.getBootstrap().authenticated) {
+ void providerPlan.load();
+ }
installAuthController({
api,
store: deps.store
diff --git a/internal/cloudcp/portal/frontend/src/api.ts b/internal/cloudcp/portal/frontend/src/api.ts
index bf4ab2cc10..b786c75729 100644
--- a/internal/cloudcp/portal/frontend/src/api.ts
+++ b/internal/cloudcp/portal/frontend/src/api.ts
@@ -1,4 +1,5 @@
import type { PortalBootstrapData, PortalAccessMember } from './types';
+import type { ProviderPlanAPI, ProviderPlanState } from './provider_plan';
interface PortalAPIContext {
getBootstrap(): PortalBootstrapData;
@@ -49,7 +50,7 @@ export interface PortalMemberRoleRequest {
role: string;
}
-export interface PortalAPI {
+export interface PortalAPI extends ProviderPlanAPI {
fetchBootstrap(): Promise;
requestMagicLink(email: string): Promise;
logout(): Promise;
@@ -223,5 +224,27 @@ export function createPortalAPI(context: PortalAPIContext): PortalAPI {
method: 'DELETE',
}, 'Failed to remove member.');
},
+ fetchPlan: function(accountID: string) {
+ return request(accountURL(accountID, '/provider-msp/plan'), {
+ headers: { Accept: 'application/json' },
+ }, 'Your plan could not be loaded.');
+ },
+ startCheckout: function(accountID: string, planVersion: string, billingCycle: string) {
+ return request<{ url?: string }>(accountURL(accountID, '/provider-msp/checkout'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ plan_version: planVersion, billing_cycle: billingCycle }),
+ }, 'Checkout is unavailable right now.');
+ },
+ openBillingPortal: function(accountID: string) {
+ return request<{ url?: string }>(accountURL(accountID, '/provider-msp/billing-portal'), {
+ method: 'POST',
+ }, 'Billing is unavailable right now.');
+ },
+ refreshLicense: function(accountID: string) {
+ return request<{ status?: string; changed?: boolean; restart_scheduled?: boolean }>(accountURL(accountID, '/provider-msp/license/refresh'), {
+ method: 'POST',
+ }, 'The licence could not be refreshed right now.');
+ },
};
}
diff --git a/internal/cloudcp/portal/frontend/src/app.ts b/internal/cloudcp/portal/frontend/src/app.ts
index 4892409edc..49bb1995a5 100644
--- a/internal/cloudcp/portal/frontend/src/app.ts
+++ b/internal/cloudcp/portal/frontend/src/app.ts
@@ -4,6 +4,7 @@ import { installAccountRuntime } from './account_runtime';
import { createPortalAPI, PortalAPIError } from './api';
import { installAuthController } from './auth_controller';
import { installBillingRuntime } from './billing';
+import { installProviderPlan, type ProviderPlanController } from './provider_plan';
import { installShell } from './shell';
import { createAnonymousBootstrap } from './store';
import { createPortalRuntime } from './runtime';
@@ -69,9 +70,14 @@ export function installPortalApp(deps: PortalAppDeps): PortalApp {
showToast: showToast,
});
+ var providerPlan: ProviderPlanController | null = null;
+
installShell({
store: deps.store,
onSectionChange: function(section) {
+ if (section === 'billing' && providerPlan) {
+ void providerPlan.load();
+ }
if (section === 'access') {
var accounts = deps.store.getBootstrap().accounts || [];
for (var i = 0; i < accounts.length; i += 1) {
@@ -86,6 +92,17 @@ export function installPortalApp(deps: PortalAppDeps): PortalApp {
store: deps.store,
});
+ // Installed after the shell so its bootstrap subscriber re-mounts the plan
+ // panel once the shell has re-rendered.
+ providerPlan = installProviderPlan({
+ api: api,
+ store: deps.store,
+ showToast: showToast,
+ });
+ if (deps.store.getBootstrap().provider_hosted_mode === true && deps.store.getBootstrap().authenticated) {
+ void providerPlan.load();
+ }
+
installAuthController({
api: api,
store: deps.store,
diff --git a/internal/cloudcp/portal/frontend/src/provider_plan.test.ts b/internal/cloudcp/portal/frontend/src/provider_plan.test.ts
new file mode 100644
index 0000000000..7686129440
--- /dev/null
+++ b/internal/cloudcp/portal/frontend/src/provider_plan.test.ts
@@ -0,0 +1,222 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { createPortalStore } from './store';
+import {
+ formatProviderPlanPrice,
+ installProviderPlan,
+ PROVIDER_PLAN_ROOT_ID,
+ renderProviderPlanHTML,
+ type ProviderPlanAPI,
+ type ProviderPlanState,
+ type ProviderPlanView,
+} from './provider_plan';
+import type { PortalBootstrapData } from './types';
+
+function evaluationPlan(overrides: Partial = {}): ProviderPlanState {
+ return {
+ plan_version: 'msp_eval',
+ plan_source: 'license_file',
+ evaluation: true,
+ license_id: 'lic_msp_eval',
+ expires_at: '2026-11-22T20:49:54Z',
+ workspace_limit: 2,
+ purchase_available: true,
+ plans: [
+ { plan_version: 'msp_solo', billing_cycle: 'monthly', unit_amount: 6900, currency: 'usd', workspace_limit: 3 },
+ { plan_version: 'msp_solo', billing_cycle: 'annual', unit_amount: 69000, currency: 'usd', workspace_limit: 3 },
+ { plan_version: 'msp_starter', billing_cycle: 'monthly', unit_amount: 14900, currency: 'usd', workspace_limit: 5 },
+ { plan_version: 'msp_scale', billing_cycle: 'annual', unit_amount: 399000, currency: 'usd', workspace_limit: 40 },
+ ],
+ ...overrides,
+ };
+}
+
+function view(overrides: Partial = {}): ProviderPlanView {
+ return { loading: false, error: '', plan: evaluationPlan(), cycle: 'monthly', busy: '', notice: '', ...overrides };
+}
+
+describe('renderProviderPlanHTML', () => {
+ it('shows the evaluation, its expiry and the plans it can grow into', () => {
+ const html = renderProviderPlanHTML(view(), true);
+ expect(html).toContain('Free evaluation');
+ expect(html).toContain('2 client workspaces');
+ expect(html).toContain('Expires');
+ expect(renderProviderPlanHTML(view(), true, 1)).toContain('1 of 2 client workspaces in use');
+ expect(html).toContain('data-provider-plan-offer="msp_solo"');
+ expect(html).toContain('Buy Solo');
+ expect(html).toContain('$69/mo');
+ expect(html).toContain('Buy Starter');
+ // Annual-only plans stay behind the annual toggle.
+ expect(html).not.toContain('data-provider-plan-offer="msp_scale"');
+ expect(html).toContain('Annual, 2 months free');
+ });
+
+ it('switches every offer to annual pricing', () => {
+ const html = renderProviderPlanHTML(view({ cycle: 'annual' }), true);
+ expect(html).toContain('$690/yr');
+ expect(html).toContain('$3,990/yr');
+ expect(html).not.toContain('Buy Starter');
+ });
+
+ it('tells a read-only member who can buy instead of offering buttons', () => {
+ const html = renderProviderPlanHTML(view(), false);
+ expect(html).not.toContain('data-provider-plan-action="buy"');
+ expect(html).not.toContain('Apply my purchase now');
+ expect(html).toContain('Only an owner or admin of this account can buy a plan.');
+ });
+
+ it('points a paying provider to Manage billing and offers no second purchase', () => {
+ const html = renderProviderPlanHTML(view({
+ plan: evaluationPlan({ plan_version: 'msp_solo', evaluation: false, plan_source: 'renewed_license', workspace_limit: 3 }),
+ }), true);
+ expect(html).toContain('Solo ');
+ expect(html).toContain('Up to 3 client workspaces');
+ expect(html).toContain('data-provider-plan-action="manage-billing"');
+ expect(html).not.toContain('data-provider-plan-action="buy"');
+ expect(html).toContain('Change plan, update your payment method or cancel renewal');
+ });
+
+ it('keeps the evaluation visible when plans cannot be loaded', () => {
+ const html = renderProviderPlanHTML(view({ plan: evaluationPlan({ plans: [], purchase_available: false, plans_error: 'Plans are unavailable right now.' }) }), true);
+ expect(html).toContain('Free evaluation');
+ expect(html).toContain('Plans are unavailable right now.');
+ expect(html).not.toContain('Choose a plan');
+ });
+
+ it('formats amounts in the listed currency', () => {
+ expect(formatProviderPlanPrice({ plan_version: 'msp_solo', billing_cycle: 'monthly', unit_amount: 6950, currency: 'eur', workspace_limit: 3 })).toBe('EUR 69.50/mo');
+ });
+});
+
+function providerBootstrap(canManage = true): PortalBootstrapData {
+ return {
+ authenticated: true,
+ email: 'owner@example.com',
+ has_self_hosted_commercial: false,
+ email_sign_in_available: false,
+ provider_hosted_mode: true,
+ public_site_url: 'https://pulserelay.pro',
+ support_email: 'support@pulserelay.pro',
+ commercial_api_base_url: '',
+ commercial_api_base_path: '',
+ portal_path: '/portal',
+ bootstrap_path: '/api/portal/bootstrap',
+ magic_link_request_path: '',
+ signup_path: '',
+ logout_path: '/auth/logout',
+ account_api_base_path: '/api/accounts',
+ portal_api_base_path: '/api/portal',
+ accounts: [{
+ id: 'a_1', name: 'Provider MSP', kind: 'msp', kind_label: 'MSP', role: canManage ? 'owner' : 'read_only',
+ can_manage: canManage, has_billing: false, workspaces: [], members: [],
+ }],
+ } as unknown as PortalBootstrapData;
+}
+
+describe('installProviderPlan', () => {
+ let api: ProviderPlanAPI;
+ let navigate: ReturnType void>>;
+ let toast: ReturnType void>>;
+ let timers: Array<() => void>;
+
+ beforeEach(() => {
+ document.body.innerHTML = '
';
+ navigate = vi.fn<(url: string) => void>();
+ toast = vi.fn<(message: string, isError?: boolean) => void>();
+ timers = [];
+ api = {
+ fetchPlan: vi.fn().mockResolvedValue(evaluationPlan()),
+ startCheckout: vi.fn().mockResolvedValue({ url: 'https://checkout.stripe.com/c/pay/cs_test' }),
+ openBillingPortal: vi.fn().mockResolvedValue({ url: 'https://billing.stripe.com/p/session/test' }),
+ refreshLicense: vi.fn().mockResolvedValue({ status: 'no_paid_subscription' }),
+ };
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ function install(bootstrap: PortalBootstrapData, search = '') {
+ const store = createPortalStore(bootstrap, bootstrap);
+ const controller = installProviderPlan({
+ api, store, showToast: toast, navigate,
+ locationSearch: () => search,
+ replaceSearch: () => {},
+ setTimeoutFn: (fn: () => void) => { timers.push(fn); return 0; },
+ });
+ return { store, controller };
+ }
+
+ function click(selector: string) {
+ const el = document.querySelector(selector) as HTMLElement | null;
+ expect(el, selector).not.toBeNull();
+ el!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ }
+
+ async function flush() {
+ for (let i = 0; i < 5; i += 1) await Promise.resolve();
+ }
+
+ it('loads the plan and sends Buy to checkout for this account', async () => {
+ const { controller } = install(providerBootstrap());
+ await controller.load();
+ expect(document.getElementById(PROVIDER_PLAN_ROOT_ID)!.innerHTML).toContain('Buy Solo');
+ click('[data-provider-plan-action="buy"][data-provider-plan-version="msp_solo"]');
+ await flush();
+ expect(api.startCheckout).toHaveBeenCalledWith('a_1', 'msp_solo', 'monthly');
+ expect(navigate).toHaveBeenCalledWith('https://checkout.stripe.com/c/pay/cs_test');
+ });
+
+ it('opens the billing portal for a paying provider', async () => {
+ api.fetchPlan = vi.fn().mockResolvedValue(evaluationPlan({ plan_version: 'msp_starter', evaluation: false, workspace_limit: 5 }));
+ const { controller } = install(providerBootstrap());
+ await controller.load();
+ click('[data-provider-plan-action="manage-billing"]');
+ await flush();
+ expect(api.openBillingPortal).toHaveBeenCalledWith('a_1');
+ expect(navigate).toHaveBeenCalledWith('https://billing.stripe.com/p/session/test');
+ });
+
+ it('applies a purchase after checkout returns and shows the new plan once the platform restarts', async () => {
+ api.refreshLicense = vi.fn()
+ .mockResolvedValueOnce({ status: 'no_paid_subscription' })
+ .mockResolvedValueOnce({ status: 'active', changed: true, restart_scheduled: true });
+ const { store, controller } = install(providerBootstrap(), '?provider_msp_checkout=complete');
+ await controller.load();
+ expect(store.getShellState().activeSection).toBe('billing');
+ expect(controller.view().notice).toContain('Payment received');
+ await flush();
+ expect(api.refreshLicense).toHaveBeenCalledTimes(1);
+ timers.shift()!();
+ await flush();
+ expect(api.refreshLicense).toHaveBeenCalledTimes(2);
+ expect(controller.view().notice).toContain('being applied');
+
+ // While the control plane restarts the plan is still the old one, then
+ // the new plan appears without a page reload.
+ api.fetchPlan = vi.fn()
+ .mockResolvedValueOnce(evaluationPlan())
+ .mockResolvedValueOnce(evaluationPlan({ plan_version: 'msp_solo', evaluation: false, workspace_limit: 3 }));
+ timers.shift()!();
+ await flush();
+ timers.shift()!();
+ await flush();
+ expect(controller.view().notice).toBe('Your Solo plan is active.');
+ expect(document.getElementById(PROVIDER_PLAN_ROOT_ID)!.innerHTML).toContain('Manage billing');
+ });
+
+ it('says nothing was charged when checkout is cancelled', () => {
+ const { controller } = install(providerBootstrap(), '?provider_msp_checkout=cancelled');
+ expect(controller.view().notice).toBe('Checkout was cancelled. Nothing was charged.');
+ expect(api.refreshLicense).not.toHaveBeenCalled();
+ });
+
+ it('does nothing outside provider-hosted mode', async () => {
+ const bootstrap = providerBootstrap();
+ bootstrap.provider_hosted_mode = false;
+ const { controller } = install(bootstrap);
+ await controller.load();
+ expect(api.fetchPlan).not.toHaveBeenCalled();
+ expect(document.getElementById(PROVIDER_PLAN_ROOT_ID)!.innerHTML).toBe('');
+ });
+});
diff --git a/internal/cloudcp/portal/frontend/src/provider_plan.ts b/internal/cloudcp/portal/frontend/src/provider_plan.ts
new file mode 100644
index 0000000000..8c5d10e3fa
--- /dev/null
+++ b/internal/cloudcp/portal/frontend/src/provider_plan.ts
@@ -0,0 +1,404 @@
+import { escapeAttribute, escapeText } from './billing_view';
+import type { PortalStore } from './store';
+import type { PortalAccountSummary } from './types';
+
+// Plan and purchase for a provider-hosted MSP platform. The control plane
+// relays to the licence server; nothing here talks to Pulse or Stripe
+// directly, and the plan list is whatever the licence server sells now.
+
+export interface ProviderPlanOption {
+ plan_version: string;
+ billing_cycle: 'monthly' | 'annual' | string;
+ unit_amount: number;
+ currency: string;
+ workspace_limit: number;
+}
+
+export interface ProviderPlanState {
+ plan_version: string;
+ plan_source: string;
+ evaluation: boolean;
+ license_id?: string;
+ expires_at?: string;
+ workspace_limit: number;
+ purchase_available: boolean;
+ plans: ProviderPlanOption[];
+ plans_error?: string;
+}
+
+export interface ProviderPlanView {
+ loading: boolean;
+ error: string;
+ plan: ProviderPlanState | null;
+ cycle: 'monthly' | 'annual';
+ busy: string;
+ notice: string;
+}
+
+export const PROVIDER_PLAN_ROOT_ID = 'provider-plan-root';
+
+const PLAN_NAMES: Record = {
+ msp_eval: 'Free evaluation',
+ msp_solo: 'Solo',
+ msp_starter: 'Starter',
+ msp_growth: 'Growth',
+ msp_scale: 'Scale',
+};
+
+export function providerPlanName(planVersion: string): string {
+ return PLAN_NAMES[planVersion] || planVersion;
+}
+
+export function formatProviderPlanPrice(option: ProviderPlanOption): string {
+ var amount = Math.round(Number(option.unit_amount || 0)) / 100;
+ var whole = amount % 1 === 0;
+ var currency = String(option.currency || 'usd').toUpperCase();
+ var symbol = currency === 'USD' ? '$' : currency + ' ';
+ var formatted = symbol + (whole ? amount.toFixed(0) : amount.toFixed(2)).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
+ return formatted + (option.billing_cycle === 'annual' ? '/yr' : '/mo');
+}
+
+function formatDate(value: string | undefined): string {
+ if (!value) return '';
+ var date = new Date(value);
+ if (isNaN(date.getTime())) return '';
+ return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
+}
+
+function clientWorkspaces(count: number): string {
+ return count === 1 ? '1 client workspace' : String(count) + ' client workspaces';
+}
+
+function renderCurrentPlan(plan: ProviderPlanState, canManage: boolean, busy: string, inUse: number): string {
+ var expires = formatDate(plan.expires_at);
+ var title = providerPlanName(plan.plan_version);
+ var description: string;
+ var meta: string[] = [inUse >= 0 ? String(inUse) + ' of ' + clientWorkspaces(plan.workspace_limit) + ' in use' : clientWorkspaces(plan.workspace_limit)];
+ var cta = '';
+ if (plan.evaluation) {
+ description = expires
+ ? 'Your evaluation covers ' + clientWorkspaces(plan.workspace_limit) + ' until ' + expires + '. Buy a plan to keep your clients monitored after that and to add more.'
+ : 'Your evaluation covers ' + clientWorkspaces(plan.workspace_limit) + '. Buy a plan to add more.';
+ if (expires) meta.push('Expires ' + expires);
+ } else {
+ description = 'Up to ' + clientWorkspaces(plan.workspace_limit) + '. Renews automatically while your subscription is active.';
+ if (expires) meta.push('Licence valid to ' + expires);
+ if (canManage) {
+ cta = '' + (busy === 'manage-billing' ? 'Opening…' : 'Manage billing') + ' ';
+ }
+ }
+ return (
+ '' +
+ '' +
+ '
' +
+ '
' + escapeText(title) + ' ' +
+ '
' + escapeText(description) + '
' +
+ '
' +
+ '
' + escapeText(meta.join(' • ')) + '
' +
+ '
' +
+ (cta ? '' + cta + '
' : '') +
+ ' '
+ );
+}
+
+function renderCycleToggle(cycle: 'monthly' | 'annual', hasAnnual: boolean): string {
+ if (!hasAnnual) return '';
+ function button(value: 'monthly' | 'annual', label: string) {
+ var active = value === cycle;
+ return '' + label + ' ';
+ }
+ return '' + button('monthly', 'Monthly') + ' ' + button('annual', 'Annual, 2 months free') + '
';
+}
+
+function renderOffer(option: ProviderPlanOption, canManage: boolean, busy: string): string {
+ var name = providerPlanName(option.plan_version);
+ var action = canManage
+ ? '' +
+ (busy === 'buy:' + option.plan_version ? 'Opening checkout…' : 'Buy ' + escapeText(name)) + ' '
+ : '';
+ return (
+ '' +
+ '' +
+ '
' +
+ '
' + escapeText(name) + ' ' +
+ '
Up to ' + escapeText(clientWorkspaces(option.workspace_limit)) + '.
' +
+ '
' +
+ '
' + escapeText(formatProviderPlanPrice(option)) + '
' +
+ '
' +
+ (action ? '' + action + '
' : '') +
+ ' '
+ );
+}
+
+export function renderProviderPlanHTML(view: ProviderPlanView, canManage: boolean, inUse = -1): string {
+ var parts: string[] = ['
Plan '];
+ if (view.notice) {
+ parts.push('' + escapeText(view.notice) + '
');
+ }
+ if (view.loading && !view.plan) {
+ parts.push('Loading your plan…
');
+ return parts.join('');
+ }
+ if (view.error && !view.plan) {
+ parts.push('' + escapeText(view.error) + '
');
+ return parts.join('');
+ }
+ var plan = view.plan;
+ if (!plan) return parts.join('');
+ parts.push(renderCurrentPlan(plan, canManage, view.busy, inUse));
+
+ if (plan.evaluation) {
+ if (plan.plans_error) {
+ parts.push('' + escapeText(plan.plans_error) + '
');
+ } else if (plan.purchase_available) {
+ var hasAnnual = plan.plans.some(function(option) { return option.billing_cycle === 'annual'; });
+ var cycle = hasAnnual ? view.cycle : 'monthly';
+ var offers = plan.plans.filter(function(option) {
+ return option.billing_cycle === cycle && option.workspace_limit > plan!.workspace_limit;
+ });
+ parts.push('Choose a plan You pay per client workspace, never per monitored system. Every client workspace is full Pulse.
');
+ parts.push(renderCycleToggle(cycle, hasAnnual));
+ parts.push(offers.map(function(option) { return renderOffer(option, canManage, view.busy); }).join(''));
+ if (!canManage) {
+ parts.push('Only an owner or admin of this account can buy a plan.
');
+ }
+ }
+ } else if (canManage) {
+ parts.push('Change plan, update your payment method or cancel renewal from Manage billing.
');
+ }
+
+ if (canManage && plan.license_id) {
+ parts.push(
+ 'Just paid? ' +
+ '' +
+ (view.busy === 'refresh' ? 'Checking…' : 'Apply my purchase now') +
+ ' ' +
+ '
'
+ );
+ }
+ return parts.join('');
+}
+
+export interface ProviderPlanAPI {
+ fetchPlan(accountID: string): Promise;
+ startCheckout(accountID: string, planVersion: string, billingCycle: string): Promise<{ url?: string }>;
+ openBillingPortal(accountID: string): Promise<{ url?: string }>;
+ refreshLicense(accountID: string): Promise<{ status?: string; changed?: boolean; restart_scheduled?: boolean }>;
+}
+
+export interface ProviderPlanDeps {
+ api: ProviderPlanAPI;
+ store: PortalStore;
+ showToast: (message: string, isError?: boolean) => void;
+ navigate?: (url: string) => void;
+ locationSearch?: () => string;
+ replaceSearch?: (search: string) => void;
+ setTimeoutFn?: (fn: () => void, ms: number) => unknown;
+}
+
+export interface ProviderPlanController {
+ load(): Promise;
+ render(): void;
+ view(): ProviderPlanView;
+}
+
+function providerAccount(accounts: PortalAccountSummary[] | undefined): PortalAccountSummary | null {
+ var list = Array.isArray(accounts) ? accounts : [];
+ for (var i = 0; i < list.length; i += 1) {
+ if (list[i] && list[i].can_manage) return list[i];
+ }
+ return list[0] || null;
+}
+
+const CHECKOUT_RETURN_PARAM = 'provider_msp_checkout';
+const APPLY_POLL_MS = 5000;
+const APPLY_POLL_ATTEMPTS = 12;
+// The control plane restarts to apply a changed licence; give it a moment,
+// then poll the plan until it answers with the new one.
+const RESTART_SETTLE_MS = 8000;
+const RESTART_POLL_MS = 3000;
+const RESTART_POLL_ATTEMPTS = 20;
+
+export function installProviderPlan(deps: ProviderPlanDeps): ProviderPlanController {
+ var navigate = deps.navigate || function(url: string) { window.location.assign(url); };
+ var later = deps.setTimeoutFn || function(fn: () => void, ms: number) { return setTimeout(fn, ms); };
+ var view: ProviderPlanView = { loading: false, error: '', plan: null, cycle: 'monthly', busy: '', notice: '' };
+
+ function account(): PortalAccountSummary | null {
+ var bootstrap = deps.store.getBootstrap();
+ if (bootstrap.provider_hosted_mode !== true || !bootstrap.authenticated) return null;
+ return providerAccount(bootstrap.accounts);
+ }
+
+ function render() {
+ var root = document.getElementById(PROVIDER_PLAN_ROOT_ID);
+ var current = account();
+ if (!root || !current) return;
+ var workspaces = Array.isArray(current.workspaces) ? current.workspaces : [];
+ root.innerHTML = renderProviderPlanHTML(view, current.can_manage === true, workspaces.length);
+ }
+
+ async function load() {
+ var current = account();
+ if (!current) return;
+ view.loading = true;
+ render();
+ try {
+ view.plan = await deps.api.fetchPlan(current.id);
+ view.error = '';
+ } catch (error) {
+ view.error = error instanceof Error && error.message ? error.message : 'Your plan could not be loaded.';
+ } finally {
+ view.loading = false;
+ render();
+ }
+ }
+
+ function waitForAppliedPlan(previousPlan: string, attempt: number) {
+ var current = account();
+ if (!current) return;
+ deps.api.fetchPlan(current.id).then(function(plan) {
+ if (plan.plan_version !== previousPlan) {
+ view.plan = plan;
+ view.error = '';
+ view.notice = 'Your ' + providerPlanName(plan.plan_version) + ' plan is active.';
+ render();
+ return;
+ }
+ throw new Error('plan not applied yet');
+ }).catch(function() {
+ if (attempt + 1 >= RESTART_POLL_ATTEMPTS) {
+ view.notice = 'Your plan should be active now. Open this page again if it still shows the old plan.';
+ render();
+ return;
+ }
+ later(function() { waitForAppliedPlan(previousPlan, attempt + 1); }, RESTART_POLL_MS);
+ });
+ }
+
+ function applyRestart() {
+ var previousPlan = view.plan ? view.plan.plan_version : '';
+ view.notice = 'Your plan is being applied. This takes a few seconds.';
+ render();
+ later(function() { waitForAppliedPlan(previousPlan, 0); }, RESTART_SETTLE_MS);
+ }
+
+ async function refresh(manual: boolean): Promise {
+ var current = account();
+ if (!current) return false;
+ view.busy = 'refresh';
+ render();
+ try {
+ var result = await deps.api.refreshLicense(current.id);
+ if (result.restart_scheduled) {
+ applyRestart();
+ return true;
+ }
+ if (manual) {
+ deps.showToast(result.status === 'active' ? 'Your plan is already up to date.' : 'No payment has reached this platform yet. It can take a minute after checkout.');
+ }
+ return false;
+ } catch (error) {
+ if (manual) deps.showToast(error instanceof Error ? error.message : 'Refresh failed.', true);
+ return false;
+ } finally {
+ view.busy = '';
+ render();
+ }
+ }
+
+ function pollAfterCheckout(attempt: number) {
+ void refresh(false).then(function(applied) {
+ if (applied) return;
+ if (attempt + 1 >= APPLY_POLL_ATTEMPTS) {
+ view.notice = 'Payment received. If your plan has not changed in a few minutes, use Apply my purchase now.';
+ render();
+ return;
+ }
+ later(function() { pollAfterCheckout(attempt + 1); }, APPLY_POLL_MS);
+ });
+ }
+
+ document.addEventListener('click', function(event) {
+ var target = event.target instanceof HTMLElement ? event.target.closest('[data-provider-plan-action]') as HTMLElement | null : null;
+ if (!target) return;
+ var current = account();
+ if (!current) return;
+ event.preventDefault();
+ var action = target.getAttribute('data-provider-plan-action') || '';
+ switch (action) {
+ case 'select-cycle':
+ view.cycle = target.getAttribute('data-provider-plan-cycle') === 'annual' ? 'annual' : 'monthly';
+ render();
+ return;
+ case 'buy': {
+ var planVersion = target.getAttribute('data-provider-plan-version') || '';
+ var cycle = target.getAttribute('data-provider-plan-cycle') || 'monthly';
+ view.busy = 'buy:' + planVersion;
+ render();
+ deps.api.startCheckout(current.id, planVersion, cycle).then(function(response) {
+ if (response && response.url) {
+ navigate(response.url);
+ return;
+ }
+ throw new Error('Checkout is unavailable right now.');
+ }).catch(function(error) {
+ view.busy = '';
+ render();
+ deps.showToast(error instanceof Error ? error.message : 'Checkout is unavailable right now.', true);
+ });
+ return;
+ }
+ case 'manage-billing':
+ view.busy = 'manage-billing';
+ render();
+ deps.api.openBillingPortal(current.id).then(function(response) {
+ if (response && response.url) {
+ navigate(response.url);
+ return;
+ }
+ throw new Error('Billing is unavailable right now.');
+ }).catch(function(error) {
+ view.busy = '';
+ render();
+ deps.showToast(error instanceof Error ? error.message : 'Billing is unavailable right now.', true);
+ });
+ return;
+ case 'refresh':
+ void refresh(true);
+ return;
+ }
+ });
+
+ // The shell re-renders on bootstrap changes; put the panel back.
+ deps.store.subscribeBootstrap(function() {
+ render();
+ });
+
+ var search = deps.locationSearch ? deps.locationSearch() : window.location.search;
+ var params = new URLSearchParams(search || '');
+ var returned = params.get(CHECKOUT_RETURN_PARAM);
+ if (returned && account()) {
+ params.delete(CHECKOUT_RETURN_PARAM);
+ var remaining = params.toString();
+ if (deps.replaceSearch) {
+ deps.replaceSearch(remaining ? '?' + remaining : '');
+ } else if (window.history && typeof window.history.replaceState === 'function') {
+ window.history.replaceState(null, '', window.location.pathname + (remaining ? '?' + remaining : '') + window.location.hash);
+ }
+ deps.store.setActiveShellSection('billing');
+ if (returned === 'complete') {
+ view.notice = 'Payment received. Applying your plan…';
+ pollAfterCheckout(0);
+ } else {
+ view.notice = 'Checkout was cancelled. Nothing was charged.';
+ }
+ }
+
+ return {
+ load: load,
+ render: render,
+ view: function() { return view; },
+ };
+}
diff --git a/internal/cloudcp/portal/frontend/src/shell_view.test.ts b/internal/cloudcp/portal/frontend/src/shell_view.test.ts
index 81923bf0c9..a715b7cb07 100644
--- a/internal/cloudcp/portal/frontend/src/shell_view.test.ts
+++ b/internal/cloudcp/portal/frontend/src/shell_view.test.ts
@@ -278,6 +278,38 @@ describe('shell view', function() {
expect(html).not.toContain('data-shell-section="overview"');
});
+ it('gives a provider-hosted platform a Plan section instead of hosted or self-hosted billing', function() {
+ var html = renderAuthenticatedPortalHTML(
+ createContext({
+ bootstrap: createBootstrap({
+ provider_hosted_mode: true,
+ accounts: [
+ {
+ id: 'acct_provider',
+ name: 'Provider MSP',
+ kind: 'msp',
+ kind_label: 'MSP',
+ role: 'owner',
+ can_manage: true,
+ has_billing: false,
+ members: [],
+ workspaces: [],
+ },
+ ],
+ }),
+ })
+ );
+
+ expect(html).toContain('data-shell-section="billing">Plan');
+ expect(html).toContain('id="provider-plan-root"');
+ expect(html).not.toContain('Self-hosted billing');
+ expect(html).not.toContain('hosted billing');
+ var tabs = ['workspaces', 'access', 'billing', 'support'].map(function(section) {
+ return html.indexOf('data-shell-action="activate-section" data-shell-section="' + section + '"');
+ });
+ expect(tabs.every(function(index, i) { return index > 0 && (i === 0 || index > tabs[i - 1]); })).toBe(true);
+ });
+
it('omits hosted billing surfaces for provider-hosted MSP accounts without billing records', function() {
var html = renderAuthenticatedPortalHTML(
createContext({
diff --git a/internal/cloudcp/portal/frontend/src/shell_view.ts b/internal/cloudcp/portal/frontend/src/shell_view.ts
index 9d09911426..8ab1f97c51 100644
--- a/internal/cloudcp/portal/frontend/src/shell_view.ts
+++ b/internal/cloudcp/portal/frontend/src/shell_view.ts
@@ -8,6 +8,7 @@ import type {
} from './types';
import { portalRoleLabel } from './account_roles';
import { preferredPortalShellSection } from './shell_section';
+import { PROVIDER_PLAN_ROOT_ID } from './provider_plan';
import {
workspaceActiveAlertLabel,
workspaceActiveAlertsUpdatedLabel,
@@ -422,12 +423,21 @@ function primaryShellSections(bootstrap: PortalBootstrapData): ShellNavEntry[] {
sections.push({ section: 'workspaces', title: accountsUseClientLanguage(accounts) ? 'Clients' : 'Workspaces' });
sections.push({ section: 'access', title: 'Access' });
}
- if (hasHostedBilling || showSelfHostedCommercial) {
+ if (providerHostedPlanShown(bootstrap)) {
+ // A provider-hosted platform's billing surface is its own plan: the
+ // licence it runs on, what it can buy, and its Stripe billing portal.
+ sections.push({ section: 'billing', title: 'Plan' });
+ } else if (hasHostedBilling || showSelfHostedCommercial) {
sections.push({ section: 'billing', title: 'Billing' });
}
return sections;
}
+function providerHostedPlanShown(bootstrap: PortalBootstrapData): boolean {
+ var accounts = Array.isArray(bootstrap.accounts) ? bootstrap.accounts : [];
+ return bootstrap.provider_hosted_mode === true && accounts.length > 0;
+}
+
function utilityShellSections(_bootstrap: PortalBootstrapData): ShellNavEntry[] {
return [{ section: 'support', title: 'Support' }];
}
@@ -1711,7 +1721,12 @@ export function renderAuthenticatedPortalHTML(context: ShellViewContext): string
''
)
: '') +
- (showBillingPanel
+ (providerHostedPlanShown(context.bootstrap)
+ ? ''
+ : '') +
+ (showBillingPanel && !providerHostedPlanShown(context.bootstrap)
? '' +
(hosted && hasHostedBilling ? renderHostedBillingCards(accounts, showSelfHostedCommercial) : '') +
(showSelfHostedBillingShell