diff --git a/src/billing/cloud-capacity.ts b/src/billing/cloud-capacity.ts index 8959407..f3b2d10 100644 --- a/src/billing/cloud-capacity.ts +++ b/src/billing/cloud-capacity.ts @@ -80,7 +80,7 @@ export async function ensureCloudAccountCapacity( let next: CatalogPlan | null = null; try { - next = cheapestPlanRaising(await deps.fetchCatalog(config), 'maxCloudAccounts', plan.usage.cloudAccounts.limit); + next = cheapestPlanRaising(await deps.fetchCatalog(config), 'maxCloudAccounts', plan.usage.cloudAccounts.limit, plan.plan.id); } catch { throw new CLIError(limitLine(plan), ExitCode.QUOTA, `Plans: ${PRICING_URL}\n${UPGRADE_LATER}`); } diff --git a/src/billing/plans.ts b/src/billing/plans.ts index 8c72c3b..fa10c30 100644 --- a/src/billing/plans.ts +++ b/src/billing/plans.ts @@ -75,16 +75,20 @@ export function isPurchasable(plan: CatalogPlan): boolean { return plan.selectable && plan.monthlyPriceCents > 0; } -// The cheapest purchasable plan that lifts `dimension` above `currentLimit`, -// or null when no plan on sale does (the workspace is already on the top -// tier for that dimension, or the catalog is empty). +// The cheapest purchasable plan above the current one that lifts `dimension` +// past `currentLimit`, or null when none does (the workspace is on the top +// tier for that dimension, or the catalog is empty). The current plan is +// never a candidate: an admin-lowered limit on a paid plan must not pitch the +// plan the workspace already pays for. export function cheapestPlanRaising( catalog: PlanCatalog, dimension: keyof PlanLimits & string, - currentLimit: number + currentLimit: number, + currentPlanId: string ): CatalogPlan | null { + const currentPrice = catalog.plans.find((p) => p.id === currentPlanId)?.monthlyPriceCents ?? 0; const candidates = catalog.plans.filter((plan) => { - if (!isPurchasable(plan)) return false; + if (!isPurchasable(plan) || plan.id === currentPlanId || plan.monthlyPriceCents <= currentPrice) return false; const limit = plan.limits[dimension]; if (limit === undefined) return false; if (isUnlimited(limit)) return !isUnlimited(currentLimit); diff --git a/test/billing-cloud-capacity.test.ts b/test/billing-cloud-capacity.test.ts index 702eddf..1ee4f75 100644 --- a/test/billing-cloud-capacity.test.ts +++ b/test/billing-cloud-capacity.test.ts @@ -206,6 +206,18 @@ describe('ensureCloudAccountCapacity', () => { assert.deepEqual(h.checkouts, [{ mode: 'change', plan: 'team', cycle: 'annual', previousPlanId: 'starter', noBrowser: false }]); }); + it('a paying workspace whose limit an admin lowered is pitched the next plan up, never its own', async () => { + const h = harness({ + plan: usage(0, 0, 'starter'), + subscription: { plan: 'starter', status: 'active', billingCycle: 'monthly', stripeSubscriptionId: 'sub_1' }, + answer: false, + }); + assert.equal(await ensureCloudAccountCapacity(config, 'ws_1', { noBrowser: false }, h.deps), 'declined'); + assert.match(errOut.join(''), /Your Starter plan includes 0 cloud accounts; 0 connected\./); + assert.match(errOut.join(''), /Team \(\$200\/mo\) allows unlimited cloud accounts\./); + assert.deepEqual(h.confirms, ['Switch to Team now? (prorated)']); + }); + it('exits QUOTA pointing at the pricing page when the catalog is unavailable', async () => { const h = harness({ plan: usage(2, 2), catalog: new Error('down') }); await assert.rejects( diff --git a/test/billing-plans.test.ts b/test/billing-plans.test.ts index a9bc7d6..89320f0 100644 --- a/test/billing-plans.test.ts +++ b/test/billing-plans.test.ts @@ -77,20 +77,20 @@ describe('capacityOf', () => { describe('cheapestPlanRaising', () => { it('picks the cheapest paid plan with a higher limit', () => { - assert.equal(cheapestPlanRaising(catalog, 'maxCloudAccounts', 2)?.id, 'starter'); + assert.equal(cheapestPlanRaising(catalog, 'maxCloudAccounts', 2, 'free')?.id, 'starter'); }); it('skips plans that do not raise the limit', () => { - assert.equal(cheapestPlanRaising(catalog, 'maxCloudAccounts', 5)?.id, 'team'); + assert.equal(cheapestPlanRaising(catalog, 'maxCloudAccounts', 5, 'starter')?.id, 'team'); }); it('returns null when the current limit is already unlimited', () => { - assert.equal(cheapestPlanRaising(catalog, 'maxCloudAccounts', -1), null); + assert.equal(cheapestPlanRaising(catalog, 'maxCloudAccounts', -1, 'team'), null); }); it('never offers Free or Enterprise', () => { const only = { ...catalog, plans: catalog.plans.filter((p) => p.monthlyPriceCents === 0) }; - assert.equal(cheapestPlanRaising(only, 'maxCloudAccounts', 0), null); + assert.equal(cheapestPlanRaising(only, 'maxCloudAccounts', 0, 'free'), null); }); it('follows the catalog when the free limit moves', () => { @@ -99,12 +99,22 @@ describe('cheapestPlanRaising', () => { plans: catalog.plans.map((p) => (p.id === 'starter' ? { ...p, limits: { ...p.limits, maxCloudAccounts: 1 } } : p)), }; // Starter no longer raises a limit of 1, so the next tier is offered. - assert.equal(cheapestPlanRaising(moved, 'maxCloudAccounts', 1)?.id, 'team'); + assert.equal(cheapestPlanRaising(moved, 'maxCloudAccounts', 1, 'free')?.id, 'team'); + }); + + it('never pitches the plan the workspace is already on, even when an admin lowered its limit below the catalog default', () => { + // Seen live on UAT: Starter with maxCloudAccounts lowered to 0 was offered Starter. + assert.equal(cheapestPlanRaising(catalog, 'maxCloudAccounts', 0, 'starter')?.id, 'team'); + }); + + it('never pitches a plan priced at or below the current one', () => { + assert.equal(cheapestPlanRaising(catalog, 'maxCloudAccounts', 0, 'team')?.id, 'scale'); + assert.equal(cheapestPlanRaising(catalog, 'maxCloudAccounts', 0, 'scale'), null); }); it('ignores plans that are not selectable', () => { const hidden = { ...catalog, plans: catalog.plans.map((p) => (p.id === 'starter' ? { ...p, selectable: false } : p)) }; - assert.equal(cheapestPlanRaising(hidden, 'maxCloudAccounts', 2)?.id, 'team'); + assert.equal(cheapestPlanRaising(hidden, 'maxCloudAccounts', 2, 'free')?.id, 'team'); }); });