mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
Merge pull request #235 from nwparker/cloud/pricing-u22-u24
feat(billing): overage billed by the minute; sandboxes go to sleep at the cap (U22, U24)
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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<StripeObject & { event_name: string }>(
|
||||
'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(
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string> {
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<T>(method: 'GET' | 'POST', path: string, params?: FormRecord, idempotencyKey?: string) {
|
||||
async function request<T>(method: 'GET' | 'POST' | 'DELETE', path: string, params?: FormRecord, idempotencyKey?: string) {
|
||||
assertKeyMode(env)
|
||||
const headers: Record<string, string> = {
|
||||
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 }
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -11,7 +11,15 @@ export type FakeSubscription = {
|
||||
cancel_at_period_end: boolean
|
||||
livemode: boolean
|
||||
metadata: Record<string, string>
|
||||
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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.'])
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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: () => (
|
||||
<PlanBillingView
|
||||
{...handlers}
|
||||
result={{
|
||||
status: 'ok',
|
||||
entitlement: {
|
||||
...sampleTeam,
|
||||
usedSeconds: sampleTeam.includedSeconds,
|
||||
remainingIncludedSeconds: 0,
|
||||
usageWarningPercent: 100,
|
||||
canStartSandbox: false,
|
||||
reason: 'included_hours_exhausted'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export const pastDueMember = defineFixture({
|
||||
width: 600,
|
||||
render: () => (
|
||||
|
||||
@@ -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' ? (
|
||||
<p className="basis-full text-xs text-muted-foreground">
|
||||
{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.'
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user