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) {