From dc0bf820eae3411c1b3a2d512bd2bddd40138eb4 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 01:26:39 -0800 Subject: [PATCH] feat(billing): overage $1.00 per sandbox hour (interim); plans.ts is the single source (setup script, e2e and spend-cap math read it); new Stripe TEST price --- cloud/apps/billing/scripts/e2e-test-mode.ts | 9 ++++++--- cloud/apps/billing/scripts/stripe-test-setup.ts | 5 +++-- cloud/apps/billing/src/plans.ts | 4 +++- cloud/apps/billing/test/billing-config.test.ts | 7 ++++--- cloud/apps/billing/test/billing-flow.test.ts | 8 ++++---- cloud/apps/billing/wrangler.dev.jsonc | 2 +- cloud/docs/accounts-billing-design.md | 4 ++-- .../components/billing/PlanBillingSection.fixture.tsx | 2 +- .../src/components/billing/plan-billing-fixtures.ts | 4 ++-- 9 files changed, 26 insertions(+), 19 deletions(-) diff --git a/cloud/apps/billing/scripts/e2e-test-mode.ts b/cloud/apps/billing/scripts/e2e-test-mode.ts index ef684d9bf5e..7290c4b1cfe 100644 --- a/cloud/apps/billing/scripts/e2e-test-mode.ts +++ b/cloud/apps/billing/scripts/e2e-test-mode.ts @@ -20,6 +20,7 @@ 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 { encodeForm } from '../src/stripe' const HOUR = 3600 @@ -263,21 +264,23 @@ 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 = 33.3 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`, capped.allow === false && capped.reason === 'overage_cap_reached', capped) const spent = await entitlement(orgA) check('metered spend stops at the cap', spent.overage?.spentCents <= 2500 && spent.overage?.spentCents >= 2490, spent.overage) 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 summaries = await until( 'meter event summaries', () => stripe<{ data: Array<{ aggregated_value: number }> }>('GET', `/v1/billing/meters/${catalog.meter}/event_summaries`, { customer: customerA, start_time: meterStart, end_time: meterEnd }), - (s) => s.data.reduce((sum, row) => sum + row.aggregated_value, 0) >= Math.floor((2500 / 75) * HOUR), + (s) => s.data.reduce((sum, row) => sum + row.aggregated_value, 0) >= CAP_SECONDS, 240_000 ) const meteredTotal = summaries.data.reduce((sum, row) => sum + row.aggregated_value, 0) -check('Stripe meter received exactly the capped overage seconds', meteredTotal === Math.floor((2500 / 75) * HOUR), { meteredTotal }) +check('Stripe meter received exactly the capped overage seconds', meteredTotal === CAP_SECONDS, { meteredTotal }) // ---- 5. Portal ------------------------------------------------------------------------------ // ---- 5a. Seats follow membership (auth pushes member counts) ------------------------------------ diff --git a/cloud/apps/billing/scripts/stripe-test-setup.ts b/cloud/apps/billing/scripts/stripe-test-setup.ts index c59e7378b5f..b5d5846a691 100644 --- a/cloud/apps/billing/scripts/stripe-test-setup.ts +++ b/cloud/apps/billing/scripts/stripe-test-setup.ts @@ -6,8 +6,9 @@ // STRIPE_SECRET_KEY=sk_test_... BILLING_RETURN_URL=https://... tsx scripts/stripe-test-setup.ts // // Prices (cents) default to the agreed Team pricing; override with -// STRIPE_SETUP_PRICES='{"teamMonthly":5000,"teamAnnual":48000,"overagePerHour":75}' +// 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 { encodeForm } from '../src/stripe' const key = process.env.STRIPE_SECRET_KEY ?? '' @@ -40,7 +41,7 @@ async function post(path: string, params: Record { includedHoursPerSeat: 40, concurrentSandboxesPerSeat: 3, idleSleepMinutes: 15, - overageCentsPerHour: 75, + overageCentsPerHour: 100, defaultSpendCapCents: 5000 }, enterprise: { label: 'Enterprise', priceLabel: 'Custom', contactUrl: 'https://example.test/sales' } @@ -80,7 +80,8 @@ describe('allowance windows and caps', () => { expect(allowanceWindow(period, period.end - day)).toEqual({ start: Date.UTC(2026, 11, 31), end: period.end }) }) - it('converts the $25 default cap to 33.3 overage hours at $0.75', () => { - expect(capSeconds(DEFAULT_PLANS.team, 2500)).toBe(Math.floor((2500 / 75) * HOUR)) + it('converts the $25 default cap to 25 overage hours at $1.00', () => { + expect(DEFAULT_PLANS.team.overageCentsPerHour).toBe(100) + expect(capSeconds(DEFAULT_PLANS.team, 2500)).toBe(25 * HOUR) }) }) diff --git a/cloud/apps/billing/test/billing-flow.test.ts b/cloud/apps/billing/test/billing-flow.test.ts index b75b31bc069..61605eb041e 100644 --- a/cloud/apps/billing/test/billing-flow.test.ts +++ b/cloud/apps/billing/test/billing-flow.test.ts @@ -215,7 +215,7 @@ describe('entitlements, overage and usage', () => { usedSeconds: 10 * HOUR, remainingIncludedSeconds: 110 * HOUR, usageWarningPercent: null, - overage: { optedIn: false, active: false, spendCapCents: 2500, spentCents: 0, centsPerHour: 75 }, + overage: { optedIn: false, active: false, spendCapCents: 2500, spentCents: 0, centsPerHour: 100 }, limits: { maxConcurrentSandboxes: 6, machineSizes: ['standard', 'large'], idleSleepMinutes: 15 }, canStartSandbox: true, canManageBilling: true, @@ -272,17 +272,17 @@ describe('entitlements, overage and usage', () => { expect.objectContaining({ identifier: 'u-5', 'payload[stripe_customer_id]': customer, 'payload[value]': String(4 * HOUR) }) ]) const entitlement = await h.call('GET', '/v1/billing/entitlements', { headers: auth }) - expect(entitlement.body.overage).toMatchObject({ optedIn: true, active: true, spentCents: 300 }) + expect(entitlement.body.overage).toMatchObject({ optedIn: true, active: true, spentCents: 400 }) }) it('stops metering and new starts at the spend cap; admins can raise it', async () => { - // $3 cap = 4 overage hours at $0.75. + // $3 cap = 3 overage hours at $1.00. const { h, auth } = await subscribedTeam({ overage: true, spendCapCents: 300 }) for (let i = 0; i < 5; i++) { await h.controlPlane('/v1/internal/usage', usage(`u-${i}`, 24)) } await h.controlPlane('/v1/internal/usage', usage('o-1', 6)) - expect(h.stripe.meterEvents.map((event) => event['payload[value]'])).toEqual([String(4 * HOUR)]) + expect(h.stripe.meterEvents.map((event) => event['payload[value]'])).toEqual([String(3 * HOUR)]) const check = () => h.controlPlane('/v1/internal/entitlements/check', { accountId: 'org:o1', machineSize: 'standard', runningSandboxes: 0 }) expect((await check()).body).toMatchObject({ allow: false, reason: 'overage_cap_reached' }) diff --git a/cloud/apps/billing/wrangler.dev.jsonc b/cloud/apps/billing/wrangler.dev.jsonc index 4066c9a671a..d8f90af061e 100644 --- a/cloud/apps/billing/wrangler.dev.jsonc +++ b/cloud/apps/billing/wrangler.dev.jsonc @@ -27,7 +27,7 @@ // 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_1UZmKvKb73TzRkjkycTvfq0o", + "STRIPE_PRICE_OVERAGE": "price_1UaBI9Kb73TzRkjkdxshOPZZ", "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/docs/accounts-billing-design.md b/cloud/docs/accounts-billing-design.md index b4051776976..45b5d041d43 100644 --- a/cloud/docs/accounts-billing-design.md +++ b/cloud/docs/accounts-billing-design.md @@ -8,7 +8,7 @@ Code: `cloud/apps/billing` (Worker), desktop `src/main/billing`, `src/renderer/s | Plan | Price | Includes | |---|---|---| | Team | $50/seat/mo or $480/seat/yr | 60 sandbox hours per seat per month, pooled across the org; one sandbox size, 8 vCPU / 16 GB (`ORCA_CLOUD_SANDBOX_SPEC`), at 1x; 3 running sandboxes per seat; 15-min idle sleep | -| Overage | opt-in, off by default | $0.75 per sandbox hour; monthly spend cap, default $25, admins can raise (max $5,000); warnings at 80% / 100% of included hours | +| Overage | opt-in, off by default | $1.00 per sandbox hour (`overageCentsPerHour` in plans.ts, the single source); monthly spend cap, default $25, admins can raise (max $5,000); warnings at 80% / 100% of included hours | | Enterprise | Custom | "Contact us" (no Stripe price) | No free plan, no trial, no other tiers. Numbers and labels live in `src/plans.ts` defaults and can be overridden @@ -162,7 +162,7 @@ account is the upgrade path for strict per-account start locking. subscription from the API (order/staleness-proof). Handles `checkout.session.completed`, `customer.subscription.*`, `invoice.paid`, `invoice.payment_failed` (old and new payload shapes). Credited only to the customer billing created for that account. Live-mode events are acked and ignored. Failures → 500 so Stripe retries. -- **Meter**: `orca_sandbox_seconds` (sum by customer); overage price $0.75/h via `transform_quantity.divide_by=3600`. +- **Meter**: `orca_sandbox_seconds` (sum by customer); overage price $1.00/h via `transform_quantity.divide_by=3600`. Only billable overage is sent, at most the remaining spend cap; `identifier=usageId` + Idempotency-Key. ## Entitlements diff --git a/src/renderer/src/components/billing/PlanBillingSection.fixture.tsx b/src/renderer/src/components/billing/PlanBillingSection.fixture.tsx index e97031b8cbf..c07c627551b 100644 --- a/src/renderer/src/components/billing/PlanBillingSection.fixture.tsx +++ b/src/renderer/src/components/billing/PlanBillingSection.fixture.tsx @@ -67,7 +67,7 @@ export const overageCapReached = defineFixture({ active: true, spendCapCents: 2500, spentCents: 2500, - centsPerHour: 75 + centsPerHour: 100 }, canStartSandbox: false, reason: 'overage_cap_reached' diff --git a/src/renderer/src/components/billing/plan-billing-fixtures.ts b/src/renderer/src/components/billing/plan-billing-fixtures.ts index d581f6f8b48..bed4b7c4667 100644 --- a/src/renderer/src/components/billing/plan-billing-fixtures.ts +++ b/src/renderer/src/components/billing/plan-billing-fixtures.ts @@ -15,7 +15,7 @@ export const sampleOffers: OrcaBillingOffers = { includedHoursPerSeat: 60, concurrentSandboxesPerSeat: 3, idleSleepMinutes: ORCA_CLOUD_IDLE_SLEEP_MINUTES, - overageCentsPerHour: 75, + overageCentsPerHour: 100, defaultSpendCapCents: 2500 }, enterprise: { @@ -40,7 +40,7 @@ export const sampleTeam: OrcaBillingEntitlement = { remainingIncludedSeconds: 78.5 * HOUR, usageWarningPercent: null, overageSeconds: 0, - overage: { optedIn: false, active: false, spendCapCents: 2500, spentCents: 0, centsPerHour: 75 }, + overage: { optedIn: false, active: false, spendCapCents: 2500, spentCents: 0, centsPerHour: 100 }, payment: { pastDueSince: null, graceEndsAt: null, suspendRunning: false }, canStartSandbox: true, reason: null,