From dd34314dc040036053b8aaf9c5ea895c10a4342c Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 04:00:05 -0800 Subject: [PATCH] feat(billing): bill overage by the minute, put sandboxes to sleep at the cap (U22, U24) - Overage stays $1.00 per sandbox hour (ORCA_CLOUD_OVERAGE_CENTS_PER_HOUR) and is now billed by the minute: a per-minute metered TEST price (unit_amount_decimal = rate / 60 to 12 decimals, transform_quantity divide_by 60, round up), created the same way by stripe-test-setup.ts. capSeconds rounds the cap down to whole minutes and spentCents mirrors the invoice, so the cap math matches Stripe. The meter still counts seconds. - Opting in re-prices an overage item still on an older price (e.g. $0.75 / h) instead of adding a second item on the same meter, and removes any extra stale one. - Alerts at 75%, 90% and 100% of included hours (warnAtPercent). - New top-level entitlement.suspendRunning: payment grace expired, or at the limit (out of hours with overage off, or at the spend cap). The start check and usage answers carry it, so the control plane puts running sandboxes to sleep (snapshot kept, never deleted). Payment fields and /v1/internal/suspensions stay payment-only, so no data-retention clock starts at a cap. - Copy (desktop billing panel, plan card, build sheet, phone, CLI): "billed by the minute" after the rate; at the limit "Running sandboxes go to sleep and new ones can't start. Nothing is deleted, and your files are safe." --- cloud/apps/billing/scripts/e2e-test-mode.ts | 56 ++++++-- .../apps/billing/scripts/stripe-test-setup.ts | 18 +-- cloud/apps/billing/src/entitlements.ts | 16 ++- cloud/apps/billing/src/index.ts | 2 +- cloud/apps/billing/src/overage.ts | 53 ++++++-- cloud/apps/billing/src/plans.ts | 32 ++++- cloud/apps/billing/src/stripe.ts | 21 ++- cloud/apps/billing/src/usage.ts | 5 +- .../apps/billing/test/billing-config.test.ts | 42 +++++- cloud/apps/billing/test/billing-flow.test.ts | 127 +++++++++++++++++- cloud/apps/billing/test/stripe-fake.ts | 38 +++++- cloud/apps/billing/wrangler.dev.jsonc | 3 +- .../test/lifecycle-flow.test.ts | 15 +++ .../src/cloud/cloud-entitlement-copy.test.ts | 8 +- mobile/src/cloud/cloud-entitlement-copy.ts | 19 ++- mobile/src/i18n/locales/en.json | 9 +- src/cli/cloud/cloud-api-errors.ts | 6 +- src/cli/cloud/cloud-glossary.test.ts | 6 +- src/cli/cloud/index-cloud-commands.test.ts | 2 +- .../index-worktree-create-on-cloud.test.ts | 4 +- .../billing/PlanBillingSection.fixture.tsx | 21 +++ .../components/billing/PlanBillingUsage.tsx | 12 +- .../components/billing/plan-billing-copy.ts | 16 +-- .../components/billing/plans/TeamPlanCard.tsx | 4 +- .../vm-build-failure-copy.ts | 16 +-- src/renderer/src/i18n/locales/en.json | 24 ++-- src/shared/orca-billing.ts | 2 +- 27 files changed, 466 insertions(+), 111 deletions(-) diff --git a/cloud/apps/billing/scripts/e2e-test-mode.ts b/cloud/apps/billing/scripts/e2e-test-mode.ts index 7290c4b1cfe..40e06ebea7f 100644 --- a/cloud/apps/billing/scripts/e2e-test-mode.ts +++ b/cloud/apps/billing/scripts/e2e-test-mode.ts @@ -5,22 +5,24 @@ // tsx scripts/e2e-test-mode.ts run # drive the flows below // // Env for `run`: BILLING_URL, STRIPE_SECRET_KEY (sk_test_/rk_test_ only), CONTROL_PLANE_TOKEN, -// E2E_JWK_PRIVATE (path), E2E_CATALOG (path to the stripe-test-setup.ts JSON), E2E_ISSUER, +// E2E_JWK_PRIVATE (path), E2E_CATALOG (path to the stripe-test-setup.ts JSON; an optional +// STRIPE_PRICE_OVERAGE_PREVIOUS, an older metered overage price, checks opt-in re-prices it), E2E_ISSUER, // E2E_D1_ARGS (wrangler d1 execute target, e.g. "BILLING_DB --local"). // // E2E_MEMBERSHIP_TOKEN (optional) enables the peak-seat checks; E2E_GRACE_SECONDS (default 96 h) // is the Worker's payment grace (short: watched expiring; long: past_due_since backdated in D1). // -// Flows: Checkout (2 seats, test card, headless browser) -> overage opt-in -> usage past the -// included hours -> meter events in Stripe -> spend cap blocks starts -> peak seats (add charged -// now, removal deferred, re-add free) -> annual plan + overage -> seat true-down + test-clock +// Flows: Checkout (2 seats, test card, headless browser) -> overage opt-in (per-minute price) -> +// 75/90/100% warnings -> usage past the included hours -> meter events in Stripe -> spend cap +// blocks starts and puts running sandboxes to sleep -> peak seats (add charged +// now, removal deferred, re-add free) -> annual plan + overage (an older overage item is re-priced, not doubled) -> seat true-down + test-clock // renewal -> invoice.payment_failed -> grace -> suspend -> pay -> resume -> cancel -> 3DS Checkout // (challenge passed; 3DS-then-decline and insufficient funds create nothing) -> cleanup. // Prints PASS/FAIL per check and the ids. import { execFileSync } from 'node:child_process' import { readFileSync, writeFileSync } from 'node:fs' import { SignJWT, exportJWK, generateKeyPair, importJWK } from 'jose' -import { DEFAULT_PLANS } from '../src/plans' +import { DEFAULT_PLANS, OVERAGE_SECONDS_PER_BILLED_UNIT, capSeconds, overageUnitAmountDecimal } from '../src/plans' import { encodeForm } from '../src/stripe' const HOUR = 3600 @@ -250,13 +252,26 @@ const subA = await stripe<{ items: { data: Array<{ price: { id: string } }> } }> check('overage opt-in adds the metered item ($25 default cap)', optIn.status === 200 && optIn.body.overage?.active === true && optIn.body.overage?.spendCapCents === 2500 && subA.items.data.some((item) => item.price.id === catalog.STRIPE_PRICE_OVERAGE), optIn.body) -// ---- 3. Usage past included hours -> meter events -------------------------------------------- +const overagePrice = await stripe<{ unit_amount_decimal: string; transform_quantity: { divide_by: number; round: string }; recurring: { usage_type: string; meter: string } }>( + 'GET', `/v1/prices/${catalog.STRIPE_PRICE_OVERAGE}`) +check(`overage price is billed by the minute (${overageUnitAmountDecimal(DEFAULT_PLANS.team.overageCentsPerHour)} cents per minute, divide_by 60, round up)`, + overagePrice.unit_amount_decimal === overageUnitAmountDecimal(DEFAULT_PLANS.team.overageCentsPerHour) && + overagePrice.transform_quantity?.divide_by === OVERAGE_SECONDS_PER_BILLED_UNIT && overagePrice.transform_quantity.round === 'up' && + overagePrice.recurring.usage_type === 'metered' && overagePrice.recurring.meter === catalog.meter, overagePrice) + +// ---- 3. Usage past included hours -> warnings -> meter events -------------------------------- const meterStart = Math.floor(Date.now() / 1000 / 60) * 60 - 2 * 24 * HOUR -for (let i = 0; i < 5; i++) { +for (let i = 0; i < 4; i++) { await reportUsage(accountA, 24) } +// 96 of 120 h (80%), then 108 h (90%), then 120 h: alerts at 75%, 90% and 100%. +check('75% included-hours warning', (await entitlement(orgA)).usageWarningPercent === 75) +await reportUsage(accountA, 12) +check('90% included-hours warning', (await entitlement(orgA)).usageWarningPercent === 90) +await reportUsage(accountA, 12) const crossing = await reportUsage(accountA, 4) -check('usage past 120 h: 4 h overage recorded, starts still allowed', crossing.overageSeconds === 4 * HOUR && crossing.canStartSandbox === true, crossing) +check('usage past 120 h: 4 h overage recorded, starts still allowed, nothing put to sleep', crossing.overageSeconds === 4 * HOUR && + crossing.canStartSandbox === true && crossing.suspendRunning === false, crossing) const warned = await entitlement(orgA) check('100% included-hours warning', warned.usageWarningPercent === 100, warned.usageWarningPercent) @@ -264,13 +279,17 @@ check('100% included-hours warning', warned.usageWarningPercent === 100, warned. await reportUsage(accountA, 20) await reportUsage(accountA, 20) const capped = await startCheck(accountA) -check(`spend cap ($25 = ${(2500 / DEFAULT_PLANS.team.overageCentsPerHour).toFixed(1)} overage h) reached -> new starts blocked`, capped.allow === false && capped.reason === 'overage_cap_reached', capped) +check(`spend cap ($25 = ${(2500 / DEFAULT_PLANS.team.overageCentsPerHour).toFixed(1)} overage h) reached -> new starts blocked, running sandboxes put to sleep`, + capped.allow === false && capped.reason === 'overage_cap_reached' && capped.suspendRunning === true, capped) const spent = await entitlement(orgA) -check('metered spend stops at the cap', spent.overage?.spentCents <= 2500 && spent.overage?.spentCents >= 2490, spent.overage) +check('metered spend stops at the cap (whole minutes, never over it)', spent.overage?.spentCents <= 2500 && spent.overage?.spentCents >= 2490 && + spent.suspendRunning === true && spent.payment?.suspendRunning === false, { overage: spent.overage, suspendRunning: spent.suspendRunning }) +const capSuspensions = await billing('GET', '/v1/internal/suspensions', null).then((r) => r.body) +check('a spend cap is not a payment suspension (no data-retention clock)', !capSuspensions.accounts?.some((a: any) => a.accountId === accountA), capSuspensions) const meterEnd = Math.ceil(Date.now() / 1000 / 60) * 60 + 60 // The $25 default cap in overage seconds at the plan's overage rate (single source: plans.ts). -const CAP_SECONDS = Math.floor((2500 / DEFAULT_PLANS.team.overageCentsPerHour) * HOUR) +const CAP_SECONDS = capSeconds(DEFAULT_PLANS.team, 2500) const summaries = await until( 'meter event summaries', () => stripe<{ data: Array<{ aggregated_value: number }> }>('GET', `/v1/billing/meters/${catalog.meter}/event_summaries`, { @@ -328,8 +347,12 @@ ids.annualCustomer = customerB.id linkAccount(`org:${orgB}`, customerB.id) await stripe('POST', '/v1/payment_methods/pm_card_visa/attach', { customer: customerB.id }) .then((pm: any) => stripe('POST', `/v1/customers/${customerB.id}`, { invoice_settings: { default_payment_method: pm.id } })) -const annualSub = await stripe<{ id: string }>('POST', '/v1/subscriptions', { - customer: customerB.id, items: [{ price: catalog.STRIPE_PRICE_TEAM_ANNUAL, quantity: 3 }], metadata: { account_id: `org:${orgB}` } +// An older overage price already on the subscription (opted in before the price changed). +const previousOverage = catalog.STRIPE_PRICE_OVERAGE_PREVIOUS +const annualSub = await stripe<{ id: string; items: { data: Array<{ id: string; price: { id: string } }> } }>('POST', '/v1/subscriptions', { + customer: customerB.id, + items: [{ price: catalog.STRIPE_PRICE_TEAM_ANNUAL, quantity: 3 }, ...(previousOverage ? [{ price: previousOverage }] : [])], + metadata: { account_id: `org:${orgB}` } }) ids.annualSubscription = annualSub.id const annualEnt = await until('annual active', () => entitlement(orgB), (e) => e.status === 'active') @@ -337,6 +360,13 @@ check('annual Team: 3 seats, 180 h monthly allowance window', annualEnt.interval annualEnt.usageWindow && annualEnt.usageWindow.end - annualEnt.usageWindow.start <= 31 * 24 * HOUR * 1000, annualEnt) const annualOptIn = await billing('POST', '/v1/billing/overage', orgB, { enabled: true }) check('annual plan accepts the monthly metered overage item', annualOptIn.status === 200 && annualOptIn.body.overage?.active === true, annualOptIn) +if (previousOverage) { + const oldItem = annualSub.items.data.find((item) => item.price.id === previousOverage)?.id + const items = (await stripe<{ items: { data: Array<{ id: string; price: { id: string } }> } }>('GET', `/v1/subscriptions/${annualSub.id}`)).items.data + const metered = items.filter((item) => item.price.id !== catalog.STRIPE_PRICE_TEAM_ANNUAL) + check('opt-in moves the older overage item to the current price (no second item)', metered.length === 1 && + metered[0]!.id === oldItem && metered[0]!.price.id === catalog.STRIPE_PRICE_OVERAGE, metered) +} // ---- 7. Test clock: renewal, then invoice.payment_failed -------------------------------------- const orgC = `e2e-${runId}-c` diff --git a/cloud/apps/billing/scripts/stripe-test-setup.ts b/cloud/apps/billing/scripts/stripe-test-setup.ts index b5d5846a691..4b4c5efbee0 100644 --- a/cloud/apps/billing/scripts/stripe-test-setup.ts +++ b/cloud/apps/billing/scripts/stripe-test-setup.ts @@ -8,7 +8,7 @@ // Prices (cents) default to the agreed Team pricing; override with // STRIPE_SETUP_PRICES='{"teamMonthly":5000,"teamAnnual":48000,"overagePerHour":100}' // Idempotency keys include the prices, so a re-run within 24h returns the same objects. -import { DEFAULT_PLANS } from '../src/plans' +import { DEFAULT_PLANS, OVERAGE_SECONDS_PER_BILLED_UNIT, overageUnitAmountDecimal } from '../src/plans' import { encodeForm } from '../src/stripe' const key = process.env.STRIPE_SECRET_KEY ?? '' @@ -82,20 +82,22 @@ const meter = await post( 'meter' ) const overageProduct = await post('/v1/products', { name: 'Orca sandbox overage', unit_label: 'std hour' }, 'product:overage') -// Per sandbox hour (one size, 1x; see MACHINE_SIZE_WEIGHT); the meter counts seconds and the -// invoice divides by 3600, rounding up. +// Per sandbox hour (one size, 1x; see MACHINE_SIZE_WEIGHT), billed by the minute: the meter counts +// seconds, the invoice divides the period's total by 60 (rounding up) and charges the hourly rate +// / 60 per minute (unit_amount_decimal, 12 decimals; overageUnitAmountDecimal in plans.ts). const overage = await post( '/v1/prices', { product: overageProduct.id, currency: 'usd', - unit_amount: prices.overagePerHour, + unit_amount_decimal: overageUnitAmountDecimal(prices.overagePerHour), recurring: { interval: 'month', usage_type: 'metered', meter: meter.id }, - transform_quantity: { divide_by: 3600, round: 'up' }, - lookup_key: 'orca_sandbox_overage_hourly', - transfer_lookup_key: true + transform_quantity: { divide_by: OVERAGE_SECONDS_PER_BILLED_UNIT, round: 'up' }, + lookup_key: 'orca_sandbox_overage_per_minute', + transfer_lookup_key: true, + nickname: 'Sandbox overage, billed by the minute' }, - `price:overage:${suffix}` + `price:overage:minute:${suffix}` ) const portal = await post( diff --git a/cloud/apps/billing/src/entitlements.ts b/cloud/apps/billing/src/entitlements.ts index 5b7663d3094..3fc2808cb62 100644 --- a/cloud/apps/billing/src/entitlements.ts +++ b/cloud/apps/billing/src/entitlements.ts @@ -6,6 +6,7 @@ import { allowanceWindow, capSeconds, includedSeconds, + overageCents, planCatalog, type BillingInterval, type MachineSize, @@ -44,7 +45,7 @@ export type Entitlement = { includedSeconds: number usedSeconds: number remainingIncludedSeconds: number - /** Highest configured warning level reached (e.g. 80, 100), or null. */ + /** Highest configured warning level reached (75, 90 or 100), or null. */ usageWarningPercent: number | null overageSeconds: number overage: { @@ -64,6 +65,12 @@ export type Entitlement = { } /** Failed payment: new starts stop at once; running sandboxes must be suspended after the grace. */ payment: { pastDueSince: number | null; graceEndsAt: number | null; suspendRunning: boolean } + /** + * The account's running sandboxes must go to sleep now (snapshot kept, never deleted): payment + * grace expired, or the account is at its limit (out of included hours with overage off, or at + * its overage spend cap). The control plane acts on it from the start check and usage reports. + */ + suspendRunning: boolean canStartSandbox: boolean reason: DenyReason | null } @@ -110,6 +117,7 @@ export async function computeEntitlement( idleSleepMinutes: team.idleSleepMinutes }, payment: { pastDueSince: null, graceEndsAt: null, suspendRunning: false }, + suspendRunning: false, canStartSandbox: false, reason: 'no_subscription' } @@ -166,7 +174,8 @@ export async function computeEntitlement( overage: { ...overageBase, active: overageActive, - spentCents: Math.round((metered / 3600) * team.overageCentsPerHour) + // What Stripe will invoice: whole minutes at the per-minute price. + spentCents: overageCents(team, metered) }, limits: { maxConcurrentSandboxes: team.concurrentSandboxesPerSeat * Math.max(1, subscription.seats), @@ -175,6 +184,7 @@ export async function computeEntitlement( idleSleepMinutes: team.idleSleepMinutes }, payment: { pastDueSince, graceEndsAt, suspendRunning: graceExpired }, + suspendRunning: graceExpired || reason === 'included_hours_exhausted' || reason === 'overage_cap_reached', canStartSandbox: reason === null, reason } @@ -189,7 +199,7 @@ export function checkStart( entitlement: Entitlement, request: { machineSize: MachineSize; runningSandboxes: number; runningSandboxesForUser?: number } ): { allow: boolean; reason: DenyReason | null; suspendRunning: boolean } { - const suspendRunning = entitlement.payment.suspendRunning + const suspendRunning = entitlement.suspendRunning if (!entitlement.canStartSandbox) { return { allow: false, reason: entitlement.reason, suspendRunning } } diff --git a/cloud/apps/billing/src/index.ts b/cloud/apps/billing/src/index.ts index 23d596c93d2..1d7b46f876c 100644 --- a/cloud/apps/billing/src/index.ts +++ b/cloud/apps/billing/src/index.ts @@ -192,7 +192,7 @@ export async function handleRequest(request: Request, env: Env, ctx: ExecutionCo ...result, canStartSandbox: entitlement.canStartSandbox, reason: entitlement.reason, - suspendRunning: entitlement.payment.suspendRunning + suspendRunning: entitlement.suspendRunning }) } diff --git a/cloud/apps/billing/src/overage.ts b/cloud/apps/billing/src/overage.ts index 69c03b17de5..590e00f1d1f 100644 --- a/cloud/apps/billing/src/overage.ts +++ b/cloud/apps/billing/src/overage.ts @@ -1,13 +1,15 @@ // Overage is opt-in per account (off by default) with a monthly spend cap admins can raise. -// Opting in adds the metered overage price to the Stripe subscription (once); opting out stops +// Opting in puts exactly one metered overage item, at STRIPE_PRICE_OVERAGE, on the Stripe +// subscription: an item still on an older overage price is moved to the current one rather than a +// second added (two items on the same meter would bill the same seconds twice). Opting out stops // metering immediately but leaves the item, so usage already reported is still invoiced. import { z } from 'zod' import type { BillingCaller } from './auth' import { OPEN_STATUSES } from './checkout' import type { Env } from './env' -import { planCatalog } from './plans' -import type { StripeClient } from './stripe' -import { accountSettings, currentSubscription, setOverageSettings, upsertSubscription } from './store' +import { planCatalog, planForPrice } from './plans' +import type { StripeClient, StripeSubscription } from './stripe' +import { accountSettings, currentSubscription, setOverageSettings, upsertSubscription, type SubscriptionRow } from './store' export const OverageRequestSchema = z .object({ @@ -37,10 +39,11 @@ export async function updateOverage( if (!subscription || !OPEN_STATUSES.has(subscription.status)) { return { ok: false, status: 409, error: 'no_subscription' } } - if (request.enabled && !subscription.overage_item_id) { - // Idempotent per subscription+price, so a retried opt-in cannot add a second item. - const item = await stripe.addSubscriptionItem({ subscription: subscription.stripe_subscription_id, price: env.STRIPE_PRICE_OVERAGE }) - await upsertSubscription(env.BILLING_DB, { ...subscription, overage_enabled: 1, overage_item_id: item.id, updated_at: now }) + if (request.enabled) { + const itemId = await ensureOverageItem(env, stripe, subscription) + if (itemId !== subscription.overage_item_id) { + await upsertSubscription(env.BILLING_DB, { ...subscription, overage_enabled: 1, overage_item_id: itemId, updated_at: now }) + } } // An omitted cap keeps the current one; null restores the plan default. const spendCapCents = @@ -50,3 +53,37 @@ export async function updateOverage( await setOverageSettings(env.BILLING_DB, caller.accountId, { optIn: request.enabled, spendCapCents }, now) return { ok: true } } + +/** A metered item that is not a seat price and not the current overage price: an older overage price. */ +function isStaleOverageItem(env: Env, item: StripeSubscription['items']['data'][number]): boolean { + return ( + item.price.id !== env.STRIPE_PRICE_OVERAGE && + planForPrice(env, item.price.id) === null && + item.price.recurring?.usage_type === 'metered' + ) +} + +/** The subscription's one overage item at the current price, creating or re-pricing it as needed. */ +async function ensureOverageItem(env: Env, stripe: StripeClient, subscription: SubscriptionRow): Promise { + // Stripe is the source of truth for the items: the stored id can be missing or on an old price. + const live = await stripe.retrieveSubscription(subscription.stripe_subscription_id) + const current = live.items.data.find((item) => item.price.id === env.STRIPE_PRICE_OVERAGE) + const stale = live.items.data.filter((item) => isStaleOverageItem(env, item)) + if (current) { + // Never leave a second metered item on the same meter. + for (const item of stale) { + await stripe.deleteSubscriptionItem(item.id) + } + return current.id + } + const [replace, ...extra] = stale + if (replace) { + await stripe.setSubscriptionItemPrice({ item: replace.id, price: env.STRIPE_PRICE_OVERAGE }) + for (const item of extra) { + await stripe.deleteSubscriptionItem(item.id) + } + return replace.id + } + // Idempotent per subscription+price, so a retried opt-in cannot add a second item. + return (await stripe.addSubscriptionItem({ subscription: subscription.stripe_subscription_id, price: env.STRIPE_PRICE_OVERAGE })).id +} diff --git a/cloud/apps/billing/src/plans.ts b/cloud/apps/billing/src/plans.ts index 75507f01f65..0b4182e49c1 100644 --- a/cloud/apps/billing/src/plans.ts +++ b/cloud/apps/billing/src/plans.ts @@ -74,11 +74,13 @@ export const DEFAULT_PLANS: PlanCatalog = { // Same constant the control plane enforces and serves (GET /v1/config); never a literal here. idleSleepMinutes: ORCA_CLOUD_IDLE_SLEEP_MINUTES, // Shared with the control plane's workspace cost; entitlements, the spend-cap math, the desktop and - // the e2e read it from here. STRIPE_PRICE_OVERAGE must be a metered price with this unit_amount. + // the e2e read it from here. STRIPE_PRICE_OVERAGE must be the metered per-minute price for this rate + // (unit_amount_decimal = overageUnitAmountDecimal(rate), transform_quantity divide_by 60, round up). overageCentsPerHour: ORCA_CLOUD_OVERAGE_CENTS_PER_HOUR, defaultSpendCapCents: 2500, maxSpendCapCents: 500_000, - warnAtPercent: [80, 100], + // Owners and admins are alerted at 75%, 90% and 100% of the team's sandbox hours. + warnAtPercent: [75, 90, 100], paymentGraceHours: 96, seatTrueDownHours: 6 }, @@ -164,9 +166,31 @@ export function includedSeconds(team: TeamPlan, seats: number): number { return Math.round(team.includedHoursPerSeat * 3600) * Math.max(1, seats) } -/** The spend cap expressed as overage seconds (what the meter counts). */ +// Overage is billed by the minute: the meter counts seconds, and the Stripe price divides the +// period's total by 60 (rounding up) and charges overageCentsPerHour / 60 per minute. Metering in +// seconds keeps every segment exact; only the invoice rounds, once per period, to a whole minute. +export const OVERAGE_SECONDS_PER_BILLED_UNIT = 60 +/** Stripe allows at most 12 decimal places in unit_amount_decimal. */ +const STRIPE_DECIMAL_PLACES = 12 + +/** The per-minute Stripe price (cents, as Stripe's unit_amount_decimal string) for an hourly rate. */ +export function overageUnitAmountDecimal(centsPerHour: number): string { + return (centsPerHour / (3600 / OVERAGE_SECONDS_PER_BILLED_UNIT)).toFixed(STRIPE_DECIMAL_PLACES) +} + +/** What Stripe invoices for `seconds` of overage: whole minutes (rounded up) at the per-minute rate. */ +export function overageCents(team: TeamPlan, seconds: number): number { + const minutes = Math.ceil(Math.max(0, seconds) / OVERAGE_SECONDS_PER_BILLED_UNIT) + return Math.round(minutes * Number(overageUnitAmountDecimal(team.overageCentsPerHour))) +} + +/** + * The spend cap expressed as overage seconds (what the meter counts): the whole minutes the cap + * pays for, so the invoice never goes over the cap after Stripe rounds up to the minute. + */ export function capSeconds(team: TeamPlan, spendCapCents: number): number { - return Math.floor((spendCapCents / team.overageCentsPerHour) * 3600) + const minutes = Math.floor((spendCapCents * (3600 / OVERAGE_SECONDS_PER_BILLED_UNIT)) / team.overageCentsPerHour) + return minutes * OVERAGE_SECONDS_PER_BILLED_UNIT } function addUtcMonths(ms: number, months: number): number { diff --git a/cloud/apps/billing/src/stripe.ts b/cloud/apps/billing/src/stripe.ts index e76f8ad1f7e..8d493a6fd58 100644 --- a/cloud/apps/billing/src/stripe.ts +++ b/cloud/apps/billing/src/stripe.ts @@ -73,7 +73,7 @@ export function createStripeClient(env: Env, fetchImpl: typeof fetch = fetch) { const base = env.STRIPE_API_BASE ?? 'https://api.stripe.com' const version = env.STRIPE_API_VERSION ?? DEFAULT_STRIPE_API_VERSION - async function request(method: 'GET' | 'POST', path: string, params?: FormRecord, idempotencyKey?: string) { + async function request(method: 'GET' | 'POST' | 'DELETE', path: string, params?: FormRecord, idempotencyKey?: string) { assertKeyMode(env) const headers: Record = { authorization: `Bearer ${env.STRIPE_SECRET_KEY}`, @@ -152,6 +152,23 @@ export function createStripeClient(env: Env, fetchImpl: typeof fetch = fetch) { `overage-item:${args.subscription}:${args.price}` ) }, + /** Moves an item to another price in place (an older overage price to the current one). */ + setSubscriptionItemPrice(args: { item: string; price: string }) { + return request<{ id: string; price: { id: string } }>( + 'POST', + `/v1/subscription_items/${encodeURIComponent(args.item)}`, + { price: args.price, proration_behavior: 'none' }, + `overage-item-price:${args.item}:${args.price}` + ) + }, + /** DELETE is idempotent in Stripe, so no idempotency key (Stripe ignores one there). */ + deleteSubscriptionItem(item: string) { + return request<{ id: string; deleted: boolean }>( + 'DELETE', + `/v1/subscription_items/${encodeURIComponent(item)}`, + { proration_behavior: 'none' } + ) + }, setSubscriptionItemQuantity(args: { item: string quantity: number @@ -202,7 +219,7 @@ export type StripeSubscription = { quantity?: number current_period_start?: number current_period_end?: number - price: { id: string } + price: { id: string; recurring?: { usage_type?: string } | null } }> } } diff --git a/cloud/apps/billing/src/usage.ts b/cloud/apps/billing/src/usage.ts index 2591a9698b7..015e0b6fc71 100644 --- a/cloud/apps/billing/src/usage.ts +++ b/cloud/apps/billing/src/usage.ts @@ -1,8 +1,9 @@ // Sandbox usage ingestion (control plane -> billing) and the Stripe meter outbox. // Only overage reaches Stripe, and only for accounts that opted in, up to their monthly spend cap: // included hours are tracked here (pooled across seats, monthly even on annual plans, which tiered -// Stripe prices cannot express); the overage price is a flat per-hour metered price on a -// "standard seconds" meter with transform_quantity divide_by=3600. +// Stripe prices cannot express); the overage price is a flat per-hour rate billed by the minute: a +// metered per-minute price on a "standard seconds" meter with transform_quantity divide_by=60. +// Metering stays in seconds (exact per segment); Stripe rounds the period total up to a minute. import { z } from 'zod' import type { Env } from './env' import { MACHINE_SIZE_WEIGHT, MACHINE_SIZES, capSeconds, includedSeconds, planCatalog } from './plans' diff --git a/cloud/apps/billing/test/billing-config.test.ts b/cloud/apps/billing/test/billing-config.test.ts index 7fffd02109d..1b5f3835a5e 100644 --- a/cloud/apps/billing/test/billing-config.test.ts +++ b/cloud/apps/billing/test/billing-config.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { allowanceWindow, capSeconds, DEFAULT_PLANS } from '../src/plans' +import { allowanceWindow, capSeconds, DEFAULT_PLANS, overageCents, overageUnitAmountDecimal } from '../src/plans' +import { ORCA_CLOUD_OVERAGE_CENTS_PER_HOUR } from '../../../../src/shared/hosted-sandbox/hosted-sandbox-wire' import { createHarness } from './harness' const HOUR = 3600 @@ -85,4 +86,43 @@ describe('allowance windows and caps', () => { expect(DEFAULT_PLANS.team.overageCentsPerHour).toBe(100) expect(capSeconds(DEFAULT_PLANS.team, 2500)).toBe(25 * HOUR) }) + + it('alerts at 75, 90 and 100% of the included hours', () => { + expect(DEFAULT_PLANS.team.warnAtPercent).toEqual([75, 90, 100]) + }) +}) + +describe('per-minute overage price', () => { + const team = DEFAULT_PLANS.team + + it('derives the Stripe per-minute unit_amount_decimal from the shared hourly rate', () => { + expect(team.overageCentsPerHour).toBe(ORCA_CLOUD_OVERAGE_CENTS_PER_HOUR) + // $1.00 per hour = 1.666666666667 cents per minute (Stripe's 12-decimal maximum). + expect(overageUnitAmountDecimal(100)).toBe('1.666666666667') + expect(overageUnitAmountDecimal(75)).toBe('1.250000000000') + expect(overageUnitAmountDecimal(team.overageCentsPerHour).split('.')[1]).toHaveLength(12) + }) + + it('charges whole minutes, rounding up, like the price (transform_quantity divide_by 60, round up)', () => { + expect(overageCents(team, 0)).toBe(0) + expect(overageCents(team, 1)).toBe(2) // one started minute: 1.67 cents + expect(overageCents(team, 60)).toBe(2) + expect(overageCents(team, 61)).toBe(3) // two minutes: 3.33 cents + expect(overageCents(team, 30 * 60)).toBe(50) + expect(overageCents(team, HOUR)).toBe(100) + expect(overageCents(team, 4 * HOUR)).toBe(400) + }) + + it('caps on whole minutes, so the invoice never passes the spend cap', () => { + for (const cap of [0, 1, 99, 100, 101, 2500, 2501, 12_345, 500_000]) { + const seconds = capSeconds(team, cap) + expect(seconds % 60).toBe(0) + expect(overageCents(team, seconds)).toBeLessThanOrEqual(cap) + // ...and one more minute would pass it. + expect(overageCents(team, seconds + 60)).toBeGreaterThan(cap) + } + // $25.01 doesn't pay for a 1,501st minute ($25.0167); $25.02 does. + expect(capSeconds(team, 2501)).toBe(25 * HOUR) + expect(capSeconds(team, 2502)).toBe(25 * HOUR + 60) + }) }) diff --git a/cloud/apps/billing/test/billing-flow.test.ts b/cloud/apps/billing/test/billing-flow.test.ts index baeb58d52bb..7638d4f5d6c 100644 --- a/cloud/apps/billing/test/billing-flow.test.ts +++ b/cloud/apps/billing/test/billing-flow.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { checkoutCompleted, invoiceEvent, stripeEvent } from './stripe-fake' -import { createHarness } from './harness' +import { CONTROL_TOKEN, createHarness } from './harness' const HOUR = 3600 const PERIOD_START = Math.floor(Date.now() / 1000) - 5 * 24 * HOUR @@ -148,7 +148,7 @@ describe('webhooks', () => { it('tracks seat and interval changes made in the portal', async () => { const { h, auth, subscription } = await subscribedTeam() subscription.items.data[0]!.quantity = 5 - subscription.items.data[0]!.price = { id: 'price_team_year' } + subscription.items.data[0]!.price = { id: 'price_team_year', recurring: { usage_type: 'licensed' } } subscription.items.data[0]!.current_period_end = PERIOD_START + 365 * 24 * HOUR await h.webhook(stripeEvent('customer.subscription.updated', subscription)) const entitlement = await h.call('GET', '/v1/billing/entitlements', { headers: auth }) @@ -224,10 +224,24 @@ describe('entitlements, overage and usage', () => { expect(entitlement.body.offers.team).toMatchObject({ monthlyPriceLabel: '$50 per seat per month', annualPriceLabel: '$480 per seat, billed yearly' }) expect(entitlement.body.offers.enterprise).toMatchObject({ label: 'Enterprise', priceLabel: 'Custom' }) - for (let i = 0; i < 4; i++) { - await h.controlPlane('/v1/internal/usage', usage(`w-${i}`, 22)) + // Alerts at 75%, 90% and 100% of the 120 pooled hours (the highest level reached). Segments + // are at most a day, so the hours arrive in pieces: 10 so far. + const warning = async () => (await h.call('GET', '/v1/billing/entitlements', { headers: auth })).body.usageWarningPercent + const add = async (id: string, hours: number[]) => { + for (const [i, value] of hours.entries()) { + await h.controlPlane('/v1/internal/usage', usage(`${id}-${i}`, value)) + } } - expect((await h.call('GET', '/v1/billing/entitlements', { headers: auth })).body.usageWarningPercent).toBe(80) + await add('a', [20, 20, 20, 19]) // 89 h + expect(await warning()).toBe(null) + await add('b', [1]) // 90 h = 75% + expect(await warning()).toBe(75) + await add('c', [17]) // 107 h + expect(await warning()).toBe(75) + await add('d', [1]) // 108 h = 90% + expect(await warning()).toBe(90) + await add('e', [12]) // 120 h + expect(await warning()).toBe(100) }) it('is idempotent per usageId and counts every size 1x (legacy large is the same 8 vCPU / 16 GB box)', async () => { @@ -293,6 +307,109 @@ describe('entitlements, overage and usage', () => { expect((await check()).body).toMatchObject({ allow: true }) }) + it('puts running sandboxes to sleep (suspendRunning) when out of hours with overage off', async () => { + const { h, auth } = await subscribedTeam() + const check = () => h.controlPlane('/v1/internal/entitlements/check', { accountId: 'org:o1', machineSize: 'standard', runningSandboxes: 0 }) + for (let i = 0; i < 4; i++) { + expect((await h.controlPlane('/v1/internal/usage', usage(`u-${i}`, 24))).body).toMatchObject({ suspendRunning: false }) + } + expect((await check()).body).toMatchObject({ allow: true, suspendRunning: false }) + // The report that uses the last included hour tells the control plane to put the rest to sleep. + expect((await h.controlPlane('/v1/internal/usage', usage('u-4', 24))).body).toMatchObject({ + canStartSandbox: false, + reason: 'included_hours_exhausted', + suspendRunning: true + }) + expect((await check()).body).toMatchObject({ allow: false, reason: 'included_hours_exhausted', suspendRunning: true }) + const entitlement = (await h.call('GET', '/v1/billing/entitlements', { headers: auth })).body + // Not a payment problem: the payment fields (and the desktop's payment notice) stay clear. + expect(entitlement).toMatchObject({ suspendRunning: true, payment: { pastDueSince: null, suspendRunning: false } }) + // A cap is not a cancellation: no payment suspension, so no data-retention clock. + expect((await h.call('GET', '/v1/internal/suspensions', { headers: { authorization: `Bearer ${CONTROL_TOKEN}` } })).body).toEqual({ accounts: [] }) + + // Turning overage on lifts it. + await h.call('POST', '/v1/billing/overage', { body: { enabled: true }, headers: auth }) + expect((await check()).body).toMatchObject({ allow: true, suspendRunning: false }) + }) + + it('puts running sandboxes to sleep at the spend cap, and lifts it when the cap is raised', async () => { + const { h, auth } = await subscribedTeam({ overage: true, spendCapCents: 300 }) + const check = () => h.controlPlane('/v1/internal/entitlements/check', { accountId: 'org:o1', machineSize: 'standard', runningSandboxes: 0 }) + for (let i = 0; i < 5; i++) { + await h.controlPlane('/v1/internal/usage', usage(`u-${i}`, 24)) + } + expect((await h.controlPlane('/v1/internal/usage', usage('o-1', 2))).body).toMatchObject({ suspendRunning: false }) + expect((await check()).body).toMatchObject({ allow: true, suspendRunning: false }) + expect((await h.controlPlane('/v1/internal/usage', usage('o-2', 1))).body).toMatchObject({ + reason: 'overage_cap_reached', + suspendRunning: true + }) + expect((await check()).body).toMatchObject({ allow: false, reason: 'overage_cap_reached', suspendRunning: true }) + const entitlement = (await h.call('GET', '/v1/billing/entitlements', { headers: auth })).body + expect(entitlement).toMatchObject({ suspendRunning: true, payment: { suspendRunning: false }, overage: { spentCents: 300 } }) + await h.call('POST', '/v1/billing/overage', { body: { enabled: true, spendCapCents: 2500 }, headers: auth }) + expect((await check()).body).toMatchObject({ allow: true, suspendRunning: false }) + }) + + it('on opt-in, moves an item on an older overage price to the current price instead of adding a second', async () => { + const h = await createHarness() + const token = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner', email: 'a@example.com' }) + const auth = { authorization: `Bearer ${token}` } + await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team', seats: 2 }, headers: auth }) + const customer = [...h.stripe.customers.keys()][0]! + // Opted in back when overage was $0.75 an hour, then opted out (the item stays). + const subscription = h.stripe.putSubscription({ + customer, + accountId: 'org:o1', + basePrice: 'price_team_month', + overagePrice: 'price_overage_075_hourly', + quantity: 2, + periodStart: PERIOD_START, + periodEnd: PERIOD_END + }) + await h.webhook(checkoutCompleted({ customer, subscription: subscription.id, accountId: 'org:o1' })) + const oldItem = subscription.items.data[1]!.id + // Off by default: no overage, and the old item is not treated as the current one. + expect((await h.call('GET', '/v1/billing/entitlements', { headers: auth })).body.overage).toMatchObject({ optedIn: false, active: false }) + + expect((await h.call('POST', '/v1/billing/overage', { body: { enabled: true }, headers: auth })).status).toBe(200) + expect(subscription.items.data.map((item) => [item.id, item.price.id])).toEqual([ + [subscription.items.data[0]!.id, 'price_team_month'], + [oldItem, 'price_overage'] + ]) + expect(h.stripe.calls.some((call) => call.path === '/v1/subscription_items')).toBe(false) + const swap = h.stripe.calls.find((call) => call.path === `/v1/subscription_items/${oldItem}`)! + expect(swap.form.get('price')).toBe('price_overage') + expect(swap.form.get('proration_behavior')).toBe('none') + expect((await h.call('GET', '/v1/billing/entitlements', { headers: auth })).body.overage).toMatchObject({ optedIn: true, active: true }) + // The item's webhook keeps the id, and a second opt-in changes nothing. + await h.webhook(stripeEvent('customer.subscription.updated', subscription)) + await h.call('POST', '/v1/billing/overage', { body: { enabled: true, spendCapCents: 5000 }, headers: auth }) + expect(subscription.items.data).toHaveLength(2) + expect(h.raw.prepare('SELECT overage_item_id FROM subscriptions').get()).toEqual({ overage_item_id: oldItem }) + }) + + it('on opt-in, removes a stale overage item that sits next to the current one', async () => { + const h = await createHarness() + const token = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner', email: 'a@example.com' }) + const auth = { authorization: `Bearer ${token}` } + await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team', seats: 2 }, headers: auth }) + const customer = [...h.stripe.customers.keys()][0]! + const subscription = h.stripe.putSubscription({ + customer, + accountId: 'org:o1', + basePrice: 'price_team_month', + overagePrice: ['price_overage_075_hourly', 'price_overage'], + quantity: 2, + periodStart: PERIOD_START, + periodEnd: PERIOD_END + }) + await h.webhook(checkoutCompleted({ customer, subscription: subscription.id, accountId: 'org:o1' })) + await h.call('POST', '/v1/billing/overage', { body: { enabled: true }, headers: auth }) + expect(subscription.items.data.map((item) => item.price.id)).toEqual(['price_team_month', 'price_overage']) + expect(h.stripe.calls.filter((call) => call.method === 'DELETE')).toHaveLength(1) + }) + it('opting out stops metering immediately and blocks starts past included hours', async () => { const { h, auth } = await subscribedTeam({ overage: true }) await h.call('POST', '/v1/billing/overage', { body: { enabled: false }, headers: auth }) diff --git a/cloud/apps/billing/test/stripe-fake.ts b/cloud/apps/billing/test/stripe-fake.ts index ae46ba2dd52..8c6209990b0 100644 --- a/cloud/apps/billing/test/stripe-fake.ts +++ b/cloud/apps/billing/test/stripe-fake.ts @@ -11,7 +11,15 @@ export type FakeSubscription = { cancel_at_period_end: boolean livemode: boolean metadata: Record - items: { object: 'list'; data: Array<{ id: string; quantity?: number; current_period_start: number; current_period_end: number; price: { id: string } }> } + items: { object: 'list'; data: FakeItem[] } +} + +type FakeItem = { + id: string + quantity?: number + current_period_start: number + current_period_end: number + price: { id: string; recurring: { usage_type: 'licensed' | 'metered' } } } export type RecordedCall = { method: string; path: string; form: URLSearchParams; headers: Headers } @@ -88,6 +96,10 @@ export function createFakeStripe() { const itemId = decodeURIComponent(url.pathname.slice('/v1/subscription_items/'.length)) for (const subscription of subscriptions.values()) { const item = subscription.items.data.find((entry) => entry.id === itemId) + if (item && form.has('price')) { + item.price = { id: form.get('price')!, recurring: { usage_type: 'metered' } } + return respond({ id: item.id, price: item.price }) + } if (item) { item.quantity = Number(form.get('quantity')) return respond({ id: item.id, quantity: item.quantity }) @@ -95,15 +107,26 @@ export function createFakeStripe() { } return Response.json({ error: { code: 'resource_missing', message: 'No such item' } }, { status: 404 }) } + if (method === 'DELETE' && url.pathname.startsWith('/v1/subscription_items/')) { + const itemId = decodeURIComponent(url.pathname.slice('/v1/subscription_items/'.length)) + for (const subscription of subscriptions.values()) { + const index = subscription.items.data.findIndex((entry) => entry.id === itemId) + if (index >= 0) { + subscription.items.data.splice(index, 1) + return Response.json({ id: itemId, object: 'subscription_item', deleted: true }) + } + } + return Response.json({ error: { code: 'resource_missing', message: 'No such item' } }, { status: 404 }) + } if (method === 'POST' && url.pathname === '/v1/subscription_items') { const subscription = subscriptions.get(form.get('subscription') ?? '') if (!subscription) { return Response.json({ error: { code: 'resource_missing', message: 'No such subscription' } }, { status: 404 }) } const base = subscription.items.data[0]! - const item = { + const item: FakeItem = { id: `si_test_${nextId++}`, - price: { id: form.get('price') ?? '' }, + price: { id: form.get('price') ?? '', recurring: { usage_type: 'metered' } }, current_period_start: base.current_period_start, current_period_end: base.current_period_end } @@ -135,7 +158,8 @@ export function createFakeStripe() { customer: string accountId: string basePrice: string - overagePrice?: string + /** Metered overage items already on the subscription (e.g. an older overage price). */ + overagePrice?: string | string[] quantity?: number status?: string periodStart: number @@ -145,10 +169,10 @@ export function createFakeStripe() { const id = `sub_test_${nextId++}` const period = { current_period_start: args.periodStart, current_period_end: args.periodEnd } const data: FakeSubscription['items']['data'] = [ - { id: `si_test_${nextId++}`, quantity: args.quantity ?? 1, price: { id: args.basePrice }, ...period } + { id: `si_test_${nextId++}`, quantity: args.quantity ?? 1, price: { id: args.basePrice, recurring: { usage_type: 'licensed' } }, ...period } ] - if (args.overagePrice) { - data.push({ id: `si_test_${nextId++}`, price: { id: args.overagePrice }, ...period }) + for (const price of [args.overagePrice ?? []].flat()) { + data.push({ id: `si_test_${nextId++}`, price: { id: price, recurring: { usage_type: 'metered' } }, ...period }) } const subscription: FakeSubscription = { id, diff --git a/cloud/apps/billing/wrangler.dev.jsonc b/cloud/apps/billing/wrangler.dev.jsonc index d8f90af061e..9eab816db28 100644 --- a/cloud/apps/billing/wrangler.dev.jsonc +++ b/cloud/apps/billing/wrangler.dev.jsonc @@ -27,7 +27,8 @@ // Stripe TEST MODE objects in acct_1Or7xeKb73TzRkjk (scripts/stripe-test-setup.ts). "STRIPE_PRICE_TEAM_MONTHLY": "price_1UZmKpKb73TzRkjk33aiB2th", "STRIPE_PRICE_TEAM_ANNUAL": "price_1UZmKpKb73TzRkjkS7qv85NI", - "STRIPE_PRICE_OVERAGE": "price_1UaBI9Kb73TzRkjkdxshOPZZ", + // $1.00 per sandbox hour, billed by the minute (unit_amount_decimal 1.666666666667, divide_by 60). + "STRIPE_PRICE_OVERAGE": "price_1UaDDzKb73TzRkjkznGWUEoC", "STRIPE_PORTAL_CONFIGURATION": "bpc_1UZmKvKb73TzRkjk59rIuHbx", "BILLING_RETURN_URL": "https://orca-billing-dev.lovecast.workers.dev/return", "BILLING_JWT_AUDIENCE": "orca-billing", diff --git a/cloud/apps/sandbox-control/test/lifecycle-flow.test.ts b/cloud/apps/sandbox-control/test/lifecycle-flow.test.ts index 7ccdbb818a9..d7a9045ca88 100644 --- a/cloud/apps/sandbox-control/test/lifecycle-flow.test.ts +++ b/cloud/apps/sandbox-control/test/lifecycle-flow.test.ts @@ -251,6 +251,21 @@ describe('sandbox control plane cron', () => { expect((await h.call('GET', `/v1/sandboxes/${a}`)).body.sandbox.status).toBe('suspended') }) + it('at the spend cap, puts every running sandbox of the account to sleep and deletes nothing', async () => { + const h = await createHarness() + const a = (await h.call('POST', '/v1/sandboxes', { body: create })).body.sandbox.id + const b = (await h.call('POST', '/v1/sandboxes', { body: { ...create, workspaceId: 'ws-2' } })).body.sandbox.id + h.billing.usageAnswer = { canStartSandbox: false, reason: 'overage_cap_reached', suspendRunning: true } + h.advance(10 * MIN) + await h.reconcile() + for (const id of [a, b]) { + expect((await h.call('GET', `/v1/sandboxes/${id}`)).body.sandbox.status).toBe('suspended') + } + // Asleep, not deleted: the VMs (and their disks) are kept, and no data-retention clock starts. + expect(h.vercel.boxes.size).toBe(2) + expect(h.raw.prepare(`SELECT COUNT(*) AS n FROM account_holds WHERE reason <> 'active'`).get()).toEqual({ n: 0 }) + }) + it('refuses machine sizes billing does not sell', async () => { const h = await createHarness() expect((await h.call('POST', '/v1/sandboxes', { body: { ...create, machineSize: 'xlarge' } })).status).toBe(400) diff --git a/mobile/src/cloud/cloud-entitlement-copy.test.ts b/mobile/src/cloud/cloud-entitlement-copy.test.ts index 362641a6dde..199ca63e10a 100644 --- a/mobile/src/cloud/cloud-entitlement-copy.test.ts +++ b/mobile/src/cloud/cloud-entitlement-copy.test.ts @@ -21,13 +21,15 @@ describe('entitlementDeniedMessage', () => { expect( entitlementDeniedMessage(denied('included_hours_exhausted'), 'admin', 'existing', t) ).toBe( - 'Out of sandbox hours. Your files are safe. Turn on overage in Orca on your computer, then try again.' + 'Out of sandbox hours. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Turn on overage in Orca on your computer, then try again.' ) expect( entitlementDeniedMessage(denied('included_hours_exhausted'), 'member', 'existing', t) - ).toBe('Out of sandbox hours. Your files are safe. Ask an owner or admin to add hours.') + ).toBe( + 'Out of sandbox hours. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Ask an owner or admin to add hours.' + ) expect(entitlementDeniedMessage(denied('overage_cap_reached'), 'owner', 'create', t)).toBe( - 'Spend cap reached. Nothing was created, and you weren’t charged. Raise the spend cap in Orca on your computer, then try again.' + 'Spend cap reached. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Raise the spend cap in Orca on your computer, then try again.' ) expect(entitlementDeniedMessage(denied('payment_grace_expired'), 'member', 'create', t)).toBe( 'Payment failed, so new sandboxes can’t start. Nothing was created, and you weren’t charged. Ask an owner or admin to update the payment method.' diff --git a/mobile/src/cloud/cloud-entitlement-copy.ts b/mobile/src/cloud/cloud-entitlement-copy.ts index c015fbd7165..a280fc44e18 100644 --- a/mobile/src/cloud/cloud-entitlement-copy.ts +++ b/mobile/src/cloud/cloud-entitlement-copy.ts @@ -70,9 +70,15 @@ function problem( ): string { switch (denial) { case 'hours': - return t('cloud.denied.hours', 'Out of sandbox hours.') + return t( + 'cloud.denied.hours2', + 'Out of sandbox hours. Running sandboxes go to sleep and new ones can’t start.' + ) case 'cap': - return t('cloud.denied.cap', 'Spend cap reached.') + return t( + 'cloud.denied.cap2', + 'Spend cap reached. Running sandboxes go to sleep and new ones can’t start.' + ) case 'payment': return t('cloud.denied.payment', 'Payment failed, so new sandboxes can’t start.') case 'plan': @@ -175,9 +181,12 @@ export function entitlementDeniedMessage( return t('cloud.denied.message', '{{problem}} {{safety}} {{next}}', { problem: problem(denial, when, owner, t), safety: - when === 'create' - ? t('cloud.denied.safety.create', 'Nothing was created, and you weren’t charged.') - : t('cloud.denied.safety.existing', 'Your files are safe.'), + // Out of hours or at the cap, running sandboxes go to sleep, so say what that keeps. + denial === 'hours' || denial === 'cap' + ? t('cloud.denied.safety.asleep', 'Nothing is deleted, and your files are safe.') + : when === 'create' + ? t('cloud.denied.safety.create', 'Nothing was created, and you weren’t charged.') + : t('cloud.denied.safety.existing', 'Your files are safe.'), next: nextStep(denial, manager, when, owner, t) }) } diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index a79e144ec2c..f83f371ccf4 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -5128,8 +5128,6 @@ "title": "Connect Orca Cloud" }, "denied": { - "hours": "Out of sandbox hours.", - "cap": "Spend cap reached.", "payment": "Payment failed, so new sandboxes can’t start.", "plan": "Orca Cloud needs a Team plan.", "teamLimit": "Your team is at its limit of running sandboxes (3 per seat).", @@ -5156,8 +5154,11 @@ "message": "{{problem}} {{safety}} {{next}}", "safety": { "create": "Nothing was created, and you weren’t charged.", - "existing": "Your files are safe." - } + "existing": "Your files are safe.", + "asleep": "Nothing is deleted, and your files are safe." + }, + "hours2": "Out of sandbox hours. Running sandboxes go to sleep and new ones can’t start.", + "cap2": "Spend cap reached. Running sandboxes go to sleep and new ones can’t start." }, "wake": { "slowQueued": "Taking longer than usual. Your files are safe. Your prompt sends as soon as it’s awake.", diff --git a/src/cli/cloud/cloud-api-errors.ts b/src/cli/cloud/cloud-api-errors.ts index 419617341a9..0c1d27e95c4 100644 --- a/src/cli/cloud/cloud-api-errors.ts +++ b/src/cli/cloud/cloud-api-errors.ts @@ -143,6 +143,8 @@ export function cloudApiError( ) } const safety = creating ? NOTHING_CREATED : FILES_SAFE + // Out of hours or at the spend cap, billing puts the team's running sandboxes to sleep (never deletes). + const GO_TO_SLEEP = "Running sandboxes go to sleep and new ones can't start." if ( code === 'entitlement_denied' && (reason === 'concurrency_limit' || reason === 'member_concurrency_limit') @@ -170,14 +172,14 @@ export function cloudApiError( if (reason === 'included_hours_exhausted') { return new RuntimeClientError( 'cloud_out_of_hours', - `Your team is out of sandbox hours. ${safety}`, + `Your team is out of sandbox hours. ${GO_TO_SLEEP} ${safety}`, data(['Ask an owner or admin to add hours.']) ) } if (reason === 'overage_cap_reached') { return new RuntimeClientError( 'cloud_spend_cap_reached', - `Your team reached its monthly spend cap. ${safety}`, + `Your team reached its monthly spend cap. ${GO_TO_SLEEP} ${safety}`, data(['Ask an owner or admin to raise the spend cap.']) ) } diff --git a/src/cli/cloud/cloud-glossary.test.ts b/src/cli/cloud/cloud-glossary.test.ts index 44dfddb58d6..02f0506910a 100644 --- a/src/cli/cloud/cloud-glossary.test.ts +++ b/src/cli/cloud/cloud-glossary.test.ts @@ -145,7 +145,7 @@ describe('glossary error copy', () => { ) expect(hours.code).toBe('cloud_out_of_hours') expect(hours.message).toBe( - "Your team is out of sandbox hours. Nothing was created, and you weren't charged." + "Your team is out of sandbox hours. Running sandboxes go to sleep and new ones can't start. Nothing was created, and you weren't charged." ) expect(hours.data).toMatchObject({ nextSteps: ['Ask an owner or admin to add hours.'] }) const cap = cloudApiError( @@ -164,7 +164,9 @@ describe('glossary error copy', () => { { error: 'entitlement_denied', reason: 'included_hours_exhausted' }, { credentials: user } ).message - ).toBe('Your team is out of sandbox hours. Your files are safe.') + ).toBe( + "Your team is out of sandbox hours. Running sandboxes go to sleep and new ones can't start. Your files are safe." + ) }) it('uses the exact 409, admin and rate-limit copy, with codes kept in data', () => { diff --git a/src/cli/cloud/index-cloud-commands.test.ts b/src/cli/cloud/index-cloud-commands.test.ts index f5807db00ff..b6d47003f3a 100644 --- a/src/cli/cloud/index-cloud-commands.test.ts +++ b/src/cli/cloud/index-cloud-commands.test.ts @@ -209,7 +209,7 @@ describe('orca cloud via main()', () => { ) await main(['cloud', 'workspace', 'create', '--name', 'x'], home) expect(err.join('\n')).toContain( - "Your team is out of sandbox hours. Nothing was created, and you weren't charged." + "Your team is out of sandbox hours. Running sandboxes go to sleep and new ones can't start. Nothing was created, and you weren't charged." ) expect(err.join('\n')).toContain('Ask an owner or admin to add hours.') expect(err.join('\n')).not.toMatch(/entitlement_denied|included_hours_exhausted|files are safe/) diff --git a/src/cli/cloud/index-worktree-create-on-cloud.test.ts b/src/cli/cloud/index-worktree-create-on-cloud.test.ts index 8f326c53f3f..c3bdfa9f858 100644 --- a/src/cli/cloud/index-worktree-create-on-cloud.test.ts +++ b/src/cli/cloud/index-worktree-create-on-cloud.test.ts @@ -147,12 +147,12 @@ describe('orca worktree create --on cloud via main()', () => { [ { error: 'entitlement_denied', reason: 'included_hours_exhausted' }, 'cloud_out_of_hours', - "Your team is out of sandbox hours. Nothing was created, and you weren't charged." + "Your team is out of sandbox hours. Running sandboxes go to sleep and new ones can't start. Nothing was created, and you weren't charged." ], [ { error: 'entitlement_denied', reason: 'overage_cap_reached' }, 'cloud_spend_cap_reached', - "Your team reached its monthly spend cap. Nothing was created, and you weren't charged." + "Your team reached its monthly spend cap. Running sandboxes go to sleep and new ones can't start. Nothing was created, and you weren't charged." ], [ { error: 'conflict', reason: 'concurrent_transition' }, diff --git a/src/renderer/src/components/billing/PlanBillingSection.fixture.tsx b/src/renderer/src/components/billing/PlanBillingSection.fixture.tsx index 930dab3c37f..e453da5969b 100644 --- a/src/renderer/src/components/billing/PlanBillingSection.fixture.tsx +++ b/src/renderer/src/components/billing/PlanBillingSection.fixture.tsx @@ -77,6 +77,27 @@ export const overageCapReached = defineFixture({ ) }) +// Overage is off by default: out of included hours, running sandboxes go to sleep. +export const hoursUsedOverageOff = defineFixture({ + width: 600, + render: () => ( + + ) +}) + export const pastDueMember = defineFixture({ width: 600, render: () => ( diff --git a/src/renderer/src/components/billing/PlanBillingUsage.tsx b/src/renderer/src/components/billing/PlanBillingUsage.tsx index 662c5e94884..1a1ca165028 100644 --- a/src/renderer/src/components/billing/PlanBillingUsage.tsx +++ b/src/renderer/src/components/billing/PlanBillingUsage.tsx @@ -138,16 +138,16 @@ export function OverageControls({ const changed = capValid && capDollars.trim() !== savedDollars const paymentFailed = entitlement.payment.pastDueSince !== null const description = t( - 'auto.components.billing.overageDescription6', - 'Optional overage at {{rate}} per sandbox hour, up to a monthly spend cap you set ({{cap}} by default).', + 'auto.components.billing.overageDescription7', + 'Optional overage at {{rate}} per sandbox hour, billed by the minute, up to a monthly spend cap you set ({{cap}} by default).', { rate: formatRateCents(overage.centsPerHour, locale), cap: formatCents(entitlement.offers?.team.defaultSpendCapCents ?? 2500, locale) } ) const descriptionOn = t( - 'auto.components.billing.overageDescriptionOn3', - 'Optional overage at {{rate}} per sandbox hour, up to a monthly spend cap you set.', + 'auto.components.billing.overageDescriptionOn4', + 'Optional overage at {{rate}} per sandbox hour, billed by the minute, up to a monthly spend cap you set.', { rate: formatRateCents(overage.centsPerHour, locale) } ) const capPercent = @@ -303,8 +303,8 @@ export function OverageControls({ {capValid && entitlement.reason === 'overage_cap_reached' ? (

