billing: App Store Go hours, Pro/Max compute allowances, Sandbox allowlist

Merged
LA
23d ago
+1362-109

Summary

Server work so a cmux Go, Pro, or Max subscription bought on iPhone works against production and stays economical.

  1. Go hours for App Store buyers. getGoVmUsage read only web subscriptions, and the database capacity guard cmux_guard_go_vm_capacity read only stripe_subscriptions, so every App Store Go subscriber got 0 hours and cmux_go_period_unavailable. A shared personalBillingPeriod (new web/services/vms/planUsage.ts) now returns the plan and period from the web subscription or the App Store subscription (newest transaction's purchase date to expiry, extended by a grace period), higher plan first. Migration 20261014160000_go_runtime_limits_apple replaces the guard function so it falls back to apple_subscriptions. Only the period lookup changes; it adds no table or column.
  2. Pro and Max allowances: code only, OFF by default. https://cmux.com/pricing promises no hour limits, so the Pro/Max compute-hour and disk allowances run only with CMUX_VM_PLAN_BUDGETS=on. With the default, no Pro or Max user gets a compute-hour or disk limit, the 402 vm_compute_budget_reached / vm_storage_budget_reached paths never fire, the minute job reads only Go machines, and GET /api/vm adds no budget fields. Go's 40 VM-hours (existing behavior) stay enforced. When the switch is on, the allowance is metered on the existing runtime meter (cloud_vm_runtime_intervals) at each machine's recorded shape, enforced at create, fork, restore, resume, and disk growth, and a yearly subscription resets monthly. The numbers below are the proposal for that switch.
  3. Sandbox entitlement. In production a Sandbox purchase grants a plan only for com.cmux.app and only when the signed-in account's primary email is verified and in APPLE_IAP_SANDBOX_ACCOUNT_EMAILS. Any other TestFlight tester gets purchases_unavailable from /api/billing/apple/account-token, so the app shows no buy buttons. The grant check reads the email only when a Sandbox row would otherwise grant. APPLE_IAP_SANDBOX_ENTITLEMENTS keeps its override semantics.
  4. Analytics. subscription_entitlement_granted is sent from both the app-posted transaction and the notification, after the write is applied and only when that subscription is the user's granting plan (insert ID apple_entitlement:<transaction id>). It and the Apple subscription_* events carry billing_period (monthly | yearly).
  5. Yearly App Store products. com.cmux.app.pro.yearly and com.cmux.app.max.yearly (and the beta-bundle equivalents) map to Pro and Max, matching the web yearly prices ($480/yr, $1,920/yr). Go has no yearly web price, so it stays monthly only. The account-token products array has one entry per (plan, period), for example {"productId":"com.cmux.app.pro.yearly","planId":"pro","billingPeriod":"yearly"}. Yearly entries are sent only when the app sends x-cmux-billing-periods: monthly,yearly; otherwise the response has monthly entries only, as today, each with billingPeriod: "monthly". Transactions and notifications map either ID to its plan, and the grant runs until expires_at. A non-USD yearly charge is estimated at the monthly list price x 9.6 until the App Store yearly prices are set.
  6. Vendor names in user copy. dashboard.billing.banners.nosub, guestCLI.routeToken, docs.cloudSecurity.isolationVm, and docs.cloudSecurity.credentialsModel no longer name vendors (en, ja; the other locales have no copy of these keys).

Budget math

The machine provider's list rates are $0.04032 per vCPU-hour, $0.0129 per GiB-hour of memory, and $0.063 per GiB-month of disk. One compute-hour is 1 vCPU + 2 GiB: $0.04032 + 2 x $0.0129 = $0.06612. The worst case is the whole allowance used and the whole disk cap kept for the month. The target is under 70% of web net revenue ($50 Pro, $200 Max). App Store net is higher ($52.49 / $209.99 in year one).

PlanComputeDiskWorst case% of web net% of iOS net
Pro375 compute-hours = $24.80160 GB = $10.08$34.8869.8%66.4%
Max1,500 compute-hours = $99.18640 GB = $40.32$139.5069.8%66.4%

The default 4 vCPU / 8 GB machine draws 4 units an hour. Pro is about 94 default-machine hours a month and Max about 375. 160 GB is 5 default 32 GB disks, so Pro can still keep its 5 machines. Without the allowance, one user running the whole pool 24/7 costs about $1,005 (Pro) and $3,942 (Max) a month. For comparison, Go is 40 h x 2 units = 80 compute-hours ($5.29) plus 2 x 16 GB of disk ($2.02), so $7.31 or 73% of $10.

Not counted: persistent home volumes (persistentHome) and any paused-memory storage the provider bills. Payment processing fees are not subtracted from net revenue.

Rollout

  • Migration 20261014160000_go_runtime_limits_apple is applied to PlanetScale cmux-prod staging and then production main (2026-10-14, from commit 92c0697555). The same run also recorded 20261014120000_cloud_vm_health_snapshots (already on main, additive) and re-recorded 20261013120000_hexclave_webhook_inbox and 20261013130000_stack_session_revocations, whose earlier ledger rows had no name (both are IF NOT EXISTS only).
  • Set production APPLE_IAP_SANDBOX_ACCOUNT_EMAILS=cmux-rev-0e6ba145@mailinator.com,lawrence@manaflow.ai before this deploys. Without it, App Review sees no products. Only verified emails match; email-code sign-in verifies the review account.
  • Leave CMUX_VM_PLAN_BUDGETS unset (allowances off).
  • Create the yearly products in App Store Connect (com.cmux.app.pro.yearly, com.cmux.app.max.yearly) in the same subscription group.

Why Apple subscription_* events were zero

  • Production has 0 apple_subscriptions rows, 0 apple_transactions rows, and 144 minted account tokens. No purchase ever reached the server, so no event could fire.
  • apple_notifications has 1 row: a Sandbox TEST notification on 2026-10-02. The Production notification URL could not be confirmed with the asc CLI because it does not return subscriptionStatusUrl.
  • POST https://cmux.com/api/billing/apple/notifications answers 400 invalid_notification to a bad payload, so the route is reachable. https://www.cmux.com/... answers 301, which App Store notifications do not follow.
  • Events are not filtered by StoreKit environment. They are sent only when VERCEL_ENV=production, so staging (beta-bundle Sandbox notifications) sends none.
  • Code cause, fixed here: lifecycle events came only from notifications, so a purchase recorded through the app post sent nothing. subscription_entitlement_granted now covers both paths.

Testing

  • New DB behavior test web/tests/plan-allowance-db-behavior.test.ts (real Postgres with every migration applied): an App Store Go subscriber starts a Go machine and is metered on its own period; Pro meters compute units and saved disk on the web period; an App Store Max outranks a web Pro; Founder's Edition has no allowance. Red on the first commit (getGoVmUsage returned the exhausted placeholder period for an App Store Go subscriber; the Pro/Max module did not exist), green on the fix: 13 pass with apple-iap-db-behavior.
  • web/tests/fixtures/go-runtime-limits.sql gains an App Store Go block for the guard.
  • Ran on a Freestyle VM with Postgres and every migration applied: the guard fixture (both blocks PASS); vm-workflows.test.ts with CMUX_DB_TEST=1 (185 pass); apple-iap-service, apple-iap-account-token, apple-iap-routes, go-pause, go-runtime-limits, dashboard-billing-screen, vm-route-auth, billing-plan-route (each file isolated, all pass); tsgo --noEmit (no errors in web/; only the two test files that import workers/, which was not synced, failed). Not run locally: the complexity gate (needs git) and the full suite; CI covers them.
  • Existing tests updated for the new Sandbox policy (apple-iap-service, apple-iap-account-token) and copy (dashboard-billing-screen).

Changelog

  • Fixed: an App Store Go subscription now includes its 40 VM-hours.
  • Added: yearly Pro and Max subscriptions in the iOS app.

Summary by CodeRabbit

  • New Features
    • Pro and Max cloud VM plans support monthly compute-hour and storage allowances. When enabled, limits may block creating, resuming, or resizing VMs, and running VMs may pause when compute time runs out.
    • VM usage details show applicable budgets, usage, and reset timing. Localized messages explain limits and suggest cleanup or upgrade options.
    • App Store subscriptions support yearly billing for Pro and Max. App Store billing periods can also inform VM usage limits and entitlement-granted analytics.
    • Go VM usage periods can be based on eligible App Store subscriptions as well as web subscriptions.
  • Bug Fixes
    • Production Sandbox purchases for the App Store app require a verified primary email on the configured allowlist, unless an entitlement override applies.
    • Billing notices now use web-subscription wording rather than referring only to Stripe.