From 53206a1e60488b7aff52b335aea77b8087974cc3 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:52:12 +0100 Subject: [PATCH] fix(portal): confirm applied provider plan before announcing it A licence refresh can finish before the initial plan request, so the old evaluation or renewal date was reported as active after restart. Match the returned licence identity and expiry, ignore stale plan responses, and keep polling until the serving control plane exposes that state. Add race and same-plan renewal regression tests and update the embedded portal bundle. Contract-Neutral: This restores the existing cloud-paid Plan contract to poll until the restarted control plane serves the refreshed licence; no plan, entitlement, route, price, or role contract changes. Change-source: pulse-maintainer --- .../cloudcp/portal/dist/build_manifest.json | 2 +- internal/cloudcp/portal/dist/portal_app.js | 53 +++++++++++---- internal/cloudcp/portal/frontend/src/api.ts | 4 +- .../portal/frontend/src/provider_plan.test.ts | 53 ++++++++++++++- .../portal/frontend/src/provider_plan.ts | 68 +++++++++++++++---- 5 files changed, 146 insertions(+), 34 deletions(-) diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index f90f4e95b4..e6265c00fe 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "02b2a130bad03a89d6b916726206d5941bd0b23de5a2475f534aaf69419d7aba", + "source_hash": "33ddecf9ec76e0a22e0facf6971b8f0825600e56007db5e7f1f4e2cbe1dd2621", "build_inputs": [ "package.json", "tsconfig.json", diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js index eeda6cf1bd..805c55a7b6 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -2826,6 +2826,19 @@ return setTimeout(fn, ms); }; var view = { loading: false, error: "", plan: null, cycle: "monthly", busy: "", notice: "" }; + var planEpoch = 0; + var applying = null; + function isApplied(plan, target) { + return !!target.plan_version && plan.plan_version === target.plan_version && (!target.license_id || plan.license_id === target.license_id) && (!target.expires_at || plan.expires_at === target.expires_at); + } + function finishApplying(plan) { + applying = null; + planEpoch += 1; + view.plan = plan; + view.error = ""; + view.notice = "Your " + providerPlanName(plan.plan_version) + " plan is active."; + render(); + } function account() { var bootstrap = deps.store.getBootstrap(); if (bootstrap.provider_hosted_mode !== true || !bootstrap.authenticated) return null; @@ -2841,47 +2854,59 @@ async function load() { var current = account(); if (!current) return; + var epoch = planEpoch; view.loading = true; render(); try { - view.plan = await deps.api.fetchPlan(current.id); + var plan = await deps.api.fetchPlan(current.id); + if (epoch !== planEpoch) return; + if (applying) { + if (isApplied(plan, applying)) finishApplying(plan); + return; + } + view.plan = plan; view.error = ""; } catch (error) { - view.error = error instanceof Error && error.message ? error.message : "Your plan could not be loaded."; + if (epoch === planEpoch) { + view.error = error instanceof Error && error.message ? error.message : "Your plan could not be loaded."; + } } finally { view.loading = false; render(); } } - function waitForAppliedPlan(previousPlan, attempt) { + function waitForAppliedPlan(target, attempt) { + if (applying !== target) return; 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(); + if (applying !== target) return; + if (isApplied(plan, target)) { + finishApplying(plan); return; } throw new Error("plan not applied yet"); }).catch(function() { + if (applying !== target) return; 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."; + applying = null; + planEpoch += 1; + view.notice = "The updated plan could not be confirmed yet. Open this page again or use the apply button."; render(); return; } later(function() { - waitForAppliedPlan(previousPlan, attempt + 1); + waitForAppliedPlan(target, attempt + 1); }, RESTART_POLL_MS); }); } - function applyRestart() { - var previousPlan = view.plan ? view.plan.plan_version : ""; + function applyRestart(target) { + applying = target; + planEpoch += 1; view.notice = "Your plan is being applied. This takes a few seconds."; render(); later(function() { - waitForAppliedPlan(previousPlan, 0); + waitForAppliedPlan(target, 0); }, RESTART_SETTLE_MS); } async function refresh(manual) { @@ -2892,7 +2917,7 @@ try { var result = await deps.api.refreshLicense(current.id); if (result.restart_scheduled) { - applyRestart(); + applyRestart(result); return true; } if (manual) { diff --git a/internal/cloudcp/portal/frontend/src/api.ts b/internal/cloudcp/portal/frontend/src/api.ts index b786c75729..2560cb35d1 100644 --- a/internal/cloudcp/portal/frontend/src/api.ts +++ b/internal/cloudcp/portal/frontend/src/api.ts @@ -1,5 +1,5 @@ import type { PortalBootstrapData, PortalAccessMember } from './types'; -import type { ProviderPlanAPI, ProviderPlanState } from './provider_plan'; +import type { ProviderPlanAPI, ProviderPlanRefreshResult, ProviderPlanState } from './provider_plan'; interface PortalAPIContext { getBootstrap(): PortalBootstrapData; @@ -242,7 +242,7 @@ export function createPortalAPI(context: PortalAPIContext): PortalAPI { }, 'Billing is unavailable right now.'); }, refreshLicense: function(accountID: string) { - return request<{ status?: string; changed?: boolean; restart_scheduled?: boolean }>(accountURL(accountID, '/provider-msp/license/refresh'), { + return request(accountURL(accountID, '/provider-msp/license/refresh'), { method: 'POST', }, 'The licence could not be refreshed right now.'); }, diff --git a/internal/cloudcp/portal/frontend/src/provider_plan.test.ts b/internal/cloudcp/portal/frontend/src/provider_plan.test.ts index 0fd864d2d7..e9be0d625f 100644 --- a/internal/cloudcp/portal/frontend/src/provider_plan.test.ts +++ b/internal/cloudcp/portal/frontend/src/provider_plan.test.ts @@ -200,7 +200,7 @@ describe('installProviderPlan', () => { 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 }); + .mockResolvedValueOnce({ status: 'active', changed: true, restart_scheduled: true, plan_version: 'msp_solo' }); const { store, controller } = install(providerBootstrap(), '?provider_msp_checkout=complete'); await controller.load(); expect(store.getShellState().activeSection).toBe('billing'); @@ -225,11 +225,60 @@ describe('installProviderPlan', () => { expect(document.getElementById(PROVIDER_PLAN_ROOT_ID)!.innerHTML).toContain('Manage billing'); }); + it('does not announce the old plan when refresh beats the initial plan request', async () => { + let resolveInitial!: (plan: ProviderPlanState) => void; + api.fetchPlan = vi.fn() + .mockImplementationOnce(() => new Promise((resolve) => { resolveInitial = resolve; })) + .mockResolvedValueOnce(evaluationPlan()) + .mockResolvedValueOnce(evaluationPlan({ plan_version: 'msp_solo', evaluation: false, workspace_limit: 3 })); + api.refreshLicense = vi.fn().mockResolvedValue({ + status: 'active', changed: true, restart_scheduled: true, plan_version: 'msp_solo', + }); + const { controller } = install(providerBootstrap(), '?provider_msp_checkout=complete'); + const initialLoad = controller.load(); + await flush(); + expect(controller.view().notice).toContain('being applied'); + + timers.shift()!(); + await flush(); + expect(controller.view().notice).not.toContain('plan is active'); + expect(timers).toHaveLength(1); + + timers.shift()!(); + await flush(); + expect(controller.view().notice).toBe('Your Solo plan is active.'); + resolveInitial(evaluationPlan()); + await initialLoad; + expect(controller.view().plan?.plan_version).toBe('msp_solo'); + }); + + it('waits for the renewed licence date when the plan name is unchanged', async () => { + const original = evaluationPlan({ plan_version: 'msp_solo', evaluation: false, expires_at: '2026-10-10T00:00:00Z' }); + const renewed = evaluationPlan({ plan_version: 'msp_solo', evaluation: false, expires_at: '2026-11-10T00:00:00Z' }); + let served = original; + api.fetchPlan = vi.fn().mockImplementation(() => Promise.resolve(served)); + api.refreshLicense = vi.fn().mockResolvedValue({ + status: 'active', changed: true, restart_scheduled: true, + plan_version: 'msp_solo', expires_at: renewed.expires_at, + }); + const { controller } = install(providerBootstrap(), '?provider_msp_checkout=billing'); + await controller.load(); + await flush(); + timers.shift()!(); + await flush(); + expect(controller.view().notice).not.toContain('plan is active'); + served = renewed; + timers.shift()!(); + await flush(); + expect(controller.view().plan?.expires_at).toBe(renewed.expires_at); + expect(controller.view().notice).toBe('Your Solo plan is active.'); + }); + it('applies a plan changed in Manage billing when the provider comes back', async () => { api.fetchPlan = vi.fn().mockResolvedValue(evaluationPlan({ plan_version: 'msp_solo', evaluation: false, workspace_limit: 3 })); api.refreshLicense = vi.fn() .mockResolvedValueOnce({ status: 'active', changed: false }) - .mockResolvedValueOnce({ status: 'active', changed: true, restart_scheduled: true }); + .mockResolvedValueOnce({ status: 'active', changed: true, restart_scheduled: true, plan_version: 'msp_starter' }); const { store, controller } = install(providerBootstrap(), '?provider_msp_checkout=billing'); await controller.load(); expect(store.getShellState().activeSection).toBe('billing'); diff --git a/internal/cloudcp/portal/frontend/src/provider_plan.ts b/internal/cloudcp/portal/frontend/src/provider_plan.ts index 43d459edcf..2e7e05a65b 100644 --- a/internal/cloudcp/portal/frontend/src/provider_plan.ts +++ b/internal/cloudcp/portal/frontend/src/provider_plan.ts @@ -198,7 +198,16 @@ 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 }>; + refreshLicense(accountID: string): Promise; +} + +export interface ProviderPlanRefreshResult { + status?: string; + changed?: boolean; + restart_scheduled?: boolean; + plan_version?: string; + license_id?: string; + expires_at?: string; } export interface ProviderPlanDeps { @@ -241,6 +250,23 @@ export function installProviderPlan(deps: ProviderPlanDeps): ProviderPlanControl 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: '' }; + var planEpoch = 0; + var applying: ProviderPlanRefreshResult | null = null; + + function isApplied(plan: ProviderPlanState, target: ProviderPlanRefreshResult): boolean { + return !!target.plan_version && plan.plan_version === target.plan_version && + (!target.license_id || plan.license_id === target.license_id) && + (!target.expires_at || plan.expires_at === target.expires_at); + } + + function finishApplying(plan: ProviderPlanState) { + applying = null; + planEpoch += 1; // Ignore plan requests started before the restart completed. + view.plan = plan; + view.error = ''; + view.notice = 'Your ' + providerPlanName(plan.plan_version) + ' plan is active.'; + render(); + } function account(): PortalAccountSummary | null { var bootstrap = deps.store.getBootstrap(); @@ -259,46 +285,58 @@ export function installProviderPlan(deps: ProviderPlanDeps): ProviderPlanControl async function load() { var current = account(); if (!current) return; + var epoch = planEpoch; view.loading = true; render(); try { - view.plan = await deps.api.fetchPlan(current.id); + var plan = await deps.api.fetchPlan(current.id); + if (epoch !== planEpoch) return; + if (applying) { + if (isApplied(plan, applying)) finishApplying(plan); + return; + } + view.plan = plan; view.error = ''; } catch (error) { - view.error = error instanceof Error && error.message ? error.message : 'Your plan could not be loaded.'; + if (epoch === planEpoch) { + 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) { + function waitForAppliedPlan(target: ProviderPlanRefreshResult, attempt: number) { + if (applying !== target) return; 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(); + if (applying !== target) return; + if (isApplied(plan, target)) { + finishApplying(plan); return; } throw new Error('plan not applied yet'); }).catch(function() { + if (applying !== target) return; 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.'; + applying = null; + planEpoch += 1; + view.notice = 'The updated plan could not be confirmed yet. Open this page again or use the apply button.'; render(); return; } - later(function() { waitForAppliedPlan(previousPlan, attempt + 1); }, RESTART_POLL_MS); + later(function() { waitForAppliedPlan(target, attempt + 1); }, RESTART_POLL_MS); }); } - function applyRestart() { - var previousPlan = view.plan ? view.plan.plan_version : ''; + function applyRestart(target: ProviderPlanRefreshResult) { + applying = target; + planEpoch += 1; view.notice = 'Your plan is being applied. This takes a few seconds.'; render(); - later(function() { waitForAppliedPlan(previousPlan, 0); }, RESTART_SETTLE_MS); + later(function() { waitForAppliedPlan(target, 0); }, RESTART_SETTLE_MS); } async function refresh(manual: boolean): Promise { @@ -309,7 +347,7 @@ export function installProviderPlan(deps: ProviderPlanDeps): ProviderPlanControl try { var result = await deps.api.refreshLicense(current.id); if (result.restart_scheduled) { - applyRestart(); + applyRestart(result); return true; } if (manual) {