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 = '";
+ }
+ }
+ 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 '";
+ }
+ return '
' + button("monthly", "Monthly") + " " + button("annual", "Annual, 2 months free") + "
";
+ }
+ function renderOffer(option, canManage, busy) {
+ var name = providerPlanName(option.plan_version);
+ var action = canManage ? '" : "";
+ 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?
"
+ );
+ }
+ 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 = '';
+ }
+ }
+ 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 '';
+ }
+ 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
+ ? ''
+ : '';
+ 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? ' +
+ '' +
+ '
'
+ );
+ }
+ 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