From eaf19ea86a7a88a389b949385e9177a4d1ac2fdd Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:17:45 -0700 Subject: [PATCH 01/12] Pricing: Pro $50, Team $60, plan machine 5 vCPU / 20 GB / 200 GB, 50 VMs Stripe Price amounts are immutable, so the four current checkout prices get new lookup keys that carry the amount (cmux-pro-monthly-50, cmux-pro-yearly-480, cmux-team-monthly-60, cmux-team-yearly-576) and the old keys become LEGACY_PRICE_LOOKUP_KEYS: still active in Stripe for the subscriptions grandfathered on them, never used by a new checkout. Price-id env overrides follow the same rule; every retired name now fails env validation instead of silently pinning checkout to an old Price. /dashboard/billing prices each subscription from its own Stripe amount and interval, so grandfathered rows (including the Stack-era $30 Prices with no lookup key) render their real figure without a per-key copy table. The plan machine now lives in web/services/vms/machineSpec.ts: 20 GB memory, one vCPU per 4 GB (so 5), a 200 GB disk, and 50 active machines per billing team for every paid plan (previously uncapped in code, 10 via a prod env brake). Freestyle boots every VM at its snapshot's resources (the devbox snapshot is 2 vCPU / 4 GB / 16 GB) and CreateVmOptions has no size, so the driver now grows a fresh VM to the plan machine before bootstrapping it; an undersized machine rolls back instead of shipping. The pricing copy (en, ja) states the spec and is pinned to the constants by test. Mac app: size picker and CLI presets top out at the 20 GB plan machine, the upgrade card and empty-state copy stop promising unlimited machines. The DEBUG-only native pricing preview loses its compute-hour rate table. Claude-Session: https://claude.ai/code/session_01YKXxKXemYnpAhSyZ4uaVhF --- CLI/CMUXCLI+VMTransfer.swift | 4 +- CLI/cmux.swift | 6 +- .../Account/ProUpgradeCard.swift | 2 +- Resources/Localizable.xcstrings | 57 ++++++--- Sources/Cloud/MachinesPanelView.swift | 12 +- Sources/Cloud/NewMachineModel.swift | 15 ++- Sources/PricingPlansScreen.swift | 64 ++-------- cmuxTests/NewMachineModelTests.swift | 20 +-- skills/cmux-billing/SKILL.md | 6 +- web/.env.example | 16 ++- web/app/[locale]/dashboard/billing/page.tsx | 57 ++++----- web/app/[locale]/pricing/page.tsx | 6 +- web/app/app-pricing/page.tsx | 6 +- web/app/env.ts | 33 ++++- web/messages/en.json | 36 ++++-- web/messages/ja.json | 36 ++++-- web/scripts/stripe/provision-catalog.sh | 20 ++- web/services/billing/plans.ts | 41 ++++-- web/services/billing/stripe.ts | 8 +- web/services/vms/README.md | 2 +- web/services/vms/drivers/freestyle.ts | 66 +++++++++- web/services/vms/entitlements.ts | 57 ++++++--- web/services/vms/machineSpec.ts | 29 +++++ web/tests/app-pricing-page.test.tsx | 17 +-- web/tests/client-config-env.test.ts | 33 ++++- web/tests/dashboard-billing-page.test.tsx | 77 +++++++---- web/tests/pricing-page.test.tsx | 25 ++-- web/tests/pro-pricing.test.ts | 120 +++++++++++++++--- web/tests/stripe-provision-catalog.test.ts | 44 ++++++- web/tests/vm-billing-limit-paywall.test.ts | 52 +++++--- web/tests/vm-freestyle-provider.test.ts | 40 ++++++ web/tests/vm-route-auth.test.ts | 8 +- 32 files changed, 701 insertions(+), 314 deletions(-) create mode 100644 web/services/vms/machineSpec.ts diff --git a/CLI/CMUXCLI+VMTransfer.swift b/CLI/CMUXCLI+VMTransfer.swift index 70a06fc6533f..ed62082a533e 100644 --- a/CLI/CMUXCLI+VMTransfer.swift +++ b/CLI/CMUXCLI+VMTransfer.swift @@ -605,7 +605,7 @@ extension CMUXCLI { static var vmRunUsage: String { """ - Usage: cmux vm run [--sync] [--pull ] [--machine ] [--new] [--size <2g|4g|8g|16g|24g|32g>] [--timeout ] -- + Usage: cmux vm run [--sync] [--pull ] [--machine ] [--new] [--size <2g|4g|8g|16g|20g>] [--timeout ] -- Run a command on a cloud machine without naming one: reuses an idle machine the router itself provisioned earlier (shown as "\(vmRunPoolLabel)" @@ -1090,7 +1090,7 @@ extension CMUXCLI { extension CMUXCLI { static var vmRouteUsage: String { """ - Usage: cmux vm route [--cwd ] [--new] [--provision] [--size <2g|4g|8g|16g|24g|32g>] [--json] + Usage: cmux vm route [--cwd ] [--new] [--provision] [--size <2g|4g|8g|16g|20g>] [--json] Print the machine `cmux vm run` / `cmux vm agent` would use for work in a directory, and why — without running anything. The policy is the router's diff --git a/CLI/cmux.swift b/CLI/cmux.swift index a2ece07109d9..916416a48c99 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -4294,14 +4294,14 @@ struct CMUXCLI { // never pins an image id unless the person passes `--image`: a pinned id // that drifted from the web deploy's manifest failed every create with // `vm_image_config_error`. - /// `--size` spellings → memory in MB. vCPUs scale with memory. + /// `--size` spellings → memory in MB. vCPUs scale with memory (one per + /// 4 GB); 20g is the plan machine and every plan's ceiling. private static let cloudVMSizeAliases: [String: Int] = [ "2g": 2048, "2gb": 2048, "small": 2048, "4g": 4096, "4gb": 4096, "medium": 4096, "8g": 8192, "8gb": 8192, "large": 8192, "16g": 16384, "16gb": 16384, "xl": 16384, - "24g": 24576, "24gb": 24576, - "32g": 32768, "32gb": 32768, "xxl": 32768, + "20g": 20480, "20gb": 20480, "xxl": 20480, ] static func parseCloudVMSize(_ raw: String) -> Int? { let key = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/ProUpgradeCard.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/ProUpgradeCard.swift index 92096328a1d6..e8d3f2341436 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/ProUpgradeCard.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/ProUpgradeCard.swift @@ -70,7 +70,7 @@ struct ProUpgradeCard: View { } return String( localized: "settings.account.pro.subtitle", - defaultValue: "Cloud dev boxes, the iOS app, and cmux AI. $30/month, or $288/year." + defaultValue: "Cloud dev boxes, the iOS app, and cmux AI. $50/month, or $480/year." ) } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index e2f33896f540..a0895d403738 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -126948,13 +126948,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Subscribe to cmux Pro for unlimited machines" + "value": "Subscribe to cmux Pro to create Cloud machines" } }, "ja": { "stringUnit": { "state": "translated", - "value": "cmux Pro に登録するとマシンを無制限に利用できます" + "value": "cmux Pro に登録すると Cloud マシンを作成できます" } } } @@ -149068,13 +149068,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "20 hrs/mo, then usage-based" + "value": "Included" } }, "ja": { "stringUnit": { "state": "translated", - "value": "月 20 時間、その後は従量課金" + "value": "含まれます" } } } @@ -149085,13 +149085,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Pooled, usage-based" + "value": "Included" } }, "ja": { "stringUnit": { "state": "translated", - "value": "共有、従量課金" + "value": "含まれます" } } } @@ -149130,6 +149130,23 @@ } } }, + "pricing.native.compare.concurrent.paid": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "50" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "50" + } + } + } + }, "pricing.native.compare.custom": { "extractionState": "manual", "localizations": { @@ -149850,13 +149867,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Model gateway with usage and cost analytics" + "value": "Unlimited workspaces" } }, "ja": { "stringUnit": { "state": "translated", - "value": "使用量とコスト分析付きのモデルゲートウェイ" + "value": "ワークスペースは無制限" } } } @@ -149867,13 +149884,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "20 active compute-hours per month, then usage-based" + "value": "Up to 50 Cloud VMs, each with 5 vCPU, 20 GB RAM, and 200 GB disk" } }, "ja": { "stringUnit": { "state": "translated", - "value": "月20時間のアクティブ計算時間、その後は従量課金" + "value": "最大 50 台の Cloud VM、各 5 vCPU / 20 GB RAM / 200 GB ディスク" } } } @@ -149918,13 +149935,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "$30" + "value": "$50" } }, "ja": { "stringUnit": { "state": "translated", - "value": "$30" + "value": "$50" } } } @@ -150088,13 +150105,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Pick a VM size per agent. You are billed per active compute-hour, and idle VMs suspend automatically. Pro includes 20 hours per month on the 4 vCPU / 16 GB size." + "value": "Every Cloud VM is 5 vCPU, 20 GB RAM, and 200 GB disk. Pro and Team include up to 50 machines with no metering or overages." } }, "ja": { "stringUnit": { "state": "translated", - "value": "エージェントごとに VM サイズを選びます。課金はアクティブなコンピュート時間単位で、アイドル状態の VM は自動停止します。Pro には 4 vCPU / 16 GB サイズで月 20 時間が含まれます。" + "value": "すべての Cloud VM は 5 vCPU / 20 GB RAM / 200 GB ディスクです。Pro と Team には最大 50 台のマシンが含まれ、従量課金や超過料金はありません。" } } } @@ -150275,13 +150292,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Pooled Cloud VM compute hours" + "value": "The same Cloud VM allowance as Pro" } }, "ja": { "stringUnit": { "state": "translated", - "value": "共有 Cloud VM コンピュート時間" + "value": "Pro と同じ Cloud VM 枠" } } } @@ -150343,13 +150360,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "$35" + "value": "$60" } }, "ja": { "stringUnit": { "state": "translated", - "value": "$35" + "value": "$60" } } } @@ -158176,13 +158193,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Cloud dev boxes, the iOS app, and cmux AI. $30/month, or $288/year." + "value": "Cloud dev boxes, the iOS app, and cmux AI. $50/month, or $480/year." } }, "ja": { "stringUnit": { "state": "translated", - "value": "クラウド開発マシン、iOSアプリ、cmux AI。月額$30、または年額$288。" + "value": "クラウド開発マシン、iOSアプリ、cmux AI。月額$50、または年額$480。" } } } diff --git a/Sources/Cloud/MachinesPanelView.swift b/Sources/Cloud/MachinesPanelView.swift index 38ff5c0e46c5..3276a232749f 100644 --- a/Sources/Cloud/MachinesPanelView.swift +++ b/Sources/Cloud/MachinesPanelView.swift @@ -523,12 +523,13 @@ struct MachinesPanelView: View { /// Free plans: "Upgrade to use more than 1 machine" — the ceiling plus the /// way past it in one line. A plan with no machines at all has no ceiling - /// to cite: upgrading is what grants access in the first place. + /// to cite: upgrading is what grants access in the first place (the paid + /// allowance itself is stated on /pricing, not guessed here). private func upgradeNudgeLabel(_ plan: MachinePlanSnapshot) -> String { guard let maxActiveVms = plan.maxActiveVms, maxActiveVms > 0 else { return String( localized: "machines.empty.upgrade.none", - defaultValue: "Subscribe to cmux Pro for unlimited machines" + defaultValue: "Subscribe to cmux Pro to create Cloud machines" ) } if plan.isSingleMachinePlan { @@ -543,9 +544,10 @@ struct MachinesPanelView: View { ) } - /// Paid plans: "Your plan includes unlimited machines" under the create - /// button, so the empty state answers "what do I get" before the meter - /// shows a count. + /// Paid plans: "Your plan includes 50 machines" under the create button, + /// so the empty state answers "what do I get" before the meter shows a + /// count. The uncapped wording only appears when an operator lifted the + /// cap. private func planIncludesLabel(_ plan: MachinePlanSnapshot) -> String { guard let maxActiveVms = plan.maxActiveVms else { return String( diff --git a/Sources/Cloud/NewMachineModel.swift b/Sources/Cloud/NewMachineModel.swift index 574dbd3066f7..aaf5cab893ab 100644 --- a/Sources/Cloud/NewMachineModel.swift +++ b/Sources/Cloud/NewMachineModel.swift @@ -32,15 +32,18 @@ final class NewMachineModel { /// Memory sizes the backend accepts (`VM_MEMORY_OPTIONS_MB` in /// `web/services/vms/entitlements.ts`); the plan ceiling trims the tail. - static let memoryOptionsMb: [Int] = [2048, 4096, 8192, 16384, 24576, 32768] - /// Mirrors `maxMemoryMbForPlan`: the free machine is a full-size computer; - /// paid plans unlock the largest size. + static let memoryOptionsMb: [Int] = [2048, 4096, 8192, 16384, planMachineMemoryMb] + /// The plan machine (`PLAN_MACHINE_MEMORY_MB`): 20 GB, 5 vCPU, 200 GB disk. + static let planMachineMemoryMb = 20480 + /// Mirrors `maxMemoryMbForPlan`: every plan, free included, gets the plan + /// machine; the paywall is machine count and the free access window. static func maxMemoryMb(planId: String?) -> Int { - planId == nil || planId == "free" ? 24576 : 32768 + _ = planId + return planMachineMemoryMb } - /// Mirrors `defaultMemoryMbForPlan`: 24 GB, never above the plan's max. + /// Mirrors `defaultMemoryMbForPlan`: the plan machine, never above the max. static func defaultMemoryMb(planId: String?) -> Int { - min(24576, maxMemoryMb(planId: planId)) + min(planMachineMemoryMb, maxMemoryMb(planId: planId)) } let mode: Mode diff --git a/Sources/PricingPlansScreen.swift b/Sources/PricingPlansScreen.swift index 5114ac6f5674..07ad75136aa2 100644 --- a/Sources/PricingPlansScreen.swift +++ b/Sources/PricingPlansScreen.swift @@ -409,7 +409,7 @@ private struct NativePricingPlansView: View { ) NativePricingPlanCard( name: String(localized: "pricing.native.plan.pro", defaultValue: "Pro"), - price: String(localized: "pricing.native.pro.price", defaultValue: "$30"), + price: String(localized: "pricing.native.pro.price", defaultValue: "$50"), period: String(localized: "pricing.native.period.month", defaultValue: "/month"), isCurrent: snapshot.isPro, actionTitle: proActionTitle, @@ -417,14 +417,14 @@ private struct NativePricingPlansView: View { isProminent: true, features: [ String(localized: "pricing.native.pro.feature.vms", defaultValue: "Cloud agents on isolated Cloud VMs"), - String(localized: "pricing.native.pro.feature.hours", defaultValue: "20 active compute-hours per month, then usage-based"), - String(localized: "pricing.native.pro.feature.gateway", defaultValue: "Model gateway with usage and cost analytics"), + String(localized: "pricing.native.pro.feature.hours", defaultValue: "Up to 50 Cloud VMs, each with 5 vCPU, 20 GB RAM, and 200 GB disk"), + String(localized: "pricing.native.pro.feature.gateway", defaultValue: "Unlimited workspaces"), String(localized: "pricing.native.pro.feature.ios", defaultValue: "cmux iOS app and email support"), ] ) NativePricingPlanCard( name: String(localized: "pricing.native.plan.team", defaultValue: "Team"), - price: String(localized: "pricing.native.team.price", defaultValue: "$35"), + price: String(localized: "pricing.native.team.price", defaultValue: "$60"), period: String(localized: "pricing.native.period.userMonth", defaultValue: "/user/month"), isCurrent: false, actionTitle: String(localized: "pricing.native.team.cta", defaultValue: "Get Teams"), @@ -432,7 +432,7 @@ private struct NativePricingPlansView: View { features: [ String(localized: "pricing.native.team.feature.billing", defaultValue: "Unified billing for the whole team"), String(localized: "pricing.native.team.feature.seats", defaultValue: "Centralized seat management"), - String(localized: "pricing.native.team.feature.compute", defaultValue: "Pooled Cloud VM compute hours"), + String(localized: "pricing.native.team.feature.compute", defaultValue: "The same Cloud VM allowance as Pro"), String(localized: "pricing.native.team.feature.gateway", defaultValue: "Team-wide model gateway analytics"), String(localized: "pricing.native.team.feature.support", defaultValue: "Priority email support"), ] @@ -597,16 +597,16 @@ private struct NativePricingComparisonSection: View { id: "cloud", label: String(localized: "pricing.native.compare.cloud", defaultValue: "Cloud agents on Cloud VMs"), free: .text(String(localized: "pricing.native.compare.cloud.free", defaultValue: "1 VM trial")), - pro: .text(String(localized: "pricing.native.compare.cloud.pro", defaultValue: "20 hrs/mo, then usage-based")), - team: .text(String(localized: "pricing.native.compare.cloud.team", defaultValue: "Pooled, usage-based")), + pro: .text(String(localized: "pricing.native.compare.cloud.pro", defaultValue: "Included")), + team: .text(String(localized: "pricing.native.compare.cloud.team", defaultValue: "Included")), enterprise: .text(String(localized: "pricing.native.compare.cloud.enterprise", defaultValue: "Committed usage")) ), NativePricingCompareRow( id: "concurrent", label: String(localized: "pricing.native.compare.concurrent", defaultValue: "Concurrent Cloud VMs"), free: .text(String(localized: "pricing.native.compare.concurrent.free", defaultValue: "1")), - pro: .text(String(localized: "pricing.native.compare.usageBased", defaultValue: "Usage-based")), - team: .text(String(localized: "pricing.native.compare.usageBased", defaultValue: "Usage-based")), + pro: .text(String(localized: "pricing.native.compare.concurrent.paid", defaultValue: "50")), + team: .text(String(localized: "pricing.native.compare.concurrent.paid", defaultValue: "50")), enterprise: .text(String(localized: "pricing.native.compare.custom", defaultValue: "Custom")) ), NativePricingCompareRow( @@ -759,35 +759,7 @@ private struct NativePricingTableCell: View { } } -private struct NativePricingVMSizeRow: Identifiable { - let id: String - let size: String - let use: String - let rate: String -} - private struct NativePricingSizeSection: View { - private let rows: [NativePricingVMSizeRow] = [ - NativePricingVMSizeRow( - id: "small", - size: "2 vCPU / 8 GB", - use: String(localized: "pricing.native.size.small.use", defaultValue: "Light agents and quick tasks"), - rate: String(localized: "pricing.native.size.small.rate", defaultValue: "$0.20") - ), - NativePricingVMSizeRow( - id: "medium", - size: "4 vCPU / 16 GB", - use: String(localized: "pricing.native.size.medium.use", defaultValue: "Standard development"), - rate: String(localized: "pricing.native.size.medium.rate", defaultValue: "$0.40") - ), - NativePricingVMSizeRow( - id: "large", - size: "8 vCPU / 32 GB", - use: String(localized: "pricing.native.size.large.use", defaultValue: "Heavy builds and parallel agents"), - rate: String(localized: "pricing.native.size.large.rate", defaultValue: "$0.80") - ) - ] - var body: some View { VStack(alignment: .leading, spacing: 10) { Text(String(localized: "pricing.native.sizes.title", defaultValue: "Cloud VM sizes")) @@ -795,26 +767,10 @@ private struct NativePricingSizeSection: View { .foregroundStyle(.secondary) Text(String( localized: "pricing.native.sizes.body", - defaultValue: "Pick a VM size per agent. You are billed per active compute-hour, and idle VMs suspend automatically. Pro includes 20 hours per month on the 4 vCPU / 16 GB size." + defaultValue: "Every Cloud VM is 5 vCPU, 20 GB RAM, and 200 GB disk. Pro and Team include up to 50 machines with no metering or overages." )) .font(.system(size: 13)) .foregroundStyle(.secondary) - VStack(spacing: 0) { - HStack(spacing: 0) { - NativePricingTableCell(text: String(localized: "pricing.native.sizes.colSize", defaultValue: "Size"), width: 180, isHeader: true) - NativePricingTableCell(text: String(localized: "pricing.native.sizes.colUse", defaultValue: "Best for"), width: 560, isHeader: true) - NativePricingTableCell(text: String(localized: "pricing.native.sizes.colRate", defaultValue: "Per active hour"), width: 180, isHeader: true) - } - .background(Color(nsColor: .controlBackgroundColor).opacity(0.7)) - ForEach(rows) { row in - HStack(spacing: 0) { - NativePricingTableCell(text: row.size, width: 180) - NativePricingTableCell(text: row.use, width: 560) - NativePricingTableCell(text: row.rate, width: 180) - } - } - } - .overlay(Rectangle().stroke(Color(nsColor: .separatorColor).opacity(0.55))) } } } diff --git a/cmuxTests/NewMachineModelTests.swift b/cmuxTests/NewMachineModelTests.swift index 322855afd294..c9c9ed62c9ac 100644 --- a/cmuxTests/NewMachineModelTests.swift +++ b/cmuxTests/NewMachineModelTests.swift @@ -81,7 +81,7 @@ final class NewMachineModelTests: XCTestCase { func testDefaultInvocationRequestsDesktopByKindWithoutPinningAnImage() { let (model, _) = makeModel() - XCTAssertEqual(model.cliArguments, ["vm", "new", "--desktop", "--size", "24576"]) + XCTAssertEqual(model.cliArguments, ["vm", "new", "--desktop", "--size", "20480"]) XCTAssertFalse(model.cliArguments.contains("--image")) } @@ -115,21 +115,21 @@ final class NewMachineModelTests: XCTestCase { // MARK: Plan ceilings - func testFreePlanCapsSizeAtTwentyFourGigabytes() { + func testFreePlanGetsThePlanMachine() { let (model, _) = makeModel(plan: MachinePlanSnapshot(activeCount: 0, maxActiveVms: 1, planId: "free")) - XCTAssertEqual(model.memoryOptions, [2048, 4096, 8192, 16384, 24576]) - XCTAssertEqual(model.memoryMb, 24576) + XCTAssertEqual(model.memoryOptions, [2048, 4096, 8192, 16384, 20480]) + XCTAssertEqual(model.memoryMb, 20480) } - func testPaidPlanUnlocksThirtyTwoGigabytesButDefaultsToTwentyFour() { - let (model, _) = makeModel(plan: MachinePlanSnapshot(activeCount: 2, maxActiveVms: 5, planId: "pro")) - XCTAssertEqual(model.memoryOptions.last, 32768) - XCTAssertEqual(model.memoryMb, 24576) + func testPaidPlanGetsThePlanMachineAndDefaultsToIt() { + let (model, _) = makeModel(plan: MachinePlanSnapshot(activeCount: 2, maxActiveVms: 50, planId: "pro")) + XCTAssertEqual(model.memoryOptions.last, 20480) + XCTAssertEqual(model.memoryMb, 20480) } - func testUnknownPlanUsesTheFreeCeiling() { + func testUnknownPlanUsesThePlanMachineCeiling() { let (model, _) = makeModel(plan: nil) - XCTAssertEqual(model.memoryOptions.last, 24576) + XCTAssertEqual(model.memoryOptions.last, 20480) XCTAssertNil(model.planMeterText) XCTAssertNil(model.freeAccessNoteText) } diff --git a/skills/cmux-billing/SKILL.md b/skills/cmux-billing/SKILL.md index 0202e8a27c55..090ded23a12a 100644 --- a/skills/cmux-billing/SKILL.md +++ b/skills/cmux-billing/SKILL.md @@ -26,9 +26,11 @@ Read before changing billing, pricing, Stripe, Pro entitlement, checkout, webhoo - Fake payment, two ways: `web/scripts/stripe/dev-grant.sh ` writes `cmuxPlan: "pro"` directly (instant, no checkout; undo with dev-reset). For the full checkout path at $0, enter promotion code `CMUXDEV100` in test-mode checkout — a 100%-off forever coupon in the test account; `allow_promotion_codes` is already set on checkout sessions. - Newer Stripe CLI prints `stripe config --list` as `key=value` (older builds used `key = 'value'`); dev-stack.sh and dev-reset.sh accept both. If key extraction fails, re-run `stripe login`. -## Test-mode resources +## Catalog -Product `prod_UpIQRE6cj0nFjs`. New checkouts use `cmux-pro-monthly` ($30/mo) and `cmux-pro-yearly-288` ($288/yr, equivalent to $24/mo). Keep `cmux-pro-yearly` ($240/yr) active for grandfathered subscriptions. Staging webhook endpoint `we_1Tq1SZGhInAdn3JbWJReKNEN` forwards to `cmux-staging.vercel.app`; its secrets are already in the `cmux-staging` Vercel project. +Prices live in `web/services/billing/plans.ts` and are provisioned by `web/scripts/stripe/provision-catalog.sh`. Current checkout keys: `cmux-pro-monthly-50` ($50/mo), `cmux-pro-yearly-480` ($480/yr, $40/mo equivalent), `cmux-team-monthly-60` ($60/user/mo), `cmux-team-yearly-576` ($576/user/yr, $48/mo equivalent). Stripe amounts are immutable, so a price change mints a new lookup key carrying the amount and adds the old key to `LEGACY_PRICE_LOOKUP_KEYS`; grandfathered Prices (`cmux-pro-monthly` $30, `cmux-pro-yearly` $240, `cmux-pro-yearly-288` $288, `cmux-team-monthly` $35, `cmux-team-yearly-336` $336) stay active for the subscriptions on them and `/dashboard/billing` prices every row from its own Stripe amount. Env price-id overrides carry the amount in their name (`STRIPE_PRO_MONTHLY_50_PRICE_ID` and friends); a retired name fails env validation, so delete it from Vercel before deploying a price change. Test-mode Pro product `prod_UyHgRPpmCzrkLJ`, live `prod_Uq4a28vk0fP3E6`. Staging webhook endpoint `we_1Tq1SZGhInAdn3JbWJReKNEN` forwards to `cmux-staging.vercel.app`; its secrets are already in the `cmux-staging` Vercel project. + +The plan machine (50 machines per billing team, 20 GB / 5 vCPU / 200 GB disk) lives in `web/services/vms/machineSpec.ts`; `web/tests/pro-pricing.test.ts` pins the pricing copy to those constants. ## Feature flags diff --git a/web/.env.example b/web/.env.example index 44364a2a097c..e5557cb6272e 100644 --- a/web/.env.example +++ b/web/.env.example @@ -38,13 +38,15 @@ CMUX_PRO_FROM_EMAIL= # prices by lookup key in Stripe test/live mode. STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= -STRIPE_PRO_MONTHLY_PRICE_ID= -# Retired. Keep unset; grandfathered $240 subscriptions remain in Stripe, while -# new annual checkout overrides use STRIPE_PRO_YEARLY_288_PRICE_ID. -STRIPE_PRO_YEARLY_PRICE_ID= -STRIPE_PRO_YEARLY_288_PRICE_ID= -STRIPE_TEAM_MONTHLY_PRICE_ID= -STRIPE_TEAM_YEARLY_PRICE_ID= +# Override names carry the amount. Retired names (STRIPE_PRO_MONTHLY_PRICE_ID, +# STRIPE_PRO_YEARLY_PRICE_ID, STRIPE_PRO_YEARLY_288_PRICE_ID, +# STRIPE_TEAM_MONTHLY_PRICE_ID, STRIPE_TEAM_YEARLY_PRICE_ID) fail validation +# when set; their grandfathered Prices stay active in Stripe for existing +# subscriptions only. +STRIPE_PRO_MONTHLY_50_PRICE_ID= +STRIPE_PRO_YEARLY_480_PRICE_ID= +STRIPE_TEAM_MONTHLY_60_PRICE_ID= +STRIPE_TEAM_YEARLY_576_PRICE_ID= # Optional local app checkout relay. Only loopback requests can preserve tagged # callback schemes. Source and destination must share the 32+ character secret. CMUX_APP_PRICING_CHECKOUT_URL= diff --git a/web/app/[locale]/dashboard/billing/page.tsx b/web/app/[locale]/dashboard/billing/page.tsx index 9bd32b0dbe08..edc978a098b9 100644 --- a/web/app/[locale]/dashboard/billing/page.tsx +++ b/web/app/[locale]/dashboard/billing/page.tsx @@ -31,7 +31,6 @@ import { } from "@/services/billing/pro"; import { resolveBillingTeam, type BillingTeamLike } from "@/services/billing/teamResolution"; import { - LEGACY_PRO_YEARLY_LOOKUP_KEY, PRO_PRICING_USD, TEAM_PRICING_USD, proBillingInterval, @@ -319,7 +318,7 @@ function FreePlanUpsell({ name={pricingT("pro.name")} price={ } @@ -344,7 +343,7 @@ function FreePlanUpsell({ name={pricingT("team.name")} price={ } @@ -394,7 +393,7 @@ function StripePlan({ subscription: StripeSubscriptionRow; canManageBilling: boolean; }) { - const price = priceCopy(subscription, t); + const price = priceCopy(subscription, t, "pro"); const periodDate = subscription.currentPeriodEnd ? formatBillingDate(subscription.currentPeriodEnd, locale) : t("dates.unknown"); @@ -486,9 +485,7 @@ function TeamPlan({ ? formatBillingDate(subscription.currentPeriodEnd, locale) : t("dates.unknown"); const seats = String(subscription.seats ?? 1); - const price = isAnnualTeamSubscription(subscription) - ? t("team.annualPrice") - : t("team.price"); + const price = priceCopy(subscription, t, "team"); return (
@@ -505,7 +502,7 @@ function TeamPlan({ value={periodDate} /> - + {price ? : null}
@@ -580,21 +577,35 @@ function billingBanner(value: string | undefined) { : null; } +/** + * What this subscription actually charges, read from its Stripe price. Amounts + * are immutable per Price, so grandfathered rows ($30/mo, $240/yr, $288/yr, + * and the Stack-era prices with no lookup key) render their own figure without + * a per-key copy table that has to grow on every price change. + */ function priceCopy( subscription: StripeSubscriptionRow, t: Awaited>, + plan: "pro" | "team", ): string | null { - const lookupKey = priceLookupKey(subscription) ?? subscription.priceId; - if (lookupKey === PRO_PRICING_USD.month.lookupKey) { - return t("pro.monthlyPrice"); - } - if (lookupKey === LEGACY_PRO_YEARLY_LOOKUP_KEY) { - return t("pro.legacyAnnualPrice"); + const unitAmount = stripePrice(subscription)?.unit_amount; + const interval = priceRecurringInterval(subscription); + if (typeof unitAmount !== "number" || !Number.isFinite(unitAmount) || !interval) { + return null; } - if (lookupKey === PRO_PRICING_USD.year.lookupKey) { - return t("pro.annualPrice"); + const dollars = unitAmount / 100; + if (interval === "month") { + return t(plan === "pro" ? "pro.monthlyPrice" : "team.price", { + amount: formatUsd(dollars), + }); } - return null; + return t(plan === "pro" ? "pro.annualPrice" : "team.annualPrice", { + monthly: formatUsd(dollars / 12), + }); +} + +function formatUsd(amount: number): string { + return Number.isInteger(amount) ? String(amount) : amount.toFixed(2); } function stripePrice( @@ -614,11 +625,6 @@ function stripePrice( : null; } -function priceLookupKey(subscription: StripeSubscriptionRow): string | null { - const lookupKey = stripePrice(subscription)?.lookup_key; - return typeof lookupKey === "string" ? lookupKey : null; -} - function priceRecurringInterval( subscription: StripeSubscriptionRow, ): "month" | "year" | null { @@ -629,13 +635,6 @@ function priceRecurringInterval( return interval === "month" || interval === "year" ? interval : null; } -function isAnnualTeamSubscription(subscription: StripeSubscriptionRow): boolean { - const lookupKey = priceLookupKey(subscription); - if (lookupKey === TEAM_PRICING_USD.year.lookupKey) return true; - if (lookupKey === TEAM_PRICING_USD.month.lookupKey) return false; - return priceRecurringInterval(subscription) === "year"; -} - function formatBillingDate(date: Date, locale: string): string { return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(date); } diff --git a/web/app/[locale]/pricing/page.tsx b/web/app/[locale]/pricing/page.tsx index 9f5bd778996d..b3b410cabdc9 100644 --- a/web/app/[locale]/pricing/page.tsx +++ b/web/app/[locale]/pricing/page.tsx @@ -189,7 +189,7 @@ export default async function PricingPage({ name={t("pro.name")} price={ } @@ -230,7 +230,7 @@ export default async function PricingPage({ name={t("team.name")} price={ } @@ -284,7 +284,7 @@ export default async function PricingPage({ free: t("free.price"), pro: ( ), diff --git a/web/app/app-pricing/page.tsx b/web/app/app-pricing/page.tsx index ea62034d7856..5ef3af538594 100644 --- a/web/app/app-pricing/page.tsx +++ b/web/app/app-pricing/page.tsx @@ -170,7 +170,7 @@ export default async function AppPricingPage({ name={pricing.pro.name} price={ } @@ -219,7 +219,7 @@ export default async function AppPricingPage({ name={pricing.team.name} price={ } @@ -282,7 +282,7 @@ export default async function AppPricingPage({ free: pricing.free.price, pro: ( ), diff --git a/web/app/env.ts b/web/app/env.ts index e35c3ecaea48..e45c50dfd665 100644 --- a/web/app/env.ts +++ b/web/app/env.ts @@ -199,16 +199,33 @@ export const env = createEnv({ // unavailable. STRIPE_SECRET_KEY: z.string().min(1).optional(), STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(), - STRIPE_PRO_MONTHLY_PRICE_ID: z.string().min(1).optional(), - // Deliberately distinct from the legacy STRIPE_PRO_YEARLY_PRICE_ID, - // which can refer to the grandfathered $240/year price. + // Price-id overrides carry the amount in their name, and every retired + // name fails env validation instead of silently pinning checkout to a + // grandfathered Price (Stripe amounts are immutable; see plans.ts). + STRIPE_PRO_MONTHLY_PRICE_ID: retiredEnvValue( + "STRIPE_PRO_MONTHLY_PRICE_ID", + "STRIPE_PRO_MONTHLY_50_PRICE_ID", + ), + STRIPE_PRO_MONTHLY_50_PRICE_ID: z.string().min(1).optional(), STRIPE_PRO_YEARLY_PRICE_ID: retiredEnvValue( "STRIPE_PRO_YEARLY_PRICE_ID", + "STRIPE_PRO_YEARLY_480_PRICE_ID", + ), + STRIPE_PRO_YEARLY_288_PRICE_ID: retiredEnvValue( "STRIPE_PRO_YEARLY_288_PRICE_ID", + "STRIPE_PRO_YEARLY_480_PRICE_ID", + ), + STRIPE_PRO_YEARLY_480_PRICE_ID: z.string().min(1).optional(), + STRIPE_TEAM_MONTHLY_PRICE_ID: retiredEnvValue( + "STRIPE_TEAM_MONTHLY_PRICE_ID", + "STRIPE_TEAM_MONTHLY_60_PRICE_ID", + ), + STRIPE_TEAM_MONTHLY_60_PRICE_ID: z.string().min(1).optional(), + STRIPE_TEAM_YEARLY_PRICE_ID: retiredEnvValue( + "STRIPE_TEAM_YEARLY_PRICE_ID", + "STRIPE_TEAM_YEARLY_576_PRICE_ID", ), - STRIPE_PRO_YEARLY_288_PRICE_ID: z.string().min(1).optional(), - STRIPE_TEAM_MONTHLY_PRICE_ID: z.string().min(1).optional(), - STRIPE_TEAM_YEARLY_PRICE_ID: z.string().min(1).optional(), + STRIPE_TEAM_YEARLY_576_PRICE_ID: z.string().min(1).optional(), CMUX_APP_PRICING_CHECKOUT_URL: z.string().url().optional(), CMUX_APP_PRICING_RELAY_SECRET: z.string().min(32).optional(), // App Store Connect API for server-side TestFlight enrollment. Optional: @@ -378,12 +395,16 @@ export const env = createEnv({ STRIPE_SECRET_KEY: trimEnv(process.env.STRIPE_SECRET_KEY), STRIPE_WEBHOOK_SECRET: trimEnv(process.env.STRIPE_WEBHOOK_SECRET), STRIPE_PRO_MONTHLY_PRICE_ID: trimEnv(process.env.STRIPE_PRO_MONTHLY_PRICE_ID), + STRIPE_PRO_MONTHLY_50_PRICE_ID: trimEnv(process.env.STRIPE_PRO_MONTHLY_50_PRICE_ID), STRIPE_PRO_YEARLY_PRICE_ID: trimEnv(process.env.STRIPE_PRO_YEARLY_PRICE_ID), STRIPE_PRO_YEARLY_288_PRICE_ID: trimEnv( process.env.STRIPE_PRO_YEARLY_288_PRICE_ID, ), + STRIPE_PRO_YEARLY_480_PRICE_ID: trimEnv(process.env.STRIPE_PRO_YEARLY_480_PRICE_ID), STRIPE_TEAM_MONTHLY_PRICE_ID: trimEnv(process.env.STRIPE_TEAM_MONTHLY_PRICE_ID), + STRIPE_TEAM_MONTHLY_60_PRICE_ID: trimEnv(process.env.STRIPE_TEAM_MONTHLY_60_PRICE_ID), STRIPE_TEAM_YEARLY_PRICE_ID: trimEnv(process.env.STRIPE_TEAM_YEARLY_PRICE_ID), + STRIPE_TEAM_YEARLY_576_PRICE_ID: trimEnv(process.env.STRIPE_TEAM_YEARLY_576_PRICE_ID), CMUX_APP_PRICING_CHECKOUT_URL: trimEnv( process.env.CMUX_APP_PRICING_CHECKOUT_URL, ), diff --git a/web/messages/en.json b/web/messages/en.json index 8cbde678918e..a8e5dc9194ec 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -200,17 +200,16 @@ "name": "cmux Pro", "activeBody": "Your plan renews on {date}.", "pendingBody": "Your plan is scheduled to end on {date}.", - "monthlyPrice": "$30/mo", - "annualPrice": "$24/mo, billed annually", - "legacyAnnualPrice": "$20/mo, billed annually" + "monthlyPrice": "${amount}/mo", + "annualPrice": "${monthly}/mo, billed annually" }, "team": { "name": "cmux Team", "fallbackName": "your team", "activeBody": "{team} renews on {date}.", "pendingBody": "{team} is scheduled to end on {date}.", - "price": "$35/seat/mo", - "annualPrice": "$28/seat/mo, billed annually" + "price": "${amount}/seat/mo", + "annualPrice": "${monthly}/seat/mo, billed annually" }, "details": { "renewsOn": "Renews on", @@ -573,12 +572,12 @@ }, "pro": { "name": "Pro", - "price": "$30", "cta": "Get Pro", "featuresLead": "Everything in Free, plus:", "features": [ "Cloud agents on Cloud VMs in isolated remote sandboxes", - "Unlimited active Cloud VMs", + "Up to 50 Cloud VMs, each with 5 vCPU, 20 GB RAM, and 200 GB disk", + "Unlimited workspaces", "The cmux iOS app and email support" ], "vaultFeatures": [ @@ -591,7 +590,6 @@ }, "team": { "name": "Team", - "price": "$35", "cta": "Get Teams", "featuresLead": "Everything in Pro, plus:", "features": [ @@ -671,9 +669,23 @@ { "label": "Concurrent Cloud VMs", "free": "false", + "pro": "50", + "team": "50", + "enterprise": "Custom" + }, + { + "label": "Cloud VM size", + "free": "false", + "pro": "5 vCPU, 20 GB RAM, 200 GB disk", + "team": "5 vCPU, 20 GB RAM, 200 GB disk", + "enterprise": "Custom" + }, + { + "label": "Workspaces", + "free": "Unlimited", "pro": "Unlimited", "team": "Unlimited", - "enterprise": "Custom" + "enterprise": "Unlimited" }, { "label": "iOS app", @@ -744,7 +756,7 @@ }, { "q": "What are Cloud VMs?", - "a": "Persistent Linux machines in the cloud: pets, not cattle. Following the Zen of cmux, a machine is a primitive, not a managed solution: it keeps its files, installed tools, and state, and what you build on it is yours. Agents clone repositories, run commands, and open pull requests there without touching your Mac. Pro and Team include unlimited active machines." + "a": "Persistent Linux machines in the cloud: pets, not cattle. Following the Zen of cmux, a machine is a primitive, not a managed solution: it keeps its files, installed tools, and state, and what you build on it is yours. Agents clone repositories, run commands, and open pull requests there without touching your Mac. Pro and Team include up to 50 machines, each with 5 vCPU, 20 GB of RAM, and 200 GB of disk." }, { "q": "What is cmux Vault?", @@ -757,7 +769,7 @@ }, { "q": "How does billing work?", - "a": "Pro is $30/mo, or $24/mo when billed annually (a 20% savings). Team is $35/user/mo, or $28/user/mo when billed annually. Paid plans include unlimited active Cloud VMs, with no metering or overages. The terminal and local agents stay free." + "a": "Pro is $50/mo, or $40/mo when billed annually (a 20% savings). Team is $60/user/mo, or $48/user/mo when billed annually. Paid plans include up to 50 Cloud VMs, with no metering or overages. The terminal and local agents stay free." }, { "q": "Can I self-host the cloud?", @@ -765,7 +777,7 @@ }, { "q": "Do you have a team plan?", - "a": "Yes. Team is $35/user/mo, or $28/user/mo when billed annually, with centralized billing for your whole team and priority support." + "a": "Yes. Team is $60/user/mo, or $48/user/mo when billed annually, with centralized billing for your whole team and priority support." } ] }, diff --git a/web/messages/ja.json b/web/messages/ja.json index 7209a6a64705..7932d936e51c 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -200,17 +200,16 @@ "name": "cmux Pro", "activeBody": "プランは{date}に更新されます。", "pendingBody": "プランは{date}に終了する予定です。", - "monthlyPrice": "$30/月", - "annualPrice": "$24/月(年払い)", - "legacyAnnualPrice": "$20/月(年払い)" + "monthlyPrice": "${amount}/月", + "annualPrice": "${monthly}/月(年払い)" }, "team": { "name": "cmux Team", "fallbackName": "チーム", "activeBody": "{team}は{date}に更新されます。", "pendingBody": "{team}は{date}に終了する予定です。", - "price": "$35/シート/月", - "annualPrice": "$28/シート/月(年払い)" + "price": "${amount}/シート/月", + "annualPrice": "${monthly}/シート/月(年払い)" }, "details": { "renewsOn": "更新日", @@ -573,12 +572,12 @@ }, "pro": { "name": "Pro", - "price": "$30", "cta": "Pro を入手", "featuresLead": "Free のすべてに加えて:", "features": [ "分離されたリモートサンドボックスの Cloud VM 上で動くクラウドエージェント", - "アクティブな Cloud VM は無制限", + "最大 50 台の Cloud VM、各 5 vCPU / 20 GB RAM / 200 GB ディスク", + "ワークスペースは無制限", "cmux iOS アプリとメールサポート" ], "vaultFeatures": [ @@ -591,7 +590,6 @@ }, "team": { "name": "Team", - "price": "$35", "cta": "Teams を入手", "featuresLead": "Pro のすべてに加えて:", "features": [ @@ -671,9 +669,23 @@ { "label": "同時 Cloud VM 数", "free": "false", + "pro": "50", + "team": "50", + "enterprise": "カスタム" + }, + { + "label": "Cloud VM のサイズ", + "free": "false", + "pro": "5 vCPU / 20 GB RAM / 200 GB ディスク", + "team": "5 vCPU / 20 GB RAM / 200 GB ディスク", + "enterprise": "カスタム" + }, + { + "label": "ワークスペース", + "free": "無制限", "pro": "無制限", "team": "無制限", - "enterprise": "カスタム" + "enterprise": "無制限" }, { "label": "iOS アプリ", @@ -744,7 +756,7 @@ }, { "q": "Cloud VM とは何ですか?", - "a": "クラウド上の永続的な Linux マシンで、使い捨て(cattle)ではなくペットです。Zen of cmux のとおり、マシンはソリューションではなくプリミティブです。ファイル、インストールしたツール、状態はそのまま保持され、その上に構築したものはあなたのものです。エージェントはそこでリポジトリをクローンし、コマンドを実行し、プルリクエストを開くため、お使いの Mac には触れません。Pro と Team ではアクティブなマシンを無制限に利用できます。" + "a": "クラウド上の永続的な Linux マシンで、使い捨て(cattle)ではなくペットです。Zen of cmux のとおり、マシンはソリューションではなくプリミティブです。ファイル、インストールしたツール、状態はそのまま保持され、その上に構築したものはあなたのものです。エージェントはそこでリポジトリをクローンし、コマンドを実行し、プルリクエストを開くため、お使いの Mac には触れません。Pro と Team では最大 50 台のマシン(各 5 vCPU / 20 GB RAM / 200 GB ディスク)を利用できます。" }, { "q": "cmux Vault とは何ですか?", @@ -757,7 +769,7 @@ }, { "q": "課金はどのように行われますか?", - "a": "Pro は月払いで $30/月、年払いでは $24/月(20%割引)です。Team は月払いで $35/ユーザー/月、年払いでは $28/ユーザー/月です。有料プランではアクティブな Cloud VM を無制限に利用でき、従量課金や超過料金はありません。ターミナルとローカルエージェントは無料のままです。" + "a": "Pro は月払いで $50/月、年払いでは $40/月(20%割引)です。Team は月払いで $60/ユーザー/月、年払いでは $48/ユーザー/月です。有料プランでは最大 50 台の Cloud VM を利用でき、従量課金や超過料金はありません。ターミナルとローカルエージェントは無料のままです。" }, { "q": "クラウドをセルフホストできますか?", @@ -765,7 +777,7 @@ }, { "q": "チームプランはありますか?", - "a": "はい。Team は月払いで $35/ユーザー/月、年払いでは $28/ユーザー/月で、チーム全体の一元請求と優先サポートが含まれます。" + "a": "はい。Team は月払いで $60/ユーザー/月、年払いでは $48/ユーザー/月で、チーム全体の一元請求と優先サポートが含まれます。" } ] }, diff --git a/web/scripts/stripe/provision-catalog.sh b/web/scripts/stripe/provision-catalog.sh index f00fa984ca18..011aa23beca8 100755 --- a/web/scripts/stripe/provision-catalog.sh +++ b/web/scripts/stripe/provision-catalog.sh @@ -254,13 +254,19 @@ echo "Resolved Pro product." >&2 team_product_id="$(canonical_product "cmux-team-monthly" "cmux Team" "team")" echo "Resolved Team product." >&2 -ensure_price "$pro_product_id" "cmux-pro-monthly" "3000" "month" "cmux Pro Monthly" -# Keep the original $240 annual Price active for existing subscribers. Stripe -# Price amounts are immutable, so new annual checkouts use a new lookup key. -ensure_price "$pro_product_id" "cmux-pro-yearly" "24000" "year" "cmux Pro Yearly (Legacy)" -ensure_price "$pro_product_id" "cmux-pro-yearly-288" "28800" "year" "cmux Pro Yearly" -ensure_price "$team_product_id" "cmux-team-monthly" "3500" "month" "cmux Team Monthly" -ensure_price "$team_product_id" "cmux-team-yearly-336" "33600" "year" "cmux Team Yearly" +# Current catalog (web/services/billing/plans.ts). Stripe Price amounts are +# immutable, so each price change mints a new lookup key carrying the amount. +ensure_price "$pro_product_id" "cmux-pro-monthly-50" "5000" "month" "cmux Pro Monthly" +ensure_price "$pro_product_id" "cmux-pro-yearly-480" "48000" "year" "cmux Pro Yearly" +ensure_price "$team_product_id" "cmux-team-monthly-60" "6000" "month" "cmux Team Monthly" +ensure_price "$team_product_id" "cmux-team-yearly-576" "57600" "year" "cmux Team Yearly" +# Grandfathered Prices stay active for the subscriptions already on them +# (LEGACY_PRICE_LOOKUP_KEYS); no new checkout may use these keys. +ensure_price "$pro_product_id" "cmux-pro-monthly" "3000" "month" "cmux Pro Monthly (Legacy \$30)" +ensure_price "$pro_product_id" "cmux-pro-yearly" "24000" "year" "cmux Pro Yearly (Legacy \$240)" +ensure_price "$pro_product_id" "cmux-pro-yearly-288" "28800" "year" "cmux Pro Yearly (Legacy \$288)" +ensure_price "$team_product_id" "cmux-team-monthly" "3500" "month" "cmux Team Monthly (Legacy \$35)" +ensure_price "$team_product_id" "cmux-team-yearly-336" "33600" "year" "cmux Team Yearly (Legacy \$336)" if [[ "$MODE" == "live" ]]; then webhook_ids="" diff --git a/web/services/billing/plans.ts b/web/services/billing/plans.ts index 6b991f079bde..241c342d020e 100644 --- a/web/services/billing/plans.ts +++ b/web/services/billing/plans.ts @@ -11,37 +11,52 @@ type PlanPricing = Record< } >; +// Stripe Price amounts are immutable, so every price change mints a new +// lookup key and leaves the old Price active for the subscriptions already on +// it (see LEGACY_PRICE_LOOKUP_KEYS). The lookup key carries the amount so a +// stale env override or catalog row can never masquerade as the current price. export const PRO_PRICING_USD = { month: { - billedAmount: 30, - monthlyEquivalent: 30, + billedAmount: 50, + monthlyEquivalent: 50, discountPercent: 0, - lookupKey: "cmux-pro-monthly", + lookupKey: "cmux-pro-monthly-50", }, year: { - billedAmount: 288, - monthlyEquivalent: 24, + billedAmount: 480, + monthlyEquivalent: 40, discountPercent: 20, - lookupKey: "cmux-pro-yearly-288", + lookupKey: "cmux-pro-yearly-480", }, } as const satisfies PlanPricing; export const TEAM_PRICING_USD = { month: { - billedAmount: 35, - monthlyEquivalent: 35, + billedAmount: 60, + monthlyEquivalent: 60, discountPercent: 0, - lookupKey: "cmux-team-monthly", + lookupKey: "cmux-team-monthly-60", }, year: { - billedAmount: 336, - monthlyEquivalent: 28, + billedAmount: 576, + monthlyEquivalent: 48, discountPercent: 20, - lookupKey: "cmux-team-yearly-336", + lookupKey: "cmux-team-yearly-576", }, } as const satisfies PlanPricing; -export const LEGACY_PRO_YEARLY_LOOKUP_KEY = "cmux-pro-yearly"; +/** + * Lookup keys that no new checkout may use. Each stays active in Stripe for + * the subscriptions grandfathered on it; the dashboard prices those rows from + * the subscription's own Stripe amount, never from these keys. + */ +export const LEGACY_PRICE_LOOKUP_KEYS = [ + "cmux-pro-monthly", // $30/mo + "cmux-pro-yearly", // $240/yr + "cmux-pro-yearly-288", // $288/yr + "cmux-team-monthly", // $35/user/mo + "cmux-team-yearly-336", // $336/user/yr +] as const; export function billingInterval(value: string | null | undefined): BillingInterval { return value === "year" ? "year" : "month"; diff --git a/web/services/billing/stripe.ts b/web/services/billing/stripe.ts index c6c4c4d0e8e4..b83075707c49 100644 --- a/web/services/billing/stripe.ts +++ b/web/services/billing/stripe.ts @@ -29,8 +29,8 @@ export function stripe(): Stripe { export async function resolveProPrice(interval: BillingInterval): Promise { const overridden = interval === "month" - ? env.STRIPE_PRO_MONTHLY_PRICE_ID - : env.STRIPE_PRO_YEARLY_288_PRICE_ID; + ? env.STRIPE_PRO_MONTHLY_50_PRICE_ID + : env.STRIPE_PRO_YEARLY_480_PRICE_ID; if (overridden) return overridden; const cached = resolvedProPriceIds.get(interval); @@ -52,8 +52,8 @@ export async function resolveProPrice(interval: BillingInterval): Promise { const overridden = interval === "month" - ? env.STRIPE_TEAM_MONTHLY_PRICE_ID - : env.STRIPE_TEAM_YEARLY_PRICE_ID; + ? env.STRIPE_TEAM_MONTHLY_60_PRICE_ID + : env.STRIPE_TEAM_YEARLY_576_PRICE_ID; if (overridden) return overridden; const cached = resolvedTeamPriceIds.get(interval); diff --git a/web/services/vms/README.md b/web/services/vms/README.md index 46b4c9412f3d..c4c2074298c9 100644 --- a/web/services/vms/README.md +++ b/web/services/vms/README.md @@ -385,7 +385,7 @@ daemon health with `bun run cloud-vm:stress -- --provider default`. The usage ledger is in Postgres. VM create pricing gates can use Stack Auth payment items, but free-plan create credits are opt-in. Configure `CMUX_VM_PLAN_FREE_CREATE_CREDIT_ITEM_ID` only when the free plan should consume a prepaid create-credit bucket. When enabled, the create workflow records a one-time local grant row, seeds the configured Stack Auth item credits once per billing team, reserves one create credit only for a newly inserted row, calls the provider, and refunds the credit if provisioning fails before a usable VM exists. -Plan limits are team-based. Stack Auth personal teams should stay enabled for both dev/staging and production projects (`createTeamOnSignUp` / `teams.createPersonalTeamOnSignUp`). New VM rows store `billing_team_id` and `billing_plan_id`; the free plan allows zero active VMs by default and remains at zero regardless of stale free-limit env values while the paid-plan gate is on. A deliberate `CMUX_VM_ALLOW_FREE_PROVISIONING=1` escape hatch re-enables the configured free allowance for local demos or a controlled rollback; paid plans have no active-machine cap (`maxActiveVms` is `null` in entitlements and the list response). Destroyed VMs do not count against a free limit; pausing does not free quota on the production provider. Paid plan activation should write a readable plan id such as `pro` into Stack Auth team read-only metadata (`cmuxVmPlan`) or equivalent billing sync metadata. `CMUX_VM_PLAN__MAX_ACTIVE_VMS` exists only as an incident brake for one paid plan. Paid plans only consume Stack Auth create credits when `CMUX_VM_PLAN__CREATE_CREDIT_ITEM_ID` or the global `CMUX_VM_CREATE_CREDIT_ITEM_ID` is configured. +Plan limits are team-based. Stack Auth personal teams should stay enabled for both dev/staging and production projects (`createTeamOnSignUp` / `teams.createPersonalTeamOnSignUp`). New VM rows store `billing_team_id` and `billing_plan_id`; the free plan allows zero active VMs by default and remains at zero regardless of stale free-limit env values while the paid-plan gate is on. A deliberate `CMUX_VM_ALLOW_FREE_PROVISIONING=1` escape hatch re-enables the configured free allowance for local demos or a controlled rollback; paid plans get the allowance sold on /pricing, 50 active machines per billing team (`PAID_MAX_ACTIVE_VMS_DEFAULT`; `maxActiveVms` in entitlements and the list response). Every machine is the plan machine: 20 GB memory, 5 vCPU (one per 4 GB), and a 200 GB disk, which the Freestyle driver grows the VM to at create (`CMUX_VM_DISK_MB` overrides the disk). Destroyed VMs do not count against a limit; pausing does not free quota on the production provider. Paid plan activation should write a readable plan id such as `pro` into Stack Auth team read-only metadata (`cmuxVmPlan`) or equivalent billing sync metadata. `CMUX_VM_PLAN__MAX_ACTIVE_VMS` and `CMUX_VM_PAID_MAX_ACTIVE_VMS` exist only as incident brakes; the product number lives in code. Paid plans only consume Stack Auth create credits when `CMUX_VM_PLAN__CREATE_CREDIT_ITEM_ID` or the global `CMUX_VM_CREATE_CREDIT_ITEM_ID` is configured. ### The free limit is the paywall moment diff --git a/web/services/vms/drivers/freestyle.ts b/web/services/vms/drivers/freestyle.ts index 70ff4dbf1fc4..9d545bfa8476 100644 --- a/web/services/vms/drivers/freestyle.ts +++ b/web/services/vms/drivers/freestyle.ts @@ -1,4 +1,4 @@ -import { Freestyle, FreestyleApiError, type VmData, type Vm } from "freestyle"; +import { Freestyle, FreestyleApiError, type ResizeVmOptions, type VmData, type VmResources, type Vm } from "freestyle"; import { randomBytes } from "node:crypto"; import { ProviderError, @@ -18,6 +18,7 @@ import { type VMProvider, type VMStatus, } from "./types"; +import { vcpusForMemoryMb, vmDiskMb } from "../machineSpec"; import { recordSpanError, setSpanAttributes, withVmSpan } from "../telemetry"; import { CMUX_TUI_INSTALL_TIMEOUT_MS, @@ -258,9 +259,16 @@ export class FreestyleProvider implements VMProvider { }); setSpanAttributes(span, { "cmux.vm.id": vmId }); try { + // CreateVmOptions has no size: a VM boots at its snapshot's + // resources (the devbox snapshot is 2 vCPU / 4 GB / 16 GB) and + // only a grow-only resize raises them. Size before bootstrap so + // the machine the daemon comes up on is the one that was sold. + await this.growToRequestedSize(fs, vm, vmId, options.memoryMb, span); await this.bootstrapCmuxTui(vm, vmId, options.envs); } catch (err) { - // A VM that failed to bootstrap must not survive as an orphan. + // A VM that failed to size or bootstrap must not survive as an + // orphan, and an undersized machine must not ship as if it were + // the plan machine. await vm.delete().catch((cleanupErr) => { console.error(`[freestyle] create rollback failed; VM ${vmId} may be orphaned`, cleanupErr); }); @@ -281,6 +289,32 @@ export class FreestyleProvider implements VMProvider { ); } + /** + * Grow the VM to the requested memory, the vCPUs that memory implies, and + * the plan disk. Freestyle resize is grow-only, so only larger dimensions + * are sent; a snapshot that already carries the size is a no-op. + */ + private async growToRequestedSize( + fs: Freestyle, + vm: Vm, + vmId: string, + memoryMb: number | undefined, + span: Parameters[0], + ): Promise { + if (memoryMb === undefined) return; + const current = (await fs.vms.get(vmId)).resources; + const target = freestyleTargetResources(memoryMb); + const request = freestyleResizeRequest(current, target); + setSpanAttributes(span, { + "cmux.vm.resources.cpu": target.cpu, + "cmux.vm.resources.memory_mb": target.memory, + "cmux.vm.resources.storage_mb": target.storage, + "cmux.vm.resize.requested": request !== null, + }); + if (!request) return; + await vm.resize(request); + } + async destroy(vmId: string): Promise { return withVmSpan( "cmux.vm.provider.destroy", @@ -619,3 +653,31 @@ export class FreestyleProvider implements VMProvider { }; } } + +/** The resources a machine of `memoryMb` is sold with (see entitlements.ts). */ +export function freestyleTargetResources( + memoryMb: number, + env: Record = process.env, +): VmResources { + return { + cpu: vcpusForMemoryMb(memoryMb), + memory: memoryMb, + storage: vmDiskMb(env), + }; +} + +/** + * The grow-only resize that takes `current` to `target`, or null when nothing + * needs to grow. Shrinks are never requested: Freestyle rejects them, and a + * snapshot restored at a larger size keeps what it had. + */ +export function freestyleResizeRequest( + current: VmResources, + target: VmResources, +): ResizeVmOptions | null { + const request: ResizeVmOptions = {}; + if (target.cpu > current.cpu) request.cpu = target.cpu; + if (target.memory > current.memory) request.memory = target.memory; + if (target.storage > current.storage) request.storage = target.storage; + return Object.keys(request).length > 0 ? request : null; +} diff --git a/web/services/vms/entitlements.ts b/web/services/vms/entitlements.ts index 06eb207dbcc4..724775b695ec 100644 --- a/web/services/vms/entitlements.ts +++ b/web/services/vms/entitlements.ts @@ -1,6 +1,16 @@ import type { AuthedUser } from "./auth"; import type { BillingCustomerType } from "./billingGateway"; import { FOUNDERS_PLAN_ID, PRO_PLAN_ID, TEAM_PLAN_ID } from "../billing/pro"; +import { PAID_MAX_ACTIVE_VMS_DEFAULT, PLAN_MACHINE_MEMORY_MB } from "./machineSpec"; + +export { + PAID_MAX_ACTIVE_VMS_DEFAULT, + PLAN_MACHINE_MEMORY_MB, + VM_DISK_MB_DEFAULT, + VM_MEMORY_MB_PER_VCPU, + vcpusForMemoryMb, + vmDiskMb, +} from "./machineSpec"; export type VmEntitlements = { readonly planId: string; @@ -115,10 +125,11 @@ function resolveBillingContext( } /** - * Machine sizes a person can pick, as memory in MB. Providers scale vCPUs with - * memory (a 4 GB machine reports 2 cpus), so memory is the whole size story. + * Machine sizes a person can pick, as memory in MB. vCPUs follow memory + * (vcpusForMemoryMb), so memory is the whole size story. The plan machine is + * the largest entry; a plan ceiling trims the tail. */ -export const VM_MEMORY_OPTIONS_MB: readonly number[] = [2048, 4096, 8192, 16384, 24576, 32768]; +export const VM_MEMORY_OPTIONS_MB: readonly number[] = [2048, 4096, 8192, 16384, PLAN_MACHINE_MEMORY_MB]; /** Largest machine a plan may create. Env-overridable per plan. */ export function maxMemoryMbForPlan( @@ -130,12 +141,18 @@ export function maxMemoryMbForPlan( const specific = env[`CMUX_VM_PLAN_${planKey}_MAX_MEMORY_MB`]; if (specific?.trim()) return positiveInteger(specific, `CMUX_VM_PLAN_${planKey}_MAX_MEMORY_MB`); if (normalized === "free") { - // The free machine is the product demo: one full-size computer, not a - // cut-down teaser. The paywall is the 7-day access window and the - // machine count, never the machine's usefulness. - return positiveInteger(env.CMUX_VM_FREE_MAX_MEMORY_MB ?? "24576", "CMUX_VM_FREE_MAX_MEMORY_MB"); + // The free machine is the product demo: the same full-size computer a + // paid plan gets, not a cut-down teaser. The paywall is the 7-day access + // window and the machine count, never the machine's usefulness. + return positiveInteger( + env.CMUX_VM_FREE_MAX_MEMORY_MB ?? String(PLAN_MACHINE_MEMORY_MB), + "CMUX_VM_FREE_MAX_MEMORY_MB", + ); } - return positiveInteger(env.CMUX_VM_PAID_MAX_MEMORY_MB ?? "32768", "CMUX_VM_PAID_MAX_MEMORY_MB"); + return positiveInteger( + env.CMUX_VM_PAID_MAX_MEMORY_MB ?? String(PLAN_MACHINE_MEMORY_MB), + "CMUX_VM_PAID_MAX_MEMORY_MB", + ); } /** Size a plan gets when it doesn't ask for one; never above the plan's max. */ @@ -149,15 +166,22 @@ export function defaultMemoryMbForPlan( const raw = specific?.trim() ? positiveInteger(specific, `CMUX_VM_PLAN_${planKey}_DEFAULT_MEMORY_MB`) : normalized === "free" - ? positiveInteger(env.CMUX_VM_FREE_DEFAULT_MEMORY_MB ?? "24576", "CMUX_VM_FREE_DEFAULT_MEMORY_MB") - : positiveInteger(env.CMUX_VM_PAID_DEFAULT_MEMORY_MB ?? "24576", "CMUX_VM_PAID_DEFAULT_MEMORY_MB"); + ? positiveInteger( + env.CMUX_VM_FREE_DEFAULT_MEMORY_MB ?? String(PLAN_MACHINE_MEMORY_MB), + "CMUX_VM_FREE_DEFAULT_MEMORY_MB", + ) + : positiveInteger( + env.CMUX_VM_PAID_DEFAULT_MEMORY_MB ?? String(PLAN_MACHINE_MEMORY_MB), + "CMUX_VM_PAID_DEFAULT_MEMORY_MB", + ); return Math.min(raw, maxMemoryMbForPlan(planId, env)); } /** * Active-machine ceiling for a plan, or null when there is none. Paid plans - * are uncapped: the subscription is the paywall and machine count is not a - * quota. Free plans stay capped (zero unless free provisioning is allowed). + * get the allowance sold on /pricing (PAID_MAX_ACTIVE_VMS_DEFAULT), counted + * per billing team. Free plans stay capped (zero unless free provisioning is + * allowed). */ export function maxActiveVmsForPlan( planId: string | null | undefined, @@ -299,11 +323,14 @@ function activeVmLimitForPlan(planId: string, env: Record = process.env): number { + const raw = (env.CMUX_VM_DISK_MB ?? String(VM_DISK_MB_DEFAULT)).trim(); + const value = Number.parseInt(raw, 10); + if (!Number.isSafeInteger(value) || value <= 0 || String(value) !== raw) { + throw new Error(`CMUX_VM_DISK_MB must be a positive integer, got: ${raw}`); + } + return value; +} diff --git a/web/tests/app-pricing-page.test.tsx b/web/tests/app-pricing-page.test.tsx index 9bd0e61cb073..888722ad7529 100644 --- a/web/tests/app-pricing-page.test.tsx +++ b/web/tests/app-pricing-page.test.tsx @@ -99,7 +99,8 @@ describe("app pricing page", () => { expect(html).toContain("/mo"); expect(html).toContain("/user/mo"); expect(html).not.toContain("/mo."); - expect(html).toContain("$35/user/mo"); + expect(html).toContain("$50"); + expect(html).toContain("$60/user/mo"); expect(html).toContain('

Includes:

'); expect(html).not.toContain('style="min-height:4rem"'); expect(html).toContain("text-3xl font-medium tabular-nums tracking-tight"); @@ -155,18 +156,18 @@ describe("app pricing page", () => { }); const html = renderToStaticMarkup(element); - expect(html).toContain("$24"); - expect(html).toContain("$28"); + expect(html).toContain("$40"); + expect(html).toContain("$48"); expect(html).toContain("/mo"); expect(html).toContain("/user/mo"); expect(html).toContain("/mo, billed yearly"); expect(html).toContain("/user/mo, billed yearly"); expect(html).not.toContain("/mo."); - expect(html).not.toContain("Billed $288 annually · save 20%"); - expect(html).not.toContain("Billed $336 annually · save 20%"); - expect(html).toContain("$28/user/mo"); - expect(html).not.toContain("$288/year"); - expect(html).not.toContain("$336/user/year"); + expect(html).not.toContain("$24"); + expect(html).not.toContain("$28"); + expect(html).toContain("$48/user/mo"); + expect(html).not.toContain("$480/year"); + expect(html).not.toContain("$576/user/year"); expect(html).toContain( "http://localhost:9210/api/billing/checkout?plan=pro&cmux_external_browser=1&cmux_scheme=cmux-dev-test&interval=year", ); diff --git a/web/tests/client-config-env.test.ts b/web/tests/client-config-env.test.ts index 12e243610d42..3ac1bd991d22 100644 --- a/web/tests/client-config-env.test.ts +++ b/web/tests/client-config-env.test.ts @@ -73,16 +73,37 @@ describe("client config env validation", () => { ); }); - test("rejects the retired annual Pro price override at startup", () => { + test("rejects every retired Stripe price override at startup", () => { + // A retired override would pin checkout to a grandfathered Price, so the + // deployment must fail loudly rather than sell at the old amount. + const retired = [ + ["STRIPE_PRO_MONTHLY_PRICE_ID", "STRIPE_PRO_MONTHLY_50_PRICE_ID"], + ["STRIPE_PRO_YEARLY_PRICE_ID", "STRIPE_PRO_YEARLY_480_PRICE_ID"], + ["STRIPE_PRO_YEARLY_288_PRICE_ID", "STRIPE_PRO_YEARLY_480_PRICE_ID"], + ["STRIPE_TEAM_MONTHLY_PRICE_ID", "STRIPE_TEAM_MONTHLY_60_PRICE_ID"], + ["STRIPE_TEAM_YEARLY_PRICE_ID", "STRIPE_TEAM_YEARLY_576_PRICE_ID"], + ] as const; + for (const [name, replacement] of retired) { + const result = importEnv({ + ...requiredEnv, + [name]: "price_grandfathered", + }); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain(`${name} is retired; use ${replacement}`); + } + }); + + test("accepts the current Stripe price overrides", () => { const result = importEnv({ ...requiredEnv, - STRIPE_PRO_YEARLY_PRICE_ID: "price_grandfathered_240", + STRIPE_PRO_MONTHLY_50_PRICE_ID: "price_pro_50", + STRIPE_PRO_YEARLY_480_PRICE_ID: "price_pro_480", + STRIPE_TEAM_MONTHLY_60_PRICE_ID: "price_team_60", + STRIPE_TEAM_YEARLY_576_PRICE_ID: "price_team_576", }); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain( - "STRIPE_PRO_YEARLY_PRICE_ID is retired; use STRIPE_PRO_YEARLY_288_PRICE_ID", - ); + expect(result.exitCode).toBe(0); }); test("allows explicit Vercel production deployments with all rate-limit ids unset", () => { diff --git a/web/tests/dashboard-billing-page.test.tsx b/web/tests/dashboard-billing-page.test.tsx index 90ebb570902c..451d52de6a5e 100644 --- a/web/tests/dashboard-billing-page.test.tsx +++ b/web/tests/dashboard-billing-page.test.tsx @@ -123,15 +123,15 @@ describe("dashboard billing page", () => { test("renders annual Pro and Team pricing from the billing upsell", async () => { const html = await renderBillingPage({ interval: "year" }); - expect(html).toContain("$24"); - expect(html).toContain("$28"); + expect(html).toContain("$40"); + expect(html).toContain("$48"); expect(html).toContain("/mo"); expect(html).toContain("/user/mo"); expect(html).toContain("/mo, billed yearly"); expect(html).toContain("/user/mo, billed yearly"); expect(html).not.toContain("/mo."); - expect(html).not.toContain("Billed $288 annually · save 20%"); - expect(html).not.toContain("Billed $336 annually · save 20%"); + expect(html).not.toContain("$24"); + expect(html).not.toContain("$28"); expect(html).toContain( 'href="/api/billing/checkout?plan=pro&cmux_external_browser=1&interval=year"', ); @@ -148,29 +148,43 @@ describe("dashboard billing page", () => { expect(html).toContain("cmux Pro"); expect(html).toContain("Your plan renews on"); - expect(html).toContain("$30/mo"); + expect(html).toContain("$50/mo"); expect(html).toContain("Cancel plan"); expect(html).toContain('action="/api/billing/subscription"'); expect(html).toContain('href="/api/billing/portal"'); }); - test("labels new and grandfathered annual Stripe prices", async () => { - subscriptionRows = [ - stripeSubscriptionRow({ - cancelAtPeriodEnd: false, - lookupKey: "cmux-pro-yearly-288", - }), - ]; + test("prices every Stripe Pro subscription from its own price amount", async () => { customerRows = [{ id: "cus_123" }]; - expect(await renderBillingPage()).toContain("$24/mo, billed annually"); + const cases: Array<[string | undefined, number, "month" | "year", string]> = [ + ["cmux-pro-yearly-480", 48000, "year", "$40/mo, billed annually"], + ["cmux-pro-yearly-288", 28800, "year", "$24/mo, billed annually"], + ["cmux-pro-yearly", 24000, "year", "$20/mo, billed annually"], + ["cmux-pro-monthly", 3000, "month", "$30/mo"], + // Stack-era Prices carry no lookup key at all. + [undefined, 3000, "month", "$30/mo"], + ]; + for (const [lookupKey, unitAmount, recurringInterval, expected] of cases) { + subscriptionRows = [ + stripeSubscriptionRow({ + cancelAtPeriodEnd: false, + lookupKey, + unitAmount, + recurringInterval, + }), + ]; + expect(await renderBillingPage()).toContain(expected); + } + }); + test("omits the price metric when Stripe sent no amount", async () => { + customerRows = [{ id: "cus_123" }]; subscriptionRows = [ - stripeSubscriptionRow({ - cancelAtPeriodEnd: false, - lookupKey: "cmux-pro-yearly", - }), + stripeSubscriptionRow({ cancelAtPeriodEnd: false, unitAmount: null }), ]; - expect(await renderBillingPage()).toContain("$20/mo, billed annually"); + const html = await renderBillingPage(); + expect(html).toContain("cmux Pro"); + expect(html).not.toContain(">Price<"); }); test("renders pending cancellation with resume and end-date copy", async () => { @@ -223,7 +237,7 @@ describe("dashboard billing page", () => { expect(html).toContain("Team Pro renews on"); expect(html).toContain("Seats"); expect(html).toContain(">4<"); - expect(html).toContain("$35/seat/mo"); + expect(html).toContain("$60/seat/mo"); expect(html).toContain('name="scope" value="team"'); expect(html).toContain('href="/api/billing/portal?scope=team"'); }); @@ -240,13 +254,15 @@ describe("dashboard billing page", () => { plan: "team", scope: "team", seats: 4, - lookupKey: "cmux-team-yearly-336", + lookupKey: "cmux-team-yearly-576", + unitAmount: 57600, + recurringInterval: "year", }), ], ]; customerRows = [{ id: "cus_team" }]; - expect(await renderBillingPage()).toContain("$28/seat/mo, billed annually"); + expect(await renderBillingPage()).toContain("$48/seat/mo, billed annually"); }); test("uses the current Stripe price interval over stale checkout metadata", async () => { @@ -262,6 +278,7 @@ describe("dashboard billing page", () => { scope: "team", seats: 4, lookupKey: "cmux-team-monthly", + unitAmount: 3500, billingInterval: "year", }), ], @@ -281,6 +298,7 @@ describe("dashboard billing page", () => { scope: "team", seats: 4, lookupKey: "operator-managed-annual-price", + unitAmount: 33600, recurringInterval: "year", }), ], @@ -288,11 +306,13 @@ describe("dashboard billing page", () => { expect(await renderBillingPage()).toContain("$28/seat/mo, billed annually"); }); - test("localizes active annual Pro prices", () => { + test("localizes active Pro and Team price templates", () => { + expect(enMessages.dashboard.billing.pro.monthlyPrice).toBe("${amount}/mo"); expect(enMessages.dashboard.billing.pro.annualPrice).toBe( - "$24/mo, billed annually", + "${monthly}/mo, billed annually", ); - expect(jaMessages.dashboard.billing.pro.annualPrice).toBe("$24/月(年払い)"); + expect(jaMessages.dashboard.billing.pro.annualPrice).toBe("${monthly}/月(年払い)"); + expect(jaMessages.dashboard.billing.team.price).toBe("${amount}/シート/月"); }); test("renders active Stripe Team for a paid team when no team is selected", async () => { @@ -383,9 +403,10 @@ function stripeSubscriptionRow({ plan = "pro", scope = "user", seats = null, - lookupKey = "cmux-pro-monthly", + lookupKey = "cmux-pro-monthly-50", + unitAmount = 5000, billingInterval, - recurringInterval, + recurringInterval = "month", }: { cancelAtPeriodEnd: boolean; status?: string; @@ -393,6 +414,7 @@ function stripeSubscriptionRow({ scope?: string; seats?: number | null; lookupKey?: string; + unitAmount?: number | null; billingInterval?: "month" | "year"; recurringInterval?: "month" | "year"; }) { @@ -412,7 +434,8 @@ function stripeSubscriptionRow({ { price: { lookup_key: lookupKey, - recurring: recurringInterval ? { interval: recurringInterval } : undefined, + unit_amount: unitAmount, + recurring: { interval: recurringInterval }, }, }, ], diff --git a/web/tests/pricing-page.test.tsx b/web/tests/pricing-page.test.tsx index 5a2dce31a231..fb40ca2137a1 100644 --- a/web/tests/pricing-page.test.tsx +++ b/web/tests/pricing-page.test.tsx @@ -145,7 +145,7 @@ describe("localized pricing page", () => { expect(html).toContain("/mo"); expect(html).toContain("/user/mo"); expect(html).not.toContain("/mo."); - expect(html).toContain("$28/user/mo"); + expect(html).toContain("$48/user/mo"); expect(html).toContain( "/api/billing/checkout?plan=team&cmux_external_browser=1&interval=year", ); @@ -208,18 +208,18 @@ describe("localized pricing page", () => { }); const html = renderToStaticMarkup(element); - expect(html).toContain("$24"); + expect(html).toContain("$40"); expect(html).toContain("/mo"); - expect(html).toContain("$24/mo"); - expect(html).toContain("$28"); + expect(html).toContain("$40/mo"); + expect(html).toContain("$48"); expect(html).toContain("/user/mo"); expect(html).toContain("/mo, billed yearly"); expect(html).toContain("/user/mo, billed yearly"); - expect(html).toContain("$28/user/mo"); - expect(html).not.toContain("$288/year"); - expect(html).not.toContain("$336/user/year"); - expect(html).not.toContain("Billed $288 annually · save 20%"); - expect(html).not.toContain("Billed $336 annually · save 20%"); + expect(html).toContain("$48/user/mo"); + expect(html).not.toContain("$480/year"); + expect(html).not.toContain("$576/user/year"); + expect(html).not.toContain("$24"); + expect(html).not.toContain("$28"); expect(html).toContain( "/api/billing/checkout?plan=pro&cmux_external_browser=1&interval=year", ); @@ -239,8 +239,11 @@ describe("localized pricing page", () => { }); const html = renderToStaticMarkup(element); - expect(html).toContain("$30"); - expect(html).toContain("$35"); + expect(html).toContain("$50"); + expect(html).toContain("$60"); + expect(html).toContain("Up to 50 Cloud VMs, each with 5 vCPU, 20 GB RAM, and 200 GB disk"); + expect(html).toContain("Unlimited workspaces"); + expect(html).not.toContain("Unlimited active Cloud VMs"); expect(html).toContain( "/api/billing/checkout?plan=pro&cmux_external_browser=1&interval=month", ); diff --git a/web/tests/pro-pricing.test.ts b/web/tests/pro-pricing.test.ts index a949fc162179..9708b2bf98c8 100644 --- a/web/tests/pro-pricing.test.ts +++ b/web/tests/pro-pricing.test.ts @@ -1,53 +1,85 @@ import { describe, expect, test } from "bun:test"; +import enMessages from "../messages/en.json"; +import jaMessages from "../messages/ja.json"; import { - LEGACY_PRO_YEARLY_LOOKUP_KEY, + LEGACY_PRICE_LOOKUP_KEYS, PRO_PRICING_USD, TEAM_PRICING_USD, proBillingInterval, } from "../services/billing/plans"; +import { + PAID_MAX_ACTIVE_VMS_DEFAULT, + PLAN_MACHINE_MEMORY_MB, + VM_DISK_MB_DEFAULT, + vcpusForMemoryMb, +} from "../services/vms/entitlements"; describe("pricing plans", () => { - test("prices annual Pro at $288 with a 20% discount", () => { + test("prices Pro at $50/mo and $480/yr with a 20% annual discount", () => { + expect(PRO_PRICING_USD.month).toEqual({ + billedAmount: 50, + monthlyEquivalent: 50, + discountPercent: 0, + lookupKey: "cmux-pro-monthly-50", + }); expect(PRO_PRICING_USD.year).toEqual({ - billedAmount: 288, - monthlyEquivalent: 24, + billedAmount: 480, + monthlyEquivalent: 40, discountPercent: 20, - lookupKey: "cmux-pro-yearly-288", + lookupKey: "cmux-pro-yearly-480", }); - expect(PRO_PRICING_USD.month.billedAmount * 12).toBe(360); expect(PRO_PRICING_USD.year.billedAmount).toBe( PRO_PRICING_USD.month.billedAmount * 12 * (1 - PRO_PRICING_USD.year.discountPercent / 100), ); - }); - - test("keeps the original annual lookup key reserved for grandfathered subscriptions", () => { - expect(LEGACY_PRO_YEARLY_LOOKUP_KEY).toBe("cmux-pro-yearly"); - expect(PRO_PRICING_USD.year.lookupKey).not.toBe( - LEGACY_PRO_YEARLY_LOOKUP_KEY, + expect(PRO_PRICING_USD.year.monthlyEquivalent * 12).toBe( + PRO_PRICING_USD.year.billedAmount, ); }); - test("prices annual Team at $336 per user with a 20% discount", () => { + test("prices Team at $60/user/mo and $576/user/yr with a 20% annual discount", () => { expect(TEAM_PRICING_USD.month).toEqual({ - billedAmount: 35, - monthlyEquivalent: 35, + billedAmount: 60, + monthlyEquivalent: 60, discountPercent: 0, - lookupKey: "cmux-team-monthly", + lookupKey: "cmux-team-monthly-60", }); expect(TEAM_PRICING_USD.year).toEqual({ - billedAmount: 336, - monthlyEquivalent: 28, + billedAmount: 576, + monthlyEquivalent: 48, discountPercent: 20, - lookupKey: "cmux-team-yearly-336", + lookupKey: "cmux-team-yearly-576", }); expect(TEAM_PRICING_USD.year.billedAmount).toBe( TEAM_PRICING_USD.month.billedAmount * 12 * (1 - TEAM_PRICING_USD.year.discountPercent / 100), ); + expect(TEAM_PRICING_USD.year.monthlyEquivalent * 12).toBe( + TEAM_PRICING_USD.year.billedAmount, + ); + }); + + test("lookup keys carry their amount and never reuse a grandfathered key", () => { + const current = [ + PRO_PRICING_USD.month, + PRO_PRICING_USD.year, + TEAM_PRICING_USD.month, + TEAM_PRICING_USD.year, + ]; + for (const price of current) { + expect(price.lookupKey.endsWith(`-${price.billedAmount}`)).toBe(true); + expect(LEGACY_PRICE_LOOKUP_KEYS).not.toContain(price.lookupKey); + } + expect(LEGACY_PRICE_LOOKUP_KEYS).toEqual([ + "cmux-pro-monthly", + "cmux-pro-yearly", + "cmux-pro-yearly-288", + "cmux-team-monthly", + "cmux-team-yearly-336", + ]); }); test("defaults unknown intervals to monthly", () => { @@ -57,3 +89,53 @@ describe("pricing plans", () => { expect(proBillingInterval(null)).toBe("month"); }); }); + +describe("pricing copy matches the plan policy", () => { + // The public pricing copy states the machine allowance as prose, so pin the + // numbers to the entitlement constants that enforce them. A price or spec + // change that forgets the copy (or the copy that forgets the policy) fails + // here instead of on the live page. + const vcpus = vcpusForMemoryMb(PLAN_MACHINE_MEMORY_MB); + const memoryGb = PLAN_MACHINE_MEMORY_MB / 1024; + const diskGb = VM_DISK_MB_DEFAULT / 1024; + + test("the plan machine is 5 vCPU, 20 GB RAM, 200 GB disk, up to 50 machines", () => { + expect(vcpus).toBe(5); + expect(memoryGb).toBe(20); + expect(diskGb).toBe(200); + expect(PAID_MAX_ACTIVE_VMS_DEFAULT).toBe(50); + }); + + for (const [locale, messages] of [ + ["en", enMessages], + ["ja", jaMessages], + ] as const) { + test(`${locale} pricing copy states the current prices and machine spec`, () => { + const pricing = messages.pricing; + const proFeatures = pricing.pro.features.join("\n"); + expect(proFeatures).toContain(`${PAID_MAX_ACTIVE_VMS_DEFAULT} `); + expect(proFeatures).toContain(`${vcpus} vCPU`); + expect(proFeatures).toContain(`${memoryGb} GB`); + expect(proFeatures).toContain(`${diskGb} GB`); + + const vmRow = pricing.compare.rows.find((row) => + row.label.includes("Cloud VM") && row.pro === String(PAID_MAX_ACTIVE_VMS_DEFAULT), + ); + expect(vmRow).toBeDefined(); + const sizeRow = pricing.compare.rows.find((row) => row.pro.includes(`${vcpus} vCPU`)); + expect(sizeRow?.pro).toContain(`${memoryGb} GB`); + expect(sizeRow?.pro).toContain(`${diskGb} GB`); + + const faq = pricing.faq.items.map((item) => item.a).join("\n"); + expect(faq).toContain(`$${PRO_PRICING_USD.month.billedAmount}/`); + expect(faq).toContain(`$${PRO_PRICING_USD.year.monthlyEquivalent}/`); + expect(faq).toContain(`$${TEAM_PRICING_USD.month.billedAmount}/`); + expect(faq).toContain(`$${TEAM_PRICING_USD.year.monthlyEquivalent}/`); + for (const stale of ["$30/", "$24/", "$35/", "$28/"]) { + expect(faq).not.toContain(stale); + } + expect(faq.toLowerCase()).not.toContain("unlimited active"); + expect(faq).not.toContain("無制限に利用"); + }); + } +}); diff --git a/web/tests/stripe-provision-catalog.test.ts b/web/tests/stripe-provision-catalog.test.ts index 5a97e1525414..034bb2fe5647 100644 --- a/web/tests/stripe-provision-catalog.test.ts +++ b/web/tests/stripe-provision-catalog.test.ts @@ -1,4 +1,11 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test as bunTest } from "bun:test"; + +// Every run shells out once per catalog Price (nine, including the +// grandfathered ones) through a mock curl, so the 5s default is too tight +// on a loaded machine. +const PROVISION_TEST_TIMEOUT_MS = 30_000; +const test = (name: string, fn: () => Promise) => + bunTest(name, fn, PROVISION_TEST_TIMEOUT_MS); import { chmodSync, mkdirSync, @@ -71,8 +78,8 @@ describe("Stripe catalog provisioning", () => { (call) => call.args.includes("https://api.stripe.com/v1/prices") && call.args.includes("POST") && - call.args.includes("lookup_key=cmux-pro-yearly-288") && - call.args.includes("unit_amount=28800"), + call.args.includes("lookup_key=cmux-pro-yearly-480") && + call.args.includes("unit_amount=48000"), ), ).toBe(true); expect( @@ -80,8 +87,8 @@ describe("Stripe catalog provisioning", () => { (call) => call.args.includes("https://api.stripe.com/v1/prices") && call.args.includes("POST") && - call.args.includes("lookup_key=cmux-team-yearly-336") && - call.args.includes("unit_amount=33600"), + call.args.includes("lookup_key=cmux-team-yearly-576") && + call.args.includes("unit_amount=57600"), ), ).toBe(true); }); @@ -288,6 +295,30 @@ const products = { }, }; const prices = { + "cmux-pro-monthly-50": { + id: "price_pro_month_50", + unit_amount: 5000, + interval: "month", + product: "pro", + }, + "cmux-pro-yearly-480": { + id: "price_pro_year_480", + unit_amount: 48000, + interval: "year", + product: "pro", + }, + "cmux-team-monthly-60": { + id: "price_team_month_60", + unit_amount: 6000, + interval: "month", + product: "team", + }, + "cmux-team-yearly-576": { + id: "price_team_year_576", + unit_amount: 57600, + interval: "year", + product: "team", + }, "cmux-pro-monthly": { id: "price_pro_month", unit_amount: 3000, @@ -327,7 +358,8 @@ if (url.endsWith("/prices") && !isPost) { scenario === "unrelated-product" && ( lookupKey?.startsWith("cmux-pro") || - lookupKey === "cmux-team-yearly-336" + lookupKey === "cmux-team-yearly-336" || + lookupKey === "cmux-team-yearly-576" ) ) || ( diff --git a/web/tests/vm-billing-limit-paywall.test.ts b/web/tests/vm-billing-limit-paywall.test.ts index 51592132db44..adb7bdf8cca1 100644 --- a/web/tests/vm-billing-limit-paywall.test.ts +++ b/web/tests/vm-billing-limit-paywall.test.ts @@ -1,9 +1,13 @@ import { describe, expect, test } from "bun:test"; import { + PLAN_MACHINE_MEMORY_MB, + VM_MEMORY_OPTIONS_MB, defaultMemoryMbForPlan, isVmFreeAccessExpired, maxActiveVmsForPlan, maxMemoryMbForPlan, + vcpusForMemoryMb, + vmDiskMb, vmFreeAccessWindowDays, } from "../services/vms/entitlements"; import { vmActiveLimitExceededResponse, vmFreeAccessExpiredResponse } from "../services/vms/routeHelpers"; @@ -17,16 +21,20 @@ describe("free plan VM allowance", () => { expect(maxActiveVmsForPlan("free", {})).toBe(0); }); - test("paid plans have no machine cap", () => { - expect(maxActiveVmsForPlan("pro", {})).toBeNull(); - expect(maxActiveVmsForPlan("team", {})).toBeNull(); - expect(maxActiveVmsForPlan("founders", {})).toBeNull(); + test("paid plans get the advertised 50-machine allowance", () => { + expect(maxActiveVmsForPlan("pro", {})).toBe(50); + expect(maxActiveVmsForPlan("team", {})).toBe(50); + expect(maxActiveVmsForPlan("founders", {})).toBe(50); }); - test("a paid cap exists only as an explicit per-plan operator override", () => { - expect(maxActiveVmsForPlan("pro", { CMUX_VM_PAID_MAX_ACTIVE_VMS: "5" })).toBeNull(); - expect(maxActiveVmsForPlan("pro", { CMUX_VM_PLAN_PRO_MAX_ACTIVE_VMS: "25" })).toBe(25); - expect(maxActiveVmsForPlan("team", { CMUX_VM_PLAN_PRO_MAX_ACTIVE_VMS: "25" })).toBeNull(); + test("operator brakes: a plan-specific cap wins over the paid-wide cap", () => { + expect(maxActiveVmsForPlan("pro", { CMUX_VM_PAID_MAX_ACTIVE_VMS: "5" })).toBe(5); + expect(maxActiveVmsForPlan("pro", { + CMUX_VM_PAID_MAX_ACTIVE_VMS: "5", + CMUX_VM_PLAN_PRO_MAX_ACTIVE_VMS: "25", + })).toBe(25); + expect(maxActiveVmsForPlan("team", { CMUX_VM_PLAN_PRO_MAX_ACTIVE_VMS: "25" })).toBe(50); + expect(() => maxActiveVmsForPlan("pro", { CMUX_VM_PAID_MAX_ACTIVE_VMS: "0" })).toThrow(); }); test("free allowance is env-overridable only with the explicit escape hatch", () => { @@ -52,14 +60,26 @@ describe("free plan VM allowance", () => { }); describe("Cloud VM memory allowance", () => { - test("free defaults to 24 GB and caps at 24 GB", () => { - expect(defaultMemoryMbForPlan("free", {})).toBe(24576); - expect(maxMemoryMbForPlan("free", {})).toBe(24576); - }); - - test("paid plans default to 24 GB and cap at 32 GB", () => { - expect(defaultMemoryMbForPlan("pro", {})).toBe(24576); - expect(maxMemoryMbForPlan("pro", {})).toBe(32768); + test("every plan defaults to and caps at the 20 GB plan machine", () => { + expect(PLAN_MACHINE_MEMORY_MB).toBe(20480); + expect(defaultMemoryMbForPlan("free", {})).toBe(20480); + expect(maxMemoryMbForPlan("free", {})).toBe(20480); + expect(defaultMemoryMbForPlan("pro", {})).toBe(20480); + expect(maxMemoryMbForPlan("pro", {})).toBe(20480); + expect(VM_MEMORY_OPTIONS_MB.at(-1)).toBe(20480); + }); + + test("vCPUs follow memory at one per 4 GB, so the plan machine is 5 vCPU", () => { + expect(vcpusForMemoryMb(20480)).toBe(5); + expect(vcpusForMemoryMb(16384)).toBe(4); + expect(vcpusForMemoryMb(2048)).toBe(1); + expect(vcpusForMemoryMb(5000)).toBe(2); + }); + + test("every machine is grown to a 200 GB disk unless an operator overrides it", () => { + expect(vmDiskMb({})).toBe(204800); + expect(vmDiskMb({ CMUX_VM_DISK_MB: "65536" })).toBe(65536); + expect(() => vmDiskMb({ CMUX_VM_DISK_MB: "0" })).toThrow(); }); test("memory defaults and caps are independently env-overridable", () => { diff --git a/web/tests/vm-freestyle-provider.test.ts b/web/tests/vm-freestyle-provider.test.ts index f30812f6b852..6fc07f993258 100644 --- a/web/tests/vm-freestyle-provider.test.ts +++ b/web/tests/vm-freestyle-provider.test.ts @@ -6,7 +6,9 @@ import { freestyleCmuxRemoteRoute, freestyleDaemonHealthyCommand, freestyleFirewallRules, + freestyleResizeRequest, freestyleStartDaemonCommand, + freestyleTargetResources, mapFreestyleState, normalizeFreestyleExecTimeout, renderFreestyleModelPlaneEnvFile, @@ -142,3 +144,41 @@ describe("Freestyle client configuration", () => { expect(packageJson.dependencies.freestyle).toBe("0.2.9"); }); }); + +describe("Freestyle machine sizing", () => { + test("the plan machine is 5 vCPU / 20 GB / 200 GB, vCPUs following memory", () => { + expect(freestyleTargetResources(20480, {})).toEqual({ cpu: 5, memory: 20480, storage: 204800 }); + expect(freestyleTargetResources(8192, {})).toEqual({ cpu: 2, memory: 8192, storage: 204800 }); + expect(freestyleTargetResources(4096, { CMUX_VM_DISK_MB: "65536" })).toEqual({ + cpu: 1, + memory: 4096, + storage: 65536, + }); + }); + + test("resize grows the devbox snapshot size to the plan machine", () => { + // Every VM boots at its snapshot's resources; the devbox snapshot is + // 2 vCPU / 4 GB / 16 GB, so a fresh create must grow all three. + expect(freestyleResizeRequest( + { cpu: 2, memory: 4096, storage: 16384 }, + { cpu: 5, memory: 20480, storage: 204800 }, + )).toEqual({ cpu: 5, memory: 20480, storage: 204800 }); + }); + + test("resize is grow-only and sends only the dimensions that grow", () => { + // A snapshot taken from an already-sized machine restores at that size: + // nothing to do. A snapshot larger than the request is never shrunk. + expect(freestyleResizeRequest( + { cpu: 5, memory: 20480, storage: 204800 }, + { cpu: 5, memory: 20480, storage: 204800 }, + )).toBeNull(); + expect(freestyleResizeRequest( + { cpu: 8, memory: 32768, storage: 262144 }, + { cpu: 5, memory: 20480, storage: 204800 }, + )).toBeNull(); + expect(freestyleResizeRequest( + { cpu: 5, memory: 20480, storage: 16384 }, + { cpu: 5, memory: 20480, storage: 204800 }, + )).toEqual({ storage: 204800 }); + }); +}); diff --git a/web/tests/vm-route-auth.test.ts b/web/tests/vm-route-auth.test.ts index f8dff81f3fab..875cbec17cad 100644 --- a/web/tests/vm-route-auth.test.ts +++ b/web/tests/vm-route-auth.test.ts @@ -405,12 +405,12 @@ describe("VM REST auth", () => { billingCustomerType: "team", billingTeamId: "team-1", billingPlanId: "pro", - maxActiveVms: null, + maxActiveVms: 50, provider: "freestyle", image: "snapshot-test", imageVersion: null, idempotencyKey: "idem-1", - memoryMb: 24576, + memoryMb: 20480, })); expect(listTeams).not.toHaveBeenCalled(); expect(runVmWorkflow).toHaveBeenCalled(); @@ -607,7 +607,7 @@ describe("VM REST auth", () => { const payload = await response.json(); expect(payload).toMatchObject({ error: "vm_memory_exceeds_plan", - details: { requestedMemoryMb: 32768, maxMemoryMb: 24576, planId: "free" }, + details: { requestedMemoryMb: 32768, maxMemoryMb: 20480, planId: "free" }, }); expect(runVmWorkflow).not.toHaveBeenCalled(); }); @@ -1390,7 +1390,7 @@ describe("VM REST auth", () => { billingCustomerType: "team", billingTeamId: "team-2", billingPlanId: "team", - maxActiveVms: null, + maxActiveVms: 50, })); expect(runVmWorkflow).toHaveBeenCalled(); }); From 0d488c2c630551a42b5efde1be27a2ef7fe1f140 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:20:13 -0700 Subject: [PATCH 02/12] Pricing test: expect the 50-machine compare row Claude-Session: https://claude.ai/code/session_01YKXxKXemYnpAhSyZ4uaVhF --- web/tests/pricing-page.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/tests/pricing-page.test.tsx b/web/tests/pricing-page.test.tsx index fb40ca2137a1..ab216f61f9af 100644 --- a/web/tests/pricing-page.test.tsx +++ b/web/tests/pricing-page.test.tsx @@ -100,8 +100,8 @@ describe("localized pricing page", () => { ).toEqual({ label: "Concurrent Cloud VMs", free: "false", - pro: "Up to 5", - team: "Up to 5", + pro: "50", + team: "50", enterprise: "Custom", }); expect(enMessages.dashboard.billing.free.upsellTitle).toBe( From 933e70f76c3f9a624bf3fa6ea6fe4021eba37e8b Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:45:28 -0700 Subject: [PATCH 03/12] Team plan: 50 Cloud VMs per paid seat syncTeamPlanMetadata now writes cmuxSeats (the Stripe subscription quantity) next to cmuxPlan, auth carries it as billingSeats, and the Team entitlement multiplies the 50-machine allowance by seats so the "50 per user" pricing copy is enforced per billing team. Pro and free are unchanged. Claude-Session: https://claude.ai/code/session_01YKXxKXemYnpAhSyZ4uaVhF --- web/messages/en.json | 6 +++--- web/messages/ja.json | 6 +++--- web/services/billing/pro.ts | 21 +++++++++++++++------ web/services/billing/purchase.ts | 4 ++-- web/services/billing/teamResolution.ts | 11 +++++++++++ web/services/vms/README.md | 2 +- web/services/vms/auth.ts | 7 +++++++ web/services/vms/entitlements.ts | 19 +++++++++++++++---- web/tests/billing-purchase.test.ts | 4 ++-- web/tests/connectivity-authority.test.ts | 1 + web/tests/iroh-route-handler.test.ts | 3 ++- web/tests/pricing-page.test.tsx | 2 +- web/tests/vault-route-helpers.test.ts | 1 + web/tests/vm-billing-limit-paywall.test.ts | 12 ++++++++++++ web/tests/vm-route-auth.test.ts | 4 ++-- 15 files changed, 78 insertions(+), 25 deletions(-) diff --git a/web/messages/en.json b/web/messages/en.json index a8e5dc9194ec..4c2123a379ba 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -670,7 +670,7 @@ "label": "Concurrent Cloud VMs", "free": "false", "pro": "50", - "team": "50", + "team": "50 per user", "enterprise": "Custom" }, { @@ -756,7 +756,7 @@ }, { "q": "What are Cloud VMs?", - "a": "Persistent Linux machines in the cloud: pets, not cattle. Following the Zen of cmux, a machine is a primitive, not a managed solution: it keeps its files, installed tools, and state, and what you build on it is yours. Agents clone repositories, run commands, and open pull requests there without touching your Mac. Pro and Team include up to 50 machines, each with 5 vCPU, 20 GB of RAM, and 200 GB of disk." + "a": "Persistent Linux machines in the cloud: pets, not cattle. Following the Zen of cmux, a machine is a primitive, not a managed solution: it keeps its files, installed tools, and state, and what you build on it is yours. Agents clone repositories, run commands, and open pull requests there without touching your Mac. Pro and Team include up to 50 machines per user, each with 5 vCPU, 20 GB of RAM, and 200 GB of disk." }, { "q": "What is cmux Vault?", @@ -769,7 +769,7 @@ }, { "q": "How does billing work?", - "a": "Pro is $50/mo, or $40/mo when billed annually (a 20% savings). Team is $60/user/mo, or $48/user/mo when billed annually. Paid plans include up to 50 Cloud VMs, with no metering or overages. The terminal and local agents stay free." + "a": "Pro is $50/mo, or $40/mo when billed annually (a 20% savings). Team is $60/user/mo, or $48/user/mo when billed annually. Paid plans include up to 50 Cloud VMs per user, with no metering or overages. The terminal and local agents stay free." }, { "q": "Can I self-host the cloud?", diff --git a/web/messages/ja.json b/web/messages/ja.json index 7932d936e51c..d1a785958132 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -670,7 +670,7 @@ "label": "同時 Cloud VM 数", "free": "false", "pro": "50", - "team": "50", + "team": "ユーザーごとに 50", "enterprise": "カスタム" }, { @@ -756,7 +756,7 @@ }, { "q": "Cloud VM とは何ですか?", - "a": "クラウド上の永続的な Linux マシンで、使い捨て(cattle)ではなくペットです。Zen of cmux のとおり、マシンはソリューションではなくプリミティブです。ファイル、インストールしたツール、状態はそのまま保持され、その上に構築したものはあなたのものです。エージェントはそこでリポジトリをクローンし、コマンドを実行し、プルリクエストを開くため、お使いの Mac には触れません。Pro と Team では最大 50 台のマシン(各 5 vCPU / 20 GB RAM / 200 GB ディスク)を利用できます。" + "a": "クラウド上の永続的な Linux マシンで、使い捨て(cattle)ではなくペットです。Zen of cmux のとおり、マシンはソリューションではなくプリミティブです。ファイル、インストールしたツール、状態はそのまま保持され、その上に構築したものはあなたのものです。エージェントはそこでリポジトリをクローンし、コマンドを実行し、プルリクエストを開くため、お使いの Mac には触れません。Pro と Team ではユーザーごとに最大 50 台のマシン(各 5 vCPU / 20 GB RAM / 200 GB ディスク)を利用できます。" }, { "q": "cmux Vault とは何ですか?", @@ -769,7 +769,7 @@ }, { "q": "課金はどのように行われますか?", - "a": "Pro は月払いで $50/月、年払いでは $40/月(20%割引)です。Team は月払いで $60/ユーザー/月、年払いでは $48/ユーザー/月です。有料プランでは最大 50 台の Cloud VM を利用でき、従量課金や超過料金はありません。ターミナルとローカルエージェントは無料のままです。" + "a": "Pro は月払いで $50/月、年払いでは $40/月(20%割引)です。Team は月払いで $60/ユーザー/月、年払いでは $48/ユーザー/月です。有料プランではユーザーごとに最大 50 台の Cloud VM を利用でき、従量課金や超過料金はありません。ターミナルとローカルエージェントは無料のままです。" }, { "q": "クラウドをセルフホストできますか?", diff --git a/web/services/billing/pro.ts b/web/services/billing/pro.ts index 0d1fea14ce78..1fb2a56e0f08 100644 --- a/web/services/billing/pro.ts +++ b/web/services/billing/pro.ts @@ -583,27 +583,36 @@ export function metadataPlanId(raw: unknown): string | null { } /** - * Writes `cmuxPlan: "team"` into a Stack team's clientReadOnlyMetadata while a - * Stripe Team subscription is active. `cmuxVmPlan` is operator-owned and left + * Writes `cmuxPlan: "team"` and `cmuxSeats` (the subscription quantity) into + * a Stack team's clientReadOnlyMetadata while a Stripe Team subscription is + * active; both are removed when it lapses. Seats size the team's Cloud VM + * allowance (50 machines per seat), so a quantity change must land here even + * when the plan id is unchanged. `cmuxVmPlan` is operator-owned and left * untouched. */ export async function syncTeamPlanMetadata( team: ProMetadataCustomer, isTeam: boolean, + seats: number | null = null, ): Promise { const raw = team.clientReadOnlyMetadata; const metadata: Record = raw && typeof raw === "object" && !Array.isArray(raw) ? { ...(raw as Record) } : {}; - const current = metadata.cmuxPlan; + const currentPlan = metadata.cmuxPlan; + const currentSeats = metadata.cmuxSeats; if (isTeam) { - if (current === TEAM_PLAN_ID) return; + const nextSeats = seats !== null && Number.isSafeInteger(seats) && seats > 0 ? seats : null; + if (currentPlan === TEAM_PLAN_ID && currentSeats === (nextSeats ?? undefined)) return; metadata.cmuxPlan = TEAM_PLAN_ID; + if (nextSeats === null) delete metadata.cmuxSeats; + else metadata.cmuxSeats = nextSeats; } else { - if (current !== TEAM_PLAN_ID) return; - delete metadata.cmuxPlan; + if (currentPlan !== TEAM_PLAN_ID && currentSeats === undefined) return; + if (currentPlan === TEAM_PLAN_ID) delete metadata.cmuxPlan; + delete metadata.cmuxSeats; } await team.update({ clientReadOnlyMetadata: metadata as ProMetadataJson }); } diff --git a/web/services/billing/purchase.ts b/web/services/billing/purchase.ts index 16c3e0476db7..f85c7a98b3e6 100644 --- a/web/services/billing/purchase.ts +++ b/web/services/billing/purchase.ts @@ -1936,7 +1936,7 @@ export async function applySubscriptionUpdate( : await db.transaction((tx) => applyTeamUpdate(tx, { kind: "legacy-team" })); if ("skipped" in lockedResult) return { skipped: true }; const team = await loadStackTeam(teamScope.stackTeamId, dependencies.stackApp); - await syncTeamPlanMetadata(team, isActive); + await syncTeamPlanMetadata(team, isActive, subscriptionSeats(subscription)); return lockedResult; } @@ -2433,7 +2433,7 @@ async function recordTeamCheckoutCompletion(input: { lockedResult.postCommitTeamSync.stackTeamId, lockedResult.postCommitTeamSync.stackApp, ); - await syncTeamPlanMetadata(team, true); + await syncTeamPlanMetadata(team, true, subscriptionSeats(input.subscription)); } return lockedResult.result; diff --git a/web/services/billing/teamResolution.ts b/web/services/billing/teamResolution.ts index d5e06dafff4c..d69c3aed13fe 100644 --- a/web/services/billing/teamResolution.ts +++ b/web/services/billing/teamResolution.ts @@ -70,6 +70,17 @@ export function billingPlanIdFromMetadata(metadata: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } +/** + * Paid seats on a team (`cmuxSeats`, written from the Stripe subscription + * quantity by syncTeamPlanMetadata). Null when absent or malformed; callers + * treat that as a single seat. + */ +export function billingSeatsFromMetadata(metadata: unknown): number | null { + if (!metadata || typeof metadata !== "object") return null; + const value = (metadata as { cmuxSeats?: unknown }).cmuxSeats; + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : null; +} + function hasActiveBillingPlan(metadata: unknown): boolean { const planId = billingPlanIdFromMetadata(metadata); return !!planId && planId !== "free"; diff --git a/web/services/vms/README.md b/web/services/vms/README.md index c4c2074298c9..9baac8964e4e 100644 --- a/web/services/vms/README.md +++ b/web/services/vms/README.md @@ -385,7 +385,7 @@ daemon health with `bun run cloud-vm:stress -- --provider default`. The usage ledger is in Postgres. VM create pricing gates can use Stack Auth payment items, but free-plan create credits are opt-in. Configure `CMUX_VM_PLAN_FREE_CREATE_CREDIT_ITEM_ID` only when the free plan should consume a prepaid create-credit bucket. When enabled, the create workflow records a one-time local grant row, seeds the configured Stack Auth item credits once per billing team, reserves one create credit only for a newly inserted row, calls the provider, and refunds the credit if provisioning fails before a usable VM exists. -Plan limits are team-based. Stack Auth personal teams should stay enabled for both dev/staging and production projects (`createTeamOnSignUp` / `teams.createPersonalTeamOnSignUp`). New VM rows store `billing_team_id` and `billing_plan_id`; the free plan allows zero active VMs by default and remains at zero regardless of stale free-limit env values while the paid-plan gate is on. A deliberate `CMUX_VM_ALLOW_FREE_PROVISIONING=1` escape hatch re-enables the configured free allowance for local demos or a controlled rollback; paid plans get the allowance sold on /pricing, 50 active machines per billing team (`PAID_MAX_ACTIVE_VMS_DEFAULT`; `maxActiveVms` in entitlements and the list response). Every machine is the plan machine: 20 GB memory, 5 vCPU (one per 4 GB), and a 200 GB disk, which the Freestyle driver grows the VM to at create (`CMUX_VM_DISK_MB` overrides the disk). Destroyed VMs do not count against a limit; pausing does not free quota on the production provider. Paid plan activation should write a readable plan id such as `pro` into Stack Auth team read-only metadata (`cmuxVmPlan`) or equivalent billing sync metadata. `CMUX_VM_PLAN__MAX_ACTIVE_VMS` and `CMUX_VM_PAID_MAX_ACTIVE_VMS` exist only as incident brakes; the product number lives in code. Paid plans only consume Stack Auth create credits when `CMUX_VM_PLAN__CREATE_CREDIT_ITEM_ID` or the global `CMUX_VM_CREATE_CREDIT_ITEM_ID` is configured. +Plan limits are team-based. Stack Auth personal teams should stay enabled for both dev/staging and production projects (`createTeamOnSignUp` / `teams.createPersonalTeamOnSignUp`). New VM rows store `billing_team_id` and `billing_plan_id`; the free plan allows zero active VMs by default and remains at zero regardless of stale free-limit env values while the paid-plan gate is on. A deliberate `CMUX_VM_ALLOW_FREE_PROVISIONING=1` escape hatch re-enables the configured free allowance for local demos or a controlled rollback; paid plans get the allowance sold on /pricing, 50 active machines per billing team, multiplied by the Team subscription's paid seats (`cmuxSeats` in the team's Stack metadata, written from the Stripe quantity) so "50 per user" holds for the whole team (`PAID_MAX_ACTIVE_VMS_DEFAULT`; `maxActiveVms` in entitlements and the list response). Every machine is the plan machine: 20 GB memory, 5 vCPU (one per 4 GB), and a 200 GB disk, which the Freestyle driver grows the VM to at create (`CMUX_VM_DISK_MB` overrides the disk). Destroyed VMs do not count against a limit; pausing does not free quota on the production provider. Paid plan activation should write a readable plan id such as `pro` into Stack Auth team read-only metadata (`cmuxVmPlan`) or equivalent billing sync metadata. `CMUX_VM_PLAN__MAX_ACTIVE_VMS` and `CMUX_VM_PAID_MAX_ACTIVE_VMS` exist only as incident brakes; the product number lives in code. Paid plans only consume Stack Auth create credits when `CMUX_VM_PLAN__CREATE_CREDIT_ITEM_ID` or the global `CMUX_VM_CREATE_CREDIT_ITEM_ID` is configured. ### The free limit is the paywall moment diff --git a/web/services/vms/auth.ts b/web/services/vms/auth.ts index aa9d1bc75f6f..b089bb660bcf 100644 --- a/web/services/vms/auth.ts +++ b/web/services/vms/auth.ts @@ -10,6 +10,7 @@ import { } from "../account/deletionLock"; import { billingPlanIdFromMetadata, + billingSeatsFromMetadata, billingTeamFromUnknown, resolveBillingTeam, type BillingTeamLike, @@ -26,6 +27,8 @@ export type AuthedUser = { teamIds: readonly string[]; userBillingPlanId: string | null; billingPlanId: string | null; + /** Paid seats on the resolved billing team; null for user billing or unknown. */ + billingSeats: number | null; resolveSubrouterPermissions: ( teamId: string, ) => Promise; @@ -35,6 +38,7 @@ export type AuthedTeam = { id: string; displayName: string | null; billingPlanId: string | null; + billingSeats: number | null; }; export type SubrouterPermissions = { @@ -543,6 +547,7 @@ async function authedUserFromStackUser( }); const userBillingPlanId = billingPlanIdFromMetadata(user.clientReadOnlyMetadata) ?? null; const billingPlanId = billingPlanIdFromMetadata(billingTeam?.clientReadOnlyMetadata) ?? userBillingPlanId; + const billingSeats = billingSeatsFromMetadata(billingTeam?.clientReadOnlyMetadata); const rawTeams = new Map(); if (selectedTeam) rawTeams.set(selectedTeam.id, selectedTeamRaw); for (const raw of listedTeamRaw) { @@ -557,6 +562,7 @@ async function authedUserFromStackUser( id: team.id, displayName: team.displayName, billingPlanId: billingPlanIdFromMetadata(team.clientReadOnlyMetadata), + billingSeats: billingSeatsFromMetadata(team.clientReadOnlyMetadata), })); const subrouterPermissionCache = new Map< string, @@ -574,6 +580,7 @@ async function authedUserFromStackUser( teamIds, userBillingPlanId, billingPlanId, + billingSeats, resolveSubrouterPermissions: async (teamId) => { const cached = subrouterPermissionCache.get(teamId); if (cached) return cached; diff --git a/web/services/vms/entitlements.ts b/web/services/vms/entitlements.ts index 724775b695ec..50c6a4ef7725 100644 --- a/web/services/vms/entitlements.ts +++ b/web/services/vms/entitlements.ts @@ -64,7 +64,7 @@ export function resolveVmEntitlements( planId, billingCustomerType: billing.billingCustomerType, billingTeamId: billing.billingTeamId, - maxActiveVms: maxActiveVmsForPlan(planId, env), + maxActiveVms: maxActiveVmsForPlan(planId, env, { seats: billing.billingSeats }), }; } @@ -79,6 +79,7 @@ function resolveBillingContext( readonly billingCustomerType: BillingCustomerType; readonly billingTeamId: string; readonly billingPlanId: string | null; + readonly billingSeats: number | null; } { const requestedTeamId = normalizedOptionalString(options.requestedBillingTeamId); if (requestedTeamId) { @@ -94,6 +95,7 @@ function resolveBillingContext( billingCustomerType: "team", billingTeamId: team.id, billingPlanId: team.billingPlanId ?? user.userBillingPlanId, + billingSeats: team.billingSeats, }; } @@ -102,6 +104,7 @@ function resolveBillingContext( billingCustomerType: "team", billingTeamId: user.billingTeamId, billingPlanId: user.billingPlanId ?? user.userBillingPlanId, + billingSeats: user.billingSeats, }; } @@ -121,6 +124,7 @@ function resolveBillingContext( billingCustomerType: "user", billingTeamId: user.billingTeamId, billingPlanId: user.userBillingPlanId, + billingSeats: null, }; } @@ -180,14 +184,21 @@ export function defaultMemoryMbForPlan( /** * Active-machine ceiling for a plan, or null when there is none. Paid plans * get the allowance sold on /pricing (PAID_MAX_ACTIVE_VMS_DEFAULT), counted - * per billing team. Free plans stay capped (zero unless free provisioning is - * allowed). + * per billing team; a Team subscription multiplies it by its paid seats + * (`cmuxSeats`), so "50 per user" holds for the whole team. Free plans stay + * capped (zero unless free provisioning is allowed). */ export function maxActiveVmsForPlan( planId: string | null | undefined, env: Record = process.env, + options: { readonly seats?: number | null } = {}, ): number | null { - return activeVmLimitForPlan(normalizedPlanId(planId ?? ""), env); + const normalized = normalizedPlanId(planId ?? ""); + const limit = activeVmLimitForPlan(normalized, env); + if (limit === null || normalized !== TEAM_PLAN_ID) return limit; + const seats = options.seats; + const paidSeats = typeof seats === "number" && Number.isSafeInteger(seats) && seats > 0 ? seats : 1; + return limit * paidSeats; } /** diff --git a/web/tests/billing-purchase.test.ts b/web/tests/billing-purchase.test.ts index 89dec8d13f37..f9465dc477c7 100644 --- a/web/tests/billing-purchase.test.ts +++ b/web/tests/billing-purchase.test.ts @@ -2259,7 +2259,7 @@ describe("recordCheckoutCompletion", () => { ), ).toBe(true); expect(updateTeam).toHaveBeenCalledWith({ - clientReadOnlyMetadata: { cmuxPlan: "team" }, + clientReadOnlyMetadata: { cmuxPlan: "team", cmuxSeats: 4 }, }); }); @@ -2310,7 +2310,7 @@ describe("recordCheckoutCompletion", () => { ), ).toBe(true); expect(updateTeam).toHaveBeenCalledWith({ - clientReadOnlyMetadata: { cmuxPlan: "team" }, + clientReadOnlyMetadata: { cmuxPlan: "team", cmuxSeats: 4 }, }); }); diff --git a/web/tests/connectivity-authority.test.ts b/web/tests/connectivity-authority.test.ts index c0a56bbb4026..fe407ad4c282 100644 --- a/web/tests/connectivity-authority.test.ts +++ b/web/tests/connectivity-authority.test.ts @@ -20,6 +20,7 @@ const USER: AuthedUser = { teamIds: [], userBillingPlanId: null, billingPlanId: null, + billingSeats: null, resolveSubrouterPermissions: async () => ({ use: false, manageAccounts: false, diff --git a/web/tests/iroh-route-handler.test.ts b/web/tests/iroh-route-handler.test.ts index 4bb17b0a48ca..8d3dbfc52541 100644 --- a/web/tests/iroh-route-handler.test.ts +++ b/web/tests/iroh-route-handler.test.ts @@ -16,10 +16,11 @@ const USER: AuthedUser = { billingCustomerType: "team", billingTeamId: "selected-team-id", selectedTeamId: "selected-team-id", - teams: [{ id: "selected-team-id", displayName: null, billingPlanId: null }], + teams: [{ id: "selected-team-id", displayName: null, billingPlanId: null, billingSeats: null }], teamIds: ["selected-team-id"], userBillingPlanId: null, billingPlanId: null, + billingSeats: null, resolveSubrouterPermissions: async () => ({ use: false, manageAccounts: false, diff --git a/web/tests/pricing-page.test.tsx b/web/tests/pricing-page.test.tsx index ab216f61f9af..4801a81e5a97 100644 --- a/web/tests/pricing-page.test.tsx +++ b/web/tests/pricing-page.test.tsx @@ -101,7 +101,7 @@ describe("localized pricing page", () => { label: "Concurrent Cloud VMs", free: "false", pro: "50", - team: "50", + team: "50 per user", enterprise: "Custom", }); expect(enMessages.dashboard.billing.free.upsellTitle).toBe( diff --git a/web/tests/vault-route-helpers.test.ts b/web/tests/vault-route-helpers.test.ts index aaaf2ab41a53..c424202f41da 100644 --- a/web/tests/vault-route-helpers.test.ts +++ b/web/tests/vault-route-helpers.test.ts @@ -279,6 +279,7 @@ const testUser: AuthedUser = { teamIds: [], userBillingPlanId: null, billingPlanId: null, + billingSeats: null, resolveSubrouterPermissions: async () => ({ use: false, manageAccounts: false, diff --git a/web/tests/vm-billing-limit-paywall.test.ts b/web/tests/vm-billing-limit-paywall.test.ts index adb7bdf8cca1..e93fe3470bda 100644 --- a/web/tests/vm-billing-limit-paywall.test.ts +++ b/web/tests/vm-billing-limit-paywall.test.ts @@ -27,6 +27,18 @@ describe("free plan VM allowance", () => { expect(maxActiveVmsForPlan("founders", {})).toBe(50); }); + test("a Team subscription gets 50 machines per paid seat", () => { + expect(maxActiveVmsForPlan("team", {}, { seats: 4 })).toBe(200); + expect(maxActiveVmsForPlan("team", {}, { seats: 1 })).toBe(50); + expect(maxActiveVmsForPlan("team", {}, { seats: null })).toBe(50); + expect(maxActiveVmsForPlan("team", {}, { seats: 0 })).toBe(50); + // Seats only mean something on the Team plan. + expect(maxActiveVmsForPlan("pro", {}, { seats: 4 })).toBe(50); + expect(maxActiveVmsForPlan("free", {}, { seats: 4 })).toBe(0); + // Operator brakes scale with seats too. + expect(maxActiveVmsForPlan("team", { CMUX_VM_PLAN_TEAM_MAX_ACTIVE_VMS: "2" }, { seats: 3 })).toBe(6); + }); + test("operator brakes: a plan-specific cap wins over the paid-wide cap", () => { expect(maxActiveVmsForPlan("pro", { CMUX_VM_PAID_MAX_ACTIVE_VMS: "5" })).toBe(5); expect(maxActiveVmsForPlan("pro", { diff --git a/web/tests/vm-route-auth.test.ts b/web/tests/vm-route-auth.test.ts index 875cbec17cad..c3965e8f9bee 100644 --- a/web/tests/vm-route-auth.test.ts +++ b/web/tests/vm-route-auth.test.ts @@ -1365,7 +1365,7 @@ describe("VM REST auth", () => { selectedTeam: null, listTeams: async () => [ { id: "team-1", clientReadOnlyMetadata: { cmuxVmPlan: "free" } }, - { id: "team-2", clientReadOnlyMetadata: { cmuxPlan: "team" } }, + { id: "team-2", clientReadOnlyMetadata: { cmuxPlan: "team", cmuxSeats: 4 } }, ], }); runVmWorkflow.mockResolvedValue({ @@ -1390,7 +1390,7 @@ describe("VM REST auth", () => { billingCustomerType: "team", billingTeamId: "team-2", billingPlanId: "team", - maxActiveVms: 50, + maxActiveVms: 200, })); expect(runVmWorkflow).toHaveBeenCalled(); }); From f9b425df986d073df0f5f2e58ccc86965e08d40a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:52:33 -0700 Subject: [PATCH 04/12] Sell exactly the plan machine; migrate NewMachineModelTests to Swift Testing The pricing copy promises every Cloud VM is 5 vCPU / 20 GB / 200 GB, so the size list is now that one entry: the API refuses other sizes with vm_memory_unsupported, the Mac picker and CLI presets offer only 20g. Claude-Session: https://claude.ai/code/session_01YKXxKXemYnpAhSyZ4uaVhF --- CLI/CMUXCLI+VMTransfer.swift | 4 +- CLI/cmux.swift | 11 +- Sources/Cloud/NewMachineModel.swift | 5 +- cmuxTests/NewMachineModelTests.swift | 209 ++++++++++----------- web/app/api/vm/route.ts | 12 ++ web/services/vms/entitlements.ts | 10 +- web/tests/vm-billing-limit-paywall.test.ts | 2 +- web/tests/vm-route-auth.test.ts | 25 ++- 8 files changed, 151 insertions(+), 127 deletions(-) diff --git a/CLI/CMUXCLI+VMTransfer.swift b/CLI/CMUXCLI+VMTransfer.swift index ed62082a533e..aa80f1454e19 100644 --- a/CLI/CMUXCLI+VMTransfer.swift +++ b/CLI/CMUXCLI+VMTransfer.swift @@ -605,7 +605,7 @@ extension CMUXCLI { static var vmRunUsage: String { """ - Usage: cmux vm run [--sync] [--pull ] [--machine ] [--new] [--size <2g|4g|8g|16g|20g>] [--timeout ] -- + Usage: cmux vm run [--sync] [--pull ] [--machine ] [--new] [--size <20g>] [--timeout ] -- Run a command on a cloud machine without naming one: reuses an idle machine the router itself provisioned earlier (shown as "\(vmRunPoolLabel)" @@ -1090,7 +1090,7 @@ extension CMUXCLI { extension CMUXCLI { static var vmRouteUsage: String { """ - Usage: cmux vm route [--cwd ] [--new] [--provision] [--size <2g|4g|8g|16g|20g>] [--json] + Usage: cmux vm route [--cwd ] [--new] [--provision] [--size <20g>] [--json] Print the machine `cmux vm run` / `cmux vm agent` would use for work in a directory, and why — without running anything. The policy is the router's diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 916416a48c99..02f86bd7b626 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -4294,14 +4294,11 @@ struct CMUXCLI { // never pins an image id unless the person passes `--image`: a pinned id // that drifted from the web deploy's manifest failed every create with // `vm_image_config_error`. - /// `--size` spellings → memory in MB. vCPUs scale with memory (one per - /// 4 GB); 20g is the plan machine and every plan's ceiling. + /// `--size` spellings → memory in MB. Every plan sells exactly the plan + /// machine (5 vCPU / 20 GB / 200 GB), so 20g is the only preset; the + /// backend refuses other sizes with `vm_memory_unsupported`. private static let cloudVMSizeAliases: [String: Int] = [ - "2g": 2048, "2gb": 2048, "small": 2048, - "4g": 4096, "4gb": 4096, "medium": 4096, - "8g": 8192, "8gb": 8192, "large": 8192, - "16g": 16384, "16gb": 16384, "xl": 16384, - "20g": 20480, "20gb": 20480, "xxl": 20480, + "20g": 20480, "20gb": 20480, ] static func parseCloudVMSize(_ raw: String) -> Int? { let key = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() diff --git a/Sources/Cloud/NewMachineModel.swift b/Sources/Cloud/NewMachineModel.swift index aaf5cab893ab..93c8264ab87a 100644 --- a/Sources/Cloud/NewMachineModel.swift +++ b/Sources/Cloud/NewMachineModel.swift @@ -31,8 +31,9 @@ final class NewMachineModel { typealias Launch = @MainActor ([String], @escaping @MainActor (CloudVMActionLauncher.Completion) -> Void) -> Bool /// Memory sizes the backend accepts (`VM_MEMORY_OPTIONS_MB` in - /// `web/services/vms/entitlements.ts`); the plan ceiling trims the tail. - static let memoryOptionsMb: [Int] = [2048, 4096, 8192, 16384, planMachineMemoryMb] + /// `web/services/vms/entitlements.ts`): exactly the plan machine, so the + /// picker mirrors what /pricing promises. The plan ceiling trims the tail. + static let memoryOptionsMb: [Int] = [planMachineMemoryMb] /// The plan machine (`PLAN_MACHINE_MEMORY_MB`): 20 GB, 5 vCPU, 200 GB disk. static let planMachineMemoryMb = 20480 /// Mirrors `maxMemoryMbForPlan`: every plan, free included, gets the plan diff --git a/cmuxTests/NewMachineModelTests.swift b/cmuxTests/NewMachineModelTests.swift index c9c9ed62c9ac..6d25fe08820a 100644 --- a/cmuxTests/NewMachineModelTests.swift +++ b/cmuxTests/NewMachineModelTests.swift @@ -1,5 +1,5 @@ import Foundation -import XCTest +import Testing #if canImport(cmux_DEV) @testable import cmux_DEV @@ -9,8 +9,9 @@ import XCTest /// The New Machine sheet's model: the CLI invocation it builds, the plan /// ceilings it mirrors, and how a failed create is surfaced for retry. +@Suite("New machine model") @MainActor -final class NewMachineModelTests: XCTestCase { +struct NewMachineModelTests { private struct LaunchRecorder { var arguments: [[String]] = [] var pendingCompletion: (@MainActor (CloudVMActionLauncher.Completion) -> Void)? @@ -38,29 +39,29 @@ final class NewMachineModelTests: XCTestCase { // MARK: Kind - func testKindInferredFromImageWhenBackendOmitsIt() { - XCTAssertEqual(VMMachineKind.inferred(fromImage: "cmux-xfce-vnc:latest"), .desktop) - XCTAssertEqual(VMMachineKind.inferred(fromImage: "cmuxd-ws:tooling-20260509f"), .base) - XCTAssertEqual(VMMachineKind.inferred(fromImage: ""), .base) + @Test func testKindInferredFromImageWhenBackendOmitsIt() { + #expect(VMMachineKind.inferred(fromImage: "cmux-xfce-vnc:latest") == .desktop) + #expect(VMMachineKind.inferred(fromImage: "cmuxd-ws:tooling-20260509f") == .base) + #expect(VMMachineKind.inferred(fromImage: "") == .base) } /// Regression: `devbox` used to imply a desktop because one provider's /// devbox image bundled xfce + noVNC. The shared devbox image every /// remaining provider boots is shell-only, so inferring a desktop from the /// name published a Desktop surface for a machine with no screen. - func testSharedDevboxImageIsNotInferredAsDesktop() { - XCTAssertEqual(VMMachineKind.inferred(fromImage: "cmux-devbox:devbox-20260828b"), .base) - XCTAssertEqual(VMMachineKind.inferred(fromImage: "cmux-devbox-20260828b"), .base) + @Test func testSharedDevboxImageIsNotInferredAsDesktop() { + #expect(VMMachineKind.inferred(fromImage: "cmux-devbox:devbox-20260828b") == .base) + #expect(VMMachineKind.inferred(fromImage: "cmux-devbox-20260828b") == .base) } - func testResolvedKindPrefersBackendField() { - XCTAssertEqual(VMMachineKind.resolved(kind: "base", image: "cmux-devbox:devbox-20260828b"), .base) - XCTAssertEqual(VMMachineKind.resolved(kind: "DESKTOP", image: "cmuxd-ws:tooling-20260509f"), .desktop) - XCTAssertEqual(VMMachineKind.resolved(kind: "bogus", image: "cmux-xfce-vnc:latest"), .desktop) - XCTAssertEqual(VMMachineKind.resolved(kind: nil, image: nil), .base) + @Test func testResolvedKindPrefersBackendField() { + #expect(VMMachineKind.resolved(kind: "base", image: "cmux-devbox:devbox-20260828b") == .base) + #expect(VMMachineKind.resolved(kind: "DESKTOP", image: "cmuxd-ws:tooling-20260509f") == .desktop) + #expect(VMMachineKind.resolved(kind: "bogus", image: "cmux-xfce-vnc:latest") == .desktop) + #expect(VMMachineKind.resolved(kind: nil, image: nil) == .base) } - func testSummaryResolvedKindPrefersServerKindOverImageName() { + @Test func testSummaryResolvedKindPrefersServerKindOverImageName() { var summary = VMSummary( id: "noble-wren", provider: "freestyle", @@ -71,142 +72,138 @@ final class NewMachineModelTests: XCTestCase { createdAt: 0, base: nil ) - XCTAssertEqual(summary.resolvedKind, .desktop) + #expect(summary.resolvedKind == .desktop) summary.kind = .base - XCTAssertEqual(summary.resolvedKind, .base) - XCTAssertFalse(MachineSnapshotBuilder.snapshot(from: summary).isDesktop) + #expect(summary.resolvedKind == .base) + #expect(!(MachineSnapshotBuilder.snapshot(from: summary).isDesktop)) } // MARK: CLI arguments - func testDefaultInvocationRequestsDesktopByKindWithoutPinningAnImage() { + @Test func testDefaultInvocationRequestsDesktopByKindWithoutPinningAnImage() { let (model, _) = makeModel() - XCTAssertEqual(model.cliArguments, ["vm", "new", "--desktop", "--size", "20480"]) - XCTAssertFalse(model.cliArguments.contains("--image")) + #expect(model.cliArguments == ["vm", "new", "--desktop", "--size", "20480"]) + #expect(!(model.cliArguments.contains("--image"))) } - func testBaseKindSizeAndNameTravelAsFlags() { + @Test func testBaseKindSizeAndNameTravelAsFlags() { let (model, _) = makeModel(plan: MachinePlanSnapshot(activeCount: 1, maxActiveVms: 5, planId: "pro")) model.kind = .base - model.memoryMb = 8192 model.name = " build box " - XCTAssertEqual(model.cliArguments, ["vm", "new", "--base", "--size", "8192", "--name", "build box"]) + #expect(model.cliArguments == ["vm", "new", "--base", "--size", "20480", "--name", "build box"]) } - func testBlankNameIsNotSent() { + @Test func testBlankNameIsNotSent() { let (model, _) = makeModel() model.name = " " - XCTAssertNil(model.trimmedName) - XCTAssertFalse(model.cliArguments.contains("--name")) + #expect(model.trimmedName == nil) + #expect(!(model.cliArguments.contains("--name"))) } - func testBaseSetupOpensTheWorkspaceWithoutSizeOrName() { + @Test func testBaseSetupOpensTheWorkspaceWithoutSizeOrName() { let workspaceID = UUID() let (model, _) = makeModel(mode: .base(workspaceID: workspaceID)) - XCTAssertFalse(model.supportsSize) - XCTAssertFalse(model.supportsName) + #expect(!(model.supportsSize)) + #expect(!(model.supportsName)) model.name = "ignored" model.kind = .base - XCTAssertEqual( - model.cliArguments, - ["vm", "base", "open", "--workspace", workspaceID.uuidString, "--base"] - ) + #expect(model.cliArguments == ["vm", "base", "open", "--workspace", workspaceID.uuidString, "--base"]) } // MARK: Plan ceilings - func testFreePlanGetsThePlanMachine() { + @Test func testFreePlanGetsThePlanMachine() { let (model, _) = makeModel(plan: MachinePlanSnapshot(activeCount: 0, maxActiveVms: 1, planId: "free")) - XCTAssertEqual(model.memoryOptions, [2048, 4096, 8192, 16384, 20480]) - XCTAssertEqual(model.memoryMb, 20480) + #expect(model.memoryOptions == [20480]) + #expect(model.memoryMb == 20480) } - func testPaidPlanGetsThePlanMachineAndDefaultsToIt() { + @Test func testPaidPlanGetsThePlanMachineAndDefaultsToIt() { let (model, _) = makeModel(plan: MachinePlanSnapshot(activeCount: 2, maxActiveVms: 50, planId: "pro")) - XCTAssertEqual(model.memoryOptions.last, 20480) - XCTAssertEqual(model.memoryMb, 20480) + #expect(model.memoryOptions == [20480]) + #expect(model.memoryMb == 20480) } - func testUnknownPlanUsesThePlanMachineCeiling() { + @Test func testUnknownPlanUsesThePlanMachineCeiling() { let (model, _) = makeModel(plan: nil) - XCTAssertEqual(model.memoryOptions.last, 20480) - XCTAssertNil(model.planMeterText) - XCTAssertNil(model.freeAccessNoteText) + #expect(model.memoryOptions == [20480]) + #expect(model.planMeterText == nil) + #expect(model.freeAccessNoteText == nil) } - func testPlanTextsMirrorTheMeterAndFreeWindow() { + @Test func testPlanTextsMirrorTheMeterAndFreeWindow() { let free = MachinePlanSnapshot(activeCount: 0, maxActiveVms: 1, planId: "free", freeAccessWindowDays: 7) let (model, _) = makeModel(plan: free) - XCTAssertEqual(model.planMeterText, "0 of 1 machine in use") - XCTAssertEqual(model.freeAccessNoteText, "Free plan: this machine stays reachable for 7 days. Upgrade to keep it.") + #expect(model.planMeterText == "0 of 1 machine in use") + #expect(model.freeAccessNoteText == "Free plan: this machine stays reachable for 7 days. Upgrade to keep it.") let pro = MachinePlanSnapshot(activeCount: 2, maxActiveVms: 5, planId: "pro", freeAccessWindowDays: 7) let (proModel, _) = makeModel(plan: pro) - XCTAssertEqual(proModel.planMeterText, "2 of 5 machines in use") - XCTAssertNil(proModel.freeAccessNoteText) + #expect(proModel.planMeterText == "2 of 5 machines in use") + #expect(proModel.freeAccessNoteText == nil) } - func testSelectedImageFollowsTheKind() { + @Test func testSelectedImageFollowsTheKind() { let kinds = [ VMImageKindOption(kind: .desktop, image: "cmux-xfce-vnc:latest"), VMImageKindOption(kind: .base, image: "cmuxd-ws:tooling-20260509f"), ] let (model, _) = makeModel(imageKinds: kinds) - XCTAssertEqual(model.selectedImage, "cmux-xfce-vnc:latest") + #expect(model.selectedImage == "cmux-xfce-vnc:latest") model.kind = .base - XCTAssertEqual(model.selectedImage, "cmuxd-ws:tooling-20260509f") + #expect(model.selectedImage == "cmuxd-ws:tooling-20260509f") } /// The sheet must not open preselected on a kind the deployment cannot /// provision: no provider ships a desktop image today, so a desktop /// default would make the primary button fail with an image config error. - func testKindDefaultsToAServableKind() { + @Test func testKindDefaultsToAServableKind() { let baseOnly = [VMImageKindOption(kind: .base, image: "cmuxd-ws:tooling-20260509f")] let (baseModel, _) = makeModel(imageKinds: baseOnly) - XCTAssertEqual(baseModel.kind, .base) - XCTAssertEqual(baseModel.selectableKinds, [.base]) + #expect(baseModel.kind == .base) + #expect(baseModel.selectableKinds == [.base]) let both = [ VMImageKindOption(kind: .desktop, image: "cmux-xfce-vnc:latest"), VMImageKindOption(kind: .base, image: "cmuxd-ws:tooling-20260509f"), ] let (bothModel, _) = makeModel(imageKinds: both) - XCTAssertEqual(bothModel.kind, .desktop) - XCTAssertEqual(bothModel.selectableKinds, [.desktop, .base]) + #expect(bothModel.kind == .desktop) + #expect(bothModel.selectableKinds == [.desktop, .base]) } /// An older control plane sends no `limits.imageKinds`. Offering nothing /// would be worse than offering both, so the sheet keeps the full picker. - func testUnknownImageKindsStillOfferEveryKind() { + @Test func testUnknownImageKindsStillOfferEveryKind() { let (model, _) = makeModel(imageKinds: []) - XCTAssertEqual(model.selectableKinds, VMMachineKind.allCases) - XCTAssertEqual(model.kind, .base) + #expect(model.selectableKinds == VMMachineKind.allCases) + #expect(model.kind == .base) } - func testMemoryLabelsReadInGigabytes() { - XCTAssertEqual(NewMachineModel.memoryLabel(mb: 24576), "24 GB") - XCTAssertEqual(NewMachineModel.memoryLabel(mb: 3000), "3000 MB") + @Test func testMemoryLabelsReadInGigabytes() { + #expect(NewMachineModel.memoryLabel(mb: 24576) == "24 GB") + #expect(NewMachineModel.memoryLabel(mb: 3000) == "3000 MB") } // MARK: Create lifecycle - func testCreateLaunchesOnceAndFinishesOnSuccess() { + @Test func testCreateLaunchesOnceAndFinishesOnSuccess() { let (model, recorder) = makeModel() var outcomes: [NewMachineModel.Outcome] = [] model.onFinished = { outcomes.append($0) } model.create() - XCTAssertTrue(model.isCreating) + #expect(model.isCreating) model.create() - XCTAssertEqual(recorder.value.arguments.count, 1, "a second click while creating must not launch again") + #expect(recorder.value.arguments.count == 1, "a second click while creating must not launch again") recorder.value.pendingCompletion?(CloudVMActionLauncher.Completion(terminationStatus: 0, output: "", workspaceId: nil)) - XCTAssertFalse(model.isCreating) - XCTAssertEqual(model.outcome, .created) - XCTAssertEqual(outcomes, [.created]) + #expect(!(model.isCreating)) + #expect(model.outcome == .created) + #expect(outcomes == [.created]) } - func testFailureShowsTheCLIOutputAndAllowsRetry() { + @Test func testFailureShowsTheCLIOutputAndAllowsRetry() { let (model, recorder) = makeModel() model.create() recorder.value.pendingCompletion?(CloudVMActionLauncher.Completion( @@ -214,51 +211,45 @@ final class NewMachineModelTests: XCTestCase { output: "Cloud VM temporarily unavailable (HTTP 503: vm_image_config_error)\n\nWhat to do:\n Retry without `image`.\n", workspaceId: nil )) - XCTAssertFalse(model.isCreating) - XCTAssertNil(model.outcome) - XCTAssertEqual( - model.errorText, - "Cloud VM temporarily unavailable (HTTP 503: vm_image_config_error)\n\nWhat to do:\n Retry without `image`." - ) + #expect(!(model.isCreating)) + #expect(model.outcome == nil) + #expect(model.errorText == "Cloud VM temporarily unavailable (HTTP 503: vm_image_config_error)\n\nWhat to do:\n Retry without `image`.") model.create() - XCTAssertNil(model.errorText, "a retry clears the previous error while it runs") - XCTAssertEqual(recorder.value.arguments.count, 2) + #expect(model.errorText == nil, "a retry clears the previous error while it runs") + #expect(recorder.value.arguments.count == 2) } - func testCreatedMachineIDIsParsedFromTheCLIsCreatedLine() { - XCTAssertEqual( - NewMachineModel.createdMachineID(fromOutput: "Created Cloud VM calm-petrel\nError: noProvider(calm-petrel)"), - "calm-petrel" - ) - XCTAssertEqual(NewMachineModel.createdMachineID(fromOutput: " Created Cloud VM noble_wren2 "), "noble_wren2") - XCTAssertNil(NewMachineModel.createdMachineID(fromOutput: "Error: Creating Cloud VM (HTTP 502)")) - XCTAssertNil(NewMachineModel.createdMachineID(fromOutput: "Created Cloud VM")) - XCTAssertNil(NewMachineModel.createdMachineID(fromOutput: "")) + @Test func testCreatedMachineIDIsParsedFromTheCLIsCreatedLine() { + #expect(NewMachineModel.createdMachineID(fromOutput: "Created Cloud VM calm-petrel\nError: noProvider(calm-petrel)") == "calm-petrel") + #expect(NewMachineModel.createdMachineID(fromOutput: " Created Cloud VM noble_wren2 ") == "noble_wren2") + #expect(NewMachineModel.createdMachineID(fromOutput: "Error: Creating Cloud VM (HTTP 502)") == nil) + #expect(NewMachineModel.createdMachineID(fromOutput: "Created Cloud VM") == nil) + #expect(NewMachineModel.createdMachineID(fromOutput: "") == nil) } - func testCreatedButOpenFailedNeverRetriesTheCreate() { + @Test func testCreatedButOpenFailedNeverRetriesTheCreate() { let (model, recorder) = makeModel() model.create() - XCTAssertEqual(recorder.value.arguments.count, 1) + #expect(recorder.value.arguments.count == 1) recorder.value.pendingCompletion?(CloudVMActionLauncher.Completion( terminationStatus: 1, output: "Created Cloud VM calm-petrel\nError: No provider for machine calm-petrel.", workspaceId: nil )) - XCTAssertEqual(model.createdMachineID, "calm-petrel") - XCTAssertNil(model.outcome, "the sheet stays up so the person sees why the open failed") - XCTAssertFalse(model.isCreating) - XCTAssertTrue(model.errorText?.contains("calm-petrel") == true) - XCTAssertTrue(model.errorText?.contains("No provider") == true, "the CLI output is kept for diagnosis") + #expect(model.createdMachineID == "calm-petrel") + #expect(model.outcome == nil, "the sheet stays up so the person sees why the open failed") + #expect(!(model.isCreating)) + #expect(model.errorText?.contains("calm-petrel") == true) + #expect(model.errorText?.contains("No provider") == true, "the CLI output is kept for diagnosis") // The primary button is now "Done": it closes the sheet without launching again. model.create() - XCTAssertEqual(recorder.value.arguments.count, 1, "a second create would mint a second machine") - XCTAssertEqual(model.outcome, .created) + #expect(recorder.value.arguments.count == 1, "a second create would mint a second machine") + #expect(model.outcome == .created) } - func testBaseSetupFailureIsNotMistakenForACreatedMachine() { + @Test func testBaseSetupFailureIsNotMistakenForACreatedMachine() { let (model, recorder) = makeModel(mode: .base(workspaceID: UUID())) model.create() recorder.value.pendingCompletion?(CloudVMActionLauncher.Completion( @@ -266,37 +257,37 @@ final class NewMachineModelTests: XCTestCase { output: "Created Cloud VM base-1\nError: attach failed", workspaceId: nil )) - XCTAssertNil(model.createdMachineID) - XCTAssertNil(model.outcome) + #expect(model.createdMachineID == nil) + #expect(model.outcome == nil) model.create() - XCTAssertEqual(recorder.value.arguments.count, 2, "Base setup retries through the idempotent base open") + #expect(recorder.value.arguments.count == 2, "Base setup retries through the idempotent base open") } - func testEmptyFailureOutputGetsAGenericMessage() { + @Test func testEmptyFailureOutputGetsAGenericMessage() { let (model, recorder) = makeModel() model.create() recorder.value.pendingCompletion?(CloudVMActionLauncher.Completion(terminationStatus: 2, output: " \n", workspaceId: nil)) - XCTAssertEqual(model.errorText, "The machine could not be created.") + #expect(model.errorText == "The machine could not be created.") } - func testLaunchRefusalIsReportedWithoutFinishing() { + @Test func testLaunchRefusalIsReportedWithoutFinishing() { let (model, _) = makeModel(starts: false) var outcomes: [NewMachineModel.Outcome] = [] model.onFinished = { outcomes.append($0) } model.create() - XCTAssertFalse(model.isCreating) - XCTAssertNotNil(model.errorText) - XCTAssertTrue(outcomes.isEmpty) + #expect(!(model.isCreating)) + #expect(model.errorText != nil) + #expect(outcomes.isEmpty) } - func testCancelFinishesOnceAndBlocksLaterCreate() { + @Test func testCancelFinishesOnceAndBlocksLaterCreate() { let (model, recorder) = makeModel() var outcomes: [NewMachineModel.Outcome] = [] model.onFinished = { outcomes.append($0) } model.cancel() model.cancel() model.create() - XCTAssertEqual(outcomes, [.cancelled]) - XCTAssertTrue(recorder.value.arguments.isEmpty) + #expect(outcomes == [.cancelled]) + #expect(recorder.value.arguments.isEmpty) } } diff --git a/web/app/api/vm/route.ts b/web/app/api/vm/route.ts index 3d0a3feedb8a..58572847d075 100644 --- a/web/app/api/vm/route.ts +++ b/web/app/api/vm/route.ts @@ -23,6 +23,7 @@ import { isVmLimitExceededError, } from "../../../services/vms/errors"; import { + VM_MEMORY_OPTIONS_MB, defaultMemoryMbForPlan, isPaidVmPlan, isVmBillingTeamResolutionError, @@ -372,6 +373,17 @@ export async function POST(request: Request): Promise { details: { requestedMemoryMb: memoryMb, maxMemoryMb, planId: entitlements.planId }, }); } + if (!VM_MEMORY_OPTIONS_MB.includes(memoryMb)) { + // The pricing page promises every machine is the plan machine, so a + // smaller request is refused rather than quietly under-delivered. + return vmErrorResponse({ + error: "vm_memory_unsupported", + status: 400, + message: "The requested Cloud VM size is not offered.", + action: `Omit \`memoryMb\`, or choose one of: ${VM_MEMORY_OPTIONS_MB.join(", ")} MB.`, + details: { requestedMemoryMb: memoryMb, memoryOptionsMb: [...VM_MEMORY_OPTIONS_MB], planId: entitlements.planId }, + }); + } setSpanAttributes(span, { "cmux.vm.memory_mb": memoryMb, "cmux.vm.max_memory_mb": maxMemoryMb, diff --git a/web/services/vms/entitlements.ts b/web/services/vms/entitlements.ts index 50c6a4ef7725..f080a34bb00b 100644 --- a/web/services/vms/entitlements.ts +++ b/web/services/vms/entitlements.ts @@ -129,11 +129,13 @@ function resolveBillingContext( } /** - * Machine sizes a person can pick, as memory in MB. vCPUs follow memory - * (vcpusForMemoryMb), so memory is the whole size story. The plan machine is - * the largest entry; a plan ceiling trims the tail. + * Machine sizes a person can pick, as memory in MB. Every plan sells exactly + * the plan machine (5 vCPU / 20 GB / 200 GB), so this is one entry: the + * pricing copy promises that size, and a smaller machine would fall short of + * it. vCPUs follow memory (vcpusForMemoryMb). Kept as a list so a future + * size tier is one entry, not a new concept. */ -export const VM_MEMORY_OPTIONS_MB: readonly number[] = [2048, 4096, 8192, 16384, PLAN_MACHINE_MEMORY_MB]; +export const VM_MEMORY_OPTIONS_MB: readonly number[] = [PLAN_MACHINE_MEMORY_MB]; /** Largest machine a plan may create. Env-overridable per plan. */ export function maxMemoryMbForPlan( diff --git a/web/tests/vm-billing-limit-paywall.test.ts b/web/tests/vm-billing-limit-paywall.test.ts index e93fe3470bda..73341606bd26 100644 --- a/web/tests/vm-billing-limit-paywall.test.ts +++ b/web/tests/vm-billing-limit-paywall.test.ts @@ -78,7 +78,7 @@ describe("Cloud VM memory allowance", () => { expect(maxMemoryMbForPlan("free", {})).toBe(20480); expect(defaultMemoryMbForPlan("pro", {})).toBe(20480); expect(maxMemoryMbForPlan("pro", {})).toBe(20480); - expect(VM_MEMORY_OPTIONS_MB.at(-1)).toBe(20480); + expect(VM_MEMORY_OPTIONS_MB).toEqual([20480]); }); test("vCPUs follow memory at one per 4 GB, so the plan machine is 5 vCPU", () => { diff --git a/web/tests/vm-route-auth.test.ts b/web/tests/vm-route-auth.test.ts index c3965e8f9bee..cc9fb32301ed 100644 --- a/web/tests/vm-route-auth.test.ts +++ b/web/tests/vm-route-auth.test.ts @@ -583,12 +583,12 @@ describe("VM REST auth", () => { new Request("https://cmux.test/api/vm", { method: "POST", headers: { origin: "https://cmux.test" }, - body: JSON.stringify({ provider: "freestyle", image: "snapshot-test", memoryMb: 16384 }), + body: JSON.stringify({ provider: "freestyle", image: "snapshot-test", memoryMb: 20480 }), }), ); expect(response.status).toBe(200); - expect(createVm).toHaveBeenCalledWith(expect.objectContaining({ memoryMb: 16384 })); + expect(createVm).toHaveBeenCalledWith(expect.objectContaining({ memoryMb: 20480 })); }); test("rejects a memory size above the plan ceiling before the workflow", async () => { @@ -612,6 +612,27 @@ describe("VM REST auth", () => { expect(runVmWorkflow).not.toHaveBeenCalled(); }); + test("refuses a Cloud VM smaller than the plan machine", async () => { + process.env.CMUX_VM_ALLOW_FREE_PROVISIONING = "1"; + getUser.mockResolvedValue(freePlanStackUser()); + + const response = await POST( + new Request("https://cmux.test/api/vm", { + method: "POST", + headers: { origin: "https://cmux.test" }, + body: JSON.stringify({ provider: "freestyle", image: "snapshot-test", memoryMb: 8192 }), + }), + ); + + expect(response.status).toBe(400); + const payload = await response.json(); + expect(payload).toMatchObject({ + error: "vm_memory_unsupported", + details: { requestedMemoryMb: 8192, memoryOptionsMb: [20480], planId: "free" }, + }); + expect(runVmWorkflow).not.toHaveBeenCalled(); + }); + test("rejects malformed memory sizes before billing or provider work", async () => { getUser.mockResolvedValue(authedStackUser()); From 1ff6dab241e2707a5eb7b2642e335978727085f4 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:59:39 -0700 Subject: [PATCH 05/12] Accept the plan's configured default size; native preview says 50 per user memoryOptionsMbForPlan derives the accepted sizes from the plan ceiling and always includes the configured default, so an operator memory override can never turn an omitted size into a 400. The DEBUG native pricing preview states the per-seat Team allowance. Claude-Session: https://claude.ai/code/session_01YKXxKXemYnpAhSyZ4uaVhF --- Resources/Localizable.xcstrings | 21 +++++++++++++++++++-- Sources/PricingPlansScreen.swift | 4 ++-- web/app/api/vm/route.ts | 11 +++++++---- web/services/vms/entitlements.ts | 16 ++++++++++++++++ web/tests/vm-billing-limit-paywall.test.ts | 10 ++++++++++ 5 files changed, 54 insertions(+), 8 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index a0895d403738..0968a85fdb8f 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -149147,6 +149147,23 @@ } } }, + "pricing.native.compare.concurrent.team": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "50 per user" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ユーザーごとに 50" + } + } + } + }, "pricing.native.compare.custom": { "extractionState": "manual", "localizations": { @@ -150292,13 +150309,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "The same Cloud VM allowance as Pro" + "value": "Up to 50 Cloud VMs per user" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Pro と同じ Cloud VM 枠" + "value": "ユーザーごとに最大 50 台の Cloud VM" } } } diff --git a/Sources/PricingPlansScreen.swift b/Sources/PricingPlansScreen.swift index 07ad75136aa2..b9d0b7e7e65c 100644 --- a/Sources/PricingPlansScreen.swift +++ b/Sources/PricingPlansScreen.swift @@ -432,7 +432,7 @@ private struct NativePricingPlansView: View { features: [ String(localized: "pricing.native.team.feature.billing", defaultValue: "Unified billing for the whole team"), String(localized: "pricing.native.team.feature.seats", defaultValue: "Centralized seat management"), - String(localized: "pricing.native.team.feature.compute", defaultValue: "The same Cloud VM allowance as Pro"), + String(localized: "pricing.native.team.feature.compute", defaultValue: "Up to 50 Cloud VMs per user"), String(localized: "pricing.native.team.feature.gateway", defaultValue: "Team-wide model gateway analytics"), String(localized: "pricing.native.team.feature.support", defaultValue: "Priority email support"), ] @@ -606,7 +606,7 @@ private struct NativePricingComparisonSection: View { label: String(localized: "pricing.native.compare.concurrent", defaultValue: "Concurrent Cloud VMs"), free: .text(String(localized: "pricing.native.compare.concurrent.free", defaultValue: "1")), pro: .text(String(localized: "pricing.native.compare.concurrent.paid", defaultValue: "50")), - team: .text(String(localized: "pricing.native.compare.concurrent.paid", defaultValue: "50")), + team: .text(String(localized: "pricing.native.compare.concurrent.team", defaultValue: "50 per user")), enterprise: .text(String(localized: "pricing.native.compare.custom", defaultValue: "Custom")) ), NativePricingCompareRow( diff --git a/web/app/api/vm/route.ts b/web/app/api/vm/route.ts index 58572847d075..bd87dd90594a 100644 --- a/web/app/api/vm/route.ts +++ b/web/app/api/vm/route.ts @@ -23,8 +23,8 @@ import { isVmLimitExceededError, } from "../../../services/vms/errors"; import { - VM_MEMORY_OPTIONS_MB, defaultMemoryMbForPlan, + memoryOptionsMbForPlan, isPaidVmPlan, isVmBillingTeamResolutionError, maxMemoryMbForPlan, @@ -373,15 +373,18 @@ export async function POST(request: Request): Promise { details: { requestedMemoryMb: memoryMb, maxMemoryMb, planId: entitlements.planId }, }); } - if (!VM_MEMORY_OPTIONS_MB.includes(memoryMb)) { + const memoryOptionsMb = memoryOptionsMbForPlan(entitlements.planId, process.env); + if (!memoryOptionsMb.includes(memoryMb)) { // The pricing page promises every machine is the plan machine, so a // smaller request is refused rather than quietly under-delivered. + // The plan default is always accepted, so operator overrides cannot + // make an omitted size fail. return vmErrorResponse({ error: "vm_memory_unsupported", status: 400, message: "The requested Cloud VM size is not offered.", - action: `Omit \`memoryMb\`, or choose one of: ${VM_MEMORY_OPTIONS_MB.join(", ")} MB.`, - details: { requestedMemoryMb: memoryMb, memoryOptionsMb: [...VM_MEMORY_OPTIONS_MB], planId: entitlements.planId }, + action: `Omit \`memoryMb\`, or choose one of: ${memoryOptionsMb.join(", ")} MB.`, + details: { requestedMemoryMb: memoryMb, memoryOptionsMb: [...memoryOptionsMb], planId: entitlements.planId }, }); } setSpanAttributes(span, { diff --git a/web/services/vms/entitlements.ts b/web/services/vms/entitlements.ts index f080a34bb00b..142ef34c418c 100644 --- a/web/services/vms/entitlements.ts +++ b/web/services/vms/entitlements.ts @@ -161,6 +161,22 @@ export function maxMemoryMbForPlan( ); } +/** + * Sizes a plan accepts: the catalog entries at or below the plan's ceiling, + * plus the plan's configured default, so an operator memory override + * (CMUX_VM_*_DEFAULT_MEMORY_MB / _MAX_MEMORY_MB) always names an accepted + * size instead of turning every create into a 400. + */ +export function memoryOptionsMbForPlan( + planId: string | null | undefined, + env: Record = process.env, +): readonly number[] { + const max = maxMemoryMbForPlan(planId, env); + const options = new Set(VM_MEMORY_OPTIONS_MB.filter((mb) => mb <= max)); + options.add(defaultMemoryMbForPlan(planId, env)); + return [...options].sort((a, b) => a - b); +} + /** Size a plan gets when it doesn't ask for one; never above the plan's max. */ export function defaultMemoryMbForPlan( planId: string | null | undefined, diff --git a/web/tests/vm-billing-limit-paywall.test.ts b/web/tests/vm-billing-limit-paywall.test.ts index 73341606bd26..a5c9a0bffd6a 100644 --- a/web/tests/vm-billing-limit-paywall.test.ts +++ b/web/tests/vm-billing-limit-paywall.test.ts @@ -6,6 +6,7 @@ import { isVmFreeAccessExpired, maxActiveVmsForPlan, maxMemoryMbForPlan, + memoryOptionsMbForPlan, vcpusForMemoryMb, vmDiskMb, vmFreeAccessWindowDays, @@ -94,6 +95,15 @@ describe("Cloud VM memory allowance", () => { expect(() => vmDiskMb({ CMUX_VM_DISK_MB: "0" })).toThrow(); }); + test("accepted sizes follow the plan ceiling and always include the configured default", () => { + expect(memoryOptionsMbForPlan("pro", {})).toEqual([20480]); + // An operator default below the catalog stays creatable, so an omitted + // size never 400s after an override. + expect(memoryOptionsMbForPlan("free", { CMUX_VM_FREE_DEFAULT_MEMORY_MB: "8192" })).toEqual([8192, 20480]); + // A lower ceiling trims the catalog and keeps the (clamped) default. + expect(memoryOptionsMbForPlan("pro", { CMUX_VM_PLAN_PRO_MAX_MEMORY_MB: "16384" })).toEqual([16384]); + }); + test("memory defaults and caps are independently env-overridable", () => { const env = { CMUX_VM_PLAN_PRO_DEFAULT_MEMORY_MB: "16384", From 53d8d34015b8f2028e6762bb6afe033e09cd71c7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:05:00 -0700 Subject: [PATCH 06/12] Freestyle sizes an unsized create to the plan machine; note seat backfill Claude-Session: https://claude.ai/code/session_01YKXxKXemYnpAhSyZ4uaVhF --- web/services/billing/teamResolution.ts | 5 ++++- web/services/vms/drivers/freestyle.ts | 12 ++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/web/services/billing/teamResolution.ts b/web/services/billing/teamResolution.ts index d69c3aed13fe..8d640164e9d7 100644 --- a/web/services/billing/teamResolution.ts +++ b/web/services/billing/teamResolution.ts @@ -73,7 +73,10 @@ export function billingPlanIdFromMetadata(metadata: unknown): string | null { /** * Paid seats on a team (`cmuxSeats`, written from the Stripe subscription * quantity by syncTeamPlanMetadata). Null when absent or malformed; callers - * treat that as a single seat. + * treat that as a single seat. Teams recorded before seats were written are + * backfilled without a migration: the billing-reconcile cron re-applies every + * stored subscription through the same sync, so `cmuxSeats` lands on its next + * pass (and on the next Stripe subscription event, whichever is first). */ export function billingSeatsFromMetadata(metadata: unknown): number | null { if (!metadata || typeof metadata !== "object") return null; diff --git a/web/services/vms/drivers/freestyle.ts b/web/services/vms/drivers/freestyle.ts index 9d545bfa8476..bf989fd79738 100644 --- a/web/services/vms/drivers/freestyle.ts +++ b/web/services/vms/drivers/freestyle.ts @@ -18,7 +18,7 @@ import { type VMProvider, type VMStatus, } from "./types"; -import { vcpusForMemoryMb, vmDiskMb } from "../machineSpec"; +import { PLAN_MACHINE_MEMORY_MB, vcpusForMemoryMb, vmDiskMb } from "../machineSpec"; import { recordSpanError, setSpanAttributes, withVmSpan } from "../telemetry"; import { CMUX_TUI_INSTALL_TIMEOUT_MS, @@ -290,9 +290,10 @@ export class FreestyleProvider implements VMProvider { } /** - * Grow the VM to the requested memory, the vCPUs that memory implies, and - * the plan disk. Freestyle resize is grow-only, so only larger dimensions - * are sent; a snapshot that already carries the size is a no-op. + * Grow the VM to the requested memory (the plan machine when the caller + * sent none), the vCPUs that memory implies, and the plan disk. Freestyle + * resize is grow-only, so only larger dimensions are sent; a snapshot that + * already carries the size is a no-op. */ private async growToRequestedSize( fs: Freestyle, @@ -301,9 +302,8 @@ export class FreestyleProvider implements VMProvider { memoryMb: number | undefined, span: Parameters[0], ): Promise { - if (memoryMb === undefined) return; const current = (await fs.vms.get(vmId)).resources; - const target = freestyleTargetResources(memoryMb); + const target = freestyleTargetResources(memoryMb ?? PLAN_MACHINE_MEMORY_MB); const request = freestyleResizeRequest(current, target); setSpanAttributes(span, { "cmux.vm.resources.cpu": target.cpu, From ce5250bb6cf441854a4844c2139fe5f8bb27a0d0 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:10:43 -0700 Subject: [PATCH 07/12] Operator VM caps stay absolute for Team; only the allowance scales with seats Claude-Session: https://claude.ai/code/session_01YKXxKXemYnpAhSyZ4uaVhF --- web/services/vms/entitlements.ts | 33 +++++++++++++++------- web/tests/vm-billing-limit-paywall.test.ts | 5 ++-- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/web/services/vms/entitlements.ts b/web/services/vms/entitlements.ts index 142ef34c418c..019a90ebd115 100644 --- a/web/services/vms/entitlements.ts +++ b/web/services/vms/entitlements.ts @@ -212,11 +212,13 @@ export function maxActiveVmsForPlan( options: { readonly seats?: number | null } = {}, ): number | null { const normalized = normalizedPlanId(planId ?? ""); - const limit = activeVmLimitForPlan(normalized, env); - if (limit === null || normalized !== TEAM_PLAN_ID) return limit; + const resolved = activeVmLimitForPlan(normalized, env); + // An operator brake is an absolute ceiling for the whole team; only the + // advertised allowance scales with paid seats. + if (resolved.limit === null || resolved.brake || normalized !== TEAM_PLAN_ID) return resolved.limit; const seats = options.seats; const paidSeats = typeof seats === "number" && Number.isSafeInteger(seats) && seats > 0 ? seats : 1; - return limit * paidSeats; + return resolved.limit * paidSeats; } /** @@ -339,27 +341,38 @@ function isVmFalseFlag(value: string | undefined): boolean { } } -function activeVmLimitForPlan(planId: string, env: Record): number | null { +/** `brake` marks a limit that came from an operator env override (absolute). */ +function activeVmLimitForPlan( + planId: string, + env: Record, +): { readonly limit: number | null; readonly brake: boolean } { const planKey = planId.replace(/[^a-zA-Z0-9]/g, "_").toUpperCase(); if (!isPaidVmPlan(planId)) { // Cloud machines are a paid feature. Keep every non-paid/unknown plan at // zero unless the same explicit escape hatch that disables the Pro gate is // set; this prevents a stale `CMUX_VM_FREE_MAX_ACTIVE_VMS` (or a plan- // specific override) from reopening provisioning by configuration drift. - if (!isVmFreeProvisioningAllowed(env)) return 0; + if (!isVmFreeProvisioningAllowed(env)) return { limit: 0, brake: false }; const specific = env[`CMUX_VM_PLAN_${planKey}_MAX_ACTIVE_VMS`]; - if (specific?.trim()) return positiveInteger(specific, `CMUX_VM_PLAN_${planKey}_MAX_ACTIVE_VMS`); - return nonNegativeInteger(env.CMUX_VM_FREE_MAX_ACTIVE_VMS ?? "0", "CMUX_VM_FREE_MAX_ACTIVE_VMS"); + if (specific?.trim()) { + return { limit: positiveInteger(specific, `CMUX_VM_PLAN_${planKey}_MAX_ACTIVE_VMS`), brake: true }; + } + return { + limit: nonNegativeInteger(env.CMUX_VM_FREE_MAX_ACTIVE_VMS ?? "0", "CMUX_VM_FREE_MAX_ACTIVE_VMS"), + brake: true, + }; } // Paid plans get the advertised allowance. A plan-specific override wins // over the paid-wide one; both exist only as operator brakes for an // incident, never as the place the product number lives. const specific = env[`CMUX_VM_PLAN_${planKey}_MAX_ACTIVE_VMS`]; - if (specific?.trim()) return positiveInteger(specific, `CMUX_VM_PLAN_${planKey}_MAX_ACTIVE_VMS`); + if (specific?.trim()) { + return { limit: positiveInteger(specific, `CMUX_VM_PLAN_${planKey}_MAX_ACTIVE_VMS`), brake: true }; + } const paid = env.CMUX_VM_PAID_MAX_ACTIVE_VMS; - if (paid?.trim()) return positiveInteger(paid, "CMUX_VM_PAID_MAX_ACTIVE_VMS"); - return PAID_MAX_ACTIVE_VMS_DEFAULT; + if (paid?.trim()) return { limit: positiveInteger(paid, "CMUX_VM_PAID_MAX_ACTIVE_VMS"), brake: true }; + return { limit: PAID_MAX_ACTIVE_VMS_DEFAULT, brake: false }; } function normalizedPlanId(planId: string): string { diff --git a/web/tests/vm-billing-limit-paywall.test.ts b/web/tests/vm-billing-limit-paywall.test.ts index a5c9a0bffd6a..fd42b6c48100 100644 --- a/web/tests/vm-billing-limit-paywall.test.ts +++ b/web/tests/vm-billing-limit-paywall.test.ts @@ -36,8 +36,9 @@ describe("free plan VM allowance", () => { // Seats only mean something on the Team plan. expect(maxActiveVmsForPlan("pro", {}, { seats: 4 })).toBe(50); expect(maxActiveVmsForPlan("free", {}, { seats: 4 })).toBe(0); - // Operator brakes scale with seats too. - expect(maxActiveVmsForPlan("team", { CMUX_VM_PLAN_TEAM_MAX_ACTIVE_VMS: "2" }, { seats: 3 })).toBe(6); + // Operator brakes are absolute for the whole team, never multiplied. + expect(maxActiveVmsForPlan("team", { CMUX_VM_PLAN_TEAM_MAX_ACTIVE_VMS: "2" }, { seats: 3 })).toBe(2); + expect(maxActiveVmsForPlan("team", { CMUX_VM_PAID_MAX_ACTIVE_VMS: "5" }, { seats: 4 })).toBe(5); }); test("operator brakes: a plan-specific cap wins over the paid-wide cap", () => { From 0f02cd089b89c5aef7bed1c99112ca5fcf963de3 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:15:28 -0700 Subject: [PATCH 08/12] Verify Stripe price-id overrides against the advertised plan price Claude-Session: https://claude.ai/code/session_01YKXxKXemYnpAhSyZ4uaVhF --- web/services/billing/priceGuard.ts | 35 ++++++++++++++ web/services/billing/stripe.ts | 64 +++++++++++++------------ web/tests/billing-price-guard.test.ts | 68 +++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 29 deletions(-) create mode 100644 web/services/billing/priceGuard.ts create mode 100644 web/tests/billing-price-guard.test.ts diff --git a/web/services/billing/priceGuard.ts b/web/services/billing/priceGuard.ts new file mode 100644 index 000000000000..41dee10ba384 --- /dev/null +++ b/web/services/billing/priceGuard.ts @@ -0,0 +1,35 @@ +import type Stripe from "stripe"; + +import type { BillingInterval } from "./plans"; + +export type PlanPrice = { readonly billedAmount: number; readonly lookupKey: string }; + +/** + * Refuses a Stripe Price that does not charge what the pricing page says. + * Used for env price-id overrides, whose names only claim an amount; a stale + * or miscopied id (say, a grandfathered $30 Price behind + * STRIPE_PRO_MONTHLY_50_PRICE_ID) fails here instead of silently selling the + * old price. Kept dependency-free so it can be tested without a Stripe client. + */ +export function assertPriceMatchesPlan( + price: Pick, + plan: PlanPrice, + interval: BillingInterval, + source: string, +): void { + const expectedAmount = plan.billedAmount * 100; + const problems: string[] = []; + if (!price.active) problems.push("inactive"); + if (price.currency !== "usd") problems.push(`currency ${price.currency}`); + if (price.unit_amount !== expectedAmount) { + problems.push(`unit_amount ${price.unit_amount ?? "null"} (expected ${expectedAmount})`); + } + if (price.recurring?.interval !== interval || (price.recurring.interval_count ?? 1) !== 1) { + problems.push(`interval ${price.recurring?.interval_count ?? 1}×${price.recurring?.interval ?? "none"} (expected 1×${interval})`); + } + if (problems.length > 0) { + throw new Error( + `Stripe price override ${source} (${price.id}) does not match ${plan.lookupKey}: ${problems.join(", ")}`, + ); + } +} diff --git a/web/services/billing/stripe.ts b/web/services/billing/stripe.ts index b83075707c49..b49d9de456e5 100644 --- a/web/services/billing/stripe.ts +++ b/web/services/billing/stripe.ts @@ -6,6 +6,7 @@ import { TEAM_PRICING_USD, type BillingInterval, } from "./plans"; +import { assertPriceMatchesPlan, type PlanPrice } from "./priceGuard"; export type { BillingInterval, ProBillingInterval } from "./plans"; @@ -31,44 +32,49 @@ export async function resolveProPrice(interval: BillingInterval): Promise { const overridden = interval === "month" ? env.STRIPE_TEAM_MONTHLY_60_PRICE_ID : env.STRIPE_TEAM_YEARLY_576_PRICE_ID; - if (overridden) return overridden; + return resolvePlanPrice(TEAM_PRICING_USD[interval], interval, overridden, resolvedTeamPriceIds); +} - const cached = resolvedTeamPriceIds.get(interval); +/** + * The Stripe price id checkout will charge. A lookup-key resolution is + * trusted (the catalog script pins the amount behind each key); an env + * override is verified against the advertised amount, interval, and currency + * on first use, so a stale or miscopied id can never sell a grandfathered + * price under the current pricing page. Both results are cached per process. + */ +async function resolvePlanPrice( + plan: PlanPrice, + interval: BillingInterval, + overridden: string | undefined, + cache: Map, +): Promise { + const cached = cache.get(interval); if (cached) return cached; - const lookupKey = TEAM_PRICING_USD[interval].lookupKey; - const prices = await stripe().prices.list({ - active: true, - lookup_keys: [lookupKey], - limit: 1, - }); - const priceId = prices.data[0]?.id; - if (!priceId) { - throw new Error(`Stripe price lookup key not found: ${lookupKey}`); + let priceId: string; + if (overridden) { + const price = await stripe().prices.retrieve(overridden); + assertPriceMatchesPlan(price, plan, interval, overridden); + priceId = price.id; + } else { + const prices = await stripe().prices.list({ + active: true, + lookup_keys: [plan.lookupKey], + limit: 1, + }); + const found = prices.data[0]?.id; + if (!found) { + throw new Error(`Stripe price lookup key not found: ${plan.lookupKey}`); + } + priceId = found; } - resolvedTeamPriceIds.set(interval, priceId); + cache.set(interval, priceId); return priceId; } diff --git a/web/tests/billing-price-guard.test.ts b/web/tests/billing-price-guard.test.ts new file mode 100644 index 000000000000..776e3043fd62 --- /dev/null +++ b/web/tests/billing-price-guard.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; + +import { PRO_PRICING_USD, TEAM_PRICING_USD } from "../services/billing/plans"; +import { assertPriceMatchesPlan } from "../services/billing/priceGuard"; + +function price(overrides: Partial<{ + id: string; + active: boolean; + currency: string; + unit_amount: number | null; + recurring: { interval: "month" | "year"; interval_count?: number } | null; +}> = {}) { + return { + id: "price_pro_50", + active: true, + currency: "usd", + unit_amount: 5000, + recurring: { interval: "month" as const, interval_count: 1 }, + ...overrides, + } as Parameters[0]; +} + +describe("Stripe price override guard", () => { + test("accepts a Price that charges the advertised amount and interval", () => { + expect(() => + assertPriceMatchesPlan(price(), PRO_PRICING_USD.month, "month", "STRIPE_PRO_MONTHLY_50_PRICE_ID"), + ).not.toThrow(); + expect(() => + assertPriceMatchesPlan( + price({ id: "price_team_576", unit_amount: 57600, recurring: { interval: "year" } }), + TEAM_PRICING_USD.year, + "year", + "STRIPE_TEAM_YEARLY_576_PRICE_ID", + ), + ).not.toThrow(); + }); + + test("refuses a grandfathered $30 Price behind the $50 override", () => { + expect(() => + assertPriceMatchesPlan( + price({ id: "price_legacy_30", unit_amount: 3000 }), + PRO_PRICING_USD.month, + "month", + "STRIPE_PRO_MONTHLY_50_PRICE_ID", + ), + ).toThrow(/price_legacy_30.*cmux-pro-monthly-50.*unit_amount 3000 \(expected 5000\)/); + }); + + test("refuses the wrong interval, cadence, currency, or an inactive Price", () => { + expect(() => + assertPriceMatchesPlan(price({ recurring: { interval: "year" } }), PRO_PRICING_USD.month, "month", "x"), + ).toThrow(/interval 1×year \(expected 1×month\)/); + expect(() => + assertPriceMatchesPlan( + price({ recurring: { interval: "month", interval_count: 3 } }), + PRO_PRICING_USD.month, + "month", + "x", + ), + ).toThrow(/interval 3×month/); + expect(() => + assertPriceMatchesPlan(price({ currency: "eur" }), PRO_PRICING_USD.month, "month", "x"), + ).toThrow(/currency eur/); + expect(() => + assertPriceMatchesPlan(price({ active: false }), PRO_PRICING_USD.month, "month", "x"), + ).toThrow(/inactive/); + }); +}); From 360fcdd9a030c63f4b2a325fb65d2c48556b81ed Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:20:35 -0700 Subject: [PATCH 09/12] Mac create omits --size for the default so a server memory brake still applies Claude-Session: https://claude.ai/code/session_01YKXxKXemYnpAhSyZ4uaVhF --- Sources/Cloud/NewMachineModel.swift | 5 ++++- cmuxTests/NewMachineModelTests.swift | 15 ++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/Sources/Cloud/NewMachineModel.swift b/Sources/Cloud/NewMachineModel.swift index 93c8264ab87a..53dd4abd4126 100644 --- a/Sources/Cloud/NewMachineModel.swift +++ b/Sources/Cloud/NewMachineModel.swift @@ -177,11 +177,14 @@ final class NewMachineModel { /// The exact CLI invocation the create runs. Kind travels as `--base` / /// `--desktop`; the backend maps it to an image, so no image id is pinned. + /// `--size` travels only for a non-default pick: an omitted size lets the + /// backend apply its own plan default, which an operator memory brake + /// (`CMUX_VM_*_MAX_MEMORY_MB`) may have clamped below the plan machine. var cliArguments: [String] { switch mode { case .newMachine: var arguments = ["vm", "new", kind == .desktop ? "--desktop" : "--base"] - if supportsSize { + if supportsSize, memoryMb != Self.defaultMemoryMb(planId: plan?.planId) { arguments += ["--size", String(memoryMb)] } if let trimmedName { diff --git a/cmuxTests/NewMachineModelTests.swift b/cmuxTests/NewMachineModelTests.swift index 6d25fe08820a..0139fa41ac46 100644 --- a/cmuxTests/NewMachineModelTests.swift +++ b/cmuxTests/NewMachineModelTests.swift @@ -80,17 +80,26 @@ struct NewMachineModelTests { // MARK: CLI arguments - @Test func testDefaultInvocationRequestsDesktopByKindWithoutPinningAnImage() { + /// The default size is omitted so the backend applies its plan default, + /// which an operator memory brake may have lowered below the plan machine. + @Test func testDefaultInvocationRequestsDesktopByKindWithoutPinningAnImageOrSize() { let (model, _) = makeModel() - #expect(model.cliArguments == ["vm", "new", "--desktop", "--size", "20480"]) + #expect(model.cliArguments == ["vm", "new", "--desktop"]) #expect(!(model.cliArguments.contains("--image"))) + #expect(!(model.cliArguments.contains("--size"))) + } + + @Test func testNonDefaultSizeTravelsAsAFlag() { + let (model, _) = makeModel(plan: MachinePlanSnapshot(activeCount: 1, maxActiveVms: 50, planId: "pro")) + model.memoryMb = 16384 + #expect(model.cliArguments == ["vm", "new", "--base", "--size", "16384"]) } @Test func testBaseKindSizeAndNameTravelAsFlags() { let (model, _) = makeModel(plan: MachinePlanSnapshot(activeCount: 1, maxActiveVms: 5, planId: "pro")) model.kind = .base model.name = " build box " - #expect(model.cliArguments == ["vm", "new", "--base", "--size", "20480", "--name", "build box"]) + #expect(model.cliArguments == ["vm", "new", "--base", "--name", "build box"]) } @Test func testBlankNameIsNotSent() { From 102dcc4ef6621039711fb19b29af0d583df74577 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:20:59 -0700 Subject: [PATCH 10/12] Pin the kind in the non-default size test Claude-Session: https://claude.ai/code/session_01YKXxKXemYnpAhSyZ4uaVhF --- cmuxTests/NewMachineModelTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/cmuxTests/NewMachineModelTests.swift b/cmuxTests/NewMachineModelTests.swift index 0139fa41ac46..548d2cfdbb7b 100644 --- a/cmuxTests/NewMachineModelTests.swift +++ b/cmuxTests/NewMachineModelTests.swift @@ -91,6 +91,7 @@ struct NewMachineModelTests { @Test func testNonDefaultSizeTravelsAsAFlag() { let (model, _) = makeModel(plan: MachinePlanSnapshot(activeCount: 1, maxActiveVms: 50, planId: "pro")) + model.kind = .base model.memoryMb = 16384 #expect(model.cliArguments == ["vm", "new", "--base", "--size", "16384"]) } From a15ecdc495be3d90d21443746c55d29c9cf3f3d3 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:28:28 -0700 Subject: [PATCH 11/12] Dashboard prices only USD rows; overrides must belong to the plan's product Claude-Session: https://claude.ai/code/session_01YKXxKXemYnpAhSyZ4uaVhF --- web/app/[locale]/dashboard/billing/page.tsx | 13 +++++- web/services/billing/priceGuard.ts | 21 +++++++++- web/services/billing/stripe.ts | 9 +++-- web/tests/billing-price-guard.test.ts | 44 ++++++++++++++++++--- web/tests/dashboard-billing-page.test.tsx | 17 +++++++- 5 files changed, 90 insertions(+), 14 deletions(-) diff --git a/web/app/[locale]/dashboard/billing/page.tsx b/web/app/[locale]/dashboard/billing/page.tsx index edc978a098b9..f3287a4aeedd 100644 --- a/web/app/[locale]/dashboard/billing/page.tsx +++ b/web/app/[locale]/dashboard/billing/page.tsx @@ -588,9 +588,18 @@ function priceCopy( t: Awaited>, plan: "pro" | "team", ): string | null { - const unitAmount = stripePrice(subscription)?.unit_amount; + const price = stripePrice(subscription); + const unitAmount = price?.unit_amount; const interval = priceRecurringInterval(subscription); - if (typeof unitAmount !== "number" || !Number.isFinite(unitAmount) || !interval) { + // unit_amount is in the currency's minor unit; only USD is formatted here. + // A non-USD row (possible only for an operator-managed subscription) shows + // no figure rather than a false dollar amount. + if ( + price?.currency !== "usd" || + typeof unitAmount !== "number" || + !Number.isFinite(unitAmount) || + !interval + ) { return null; } const dollars = unitAmount / 100; diff --git a/web/services/billing/priceGuard.ts b/web/services/billing/priceGuard.ts index 41dee10ba384..8ae17f0239c4 100644 --- a/web/services/billing/priceGuard.ts +++ b/web/services/billing/priceGuard.ts @@ -4,6 +4,11 @@ import type { BillingInterval } from "./plans"; export type PlanPrice = { readonly billedAmount: number; readonly lookupKey: string }; +/** The Stripe Price fields the guard inspects; `product` must be expanded. */ +export type GuardedPrice = Pick & { + readonly product: string | Pick | Pick; +}; + /** * Refuses a Stripe Price that does not charge what the pricing page says. * Used for env price-id overrides, whose names only claim an amount; a stale @@ -12,14 +17,28 @@ export type PlanPrice = { readonly billedAmount: number; readonly lookupKey: str * old price. Kept dependency-free so it can be tested without a Stripe client. */ export function assertPriceMatchesPlan( - price: Pick, + price: GuardedPrice, plan: PlanPrice, interval: BillingInterval, source: string, + planId: string, ): void { const expectedAmount = plan.billedAmount * 100; const problems: string[] = []; if (!price.active) problems.push("inactive"); + // The catalog script stamps every cmux product with metadata.app/plan, so + // an override must belong to this plan's product, not merely cost the same. + const product = price.product; + if (typeof product === "string") { + problems.push("product not expanded"); + } else if ("deleted" in product && product.deleted) { + problems.push(`product ${product.id} deleted`); + } else { + const metadata = (product as Pick).metadata ?? {}; + if (metadata.app !== "cmux" || metadata.plan !== planId) { + problems.push(`product ${product.id} is not the cmux ${planId} product`); + } + } if (price.currency !== "usd") problems.push(`currency ${price.currency}`); if (price.unit_amount !== expectedAmount) { problems.push(`unit_amount ${price.unit_amount ?? "null"} (expected ${expectedAmount})`); diff --git a/web/services/billing/stripe.ts b/web/services/billing/stripe.ts index b49d9de456e5..833fdb2d3cdc 100644 --- a/web/services/billing/stripe.ts +++ b/web/services/billing/stripe.ts @@ -32,14 +32,14 @@ export async function resolveProPrice(interval: BillingInterval): Promise { const overridden = interval === "month" ? env.STRIPE_TEAM_MONTHLY_60_PRICE_ID : env.STRIPE_TEAM_YEARLY_576_PRICE_ID; - return resolvePlanPrice(TEAM_PRICING_USD[interval], interval, overridden, resolvedTeamPriceIds); + return resolvePlanPrice(TEAM_PRICING_USD[interval], interval, overridden, resolvedTeamPriceIds, "team"); } /** @@ -54,14 +54,15 @@ async function resolvePlanPrice( interval: BillingInterval, overridden: string | undefined, cache: Map, + planId: "pro" | "team", ): Promise { const cached = cache.get(interval); if (cached) return cached; let priceId: string; if (overridden) { - const price = await stripe().prices.retrieve(overridden); - assertPriceMatchesPlan(price, plan, interval, overridden); + const price = await stripe().prices.retrieve(overridden, { expand: ["product"] }); + assertPriceMatchesPlan(price, plan, interval, overridden, planId); priceId = price.id; } else { const prices = await stripe().prices.list({ diff --git a/web/tests/billing-price-guard.test.ts b/web/tests/billing-price-guard.test.ts index 776e3043fd62..f48e0abc7005 100644 --- a/web/tests/billing-price-guard.test.ts +++ b/web/tests/billing-price-guard.test.ts @@ -9,6 +9,7 @@ function price(overrides: Partial<{ currency: string; unit_amount: number | null; recurring: { interval: "month" | "year"; interval_count?: number } | null; + product: string | { id: string; metadata: Record } | { id: string; deleted: true }; }> = {}) { return { id: "price_pro_50", @@ -16,6 +17,7 @@ function price(overrides: Partial<{ currency: "usd", unit_amount: 5000, recurring: { interval: "month" as const, interval_count: 1 }, + product: { id: "prod_pro", metadata: { app: "cmux", plan: "pro" } }, ...overrides, } as Parameters[0]; } @@ -23,14 +25,20 @@ function price(overrides: Partial<{ describe("Stripe price override guard", () => { test("accepts a Price that charges the advertised amount and interval", () => { expect(() => - assertPriceMatchesPlan(price(), PRO_PRICING_USD.month, "month", "STRIPE_PRO_MONTHLY_50_PRICE_ID"), + assertPriceMatchesPlan(price(), PRO_PRICING_USD.month, "month", "STRIPE_PRO_MONTHLY_50_PRICE_ID", "pro"), ).not.toThrow(); expect(() => assertPriceMatchesPlan( - price({ id: "price_team_576", unit_amount: 57600, recurring: { interval: "year" } }), + price({ + id: "price_team_576", + unit_amount: 57600, + recurring: { interval: "year" }, + product: { id: "prod_team", metadata: { app: "cmux", plan: "team" } }, + }), TEAM_PRICING_USD.year, "year", "STRIPE_TEAM_YEARLY_576_PRICE_ID", + "team", ), ).not.toThrow(); }); @@ -42,13 +50,14 @@ describe("Stripe price override guard", () => { PRO_PRICING_USD.month, "month", "STRIPE_PRO_MONTHLY_50_PRICE_ID", + "pro", ), ).toThrow(/price_legacy_30.*cmux-pro-monthly-50.*unit_amount 3000 \(expected 5000\)/); }); test("refuses the wrong interval, cadence, currency, or an inactive Price", () => { expect(() => - assertPriceMatchesPlan(price({ recurring: { interval: "year" } }), PRO_PRICING_USD.month, "month", "x"), + assertPriceMatchesPlan(price({ recurring: { interval: "year" } }), PRO_PRICING_USD.month, "month", "x", "pro"), ).toThrow(/interval 1×year \(expected 1×month\)/); expect(() => assertPriceMatchesPlan( @@ -56,13 +65,38 @@ describe("Stripe price override guard", () => { PRO_PRICING_USD.month, "month", "x", + "pro", ), ).toThrow(/interval 3×month/); expect(() => - assertPriceMatchesPlan(price({ currency: "eur" }), PRO_PRICING_USD.month, "month", "x"), + assertPriceMatchesPlan(price({ currency: "eur" }), PRO_PRICING_USD.month, "month", "x", "pro"), ).toThrow(/currency eur/); expect(() => - assertPriceMatchesPlan(price({ active: false }), PRO_PRICING_USD.month, "month", "x"), + assertPriceMatchesPlan(price({ active: false }), PRO_PRICING_USD.month, "month", "x", "pro"), ).toThrow(/inactive/); }); + + test("refuses a Price from another product, even at the right amount", () => { + expect(() => + assertPriceMatchesPlan( + price({ product: { id: "prod_team", metadata: { app: "cmux", plan: "team" } } }), + PRO_PRICING_USD.month, + "month", + "x", + "pro", + ), + ).toThrow(/product prod_team is not the cmux pro product/); + expect(() => + assertPriceMatchesPlan(price({ product: "prod_pro" }), PRO_PRICING_USD.month, "month", "x", "pro"), + ).toThrow(/product not expanded/); + expect(() => + assertPriceMatchesPlan( + price({ product: { id: "prod_gone", deleted: true } }), + PRO_PRICING_USD.month, + "month", + "x", + "pro", + ), + ).toThrow(/deleted/); + }); }); diff --git a/web/tests/dashboard-billing-page.test.tsx b/web/tests/dashboard-billing-page.test.tsx index 451d52de6a5e..e7cc799fd5cf 100644 --- a/web/tests/dashboard-billing-page.test.tsx +++ b/web/tests/dashboard-billing-page.test.tsx @@ -177,14 +177,22 @@ describe("dashboard billing page", () => { } }); - test("omits the price metric when Stripe sent no amount", async () => { + test("omits the price metric when Stripe sent no amount or a non-USD currency", async () => { customerRows = [{ id: "cus_123" }]; subscriptionRows = [ stripeSubscriptionRow({ cancelAtPeriodEnd: false, unitAmount: null }), ]; - const html = await renderBillingPage(); + let html = await renderBillingPage(); expect(html).toContain("cmux Pro"); expect(html).not.toContain(">Price<"); + + // 5000 JPY is not $50. + subscriptionRows = [ + stripeSubscriptionRow({ cancelAtPeriodEnd: false, unitAmount: 5000, currency: "jpy" }), + ]; + html = await renderBillingPage(); + expect(html).not.toContain(">Price<"); + expect(html).not.toContain("$50/mo"); }); test("renders pending cancellation with resume and end-date copy", async () => { @@ -226,6 +234,8 @@ describe("dashboard billing page", () => { plan: "team", scope: "team", seats: 4, + lookupKey: "cmux-team-monthly-60", + unitAmount: 6000, }), ], ]; @@ -405,6 +415,7 @@ function stripeSubscriptionRow({ seats = null, lookupKey = "cmux-pro-monthly-50", unitAmount = 5000, + currency = "usd", billingInterval, recurringInterval = "month", }: { @@ -415,6 +426,7 @@ function stripeSubscriptionRow({ seats?: number | null; lookupKey?: string; unitAmount?: number | null; + currency?: string; billingInterval?: "month" | "year"; recurringInterval?: "month" | "year"; }) { @@ -435,6 +447,7 @@ function stripeSubscriptionRow({ price: { lookup_key: lookupKey, unit_amount: unitAmount, + currency, recurring: { interval: recurringInterval }, }, }, From 17fcdc077c4ae3b5643ab6a46fb7189780e92435 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:52:29 -0700 Subject: [PATCH 12/12] VM README: paid plans are capped at the plan allowance Claude-Session: https://claude.ai/code/session_01YKXxKXemYnpAhSyZ4uaVhF --- web/services/vms/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/services/vms/README.md b/web/services/vms/README.md index 51ecae26e154..4345aa84e03a 100644 --- a/web/services/vms/README.md +++ b/web/services/vms/README.md @@ -414,8 +414,8 @@ Plan limits are team-based. Stack Auth personal teams should stay enabled for bo ### The free limit is the paywall moment -`vmActiveLimitExceededResponse` (routeHelpers) renders every provisioning verb's over-limit error. On unpaid plans the message sells the upgrade — with the default zero allowance it is the subscribe gate ("Cloud VMs require a cmux Pro subscription") with `upgradeRequired: true` and `upgradeUrl` pointing at `/pricing` — so clients can show a real upgrade prompt (checkout flow per `skills/cmux-billing`) instead of a dead error. Paid plans have no cap, so they only see this response when an operator has set a per-plan incident brake; then the message is operational "delete one" guidance, not a paywall. +`vmActiveLimitExceededResponse` (routeHelpers) renders every provisioning verb's over-limit error. On unpaid plans the message sells the upgrade — with the default zero allowance it is the subscribe gate ("Cloud VMs require a cmux Pro subscription") with `upgradeRequired: true` and `upgradeUrl` pointing at `/pricing` — so clients can show a real upgrade prompt (checkout flow per `skills/cmux-billing`) instead of a dead error. Paid plans see it at the plan allowance (50 active machines, times paid seats on Team) or at an operator incident brake; then the message is operational "delete one" guidance, not a paywall. ### Pricing is flat -Paid plans include unlimited active VMs for a flat subscription price. There is no usage metering, no overages, no per-hour VM size pricing, and no machine count quota; an earlier GB-RAM-awake-seconds metering design was considered and dropped to keep pricing simple. +Paid plans include up to 50 active VMs (per paid seat on Team) for a flat subscription price, every one the plan machine. There is no usage metering, no overages, and no per-hour VM size pricing; an earlier GB-RAM-awake-seconds metering design was considered and dropped to keep pricing simple.