Skip to content

fix: never pitch the current plan when a lowered limit trips the cloud connect gate - #97

Merged
justinhelmer merged 1 commit into
mainfrom
fix/gate-pitch-excludes-current-plan
Sep 15, 2026
Merged

justinhelmer merged 1 commit into
mainfrom
fix/gate-pitch-excludes-current-plan

Conversation

@justinhelmer

Copy link
Copy Markdown
Contributor

On UAT the new cloud connect gate offered a Starter workspace an upgrade to Starter. This PR makes the picker skip the plan the workspace is already on, and anything priced at or below it.

What & why

Found during the UAT validation of #94: with the Coreplane UAT workspace on Starter and its cloud-account limit lowered to 0 in Polaris, cloud connect printed Starter ($80/mo) allows 5 cloud accounts and asked Switch to Starter now? (prorated). cheapestPlanRaising compared each catalog plan's limit against the workspace's own (lowered) limit and never excluded the current plan, so it qualified. A yes would have sent a change-plan request to the same plan. Only reachable when an admin lowers a paid plan's limit, but wrong when it happens.

Tour

1. The picker excludes the current plan and cheaper ones

It now takes the current plan id, looks up that plan's price in the catalog (0 when the catalog does not list it), and drops the current plan plus every plan priced at or below it before the limit comparison. This is the rule upgradeCandidates already applies for subscription upgrade, so both surfaces agree on what counts as up.

cli/src/billing/plans.ts

Lines 78 to 99 in 232dbda

// 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,
currentPlanId: string
): CatalogPlan | null {
const currentPrice = catalog.plans.find((p) => p.id === currentPlanId)?.monthlyPriceCents ?? 0;
const candidates = catalog.plans.filter((plan) => {
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);
return !isUnlimited(currentLimit) && limit > currentLimit;
});
candidates.sort((a, b) => a.monthlyPriceCents - b.monthlyPriceCents);
return candidates[0] ?? null;
}

2. The regression, pinned

The live case as a unit test: Starter with a limit of 0 pitches Team; Team at 0 pitches Scale; Scale at 0 pitches nothing. Plus the gate-level test with the exact stderr lines and the Switch to Team now? (prorated) question.

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, 'free')?.id, 'team');

3. Remaining changes

  • src/billing/cloud-capacity.ts — passes plan.plan.id to the picker.
  • test/billing-cloud-capacity.test.ts — the paying-workspace-with-lowered-limit case.

Validation

  • npm test 503 pass (the two new plans tests and the new gate test failed before the fix), lint clean, typecheck clean against the live spec.
  • Human-gated after release: rerun cloud connect on the same UAT workspace (Starter, limit 0) and see Team ($200/mo) allows unlimited cloud accounts and Switch to Team now? (prorated); answer y and watch the connect continue into the picker.

🤖 Generated with Claude Code

…d connect gate

Seen live on UAT: a Starter workspace whose cloud-account limit an admin had
lowered to 0 was offered "Switch to Starter now? (prorated)". The picker
compared each catalog plan's limit against the workspace's own limit and
never excluded the plan the workspace is already on, so the current plan
qualified as "raising" the lowered number. A yes would have re-priced the
subscription to the same plan.

cheapestPlanRaising now takes the current plan id and skips it along with
every plan priced at or below it, the same rule `subscription upgrade`
already applies to its candidates. The Starter-at-0 case pitches Team.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@coreplane-switchboard coreplane-switchboard Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM: Correct fix: the capacity gate's plan picker now excludes the current plan and anything priced at or below it, mirroring upgradeCandidates; regression pinned by unit and gate tests.

Approve — the fix is correct and well-tested; no findings.

What I checked

  • The fix itself (src/billing/plans.ts:83-99): cheapestPlanRaising now takes currentPlanId, resolves its catalog price (defaulting to $0 for admin-granted plans the catalog doesn't list — the safe direction, since that keeps all paid plans offerable), and filters out the current plan plus anything priced at or below it. This exactly mirrors the pre-existing upgradeCandidates rule for subscription upgrade, so both surfaces now agree on what counts as an upgrade.
  • The one caller (src/billing/cloud-capacity.ts:83): passes plan.plan.id; grep confirms there are no other call sites left on the old signature.
  • The regression scenario: with Starter's limit lowered to 0, the old code compared catalog limits against the workspace limit only, so Starter (limit 5 > 0) qualified and would be pitched to itself. New tests pin the live case (Starter→Team, Team→Scale, Scale→null) at both the unit level (test/billing-plans.test.ts:105-117) and the gate level with exact stderr lines and the prorated-switch prompt (test/billing-cloud-capacity.test.ts:209-220).
  • Edge cases: unlimited-current-limit and non-selectable/free-plan behavior unchanged; existing tests updated only by adding the new argument — no assertions weakened or removed.

No spec directory in this repo, so no spec-contradiction check applied. Clean change.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto-approved: coreplane-switchboard[bot] reviewed this PR and posted an LGTM verdict (see its review). A repo admin enabled this via the auto-approve workflow.

@justinhelmer
justinhelmer merged commit 023fd2d into main Sep 15, 2026
4 checks passed
@justinhelmer
justinhelmer deleted the fix/gate-pitch-excludes-current-plan branch September 15, 2026 01:06
@justinhelmer

Copy link
Copy Markdown
Contributor Author

Release receipt — shipped in v0.2.34 (cut-release 34915958044, release 34915999889, both green). The tag's commit 7a8eb6a sits on the squash-merge 023fd2d of this PR. Published assets validated: checksums.txt matches the downloaded polylane.mjs (sha256 c21835b0…96b3), --version → 0.2.34, anonymous subscription plans prints the live catalog. Still human-gated: the UAT rerun of cloud connect on the Starter workspace with limit 0, expecting the Team pitch and a continued connect on yes; needs a fresh UAT key.

@justinhelmer

Copy link
Copy Markdown
Contributor Author

UAT receipt, live gate yes path on 0.2.34 — Coreplane UAT workspace on Starter with cloud-account limit 0:

Your Starter plan includes 0 cloud accounts; 0 connected.
Team ($200/mo) allows unlimited cloud accounts. All plans: polylane subscription plans
Switch to Team now? (prorated) [y/N] y
Upgraded to Team: unlimited cloud accounts.

The pitch is Team now (0.2.33 offered Starter to itself), the in-place change-plan went through Stripe test mode, the CLI waited for the webhook to flip the plan, then continued straight into the provider picker. subscription show afterwards: Plan Team · Cloud accounts 0 of unlimited. With the earlier decline path (exit 4), this closes the gate's UAT validation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant