Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/billing/cloud-capacity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
Expand Down
14 changes: 9 additions & 5 deletions src/billing/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions test/billing-cloud-capacity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
22 changes: 16 additions & 6 deletions test/billing-plans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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');
});
});

Expand Down
Loading