diff --git a/CLI/CMUXCLI+VMTransfer.swift b/CLI/CMUXCLI+VMTransfer.swift index 70a06fc6533f..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|24g|32g>] [--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|24g|32g>] [--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 2ceae9402274..7a0d559f3aa3 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -4305,14 +4305,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. + /// `--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, - "24g": 24576, "24gb": 24576, - "32g": 32768, "32gb": 32768, "xxl": 32768, + "20g": 20480, "20gb": 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 d35a2ca953d1..623e1db926d5 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -126785,13 +126785,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 マシンを作成できます" } } } @@ -148922,13 +148922,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "20 hrs/mo, then usage-based" + "value": "Included" } }, "ja": { "stringUnit": { "state": "translated", - "value": "月 20 時間、その後は従量課金" + "value": "含まれます" } } } @@ -148939,13 +148939,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Pooled, usage-based" + "value": "Included" } }, "ja": { "stringUnit": { "state": "translated", - "value": "共有、従量課金" + "value": "含まれます" } } } @@ -148984,6 +148984,40 @@ } } }, + "pricing.native.compare.concurrent.paid": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "50" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "50" + } + } + } + }, + "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": { @@ -149704,13 +149738,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Model gateway with usage and cost analytics" + "value": "Unlimited workspaces" } }, "ja": { "stringUnit": { "state": "translated", - "value": "使用量とコスト分析付きのモデルゲートウェイ" + "value": "ワークスペースは無制限" } } } @@ -149721,13 +149755,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 ディスク" } } } @@ -149772,13 +149806,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "$30" + "value": "$50" } }, "ja": { "stringUnit": { "state": "translated", - "value": "$30" + "value": "$50" } } } @@ -149942,13 +149976,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 台のマシンが含まれ、従量課金や超過料金はありません。" } } } @@ -150129,13 +150163,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Pooled Cloud VM compute hours" + "value": "Up to 50 Cloud VMs per user" } }, "ja": { "stringUnit": { "state": "translated", - "value": "共有 Cloud VM コンピュート時間" + "value": "ユーザーごとに最大 50 台の Cloud VM" } } } @@ -150197,13 +150231,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "$35" + "value": "$60" } }, "ja": { "stringUnit": { "state": "translated", - "value": "$35" + "value": "$60" } } } @@ -158034,13 +158068,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 93f6f3939ae3..42e323b61f83 100644 --- a/Sources/Cloud/MachinesPanelView.swift +++ b/Sources/Cloud/MachinesPanelView.swift @@ -522,12 +522,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 { @@ -542,9 +543,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 4a61b3ee43b6..49480930155e 100644 --- a/Sources/Cloud/NewMachineModel.swift +++ b/Sources/Cloud/NewMachineModel.swift @@ -35,15 +35,19 @@ 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] + static let memoryOptionsMb: [Int] = [planMachineMemoryMb] + /// The plan machine (`PLAN_MACHINE_MEMORY_MB`): 20 GB, 5 vCPU, 200 GB disk, + /// the only size /pricing sells. + static let planMachineMemoryMb = 20480 /// Mirrors `maxMemoryMbForPlan`: the free machine is a full-size computer; /// paid plans unlock the largest size. 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 @@ -177,7 +181,10 @@ final class NewMachineModel { switch mode { case .newMachine: var arguments = ["vm", "new", kind == .desktop ? "--desktop" : "--base"] - if supportsSize { + // `--size` travels only for a non-default pick: an omitted size lets + // the backend apply its plan default, which an operator memory brake + // (`CMUX_VM_*_MAX_MEMORY_MB`) may have clamped below the plan machine. + if supportsSize, memoryMb != Self.defaultMemoryMb(planId: plan?.planId) { arguments += ["--size", String(memoryMb)] } if let trimmedName { diff --git a/Sources/PricingPlansScreen.swift b/Sources/PricingPlansScreen.swift index 5114ac6f5674..b9d0b7e7e65c 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: "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"), ] @@ -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.team", defaultValue: "50 per user")), 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 944d0061814f..937d9a30e04c 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 Create hands the work off without waiting. +@Suite("New machine model") @MainActor -final class NewMachineModelTests: XCTestCase { +struct NewMachineModelTests { private struct SubmitRecorder { var requests: [MachineCreateRequest] = [] } @@ -36,29 +37,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", @@ -69,10 +70,10 @@ 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 @@ -80,129 +81,125 @@ final class NewMachineModelTests: XCTestCase { /// With no `limits.imageKinds` from the backend the sheet opens on /// shell-only (no provider ships a desktop image), and the kind travels /// as a flag: no image id is pinned, and the create runs in the background. - func testDefaultInvocationRequestsShellOnlyByKindInTheBackground() { + /// 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 testDefaultInvocationRequestsShellOnlyByKindInTheBackground() { let (model, _) = makeModel() - XCTAssertEqual(model.cliArguments, ["vm", "new", "--base", "--size", "24576", "--focus", "false"]) - XCTAssertFalse(model.cliArguments.contains("--image")) + #expect(model.cliArguments == ["vm", "new", "--base", "--focus", "false"]) + #expect(!(model.cliArguments.contains("--image"))) + #expect(!(model.cliArguments.contains("--size"))) } - func testDesktopKindTravelsAsAFlagWhenTheBackendServesIt() { + @Test func testDesktopKindTravelsAsAFlagWhenTheBackendServesIt() { 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.cliArguments, ["vm", "new", "--desktop", "--size", "24576", "--focus", "false"]) - XCTAssertFalse(model.cliArguments.contains("--image")) + #expect(model.cliArguments == ["vm", "new", "--desktop", "--focus", "false"]) + #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", "--focus", "false"]) + #expect(model.cliArguments == ["vm", "new", "--base", "--name", "build box", "--focus", "false"]) } - 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", "--focus", "false"] - ) - XCTAssertNil(model.createRequest.name, "Base has no label; the row is called Base") - XCTAssertEqual(model.createRequest.baseWorkspaceID, workspaceID) + #expect(model.cliArguments == ["vm", "base", "open", "--workspace", workspaceID.uuidString, "--base", "--focus", "false"]) + #expect(model.createRequest.name == nil, "Base has no label; the row is called Base") + #expect(model.createRequest.baseWorkspaceID == workspaceID) } // MARK: Plan ceilings - func testFreePlanCapsSizeAtTwentyFourGigabytes() { + @Test 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) + #expect(model.memoryOptions == [20480]) + #expect(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) + @Test func testPaidPlanGetsThePlanMachineAndDefaultsToIt() { + let (model, _) = makeModel(plan: MachinePlanSnapshot(activeCount: 2, maxActiveVms: 50, planId: "pro")) + #expect(model.memoryOptions == [20480]) + #expect(model.memoryMb == 20480) } - func testUnknownPlanUsesTheFreeCeiling() { + @Test func testUnknownPlanUsesThePlanMachineCeiling() { let (model, _) = makeModel(plan: nil) - XCTAssertEqual(model.memoryOptions.last, 24576) - 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 (freeModel, _) = makeModel(plan: free) - XCTAssertEqual(freeModel.planMeterText, "0 of 1 machine in use") - XCTAssertEqual( - freeModel.freeAccessNoteText, - "Free plan: this machine stays reachable for 7 days. Upgrade to keep it." - ) + #expect(freeModel.planMeterText == "0 of 1 machine in use") + #expect(freeModel.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, "paid plans have no access window") + #expect(proModel.planMeterText == "2 of 5 machines in use") + #expect(proModel.freeAccessNoteText == nil, "paid plans have no access window") } - func testMemoryLabelsReadInGigabytes() { - XCTAssertEqual(NewMachineModel.memoryLabel(mb: 2048), "2 GB") - XCTAssertEqual(NewMachineModel.memoryLabel(mb: 24576), "24 GB") - XCTAssertEqual(NewMachineModel.memoryLabel(mb: 1500), "1500 MB") + @Test func testMemoryLabelsReadInGigabytes() { + #expect(NewMachineModel.memoryLabel(mb: 2048) == "2 GB") + #expect(NewMachineModel.memoryLabel(mb: 24576) == "24 GB") + #expect(NewMachineModel.memoryLabel(mb: 1500) == "1500 MB") } - 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) } // MARK: Create lifecycle @@ -211,64 +208,64 @@ final class NewMachineModelTests: XCTestCase { /// person back their window immediately. The sheet finishes as soon as the /// create is submitted; the machine coming up (tens of seconds) is the /// coordinator's business, never the sheet's lifetime. - func testCreateFinishesTheSheetBeforeTheMachineExists() { + @Test func testCreateFinishesTheSheetBeforeTheMachineExists() { let (model, recorder) = makeModel() var outcomes: [NewMachineModel.Outcome] = [] model.onFinished = { outcomes.append($0) } model.create() - XCTAssertEqual(recorder.value.requests.count, 1, "the create is submitted once") - XCTAssertEqual(outcomes, [.submitted], "the sheet must finish without waiting for the CLI to complete") - XCTAssertEqual(model.outcome, .submitted) - XCTAssertNil(model.errorText) + #expect(recorder.value.requests.count == 1, "the create is submitted once") + #expect(outcomes == [.submitted], "the sheet must finish without waiting for the CLI to complete") + #expect(model.outcome == .submitted) + #expect(model.errorText == nil) } - func testSubmittedRequestCarriesTheSheetsChoices() { + @Test func testSubmittedRequestCarriesTheSheetsChoices() { let (model, recorder) = makeModel(plan: MachinePlanSnapshot(activeCount: 1, maxActiveVms: 5, planId: "pro")) model.kind = .base model.memoryMb = 4096 model.name = " ci box " model.create() let request = recorder.value.requests.first - XCTAssertEqual(request?.mode, .newMachine) - XCTAssertEqual(request?.kind, .base) - XCTAssertEqual(request?.name, "ci box") - XCTAssertEqual(request?.displayName, "ci box") - XCTAssertEqual(request?.arguments, ["vm", "new", "--base", "--size", "4096", "--name", "ci box", "--focus", "false"]) - XCTAssertEqual(request?.progressLabel, "Creating…") + #expect(request?.mode == .newMachine) + #expect(request?.kind == .base) + #expect(request?.name == "ci box") + #expect(request?.displayName == "ci box") + #expect(request?.arguments == ["vm", "new", "--base", "--size", "4096", "--name", "ci box", "--focus", "false"]) + #expect(request?.progressLabel == "Creating…") } - func testSecondCreateAfterSubmitIsIgnored() { + @Test func testSecondCreateAfterSubmitIsIgnored() { let (model, recorder) = makeModel() model.create() model.create() - XCTAssertEqual(recorder.value.requests.count, 1, "a second click must not launch a second create") + #expect(recorder.value.requests.count == 1, "a second click must not launch a second create") } - func testLaunchRefusalIsReportedWithoutFinishing() { + @Test func testLaunchRefusalIsReportedWithoutFinishing() { let (model, recorder) = makeModel(starts: false) var outcomes: [NewMachineModel.Outcome] = [] model.onFinished = { outcomes.append($0) } model.create() - XCTAssertNil(model.outcome) - XCTAssertNotNil(model.errorText, "a refused launch is the one error the sheet still shows inline") - XCTAssertTrue(outcomes.isEmpty) + #expect(model.outcome == nil) + #expect(model.errorText != nil, "a refused launch is the one error the sheet still shows inline") + #expect(outcomes.isEmpty) // Retry re-submits and clears the message while it runs. model.create() - XCTAssertEqual(recorder.value.requests.count, 2) - XCTAssertNotNil(model.errorText, "still refused, still shown") + #expect(recorder.value.requests.count == 2) + #expect(model.errorText != nil, "still refused, still shown") } - 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.requests.isEmpty) + #expect(outcomes == [.cancelled]) + #expect(recorder.value.requests.isEmpty) } } 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 4a68c2e9e8af..7ca9736b1cfa 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..f3287a4aeedd 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,44 @@ 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 price = stripePrice(subscription); + const unitAmount = price?.unit_amount; + const interval = priceRecurringInterval(subscription); + // 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; } - 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 +634,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 +644,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/api/vm/route.ts b/web/app/api/vm/route.ts index 9f188e78ded2..8bf275415d61 100644 --- a/web/app/api/vm/route.ts +++ b/web/app/api/vm/route.ts @@ -24,6 +24,7 @@ import { } from "../../../services/vms/errors"; import { defaultMemoryMbForPlan, + memoryOptionsMbForPlan, isPaidVmPlan, isVmBillingTeamResolutionError, maxMemoryMbForPlan, @@ -376,6 +377,20 @@ export async function POST(request: Request): Promise { details: { requestedMemoryMb: memoryMb, maxMemoryMb, planId: entitlements.planId }, }); } + 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: ${memoryOptionsMb.join(", ")} MB.`, + details: { requestedMemoryMb: memoryMb, memoryOptionsMb: [...memoryOptionsMb], planId: entitlements.planId }, + }); + } setSpanAttributes(span, { "cmux.vm.memory_mb": memoryMb, "cmux.vm.max_memory_mb": maxMemoryMb, 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..4c2123a379ba 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 per user", + "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 per user, 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 per user, 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..d1a785958132 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/priceGuard.ts b/web/services/billing/priceGuard.ts new file mode 100644 index 000000000000..8ae17f0239c4 --- /dev/null +++ b/web/services/billing/priceGuard.ts @@ -0,0 +1,54 @@ +import type Stripe from "stripe"; + +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 + * 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: 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})`); + } + 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/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/stripe.ts b/web/services/billing/stripe.ts index c6c4c4d0e8e4..833fdb2d3cdc 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"; @@ -29,46 +30,52 @@ 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; - if (overridden) return overridden; - - const cached = resolvedProPriceIds.get(interval); - if (cached) return cached; - - const lookupKey = PRO_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}`); - } - resolvedProPriceIds.set(interval, priceId); - return priceId; + ? env.STRIPE_PRO_MONTHLY_50_PRICE_ID + : env.STRIPE_PRO_YEARLY_480_PRICE_ID; + return resolvePlanPrice(PRO_PRICING_USD[interval], interval, overridden, resolvedProPriceIds, "pro"); } export async function resolveTeamPrice(interval: BillingInterval): Promise { const overridden = interval === "month" - ? env.STRIPE_TEAM_MONTHLY_PRICE_ID - : env.STRIPE_TEAM_YEARLY_PRICE_ID; - if (overridden) return overridden; + ? env.STRIPE_TEAM_MONTHLY_60_PRICE_ID + : env.STRIPE_TEAM_YEARLY_576_PRICE_ID; + return resolvePlanPrice(TEAM_PRICING_USD[interval], interval, overridden, resolvedTeamPriceIds, "team"); +} - 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, + planId: "pro" | "team", +): 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, { expand: ["product"] }); + assertPriceMatchesPlan(price, plan, interval, overridden, planId); + 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/services/billing/teamResolution.ts b/web/services/billing/teamResolution.ts index d5e06dafff4c..8d640164e9d7 100644 --- a/web/services/billing/teamResolution.ts +++ b/web/services/billing/teamResolution.ts @@ -70,6 +70,20 @@ 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. 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; + 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 bd13d713ffb8..4345aa84e03a 100644 --- a/web/services/vms/README.md +++ b/web/services/vms/README.md @@ -410,12 +410,12 @@ 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, 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 -`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. 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/drivers/freestyle.ts b/web/services/vms/drivers/freestyle.ts index c1a3e0e8e77a..7ab92f003c67 100644 --- a/web/services/vms/drivers/freestyle.ts +++ b/web/services/vms/drivers/freestyle.ts @@ -1,4 +1,13 @@ -import { Freestyle, FreestyleApiError, type TunnelData, type VmData, type Vm, type VpcData } from "freestyle"; +import { + Freestyle, + FreestyleApiError, + type ResizeVmOptions, + type TunnelData, + type VmData, + type VmResources, + type Vm, + type VpcData, +} from "freestyle"; import { randomBytes } from "node:crypto"; import { ProviderError, @@ -23,6 +32,7 @@ import { type VMProvider, type VMStatus, } from "./types"; +import { PLAN_MACHINE_MEMORY_MB, vcpusForMemoryMb, vmDiskMb } from "../machineSpec"; import { recordSpanError, setSpanAttributes, withVmSpan } from "../telemetry"; import { CMUX_TUI_INSTALL_TIMEOUT_MS, @@ -577,9 +587,16 @@ export class FreestyleProvider implements VMProvider { "cmux.vm.network.private": !!networkId, }); 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); }); @@ -609,6 +626,32 @@ export class FreestyleProvider implements VMProvider { ); } + /** + * 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, + vm: Vm, + vmId: string, + memoryMb: number | undefined, + span: Parameters[0], + ): Promise { + const current = (await fs.vms.get(vmId)).resources; + const target = freestyleTargetResources(memoryMb ?? PLAN_MACHINE_MEMORY_MB); + 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", @@ -962,3 +1005,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..019a90ebd115 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; @@ -54,7 +64,7 @@ export function resolveVmEntitlements( planId, billingCustomerType: billing.billingCustomerType, billingTeamId: billing.billingTeamId, - maxActiveVms: maxActiveVmsForPlan(planId, env), + maxActiveVms: maxActiveVmsForPlan(planId, env, { seats: billing.billingSeats }), }; } @@ -69,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) { @@ -84,6 +95,7 @@ function resolveBillingContext( billingCustomerType: "team", billingTeamId: team.id, billingPlanId: team.billingPlanId ?? user.userBillingPlanId, + billingSeats: team.billingSeats, }; } @@ -92,6 +104,7 @@ function resolveBillingContext( billingCustomerType: "team", billingTeamId: user.billingTeamId, billingPlanId: user.billingPlanId ?? user.userBillingPlanId, + billingSeats: user.billingSeats, }; } @@ -111,14 +124,18 @@ function resolveBillingContext( billingCustomerType: "user", billingTeamId: user.billingTeamId, billingPlanId: user.userBillingPlanId, + billingSeats: null, }; } /** - * 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. 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, 24576, 32768]; +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( @@ -130,12 +147,34 @@ 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", + ); +} + +/** + * 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. */ @@ -149,21 +188,37 @@ 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; 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 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 resolved.limit * paidSeats; } /** @@ -286,24 +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 have no machine cap. A plan-specific env override remains as an - // operator-only brake for an incident; unset means unlimited. + // 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`); - return null; + 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 { 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/services/vms/machineSpec.ts b/web/services/vms/machineSpec.ts new file mode 100644 index 000000000000..94e909a3e7c3 --- /dev/null +++ b/web/services/vms/machineSpec.ts @@ -0,0 +1,29 @@ +/** + * The plan machine, as sold on /pricing: every paid plan gets up to + * PAID_MAX_ACTIVE_VMS_DEFAULT machines of PLAN_MACHINE_MEMORY_MB memory, with + * vCPUs derived from memory at VM_MEMORY_MB_PER_VCPU and a VM_DISK_MB_DEFAULT + * disk. The pricing copy is pinned to these constants by test, so a change + * here that forgets the copy (or vice versa) fails CI. + * + * Kept dependency-free so the provider drivers can size a machine without + * pulling the billing graph into their module. + */ +export const PAID_MAX_ACTIVE_VMS_DEFAULT = 50; +export const PLAN_MACHINE_MEMORY_MB = 20480; +export const VM_MEMORY_MB_PER_VCPU = 4096; +export const VM_DISK_MB_DEFAULT = 204800; + +/** vCPUs a machine of `memoryMb` gets: one per 4 GB, rounded up (20 GB → 5). */ +export function vcpusForMemoryMb(memoryMb: number): number { + return Math.max(1, Math.ceil(memoryMb / VM_MEMORY_MB_PER_VCPU)); +} + +/** Disk every machine is grown to at create, in MB. Env-overridable. */ +export function vmDiskMb(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/billing-price-guard.test.ts b/web/tests/billing-price-guard.test.ts new file mode 100644 index 000000000000..f48e0abc7005 --- /dev/null +++ b/web/tests/billing-price-guard.test.ts @@ -0,0 +1,102 @@ +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; + product: string | { id: string; metadata: Record } | { id: string; deleted: true }; +}> = {}) { + return { + id: "price_pro_50", + active: true, + 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]; +} + +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", "pro"), + ).not.toThrow(); + expect(() => + assertPriceMatchesPlan( + 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(); + }); + + 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", + "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", "pro"), + ).toThrow(/interval 1×year \(expected 1×month\)/); + expect(() => + assertPriceMatchesPlan( + price({ recurring: { interval: "month", interval_count: 3 } }), + PRO_PRICING_USD.month, + "month", + "x", + "pro", + ), + ).toThrow(/interval 3×month/); + expect(() => + 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", "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/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/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/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/dashboard-billing-page.test.tsx b/web/tests/dashboard-billing-page.test.tsx index 90ebb570902c..e7cc799fd5cf 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,51 @@ 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" }]; + 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 or a non-USD currency", async () => { customerRows = [{ id: "cus_123" }]; - expect(await renderBillingPage()).toContain("$24/mo, billed annually"); + subscriptionRows = [ + stripeSubscriptionRow({ cancelAtPeriodEnd: false, unitAmount: null }), + ]; + let html = await renderBillingPage(); + expect(html).toContain("cmux Pro"); + expect(html).not.toContain(">Price<"); + // 5000 JPY is not $50. subscriptionRows = [ - stripeSubscriptionRow({ - cancelAtPeriodEnd: false, - lookupKey: "cmux-pro-yearly", - }), + stripeSubscriptionRow({ cancelAtPeriodEnd: false, unitAmount: 5000, currency: "jpy" }), ]; - expect(await renderBillingPage()).toContain("$20/mo, billed annually"); + 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 () => { @@ -212,6 +234,8 @@ describe("dashboard billing page", () => { plan: "team", scope: "team", seats: 4, + lookupKey: "cmux-team-monthly-60", + unitAmount: 6000, }), ], ]; @@ -223,7 +247,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 +264,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 +288,7 @@ describe("dashboard billing page", () => { scope: "team", seats: 4, lookupKey: "cmux-team-monthly", + unitAmount: 3500, billingInterval: "year", }), ], @@ -281,6 +308,7 @@ describe("dashboard billing page", () => { scope: "team", seats: 4, lookupKey: "operator-managed-annual-price", + unitAmount: 33600, recurringInterval: "year", }), ], @@ -288,11 +316,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 +413,11 @@ function stripeSubscriptionRow({ plan = "pro", scope = "user", seats = null, - lookupKey = "cmux-pro-monthly", + lookupKey = "cmux-pro-monthly-50", + unitAmount = 5000, + currency = "usd", billingInterval, - recurringInterval, + recurringInterval = "month", }: { cancelAtPeriodEnd: boolean; status?: string; @@ -393,6 +425,8 @@ function stripeSubscriptionRow({ scope?: string; seats?: number | null; lookupKey?: string; + unitAmount?: number | null; + currency?: string; billingInterval?: "month" | "year"; recurringInterval?: "month" | "year"; }) { @@ -412,7 +446,9 @@ function stripeSubscriptionRow({ { price: { lookup_key: lookupKey, - recurring: recurringInterval ? { interval: recurringInterval } : undefined, + unit_amount: unitAmount, + currency, + recurring: { interval: recurringInterval }, }, }, ], 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 5a2dce31a231..4801a81e5a97 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 per user", enterprise: "Custom", }); expect(enMessages.dashboard.billing.free.upsellTitle).toBe( @@ -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/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 51592132db44..fd42b6c48100 100644 --- a/web/tests/vm-billing-limit-paywall.test.ts +++ b/web/tests/vm-billing-limit-paywall.test.ts @@ -1,9 +1,14 @@ import { describe, expect, test } from "bun:test"; import { + PLAN_MACHINE_MEMORY_MB, + VM_MEMORY_OPTIONS_MB, defaultMemoryMbForPlan, isVmFreeAccessExpired, maxActiveVmsForPlan, maxMemoryMbForPlan, + memoryOptionsMbForPlan, + vcpusForMemoryMb, + vmDiskMb, vmFreeAccessWindowDays, } from "../services/vms/entitlements"; import { vmActiveLimitExceededResponse, vmFreeAccessExpiredResponse } from "../services/vms/routeHelpers"; @@ -17,16 +22,33 @@ 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("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 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", () => { + 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 +74,35 @@ 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).toEqual([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("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", () => { diff --git a/web/tests/vm-freestyle-provider.test.ts b/web/tests/vm-freestyle-provider.test.ts index 60fa44549244..3cf464ae9840 100644 --- a/web/tests/vm-freestyle-provider.test.ts +++ b/web/tests/vm-freestyle-provider.test.ts @@ -8,7 +8,9 @@ import { freestyleNetworkAddressMetadata, freestyleDaemonHealthyCommand, freestyleFirewallRules, + freestyleResizeRequest, freestyleStartDaemonCommand, + freestyleTargetResources, mapFreestyleState, normalizeFreestyleExecTimeout, renderFreestyleModelPlaneEnvFile, @@ -206,3 +208,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..cc9fb32301ed 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(); @@ -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 () => { @@ -607,7 +607,28 @@ 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(); + }); + + 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(); }); @@ -1365,7 +1386,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 +1411,7 @@ describe("VM REST auth", () => { billingCustomerType: "team", billingTeamId: "team-2", billingPlanId: "team", - maxActiveVms: null, + maxActiveVms: 200, })); expect(runVmWorkflow).toHaveBeenCalled(); });