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.
This commit is contained in:
rcourtman
2026-09-23 23:45:28 +01:00
parent 185c6ac80b
commit edda116236
9 changed files with 1075 additions and 7 deletions
@@ -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
+3 -1
View File
@@ -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",
+344 -2
View File
@@ -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 = '<button class="btn-secondary billing-action-button" type="button" data-provider-plan-action="manage-billing"' + (busy ? " disabled" : "") + ">" + (busy === "manage-billing" ? "Opening\u2026" : "Manage billing") + "</button>";
}
}
return '<article class="billing-action-row" data-provider-plan-current="' + escapeAttribute(plan.plan_version) + '"><div class="billing-action-main"><div class="billing-action-copy"><h3>' + escapeText(title) + "</h3><p>" + escapeText(description) + '</p></div><div class="billing-action-meta">' + escapeText(meta.join(" \u2022 ")) + "</div></div>" + (cta ? '<div class="billing-action-cta">' + cta + "</div>" : "") + "</article>";
}
function renderCycleToggle(cycle, hasAnnual) {
if (!hasAnnual) return "";
function button(value, label) {
var active = value === cycle;
return '<button class="' + (active ? "btn-primary" : "btn-secondary") + ' btn-compact" type="button" data-provider-plan-action="select-cycle" data-provider-plan-cycle="' + value + '" aria-pressed="' + (active ? "true" : "false") + '">' + label + "</button>";
}
return '<div class="billing-action-meta" role="group" aria-label="Billing period">' + button("monthly", "Monthly") + " " + button("annual", "Annual, 2 months free") + "</div>";
}
function renderOffer(option, canManage, busy) {
var name = providerPlanName(option.plan_version);
var action = canManage ? '<button class="btn-primary billing-action-button" type="button" data-provider-plan-action="buy" data-provider-plan-version="' + escapeAttribute(option.plan_version) + '" data-provider-plan-cycle="' + escapeAttribute(option.billing_cycle) + '"' + (busy ? " disabled" : "") + ">" + (busy === "buy:" + option.plan_version ? "Opening checkout\u2026" : "Buy " + escapeText(name)) + "</button>" : "";
return '<article class="billing-action-row" data-provider-plan-offer="' + escapeAttribute(option.plan_version) + '"><div class="billing-action-main"><div class="billing-action-copy"><h3>' + escapeText(name) + "</h3><p>Up to " + escapeText(clientWorkspaces(option.workspace_limit)) + '.</p></div><div class="billing-action-meta">' + escapeText(formatProviderPlanPrice(option)) + "</div></div>" + (action ? '<div class="billing-action-cta">' + action + "</div>" : "") + "</article>";
}
function renderProviderPlanHTML(view, canManage, inUse = -1) {
var parts = ['<div class="billing-section-intro"><h2>Plan</h2></div>'];
if (view.notice) {
parts.push('<p class="billing-action-meta" role="status">' + escapeText(view.notice) + "</p>");
}
if (view.loading && !view.plan) {
parts.push('<p class="billing-action-meta">Loading your plan\u2026</p>');
return parts.join("");
}
if (view.error && !view.plan) {
parts.push('<p class="billing-action-meta" role="alert">' + escapeText(view.error) + "</p>");
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('<p class="billing-action-meta" role="alert">' + escapeText(plan.plans_error) + "</p>");
} 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('<div class="billing-section-intro"><h3>Choose a plan</h3><p>You pay per client workspace, never per monitored system. Every client workspace is full Pulse.</p></div>');
parts.push(renderCycleToggle(cycle, hasAnnual));
parts.push(offers.map(function(option) {
return renderOffer(option, canManage, view.busy);
}).join(""));
if (!canManage) {
parts.push('<p class="billing-action-meta">Only an owner or admin of this account can buy a plan.</p>');
}
}
} else if (canManage) {
parts.push('<p class="billing-action-meta">Change plan, update your payment method or cancel renewal from Manage billing.</p>');
}
if (canManage && plan.license_id) {
parts.push(
'<p class="billing-action-meta">Just paid? <button class="btn-secondary btn-compact" type="button" data-provider-plan-action="refresh"' + (view.busy ? " disabled" : "") + ">" + (view.busy === "refresh" ? "Checking\u2026" : "Apply my purchase now") + "</button></p>"
);
}
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
@@ -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 @@
'<div class="subsection"><div id="data-export-root"></div></div><div class="subsection"><div id="data-delete-root"></div></div><div class="helper-text">Payment-card data stays with Stripe. For Stripe deletion support, contact <a href="mailto:' + escapeAttr(context.bootstrap.support_email || "") + '">' + escapeHTML(context.bootstrap.support_email || "") + "</a>.</div>"
);
}
return '<div class="portal-shell" data-shell-section="' + activeSection + '"><div class="portal-shell-main">' + renderIdentityBar(accounts, showSelfHostedCommercial) + renderTabBar(context.bootstrap, activeSection) + (hosted ? '<section class="portal-content-panel portal-content-panel-workspaces">' + workspaceSummaryContent + workspacesContent + '</section><section class="portal-content-panel portal-content-panel-access">' + accessContent + "</section>" : "") + (showBillingPanel ? '<section class="portal-content-panel portal-content-panel-billing billing-section" id="billing-section">' + (hosted && hasHostedBilling ? renderHostedBillingCards(accounts, showSelfHostedCommercial) : "") + (showSelfHostedBillingShell ? '<div class="billing-shell billing-shell-idle"><div class="billing-shell-main"><div class="billing-shell-main-head"><h3>Self-hosted billing</h3><p>' + escapeHTML(selfHostedBillingLeadCopy) + '</p></div><div class="billing-action-list">' + selfHostedBillingActionsHTML + '</div><div class="billing-inline-support"><h4>Support</h4><p>' + selfHostedBillingEscalationCopy + '</p><div class="billing-inline-support-actions"><button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="support">Open support</button><a class="portal-support-link" href="mailto:' + escapeAttr(context.bootstrap.support_email || "") + '">' + escapeHTML(context.bootstrap.support_email || "") + '</a></div></div></div><div class="billing-shell-detail" id="billing-detail-shell" hidden>' + selfHostedBillingPanelsHTML + "</div></div>" : "") + "</section>" : "") + '<section class="portal-content-panel portal-content-panel-support">' + renderSupportSection(context) + "</section></div></div>";
return '<div class="portal-shell" data-shell-section="' + activeSection + '"><div class="portal-shell-main">' + renderIdentityBar(accounts, showSelfHostedCommercial) + renderTabBar(context.bootstrap, activeSection) + (hosted ? '<section class="portal-content-panel portal-content-panel-workspaces">' + workspaceSummaryContent + workspacesContent + '</section><section class="portal-content-panel portal-content-panel-access">' + accessContent + "</section>" : "") + (providerHostedPlanShown(context.bootstrap) ? '<section class="portal-content-panel portal-content-panel-billing billing-section" id="billing-section"><div id="' + PROVIDER_PLAN_ROOT_ID + '"><p class="billing-action-meta">Loading your plan\u2026</p></div></section>' : "") + (showBillingPanel && !providerHostedPlanShown(context.bootstrap) ? '<section class="portal-content-panel portal-content-panel-billing billing-section" id="billing-section">' + (hosted && hasHostedBilling ? renderHostedBillingCards(accounts, showSelfHostedCommercial) : "") + (showSelfHostedBillingShell ? '<div class="billing-shell billing-shell-idle"><div class="billing-shell-main"><div class="billing-shell-main-head"><h3>Self-hosted billing</h3><p>' + escapeHTML(selfHostedBillingLeadCopy) + '</p></div><div class="billing-action-list">' + selfHostedBillingActionsHTML + '</div><div class="billing-inline-support"><h4>Support</h4><p>' + selfHostedBillingEscalationCopy + '</p><div class="billing-inline-support-actions"><button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="support">Open support</button><a class="portal-support-link" href="mailto:' + escapeAttr(context.bootstrap.support_email || "") + '">' + escapeHTML(context.bootstrap.support_email || "") + '</a></div></div></div><div class="billing-shell-detail" id="billing-detail-shell" hidden>' + selfHostedBillingPanelsHTML + "</div></div>" : "") + "</section>" : "") + '<section class="portal-content-panel portal-content-panel-support">' + renderSupportSection(context) + "</section></div></div>";
}
function renderAuthScopeRow(title, copy) {
return '<article class="portal-auth-scope-row"><h3>' + escapeHTML(title) + "</h3><p>" + escapeHTML(copy) + "</p></article>";
@@ -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
+24 -1
View File
@@ -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<PortalBootstrapData>;
requestMagicLink(email: string): Promise<PortalMagicLinkResponse>;
logout(): Promise<void>;
@@ -223,5 +224,27 @@ export function createPortalAPI(context: PortalAPIContext): PortalAPI {
method: 'DELETE',
}, 'Failed to remove member.');
},
fetchPlan: function(accountID: string) {
return request<ProviderPlanState>(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.');
},
};
}
@@ -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,
@@ -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> = {}): 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> = {}): 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('<h3>Solo</h3>');
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<typeof vi.fn<(url: string) => void>>;
let toast: ReturnType<typeof vi.fn<(message: string, isError?: boolean) => void>>;
let timers: Array<() => void>;
beforeEach(() => {
document.body.innerHTML = '<div id="' + PROVIDER_PLAN_ROOT_ID + '"></div>';
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('');
});
});
@@ -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<string, string> = {
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 = '<button class="btn-secondary billing-action-button" type="button" data-provider-plan-action="manage-billing"' +
(busy ? ' disabled' : '') + '>' + (busy === 'manage-billing' ? 'Opening…' : 'Manage billing') + '</button>';
}
}
return (
'<article class="billing-action-row" data-provider-plan-current="' + escapeAttribute(plan.plan_version) + '">' +
'<div class="billing-action-main">' +
'<div class="billing-action-copy">' +
'<h3>' + escapeText(title) + '</h3>' +
'<p>' + escapeText(description) + '</p>' +
'</div>' +
'<div class="billing-action-meta">' + escapeText(meta.join(' • ')) + '</div>' +
'</div>' +
(cta ? '<div class="billing-action-cta">' + cta + '</div>' : '') +
'</article>'
);
}
function renderCycleToggle(cycle: 'monthly' | 'annual', hasAnnual: boolean): string {
if (!hasAnnual) return '';
function button(value: 'monthly' | 'annual', label: string) {
var active = value === cycle;
return '<button class="' + (active ? 'btn-primary' : 'btn-secondary') + ' btn-compact" type="button" data-provider-plan-action="select-cycle" data-provider-plan-cycle="' + value + '" aria-pressed="' + (active ? 'true' : 'false') + '">' + label + '</button>';
}
return '<div class="billing-action-meta" role="group" aria-label="Billing period">' + button('monthly', 'Monthly') + ' ' + button('annual', 'Annual, 2 months free') + '</div>';
}
function renderOffer(option: ProviderPlanOption, canManage: boolean, busy: string): string {
var name = providerPlanName(option.plan_version);
var action = canManage
? '<button class="btn-primary billing-action-button" type="button" data-provider-plan-action="buy" data-provider-plan-version="' + escapeAttribute(option.plan_version) + '" data-provider-plan-cycle="' + escapeAttribute(option.billing_cycle) + '"' + (busy ? ' disabled' : '') + '>' +
(busy === 'buy:' + option.plan_version ? 'Opening checkout…' : 'Buy ' + escapeText(name)) + '</button>'
: '';
return (
'<article class="billing-action-row" data-provider-plan-offer="' + escapeAttribute(option.plan_version) + '">' +
'<div class="billing-action-main">' +
'<div class="billing-action-copy">' +
'<h3>' + escapeText(name) + '</h3>' +
'<p>Up to ' + escapeText(clientWorkspaces(option.workspace_limit)) + '.</p>' +
'</div>' +
'<div class="billing-action-meta">' + escapeText(formatProviderPlanPrice(option)) + '</div>' +
'</div>' +
(action ? '<div class="billing-action-cta">' + action + '</div>' : '') +
'</article>'
);
}
export function renderProviderPlanHTML(view: ProviderPlanView, canManage: boolean, inUse = -1): string {
var parts: string[] = ['<div class="billing-section-intro"><h2>Plan</h2></div>'];
if (view.notice) {
parts.push('<p class="billing-action-meta" role="status">' + escapeText(view.notice) + '</p>');
}
if (view.loading && !view.plan) {
parts.push('<p class="billing-action-meta">Loading your plan…</p>');
return parts.join('');
}
if (view.error && !view.plan) {
parts.push('<p class="billing-action-meta" role="alert">' + escapeText(view.error) + '</p>');
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('<p class="billing-action-meta" role="alert">' + escapeText(plan.plans_error) + '</p>');
} 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('<div class="billing-section-intro"><h3>Choose a plan</h3><p>You pay per client workspace, never per monitored system. Every client workspace is full Pulse.</p></div>');
parts.push(renderCycleToggle(cycle, hasAnnual));
parts.push(offers.map(function(option) { return renderOffer(option, canManage, view.busy); }).join(''));
if (!canManage) {
parts.push('<p class="billing-action-meta">Only an owner or admin of this account can buy a plan.</p>');
}
}
} else if (canManage) {
parts.push('<p class="billing-action-meta">Change plan, update your payment method or cancel renewal from Manage billing.</p>');
}
if (canManage && plan.license_id) {
parts.push(
'<p class="billing-action-meta">Just paid? ' +
'<button class="btn-secondary btn-compact" type="button" data-provider-plan-action="refresh"' + (view.busy ? ' disabled' : '') + '>' +
(view.busy === 'refresh' ? 'Checking…' : 'Apply my purchase now') +
'</button>' +
'</p>'
);
}
return parts.join('');
}
export interface ProviderPlanAPI {
fetchPlan(accountID: string): Promise<ProviderPlanState>;
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<void>;
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<boolean> {
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; },
};
}
@@ -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</button>');
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({
@@ -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
'</section>'
)
: '') +
(showBillingPanel
(providerHostedPlanShown(context.bootstrap)
? '<section class="portal-content-panel portal-content-panel-billing billing-section" id="billing-section">' +
'<div id="' + PROVIDER_PLAN_ROOT_ID + '"><p class="billing-action-meta">Loading your plan…</p></div>' +
'</section>'
: '') +
(showBillingPanel && !providerHostedPlanShown(context.bootstrap)
? '<section class="portal-content-panel portal-content-panel-billing billing-section" id="billing-section">' +
(hosted && hasHostedBilling ? renderHostedBillingCards(accounts, showSelfHostedCommercial) : '') +
(showSelfHostedBillingShell