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('"); + 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('"); + } 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 + '
' + accessContent + "
" : "") + (showBillingPanel ? '
' + (hosted && hasHostedBilling ? renderHostedBillingCards(accounts, showSelfHostedCommercial) : "") + (showSelfHostedBillingShell ? '

Self-hosted billing

' + escapeHTML(selfHostedBillingLeadCopy) + '

' + selfHostedBillingActionsHTML + '

Support

' + selfHostedBillingEscalationCopy + '

" : "") + "
" : "") + '
' + renderSupportSection(context) + "
"; + return '
' + renderIdentityBar(accounts, showSelfHostedCommercial) + renderTabBar(context.bootstrap, activeSection) + (hosted ? '
' + workspaceSummaryContent + workspacesContent + '
' + accessContent + "
" : "") + (providerHostedPlanShown(context.bootstrap) ? '

Loading your plan\u2026

' : "") + (showBillingPanel && !providerHostedPlanShown(context.bootstrap) ? '
' + (hosted && hasHostedBilling ? renderHostedBillingCards(accounts, showSelfHostedCommercial) : "") + (showSelfHostedBillingShell ? '

Self-hosted billing

' + escapeHTML(selfHostedBillingLeadCopy) + '

' + selfHostedBillingActionsHTML + '

Support

' + selfHostedBillingEscalationCopy + '

" : "") + "
" : "") + '
' + 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(''); + 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(''); + } 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) + ? '
' + + '

Loading your plan…

' + + '
' + : '') + + (showBillingPanel && !providerHostedPlanShown(context.bootstrap) ? '
' + (hosted && hasHostedBilling ? renderHostedBillingCards(accounts, showSelfHostedCommercial) : '') + (showSelfHostedBillingShell