From d1ea9ce0dbcb0d50ed64e2e7b451d907bb9d2162 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 04:53:06 +0100 Subject: [PATCH 1/4] fix(portal): confirm paid provider plan from running licence Treat the provider checkout return as navigation, not payment evidence. Confirm the signed active refresh against the paid running plan even when background refresh applied it without another restart, keep an unmatched return conditional, cover manual Apply and adverse paths, rebuild the embedded bundle, and record the Plan confirmation contract in cloud-paid. Change-source: pulse-maintainer --- .../v6/internal/subsystems/cloud-paid.md | 13 +++- .../cloudcp/portal/dist/build_manifest.json | 2 +- internal/cloudcp/portal/dist/portal_app.js | 21 ++++-- .../portal/frontend/src/provider_plan.test.ts | 75 ++++++++++++++++++- .../portal/frontend/src/provider_plan.ts | 26 +++++-- 5 files changed, 118 insertions(+), 19 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index ba6b3ff798..ba5c2adec7 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -3483,9 +3483,16 @@ inside its 14-day grace the panel says the subscription has not renewed and when clients lose the plan. Owners and admins get Buy, Manage billing and an immediate refresh ("Apply my purchase now", or "Apply it now" on a paid plan); 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. Manage billing returns to +`?provider_msp_checkout=complete`, the redirect is a navigation hint, not +proof of payment. The panel keeps checking without saying payment was received +until the licence refresh reports `active` and the running plan is paid and +matches the returned plan version, licence ID and expiry. A changed licence +that schedules a restart is confirmed only after the restarted control plane +serves the matching plan; an unchanged paid licence already applied by a +background refresh can be confirmed without another restart. If no matching +paid plan appears, the panel gives conditional follow-up instead of claiming +payment or activation. Manual Apply uses the same running-plan confirmation +before updating the view. The panel never reloads the page. Manage billing returns to `?provider_msp_checkout=billing`, and the panel checks the licence twice so a plan changed in the Stripe billing portal applies without a click. Regression coverage: diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index e6265c00fe..303ecaeff2 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "33ddecf9ec76e0a22e0facf6971b8f0825600e56007db5e7f1f4e2cbe1dd2621", + "source_hash": "5a72090fb4e236e040f29fae0e7685c1e5ce21499deaa6cb92f24181562738fd", "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 805c55a7b6..f71c9b7a7f 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -2829,7 +2829,7 @@ 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); + return !plan.evaluation && !!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; @@ -2909,7 +2909,7 @@ waitForAppliedPlan(target, 0); }, RESTART_SETTLE_MS); } - async function refresh(manual) { + async function refresh(manual, confirmCurrent = false) { var current = account(); if (!current) return false; view.busy = "refresh"; @@ -2920,8 +2920,15 @@ applyRestart(result); return true; } + if (confirmCurrent && result.status === "active" && result.plan_version) { + var plan = await deps.api.fetchPlan(current.id); + if (isApplied(plan, result)) { + finishApplying(plan); + 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."); + deps.showToast(result.status === "active" ? "The updated plan could not be confirmed yet. Try again shortly." : "No payment has reached this platform yet. It can take a minute after checkout."); } return false; } catch (error) { @@ -2933,10 +2940,10 @@ } } function pollAfterCheckout(attempt) { - void refresh(false).then(function(applied) { + void refresh(false, true).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."; + view.notice = "A paid plan could not be confirmed yet. If you completed checkout, wait a minute and use Apply my purchase now."; render(); return; } @@ -2999,7 +3006,7 @@ }); return; case "refresh": - void refresh(true); + void refresh(true, true); return; } }); @@ -3019,7 +3026,7 @@ } deps.store.setActiveShellSection("billing"); if (returned === "complete") { - view.notice = "Payment received. Applying your plan\u2026"; + view.notice = "Checking for your paid plan\u2026"; pollAfterCheckout(0); } else if (returned === "billing") { checkAfterBillingReturn(0); diff --git a/internal/cloudcp/portal/frontend/src/provider_plan.test.ts b/internal/cloudcp/portal/frontend/src/provider_plan.test.ts index e9be0d625f..d19d9badd7 100644 --- a/internal/cloudcp/portal/frontend/src/provider_plan.test.ts +++ b/internal/cloudcp/portal/frontend/src/provider_plan.test.ts @@ -197,6 +197,24 @@ describe('installProviderPlan', () => { expect(navigate).toHaveBeenCalledWith('https://billing.stripe.com/p/session/test'); }); + it('updates the manual apply view when a paid licence is already running', async () => { + const paid = evaluationPlan({ plan_version: 'msp_solo', evaluation: false, license_id: 'lic_msp_paid', workspace_limit: 3 }); + api.fetchPlan = vi.fn() + .mockResolvedValueOnce(evaluationPlan()) + .mockResolvedValueOnce(paid); + api.refreshLicense = vi.fn().mockResolvedValue({ + status: 'active', changed: false, restart_scheduled: false, + plan_version: paid.plan_version, license_id: paid.license_id, expires_at: paid.expires_at, + }); + const { controller } = install(providerBootstrap()); + await controller.load(); + click('[data-provider-plan-action="refresh"]'); + await flush(); + expect(controller.view().notice).toBe('Your Solo plan is active.'); + expect(controller.view().plan).toEqual(paid); + expect(toast).not.toHaveBeenCalled(); + }); + 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' }) @@ -204,7 +222,7 @@ describe('installProviderPlan', () => { 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'); + expect(controller.view().notice).toBe('Checking for your paid plan…'); await flush(); expect(api.refreshLicense).toHaveBeenCalledTimes(1); timers.shift()!(); @@ -225,6 +243,61 @@ describe('installProviderPlan', () => { expect(document.getElementById(PROVIDER_PLAN_ROOT_ID)!.innerHTML).toContain('Manage billing'); }); + it('does not claim payment from the checkout return when no paid subscription appears', async () => { + const { controller } = install(providerBootstrap(), '?provider_msp_checkout=complete'); + await controller.load(); + await flush(); + for (let attempt = 1; attempt < 12; attempt += 1) { + expect(timers).toHaveLength(1); + timers.shift()!(); + await flush(); + } + expect(api.refreshLicense).toHaveBeenCalledTimes(12); + expect(timers).toHaveLength(0); + expect(controller.view().notice).toContain('could not be confirmed yet'); + expect(controller.view().notice).not.toContain('Payment received'); + expect(controller.view().plan?.evaluation).toBe(true); + }); + + it('confirms an already-applied paid licence without requiring another restart', async () => { + const paid = evaluationPlan({ + plan_version: 'msp_solo', evaluation: false, license_id: 'lic_msp_paid', workspace_limit: 3, + }); + api.fetchPlan = vi.fn().mockResolvedValue(paid); + api.refreshLicense = vi.fn().mockResolvedValue({ + status: 'active', changed: false, restart_scheduled: false, + plan_version: paid.plan_version, license_id: paid.license_id, expires_at: paid.expires_at, + }); + const { controller } = install(providerBootstrap(), '?provider_msp_checkout=complete'); + await controller.load(); + await flush(); + expect(controller.view().notice).toBe('Your Solo plan is active.'); + expect(controller.view().plan).toEqual(paid); + expect(timers).toHaveLength(0); + }); + + it('waits for the matching running plan even when refresh reports an active licence', async () => { + const paid = evaluationPlan({ + plan_version: 'msp_solo', evaluation: false, license_id: 'lic_msp_paid', workspace_limit: 3, + }); + api.fetchPlan = vi.fn() + .mockResolvedValueOnce(evaluationPlan()) + .mockResolvedValueOnce(evaluationPlan()) + .mockResolvedValueOnce(paid); + api.refreshLicense = vi.fn().mockResolvedValue({ + status: 'active', changed: false, restart_scheduled: false, + plan_version: paid.plan_version, license_id: paid.license_id, expires_at: paid.expires_at, + }); + const { controller } = install(providerBootstrap(), '?provider_msp_checkout=complete'); + await controller.load(); + await flush(); + expect(controller.view().notice).toBe('Checking for your paid plan…'); + expect(timers).toHaveLength(1); + timers.shift()!(); + await flush(); + expect(controller.view().notice).toBe('Your Solo plan is active.'); + }); + it('does not announce the old plan when refresh beats the initial plan request', async () => { let resolveInitial!: (plan: ProviderPlanState) => void; api.fetchPlan = vi.fn() diff --git a/internal/cloudcp/portal/frontend/src/provider_plan.ts b/internal/cloudcp/portal/frontend/src/provider_plan.ts index 2e7e05a65b..384a2f6bd6 100644 --- a/internal/cloudcp/portal/frontend/src/provider_plan.ts +++ b/internal/cloudcp/portal/frontend/src/provider_plan.ts @@ -254,7 +254,7 @@ export function installProviderPlan(deps: ProviderPlanDeps): ProviderPlanControl var applying: ProviderPlanRefreshResult | null = null; function isApplied(plan: ProviderPlanState, target: ProviderPlanRefreshResult): boolean { - return !!target.plan_version && plan.plan_version === target.plan_version && + return !plan.evaluation && !!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); } @@ -339,7 +339,7 @@ export function installProviderPlan(deps: ProviderPlanDeps): ProviderPlanControl later(function() { waitForAppliedPlan(target, 0); }, RESTART_SETTLE_MS); } - async function refresh(manual: boolean): Promise { + async function refresh(manual: boolean, confirmCurrent = false): Promise { var current = account(); if (!current) return false; view.busy = 'refresh'; @@ -350,8 +350,20 @@ export function installProviderPlan(deps: ProviderPlanDeps): ProviderPlanControl applyRestart(result); return true; } + // The background refresher may have restarted the platform before the + // browser returns from checkout. Confirm the running paid plan rather + // than waiting for another restart that will never be scheduled. + if (confirmCurrent && result.status === 'active' && result.plan_version) { + var plan = await deps.api.fetchPlan(current.id); + if (isApplied(plan, result)) { + finishApplying(plan); + 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.'); + deps.showToast(result.status === 'active' + ? 'The updated plan could not be confirmed yet. Try again shortly.' + : 'No payment has reached this platform yet. It can take a minute after checkout.'); } return false; } catch (error) { @@ -364,10 +376,10 @@ export function installProviderPlan(deps: ProviderPlanDeps): ProviderPlanControl } function pollAfterCheckout(attempt: number) { - void refresh(false).then(function(applied) { + void refresh(false, true).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.'; + view.notice = 'A paid plan could not be confirmed yet. If you completed checkout, wait a minute and use Apply my purchase now.'; render(); return; } @@ -428,7 +440,7 @@ export function installProviderPlan(deps: ProviderPlanDeps): ProviderPlanControl }); return; case 'refresh': - void refresh(true); + void refresh(true, true); return; } }); @@ -451,7 +463,7 @@ export function installProviderPlan(deps: ProviderPlanDeps): ProviderPlanControl } deps.store.setActiveShellSection('billing'); if (returned === 'complete') { - view.notice = 'Payment received. Applying your plan…'; + view.notice = 'Checking for your paid plan…'; pollAfterCheckout(0); } else if (returned === 'billing') { checkAfterBillingReturn(0); From 8a59a8df7fadc4fcca725847a170c67083eac1fa Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 05:06:04 +0100 Subject: [PATCH 2/4] fix(portal): recognise already-applied billing returns Confirm a matching paid licence in the running plan even when the background refresher applied it before the Manage billing return check. Keep unchanged visits silent and guard stale or unmatched plan responses with regression coverage. Change-source: pulse-maintainer --- .../v6/internal/subsystems/cloud-paid.md | 6 +- .../cloudcp/portal/dist/build_manifest.json | 2 +- internal/cloudcp/portal/dist/portal_app.js | 21 ++++- .../portal/frontend/src/provider_plan.test.ts | 81 +++++++++++++++++++ .../portal/frontend/src/provider_plan.ts | 29 +++++-- 5 files changed, 128 insertions(+), 11 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index ba5c2adec7..7c99da74fe 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -3494,7 +3494,11 @@ paid plan appears, the panel gives conditional follow-up instead of claiming payment or activation. Manual Apply uses the same running-plan confirmation before updating the view. The panel never reloads the page. Manage billing returns to `?provider_msp_checkout=billing`, and the panel checks the licence twice so a -plan changed in the Stripe billing portal applies without a click. Regression +plan changed in the Stripe billing portal applies without a click. It also +accepts a matching paid plan already applied by the background refresher +without requiring a second restart; an unchanged visit stays silent. A stale +panel is replaced only after the refreshed running plan is confirmed, never +because the billing return URL says a change was made. Regression coverage: `TestLoadConfig_ProviderHostedMSPPrefersRenewedLicense` in `internal/cloudcp/config_test.go`, diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index 303ecaeff2..90c44f8d4e 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "5a72090fb4e236e040f29fae0e7685c1e5ce21499deaa6cb92f24181562738fd", + "source_hash": "294e2ef873914caeb1132876df403b0ef2b101262f00cdaf34fb13d0b3d22c29", "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 f71c9b7a7f..68a7103de2 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -2831,14 +2831,20 @@ function isApplied(plan, target) { return !plan.evaluation && !!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) { + function sameRunningPlan(left, right) { + return left.evaluation === right.evaluation && left.plan_version === right.plan_version && left.license_id === right.license_id && left.expires_at === right.expires_at; + } + function acceptRunningPlan(plan, announce) { applying = null; planEpoch += 1; view.plan = plan; view.error = ""; - view.notice = "Your " + providerPlanName(plan.plan_version) + " plan is active."; + if (announce) view.notice = "Your " + providerPlanName(plan.plan_version) + " plan is active."; render(); } + function finishApplying(plan) { + acceptRunningPlan(plan, true); + } function account() { var bootstrap = deps.store.getBootstrap(); if (bootstrap.provider_hosted_mode !== true || !bootstrap.authenticated) return null; @@ -2909,7 +2915,7 @@ waitForAppliedPlan(target, 0); }, RESTART_SETTLE_MS); } - async function refresh(manual, confirmCurrent = false) { + async function refresh(manual, confirmCurrent = false, billingReturn = false) { var current = account(); if (!current) return false; view.busy = "refresh"; @@ -2923,6 +2929,13 @@ if (confirmCurrent && result.status === "active" && result.plan_version) { var plan = await deps.api.fetchPlan(current.id); if (isApplied(plan, result)) { + if (billingReturn) { + if (!view.plan) { + acceptRunningPlan(plan, false); + return true; + } + if (sameRunningPlan(view.plan, plan)) return false; + } finishApplying(plan); return true; } @@ -2953,7 +2966,7 @@ }); } function checkAfterBillingReturn(attempt) { - void refresh(false).then(function(applied) { + void refresh(false, true, true).then(function(applied) { if (applied || attempt + 1 >= BILLING_RETURN_ATTEMPTS) return; later(function() { checkAfterBillingReturn(attempt + 1); diff --git a/internal/cloudcp/portal/frontend/src/provider_plan.test.ts b/internal/cloudcp/portal/frontend/src/provider_plan.test.ts index d19d9badd7..45a59b6d31 100644 --- a/internal/cloudcp/portal/frontend/src/provider_plan.test.ts +++ b/internal/cloudcp/portal/frontend/src/provider_plan.test.ts @@ -365,6 +365,87 @@ describe('installProviderPlan', () => { expect(toast).not.toHaveBeenCalled(); }); + it('confirms a paid plan already applied by the background refresh on a billing return', async () => { + const oldPlan = evaluationPlan({ + plan_version: 'msp_solo', evaluation: false, license_id: 'lic_old', + workspace_limit: 3, expires_at: '2026-10-10T00:00:00Z', + }); + const newPlan = evaluationPlan({ + plan_version: 'msp_starter', evaluation: false, license_id: 'lic_new', + workspace_limit: 5, expires_at: '2026-11-10T00:00:00Z', + }); + let resolveRefresh!: (result: Awaited>) => void; + api.refreshLicense = vi.fn().mockImplementation(() => new Promise((resolve) => { resolveRefresh = resolve; })); + api.fetchPlan = vi.fn().mockResolvedValueOnce(oldPlan).mockResolvedValueOnce(newPlan); + const { controller } = install(providerBootstrap(), '?provider_msp_checkout=billing'); + await controller.load(); + expect(controller.view().plan).toEqual(oldPlan); + expect(controller.view().notice).toBe(''); + + resolveRefresh({ + status: 'active', changed: false, restart_scheduled: false, + plan_version: newPlan.plan_version, license_id: newPlan.license_id, expires_at: newPlan.expires_at, + }); + await flush(); + expect(controller.view().plan).toEqual(newPlan); + expect(controller.view().notice).toBe('Your Starter plan is active.'); + expect(timers).toHaveLength(0); + expect(toast).not.toHaveBeenCalled(); + }); + + it('does not announce an unchanged paid plan from a Manage billing visit', async () => { + const paid = evaluationPlan({ + plan_version: 'msp_solo', evaluation: false, license_id: 'lic_paid', workspace_limit: 3, + }); + api.fetchPlan = vi.fn().mockResolvedValue(paid); + api.refreshLicense = vi.fn().mockResolvedValue({ + status: 'active', changed: false, restart_scheduled: false, + plan_version: paid.plan_version, license_id: paid.license_id, expires_at: paid.expires_at, + }); + const { controller } = install(providerBootstrap(), '?provider_msp_checkout=billing'); + await controller.load(); + await flush(); + expect(controller.view().notice).toBe(''); + expect(timers).toHaveLength(1); + timers.shift()!(); + await flush(); + expect(controller.view().notice).toBe(''); + expect(controller.view().plan).toEqual(paid); + expect(timers).toHaveLength(0); + }); + + it('confirms a renewed paid period but not an unmatched refresh on a billing return', async () => { + const oldPlan = evaluationPlan({ + plan_version: 'msp_solo', evaluation: false, license_id: 'lic_paid', + workspace_limit: 3, expires_at: '2026-10-10T00:00:00Z', + }); + const renewed = { ...oldPlan, expires_at: '2026-11-10T00:00:00Z' }; + let served = oldPlan; + api.fetchPlan = vi.fn().mockImplementation(() => Promise.resolve(served)); + let resolveRefresh!: (result: Awaited>) => void; + api.refreshLicense = vi.fn().mockImplementationOnce(() => new Promise((resolve) => { resolveRefresh = resolve; })) + .mockResolvedValue({ + status: 'active', changed: false, restart_scheduled: false, + plan_version: renewed.plan_version, license_id: renewed.license_id, expires_at: renewed.expires_at, + }); + const { controller } = install(providerBootstrap(), '?provider_msp_checkout=billing'); + await controller.load(); + resolveRefresh({ + status: 'active', changed: false, restart_scheduled: false, + plan_version: renewed.plan_version, license_id: renewed.license_id, expires_at: renewed.expires_at, + }); + await flush(); + expect(controller.view().plan).toEqual(oldPlan); + expect(controller.view().notice).toBe(''); + expect(timers).toHaveLength(1); + served = renewed; + timers.shift()!(); + await flush(); + expect(controller.view().plan).toEqual(renewed); + expect(controller.view().notice).toBe('Your Solo plan is active.'); + expect(timers).toHaveLength(0); + }); + it('checks a Manage billing return only twice when nothing changed', async () => { api.refreshLicense = vi.fn().mockResolvedValue({ status: 'active', changed: false }); const { controller } = install(providerBootstrap(), '?provider_msp_checkout=billing'); diff --git a/internal/cloudcp/portal/frontend/src/provider_plan.ts b/internal/cloudcp/portal/frontend/src/provider_plan.ts index 384a2f6bd6..47a19f735d 100644 --- a/internal/cloudcp/portal/frontend/src/provider_plan.ts +++ b/internal/cloudcp/portal/frontend/src/provider_plan.ts @@ -259,15 +259,24 @@ export function installProviderPlan(deps: ProviderPlanDeps): ProviderPlanControl (!target.expires_at || plan.expires_at === target.expires_at); } - function finishApplying(plan: ProviderPlanState) { + function sameRunningPlan(left: ProviderPlanState, right: ProviderPlanState): boolean { + return left.evaluation === right.evaluation && left.plan_version === right.plan_version && + left.license_id === right.license_id && left.expires_at === right.expires_at; + } + + function acceptRunningPlan(plan: ProviderPlanState, announce: boolean) { applying = null; - planEpoch += 1; // Ignore plan requests started before the restart completed. + planEpoch += 1; // Ignore requests started before this confirmed plan was served. view.plan = plan; view.error = ''; - view.notice = 'Your ' + providerPlanName(plan.plan_version) + ' plan is active.'; + if (announce) view.notice = 'Your ' + providerPlanName(plan.plan_version) + ' plan is active.'; render(); } + function finishApplying(plan: ProviderPlanState) { + acceptRunningPlan(plan, true); + } + function account(): PortalAccountSummary | null { var bootstrap = deps.store.getBootstrap(); if (bootstrap.provider_hosted_mode !== true || !bootstrap.authenticated) return null; @@ -339,7 +348,7 @@ export function installProviderPlan(deps: ProviderPlanDeps): ProviderPlanControl later(function() { waitForAppliedPlan(target, 0); }, RESTART_SETTLE_MS); } - async function refresh(manual: boolean, confirmCurrent = false): Promise { + async function refresh(manual: boolean, confirmCurrent = false, billingReturn = false): Promise { var current = account(); if (!current) return false; view.busy = 'refresh'; @@ -356,6 +365,16 @@ export function installProviderPlan(deps: ProviderPlanDeps): ProviderPlanControl if (confirmCurrent && result.status === 'active' && result.plan_version) { var plan = await deps.api.fetchPlan(current.id); if (isApplied(plan, result)) { + // A visit to Manage billing is not itself a change. If the + // background refresher applied a new licence before this check, + // replace the older panel; otherwise keep the visit silent. + if (billingReturn) { + if (!view.plan) { + acceptRunningPlan(plan, false); + return true; + } + if (sameRunningPlan(view.plan, plan)) return false; + } finishApplying(plan); return true; } @@ -388,7 +407,7 @@ export function installProviderPlan(deps: ProviderPlanDeps): ProviderPlanControl } function checkAfterBillingReturn(attempt: number) { - void refresh(false).then(function(applied) { + void refresh(false, true, true).then(function(applied) { if (applied || attempt + 1 >= BILLING_RETURN_ATTEMPTS) return; later(function() { checkAfterBillingReturn(attempt + 1); }, APPLY_POLL_MS); }); From ac721a264b56f80d59adcdf6f86474f488c5e222 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 05:07:50 +0100 Subject: [PATCH 3/4] fix(portal): preserve trailing billing return check When the licence check beats the initial Plan load, show a matching running paid plan without interpreting it as a billing change, but retain the second check so a trailing webhook can still apply a later update. Change-source: pulse-maintainer --- .../v6/internal/subsystems/cloud-paid.md | 4 ++- .../cloudcp/portal/dist/build_manifest.json | 2 +- internal/cloudcp/portal/dist/portal_app.js | 2 +- .../portal/frontend/src/provider_plan.test.ts | 27 +++++++++++++++++++ .../portal/frontend/src/provider_plan.ts | 4 ++- 5 files changed, 35 insertions(+), 4 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 7c99da74fe..aaaaaa9b62 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -3498,7 +3498,9 @@ plan changed in the Stripe billing portal applies without a click. It also accepts a matching paid plan already applied by the background refresher without requiring a second restart; an unchanged visit stays silent. A stale panel is replaced only after the refreshed running plan is confirmed, never -because the billing return URL says a change was made. Regression +because the billing return URL says a change was made. When the return check +finishes before the initial plan load, the panel may show that confirmed paid +plan silently but must keep its second check for a later webhook. Regression coverage: `TestLoadConfig_ProviderHostedMSPPrefersRenewedLicense` in `internal/cloudcp/config_test.go`, diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index 90c44f8d4e..58e6172e48 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "294e2ef873914caeb1132876df403b0ef2b101262f00cdaf34fb13d0b3d22c29", + "source_hash": "a630a1a29019df3a176613efe21ad63fd006ea2a531bdb1d4cadc2bbb50b2158", "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 68a7103de2..897d926212 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -2932,7 +2932,7 @@ if (billingReturn) { if (!view.plan) { acceptRunningPlan(plan, false); - return true; + return false; } if (sameRunningPlan(view.plan, plan)) return false; } diff --git a/internal/cloudcp/portal/frontend/src/provider_plan.test.ts b/internal/cloudcp/portal/frontend/src/provider_plan.test.ts index 45a59b6d31..aa5b3148a8 100644 --- a/internal/cloudcp/portal/frontend/src/provider_plan.test.ts +++ b/internal/cloudcp/portal/frontend/src/provider_plan.test.ts @@ -446,6 +446,33 @@ describe('installProviderPlan', () => { expect(timers).toHaveLength(0); }); + it('keeps the second billing check when refresh finishes before the first plan load', async () => { + const oldPlan = evaluationPlan({ + plan_version: 'msp_solo', evaluation: false, license_id: 'lic_old', workspace_limit: 3, + }); + const newPlan = evaluationPlan({ + plan_version: 'msp_starter', evaluation: false, license_id: 'lic_new', workspace_limit: 5, + }); + let served = oldPlan; + api.fetchPlan = vi.fn().mockImplementation(() => Promise.resolve(served)); + api.refreshLicense = vi.fn().mockImplementation(() => Promise.resolve({ + status: 'active', changed: false, restart_scheduled: false, + plan_version: served.plan_version, license_id: served.license_id, expires_at: served.expires_at, + })); + const { controller } = install(providerBootstrap(), '?provider_msp_checkout=billing'); + await flush(); + expect(controller.view().plan).toEqual(oldPlan); + expect(controller.view().notice).toBe(''); + expect(timers).toHaveLength(1); + + served = newPlan; + timers.shift()!(); + await flush(); + expect(controller.view().plan).toEqual(newPlan); + expect(controller.view().notice).toBe('Your Starter plan is active.'); + expect(timers).toHaveLength(0); + }); + it('checks a Manage billing return only twice when nothing changed', async () => { api.refreshLicense = vi.fn().mockResolvedValue({ status: 'active', changed: false }); const { controller } = install(providerBootstrap(), '?provider_msp_checkout=billing'); diff --git a/internal/cloudcp/portal/frontend/src/provider_plan.ts b/internal/cloudcp/portal/frontend/src/provider_plan.ts index 47a19f735d..5664292c35 100644 --- a/internal/cloudcp/portal/frontend/src/provider_plan.ts +++ b/internal/cloudcp/portal/frontend/src/provider_plan.ts @@ -371,7 +371,9 @@ export function installProviderPlan(deps: ProviderPlanDeps): ProviderPlanControl if (billingReturn) { if (!view.plan) { acceptRunningPlan(plan, false); - return true; + // Without an earlier view there is no evidence of a change; + // keep the second check for a webhook trailing the redirect. + return false; } if (sameRunningPlan(view.plan, plan)) return false; } From a631fc8039fceaadee8fffcf34896bb7e0777ce1 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 05:08:47 +0100 Subject: [PATCH 4/4] fix(portal): avoid charge claims from cancelled return Treat checkout return values as navigation hints rather than evidence of a completed or absent charge. Keep the cancelled message conditional and ignore unrecognised values. Change-source: pulse-maintainer --- .../v6/internal/subsystems/cloud-paid.md | 4 +++- internal/cloudcp/portal/dist/build_manifest.json | 2 +- internal/cloudcp/portal/dist/portal_app.js | 4 ++-- .../cloudcp/portal/frontend/src/provider_plan.test.ts | 10 ++++++++-- internal/cloudcp/portal/frontend/src/provider_plan.ts | 4 ++-- 5 files changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index aaaaaa9b62..8d90d9d6e6 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -3500,7 +3500,9 @@ without requiring a second restart; an unchanged visit stays silent. A stale panel is replaced only after the refreshed running plan is confirmed, never because the billing return URL says a change was made. When the return check finishes before the initial plan load, the panel may show that confirmed paid -plan silently but must keep its second check for a later webhook. Regression +plan silently but must keep its second check for a later webhook. A cancelled +or unknown checkout return value must not assert whether a charge occurred. +Regression coverage: `TestLoadConfig_ProviderHostedMSPPrefersRenewedLicense` in `internal/cloudcp/config_test.go`, diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index 58e6172e48..7780992355 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "a630a1a29019df3a176613efe21ad63fd006ea2a531bdb1d4cadc2bbb50b2158", + "source_hash": "d5c69f37d9cf32a2e8ef727af6779bd44148235ed352fcb427967ef8a6a24859", "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 897d926212..bc85e87910 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -3043,8 +3043,8 @@ pollAfterCheckout(0); } else if (returned === "billing") { checkAfterBillingReturn(0); - } else { - view.notice = "Checkout was cancelled. Nothing was charged."; + } else if (returned === "cancelled") { + view.notice = "Checkout was not completed here. Check your current plan below."; } } return { diff --git a/internal/cloudcp/portal/frontend/src/provider_plan.test.ts b/internal/cloudcp/portal/frontend/src/provider_plan.test.ts index aa5b3148a8..bea0801219 100644 --- a/internal/cloudcp/portal/frontend/src/provider_plan.test.ts +++ b/internal/cloudcp/portal/frontend/src/provider_plan.test.ts @@ -485,9 +485,15 @@ describe('installProviderPlan', () => { expect(controller.view().notice).toBe(''); }); - it('says nothing was charged when checkout is cancelled', () => { + it('does not infer a charge outcome from a cancelled checkout return', () => { const { controller } = install(providerBootstrap(), '?provider_msp_checkout=cancelled'); - expect(controller.view().notice).toBe('Checkout was cancelled. Nothing was charged.'); + expect(controller.view().notice).toBe('Checkout was not completed here. Check your current plan below.'); + expect(api.refreshLicense).not.toHaveBeenCalled(); + }); + + it('ignores an unrecognised checkout return value', () => { + const { controller } = install(providerBootstrap(), '?provider_msp_checkout=unknown'); + expect(controller.view().notice).toBe(''); expect(api.refreshLicense).not.toHaveBeenCalled(); }); diff --git a/internal/cloudcp/portal/frontend/src/provider_plan.ts b/internal/cloudcp/portal/frontend/src/provider_plan.ts index 5664292c35..144af167e1 100644 --- a/internal/cloudcp/portal/frontend/src/provider_plan.ts +++ b/internal/cloudcp/portal/frontend/src/provider_plan.ts @@ -488,8 +488,8 @@ export function installProviderPlan(deps: ProviderPlanDeps): ProviderPlanControl pollAfterCheckout(0); } else if (returned === 'billing') { checkAfterBillingReturn(0); - } else { - view.notice = 'Checkout was cancelled. Nothing was charged.'; + } else if (returned === 'cancelled') { + view.notice = 'Checkout was not completed here. Check your current plan below.'; } }