mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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
This commit is contained in:
@@ -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) ------------------------------------
|
||||
|
||||
@@ -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<T extends StripeObject>(path: string, params: Record<string,
|
||||
return json
|
||||
}
|
||||
|
||||
const prices = { teamMonthly: 5000, teamAnnual: 48000, overagePerHour: 75, ...JSON.parse(process.env.STRIPE_SETUP_PRICES ?? '{}') }
|
||||
const prices = { teamMonthly: 5000, teamAnnual: 48000, overagePerHour: DEFAULT_PLANS.team.overageCentsPerHour, ...JSON.parse(process.env.STRIPE_SETUP_PRICES ?? '{}') }
|
||||
const suffix = `${prices.teamMonthly}-${prices.teamAnnual}-${prices.overagePerHour}`
|
||||
|
||||
const team = await post('/v1/products', { name: 'Orca Team', unit_label: 'seat', metadata: { orca_plan: 'team' } }, 'product:team')
|
||||
|
||||
@@ -70,7 +70,9 @@ export const DEFAULT_PLANS: PlanCatalog = {
|
||||
machineSizes: ['standard', 'large'],
|
||||
// Same constant the control plane enforces and serves (GET /v1/config); never a literal here.
|
||||
idleSleepMinutes: ORCA_CLOUD_IDLE_SLEEP_MINUTES,
|
||||
overageCentsPerHour: 75,
|
||||
// The one overage rate: entitlements, the spend-cap math, the desktop and the e2e all read it from
|
||||
// here. STRIPE_PRICE_OVERAGE must be a metered price with this unit_amount (stripe-test-setup.ts).
|
||||
overageCentsPerHour: 100,
|
||||
defaultSpendCapCents: 2500,
|
||||
maxSpendCapCents: 500_000,
|
||||
warnAtPercent: [80, 100],
|
||||
|
||||
@@ -41,7 +41,7 @@ describe('config: tax and plan catalog', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -67,7 +67,7 @@ export const overageCapReached = defineFixture({
|
||||
active: true,
|
||||
spendCapCents: 2500,
|
||||
spentCents: 2500,
|
||||
centsPerHour: 75
|
||||
centsPerHour: 100
|
||||
},
|
||||
canStartSandbox: false,
|
||||
reason: 'overage_cap_reached'
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user