{t( - 'auto.components.billing.capReachedHint', - 'Raise the cap to keep starting sandboxes. Your files are safe.' + 'auto.components.billing.capReachedHint2', + 'Raise the cap to start sandboxes again. Your sandboxes are asleep, and your files are safe.' )}

) : null} diff --git a/src/renderer/src/components/billing/plan-billing-copy.ts b/src/renderer/src/components/billing/plan-billing-copy.ts index 8563da88b66..df35f015606 100644 --- a/src/renderer/src/components/billing/plan-billing-copy.ts +++ b/src/renderer/src/components/billing/plan-billing-copy.ts @@ -48,22 +48,22 @@ export function denyReasonCopy( case 'included_hours_exhausted': return canManage ? t( - 'auto.components.billing.reasonExhausted3', - 'Out of sandbox hours for this month. Turn on overage to keep starting sandboxes. Your files are safe.' + 'auto.components.billing.reasonExhausted4', + 'Out of sandbox hours for this month. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Turn on overage to start sandboxes again.' ) : t( - 'auto.components.billing.reasonExhaustedMember2', - 'Out of sandbox hours for this month. Ask an owner or admin to add hours. Your files are safe.' + 'auto.components.billing.reasonExhaustedMember3', + 'Out of sandbox hours for this month. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Ask an owner or admin to add hours.' ) case 'overage_cap_reached': return canManage ? t( - 'auto.components.billing.reasonCapAdmin2', - 'Spend cap reached. Raise the monthly spend cap to keep starting sandboxes. Your files are safe.' + 'auto.components.billing.reasonCapAdmin3', + 'Spend cap reached. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Raise the monthly spend cap to start sandboxes again.' ) : t( - 'auto.components.billing.reasonCapMember3', - 'Spend cap reached. Ask an owner or admin to raise the spend cap. Your files are safe.' + 'auto.components.billing.reasonCapMember4', + 'Spend cap reached. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Ask an owner or admin to raise the spend cap.' ) } } diff --git a/src/renderer/src/components/billing/plans/TeamPlanCard.tsx b/src/renderer/src/components/billing/plans/TeamPlanCard.tsx index b4cd0843ff7..d4f9d177058 100644 --- a/src/renderer/src/components/billing/plans/TeamPlanCard.tsx +++ b/src/renderer/src/components/billing/plans/TeamPlanCard.tsx @@ -70,8 +70,8 @@ export function TeamPlanCard({ ), t('auto.components.billing.plans.featureReady', 'Ready in seconds, nothing to set up or host'), t( - 'auto.components.billing.plans.featureOverage6', - 'Optional overage at {{rate}} per sandbox hour, up to a monthly spend cap you set ({{cap}} by default)', + 'auto.components.billing.plans.featureOverage7', + 'Optional overage at {{rate}} per sandbox hour, billed by the minute, up to a monthly spend cap you set ({{cap}} by default)', { rate: formatRateCents(team.overageCentsPerHour, locale), cap: formatCents(team.defaultSpendCapCents, locale) diff --git a/src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts b/src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts index 8a55cc84ea8..306d33527ab 100644 --- a/src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts +++ b/src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts @@ -92,12 +92,12 @@ export function vmBuildFailureCopy( ), body: canManageBilling ? t( - 'auto.components.worktree.creation.VmBuildFailureCard.outOfHoursBody', - 'Your team has used this month’s sandbox hours. Turn on overage to keep starting sandboxes. Nothing was created, and you weren’t charged.' + 'auto.components.worktree.creation.VmBuildFailureCard.outOfHoursBody2', + 'Your team has used this month’s sandbox hours, so sandboxes can’t start or keep running. Nothing was created, and you weren’t charged. Turn on overage to start sandboxes again.' ) : t( - 'auto.components.worktree.creation.VmBuildFailureCard.outOfHoursBodyMember2', - 'Your team has used this month’s sandbox hours. Nothing was created, and you weren’t charged. Ask an owner or admin to add hours.' + 'auto.components.worktree.creation.VmBuildFailureCard.outOfHoursBodyMember3', + 'Your team has used this month’s sandbox hours, so sandboxes can’t start or keep running. Nothing was created, and you weren’t charged. Ask an owner or admin to add hours.' ) } case 'spendCap': @@ -108,12 +108,12 @@ export function vmBuildFailureCopy( ), body: canManageBilling ? t( - 'auto.components.worktree.creation.VmBuildFailureCard.spendCapReachedBody', - 'Your team has reached this month’s spend cap. Raise it to keep starting sandboxes. Nothing was created, and you weren’t charged.' + 'auto.components.worktree.creation.VmBuildFailureCard.spendCapReachedBody2', + 'Your team has reached this month’s spend cap, so sandboxes can’t start or keep running. Nothing was created, and you weren’t charged. Raise the cap to start sandboxes again.' ) : t( - 'auto.components.worktree.creation.VmBuildFailureCard.spendCapReachedBodyMember3', - 'Your team has reached this month’s spend cap. Nothing was created, and you weren’t charged. Ask an owner or admin to raise the spend cap.' + 'auto.components.worktree.creation.VmBuildFailureCard.spendCapReachedBodyMember4', + 'Your team has reached this month’s spend cap, so sandboxes can’t start or keep running. Nothing was created, and you weren’t charged. Ask an owner or admin to raise the spend cap.' ) } case 'payment': diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9c21ee239a7..ba817e06427 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4195,9 +4195,9 @@ "connectGitHub": "Connect GitHub", "planBody2": "Cloud workspaces are part of the Team plan. Nothing was created, and you weren’t charged.", "outOfHoursTitle": "Out of sandbox hours", - "outOfHoursBody": "Your team has used this month’s sandbox hours. Turn on overage to keep starting sandboxes. Nothing was created, and you weren’t charged.", + "outOfHoursBody2": "Your team has used this month’s sandbox hours, so sandboxes can’t start or keep running. Nothing was created, and you weren’t charged. Turn on overage to start sandboxes again.", "spendCapReachedTitle": "Spend cap reached", - "spendCapReachedBody": "Your team has reached this month’s spend cap. Raise it to keep starting sandboxes. Nothing was created, and you weren’t charged.", + "spendCapReachedBody2": "Your team has reached this month’s spend cap, so sandboxes can’t start or keep running. Nothing was created, and you weren’t charged. Raise the cap to start sandboxes again.", "paymentFailedTitle": "Payment failed", "paymentFailedBody": "New sandboxes can’t start until the payment goes through. Nothing was created, and you weren’t charged.", "teamLimitTitle": "You’re at your team’s limit of running sandboxes", @@ -4217,8 +4217,8 @@ "howToPushToGitHub": "How to push to GitHub", "useThisComputer": "Use this computer instead", "planBodyMember3": "Nothing was created, and you weren’t charged. Ask an owner or admin to choose a Team plan.", - "outOfHoursBodyMember2": "Your team has used this month’s sandbox hours. Nothing was created, and you weren’t charged. Ask an owner or admin to add hours.", - "spendCapReachedBodyMember3": "Your team has reached this month’s spend cap. Nothing was created, and you weren’t charged. Ask an owner or admin to raise the spend cap.", + "outOfHoursBodyMember3": "Your team has used this month’s sandbox hours, so sandboxes can’t start or keep running. Nothing was created, and you weren’t charged. Ask an owner or admin to add hours.", + "spendCapReachedBodyMember4": "Your team has reached this month’s spend cap, so sandboxes can’t start or keep running. Nothing was created, and you weren’t charged. Ask an owner or admin to raise the spend cap.", "paymentFailedBodyMember2": "New sandboxes can’t start until the payment goes through. Nothing was created, and you weren’t charged. Ask an owner or admin to update the payment method." }, "VmBuildLog": { @@ -20623,7 +20623,7 @@ "was": "Was", "featureHours4": "{{value0}} sandbox hours per seat each month, pooled across your team", "featureSpec": "Every sandbox gets {{value0}} vCPU · {{value1}} GB", - "featureOverage6": "Optional overage at {{rate}} per sandbox hour, up to a monthly spend cap you set ({{cap}} by default)", + "featureOverage7": "Optional overage at {{rate}} per sandbox hour, billed by the minute, up to a monthly spend cap you set ({{cap}} by default)", "footTerms4": "Prices in USD, before tax. Overage is off until an owner or admin turns it on. Cancel anytime; your plan runs to the end of the billing period.", "lockedBody2": "Only an owner or admin of your team can choose a plan. Ask one of them to open Plan & billing in Orca.", "vpSecretsBody4": "API keys you add to Orca are never written to the sandbox. Agents and terminals get them as environment variables.", @@ -20694,30 +20694,30 @@ "upsellLineNoPrice": "Orca Cloud runs agents in hosted sandboxes for your whole team.", "askAdmin2": "Ask an owner or admin to change the plan.", "capInvalid": "Enter a whole dollar amount.", - "capReachedHint": "Raise the cap to keep starting sandboxes. Your files are safe.", + "capReachedHint2": "Raise the cap to start sandboxes again. Your sandboxes are asleep, and your files are safe.", "capSaving": "Saving…", "capUsedLabel": "Overage spend cap used", "enterpriseContactSales": "Contact sales", "openForbidden2": "Only an owner or admin can manage billing.", "openSignedOut2": "Sign in to Orca again to manage your team.", - "overageDescription6": "Optional overage at {{rate}} per sandbox hour, up to a monthly spend cap you set ({{cap}} by default).", + "overageDescription7": "Optional overage at {{rate}} per sandbox hour, billed by the minute, up to a monthly spend cap you set ({{cap}} by default).", "overageMemberHint": "Owners and admins can change overage.", "overageOffRead": "Off", "overageOnRead": "On", "overagePaymentFailed": "Update the payment method before changing overage.", "paymentAsleepBody": "New sandboxes can’t start and running sandboxes are asleep. They can wake again once the payment goes through. Your files are safe.", "paymentGraceBody2": "New sandboxes can’t start. Running sandboxes go to sleep on {{value0}} unless the payment goes through.", - "reasonCapAdmin2": "Spend cap reached. Raise the monthly spend cap to keep starting sandboxes. Your files are safe.", - "reasonExhausted3": "Out of sandbox hours for this month. Turn on overage to keep starting sandboxes. Your files are safe.", - "reasonExhaustedMember2": "Out of sandbox hours for this month. Ask an owner or admin to add hours. Your files are safe.", + "reasonCapAdmin3": "Spend cap reached. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Raise the monthly spend cap to start sandboxes again.", + "reasonExhausted4": "Out of sandbox hours for this month. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Turn on overage to start sandboxes again.", + "reasonExhaustedMember3": "Out of sandbox hours for this month. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Ask an owner or admin to add hours.", "reasonInactiveMember": "Your team’s plan is not active. Ask an owner or admin to choose a plan.", "reasonPastDueMember": "Your team’s last payment failed. Ask an owner or admin to update the payment method.", "resetsAndRenews": "Resets and renews {{value0}}", "resetsEnds": "Hours reset {{value0}} · Plan ends {{value1}}", "resetsRenews": "Hours reset {{value0}} · Renews {{value1}}", "sandboxHoursUsedMonth": "{{value0}} of {{value1}} sandbox hours used this month", - "overageDescriptionOn3": "Optional overage at {{rate}} per sandbox hour, up to a monthly spend cap you set.", - "reasonCapMember3": "Spend cap reached. Ask an owner or admin to raise the spend cap. Your files are safe.", + "overageDescriptionOn4": "Optional overage at {{rate}} per sandbox hour, billed by the minute, up to a monthly spend cap you set.", + "reasonCapMember4": "Spend cap reached. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Ask an owner or admin to raise the spend cap.", "reasonGraceExpired3": "Payment failed. New sandboxes can’t start and running sandboxes are asleep. Update your payment method. Your files are safe.", "reasonGraceExpiredMember2": "Payment failed. New sandboxes can’t start and running sandboxes are asleep. Ask an owner or admin to update the payment method. Your files are safe.", "reasonNoPlan2": "Orca Cloud needs a Team plan.", diff --git a/src/shared/orca-billing.ts b/src/shared/orca-billing.ts index 74f544bfd27..f12e089b8c2 100644 --- a/src/shared/orca-billing.ts +++ b/src/shared/orca-billing.ts @@ -99,7 +99,7 @@ export type OrcaBillingEntitlement = { includedSeconds: number usedSeconds: number remainingIncludedSeconds: number - /** Highest included-hours warning level reached (80, 100), or null. */ + /** Highest included-hours warning level reached (75, 90 or 100), or null. */ usageWarningPercent: number | null overageSeconds: number overage: {