mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 12:42:49 +00:00
Merge pull request #2222 from rcourtman/maintainer/20260924T025314Z
Confirm purchased MSP plans only after the new licence is active
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"source_hash": "02b2a130bad03a89d6b916726206d5941bd0b23de5a2475f534aaf69419d7aba",
|
||||
"source_hash": "33ddecf9ec76e0a22e0facf6971b8f0825600e56007db5e7f1f4e2cbe1dd2621",
|
||||
"build_inputs": [
|
||||
"package.json",
|
||||
"tsconfig.json",
|
||||
|
||||
+39
-14
@@ -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) {
|
||||
|
||||
@@ -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<ProviderPlanRefreshResult>(accountURL(accountID, '/provider-msp/license/refresh'), {
|
||||
method: 'POST',
|
||||
}, 'The licence could not be refreshed right now.');
|
||||
},
|
||||
|
||||
@@ -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<ProviderPlanState>((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');
|
||||
|
||||
@@ -198,7 +198,16 @@ export interface ProviderPlanAPI {
|
||||
fetchPlan(accountID: string): Promise<ProviderPlanState>;
|
||||
startCheckout(accountID: string, planVersion: string, billingCycle: string): Promise<{ url?: string }>;
|
||||
openBillingPortal(accountID: string): Promise<{ url?: string }>;
|
||||
refreshLicense(accountID: string): Promise<{ status?: string; changed?: boolean; restart_scheduled?: boolean }>;
|
||||
refreshLicense(accountID: string): Promise<ProviderPlanRefreshResult>;
|
||||
}
|
||||
|
||||
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<boolean> {
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user