mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 12:42:49 +00:00
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
This commit is contained in:
@@ -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:
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"source_hash": "33ddecf9ec76e0a22e0facf6971b8f0825600e56007db5e7f1f4e2cbe1dd2621",
|
||||
"source_hash": "5a72090fb4e236e040f29fae0e7685c1e5ce21499deaa6cb92f24181562738fd",
|
||||
"build_inputs": [
|
||||
"package.json",
|
||||
"tsconfig.json",
|
||||
|
||||
+14
-7
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<boolean> {
|
||||
async function refresh(manual: boolean, confirmCurrent = false): Promise<boolean> {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user