diff --git a/cloud/apps/billing/.gitignore b/cloud/apps/billing/.gitignore new file mode 100644 index 00000000000..a933f108fe0 --- /dev/null +++ b/cloud/apps/billing/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.wrangler/ +.dev.vars diff --git a/cloud/apps/billing/migrations/0001_init.sql b/cloud/apps/billing/migrations/0001_init.sql new file mode 100644 index 00000000000..6eb0998cc06 --- /dev/null +++ b/cloud/apps/billing/migrations/0001_init.sql @@ -0,0 +1,69 @@ +-- Billing ledger (D1). Identity (users, orgs, membership, roles) stays in Orca Cloud auth; +-- billing only stores the account id the auth token names. Times are epoch milliseconds. + +-- One row per billable account: an org ('org:') or a signed-in user without an org +-- ('user:'). +CREATE TABLE billing_accounts ( + account_id TEXT PRIMARY KEY, + stripe_customer_id TEXT UNIQUE, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +-- Mirror of the Stripe subscription, rewritten from a fresh API read on every webhook so +-- out-of-order deliveries cannot regress it. +CREATE TABLE subscriptions ( + stripe_subscription_id TEXT PRIMARY KEY, + account_id TEXT NOT NULL REFERENCES billing_accounts(account_id), + stripe_customer_id TEXT NOT NULL, + status TEXT NOT NULL, + plan TEXT NOT NULL, + seats INTEGER NOT NULL, + overage_enabled INTEGER NOT NULL, + current_period_start INTEGER NOT NULL, + current_period_end INTEGER NOT NULL, + cancel_at_period_end INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX subscriptions_account ON subscriptions(account_id, current_period_end); + +-- Webhook idempotency: an event id is processed at most once (processed_at set on success). +CREATE TABLE stripe_events ( + event_id TEXT PRIMARY KEY, + type TEXT NOT NULL, + received_at INTEGER NOT NULL, + processed_at INTEGER, + last_error TEXT +); + +-- Sandbox usage segments reported by the control plane. usage_id is the caller's idempotency +-- key. weighted_seconds = wall seconds x machine size weight. overage_seconds is the part of +-- this segment past the period's included allowance, fixed when the row is inserted. +CREATE TABLE usage_events ( + usage_id TEXT PRIMARY KEY, + account_id TEXT NOT NULL, + sandbox_id TEXT NOT NULL, + machine_size TEXT NOT NULL, + started_at INTEGER NOT NULL, + ended_at INTEGER NOT NULL, + weighted_seconds INTEGER NOT NULL, + period_start INTEGER, + overage_seconds INTEGER NOT NULL, + created_at INTEGER NOT NULL +); +CREATE INDEX usage_events_period ON usage_events(account_id, period_start); + +-- Outbox for Stripe meter events (overage only). Sent inline, retried by the cron trigger. +-- usage_id doubles as the Stripe meter event identifier, so a retry is deduplicated by Stripe. +CREATE TABLE meter_reports ( + usage_id TEXT PRIMARY KEY REFERENCES usage_events(usage_id), + stripe_customer_id TEXT NOT NULL, + seconds INTEGER NOT NULL, + event_timestamp INTEGER NOT NULL, + status TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + created_at INTEGER NOT NULL, + sent_at INTEGER +); +CREATE INDEX meter_reports_pending ON meter_reports(status, created_at); diff --git a/cloud/apps/billing/migrations/0002_team_overage.sql b/cloud/apps/billing/migrations/0002_team_overage.sql new file mode 100644 index 00000000000..be5b5dc29f3 --- /dev/null +++ b/cloud/apps/billing/migrations/0002_team_overage.sql @@ -0,0 +1,10 @@ +-- Team pricing: overage is opt-in per account with an admin-set monthly spend cap, and annual +-- plans keep a monthly allowance window (usage_events.period_start is that window's start). + +-- 1 once an admin opts in to overage; spend cap in cents (NULL = the plan default). +ALTER TABLE billing_accounts ADD COLUMN overage_opt_in INTEGER NOT NULL DEFAULT 0; +ALTER TABLE billing_accounts ADD COLUMN spend_cap_cents INTEGER; + +-- 'month' | 'year' (the seat price's interval) and the metered overage item, once added. +ALTER TABLE subscriptions ADD COLUMN billing_interval TEXT NOT NULL DEFAULT 'month'; +ALTER TABLE subscriptions ADD COLUMN overage_item_id TEXT; diff --git a/cloud/apps/billing/migrations/0003_payment_grace.sql b/cloud/apps/billing/migrations/0003_payment_grace.sql new file mode 100644 index 00000000000..0a2c3f2ec86 --- /dev/null +++ b/cloud/apps/billing/migrations/0003_payment_grace.sql @@ -0,0 +1,4 @@ +-- Payment failure grace: when billing first saw the subscription past_due/unpaid (epoch ms). +-- Cleared when it is paid again. New starts stop immediately; running sandboxes are suspended +-- once the plan's grace period has passed (entitlements.ts). +ALTER TABLE subscriptions ADD COLUMN past_due_since INTEGER; diff --git a/cloud/apps/billing/migrations/0004_seat_fairness.sql b/cloud/apps/billing/migrations/0004_seat_fairness.sql new file mode 100644 index 00000000000..332024b5df0 --- /dev/null +++ b/cloud/apps/billing/migrations/0004_seat_fairness.sql @@ -0,0 +1,5 @@ +-- Seat fairness: the latest org member count (seats may stay above it until renewal), and the +-- member who started each sandbox (usage attribution, per-member caps, abuse review). +ALTER TABLE subscriptions ADD COLUMN member_count INTEGER; +ALTER TABLE usage_events ADD COLUMN user_id TEXT; +CREATE INDEX usage_events_member ON usage_events(account_id, period_start, user_id); diff --git a/cloud/apps/billing/package.json b/cloud/apps/billing/package.json new file mode 100644 index 00000000000..453c1a82ecc --- /dev/null +++ b/cloud/apps/billing/package.json @@ -0,0 +1,23 @@ +{ + "name": "@orca-cloud/billing", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "wrangler dev --port 8790 --ip 127.0.0.1", + "db:migrate:local": "wrangler d1 migrations apply BILLING_DB --local", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "jose": "^6.2.13", + "zod": "^4.6.7" + }, + "devDependencies": { + "@cloudflare/workers-types": "^5.20261103.1", + "@types/node": "^24.19.2", + "typescript": "^5.9.3", + "vitest": "^5.0.5", + "wrangler": "^4.157.0" + } +} diff --git a/cloud/apps/billing/scripts/e2e-test-mode.ts b/cloud/apps/billing/scripts/e2e-test-mode.ts new file mode 100644 index 00000000000..19722f3c439 --- /dev/null +++ b/cloud/apps/billing/scripts/e2e-test-mode.ts @@ -0,0 +1,334 @@ +// End-to-end billing run against a running Worker (local `wrangler dev` or a dev deploy) and +// Stripe TEST MODE, with real webhooks (via `stripe listen` locally, or a test webhook endpoint). +// +// tsx scripts/e2e-test-mode.ts keys # local-only ES256 key + JWKS (stands in for auth) +// 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_D1_ARGS (wrangler d1 execute target, e.g. "BILLING_DB --local"). +// +// Flows: Checkout (2 seats, test card, headless browser) -> overage opt-in -> usage past the +// included hours -> meter events in Stripe -> spend cap blocks starts -> annual plan + overage -> +// test-clock renewal -> invoice.payment_failed -> cancel. 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 { encodeForm } from '../src/stripe' + +const HOUR = 3600 +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +if (process.argv[2] === 'keys') { + const dir = process.argv[3]! + const { privateKey, publicKey } = await generateKeyPair('ES256', { extractable: true }) + writeFileSync(`${dir}/jwk-private.json`, JSON.stringify({ ...(await exportJWK(privateKey)), kid: 'e2e', alg: 'ES256' }), { + mode: 0o600 + }) + writeFileSync(`${dir}/jwks.json`, JSON.stringify({ keys: [{ ...(await exportJWK(publicKey)), kid: 'e2e', alg: 'ES256', use: 'sig' }] })) + console.log(`wrote ${dir}/jwk-private.json and ${dir}/jwks.json`) + process.exit(0) +} + +const env = (name: string): string => { + const value = process.env[name] + if (!value) { + throw new Error(`missing ${name}`) + } + return value +} +const BILLING_URL = env('BILLING_URL') +const STRIPE_KEY = env('STRIPE_SECRET_KEY') +if (!STRIPE_KEY.startsWith('sk_test_') && !STRIPE_KEY.startsWith('rk_test_')) { + throw new Error('Refusing: test-mode Stripe key required') +} +const CONTROL = env('CONTROL_PLANE_TOKEN') +const catalog = JSON.parse(readFileSync(env('E2E_CATALOG'), 'utf8')) as Record & { meter: string } +const jwk = JSON.parse(readFileSync(env('E2E_JWK_PRIVATE'), 'utf8')) as { kid: string } +const signingKey = await importJWK(jwk, 'ES256') +const issuer = env('E2E_ISSUER') +const d1Args = env('E2E_D1_ARGS').split(' ') +const runId = Date.now().toString(36) + +const results: Array<{ ok: boolean; name: string; detail: string }> = [] +function check(name: string, ok: boolean, detail: unknown = ''): boolean { + results.push({ ok, name, detail: typeof detail === 'string' ? detail : JSON.stringify(detail) }) + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${ok ? '' : ` :: ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`}`) + return ok +} +const ids: Record = {} + +async function token(org: string): Promise { + const now = Math.floor(Date.now() / 1000) + return new SignJWT({ sub: `user_${org}`, org, orgRole: 'owner', email: `e2e+${org}@example.com`, purpose: 'billing' }) + .setProtectedHeader({ alg: 'ES256', kid: jwk.kid }) + .setIssuer(issuer) + .setAudience('orca-billing') + .setIssuedAt(now) + .setExpirationTime(now + 300) + .sign(signingKey) +} + +async function billing(method: string, path: string, org: string | null, body?: unknown) { + const headers: Record = { 'content-type': 'application/json' } + headers.authorization = `Bearer ${org ? await token(org) : CONTROL}` + const response = await fetch(`${BILLING_URL}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }) + return { status: response.status, body: (await response.json().catch(() => ({}))) as Record } +} + +async function stripe>(method: 'GET' | 'POST' | 'DELETE', path: string, params?: Record): Promise { + const url = new URL(`https://api.stripe.com${path}`) + if (method !== 'POST' && params) { + url.search = encodeForm(params as never) + } + const response = await fetch(url, { + method, + headers: { authorization: `Bearer ${STRIPE_KEY}`, 'content-type': 'application/x-www-form-urlencoded', 'stripe-version': '2025-09-30.clover' }, + body: method === 'POST' ? encodeForm((params ?? {}) as never) : undefined + }) + const json = (await response.json()) as T & { livemode?: boolean; error?: { message: string } } + if (!response.ok) { + throw new Error(`stripe ${method} ${path}: ${json.error?.message}`) + } + if (json.livemode) { + throw new Error('live-mode object returned; aborting') + } + return json +} + +async function until(what: string, read: () => Promise, done: (value: T) => boolean, timeoutMs = 90_000): Promise { + const deadline = Date.now() + timeoutMs + let last = await read() + while (!done(last) && Date.now() < deadline) { + await sleep(2000) + last = await read() + } + if (!done(last)) { + console.log(` (timed out waiting for ${what})`) + } + return last +} + +const entitlement = (org: string) => billing('GET', '/v1/billing/entitlements', org).then((r) => r.body) +const startCheck = (accountId: string) => + billing('POST', '/v1/internal/entitlements/check', null, { accountId, machineSize: 'standard', runningSandboxes: 0 }).then((r) => r.body) +let usageCounter = 0 +async function reportUsage(accountId: string, hours: number) { + const endedAt = Date.now() - 60_000 - usageCounter * 1000 + usageCounter++ + return billing('POST', '/v1/internal/usage', null, { + usageId: `e2e-${runId}-${usageCounter}`, + accountId, + sandboxId: `sbx-${runId}`, + machineSize: 'standard', + startedAt: endedAt - hours * HOUR * 1000, + endedAt + }).then((r) => r.body) +} + +function linkAccount(accountId: string, customer: string) { + if (!/^[A-Za-z0-9_:.-]+$/.test(accountId + customer)) { + throw new Error('unsafe id') + } + const now = Date.now() + execFileSync( + 'npx', + ['wrangler', 'd1', 'execute', ...d1Args, '--command', + `INSERT INTO billing_accounts (account_id, stripe_customer_id, created_at, updated_at) VALUES ('${accountId}', '${customer}', ${now}, ${now})`], + { stdio: 'ignore', env: { ...process.env, CI: '1' } } + ) +} + +async function completeCheckout(url: string) { + const { chromium } = await import('playwright-core') + const browser = await chromium.launch({ headless: true }) + try { + const page = await browser.newPage() + await page.goto(url, { waitUntil: 'domcontentloaded' }) + await page.locator('#cardNumber').waitFor({ timeout: 60_000 }) + await page.fill('#cardNumber', '4242424242424242') + await page.fill('#cardExpiry', '12/34') + await page.fill('#cardCvc', '123') + if (await page.locator('#billingName').count()) { + await page.fill('#billingName', 'Orca E2E') + } + if (await page.locator('#billingPostalCode').count()) { + await page.fill('#billingPostalCode', '94107') + } + // Opting out of "Save my information with Link" (it would require a phone number). + const link = page.locator('#enableStripePass') + if ((await link.count()) && (await link.isChecked())) { + await link.uncheck({ force: true }) + } + await page.locator('[data-testid="hosted-payment-submit-button"], .SubmitButton').first().click() + try { + await page.waitForURL((current) => current.searchParams.get('checkout') === 'success', { timeout: 90_000 }) + } catch (error) { + if (process.env.E2E_SCREENSHOT_DIR) { + await page.screenshot({ path: `${process.env.E2E_SCREENSHOT_DIR}/checkout-failure.png`, fullPage: true }) + } + throw error + } + } finally { + await browser.close() + } +} + +async function advanceClock(clock: string, to: number) { + await stripe('POST', `/v1/test_helpers/test_clocks/${clock}/advance`, { frozen_time: to }) + await until('test clock ready', () => stripe('GET', `/v1/test_helpers/test_clocks/${clock}`), (c) => c.status === 'ready', 180_000) +} + +// ---- 1. Checkout, 2 seats, test card ---------------------------------------------------------- +const orgA = `e2e-${runId}-a` +const accountA = `org:${orgA}` +const checkout = await billing('POST', '/v1/billing/checkout', orgA, { plan: 'team', seats: 2, interval: 'month' }) +check('checkout session created (hosted Stripe URL)', checkout.status === 200 && /^https:\/\/checkout\.stripe\.com\//.test(checkout.body.url), checkout) +await completeCheckout(checkout.body.url) +const afterCheckout = await until('active subscription', () => entitlement(orgA), (e) => e.status === 'active') +check('webhook -> 2-seat Team active, 120 h included, 6 concurrent', afterCheckout.status === 'active' && afterCheckout.seats === 2 && + afterCheckout.includedSeconds === 120 * HOUR && afterCheckout.limits?.maxConcurrentSandboxes === 6, afterCheckout) +// List-by-email is consistent (search is not); the email is unique to this run. +const customerA = (await stripe<{ data: Array<{ id: string; metadata: Record }> }>('GET', '/v1/customers', { + email: `e2e+${orgA}@example.com` +})).data.find((customer) => customer.metadata.account_id === accountA)?.id ?? '' +const subsA = await stripe<{ data: Array<{ id: string; items: { data: Array<{ price: { id: string } }> } }> }>('GET', '/v1/subscriptions', { customer: customerA }) +ids.checkoutCustomer = customerA +ids.checkoutSubscription = subsA.data[0]?.id ?? '' + +// ---- 2. Opt in to overage -------------------------------------------------------------------- +const optIn = await billing('POST', '/v1/billing/overage', orgA, { enabled: true }) +const subA = await stripe<{ items: { data: Array<{ price: { id: string } }> } }>('GET', `/v1/subscriptions/${ids.checkoutSubscription}`) +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 meterStart = Math.floor(Date.now() / 1000 / 60) * 60 - 2 * 24 * HOUR +for (let i = 0; i < 5; i++) { + await reportUsage(accountA, 24) +} +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) +const warned = await entitlement(orgA) +check('100% included-hours warning', warned.usageWarningPercent === 100, warned.usageWarningPercent) + +// ---- 4. Spend cap ---------------------------------------------------------------------------- +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) +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 +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), + 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 }) + +// ---- 5. Portal ------------------------------------------------------------------------------ +// ---- 5a. Seats follow membership (auth pushes member counts) ------------------------------------ +const membershipToken = process.env.E2E_MEMBERSHIP_TOKEN +if (membershipToken) { + const push = async (members: number) => { + const response = await fetch(`${BILLING_URL}/v1/internal/seats`, { + method: 'POST', + headers: { authorization: `Bearer ${membershipToken}`, 'content-type': 'application/json' }, + body: JSON.stringify({ accountId: accountA, members }) + }) + return (await response.json()) as Record + } + const seatItem = async () => + (await stripe<{ items: { data: Array<{ quantity: number; price: { id: string } }> } }>('GET', `/v1/subscriptions/${ids.checkoutSubscription}`)) + .items.data.find((item) => item.price.id === catalog.STRIPE_PRICE_TEAM_MONTHLY)?.quantity + const added = await push(3) + const invoices = await stripe<{ data: Array<{ billing_reason: string; status: string; amount_due: number }> }>('GET', '/v1/invoices', { + subscription: ids.checkoutSubscription, limit: 1 + }) + check('member added -> 3 seats, prorated invoice charged now', added.status === 'updated' && (await seatItem()) === 3 && + invoices.data[0]?.billing_reason === 'subscription_update' && invoices.data[0]?.status === 'paid', { added, invoice: invoices.data[0] }) + const removed = await push(2) + const afterRemove = await entitlement(orgA) + check('member removed -> 2 seats (credit on next invoice), 120 h included', removed.status === 'updated' && (await seatItem()) === 2 && + afterRemove.seats === 2 && afterRemove.includedSeconds === 120 * HOUR, { removed, seats: afterRemove.seats }) + check('pending invites do not change seats (same member count is a no-op)', (await push(2)).status === 'unchanged') +} + +const portal = await billing('POST', '/v1/billing/portal', orgA) +check('customer portal session (seats, monthly<->annual, cancel config)', /^https:\/\/billing\.stripe\.com\//.test(portal.body.url ?? ''), portal.body) + +// ---- 6. Annual plan + overage (mixed intervals) ----------------------------------------------- +const orgB = `e2e-${runId}-b` +const customerB = await stripe<{ id: string }>('POST', '/v1/customers', { email: `e2e+${orgB}@example.com`, metadata: { account_id: `org:${orgB}` } }) +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}` } +}) +ids.annualSubscription = annualSub.id +const annualEnt = await until('annual active', () => entitlement(orgB), (e) => e.status === 'active') +check('annual Team: 3 seats, 180 h monthly allowance window', annualEnt.interval === 'year' && annualEnt.includedSeconds === 180 * HOUR && + 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) + +// ---- 7. Test clock: renewal, then invoice.payment_failed -------------------------------------- +const orgC = `e2e-${runId}-c` +const start = Math.floor(Date.now() / 1000) +const clock = await stripe<{ id: string }>('POST', '/v1/test_helpers/test_clocks', { frozen_time: start, name: `orca-e2e-${runId}` }) +ids.testClock = clock.id +const customerC = await stripe<{ id: string }>('POST', '/v1/customers', { email: `e2e+${orgC}@example.com`, test_clock: clock.id, metadata: { account_id: `org:${orgC}` } }) +ids.clockCustomer = customerC.id +linkAccount(`org:${orgC}`, customerC.id) +const visa = await stripe<{ id: string }>('POST', '/v1/payment_methods/pm_card_visa/attach', { customer: customerC.id }) +await stripe('POST', `/v1/customers/${customerC.id}`, { invoice_settings: { default_payment_method: visa.id } }) +const clockSub = await stripe<{ id: string }>('POST', '/v1/subscriptions', { + customer: customerC.id, items: [{ price: catalog.STRIPE_PRICE_TEAM_MONTHLY, quantity: 2 }], metadata: { account_id: `org:${orgC}` } +}) +ids.clockSubscription = clockSub.id +const before = await until('clock sub active', () => entitlement(orgC), (e) => e.status === 'active') +await advanceClock(clock.id, before.period.end / 1000 + HOUR) +const renewed = await until('renewal synced', () => entitlement(orgC), (e) => e.period?.start === before.period.end, 120_000) +check('test-clock renewal rolls the period (invoice.paid -> new window)', renewed.period?.start === before.period.end && renewed.status === 'active', { + before: before.period, after: renewed.period +}) +const failing = await stripe<{ id: string }>('POST', '/v1/payment_methods/pm_card_chargeCustomerFail/attach', { customer: customerC.id }) +await stripe('POST', `/v1/customers/${customerC.id}`, { invoice_settings: { default_payment_method: failing.id } }) +await advanceClock(clock.id, renewed.period.end / 1000 + HOUR) +const pastDue = await until('past_due synced', () => entitlement(orgC), (e) => e.status === 'past_due', 120_000) +check('invoice.payment_failed -> past_due -> starts blocked', pastDue.status === 'past_due' && pastDue.reason === 'payment_past_due', pastDue.status) +const graceSeconds = Number(process.env.E2E_GRACE_SECONDS ?? 4 * 24 * HOUR) +check('grace window recorded (suspend running after the grace)', pastDue.payment?.suspendRunning === false && + pastDue.payment?.graceEndsAt - pastDue.payment?.pastDueSince === graceSeconds * 1000, pastDue.payment) +if (graceSeconds <= 120) { + // Short grace configured for this run (BILLING_PLANS team.paymentGraceHours): watch it expire. + const suspended = await until('grace expiry', () => startCheck(`org:${orgC}`), (c) => c.suspendRunning === true, (graceSeconds + 60) * 1000) + check('grace expired -> suspendRunning, reason payment_grace_expired', suspended.suspendRunning === true && suspended.reason === 'payment_grace_expired', suspended) + const listed = await billing('GET', '/v1/internal/suspensions', null).then((r) => r.body) + check('/v1/internal/suspensions lists the account', listed.accounts?.some((a: any) => a.accountId === `org:${orgC}`), listed) + // Pay the open invoice with a good card -> active -> resume allowed. + const goodCard = await stripe<{ id: string }>('POST', '/v1/payment_methods/pm_card_visa/attach', { customer: customerC.id }) + await stripe('POST', `/v1/customers/${customerC.id}`, { invoice_settings: { default_payment_method: goodCard.id } }) + const live = await stripe<{ latest_invoice: string }>('GET', `/v1/subscriptions/${clockSub.id}`) + await stripe('POST', `/v1/invoices/${live.latest_invoice}/pay`, { payment_method: goodCard.id }) + const resumed = await until('paid', () => startCheck(`org:${orgC}`), (c) => c.allow === true, 120_000) + check('paid -> resume allowed, suspendRunning cleared', resumed.allow === true && resumed.suspendRunning === false, resumed) +} + +// ---- 8. Cancel ------------------------------------------------------------------------------- +await stripe('DELETE', `/v1/subscriptions/${ids.checkoutSubscription}`) +const canceled = await until('cancel synced', () => entitlement(orgA), (e) => e.status === 'canceled') +check('cancel -> subscription_inactive, starts blocked', canceled.status === 'canceled' && canceled.canStartSandbox === false, canceled.status) + +console.log(JSON.stringify({ runId, ids }, null, 2)) +const failed = results.filter((r) => !r.ok) +console.log(`${results.length - failed.length}/${results.length} checks passed`) +process.exit(failed.length === 0 ? 0 : 1) diff --git a/cloud/apps/billing/scripts/stripe-test-setup.ts b/cloud/apps/billing/scripts/stripe-test-setup.ts new file mode 100644 index 00000000000..02df0148a71 --- /dev/null +++ b/cloud/apps/billing/scripts/stripe-test-setup.ts @@ -0,0 +1,136 @@ +// One-time TEST MODE catalog setup: the Team product with monthly + annual seat prices, the +// sandbox-seconds meter with its metered overage price, and a Customer Portal configuration +// (monthly <-> annual switch, cancel; seats follow membership). Refuses any key that is not +// sk_test_/rk_test_ and stops if Stripe ever reports a live-mode object. Prints the Worker vars. +// +// 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}' +// Idempotency keys include the prices, so a re-run within 24h returns the same objects. +import { encodeForm } from '../src/stripe' + +const key = process.env.STRIPE_SECRET_KEY ?? '' +if (!key.startsWith('sk_test_') && !key.startsWith('rk_test_')) { + console.error('Refusing: STRIPE_SECRET_KEY must be a test-mode key (sk_test_ or rk_test_).') + process.exit(1) +} +const returnUrl = process.env.BILLING_RETURN_URL ?? 'https://onorca.dev/billing/return' + +type StripeObject = { id: string; livemode?: boolean; error?: { message: string } } + +async function post(path: string, params: Record, idempotencyKey: string): Promise { + const response = await fetch(`https://api.stripe.com${path}`, { + method: 'POST', + headers: { + authorization: `Bearer ${key}`, + 'content-type': 'application/x-www-form-urlencoded', + 'stripe-version': '2025-09-30.clover', + 'idempotency-key': `orca-billing-setup:${idempotencyKey}` + }, + body: encodeForm(params as never) + }) + const json = (await response.json()) as T + if (!response.ok) { + throw new Error(`${path}: ${json.error?.message}`) + } + if (json.livemode) { + throw new Error('Stripe reported a live-mode object; stopping.') + } + return json +} + +const prices = { teamMonthly: 5000, teamAnnual: 48000, overagePerHour: 75, ...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') +const monthly = await post( + '/v1/prices', + { + product: team.id, + currency: 'usd', + unit_amount: prices.teamMonthly, + recurring: { interval: 'month' }, + lookup_key: 'orca_team_seat_monthly', + transfer_lookup_key: true + }, + `price:team:month:${suffix}` +) +const annual = await post( + '/v1/prices', + { + product: team.id, + currency: 'usd', + unit_amount: prices.teamAnnual, + recurring: { interval: 'year' }, + lookup_key: 'orca_team_seat_annual', + transfer_lookup_key: true + }, + `price:team:year:${suffix}` +) + +const meter = await post( + '/v1/billing/meters', + { + display_name: 'Orca sandbox overage (standard seconds)', + event_name: 'orca_sandbox_seconds', + default_aggregation: { formula: 'sum' }, + customer_mapping: { type: 'by_id', event_payload_key: 'stripe_customer_id' }, + value_settings: { event_payload_key: 'value' } + }, + 'meter' +) +const overageProduct = await post('/v1/products', { name: 'Orca sandbox overage', unit_label: 'std hour' }, 'product:overage') +// Per Standard hour (Large burns 2x through the meter weight); the meter counts seconds and the +// invoice divides by 3600, rounding up. +const overage = await post( + '/v1/prices', + { + product: overageProduct.id, + currency: 'usd', + unit_amount: 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 + }, + `price:overage:${suffix}` +) + +const portal = await post( + '/v1/billing_portal/configurations', + { + business_profile: { headline: 'Orca Team billing' }, + default_return_url: returnUrl, + features: { + customer_update: { enabled: true, allowed_updates: ['email', 'address', 'tax_id', 'name'] }, + invoice_history: { enabled: true }, + payment_method_update: { enabled: true }, + subscription_cancel: { enabled: true, mode: 'at_period_end', cancellation_reason: { enabled: true, options: ['too_expensive', 'missing_features', 'switched_service', 'unused', 'other'] } }, + subscription_update: { + enabled: true, + // Seats follow org membership (seats.ts); the portal only switches monthly <-> annual. + default_allowed_updates: ['price'], + proration_behavior: 'create_prorations', + products: [{ product: team.id, prices: [monthly.id, annual.id] }] + } + } + }, + `portal:${team.id}:${monthly.id}:${annual.id}` +) + +console.log( + JSON.stringify( + { + STRIPE_PRICE_TEAM_MONTHLY: monthly.id, + STRIPE_PRICE_TEAM_ANNUAL: annual.id, + STRIPE_PRICE_OVERAGE: overage.id, + STRIPE_METER_EVENT_NAME: meter.event_name, + STRIPE_PORTAL_CONFIGURATION: portal.id, + products: { team: team.id, overage: overageProduct.id }, + meter: meter.id + }, + null, + 2 + ) +) diff --git a/cloud/apps/billing/src/auth.ts b/cloud/apps/billing/src/auth.ts new file mode 100644 index 00000000000..1c80efbb520 --- /dev/null +++ b/cloud/apps/billing/src/auth.ts @@ -0,0 +1,146 @@ +// Caller authentication. +// - Desktop: a short-lived ES256 JWT minted by Orca Cloud auth for the signed-in user +// (aud = BILLING_JWT_AUDIENCE, purpose = 'billing'), verified against the auth JWKS — the same +// pattern the relay uses, so billing never sees the user's refresh or access token. +// - Control plane: a static bearer secret (rotation: comma-separated), compared in constant time. +import { createLocalJWKSet, createRemoteJWKSet, jwtVerify, type JSONWebKeySet, type JWTVerifyGetKey } from 'jose' +import { z } from 'zod' +import type { Env } from './env' +import { constantTimeEqual } from './webhook-signature' + +const ID = z.string().min(1).max(128).regex(/^[A-Za-z0-9_.:-]+$/) + +const ClaimsSchema = z.object({ + sub: ID, + org: ID.optional(), + // Role in `org`. Only owners and admins may buy or manage the org's plan. + orgRole: z.enum(['owner', 'admin', 'member']).optional(), + email: z.string().email().max(320).optional(), + purpose: z.literal('billing') +}) + +export type BillingCaller = { + userId: string + accountId: string + canManage: boolean + email?: string +} + +const keySets = new Map() + +function keysFor(env: Env): JWTVerifyGetKey { + const source = env.BILLING_JWKS_URL ?? `inline:${env.BILLING_JWKS ?? ''}` + let keys = keySets.get(source) + if (!keys) { + keys = env.BILLING_JWKS_URL + ? createRemoteJWKSet(new URL(env.BILLING_JWKS_URL)) + : createLocalJWKSet(JSON.parse(env.BILLING_JWKS ?? '{"keys":[]}') as JSONWebKeySet) + keySets.set(source, keys) + } + return keys +} + +export const DEV_ISSUER = 'orca-billing-dev-test' +let devKeys: { source: string; keys: JWTVerifyGetKey } | null = null + +/** Dev operator keys; never honored when BILLING_ENV is 'prod'. */ +function devKeysFor(env: Env): JWTVerifyGetKey | null { + if (env.BILLING_ENV === 'prod' || !env.BILLING_DEV_JWKS) { + return null + } + if (devKeys?.source !== env.BILLING_DEV_JWKS) { + devKeys = { + source: env.BILLING_DEV_JWKS, + keys: createLocalJWKSet(JSON.parse(env.BILLING_DEV_JWKS) as JSONWebKeySet) + } + } + return devKeys.keys +} + +export function readBearer(value: string | null): string | null { + return /^Bearer ([^\s]+)$/.exec(value ?? '')?.[1] ?? null +} + +/** An org pays for its members; a user without an org is their own account. */ +export function accountIdFor(claims: { sub: string; org?: string }): string { + return claims.org ? `org:${claims.org}` : `user:${claims.sub}` +} + +// Billing tokens re-resolve org and role from auth's org_memberships rows when minted and live 5 +// minutes. Actions that spend money or change billing take only tokens minted in the last few +// minutes, so a demotion or removal stops them within that window (auth backdates iat by 30 s). +export const ADMIN_ACTION_MAX_TOKEN_AGE = '6m' + +export async function authenticateDesktop( + env: Env, + request: Request, + options: { maxTokenAge?: string } = {} +): Promise { + const token = readBearer(request.headers.get('authorization')) + if (!token) { + return null + } + const verify = (keys: JWTVerifyGetKey, issuer: string | undefined) => + jwtVerify(token, keys, { + audience: env.BILLING_JWT_AUDIENCE, + algorithms: ['ES256'], + // Billing tokens are minted per call; refuse anything long-lived. + maxTokenAge: options.maxTokenAge ?? '15m', + requiredClaims: ['exp', 'iat'], + ...(issuer ? { issuer } : {}) + }) + try { + let verified + try { + verified = await verify(keysFor(env), env.BILLING_AUTH_ISSUER) + } catch (error) { + // Dev only: an operator key set (its own issuer) until auth mints billing tokens. + const dev = devKeysFor(env) + if (!dev) { + throw error + } + verified = await verify(dev, DEV_ISSUER) + } + const claims = ClaimsSchema.parse(verified.payload) + return { + userId: claims.sub, + accountId: accountIdFor(claims), + canManage: !claims.org || claims.orgRole === 'owner' || claims.orgRole === 'admin', + email: claims.email + } + } catch { + return null + } +} + +async function digest(value: string): Promise { + const bytes = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))) + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') +} + +export function authenticateControlPlane(env: Env, request: Request): Promise { + return authenticateSharedSecret(env.CONTROL_PLANE_TOKEN, request) +} + +/** Orca Cloud auth -> billing membership pushes; a separate secret from the control plane's. */ +export function authenticateMembershipSync(env: Env, request: Request): Promise { + return authenticateSharedSecret(env.MEMBERSHIP_SYNC_TOKEN, request) +} + +async function authenticateSharedSecret(secret: string | undefined, request: Request): Promise { + const token = readBearer(request.headers.get('authorization')) + const accepted = (secret ?? '') + .split(',') + .map((value) => value.trim()) + .filter((value) => value.length >= 32) + if (!token || accepted.length === 0) { + return false + } + // Hash both sides so the comparison is fixed-length and constant time. + const presented = await digest(token) + let matched = false + for (const candidate of accepted) { + matched = constantTimeEqual(presented, await digest(candidate)) || matched + } + return matched +} diff --git a/cloud/apps/billing/src/checkout.ts b/cloud/apps/billing/src/checkout.ts new file mode 100644 index 00000000000..ec5f766d321 --- /dev/null +++ b/cloud/apps/billing/src/checkout.ts @@ -0,0 +1,106 @@ +// Hosted Checkout and Customer Portal sessions. Card data only ever touches Stripe's pages. +import { z } from 'zod' +import type { BillingCaller } from './auth' +import type { Env } from './env' +import { BILLING_INTERVALS, PLAN_IDS, priceForPlan, type BillingInterval, type PlanId } from './plans' +import type { StripeClient } from './stripe' +import { currentSubscription, customerFor, ensureAccount, linkCustomer } from './store' +import { orgIdOf, type MembershipSource } from './membership' + +export const MAX_TEAM_SEATS = 500 + +export const CheckoutRequestSchema = z + .object({ + plan: z.enum(PLAN_IDS), + interval: z.enum(BILLING_INTERVALS).default('month'), + seats: z.number().int().min(1).max(MAX_TEAM_SEATS).default(1) + }) + .strict() + +export const OPEN_STATUSES = new Set(['active', 'trialing', 'past_due', 'incomplete', 'unpaid', 'paused']) + +export type CheckoutOutcome = { ok: true; url: string } | { ok: false; status: number; error: string } + +function returnUrl(env: Env, params: Record): string { + const url = new URL(env.BILLING_RETURN_URL) + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value) + } + // Stripe substitutes this literal placeholder; URLSearchParams would percent-encode the braces. + return url.toString().replace('CHECKOUT_SESSION_ID_PLACEHOLDER', '{CHECKOUT_SESSION_ID}') +} + +async function ensureCustomer(env: Env, stripe: StripeClient, caller: BillingCaller, now: number): Promise { + await ensureAccount(env.BILLING_DB, caller.accountId, now) + const existing = await customerFor(env.BILLING_DB, caller.accountId) + if (existing) { + return existing + } + const created = await stripe.createCustomer({ accountId: caller.accountId, email: caller.email }) + return linkCustomer(env.BILLING_DB, caller.accountId, created.id, now) +} + +export async function createCheckout( + env: Env, + stripe: StripeClient, + caller: BillingCaller, + request: { plan: PlanId; interval: BillingInterval; seats: number }, + now: number, + membership: MembershipSource +): Promise { + if (!caller.canManage) { + return { ok: false, status: 403, error: 'billing_admin_required' } + } + const existing = await currentSubscription(env.BILLING_DB, caller.accountId) + if (existing && OPEN_STATUSES.has(existing.status)) { + // Seat and interval changes go through the portal, which prorates; a second subscription + // would double-bill. + return { ok: false, status: 409, error: 'already_subscribed' } + } + // Seats = org members from auth when it is reachable; never fewer than 1. Throws (-> 503) if + // auth is configured but unavailable rather than guessing a seat count. + const orgId = orgIdOf(caller.accountId) + const live = orgId ? await membership(orgId) : null + const seats = Math.max(1, live ?? request.seats) + const customer = await ensureCustomer(env, stripe, caller, now) + const metadata = { account_id: caller.accountId, plan: request.plan, interval: request.interval } + const session = await stripe.createCheckoutSession( + { + mode: 'subscription', + customer, + client_reference_id: caller.accountId, + // Seats only. Overage is opt-in later; its metered item is added then (overage.ts). + line_items: [ + // Seats then follow org membership automatically (seats.ts); not editable on the page. + { price: priceForPlan(env, request.interval), quantity: seats } + ], + payment_method_collection: 'always', + allow_promotion_codes: true, + customer_update: { address: 'auto', name: 'auto' }, + success_url: returnUrl(env, { checkout: 'success', session_id: 'CHECKOUT_SESSION_ID_PLACEHOLDER' }), + cancel_url: returnUrl(env, { checkout: 'cancel' }), + metadata, + ...(env.STRIPE_AUTOMATIC_TAX === 'true' + ? { automatic_tax: { enabled: true }, tax_id_collection: { enabled: true } } + : {}), + subscription_data: { metadata } + }, + `checkout:${caller.accountId}:${crypto.randomUUID()}` + ) + return { ok: true, url: session.url } +} + +export async function createPortal(env: Env, stripe: StripeClient, caller: BillingCaller): Promise { + if (!caller.canManage) { + return { ok: false, status: 403, error: 'billing_admin_required' } + } + const customer = await customerFor(env.BILLING_DB, caller.accountId) + if (!customer) { + return { ok: false, status: 404, error: 'no_billing_account' } + } + const session = await stripe.createPortalSession( + { customer, returnUrl: returnUrl(env, { portal: 'return' }), configuration: env.STRIPE_PORTAL_CONFIGURATION }, + `portal:${caller.accountId}:${crypto.randomUUID()}` + ) + return { ok: true, url: session.url } +} diff --git a/cloud/apps/billing/src/entitlements.ts b/cloud/apps/billing/src/entitlements.ts new file mode 100644 index 00000000000..5b7663d3094 --- /dev/null +++ b/cloud/apps/billing/src/entitlements.ts @@ -0,0 +1,209 @@ +// "Can this account start a sandbox, and how many hours are left?" One computation shared by the +// desktop endpoint and the control plane's start check, so the two can never disagree. +import type { Env } from './env' +import { + MACHINE_SIZES, + allowanceWindow, + capSeconds, + includedSeconds, + planCatalog, + type BillingInterval, + type MachineSize, + type PlanId +} from './plans' +import { accountSettings, currentSubscription, meteredSeconds, periodUsage, type SubscriptionRow } from './store' +import { PAYMENT_FAILED_STATUSES } from './webhook' + +export type DenyReason = + | 'no_subscription' + | 'subscription_inactive' + | 'payment_past_due' + | 'payment_grace_expired' + | 'included_hours_exhausted' + | 'overage_cap_reached' + | 'machine_size_not_in_plan' + | 'concurrency_limit' + | 'member_concurrency_limit' + +export type Entitlement = { + accountId: string + plan: PlanId | null + planLabel: string | null + status: string + interval: BillingInterval | null + seats: number + /** Latest org member count from auth; seats stay at the period's peak until renewal. */ + members: number | null + /** When seats will come down to the member count at renewal. */ + seatChange: { seatsAtRenewal: number; effectiveAt: number } | null + /** Billing period (a year for annual plans). */ + period: { start: number; end: number } | null + /** Monthly allowance window the hours below count against. */ + usageWindow: { start: number; end: number } | null + cancelAtPeriodEnd: boolean + includedSeconds: number + usedSeconds: number + remainingIncludedSeconds: number + /** Highest configured warning level reached (e.g. 80, 100), or null. */ + usageWarningPercent: number | null + overageSeconds: number + overage: { + optedIn: boolean + /** Opted in and the metered item is on the subscription: overage is being billed. */ + active: boolean + spendCapCents: number + spentCents: number + centsPerHour: number + } + limits: { + maxConcurrentSandboxes: number + /** Per member, so one seat cannot run the whole team's sandboxes. */ + maxConcurrentSandboxesPerMember: number + machineSizes: readonly MachineSize[] + idleSleepMinutes: number + } + /** Failed payment: new starts stop at once; running sandboxes must be suspended after the grace. */ + payment: { pastDueSince: number | null; graceEndsAt: number | null; suspendRunning: boolean } + canStartSandbox: boolean + reason: DenyReason | null +} + +const LIVE_STATUSES = new Set(['active', 'trialing']) + +export function subscriptionWindow(subscription: SubscriptionRow, at: number): { start: number; end: number } { + return allowanceWindow({ start: subscription.current_period_start, end: subscription.current_period_end }, at) +} + +export async function computeEntitlement( + env: Pick, + accountId: string, + now: number = Date.now() +): Promise { + const db = env.BILLING_DB + const team = planCatalog(env).team + const [subscription, settings] = await Promise.all([currentSubscription(db, accountId), accountSettings(db, accountId)]) + const spendCapCents = settings.spend_cap_cents ?? team.defaultSpendCapCents + const overageBase = { optedIn: settings.overage_opt_in === 1, spendCapCents, centsPerHour: team.overageCentsPerHour } + if (!subscription) { + return { + accountId, + plan: null, + planLabel: null, + status: 'none', + interval: null, + seats: 0, + members: null, + seatChange: null, + period: null, + usageWindow: null, + cancelAtPeriodEnd: false, + includedSeconds: 0, + usedSeconds: 0, + remainingIncludedSeconds: 0, + usageWarningPercent: null, + overageSeconds: 0, + overage: { ...overageBase, active: false, spentCents: 0 }, + limits: { + maxConcurrentSandboxes: 0, + maxConcurrentSandboxesPerMember: 0, + machineSizes: [], + idleSleepMinutes: team.idleSleepMinutes + }, + payment: { pastDueSince: null, graceEndsAt: null, suspendRunning: false }, + canStartSandbox: false, + reason: 'no_subscription' + } + } + const window = subscriptionWindow(subscription, now) + const included = includedSeconds(team, subscription.seats) + const [usage, metered] = await Promise.all([ + periodUsage(db, accountId, window.start), + meteredSeconds(db, accountId, window.start) + ]) + const remaining = Math.max(0, included - usage.usedSeconds) + const overageActive = overageBase.optedIn && subscription.overage_item_id !== null + const usedPercent = included > 0 ? (usage.usedSeconds / included) * 100 : 0 + const warnings = team.warnAtPercent.filter((level) => usedPercent >= level) + + const pastDueSince = PAYMENT_FAILED_STATUSES.has(subscription.status) ? (subscription.past_due_since ?? now) : null + const graceEndsAt = pastDueSince === null ? null : pastDueSince + team.paymentGraceHours * 3600_000 + const graceExpired = graceEndsAt !== null && now >= graceEndsAt + + let reason: DenyReason | null = null + if (graceExpired) { + reason = 'payment_grace_expired' + } else if (pastDueSince !== null) { + // Stripe is retrying the card (Smart Retries + dunning emails): no new starts, running ones keep going. + reason = 'payment_past_due' + } else if (!LIVE_STATUSES.has(subscription.status)) { + reason = 'subscription_inactive' + } else if (remaining === 0 && !overageActive) { + reason = 'included_hours_exhausted' + } else if (remaining === 0 && metered >= capSeconds(team, spendCapCents)) { + reason = 'overage_cap_reached' + } + + return { + accountId, + plan: subscription.plan, + planLabel: team.label, + status: subscription.status, + interval: subscription.billing_interval, + seats: subscription.seats, + members: subscription.member_count, + seatChange: + subscription.member_count !== null && Math.max(1, subscription.member_count) < subscription.seats + ? { seatsAtRenewal: Math.max(1, subscription.member_count), effectiveAt: subscription.current_period_end } + : null, + period: { start: subscription.current_period_start, end: subscription.current_period_end }, + usageWindow: window, + cancelAtPeriodEnd: subscription.cancel_at_period_end === 1, + includedSeconds: included, + usedSeconds: usage.usedSeconds, + remainingIncludedSeconds: remaining, + usageWarningPercent: warnings.length > 0 ? Math.max(...warnings) : null, + overageSeconds: usage.overageSeconds, + overage: { + ...overageBase, + active: overageActive, + spentCents: Math.round((metered / 3600) * team.overageCentsPerHour) + }, + limits: { + maxConcurrentSandboxes: team.concurrentSandboxesPerSeat * Math.max(1, subscription.seats), + maxConcurrentSandboxesPerMember: team.concurrentSandboxesPerSeat, + machineSizes: team.machineSizes, + idleSleepMinutes: team.idleSleepMinutes + }, + payment: { pastDueSince, graceEndsAt, suspendRunning: graceExpired }, + canStartSandbox: reason === null, + reason + } +} + +export function isMachineSize(value: unknown): value is MachineSize { + return typeof value === 'string' && (MACHINE_SIZES as readonly string[]).includes(value) +} + +/** The control plane's start gate: the account-level answer plus size and concurrency. */ +export function checkStart( + entitlement: Entitlement, + request: { machineSize: MachineSize; runningSandboxes: number; runningSandboxesForUser?: number } +): { allow: boolean; reason: DenyReason | null; suspendRunning: boolean } { + const suspendRunning = entitlement.payment.suspendRunning + if (!entitlement.canStartSandbox) { + return { allow: false, reason: entitlement.reason, suspendRunning } + } + if (!entitlement.limits.machineSizes.includes(request.machineSize)) { + return { allow: false, reason: 'machine_size_not_in_plan', suspendRunning } + } + if (request.runningSandboxes >= entitlement.limits.maxConcurrentSandboxes) { + return { allow: false, reason: 'concurrency_limit', suspendRunning } + } + if ( + request.runningSandboxesForUser !== undefined && + request.runningSandboxesForUser >= entitlement.limits.maxConcurrentSandboxesPerMember + ) { + return { allow: false, reason: 'member_concurrency_limit', suspendRunning } + } + return { allow: true, reason: null, suspendRunning } +} diff --git a/cloud/apps/billing/src/env.ts b/cloud/apps/billing/src/env.ts new file mode 100644 index 00000000000..59bd0f02764 --- /dev/null +++ b/cloud/apps/billing/src/env.ts @@ -0,0 +1,60 @@ +// Billing Worker bindings. Secrets are set with `wrangler secret put` (stdin), never in +// wrangler.jsonc or git. Deployed environments live in the private deploy config. +export type Env = { + BILLING_DB: D1Database + + // Stripe. Use a RESTRICTED key (rk_test_...) scoped to: Customers write, Checkout Sessions + // write, Customer portal write, Subscriptions read, Billing meter events write. Nothing else. + STRIPE_SECRET_KEY: string + // Webhook endpoint signing secret(s), comma-separated during rotation (whsec_...). + STRIPE_WEBHOOK_SECRET: string + // Pinned so a dashboard default-version bump cannot change payload shapes under us. + STRIPE_API_VERSION?: string + // Must be exactly 'true' to accept live-mode keys or events. Unset everywhere until launch. + STRIPE_ALLOW_LIVEMODE?: string + // Overrides the Stripe API origin (tests and stripe-mock only). + STRIPE_API_BASE?: string + + // Team seat prices (price_...), quantity = seats. + STRIPE_PRICE_TEAM_MONTHLY: string + STRIPE_PRICE_TEAM_ANNUAL: string + // Monthly metered price on the sandbox-seconds meter, added to a subscription when an admin + // opts in to overage (see overage.ts, usage.ts). + STRIPE_PRICE_OVERAGE: string + // Billing meter event_name the overage price aggregates. + STRIPE_METER_EVENT_NAME: string + // Customer portal configuration (bpc_...) allowing seat changes, monthly<->annual and cancel. + STRIPE_PORTAL_CONFIGURATION?: string + // 'true' turns on Stripe Tax in Checkout (automatic_tax + tax id collection). Requires Stripe Tax + // to be activated with an origin address and registrations in the dashboard. Default off. + STRIPE_AUTOMATIC_TAX?: string + + // Optional JSON overriding any field of the default plan catalog (src/plans.ts), e.g. + // {"pro":{"priceLabel":"$20/mo","includedHoursPerSeat":40}}. Malformed JSON fails closed (503). + BILLING_PLANS?: string + + // Where Checkout and the portal send the browser back (a page that deep-links into Orca). + BILLING_RETURN_URL: string + + // Desktop tokens: short-lived ES256 JWTs minted by Orca Cloud auth with aud=BILLING_JWT_AUDIENCE. + // BILLING_JWKS (inline set) is for local dev and tests only. + BILLING_JWKS_URL?: string + BILLING_JWKS?: string + BILLING_AUTH_ISSUER?: string + BILLING_JWT_AUDIENCE: string + // 'dev' | 'prod'. Dev may also accept BILLING_DEV_JWKS (issuer 'orca-billing-dev-test', a secret + // operator key set for e2e runs before auth mints billing tokens); prod never does. + BILLING_ENV?: string + BILLING_DEV_JWKS?: string + + // Sandbox control plane -> billing (usage reports, start checks). Secret; >= 32 chars. + // Comma-separated during rotation. Replace with a service binding once the control plane is a Worker. + CONTROL_PLANE_TOKEN: string + // Shared secret between Orca Cloud auth and billing for membership: auth pushes member counts to + // POST /v1/internal/seats, billing pulls them from AUTH_INTERNAL_URL. >= 32 chars; comma-separated + // during rotation (billing sends the first). + MEMBERSHIP_SYNC_TOKEN?: string + // Auth origin serving POST /v1/internal/billing/org-members. Unset: seats come from pushes and the + // Checkout request only (local dev). + AUTH_INTERNAL_URL?: string +} diff --git a/cloud/apps/billing/src/index.ts b/cloud/apps/billing/src/index.ts new file mode 100644 index 00000000000..c2fe3ab9d34 --- /dev/null +++ b/cloud/apps/billing/src/index.ts @@ -0,0 +1,238 @@ +// Orca billing Worker: Stripe Checkout/Portal, Stripe webhooks, entitlements and usage. +// +// Desktop (Orca Cloud billing JWT): +// GET /v1/billing/entitlements current plan, hours used/remaining, can-start +// POST /v1/billing/checkout {plan,interval,seats} -> { url } hosted Stripe Checkout +// POST /v1/billing/overage {enabled, spendCapCents?} opt in/out of overage, set the cap +// POST /v1/billing/portal -> { url } Stripe Customer Portal +// Stripe: +// POST /v1/stripe/webhook signed events +// Sandbox control plane (CONTROL_PLANE_TOKEN): +// POST /v1/internal/entitlements/check {accountId, machineSize, runningSandboxes} +// POST /v1/internal/usage {usageId, accountId, sandboxId, machineSize, startedAt, endedAt} +// GET /v1/internal/suspensions accounts whose payment grace expired (suspend running) +// Orca Cloud auth (MEMBERSHIP_SYNC_TOKEN): +// POST /v1/internal/seats {accountId, members} +import { z } from 'zod' +import { + ADMIN_ACTION_MAX_TOKEN_AGE, + authenticateControlPlane, + authenticateDesktop, + authenticateMembershipSync +} from './auth' +import { SeatSyncSchema, reconcileSeats, syncSeats } from './seats' +import { MembershipUnavailableError, authMembershipSource, type MembershipSource } from './membership' +import { graceExpiredAccounts } from './store' +import { CheckoutRequestSchema, createCheckout, createPortal } from './checkout' +import { checkStart, computeEntitlement } from './entitlements' +import type { Env } from './env' +import { MACHINE_SIZES, PlanConfigError, planCatalog, publicOffers } from './plans' +import { OverageRequestSchema, updateOverage } from './overage' +import { LiveModeRefusedError, StripeError, createStripeClient, type StripeClient } from './stripe' +import { UsageReportSchema, flushMeterReports, recordUsage, validateSegment } from './usage' +import { handleStripeWebhook } from './webhook' + +const MAX_JSON_BYTES = 16 * 1024 + +const StartCheckSchema = z + .object({ + accountId: z.string().min(1).max(128), + machineSize: z.enum(MACHINE_SIZES), + runningSandboxes: z.number().int().min(0).max(10_000), + // The member starting it and how many they already run (per-member cap; attribution). + userId: z.string().min(1).max(128).regex(/^[A-Za-z0-9_.:-]+$/).optional(), + runningSandboxesForUser: z.number().int().min(0).max(10_000).optional() + }) + .strict() + +function json(body: unknown, status = 200): Response { + return Response.json(body, { status, headers: { 'cache-control': 'no-store' } }) +} + +async function readJson(request: Request): Promise { + const text = await request.text() + if (text.length > MAX_JSON_BYTES) { + throw new SyntaxError('too_large') + } + return text ? JSON.parse(text) : {} +} + +export type Deps = { stripe?: StripeClient; now?: () => number; membership?: MembershipSource } + +export async function handleRequest(request: Request, env: Env, ctx: ExecutionContext, deps: Deps = {}): Promise { + const url = new URL(request.url) + const now = deps.now?.() ?? Date.now() + const stripe = deps.stripe ?? createStripeClient(env) + const membership = deps.membership ?? authMembershipSource(env) + const route = `${request.method} ${url.pathname}` + try { + switch (route) { + case 'GET /return': + // Where Checkout and the Portal send the browser back. Static; reveals nothing. + return new Response( + 'Orca' + + '' + + '

You can close this tab and return to Orca.

', + { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } } + ) + + case 'POST /v1/stripe/webhook': + return await handleStripeWebhook(env, stripe, request, now) + + case 'GET /v1/billing/entitlements': { + const caller = await authenticateDesktop(env, request) + if (!caller) { + return json({ error: 'unauthorized' }, 401) + } + const entitlement = await computeEntitlement(env, caller.accountId, now) + return json({ ...entitlement, canManageBilling: caller.canManage, offers: publicOffers(planCatalog(env)) }) + } + + case 'POST /v1/billing/checkout': { + const caller = await authenticateDesktop(env, request, { maxTokenAge: ADMIN_ACTION_MAX_TOKEN_AGE }) + if (!caller) { + return json({ error: 'unauthorized' }, 401) + } + const body = CheckoutRequestSchema.safeParse(await readJson(request)) + if (!body.success) { + return json({ error: 'invalid_request' }, 400) + } + const outcome = await createCheckout(env, stripe, caller, body.data, now, membership) + return outcome.ok ? json({ url: outcome.url }) : json({ error: outcome.error }, outcome.status) + } + + case 'POST /v1/billing/overage': { + const caller = await authenticateDesktop(env, request, { maxTokenAge: ADMIN_ACTION_MAX_TOKEN_AGE }) + if (!caller) { + return json({ error: 'unauthorized' }, 401) + } + const body = OverageRequestSchema.safeParse(await readJson(request)) + if (!body.success) { + return json({ error: 'invalid_request' }, 400) + } + const outcome = await updateOverage(env, stripe, caller, body.data, now) + if (!outcome.ok) { + return json({ error: outcome.error }, outcome.status) + } + const entitlement = await computeEntitlement(env, caller.accountId, now) + return json({ ...entitlement, canManageBilling: caller.canManage, offers: publicOffers(planCatalog(env)) }) + } + + case 'POST /v1/internal/seats': { + if (!(await authenticateMembershipSync(env, request))) { + return json({ error: 'unauthorized' }, 401) + } + const body = SeatSyncSchema.safeParse(await readJson(request)) + if (!body.success) { + return json({ error: 'invalid_request' }, 400) + } + return json(await syncSeats(env, stripe, body.data.accountId, body.data.members, now)) + } + + case 'GET /v1/internal/suspensions': { + // Accounts whose payment grace ran out: suspend their running sandboxes (poll from a cron). + if (!(await authenticateControlPlane(env, request))) { + return json({ error: 'unauthorized' }, 401) + } + const graceMs = planCatalog(env).team.paymentGraceHours * 3600_000 + const accounts = await graceExpiredAccounts(env.BILLING_DB, now - graceMs) + return json({ + accounts: accounts.map((row) => ({ + accountId: row.account_id, + pastDueSince: row.past_due_since, + graceEndsAt: row.past_due_since + graceMs + })) + }) + } + + case 'POST /v1/billing/portal': { + const caller = await authenticateDesktop(env, request, { maxTokenAge: ADMIN_ACTION_MAX_TOKEN_AGE }) + if (!caller) { + return json({ error: 'unauthorized' }, 401) + } + const outcome = await createPortal(env, stripe, caller) + return outcome.ok ? json({ url: outcome.url }) : json({ error: outcome.error }, outcome.status) + } + + case 'POST /v1/internal/entitlements/check': { + if (!(await authenticateControlPlane(env, request))) { + return json({ error: 'unauthorized' }, 401) + } + const body = StartCheckSchema.safeParse(await readJson(request)) + if (!body.success) { + return json({ error: 'invalid_request' }, 400) + } + const entitlement = await computeEntitlement(env, body.data.accountId, now) + return json({ + ...checkStart(entitlement, body.data), + // Billed seats: the period's peak (never raw membership), so caps can't be inflated by + // adding members the org hasn't paid for, nor shrunk by mid-period removals. + seats: entitlement.seats, + maxConcurrentSandboxes: entitlement.limits.maxConcurrentSandboxes, + maxConcurrentSandboxesPerMember: entitlement.limits.maxConcurrentSandboxesPerMember, + entitlement + }) + } + + case 'POST /v1/internal/usage': { + if (!(await authenticateControlPlane(env, request))) { + return json({ error: 'unauthorized' }, 401) + } + const body = UsageReportSchema.safeParse(await readJson(request)) + if (!body.success) { + return json({ error: 'invalid_request' }, 400) + } + const invalid = validateSegment(body.data, now) + if (invalid) { + return json({ error: invalid }, 400) + } + const result = await recordUsage(env, body.data, now) + if (result.overageSeconds > 0) { + // Best effort inline; the cron trigger retries whatever is still pending. + ctx.waitUntil(flushMeterReports(env, stripe, now, { usageId: body.data.usageId }).catch(() => undefined)) + } + const entitlement = await computeEntitlement(env, body.data.accountId, now) + return json({ + ...result, + canStartSandbox: entitlement.canStartSandbox, + reason: entitlement.reason, + suspendRunning: entitlement.payment.suspendRunning + }) + } + + default: + return json({ error: 'not_found' }, 404) + } + } catch (error) { + if (error instanceof SyntaxError) { + return json({ error: 'invalid_json' }, 400) + } + if (error instanceof LiveModeRefusedError || error instanceof PlanConfigError) { + return json({ error: 'billing_misconfigured' }, 503) + } + if (error instanceof MembershipUnavailableError) { + // Never guess a seat count: fail the action until auth answers. + console.error(JSON.stringify({ route, error: 'membership', message: error.message })) + return json({ error: 'membership_unavailable' }, 503) + } + if (error instanceof StripeError) { + // Stripe's message never contains the key or card data. + console.error(JSON.stringify({ route, error: 'stripe', status: error.status, code: error.code, message: error.message })) + return json({ error: 'stripe_unavailable' }, 502) + } + console.error(JSON.stringify({ route, error: error instanceof Error ? error.name : 'unknown', message: error instanceof Error ? error.message.slice(0, 200) : '' })) + return json({ error: 'internal_error' }, 500) + } +} + +export default { + fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + return handleRequest(request, env, ctx) + }, + async scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise { + const stripe = createStripeClient(env) + ctx.waitUntil(flushMeterReports(env, stripe, Date.now()).then(() => undefined)) + // Seat drift from a missed membership push heals here (needs AUTH_INTERNAL_URL). + ctx.waitUntil(reconcileSeats(env, stripe, authMembershipSource(env), Date.now()).then(() => undefined)) + } +} satisfies ExportedHandler diff --git a/cloud/apps/billing/src/membership.ts b/cloud/apps/billing/src/membership.ts new file mode 100644 index 00000000000..d83d1066fe1 --- /dev/null +++ b/cloud/apps/billing/src/membership.ts @@ -0,0 +1,52 @@ +// Seat count source of truth: Orca Cloud auth's org membership (org_memberships rows; pending +// invites excluded). Auth pushes changes to POST /v1/internal/seats; billing also pulls the live +// count when it needs one (Checkout, the reconcile cron) so a missed push self-heals. +import { z } from 'zod' +import type { Env } from './env' + +const MemberCountResponse = z.object({ orgId: z.string(), members: z.number().int().min(0) }) + +export class MembershipUnavailableError extends Error { + constructor(message: string) { + super(message) + this.name = 'MembershipUnavailableError' + } +} + +/** Live member count from auth, or null when no auth URL is configured (local dev and tests). */ +export type MembershipSource = (orgId: string) => Promise + +export function authMembershipSource(env: Env, fetchImpl: typeof fetch = fetch): MembershipSource { + return async (orgId) => { + if (!env.AUTH_INTERNAL_URL) { + return null + } + if (!env.MEMBERSHIP_SYNC_TOKEN) { + throw new MembershipUnavailableError('membership token not configured') + } + let response: Response + try { + response = await fetchImpl(new URL('/v1/internal/billing/org-members', env.AUTH_INTERNAL_URL), { + method: 'POST', + headers: { authorization: `Bearer ${env.MEMBERSHIP_SYNC_TOKEN.split(',')[0]!.trim()}`, 'content-type': 'application/json' }, + body: JSON.stringify({ orgId }), + redirect: 'manual', + signal: AbortSignal.timeout(10_000) + }) + } catch { + throw new MembershipUnavailableError('auth unreachable') + } + if (!response.ok) { + throw new MembershipUnavailableError(`auth answered ${response.status}`) + } + const parsed = MemberCountResponse.safeParse(await response.json().catch(() => null)) + if (!parsed.success || parsed.data.orgId !== orgId) { + throw new MembershipUnavailableError('auth answered an unexpected body') + } + return parsed.data.members + } +} + +export function orgIdOf(accountId: string): string | null { + return accountId.startsWith('org:') ? accountId.slice(4) : null +} diff --git a/cloud/apps/billing/src/overage.ts b/cloud/apps/billing/src/overage.ts new file mode 100644 index 00000000000..69c03b17de5 --- /dev/null +++ b/cloud/apps/billing/src/overage.ts @@ -0,0 +1,52 @@ +// 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 +// 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' + +export const OverageRequestSchema = z + .object({ + enabled: z.boolean(), + // Whole cents; null restores the plan default. + spendCapCents: z.number().int().min(0).nullable().optional() + }) + .strict() + +export type OverageOutcome = { ok: true } | { ok: false; status: number; error: string } + +export async function updateOverage( + env: Env, + stripe: StripeClient, + caller: BillingCaller, + request: z.infer, + now: number +): Promise { + if (!caller.canManage) { + return { ok: false, status: 403, error: 'billing_admin_required' } + } + const team = planCatalog(env).team + if (request.spendCapCents != null && request.spendCapCents > team.maxSpendCapCents) { + return { ok: false, status: 400, error: 'spend_cap_too_high' } + } + const subscription = await currentSubscription(env.BILLING_DB, caller.accountId) + 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 }) + } + // An omitted cap keeps the current one; null restores the plan default. + const spendCapCents = + request.spendCapCents === undefined + ? (await accountSettings(env.BILLING_DB, caller.accountId)).spend_cap_cents + : request.spendCapCents + await setOverageSettings(env.BILLING_DB, caller.accountId, { optIn: request.enabled, spendCapCents }, now) + return { ok: true } +} diff --git a/cloud/apps/billing/src/plans.ts b/cloud/apps/billing/src/plans.ts new file mode 100644 index 00000000000..854b1d5f75a --- /dev/null +++ b/cloud/apps/billing/src/plans.ts @@ -0,0 +1,187 @@ +// Plan catalog. One self-serve plan (Team, per seat, monthly or annual) plus Enterprise (contact +// sales, no Stripe price). Stripe price ids are Worker vars; every number and label below is a +// default that BILLING_PLANS (JSON) can override, so pricing changes need no code change. +// +// Usage is metered in "standard seconds": wall-clock seconds x the machine size weight, so one +// included hour is one hour on the Standard 4 vCPU / 8 GB box and a Large box burns 2x. +// Included hours are a MONTHLY per-seat allowance pooled across the org, for annual plans too. +import { z } from 'zod' +import type { Env } from './env' + +export const PLAN_IDS = ['team'] as const +export type PlanId = (typeof PLAN_IDS)[number] +export const BILLING_INTERVALS = ['month', 'year'] as const +export type BillingInterval = (typeof BILLING_INTERVALS)[number] + +export const MACHINE_SIZES = ['standard', 'large'] as const +export type MachineSize = (typeof MACHINE_SIZES)[number] +export const MACHINE_SIZE_WEIGHT: Record = { standard: 1, large: 2 } + +const TeamSchema = z + .object({ + label: z.string().min(1).max(40), + // Display only; Stripe's prices are what is charged. + monthlyPriceLabel: z.string().max(40), + annualPriceLabel: z.string().max(40), + // Display only, for totals and the annual saving on the plans page; must match Stripe's prices. + monthlyPriceCentsPerSeat: z.number().int().nonnegative(), + annualPriceCentsPerSeat: z.number().int().nonnegative(), + includedHoursPerSeat: z.number().nonnegative(), + concurrentSandboxesPerSeat: z.number().int().nonnegative(), + machineSizes: z.array(z.enum(MACHINE_SIZES)).min(1), + idleSleepMinutes: z.number().int().positive(), + // Overage: opt-in per account. The Stripe overage price must match overageCentsPerHour. + overageCentsPerHour: z.number().int().positive(), + defaultSpendCapCents: z.number().int().nonnegative(), + maxSpendCapCents: z.number().int().positive(), + // Included-hours usage levels (percent) the desktop warns at. + warnAtPercent: z.array(z.number().int().min(1).max(100)).max(4), + // After a failed payment: new starts stop at once; running sandboxes are suspended after this. + paymentGraceHours: z.number().nonnegative(), + // Seats only come down to the member count this close to renewal (never mid-period). + seatTrueDownHours: z.number().positive() + }) + .strict() + +const EnterpriseSchema = z + .object({ + label: z.string().min(1).max(40), + priceLabel: z.string().max(40), + contactUrl: z.string().url().startsWith('https://') + }) + .strict() + +export type TeamPlan = z.infer +export type EnterpriseOffer = z.infer +export type PlanCatalog = { team: TeamPlan; enterprise: EnterpriseOffer } + +export const DEFAULT_PLANS: PlanCatalog = { + team: { + label: 'Team', + monthlyPriceLabel: '$50/seat/mo', + annualPriceLabel: '$480/seat/yr', + monthlyPriceCentsPerSeat: 5000, + annualPriceCentsPerSeat: 48000, + includedHoursPerSeat: 60, + concurrentSandboxesPerSeat: 3, + machineSizes: ['standard', 'large'], + idleSleepMinutes: 15, + overageCentsPerHour: 75, + defaultSpendCapCents: 2500, + maxSpendCapCents: 500_000, + warnAtPercent: [80, 100], + paymentGraceHours: 96, + seatTrueDownHours: 6 + }, + enterprise: { + label: 'Enterprise', + priceLabel: 'Custom', + contactUrl: 'https://onorca.dev/contact-sales' + } +} + +const OverridesSchema = z + .object({ team: TeamSchema.partial().optional(), enterprise: EnterpriseSchema.partial().optional() }) + .strict() + +export class PlanConfigError extends Error { + constructor() { + super('billing_plans_invalid') + this.name = 'PlanConfigError' + } +} + +const catalogs = new Map() + +/** The effective catalog. Throws PlanConfigError (-> 503) on malformed BILLING_PLANS. */ +export function planCatalog(env: Pick): PlanCatalog { + const raw = env.BILLING_PLANS?.trim() ?? '' + const cached = catalogs.get(raw) + if (cached) { + return cached + } + let catalog: PlanCatalog + try { + const overrides = raw ? OverridesSchema.parse(JSON.parse(raw)) : {} + catalog = { + team: TeamSchema.parse({ ...DEFAULT_PLANS.team, ...overrides.team }), + enterprise: EnterpriseSchema.parse({ ...DEFAULT_PLANS.enterprise, ...overrides.enterprise }) + } + } catch { + throw new PlanConfigError() + } + catalogs.set(raw, catalog) + return catalog +} + +/** What the desktop shows before Checkout. */ +export function publicOffers(catalog: PlanCatalog) { + const team = catalog.team + return { + team: { + label: team.label, + monthlyPriceLabel: team.monthlyPriceLabel, + annualPriceLabel: team.annualPriceLabel, + monthlyPriceCentsPerSeat: team.monthlyPriceCentsPerSeat, + annualPriceCentsPerSeat: team.annualPriceCentsPerSeat, + largeMachineWeight: MACHINE_SIZE_WEIGHT.large, + includedHoursPerSeat: team.includedHoursPerSeat, + concurrentSandboxesPerSeat: team.concurrentSandboxesPerSeat, + idleSleepMinutes: team.idleSleepMinutes, + overageCentsPerHour: team.overageCentsPerHour, + defaultSpendCapCents: team.defaultSpendCapCents + }, + enterprise: catalog.enterprise + } +} + +export function priceForPlan(env: Env, interval: BillingInterval): string { + return interval === 'year' ? env.STRIPE_PRICE_TEAM_ANNUAL : env.STRIPE_PRICE_TEAM_MONTHLY +} + +/** The plan and interval a subscription's seat price maps to, or null for a price we did not configure. */ +export function planForPrice(env: Env, priceId: string): { plan: PlanId; interval: BillingInterval } | null { + if (priceId === env.STRIPE_PRICE_TEAM_MONTHLY) { + return { plan: 'team', interval: 'month' } + } + if (priceId === env.STRIPE_PRICE_TEAM_ANNUAL) { + return { plan: 'team', interval: 'year' } + } + return null +} + +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). */ +export function capSeconds(team: TeamPlan, spendCapCents: number): number { + return Math.floor((spendCapCents / team.overageCentsPerHour) * 3600) +} + +function addUtcMonths(ms: number, months: number): number { + const date = new Date(ms) + const day = date.getUTCDate() + const target = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, 1, date.getUTCHours(), + date.getUTCMinutes(), date.getUTCSeconds(), date.getUTCMilliseconds())) + // Clamp to the target month's last day (Jan 31 + 1 month = Feb 28/29), like Stripe's anchors. + const lastDay = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate() + target.setUTCDate(Math.min(day, lastDay)) + return target.getTime() +} + +/** + * The monthly allowance window containing `at`: the billing period itself for monthly plans, + * one calendar-month slice of the year (anchored on the period start) for annual plans. + */ +export function allowanceWindow(period: { start: number; end: number }, at: number): { start: number; end: number } { + let start = period.start + for (let month = 1; month <= 12; month++) { + const next = Math.min(addUtcMonths(period.start, month), period.end) + if (at < next || next >= period.end) { + return { start, end: next } + } + start = next + } + return { start, end: period.end } +} diff --git a/cloud/apps/billing/src/seats.ts b/cloud/apps/billing/src/seats.ts new file mode 100644 index 00000000000..ee2ef38e174 --- /dev/null +++ b/cloud/apps/billing/src/seats.ts @@ -0,0 +1,123 @@ +// Team seats follow org membership, billed by the period's PEAK (the GitHub model, with peak +// protection against remove/re-add churn): +// +// - Adding members above the seats already paid this period: the extra seats are invoiced and +// charged now, prorated (`always_invoice`). +// - Removing members: nothing changes mid-period (no credit). Seats come down to the current +// member count only at renewal: inside `seatTrueDownHours` of the period end, billing lowers the +// quantity with `proration_behavior: none`, so the renewal invoice bills the smaller team. +// - Re-adding someone removed earlier in the same period costs nothing: the period's seats are +// never lowered mid-period, so membership up to the peak is already paid. +// +// Membership is owned by Orca Cloud auth (pull: /v1/internal/billing/org-members; push: +// /v1/internal/seats). Seats are never below 1. See cloud/docs/accounts-billing-design.md. +import { z } from 'zod' +import { MAX_TEAM_SEATS, OPEN_STATUSES } from './checkout' +import type { Env } from './env' +import { orgIdOf, type MembershipSource } from './membership' +import { planCatalog, planForPrice } from './plans' +import type { StripeClient } from './stripe' +import { currentSubscription, openOrgAccounts, setMemberCount } from './store' +import { syncSubscription } from './webhook' + +export const SeatSyncSchema = z + .object({ + accountId: z.string().min(1).max(128).regex(/^org:[A-Za-z0-9_.:-]+$/), + members: z.number().int().min(0).max(100_000) + }) + .strict() + +export type SeatSyncOutcome = + | { status: 'unchanged'; seats: number } + | { status: 'increased'; seats: number; previous: number } + /** Fewer members than paid seats: the paid seats stay until renewal. */ + | { status: 'deferred'; seats: number; members: number; effectiveAt: number } + /** Lowered for the renewal invoice (no credit for the current period). */ + | { status: 'reduced_at_renewal'; seats: number; previous: number; effectiveAt: number } + | { status: 'no_subscription' } + | { status: 'too_many_seats' } + +export async function syncSeats( + env: Env, + stripe: StripeClient, + accountId: string, + members: number, + now: number +): Promise { + const db = env.BILLING_DB + const subscription = await currentSubscription(db, accountId) + if (!subscription || !OPEN_STATUSES.has(subscription.status)) { + return { status: 'no_subscription' } + } + const desired = Math.max(1, members) + if (desired > MAX_TEAM_SEATS) { + return { status: 'too_many_seats' } + } + // Remembered so the reconcile cron can apply a deferred decrease without a fresh push. + await setMemberCount(db, subscription.stripe_subscription_id, members, now) + if (desired === subscription.seats) { + return { status: 'unchanged', seats: desired } + } + const periodEnd = subscription.current_period_end + const trueDownFrom = periodEnd - planCatalog(env).team.seatTrueDownHours * 3600_000 + if (desired < subscription.seats && now < trueDownFrom) { + return { status: 'deferred', seats: subscription.seats, members: desired, effectiveAt: periodEnd } + } + + // Read the live item: the mirror can lag a webhook, and the item id is not stored. + const live = await stripe.retrieveSubscription(subscription.stripe_subscription_id) + const seatItem = live.items.data.find((item) => planForPrice(env, item.price.id)) + if (!seatItem) { + return { status: 'no_subscription' } + } + const current = seatItem.quantity ?? 1 + if (current !== desired) { + await stripe.setSubscriptionItemQuantity({ + item: seatItem.id, + quantity: desired, + prorationBehavior: desired > current ? 'always_invoice' : 'none', + // Same change retried within Stripe's idempotency window is applied once. + idempotencyKey: `seats:${seatItem.id}:${periodEnd}:${current}->${desired}` + }) + } + await syncSubscription(env, stripe, subscription.stripe_subscription_id, now) + return desired > current + ? { status: 'increased', seats: desired, previous: current } + : { status: 'reduced_at_renewal', seats: desired, previous: current, effectiveAt: periodEnd } +} + +/** + * Cron: brings every open org's seats in line with membership. Re-reads the count from auth when + * configured (catching invites accepted at the provider); otherwise uses the last pushed count. + * Also where deferred decreases land, once a subscription is inside its true-down window. + */ +export async function reconcileSeats( + env: Env, + stripe: StripeClient, + membership: MembershipSource, + now: number +): Promise<{ checked: number; changed: number; failed: number }> { + let checked = 0 + let changed = 0 + let failed = 0 + for (const { account_id: accountId, member_count: stored } of await openOrgAccounts(env.BILLING_DB, 200)) { + const orgId = orgIdOf(accountId) + if (!orgId) { + continue + } + try { + const members = (await membership(orgId)) ?? stored + if (members === null) { + continue + } + checked++ + const outcome = await syncSeats(env, stripe, accountId, members, now) + if (outcome.status === 'increased' || outcome.status === 'reduced_at_renewal') { + changed++ + } + } catch { + failed++ + } + } + return { checked, changed, failed } +} diff --git a/cloud/apps/billing/src/store.ts b/cloud/apps/billing/src/store.ts new file mode 100644 index 00000000000..af9f27f66eb --- /dev/null +++ b/cloud/apps/billing/src/store.ts @@ -0,0 +1,350 @@ +// D1 access. Every multi-statement write goes through db.batch() (one transaction), and every +// insert that can be retried is INSERT OR IGNORE on a caller-supplied key. +import type { BillingInterval, PlanId } from './plans' + +export type SubscriptionRow = { + stripe_subscription_id: string + account_id: string + stripe_customer_id: string + status: string + plan: PlanId + seats: number + overage_enabled: number + current_period_start: number + current_period_end: number + cancel_at_period_end: number + updated_at: number + billing_interval: BillingInterval + overage_item_id: string | null + past_due_since: number | null + member_count: number | null +} + +export type AccountSettings = { overage_opt_in: number; spend_cap_cents: number | null } + +export type MeterReportRow = { + usage_id: string + stripe_customer_id: string + seconds: number + event_timestamp: number + attempts: number +} + +export async function ensureAccount(db: D1Database, accountId: string, now: number): Promise { + await db + .prepare('INSERT OR IGNORE INTO billing_accounts (account_id, created_at, updated_at) VALUES (?1, ?2, ?2)') + .bind(accountId, now) + .run() +} + +export async function customerFor(db: D1Database, accountId: string): Promise { + const row = await db + .prepare('SELECT stripe_customer_id FROM billing_accounts WHERE account_id = ?1') + .bind(accountId) + .first<{ stripe_customer_id: string | null }>() + return row?.stripe_customer_id ?? null +} + +export async function accountForCustomer(db: D1Database, customerId: string): Promise { + const row = await db + .prepare('SELECT account_id FROM billing_accounts WHERE stripe_customer_id = ?1') + .bind(customerId) + .first<{ account_id: string }>() + return row?.account_id ?? null +} + +/** Links only an unlinked account, so a race between two first checkouts keeps the first. */ +export async function linkCustomer(db: D1Database, accountId: string, customerId: string, now: number): Promise { + await db + .prepare( + 'UPDATE billing_accounts SET stripe_customer_id = ?2, updated_at = ?3 WHERE account_id = ?1 AND stripe_customer_id IS NULL' + ) + .bind(accountId, customerId, now) + .run() + return (await customerFor(db, accountId)) ?? customerId +} + +// Live statuses sort first, then the latest period: the row entitlements are computed from. +export async function currentSubscription(db: D1Database, accountId: string): Promise { + return db + .prepare( + `SELECT * FROM subscriptions WHERE account_id = ?1 + ORDER BY CASE status WHEN 'active' THEN 0 WHEN 'trialing' THEN 0 WHEN 'past_due' THEN 1 ELSE 2 END, + current_period_end DESC + LIMIT 1` + ) + .bind(accountId) + .first() +} + +export async function upsertSubscription(db: D1Database, row: SubscriptionRow): Promise { + await db + .prepare( + `INSERT INTO subscriptions (stripe_subscription_id, account_id, stripe_customer_id, status, plan, seats, + overage_enabled, current_period_start, current_period_end, cancel_at_period_end, updated_at, + billing_interval, overage_item_id, past_due_since, member_count) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15) + ON CONFLICT (stripe_subscription_id) DO UPDATE SET + status = excluded.status, plan = excluded.plan, seats = excluded.seats, + overage_enabled = excluded.overage_enabled, current_period_start = excluded.current_period_start, + current_period_end = excluded.current_period_end, cancel_at_period_end = excluded.cancel_at_period_end, + updated_at = excluded.updated_at, billing_interval = excluded.billing_interval, + overage_item_id = excluded.overage_item_id, + -- Keep the first time it went past due; clear it once paid. + past_due_since = CASE WHEN excluded.past_due_since IS NULL THEN NULL + ELSE COALESCE(subscriptions.past_due_since, excluded.past_due_since) END, + member_count = COALESCE(excluded.member_count, subscriptions.member_count)` + ) + .bind( + row.stripe_subscription_id, + row.account_id, + row.stripe_customer_id, + row.status, + row.plan, + row.seats, + row.overage_enabled, + row.current_period_start, + row.current_period_end, + row.cancel_at_period_end, + row.updated_at, + row.billing_interval, + row.overage_item_id, + row.past_due_since, + row.member_count + ) + .run() +} + +export async function accountSettings(db: D1Database, accountId: string): Promise { + const row = await db + .prepare('SELECT overage_opt_in, spend_cap_cents FROM billing_accounts WHERE account_id = ?1') + .bind(accountId) + .first() + return row ?? { overage_opt_in: 0, spend_cap_cents: null } +} + +export async function setOverageSettings( + db: D1Database, + accountId: string, + settings: { optIn: boolean; spendCapCents: number | null }, + now: number +): Promise { + await db + .prepare('UPDATE billing_accounts SET overage_opt_in = ?2, spend_cap_cents = ?3, updated_at = ?4 WHERE account_id = ?1') + .bind(accountId, settings.optIn ? 1 : 0, settings.spendCapCents, now) + .run() +} + +export async function periodUsage( + db: D1Database, + accountId: string, + periodStart: number +): Promise<{ usedSeconds: number; overageSeconds: number }> { + const row = await db + .prepare( + `SELECT COALESCE(SUM(weighted_seconds), 0) AS used, COALESCE(SUM(overage_seconds), 0) AS overage + FROM usage_events WHERE account_id = ?1 AND period_start = ?2` + ) + .bind(accountId, periodStart) + .first<{ used: number; overage: number }>() + return { usedSeconds: row?.used ?? 0, overageSeconds: row?.overage ?? 0 } +} + +export type UsageInsert = { + usageId: string + accountId: string + sandboxId: string + machineSize: string + startedAt: number + endedAt: number + weightedSeconds: number + periodStart: number | null + includedSeconds: number + now: number + // The member who started the sandbox, when the control plane reports it. + userId: string | null + // Set when overage is billable: queues a meter report in the same transaction, for at most + // what is left of the window's spend cap (meterCapSeconds). + meterCustomer: string | null + meterCapSeconds: number +} + +/** + * Records one usage segment and, atomically, the overage it adds. The overage is computed inside + * the INSERT from the period's running total, so concurrent reports cannot both claim the same + * included seconds. Returns false when usageId was already recorded (a retry). + */ +export async function insertUsage(db: D1Database, usage: UsageInsert): Promise { + const insert = db + .prepare( + `INSERT OR IGNORE INTO usage_events (usage_id, account_id, sandbox_id, machine_size, started_at, ended_at, + weighted_seconds, period_start, overage_seconds, created_at, user_id) + SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, + CASE WHEN ?8 IS NULL THEN 0 + ELSE MAX(0, MIN(?7, COALESCE(SUM(weighted_seconds), 0) + ?7 - ?9)) END, + ?10, ?11 + FROM usage_events WHERE account_id = ?2 AND period_start IS ?8` + ) + .bind( + usage.usageId, + usage.accountId, + usage.sandboxId, + usage.machineSize, + usage.startedAt, + usage.endedAt, + usage.weightedSeconds, + usage.periodStart, + usage.includedSeconds, + usage.now, + usage.userId + ) + const statements = [insert] + if (usage.meterCustomer) { + statements.push( + db + .prepare( + `INSERT OR IGNORE INTO meter_reports (usage_id, stripe_customer_id, seconds, event_timestamp, status, created_at) + SELECT usage_id, ?2, billable, ended_at, 'pending', ?3 FROM ( + SELECT u.usage_id, u.ended_at, + MIN(u.overage_seconds, MAX(0, ?4 - ( + SELECT COALESCE(SUM(m.seconds), 0) FROM meter_reports m + JOIN usage_events p ON p.usage_id = m.usage_id + WHERE p.account_id = u.account_id AND p.period_start IS u.period_start))) AS billable + FROM usage_events u WHERE u.usage_id = ?1 AND u.overage_seconds > 0 + ) WHERE billable > 0` + ) + .bind(usage.usageId, usage.meterCustomer, usage.now, usage.meterCapSeconds) + ) + } + const [result] = await db.batch(statements) + return (result?.meta.changes ?? 0) > 0 +} + +/** Overage seconds already metered (billed) in an allowance window. */ +export async function meteredSeconds(db: D1Database, accountId: string, windowStart: number): Promise { + const row = await db + .prepare( + `SELECT COALESCE(SUM(m.seconds), 0) AS metered FROM meter_reports m + JOIN usage_events p ON p.usage_id = m.usage_id + WHERE p.account_id = ?1 AND p.period_start = ?2` + ) + .bind(accountId, windowStart) + .first<{ metered: number }>() + return row?.metered ?? 0 +} + +/** Accounts whose payment grace has run out: the control plane suspends their running sandboxes. */ +export async function graceExpiredAccounts(db: D1Database, cutoff: number): Promise> { + return ( + await db + .prepare( + `SELECT account_id, MIN(past_due_since) AS past_due_since FROM subscriptions + WHERE status IN ('past_due', 'unpaid') AND past_due_since IS NOT NULL AND past_due_since <= ?1 + GROUP BY account_id ORDER BY past_due_since LIMIT 1000` + ) + .bind(cutoff) + .all<{ account_id: string; past_due_since: number }>() + ).results +} + +/** Org accounts with an open subscription (and the last member count seen), for the seat cron. */ +export async function openOrgAccounts( + db: D1Database, + limit: number +): Promise> { + return ( + await db + .prepare( + `SELECT account_id, MAX(member_count) AS member_count FROM subscriptions + WHERE account_id LIKE 'org:%' AND status IN ('active', 'trialing', 'past_due', 'unpaid') + GROUP BY account_id ORDER BY MIN(updated_at) LIMIT ?1` + ) + .bind(limit) + .all<{ account_id: string; member_count: number | null }>() + ).results +} + +export async function setMemberCount( + db: D1Database, + subscriptionId: string, + members: number, + now: number +): Promise { + await db + .prepare('UPDATE subscriptions SET member_count = ?2, updated_at = ?3 WHERE stripe_subscription_id = ?1') + .bind(subscriptionId, members, now) + .run() +} + +export async function usageOverage(db: D1Database, usageId: string): Promise { + const row = await db + .prepare('SELECT overage_seconds FROM usage_events WHERE usage_id = ?1') + .bind(usageId) + .first<{ overage_seconds: number }>() + return row?.overage_seconds ?? 0 +} + +export async function pendingMeterReports(db: D1Database, limit: number, usageId?: string): Promise { + const query = usageId + ? db + .prepare( + "SELECT usage_id, stripe_customer_id, seconds, event_timestamp, attempts FROM meter_reports WHERE usage_id = ?1 AND status = 'pending'" + ) + .bind(usageId) + : db + .prepare( + "SELECT usage_id, stripe_customer_id, seconds, event_timestamp, attempts FROM meter_reports WHERE status = 'pending' ORDER BY created_at LIMIT ?1" + ) + .bind(limit) + return (await query.all()).results +} + +export async function markMeterReport( + db: D1Database, + usageId: string, + outcome: { sent: true; now: number } | { sent: false; error: string; giveUp: boolean } +): Promise { + if (outcome.sent) { + await db + .prepare("UPDATE meter_reports SET status = 'sent', sent_at = ?2, attempts = attempts + 1 WHERE usage_id = ?1") + .bind(usageId, outcome.now) + .run() + return + } + await db + .prepare('UPDATE meter_reports SET status = ?2, last_error = ?3, attempts = attempts + 1 WHERE usage_id = ?1') + .bind(usageId, outcome.giveUp ? 'failed' : 'pending', outcome.error.slice(0, 200)) + .run() +} + +/** Claims an event id. Returns 'duplicate' when it was already processed successfully. */ +export async function claimEvent(db: D1Database, eventId: string, type: string, now: number): Promise<'new' | 'duplicate'> { + await db + .prepare('INSERT OR IGNORE INTO stripe_events (event_id, type, received_at) VALUES (?1, ?2, ?3)') + .bind(eventId, type, now) + .run() + const row = await db + .prepare('SELECT processed_at FROM stripe_events WHERE event_id = ?1') + .bind(eventId) + .first<{ processed_at: number | null }>() + return row?.processed_at ? 'duplicate' : 'new' +} + +/** `now` marks the event done (with an optional note, e.g. why it was ignored); `error` leaves it retryable. */ +export async function finishEvent( + db: D1Database, + eventId: string, + outcome: { now: number; note?: string } | { error: string } +): Promise { + if ('now' in outcome) { + await db + .prepare('UPDATE stripe_events SET processed_at = ?2, last_error = ?3 WHERE event_id = ?1') + .bind(eventId, outcome.now, outcome.note ?? null) + .run() + return + } + await db + .prepare('UPDATE stripe_events SET last_error = ?2 WHERE event_id = ?1') + .bind(eventId, outcome.error.slice(0, 200)) + .run() +} diff --git a/cloud/apps/billing/src/stripe.ts b/cloud/apps/billing/src/stripe.ts new file mode 100644 index 00000000000..d20702eba2a --- /dev/null +++ b/cloud/apps/billing/src/stripe.ts @@ -0,0 +1,193 @@ +// Minimal Stripe REST client over fetch: only the calls billing makes. Form-encoded bodies, +// pinned API version, an Idempotency-Key on every POST, and a hard refusal of live-mode keys +// unless STRIPE_ALLOW_LIVEMODE is exactly 'true'. +import type { Env } from './env' + +export const DEFAULT_STRIPE_API_VERSION = '2025-09-30.clover' +const STRIPE_TIMEOUT_MS = 15_000 + +export class StripeError extends Error { + constructor( + public readonly status: number, + public readonly code: string | undefined, + message: string + ) { + super(message) + this.name = 'StripeError' + } +} + +export class LiveModeRefusedError extends Error { + constructor() { + super('stripe_livemode_refused') + this.name = 'LiveModeRefusedError' + } +} + +export function allowsLiveMode(env: Env): boolean { + return env.STRIPE_ALLOW_LIVEMODE === 'true' +} + +/** Throws unless the key is a test-mode key (or live mode was explicitly enabled). */ +export function assertKeyMode(env: Env): void { + const key = env.STRIPE_SECRET_KEY ?? '' + const isTest = key.startsWith('sk_test_') || key.startsWith('rk_test_') + const isLive = key.startsWith('sk_live_') || key.startsWith('rk_live_') + if (isLive && !allowsLiveMode(env)) { + throw new LiveModeRefusedError() + } + if (!isTest && !isLive) { + throw new Error('stripe_key_missing') + } +} + +type FormValue = string | number | boolean | null | undefined | FormRecord | FormValue[] +type FormRecord = { [key: string]: FormValue } + +/** Stripe's bracket encoding: a[b][0][c]=v. Undefined/null values are omitted. */ +export function encodeForm(params: FormRecord): string { + const pairs: string[] = [] + const walk = (prefix: string, value: FormValue): void => { + if (value === undefined || value === null) { + return + } + if (Array.isArray(value)) { + value.forEach((item, index) => walk(`${prefix}[${index}]`, item)) + return + } + if (typeof value === 'object') { + for (const [key, inner] of Object.entries(value)) { + walk(prefix ? `${prefix}[${key}]` : key, inner) + } + return + } + pairs.push(`${encodeURIComponent(prefix)}=${encodeURIComponent(String(value))}`) + } + walk('', params) + return pairs.join('&') +} + +export type StripeClient = ReturnType + +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) { + assertKeyMode(env) + const headers: Record = { + authorization: `Bearer ${env.STRIPE_SECRET_KEY}`, + 'stripe-version': version + } + let url = `${base}${path}` + let body: string | undefined + if (method === 'POST') { + headers['content-type'] = 'application/x-www-form-urlencoded' + body = encodeForm(params ?? {}) + if (idempotencyKey) { + headers['idempotency-key'] = idempotencyKey + } + } else if (params) { + url += `?${encodeForm(params)}` + } + const response = await fetchImpl(url, { + method, + headers, + body, + // Never follow a redirect with the key attached. Workers reject redirect: 'error', so take + // the 3xx manually and fail it below (a 3xx is never ok). + redirect: 'manual', + signal: AbortSignal.timeout(STRIPE_TIMEOUT_MS) + }) + const json = (await response.json().catch(() => ({}))) as { error?: { code?: string; message?: string } } + if (!response.ok) { + // Stripe's message never contains the key; still keep it short for logs. + throw new StripeError(response.status, json.error?.code, (json.error?.message ?? 'stripe_error').slice(0, 200)) + } + return json as T + } + + return { + createCustomer(args: { accountId: string; email?: string; name?: string }) { + return request<{ id: string }>( + 'POST', + '/v1/customers', + { email: args.email, name: args.name, metadata: { account_id: args.accountId } }, + // Stable per account: a retried first checkout cannot create a second customer. + `customer-create:${args.accountId}` + ) + }, + createCheckoutSession(params: FormRecord, idempotencyKey: string) { + return request<{ id: string; url: string }>('POST', '/v1/checkout/sessions', params, idempotencyKey) + }, + createPortalSession(args: { customer: string; returnUrl: string; configuration?: string }, idempotencyKey: string) { + return request<{ id: string; url: string }>( + 'POST', + '/v1/billing_portal/sessions', + { customer: args.customer, return_url: args.returnUrl, configuration: args.configuration }, + idempotencyKey + ) + }, + addSubscriptionItem(args: { subscription: string; price: string }) { + return request<{ id: string }>( + 'POST', + '/v1/subscription_items', + // Metered: nothing to prorate; usage is billed in arrears. + { subscription: args.subscription, price: args.price, proration_behavior: 'none' }, + `overage-item:${args.subscription}:${args.price}` + ) + }, + setSubscriptionItemQuantity(args: { + item: string + quantity: number + prorationBehavior: 'always_invoice' | 'none' + idempotencyKey: string + }) { + return request<{ id: string; quantity: number }>( + 'POST', + `/v1/subscription_items/${encodeURIComponent(args.item)}`, + { quantity: args.quantity, proration_behavior: args.prorationBehavior }, + args.idempotencyKey + ) + }, + retrieveSubscription(id: string) { + return request('GET', `/v1/subscriptions/${encodeURIComponent(id)}`) + }, + createMeterEvent(args: { eventName: string; customer: string; value: number; identifier: string; timestamp: number }) { + return request<{ identifier: string }>( + 'POST', + '/v1/billing/meter_events', + { + event_name: args.eventName, + identifier: args.identifier, + timestamp: Math.floor(args.timestamp / 1000), + payload: { stripe_customer_id: args.customer, value: String(args.value) } + }, + `meter:${args.identifier}` + ) + } + } +} + +// Fields billing reads. Since API 2025-03-31 the period lives on each item; older payloads keep it +// on the subscription, so both are read. +export type StripeSubscription = { + id: string + object: 'subscription' + customer: string | { id: string } + status: string + cancel_at_period_end: boolean + livemode: boolean + metadata?: Record + current_period_start?: number + current_period_end?: number + items: { + data: Array<{ + id: string + quantity?: number + current_period_start?: number + current_period_end?: number + price: { id: string } + }> + } +} diff --git a/cloud/apps/billing/src/usage.ts b/cloud/apps/billing/src/usage.ts new file mode 100644 index 00000000000..2591a9698b7 --- /dev/null +++ b/cloud/apps/billing/src/usage.ts @@ -0,0 +1,127 @@ +// 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. +import { z } from 'zod' +import type { Env } from './env' +import { MACHINE_SIZE_WEIGHT, MACHINE_SIZES, capSeconds, includedSeconds, planCatalog } from './plans' +import { subscriptionWindow } from './entitlements' +import type { StripeClient } from './stripe' +import { + accountSettings, + currentSubscription, + customerFor, + insertUsage, + markMeterReport, + pendingMeterReports, + usageOverage +} from './store' + +// One segment is at most a day: the control plane settles running sandboxes periodically. +const MAX_SEGMENT_MS = 24 * 3600 * 1000 +// Stripe accepts meter events up to 35 days old and 5 minutes in the future. +const MAX_AGE_MS = 30 * 24 * 3600 * 1000 +const FUTURE_SKEW_MS = 5 * 60 * 1000 +export const MAX_METER_ATTEMPTS = 10 + +const ID = z.string().min(1).max(128).regex(/^[A-Za-z0-9_.:-]+$/) + +export const UsageReportSchema = z + .object({ + usageId: ID, + accountId: ID, + sandboxId: ID, + machineSize: z.enum(MACHINE_SIZES), + startedAt: z.number().int().positive(), + endedAt: z.number().int().positive(), + // The member who started the sandbox (attribution and per-member caps). + userId: ID.optional() + }) + .strict() + +export type UsageReport = z.infer + +export function validateSegment(report: UsageReport, now: number): string | null { + if (report.endedAt <= report.startedAt) { + return 'empty_segment' + } + if (report.endedAt - report.startedAt > MAX_SEGMENT_MS) { + return 'segment_too_long' + } + if (report.endedAt > now + FUTURE_SKEW_MS) { + return 'segment_in_future' + } + if (report.endedAt < now - MAX_AGE_MS) { + return 'segment_too_old' + } + return null +} + +export async function recordUsage( + env: Env, + report: UsageReport, + now: number +): Promise<{ recorded: boolean; weightedSeconds: number; overageSeconds: number }> { + const db = env.BILLING_DB + const team = planCatalog(env).team + const [subscription, settings] = await Promise.all([ + currentSubscription(db, report.accountId), + accountSettings(db, report.accountId) + ]) + const wallSeconds = Math.ceil((report.endedAt - report.startedAt) / 1000) + const weightedSeconds = wallSeconds * MACHINE_SIZE_WEIGHT[report.machineSize] + // A segment counts in the allowance window it ended in. + const window = subscription ? subscriptionWindow(subscription, report.endedAt) : null + const billable = subscription !== null && settings.overage_opt_in === 1 && subscription.overage_item_id !== null + const customer = billable ? await customerFor(db, report.accountId) : null + const recorded = await insertUsage(db, { + usageId: report.usageId, + accountId: report.accountId, + sandboxId: report.sandboxId, + machineSize: report.machineSize, + startedAt: report.startedAt, + endedAt: report.endedAt, + weightedSeconds, + periodStart: window?.start ?? null, + includedSeconds: subscription ? includedSeconds(team, subscription.seats) : 0, + now, + userId: report.userId ?? null, + meterCustomer: customer, + meterCapSeconds: capSeconds(team, settings.spend_cap_cents ?? team.defaultSpendCapCents) + }) + return { recorded, weightedSeconds, overageSeconds: await usageOverage(db, report.usageId) } +} + +/** Sends pending meter reports (one usage id, or the oldest batch for the cron). */ +export async function flushMeterReports( + env: Env, + stripe: StripeClient, + now: number, + options: { usageId?: string; limit?: number } = {} +): Promise<{ sent: number; failed: number }> { + const pending = await pendingMeterReports(env.BILLING_DB, options.limit ?? 100, options.usageId) + let sent = 0 + let failed = 0 + for (const report of pending) { + try { + await stripe.createMeterEvent({ + eventName: env.STRIPE_METER_EVENT_NAME, + customer: report.stripe_customer_id, + value: report.seconds, + identifier: report.usage_id, + timestamp: report.event_timestamp + }) + await markMeterReport(env.BILLING_DB, report.usage_id, { sent: true, now }) + sent++ + } catch (error) { + failed++ + await markMeterReport(env.BILLING_DB, report.usage_id, { + sent: false, + error: error instanceof Error ? error.message : 'unknown', + giveUp: report.attempts + 1 >= MAX_METER_ATTEMPTS + }) + } + } + return { sent, failed } +} diff --git a/cloud/apps/billing/src/webhook-signature.ts b/cloud/apps/billing/src/webhook-signature.ts new file mode 100644 index 00000000000..9c3d8043f3b --- /dev/null +++ b/cloud/apps/billing/src/webhook-signature.ts @@ -0,0 +1,84 @@ +// Stripe webhook signature verification (https://docs.stripe.com/webhooks#verify-manually): +// header `Stripe-Signature: t=,v1=[,v1=...]`, HMAC-SHA256 over `${t}.${rawBody}` +// keyed by the endpoint secret. Implemented on WebCrypto so the Worker carries no Stripe SDK. +export const DEFAULT_TOLERANCE_SECONDS = 300 + +export type SignatureResult = { ok: true } | { ok: false; reason: 'malformed' | 'stale' | 'mismatch' } + +const encoder = new TextEncoder() + +async function hmacHex(secret: string, payload: string): Promise { + const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, [ + 'sign' + ]) + const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, encoder.encode(payload))) + return Array.from(mac, (byte) => byte.toString(16).padStart(2, '0')).join('') +} + +/** Constant-time over equal-length strings; length itself is not secret (always 64 hex chars). */ +export function constantTimeEqual(a: string, b: string): boolean { + if (a.length !== b.length) { + return false + } + let diff = 0 + for (let i = 0; i < a.length; i++) { + diff |= a.charCodeAt(i) ^ b.charCodeAt(i) + } + return diff === 0 +} + +function parseHeader(header: string): { timestamp: number; signatures: string[] } | null { + let timestamp: number | null = null + const signatures: string[] = [] + for (const part of header.split(',')) { + const index = part.indexOf('=') + if (index <= 0) { + continue + } + const key = part.slice(0, index).trim() + const value = part.slice(index + 1).trim() + if (key === 't' && /^\d{1,12}$/.test(value)) { + timestamp = Number(value) + } else if (key === 'v1' && /^[0-9a-f]{64}$/.test(value)) { + signatures.push(value) + } + } + return timestamp === null || signatures.length === 0 ? null : { timestamp, signatures } +} + +export async function verifyStripeSignature( + rawBody: string, + header: string | null, + secrets: readonly string[], + nowMs: number = Date.now(), + toleranceSeconds: number = DEFAULT_TOLERANCE_SECONDS +): Promise { + const parsed = header ? parseHeader(header) : null + const usable = secrets.filter((secret) => secret.length > 0) + if (!parsed || usable.length === 0) { + return { ok: false, reason: 'malformed' } + } + if (Math.abs(nowMs / 1000 - parsed.timestamp) > toleranceSeconds) { + return { ok: false, reason: 'stale' } + } + const signedPayload = `${parsed.timestamp}.${rawBody}` + for (const secret of usable) { + const expected = await hmacHex(secret, signedPayload) + // Check every candidate so timing does not reveal which one matched. + let matched = false + for (const signature of parsed.signatures) { + matched = constantTimeEqual(expected, signature) || matched + } + if (matched) { + return { ok: true } + } + } + return { ok: false, reason: 'mismatch' } +} + +export function splitSecrets(value: string | undefined): string[] { + return (value ?? '') + .split(',') + .map((secret) => secret.trim()) + .filter(Boolean) +} diff --git a/cloud/apps/billing/src/webhook.ts b/cloud/apps/billing/src/webhook.ts new file mode 100644 index 00000000000..e4373aecd8a --- /dev/null +++ b/cloud/apps/billing/src/webhook.ts @@ -0,0 +1,169 @@ +// Stripe webhook: verify the signature over the raw body, dedupe by event id, then re-read the +// subscription from the API instead of trusting the (possibly stale, possibly out-of-order) payload. +import type { Env } from './env' +import { planForPrice } from './plans' +import { allowsLiveMode, type StripeClient, type StripeSubscription } from './stripe' +import { accountForCustomer, claimEvent, customerFor, finishEvent, upsertSubscription } from './store' +import { splitSecrets, verifyStripeSignature } from './webhook-signature' + +const MAX_BODY_BYTES = 512 * 1024 + +type StripeEvent = { + id: string + type: string + livemode: boolean + data: { object: Record } +} + +const SUBSCRIPTION_EVENTS = new Set([ + 'customer.subscription.created', + 'customer.subscription.updated', + 'customer.subscription.deleted', + 'customer.subscription.paused', + 'customer.subscription.resumed' +]) +const INVOICE_EVENTS = new Set(['invoice.paid', 'invoice.payment_failed']) + +function idOf(value: unknown): string | null { + if (typeof value === 'string' && value) { + return value + } + if (value && typeof value === 'object' && typeof (value as { id?: unknown }).id === 'string') { + return (value as { id: string }).id + } + return null +} + +/** The subscription an event concerns, across the payload shapes of old and new API versions. */ +export function subscriptionIdFor(event: StripeEvent): string | null { + const object = event.data.object + if (SUBSCRIPTION_EVENTS.has(event.type)) { + return idOf(object.id) + } + if (event.type === 'checkout.session.completed') { + return object.mode === 'subscription' ? idOf(object.subscription) : null + } + if (INVOICE_EVENTS.has(event.type)) { + const parent = object.parent as { subscription_details?: { subscription?: unknown } } | undefined + return idOf(object.subscription) ?? idOf(parent?.subscription_details?.subscription) + } + return null +} + +class IgnoredSubscription extends Error {} + +/** Stripe statuses after a failed renewal: past_due while retrying, unpaid once retries are exhausted. */ +export const PAYMENT_FAILED_STATUSES = new Set(['past_due', 'unpaid']) + +export async function syncSubscription(env: Env, stripe: StripeClient, subscriptionId: string, now: number) { + const subscription: StripeSubscription = await stripe.retrieveSubscription(subscriptionId) + if (subscription.livemode && !allowsLiveMode(env)) { + throw new IgnoredSubscription('livemode_subscription') + } + const customer = idOf(subscription.customer) + if (!customer) { + throw new IgnoredSubscription('subscription_without_customer') + } + const db = env.BILLING_DB + // Metadata is ours (customers cannot edit it), but the customer link is the authority: an + // account is only credited with a subscription billed to the customer we created for it. + const claimed = subscription.metadata?.account_id + const accountId = claimed && (await customerFor(db, claimed)) === customer ? claimed : await accountForCustomer(db, customer) + if (!accountId) { + throw new IgnoredSubscription('unknown_customer') + } + let match: ReturnType = null + let seats = 1 + let overageItemId: string | null = null + let periodStart = subscription.current_period_start + let periodEnd = subscription.current_period_end + for (const item of subscription.items.data) { + if (item.price.id === env.STRIPE_PRICE_OVERAGE) { + overageItemId = item.id + continue + } + const itemPlan = planForPrice(env, item.price.id) + if (itemPlan) { + match = itemPlan + seats = Math.max(1, item.quantity ?? 1) + periodStart = item.current_period_start ?? periodStart + periodEnd = item.current_period_end ?? periodEnd + } + } + if (!match || !periodStart || !periodEnd) { + throw new IgnoredSubscription('unrecognized_subscription_items') + } + await upsertSubscription(db, { + stripe_subscription_id: subscription.id, + account_id: accountId, + stripe_customer_id: customer, + status: subscription.status, + plan: match.plan, + seats, + overage_enabled: overageItemId ? 1 : 0, + current_period_start: periodStart * 1000, + current_period_end: periodEnd * 1000, + cancel_at_period_end: subscription.cancel_at_period_end ? 1 : 0, + updated_at: now, + billing_interval: match.interval, + overage_item_id: overageItemId, + // The upsert keeps the earliest value while it stays unpaid and clears it once paid. + past_due_since: PAYMENT_FAILED_STATUSES.has(subscription.status) ? now : null, + // Membership comes from auth, not Stripe: keep the stored count. + member_count: null + }) +} + +export async function handleStripeWebhook(env: Env, stripe: StripeClient, request: Request, now: number): Promise { + const length = Number(request.headers.get('content-length') ?? '0') + if (length > MAX_BODY_BYTES) { + return new Response(null, { status: 413 }) + } + const rawBody = await request.text() + if (rawBody.length > MAX_BODY_BYTES) { + return new Response(null, { status: 413 }) + } + const verified = await verifyStripeSignature( + rawBody, + request.headers.get('stripe-signature'), + splitSecrets(env.STRIPE_WEBHOOK_SECRET), + now + ) + if (!verified.ok) { + return Response.json({ error: `signature_${verified.reason}` }, { status: 400 }) + } + let event: StripeEvent + try { + event = JSON.parse(rawBody) as StripeEvent + } catch { + return Response.json({ error: 'invalid_json' }, { status: 400 }) + } + if (typeof event.id !== 'string' || typeof event.type !== 'string' || !event.data?.object) { + return Response.json({ error: 'invalid_event' }, { status: 400 }) + } + // Acknowledged (so Stripe stops retrying) but never applied. + if (event.livemode && !allowsLiveMode(env)) { + return Response.json({ ignored: 'livemode' }) + } + const subscriptionId = subscriptionIdFor(event) + if (!subscriptionId) { + return Response.json({ ignored: 'event_type' }) + } + if ((await claimEvent(env.BILLING_DB, event.id, event.type, now)) === 'duplicate') { + return Response.json({ duplicate: true }) + } + try { + await syncSubscription(env, stripe, subscriptionId, now) + await finishEvent(env.BILLING_DB, event.id, { now }) + return Response.json({ received: true }) + } catch (error) { + if (error instanceof IgnoredSubscription) { + // Not ours or not recognizable: retrying will not help. Recorded for the audit script. + await finishEvent(env.BILLING_DB, event.id, { now, note: error.message }) + return Response.json({ ignored: error.message }) + } + await finishEvent(env.BILLING_DB, event.id, { error: error instanceof Error ? error.message : 'unknown' }) + // Non-2xx: Stripe retries with backoff for up to three days. + return Response.json({ error: 'processing_failed' }, { status: 500 }) + } +} diff --git a/cloud/apps/billing/test/auth-dev-keys.test.ts b/cloud/apps/billing/test/auth-dev-keys.test.ts new file mode 100644 index 00000000000..6bdbe1d3187 --- /dev/null +++ b/cloud/apps/billing/test/auth-dev-keys.test.ts @@ -0,0 +1,46 @@ +import { SignJWT, exportJWK, generateKeyPair } from 'jose' +import { describe, expect, it } from 'vitest' +import { DEV_ISSUER } from '../src/auth' +import { createHarness } from './harness' + +async function devKeyPair() { + const { privateKey, publicKey } = await generateKeyPair('ES256') + const jwks = JSON.stringify({ keys: [{ ...(await exportJWK(publicKey)), kid: 'dev', alg: 'ES256' }] }) + const sign = (issuer: string) => { + const now = Math.floor(Date.now() / 1000) + return new SignJWT({ sub: 'u1', purpose: 'billing' }) + .setProtectedHeader({ alg: 'ES256', kid: 'dev' }) + .setIssuer(issuer) + .setAudience('orca-billing') + .setIssuedAt(now) + .setExpirationTime(now + 300) + .sign(privateKey) + } + return { jwks, sign } +} + +describe('dev operator keys', () => { + it('accepts dev-issuer tokens outside prod only, and only with the dev issuer', async () => { + const dev = await devKeyPair() + const call = async (env: Record, token: string) => { + const h = await createHarness(env) + return (await h.call('GET', '/v1/billing/entitlements', { headers: { authorization: `Bearer ${token}` } })).status + } + expect(await call({ BILLING_ENV: 'dev', BILLING_DEV_JWKS: dev.jwks }, await dev.sign(DEV_ISSUER))).toBe(200) + expect(await call({ BILLING_ENV: 'prod', BILLING_DEV_JWKS: dev.jwks }, await dev.sign(DEV_ISSUER))).toBe(401) + // A dev key cannot impersonate the real issuer. + expect(await call({ BILLING_ENV: 'dev', BILLING_DEV_JWKS: dev.jwks }, await dev.sign('https://login.onorca.dev'))).toBe(401) + expect(await call({ BILLING_ENV: 'dev' }, await dev.sign(DEV_ISSUER))).toBe(401) + }) + + it('serves a static return page for Checkout and the Portal', async () => { + const h = await createHarness() + const { handleRequest } = await import('../src/index') + const response = await handleRequest(new Request('https://billing.test/return?checkout=success'), h.env, { + waitUntil() {}, + passThroughOnException() {} + } as unknown as ExecutionContext) + expect(response.status).toBe(200) + expect(await response.text()).toContain('return to Orca') + }) +}) diff --git a/cloud/apps/billing/test/billing-config.test.ts b/cloud/apps/billing/test/billing-config.test.ts new file mode 100644 index 00000000000..e8ac62c56df --- /dev/null +++ b/cloud/apps/billing/test/billing-config.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { allowanceWindow, capSeconds, DEFAULT_PLANS } from '../src/plans' +import { createHarness } from './harness' + +const HOUR = 3600 + +describe('config: tax and plan catalog', () => { + it('leaves Stripe Tax off by default and turns it on with STRIPE_AUTOMATIC_TAX=true', async () => { + for (const [flag, expected] of [ + [undefined, null], + ['true', 'true'] + ] as const) { + const h = await createHarness(flag ? { STRIPE_AUTOMATIC_TAX: flag } : {}) + const token = await h.desktopToken({ sub: 'u1' }) + await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team' }, headers: { authorization: `Bearer ${token}` } }) + const session = h.stripe.calls.find((call) => call.path === '/v1/checkout/sessions')! + expect(session.form.get('automatic_tax[enabled]')).toBe(expected) + expect(session.form.get('tax_id_collection[enabled]')).toBe(expected) + // Stripe Tax needs the customer's address; Checkout saves it back to the customer. + expect(session.form.get('customer_update[address]')).toBe('auto') + } + }) + + it('takes Team numbers and labels and the Enterprise contact from BILLING_PLANS', async () => { + const h = await createHarness({ + BILLING_PLANS: JSON.stringify({ + team: { includedHoursPerSeat: 40, monthlyPriceLabel: '$45/seat/mo', defaultSpendCapCents: 5000 }, + enterprise: { contactUrl: 'https://example.test/sales' } + }) + }) + const token = await h.desktopToken({ sub: 'u1' }) + const entitlement = await h.call('GET', '/v1/billing/entitlements', { headers: { authorization: `Bearer ${token}` } }) + expect(entitlement.body.offers).toEqual({ + team: { + label: 'Team', + monthlyPriceLabel: '$45/seat/mo', + annualPriceLabel: '$480/seat/yr', + monthlyPriceCentsPerSeat: 5000, + annualPriceCentsPerSeat: 48000, + largeMachineWeight: 2, + includedHoursPerSeat: 40, + concurrentSandboxesPerSeat: 3, + idleSleepMinutes: 15, + overageCentsPerHour: 75, + defaultSpendCapCents: 5000 + }, + enterprise: { label: 'Enterprise', priceLabel: 'Custom', contactUrl: 'https://example.test/sales' } + }) + expect(entitlement.body.overage.spendCapCents).toBe(5000) + }) + + it('fails closed on a malformed catalog', async () => { + for (const bad of [ + '{"team":{"includedHoursPerSeat":"lots"}}', + '{"pro":{}}', + '{"enterprise":{"contactUrl":"http://insecure.test"}}', + 'not json' + ]) { + const h = await createHarness({ BILLING_PLANS: bad }) + const token = await h.desktopToken({ sub: 'u1' }) + expect(await h.call('GET', '/v1/billing/entitlements', { headers: { authorization: `Bearer ${token}` } })).toEqual({ + status: 503, + body: { error: 'billing_misconfigured' } + }) + } + }) +}) + +describe('allowance windows and caps', () => { + const day = 24 * HOUR * 1000 + it('uses the billing period for monthly plans', () => { + const period = { start: Date.UTC(2026, 0, 15), end: Date.UTC(2026, 1, 15) } + expect(allowanceWindow(period, Date.UTC(2026, 1, 1))).toEqual(period) + }) + + it('slices annual plans into monthly windows anchored on the start, clamping short months', () => { + const period = { start: Date.UTC(2026, 0, 31), end: Date.UTC(2027, 0, 31) } + expect(allowanceWindow(period, Date.UTC(2026, 1, 10))).toEqual({ start: Date.UTC(2026, 0, 31), end: Date.UTC(2026, 1, 28) }) + expect(allowanceWindow(period, Date.UTC(2026, 2, 1))).toEqual({ start: Date.UTC(2026, 1, 28), end: Date.UTC(2026, 2, 31) }) + 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)) + }) +}) diff --git a/cloud/apps/billing/test/billing-flow.test.ts b/cloud/apps/billing/test/billing-flow.test.ts new file mode 100644 index 00000000000..437e055905a --- /dev/null +++ b/cloud/apps/billing/test/billing-flow.test.ts @@ -0,0 +1,356 @@ +import { describe, expect, it } from 'vitest' +import { checkoutCompleted, invoiceEvent, stripeEvent } from './stripe-fake' +import { createHarness } from './harness' + +const HOUR = 3600 +const PERIOD_START = Math.floor(Date.now() / 1000) - 5 * 24 * HOUR +const PERIOD_END = PERIOD_START + 30 * 24 * HOUR + +/** A 2-seat monthly Team org (120 included hours, 6 concurrent), optionally opted in to overage. */ +async function subscribedTeam(options: { seats?: number; overage?: boolean; spendCapCents?: number } = {}) { + const h = await createHarness() + const token = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner', email: 'a@example.com' }) + const auth = { authorization: `Bearer ${token}` } + const seats = options.seats ?? 2 + const checkout = await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team', seats }, headers: auth }) + expect(checkout.status).toBe(200) + const customer = [...h.stripe.customers.keys()][0]! + const subscription = h.stripe.putSubscription({ + customer, + accountId: 'org:o1', + basePrice: 'price_team_month', + quantity: seats, + periodStart: PERIOD_START, + periodEnd: PERIOD_END + }) + expect((await h.webhook(checkoutCompleted({ customer, subscription: subscription.id, accountId: 'org:o1' }))).body).toEqual({ + received: true + }) + if (options.overage) { + const body = options.spendCapCents === undefined ? { enabled: true } : { enabled: true, spendCapCents: options.spendCapCents } + expect((await h.call('POST', '/v1/billing/overage', { body, headers: auth })).status).toBe(200) + } + return { h, token, auth, customer, subscription } +} + +function usage(id: string, hours: number, extra: Record = {}) { + const endedAt = Date.now() - 60_000 + return { usageId: id, accountId: 'org:o1', sandboxId: 'sbx_1', machineSize: 'standard', startedAt: endedAt - hours * HOUR * 1000, endedAt, ...extra } +} + +describe('checkout and portal', () => { + it('creates one customer per account and a seat-quantity Checkout without overage', async () => { + const h = await createHarness() + const token = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'admin', email: 'a@example.com' }) + const auth = { authorization: `Bearer ${token}` } + const first = await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team', seats: 4, interval: 'year' }, headers: auth }) + expect(first.status).toBe(200) + expect(first.body.url).toMatch(/^https:\/\/checkout\.stripe\.com\//) + await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team', seats: 4 }, headers: auth }) + expect(h.stripe.customers.size).toBe(1) + + const [annual, monthly] = h.stripe.calls.filter((call) => call.path === '/v1/checkout/sessions') + expect(annual!.form.get('mode')).toBe('subscription') + expect(annual!.form.get('line_items[0][price]')).toBe('price_team_year') + expect(monthly!.form.get('line_items[0][price]')).toBe('price_team_month') + expect(annual!.form.get('line_items[0][quantity]')).toBe('4') + expect(annual!.form.has('line_items[0][adjustable_quantity][enabled]')).toBe(false) + expect(annual!.form.has('line_items[1][price]')).toBe(false) + expect(annual!.form.get('subscription_data[metadata][account_id]')).toBe('org:o1') + expect(annual!.form.has('subscription_data[trial_period_days]')).toBe(false) + expect(annual!.form.get('payment_method_collection')).toBe('always') + expect(annual!.form.get('success_url')).toContain('session_id={CHECKOUT_SESSION_ID}') + expect(annual!.headers.get('idempotency-key')).toMatch(/^checkout:org:o1:/) + }) + + it('lets only org owners and admins buy or manage the plan; personal accounts manage themselves', async () => { + const h = await createHarness() + const member = await h.desktopToken({ sub: 'u2', org: 'o1', orgRole: 'member' }) + const auth = { authorization: `Bearer ${member}` } + expect(await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team' }, headers: auth })).toEqual({ + status: 403, + body: { error: 'billing_admin_required' } + }) + expect((await h.call('POST', '/v1/billing/portal', { headers: auth })).status).toBe(403) + expect((await h.call('POST', '/v1/billing/overage', { body: { enabled: true }, headers: auth })).status).toBe(403) + + const solo = await h.desktopToken({ sub: 'u3' }) + expect((await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team' }, headers: { authorization: `Bearer ${solo}` } })).status).toBe(200) + expect(h.stripe.customers.values().next().value?.metadata.account_id).toBe('user:u3') + }) + + it('rejects bad tokens, unknown plans and a second subscription', async () => { + const { h, auth } = await subscribedTeam() + const wrongAudience = await h.desktopToken({ sub: 'u1' }, { audience: 'orca-relay' }) + const wrongPurpose = await h.desktopToken({ sub: 'u1' }, { purpose: 'host-control' }) + const tooOld = await h.desktopToken({ sub: 'u1' }, { ageSeconds: 20 * 60 }) + for (const bad of [wrongAudience, wrongPurpose, tooOld, 'garbage']) { + expect((await h.call('GET', '/v1/billing/entitlements', { headers: { authorization: `Bearer ${bad}` } })).status).toBe(401) + } + expect((await h.call('GET', '/v1/billing/entitlements')).status).toBe(401) + for (const body of [{ plan: 'pro' }, { plan: 'enterprise' }, { plan: 'team', interval: 'week' }, { plan: 'team', seats: 0 }]) { + expect((await h.call('POST', '/v1/billing/checkout', { body, headers: auth })).status).toBe(400) + } + expect(await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team' }, headers: auth })).toEqual({ + status: 409, + body: { error: 'already_subscribed' } + }) + }) + + it('opens the portal (with the configured portal configuration) for the linked customer only', async () => { + const { h, auth, customer } = await subscribedTeam() + h.env.STRIPE_PORTAL_CONFIGURATION = 'bpc_test_1' + const portal = await h.call('POST', '/v1/billing/portal', { headers: auth }) + expect(portal.body.url).toMatch(/^https:\/\/billing\.stripe\.com\//) + const call = h.stripe.calls.find((entry) => entry.path === '/v1/billing_portal/sessions')! + expect(call.form.get('customer')).toBe(customer) + expect(call.form.get('configuration')).toBe('bpc_test_1') + const stranger = await h.desktopToken({ sub: 'u9' }) + expect(await h.call('POST', '/v1/billing/portal', { headers: { authorization: `Bearer ${stranger}` } })).toEqual({ + status: 404, + body: { error: 'no_billing_account' } + }) + }) +}) + +describe('webhooks', () => { + it('rejects unsigned or mis-signed deliveries without touching state', async () => { + const { h, subscription, customer } = await subscribedTeam() + const event = invoiceEvent('invoice.paid', subscription.id, customer) + expect((await h.call('POST', '/v1/stripe/webhook', { rawBody: JSON.stringify(event) })).status).toBe(400) + expect((await h.webhook(event, 'whsec_wrong')).body).toEqual({ error: 'signature_mismatch' }) + expect(h.raw.prepare('SELECT COUNT(*) AS n FROM stripe_events WHERE event_id = ?').get(event.id)).toEqual({ n: 0 }) + }) + + it('processes an event id once', async () => { + const { h, subscription, customer } = await subscribedTeam() + const event = invoiceEvent('invoice.paid', subscription.id, customer) + expect((await h.webhook(event)).body).toEqual({ received: true }) + const reads = h.stripe.calls.filter((call) => call.method === 'GET').length + expect((await h.webhook(event)).body).toEqual({ duplicate: true }) + expect(h.stripe.calls.filter((call) => call.method === 'GET').length).toBe(reads) + }) + + it('applies Stripe current state, so out-of-order deliveries cannot resurrect a canceled plan', async () => { + const { h, auth, subscription } = await subscribedTeam() + const staleUpdate = stripeEvent('customer.subscription.updated', { ...subscription, status: 'active' }) + subscription.status = 'canceled' + await h.webhook(stripeEvent('customer.subscription.deleted', { ...subscription })) + await h.webhook(staleUpdate) + const entitlement = await h.call('GET', '/v1/billing/entitlements', { headers: auth }) + expect(entitlement.body).toMatchObject({ status: 'canceled', canStartSandbox: false, reason: 'subscription_inactive' }) + }) + + 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]!.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 }) + expect(entitlement.body).toMatchObject({ seats: 5, interval: 'year', includedSeconds: 300 * HOUR }) + expect(entitlement.body.limits).toMatchObject({ maxConcurrentSandboxes: 15, idleSleepMinutes: 15 }) + // Annual plans still get a monthly allowance window. + const window = entitlement.body.usageWindow + expect(window.end - window.start).toBeLessThanOrEqual(31 * 24 * HOUR * 1000) + }) + + it('marks past_due from invoice.payment_failed (new invoice.parent shape)', async () => { + const { h, auth, subscription, customer } = await subscribedTeam() + subscription.status = 'past_due' + expect((await h.webhook(invoiceEvent('invoice.payment_failed', subscription.id, customer))).body).toEqual({ received: true }) + const entitlement = await h.call('GET', '/v1/billing/entitlements', { headers: auth }) + expect(entitlement.body).toMatchObject({ status: 'past_due', canStartSandbox: false, reason: 'payment_past_due' }) + }) + + it('acknowledges but ignores live-mode events and subscriptions on customers it did not create', async () => { + const { h, subscription } = await subscribedTeam() + expect((await h.webhook(stripeEvent('customer.subscription.updated', subscription, { livemode: true }))).body).toEqual({ + ignored: 'livemode' + }) + const foreign = h.stripe.putSubscription({ + customer: 'cus_someone_else', + accountId: 'org:o1', + basePrice: 'price_team_year', + quantity: 50, + periodStart: PERIOD_START, + periodEnd: PERIOD_END + }) + // Metadata naming org:o1 is not enough: the customer must be the one billing created for it. + expect((await h.webhook(stripeEvent('customer.subscription.created', foreign))).body).toEqual({ ignored: 'unknown_customer' }) + expect(h.raw.prepare('SELECT seats FROM subscriptions WHERE account_id = ?').all('org:o1')).toEqual([{ seats: 2 }]) + }) + + it('returns 500 (so Stripe retries) when Stripe cannot be read, and succeeds on redelivery', async () => { + const { h, subscription, customer } = await subscribedTeam() + const event = invoiceEvent('invoice.paid', 'sub_missing', customer) + expect((await h.webhook(event)).status).toBe(500) + h.stripe.subscriptions.set('sub_missing', { ...subscription, id: 'sub_missing' }) + expect((await h.webhook(event)).body).toEqual({ received: true }) + }) + + it('ignores event types it does not handle', async () => { + const h = await createHarness() + expect((await h.webhook(stripeEvent('customer.created', { id: 'cus_1' }))).body).toEqual({ ignored: 'event_type' }) + }) +}) + +describe('entitlements, overage and usage', () => { + it('reports pooled hours, warnings and limits for the desktop', async () => { + const { h, auth } = await subscribedTeam() + expect((await h.controlPlane('/v1/internal/usage', usage('u-1', 10))).status).toBe(200) + const entitlement = await h.call('GET', '/v1/billing/entitlements', { headers: auth }) + expect(entitlement.body).toMatchObject({ + accountId: 'org:o1', + plan: 'team', + planLabel: 'Team', + status: 'active', + interval: 'month', + seats: 2, + includedSeconds: 120 * HOUR, + usedSeconds: 10 * HOUR, + remainingIncludedSeconds: 110 * HOUR, + usageWarningPercent: null, + overage: { optedIn: false, active: false, spendCapCents: 2500, spentCents: 0, centsPerHour: 75 }, + limits: { maxConcurrentSandboxes: 6, machineSizes: ['standard', 'large'], idleSleepMinutes: 15 }, + canStartSandbox: true, + canManageBilling: true, + period: { start: PERIOD_START * 1000, end: PERIOD_END * 1000 } + }) + expect(entitlement.body.offers.team).toMatchObject({ monthlyPriceLabel: '$50/seat/mo', annualPriceLabel: '$480/seat/yr' }) + 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)) + } + expect((await h.call('GET', '/v1/billing/entitlements', { headers: auth })).body.usageWarningPercent).toBe(80) + }) + + it('is idempotent per usageId and burns Large at 2x', async () => { + const { h } = await subscribedTeam() + const first = await h.controlPlane('/v1/internal/usage', usage('u-1', 1, { machineSize: 'large' })) + const again = await h.controlPlane('/v1/internal/usage', usage('u-1', 1, { machineSize: 'large' })) + expect(first.body).toMatchObject({ recorded: true, weightedSeconds: 2 * HOUR }) + expect(again.body).toMatchObject({ recorded: false }) + expect(h.raw.prepare('SELECT SUM(weighted_seconds) AS s FROM usage_events').get()).toEqual({ s: 2 * HOUR }) + }) + + it('without opt-in, stops new starts at the included hours and meters nothing', async () => { + const { h, auth } = await subscribedTeam() + for (let i = 0; i < 5; i++) { + await h.controlPlane('/v1/internal/usage', usage(`u-${i}`, 24)) + } + const entitlement = await h.call('GET', '/v1/billing/entitlements', { headers: auth }) + expect(entitlement.body).toMatchObject({ usageWarningPercent: 100, canStartSandbox: false, reason: 'included_hours_exhausted' }) + expect(h.stripe.meterEvents).toHaveLength(0) + expect(h.stripe.calls.some((call) => call.path === '/v1/subscription_items')).toBe(false) + }) + + it('opting in adds the metered item once, then meters only the overage past included hours', async () => { + const { h, auth, customer, subscription } = await subscribedTeam({ overage: true }) + expect(subscription.items.data.map((item) => item.price.id)).toEqual(['price_team_month', 'price_overage']) + await h.call('POST', '/v1/billing/overage', { body: { enabled: true }, headers: auth }) + expect(h.stripe.calls.filter((call) => call.path === '/v1/subscription_items')).toHaveLength(1) + // The item's webhook must not drop the item id. + await h.webhook(stripeEvent('customer.subscription.updated', subscription)) + + for (let i = 0; i < 5; i++) { + await h.controlPlane('/v1/internal/usage', usage(`u-${i}`, 24)) + } + // Exactly the 120 included hours: nothing billable yet. + expect(h.stripe.meterEvents).toHaveLength(0) + const crossing = await h.controlPlane('/v1/internal/usage', usage('u-5', 4)) + expect(crossing.body).toMatchObject({ overageSeconds: 4 * HOUR, canStartSandbox: true }) + await h.controlPlane('/v1/internal/usage', usage('u-5', 4)) + expect(h.stripe.meterEvents).toEqual([ + 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 }) + }) + + it('stops metering and new starts at the spend cap; admins can raise it', async () => { + // $3 cap = 4 overage hours at $0.75. + 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)]) + 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' }) + + expect((await h.call('POST', '/v1/billing/overage', { body: { enabled: true, spendCapCents: 10_000_000 }, headers: auth })).body).toEqual({ + error: 'spend_cap_too_high' + }) + await h.call('POST', '/v1/billing/overage', { body: { enabled: true, spendCapCents: 2500 }, headers: auth }) + expect((await check()).body).toMatchObject({ allow: true }) + }) + + 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 }) + for (let i = 0; i < 6; i++) { + await h.controlPlane('/v1/internal/usage', usage(`u-${i}`, 24)) + } + expect(h.stripe.meterEvents).toHaveLength(0) + expect((await h.call('GET', '/v1/billing/entitlements', { headers: auth })).body).toMatchObject({ reason: 'included_hours_exhausted' }) + }) + + it('retries failed meter reports from the outbox', async () => { + const { h } = await subscribedTeam({ overage: true }) + for (let i = 0; i < 5; i++) { + await h.controlPlane('/v1/internal/usage', usage(`u-${i}`, 24)) + } + h.stripe.failNextMeterEvents(1) + await h.controlPlane('/v1/internal/usage', usage('u-9', 2)) + expect(h.stripe.meterEvents).toHaveLength(0) + expect(h.raw.prepare('SELECT status, attempts FROM meter_reports').get()).toEqual({ status: 'pending', attempts: 1 }) + const { flushMeterReports } = await import('../src/usage') + const { createStripeClient } = await import('../src/stripe') + expect(await flushMeterReports(h.env, createStripeClient(h.env, h.stripe.fetchImpl), Date.now())).toEqual({ sent: 1, failed: 0 }) + expect(h.stripe.meterEvents[0]?.['payload[value]']).toBe(String(2 * HOUR)) + }) + + it('gates starts by machine size and per-seat concurrency for the control plane', async () => { + const { h } = await subscribedTeam() + const check = (body: Record) => h.controlPlane('/v1/internal/entitlements/check', { accountId: 'org:o1', ...body }) + expect((await check({ machineSize: 'large', runningSandboxes: 5 })).body).toMatchObject({ allow: true, reason: null, seats: 2, maxConcurrentSandboxes: 6, maxConcurrentSandboxesPerMember: 3 }) + expect((await check({ machineSize: 'standard', runningSandboxes: 6 })).body).toMatchObject({ allow: false, reason: 'concurrency_limit' }) + expect((await check({ machineSize: 'xlarge', runningSandboxes: 0 })).status).toBe(400) + const unknown = await h.controlPlane('/v1/internal/entitlements/check', { accountId: 'org:nobody', machineSize: 'standard', runningSandboxes: 0 }) + expect(unknown.body).toMatchObject({ allow: false, reason: 'no_subscription' }) + }) + + it('refuses overage opt-in without an open subscription', async () => { + const h = await createHarness() + const token = await h.desktopToken({ sub: 'u1' }) + expect(await h.call('POST', '/v1/billing/overage', { body: { enabled: true }, headers: { authorization: `Bearer ${token}` } })).toEqual({ + status: 409, + body: { error: 'no_subscription' } + }) + }) + + it('authenticates the control plane and validates segments', async () => { + const { h } = await subscribedTeam() + expect((await h.controlPlane('/v1/internal/usage', usage('u-1', 1), 'cp_test_wrong_but_long_enough_000000000')).status).toBe(401) + const desktop = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner' }) + expect((await h.controlPlane('/v1/internal/usage', usage('u-1', 1), desktop)).status).toBe(401) + expect((await h.controlPlane('/v1/internal/usage', usage('u-1', 25))).body).toEqual({ error: 'segment_too_long' }) + expect((await h.controlPlane('/v1/internal/usage', usage('u-1', 0))).body).toEqual({ error: 'empty_segment' }) + const future = Date.now() + 3600_000 + expect((await h.controlPlane('/v1/internal/usage', usage('u-1', 1, { startedAt: future - 1000, endedAt: future }))).body).toEqual({ + error: 'segment_in_future' + }) + expect((await h.controlPlane('/v1/internal/usage', { ...usage('u-1', 1), accountId: 'org:o1; DROP' })).status).toBe(400) + }) + + it('refuses to run with a live Stripe key', async () => { + const h = await createHarness({ STRIPE_SECRET_KEY: 'sk_live_nope' }) + const token = await h.desktopToken({ sub: 'u1' }) + expect(await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team' }, headers: { authorization: `Bearer ${token}` } })).toEqual({ + status: 503, + body: { error: 'billing_misconfigured' } + }) + }) +}) diff --git a/cloud/apps/billing/test/d1-fake.ts b/cloud/apps/billing/test/d1-fake.ts new file mode 100644 index 00000000000..906896cc54f --- /dev/null +++ b/cloud/apps/billing/test/d1-fake.ts @@ -0,0 +1,64 @@ +// A D1Database over node:sqlite for tests: same SQL dialect (SQLite), same prepare/bind/first/ +// all/run/batch surface, batch() in one transaction like D1. +import { readFileSync, readdirSync } from 'node:fs' +import { DatabaseSync } from 'node:sqlite' +import { fileURLToPath } from 'node:url' + +type Param = string | number | null + +class FakeStatement { + constructor( + private readonly db: DatabaseSync, + readonly sql: string, + readonly params: Param[] = [] + ) {} + + bind(...params: unknown[]): FakeStatement { + for (const param of params) { + if (param === undefined || typeof param === 'boolean' || typeof param === 'object' && param !== null) { + throw new Error(`D1 cannot bind ${typeof param}`) + } + } + return new FakeStatement(this.db, this.sql, params as Param[]) + } + + async first(): Promise { + return (this.db.prepare(this.sql).get(...this.params) as T | undefined) ?? null + } + + async all(): Promise<{ results: T[]; success: true; meta: { changes: number } }> { + return { results: this.db.prepare(this.sql).all(...this.params) as T[], success: true, meta: { changes: 0 } } + } + + runSync(): { success: true; meta: { changes: number } } { + const result = this.db.prepare(this.sql).run(...this.params) + return { success: true, meta: { changes: Number(result.changes) } } + } + + async run() { + return this.runSync() + } +} + +export function createFakeD1(): { db: D1Database; raw: DatabaseSync } { + const raw = new DatabaseSync(':memory:') + const dir = fileURLToPath(new URL('../migrations/', import.meta.url).href) + for (const file of readdirSync(dir).filter((name) => name.endsWith('.sql')).sort()) { + raw.exec(readFileSync(`${dir}${file}`, 'utf8')) + } + const db = { + prepare: (sql: string) => new FakeStatement(raw, sql), + async batch(statements: FakeStatement[]) { + raw.exec('BEGIN') + try { + const results = statements.map((statement) => statement.runSync()) + raw.exec('COMMIT') + return results + } catch (error) { + raw.exec('ROLLBACK') + throw error + } + } + } + return { db: db as unknown as D1Database, raw } +} diff --git a/cloud/apps/billing/test/harness.ts b/cloud/apps/billing/test/harness.ts new file mode 100644 index 00000000000..1572e95117b --- /dev/null +++ b/cloud/apps/billing/test/harness.ts @@ -0,0 +1,85 @@ +import { SignJWT, exportJWK, generateKeyPair } from 'jose' +import type { Env } from '../src/env' +import { handleRequest } from '../src/index' +import { createStripeClient } from '../src/stripe' +import { createFakeD1 } from './d1-fake' +import { createFakeStripe, signStripePayload } from './stripe-fake' + +export const WEBHOOK_SECRET = 'whsec_test_local_fixture_secret' +export const CONTROL_TOKEN = 'cp_test_0123456789abcdef0123456789abcdef' + +export async function createHarness(overrides: Partial = {}) { + const { privateKey, publicKey } = await generateKeyPair('ES256') + const jwk = { ...(await exportJWK(publicKey)), kid: `k${Math.random()}`, alg: 'ES256', use: 'sig' } + const { db, raw } = createFakeD1() + const stripe = createFakeStripe() + const env: Env = { + BILLING_DB: db, + STRIPE_SECRET_KEY: 'rk_test_fixture', + STRIPE_WEBHOOK_SECRET: WEBHOOK_SECRET, + STRIPE_API_BASE: 'https://stripe.test', + STRIPE_PRICE_TEAM_MONTHLY: 'price_team_month', + STRIPE_PRICE_TEAM_ANNUAL: 'price_team_year', + STRIPE_PRICE_OVERAGE: 'price_overage', + STRIPE_METER_EVENT_NAME: 'orca_sandbox_seconds', + BILLING_RETURN_URL: 'https://onorca.dev/billing/return', + BILLING_JWKS: JSON.stringify({ keys: [jwk] }), + BILLING_JWT_AUDIENCE: 'orca-billing', + BILLING_AUTH_ISSUER: 'https://login.onorca.dev', + CONTROL_PLANE_TOKEN: CONTROL_TOKEN, + ...overrides + } + let now = Date.now() + // Live org member counts from auth; null = not configured (the default). + let membership: (orgId: string) => Promise = async () => null + const waits: Promise[] = [] + const ctx = { waitUntil: (promise: Promise) => waits.push(promise), passThroughOnException() {} } as unknown as ExecutionContext + + async function call(method: string, path: string, init: { body?: unknown; rawBody?: string; headers?: Record } = {}) { + const request = new Request(`https://billing.test${path}`, { + method, + headers: { 'content-type': 'application/json', ...init.headers }, + body: init.rawBody ?? (init.body === undefined ? undefined : JSON.stringify(init.body)) + }) + const response = await handleRequest(request, env, ctx, { + stripe: createStripeClient(env, stripe.fetchImpl), + now: () => now, + membership: (orgId) => membership(orgId) + }) + await Promise.all(waits.splice(0)) + return { status: response.status, body: (await response.json()) as Record } + } + + return { + env, + raw, + stripe, + call, + setNow(value: number) { + now = value + }, + setMembership(source: (orgId: string) => Promise) { + membership = source + }, + async desktopToken(claims: { sub: string; org?: string; orgRole?: string; email?: string; orgMembers?: number }, options: { audience?: string; purpose?: string; ageSeconds?: number } = {}) { + const iat = Math.floor(now / 1000) - (options.ageSeconds ?? 0) + return new SignJWT({ ...claims, purpose: options.purpose ?? 'billing' }) + .setProtectedHeader({ alg: 'ES256', kid: jwk.kid }) + .setIssuer('https://login.onorca.dev') + .setAudience(options.audience ?? 'orca-billing') + .setIssuedAt(iat) + .setExpirationTime(iat + 900) + .sign(privateKey) + }, + async webhook(event: unknown, secret = WEBHOOK_SECRET) { + const rawBody = JSON.stringify(event) + return call('POST', '/v1/stripe/webhook', { + rawBody, + headers: { 'stripe-signature': signStripePayload(rawBody, secret, Math.floor(now / 1000)) } + }) + }, + controlPlane(path: string, body: unknown, token = CONTROL_TOKEN) { + return call('POST', path, { body, headers: { authorization: `Bearer ${token}` } }) + } + } +} diff --git a/cloud/apps/billing/test/seats-grace.test.ts b/cloud/apps/billing/test/seats-grace.test.ts new file mode 100644 index 00000000000..add89b2e021 --- /dev/null +++ b/cloud/apps/billing/test/seats-grace.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from 'vitest' +import { checkoutCompleted, invoiceEvent, stripeEvent } from './stripe-fake' +import { createHarness, CONTROL_TOKEN } from './harness' + +const HOUR = 3600 +const PERIOD_START = Math.floor(Date.now() / 1000) - 5 * 24 * HOUR +const PERIOD_END = PERIOD_START + 30 * 24 * HOUR +const MEMBERSHIP_TOKEN = 'ms_test_0123456789abcdef0123456789abcdef' + +async function team(seats = 2) { + const h = await createHarness({ MEMBERSHIP_SYNC_TOKEN: MEMBERSHIP_TOKEN }) + const owner = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner' }) + await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team', seats }, headers: { authorization: `Bearer ${owner}` } }) + const customer = [...h.stripe.customers.keys()][0]! + const subscription = h.stripe.putSubscription({ + customer, + accountId: 'org:o1', + basePrice: 'price_team_month', + quantity: seats, + periodStart: PERIOD_START, + periodEnd: PERIOD_END + }) + await h.webhook(checkoutCompleted({ customer, subscription: subscription.id, accountId: 'org:o1' })) + return { h, customer, subscription } +} + +const seatUpdates = (h: Awaited>['h']) => + h.stripe.calls + .filter((call) => call.method === 'POST' && call.path.startsWith('/v1/subscription_items/')) + .map((call) => ({ quantity: call.form.get('quantity'), proration: call.form.get('proration_behavior') })) + +describe('seats follow membership', () => { + it('adds are charged now; removals wait for renewal; re-adding within the period is free', async () => { + const { h } = await team(2) + const push = (members: number, token = MEMBERSHIP_TOKEN) => + h.call('POST', '/v1/internal/seats', { body: { accountId: 'org:o1', members }, headers: { authorization: `Bearer ${token}` } }) + const owner = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner' }) + const entitlement = async () => + (await h.call('GET', '/v1/billing/entitlements', { headers: { authorization: `Bearer ${owner}` } })).body + + expect((await push(3, CONTROL_TOKEN)).status).toBe(401) + expect((await push(3)).body).toEqual({ status: 'increased', seats: 3, previous: 2 }) + expect((await push(3)).body).toEqual({ status: 'unchanged', seats: 3 }) + // Two members leave mid-period: still billed (and entitled) for the 3 seats paid this period. + expect((await push(1)).body).toEqual({ status: 'deferred', seats: 3, members: 1, effectiveAt: PERIOD_END * 1000 }) + expect(await entitlement()).toMatchObject({ + seats: 3, + members: 1, + seatChange: { seatsAtRenewal: 1, effectiveAt: PERIOD_END * 1000 }, + includedSeconds: 180 * HOUR + }) + // Churn: they come back the same period. Already paid for, so no new charge. + expect((await push(3)).body).toEqual({ status: 'unchanged', seats: 3 }) + expect(seatUpdates(h)).toEqual([{ quantity: '3', proration: 'always_invoice' }]) + + // Near renewal, seats come down to the team (no credit for the current period). Min 1 seat. + h.setNow(PERIOD_END * 1000 - 3600_000) + expect((await push(0)).body).toEqual({ status: 'reduced_at_renewal', seats: 1, previous: 3, effectiveAt: PERIOD_END * 1000 }) + expect(seatUpdates(h)).toEqual([ + { quantity: '3', proration: 'always_invoice' }, + { quantity: '1', proration: 'none' } + ]) + expect(await entitlement()).toMatchObject({ seats: 1, includedSeconds: 60 * HOUR, limits: { maxConcurrentSandboxes: 3 } }) + }) + + it('Checkout bills the live member count from auth, never fewer than 1 seat', async () => { + const h = await createHarness() + const owner = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner' }) + const seen: string[] = [] + h.setMembership(async (orgId) => { + seen.push(orgId) + return 0 + }) + await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team', seats: 9 }, headers: { authorization: `Bearer ${owner}` } }) + expect(seen).toEqual(['o1']) + expect(h.stripe.calls.find((call) => call.path === '/v1/checkout/sessions')?.form.get('line_items[0][quantity]')).toBe('1') + }) + + it('fails closed when auth is configured but cannot answer', async () => { + const h = await createHarness() + const owner = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner' }) + const { MembershipUnavailableError } = await import('../src/membership') + h.setMembership(async () => { + throw new MembershipUnavailableError('auth answered 500') + }) + expect(await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team' }, headers: { authorization: `Bearer ${owner}` } })).toEqual({ + status: 503, + body: { error: 'membership_unavailable' } + }) + expect(h.stripe.calls).toHaveLength(0) + }) + + it('the reconcile cron heals a missed membership push', async () => { + const { h } = await team(2) + const { reconcileSeats } = await import('../src/seats') + const { createStripeClient } = await import('../src/stripe') + const stripe = createStripeClient(h.env, h.stripe.fetchImpl) + expect(await reconcileSeats(h.env, stripe, async () => 5, Date.now())).toEqual({ checked: 1, changed: 1, failed: 0 }) + expect(await reconcileSeats(h.env, stripe, async () => 5, Date.now())).toEqual({ checked: 1, changed: 0, failed: 0 }) + expect(seatUpdates(h)).toEqual([{ quantity: '5', proration: 'always_invoice' }]) + }) + + it('the cron applies a deferred decrease in the true-down window from the last pushed count', async () => { + const { h } = await team(4) + await h.call('POST', '/v1/internal/seats', { + body: { accountId: 'org:o1', members: 2 }, + headers: { authorization: `Bearer ${MEMBERSHIP_TOKEN}` } + }) + const { reconcileSeats } = await import('../src/seats') + const { createStripeClient } = await import('../src/stripe') + const stripe = createStripeClient(h.env, h.stripe.fetchImpl) + // Mid-period: nothing changes. Without an auth pull, the stored count is used. + expect(await reconcileSeats(h.env, stripe, async () => null, Date.now())).toEqual({ checked: 1, changed: 0, failed: 0 }) + expect(await reconcileSeats(h.env, stripe, async () => null, PERIOD_END * 1000 - 60_000)).toEqual({ + checked: 1, + changed: 1, + failed: 0 + }) + expect(seatUpdates(h)).toEqual([{ quantity: '2', proration: 'none' }]) + }) + + it('billing admin actions need a freshly minted token (role re-read by auth at mint)', async () => { + const { h } = await team(2) + const stale = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner' }, { ageSeconds: 7 * 60 }) + const auth = { authorization: `Bearer ${stale}` } + expect((await h.call('GET', '/v1/billing/entitlements', { headers: auth })).status).toBe(200) + expect((await h.call('POST', '/v1/billing/portal', { headers: auth })).status).toBe(401) + expect((await h.call('POST', '/v1/billing/overage', { body: { enabled: true }, headers: auth })).status).toBe(401) + expect((await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team' }, headers: auth })).status).toBe(401) + }) + + it('rejects malformed or missing member counts instead of billing 0 seats', async () => { + const { h } = await team(2) + for (const body of [{ accountId: 'org:o1' }, { accountId: 'org:o1', members: -1 }, { accountId: 'org:o1', members: 'many' }, { accountId: 'user:u1', members: 3 }]) { + const response = await h.call('POST', '/v1/internal/seats', { body, headers: { authorization: `Bearer ${MEMBERSHIP_TOKEN}` } }) + expect(response.status).toBe(400) + } + expect(seatUpdates(h)).toEqual([]) + }) + + it('does nothing without an open subscription', async () => { + const h = await createHarness({ MEMBERSHIP_SYNC_TOKEN: MEMBERSHIP_TOKEN }) + const pushed = await h.call('POST', '/v1/internal/seats', { + body: { accountId: 'org:nobody', members: 5 }, + headers: { authorization: `Bearer ${MEMBERSHIP_TOKEN}` } + }) + expect(pushed.body).toEqual({ status: 'no_subscription' }) + expect(h.stripe.calls.filter((call) => call.method === 'POST')).toHaveLength(0) + }) +}) + +describe('payment failure grace', () => { + it('blocks new starts at once, suspends running after 4 days, resumes when paid', async () => { + const { h, customer, subscription } = await team(2) + const failedAt = Date.now() + h.setNow(failedAt) + subscription.status = 'past_due' + await h.webhook(invoiceEvent('invoice.payment_failed', subscription.id, customer)) + const check = () => + h.controlPlane('/v1/internal/entitlements/check', { accountId: 'org:o1', machineSize: 'standard', runningSandboxes: 0 }) + const suspensions = () => h.call('GET', '/v1/internal/suspensions', { headers: { authorization: `Bearer ${CONTROL_TOKEN}` } }) + + expect((await check()).body).toMatchObject({ allow: false, reason: 'payment_past_due', suspendRunning: false }) + expect((await check()).body.entitlement.payment).toEqual({ + pastDueSince: failedAt, + graceEndsAt: failedAt + 96 * HOUR * 1000, + suspendRunning: false + }) + expect((await suspensions()).body).toEqual({ accounts: [] }) + + // A retry failing again later must not restart the grace clock. + h.setNow(failedAt + 50 * HOUR * 1000) + await h.webhook(invoiceEvent('invoice.payment_failed', subscription.id, customer)) + h.setNow(failedAt + 97 * HOUR * 1000) + expect((await check()).body).toMatchObject({ allow: false, reason: 'payment_grace_expired', suspendRunning: true }) + expect((await suspensions()).body.accounts).toEqual([ + { accountId: 'org:o1', pastDueSince: failedAt, graceEndsAt: failedAt + 96 * HOUR * 1000 } + ]) + const usage = await h.controlPlane('/v1/internal/usage', { + usageId: 'u-1', + accountId: 'org:o1', + sandboxId: 's', + machineSize: 'standard', + startedAt: failedAt + 96 * HOUR * 1000 - 60_000, + endedAt: failedAt + 96 * HOUR * 1000 + }) + expect(usage.body).toMatchObject({ canStartSandbox: false, suspendRunning: true }) + + // Retries exhausted (unpaid) stays suspended; paying restores everything. + subscription.status = 'unpaid' + await h.webhook(stripeEvent('customer.subscription.updated', subscription)) + expect((await check()).body).toMatchObject({ reason: 'payment_grace_expired' }) + subscription.status = 'active' + await h.webhook(invoiceEvent('invoice.paid', subscription.id, customer)) + expect((await check()).body).toMatchObject({ allow: true, suspendRunning: false }) + expect((await suspensions()).body).toEqual({ accounts: [] }) + }) + + it('takes the grace length from config', async () => { + const h = await createHarness({ BILLING_PLANS: JSON.stringify({ team: { paymentGraceHours: 0 } }) }) + const owner = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner' }) + await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team' }, headers: { authorization: `Bearer ${owner}` } }) + const customer = [...h.stripe.customers.keys()][0]! + const subscription = h.stripe.putSubscription({ + customer, + accountId: 'org:o1', + basePrice: 'price_team_month', + status: 'past_due', + periodStart: PERIOD_START, + periodEnd: PERIOD_END + }) + await h.webhook(stripeEvent('customer.subscription.updated', subscription)) + const entitlement = await h.call('GET', '/v1/billing/entitlements', { headers: { authorization: `Bearer ${owner}` } }) + expect(entitlement.body).toMatchObject({ reason: 'payment_grace_expired', payment: { suspendRunning: true } }) + }) +}) + +describe('per-member limits and attribution', () => { + it('caps concurrent sandboxes per member so one seat cannot run the team', async () => { + const { h } = await team(3) + const check = (runningSandboxes: number, runningSandboxesForUser?: number) => + h.controlPlane('/v1/internal/entitlements/check', { + accountId: 'org:o1', + machineSize: 'standard', + runningSandboxes, + userId: 'u2', + ...(runningSandboxesForUser === undefined ? {} : { runningSandboxesForUser }) + }) + expect((await check(2, 2)).body).toMatchObject({ allow: true }) + expect((await check(3, 3)).body).toMatchObject({ allow: false, reason: 'member_concurrency_limit' }) + // The org-wide cap (3 per seat x 3 seats) still applies. + expect((await check(9, 0)).body).toMatchObject({ allow: false, reason: 'concurrency_limit' }) + }) + + it('records which member used the hours', async () => { + const { h } = await team(2) + const endedAt = Date.now() - 60_000 + await h.controlPlane('/v1/internal/usage', { + usageId: 'u-attr', + accountId: 'org:o1', + sandboxId: 's1', + machineSize: 'standard', + startedAt: endedAt - 3600_000, + endedAt, + userId: 'usr_alice' + }) + expect(h.raw.prepare('SELECT user_id FROM usage_events WHERE usage_id = ?').get('u-attr')).toEqual({ user_id: 'usr_alice' }) + }) +}) diff --git a/cloud/apps/billing/test/stripe-client.test.ts b/cloud/apps/billing/test/stripe-client.test.ts new file mode 100644 index 00000000000..29b13759bec --- /dev/null +++ b/cloud/apps/billing/test/stripe-client.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import type { Env } from '../src/env' +import { LiveModeRefusedError, assertKeyMode, createStripeClient, encodeForm } from '../src/stripe' + +const env = (key: string, extra: Partial = {}) => ({ STRIPE_SECRET_KEY: key, ...extra }) as Env + +describe('stripe client', () => { + it('encodes nested params with Stripe bracket syntax and drops undefined', () => { + expect( + decodeURIComponent( + encodeForm({ mode: 'subscription', line_items: [{ price: 'p', quantity: 2 }, { price: 'o' }], metadata: { a: 'b' }, x: undefined }) + ) + ).toBe('mode=subscription&line_items[0][price]=p&line_items[0][quantity]=2&line_items[1][price]=o&metadata[a]=b') + }) + + it('refuses live-mode keys unless live mode is explicitly enabled', () => { + expect(() => assertKeyMode(env('sk_live_x'))).toThrow(LiveModeRefusedError) + expect(() => assertKeyMode(env('rk_live_x'))).toThrow(LiveModeRefusedError) + expect(() => assertKeyMode(env('rk_live_x', { STRIPE_ALLOW_LIVEMODE: '1' }))).toThrow(LiveModeRefusedError) + expect(() => assertKeyMode(env('rk_live_x', { STRIPE_ALLOW_LIVEMODE: 'true' }))).not.toThrow() + expect(() => assertKeyMode(env('rk_test_x'))).not.toThrow() + expect(() => assertKeyMode(env(''))).toThrow('stripe_key_missing') + }) + + it('never sends a request with a live key', async () => { + let called = false + const client = createStripeClient(env('sk_live_x'), async () => { + called = true + return Response.json({}) + }) + await expect(client.retrieveSubscription('sub_1')).rejects.toThrow(LiveModeRefusedError) + expect(called).toBe(false) + }) + + it('pins the API version, sends an idempotency key and refuses redirects', async () => { + let seen: RequestInit | undefined + const client = createStripeClient(env('rk_test_x'), async (_url, init) => { + seen = init + return Response.json({ id: 'cus_1' }) + }) + await client.createCustomer({ accountId: 'org:o1' }) + const headers = new Headers(seen?.headers) + expect(headers.get('stripe-version')).toBe('2025-09-30.clover') + expect(headers.get('idempotency-key')).toBe('customer-create:org:o1') + expect(seen?.redirect).toBe('manual') + }) +}) diff --git a/cloud/apps/billing/test/stripe-fake.ts b/cloud/apps/billing/test/stripe-fake.ts new file mode 100644 index 00000000000..3a2568907a5 --- /dev/null +++ b/cloud/apps/billing/test/stripe-fake.ts @@ -0,0 +1,192 @@ +// In-memory Stripe API behind a fetch function, plus event fixtures shaped like Stripe's +// (API version 2025-09-30.clover: periods on subscription items, invoice.parent.subscription_details). +// The real client in src/stripe.ts talks to it, so form encoding and headers are exercised too. +import { createHmac } from 'node:crypto' + +export type FakeSubscription = { + id: string + object: 'subscription' + customer: string + status: string + 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 } }> } +} + +export type RecordedCall = { method: string; path: string; form: URLSearchParams; headers: Headers } + +export function createFakeStripe() { + const calls: RecordedCall[] = [] + const customers = new Map }>() + const subscriptions = new Map() + const idempotent = new Map() + const meterEvents: Array> = [] + let nextId = 1 + let failMeterEvents = 0 + + const fetchImpl: typeof fetch = async (input, init) => { + const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url) + const headers = new Headers(init?.headers) + const form = new URLSearchParams(typeof init?.body === 'string' ? init.body : '') + const method = init?.method ?? 'GET' + calls.push({ method, path: url.pathname, form, headers }) + if (!headers.get('authorization')?.startsWith('Bearer rk_test_')) { + return Response.json({ error: { message: 'Invalid API Key provided' } }, { status: 401 }) + } + const key = headers.get('idempotency-key') + if (method === 'POST' && key && idempotent.has(key)) { + return Response.json(idempotent.get(key)) + } + const respond = (body: unknown) => { + if (method === 'POST' && key) { + idempotent.set(key, body) + } + return Response.json(body) + } + if (method === 'POST' && url.pathname === '/v1/customers') { + const id = `cus_test_${nextId++}` + const customer = { id, metadata: { account_id: form.get('metadata[account_id]') ?? '' } } + customers.set(id, customer) + return respond(customer) + } + if (method === 'POST' && url.pathname === '/v1/checkout/sessions') { + const id = `cs_test_${nextId++}` + return respond({ id, object: 'checkout.session', url: `https://checkout.stripe.com/c/pay/${id}` }) + } + if (method === 'POST' && url.pathname === '/v1/billing_portal/sessions') { + const id = `bps_test_${nextId++}` + return respond({ id, url: `https://billing.stripe.com/p/session/${id}` }) + } + if (method === 'GET' && url.pathname.startsWith('/v1/subscriptions/')) { + const subscription = subscriptions.get(decodeURIComponent(url.pathname.slice('/v1/subscriptions/'.length))) + return subscription + ? Response.json(subscription) + : Response.json({ error: { code: 'resource_missing', message: 'No such subscription' } }, { status: 404 }) + } + if (method === 'POST' && url.pathname.startsWith('/v1/subscription_items/')) { + 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) { + item.quantity = Number(form.get('quantity')) + return respond({ id: item.id, quantity: item.quantity }) + } + } + 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 = { + id: `si_test_${nextId++}`, + price: { id: form.get('price') ?? '' }, + current_period_start: base.current_period_start, + current_period_end: base.current_period_end + } + subscription.items.data.push(item) + return respond(item) + } + if (method === 'POST' && url.pathname === '/v1/billing/meter_events') { + if (failMeterEvents > 0) { + failMeterEvents-- + return Response.json({ error: { message: 'temporarily unavailable' } }, { status: 503 }) + } + meterEvents.push(Object.fromEntries(form)) + return respond({ object: 'billing.meter_event', identifier: form.get('identifier') }) + } + return Response.json({ error: { message: `unhandled ${method} ${url.pathname}` } }, { status: 404 }) + } + + return { + fetchImpl, + calls, + customers, + subscriptions, + meterEvents, + failNextMeterEvents(count: number) { + failMeterEvents = count + }, + /** What Stripe holds after Checkout completes for `customer` (the test plays the browser). */ + putSubscription(args: { + customer: string + accountId: string + basePrice: string + overagePrice?: string + quantity?: number + status?: string + periodStart: number + periodEnd: number + livemode?: boolean + }): FakeSubscription { + 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 } + ] + if (args.overagePrice) { + data.push({ id: `si_test_${nextId++}`, price: { id: args.overagePrice }, ...period }) + } + const subscription: FakeSubscription = { + id, + object: 'subscription', + customer: args.customer, + status: args.status ?? 'active', + cancel_at_period_end: false, + livemode: args.livemode ?? false, + metadata: { account_id: args.accountId }, + items: { object: 'list', data } + } + subscriptions.set(id, subscription) + return subscription + } + } +} + +let eventCounter = 1 + +export function stripeEvent(type: string, object: Record, options: { livemode?: boolean; id?: string } = {}) { + return { + id: options.id ?? `evt_test_${eventCounter++}`, + object: 'event', + api_version: '2025-09-30.clover', + created: Math.floor(Date.now() / 1000), + livemode: options.livemode ?? false, + pending_webhooks: 1, + request: { id: null, idempotency_key: null }, + type, + data: { object } + } +} + +export function checkoutCompleted(args: { customer: string; subscription: string; accountId: string }) { + return stripeEvent('checkout.session.completed', { + id: `cs_test_${eventCounter++}`, + object: 'checkout.session', + mode: 'subscription', + status: 'complete', + payment_status: 'paid', + customer: args.customer, + subscription: args.subscription, + client_reference_id: args.accountId, + metadata: { account_id: args.accountId } + }) +} + +export function invoiceEvent(type: 'invoice.paid' | 'invoice.payment_failed', subscription: string, customer: string) { + return stripeEvent(type, { + id: `in_test_${eventCounter++}`, + object: 'invoice', + customer, + parent: { type: 'subscription_details', subscription_details: { subscription } } + }) +} + +/** Signs exactly like Stripe (and stripe-node's generateTestHeaderString), via node:crypto. */ +export function signStripePayload(payload: string, secret: string, timestampSeconds = Math.floor(Date.now() / 1000)): string { + const signature = createHmac('sha256', secret).update(`${timestampSeconds}.${payload}`, 'utf8').digest('hex') + return `t=${timestampSeconds},v1=${signature}` +} diff --git a/cloud/apps/billing/test/webhook-signature.test.ts b/cloud/apps/billing/test/webhook-signature.test.ts new file mode 100644 index 00000000000..e977332e83e --- /dev/null +++ b/cloud/apps/billing/test/webhook-signature.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { constantTimeEqual, splitSecrets, verifyStripeSignature } from '../src/webhook-signature' +import { signStripePayload } from './stripe-fake' + +const SECRET = 'whsec_test_signature' +const BODY = '{"id":"evt_test_1","object":"event"}' +const NOW = 1_762_000_000_000 +const T = NOW / 1000 + +describe('verifyStripeSignature', () => { + it('accepts a header signed the way Stripe signs', async () => { + expect(await verifyStripeSignature(BODY, signStripePayload(BODY, SECRET, T), [SECRET], NOW)).toEqual({ ok: true }) + }) + + it('accepts any of several v1 signatures and either secret during rotation', async () => { + const good = signStripePayload(BODY, 'whsec_new', T).split(',')[1] + const header = `t=${T},v1=${'0'.repeat(64)},${good},v0=ignored` + expect(await verifyStripeSignature(BODY, header, ['whsec_old', 'whsec_new'], NOW)).toEqual({ ok: true }) + }) + + it('rejects a tampered body, a wrong secret and a replayed old timestamp', async () => { + const header = signStripePayload(BODY, SECRET, T) + expect(await verifyStripeSignature(`${BODY} `, header, [SECRET], NOW)).toEqual({ ok: false, reason: 'mismatch' }) + expect(await verifyStripeSignature(BODY, header, ['whsec_other'], NOW)).toEqual({ ok: false, reason: 'mismatch' }) + expect(await verifyStripeSignature(BODY, header, [SECRET], NOW + 301_000)).toEqual({ ok: false, reason: 'stale' }) + }) + + it('rejects missing or malformed headers and an empty secret list', async () => { + expect(await verifyStripeSignature(BODY, null, [SECRET], NOW)).toEqual({ ok: false, reason: 'malformed' }) + expect(await verifyStripeSignature(BODY, 't=abc,v1=zz', [SECRET], NOW)).toEqual({ ok: false, reason: 'malformed' }) + expect(await verifyStripeSignature(BODY, signStripePayload(BODY, SECRET, T), [], NOW)).toEqual({ + ok: false, + reason: 'malformed' + }) + }) + + it('splits rotation secrets and compares in constant time', () => { + expect(splitSecrets(' whsec_a , ,whsec_b')).toEqual(['whsec_a', 'whsec_b']) + expect(constantTimeEqual('abc', 'abc')).toBe(true) + expect(constantTimeEqual('abc', 'abd')).toBe(false) + expect(constantTimeEqual('abc', 'abcd')).toBe(false) + }) +}) diff --git a/cloud/apps/billing/tsconfig.json b/cloud/apps/billing/tsconfig.json new file mode 100644 index 00000000000..69d420342f1 --- /dev/null +++ b/cloud/apps/billing/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "es2022", + "moduleResolution": "bundler", + "lib": ["es2022"], + "types": ["@cloudflare/workers-types", "@types/node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src", "test", "scripts"] +} diff --git a/cloud/apps/billing/vitest.config.ts b/cloud/apps/billing/vitest.config.ts new file mode 100644 index 00000000000..9f469145c99 --- /dev/null +++ b/cloud/apps/billing/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + // A string tsconfig skips the lookup, which otherwise climbs to the desktop app's tsconfig. + oxc: { tsconfig: false }, + test: { include: ['test/**/*.test.ts'] } +}) diff --git a/cloud/apps/billing/wrangler.dev.jsonc b/cloud/apps/billing/wrangler.dev.jsonc new file mode 100644 index 00000000000..b741e073b20 --- /dev/null +++ b/cloud/apps/billing/wrangler.dev.jsonc @@ -0,0 +1,37 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + // DEV deployment only (workers.dev, no custom domain, Stripe TEST MODE). Deploy: + // wrangler d1 migrations apply BILLING_DB --remote -c wrangler.dev.jsonc + // wrangler deploy -c wrangler.dev.jsonc + // Secrets (wrangler secret put -c wrangler.dev.jsonc, never here): STRIPE_SECRET_KEY (sk_test_/rk_test_), + // STRIPE_WEBHOOK_SECRET (the test-mode endpoint's whsec_), CONTROL_PLANE_TOKEN (shared with + // orca-sandbox-control-dev), BILLING_DEV_JWKS (operator key set for e2e until auth mints billing tokens). + "name": "orca-billing-dev", + "main": "src/index.ts", + "compatibility_date": "2026-11-01", + "workers_dev": true, + "d1_databases": [ + { + "binding": "BILLING_DB", + "database_name": "orca-billing-dev", + "database_id": "12aa0820-642a-4174-8504-f42d32043959", + "migrations_dir": "migrations" + } + ], + // Retries Stripe meter reports that failed inline. + "triggers": { "crons": ["*/10 * * * *"] }, + "vars": { + "BILLING_ENV": "dev", + "STRIPE_API_VERSION": "2025-09-30.clover", + "STRIPE_METER_EVENT_NAME": "orca_sandbox_seconds", + // 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_PORTAL_CONFIGURATION": "bpc_1UZmKvKb73TzRkjk59rIuHbx", + "BILLING_RETURN_URL": "https://orca-billing-dev.lovecast.workers.dev/return", + "BILLING_JWT_AUDIENCE": "orca-billing", + "BILLING_JWKS_URL": "https://login.onorca.dev/.well-known/jwks.json", + "BILLING_AUTH_ISSUER": "https://login.onorca.dev" + } +} diff --git a/cloud/apps/billing/wrangler.jsonc b/cloud/apps/billing/wrangler.jsonc new file mode 100644 index 00000000000..d969ce6b18a --- /dev/null +++ b/cloud/apps/billing/wrangler.jsonc @@ -0,0 +1,30 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + // Local `wrangler dev` only. No routes and workers.dev is off, so a deploy from this file + // publishes nothing reachable. Deployed environments live in the private deploy config. + // Secrets (stdin only, never here): STRIPE_SECRET_KEY (rk_test_...), STRIPE_WEBHOOK_SECRET, + // CONTROL_PLANE_TOKEN. For local dev put them in an untracked .dev.vars. + "name": "orca-billing-local", + "main": "src/index.ts", + "compatibility_date": "2026-11-01", + "workers_dev": false, + "d1_databases": [ + { + "binding": "BILLING_DB", + "database_name": "orca-billing-local", + "database_id": "00000000-0000-0000-0000-000000000000", + "migrations_dir": "migrations" + } + ], + // Retries Stripe meter reports that failed inline. + "triggers": { "crons": ["*/10 * * * *"] }, + "vars": { + "STRIPE_API_VERSION": "2025-09-30.clover", + "STRIPE_METER_EVENT_NAME": "orca_sandbox_seconds", + "STRIPE_PRICE_TEAM_MONTHLY": "price_local_team_month", + "STRIPE_PRICE_TEAM_ANNUAL": "price_local_team_year", + "STRIPE_PRICE_OVERAGE": "price_local_overage", + "BILLING_RETURN_URL": "http://127.0.0.1:8790/return", + "BILLING_JWT_AUDIENCE": "orca-billing" + } +} diff --git a/cloud/docs/accounts-billing-design.md b/cloud/docs/accounts-billing-design.md new file mode 100644 index 00000000000..383adde3ce6 --- /dev/null +++ b/cloud/docs/accounts-billing-design.md @@ -0,0 +1,184 @@ +# Orca accounts + billing — design (v1) + +Status: built in `nwparker/orca-private` (base branch `nwparker/accounts-billing`). Stripe TEST MODE only. +Code: `cloud/apps/billing` (Worker), desktop `src/main/billing`, `src/renderer/src/components/billing`. + +## Pricing (final, all config) + +| Plan | Price | Includes | +|---|---|---| +| Team | $50/seat/mo or $480/seat/yr | 60 Standard (4 vCPU / 8 GB) sandbox hours per seat per month, pooled across the org; Large (8/16) burns 2x; 3 running sandboxes per seat; 15-min idle sleep | +| Overage | opt-in, off by default | $0.75/h Standard ($1.50/h Large); 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 +without a code change by `BILLING_PLANS` (JSON, validated; malformed config fails closed with 503). Stripe price +ids are Worker vars. Annual plans still get a **monthly** allowance window (a calendar-month slice of the year, +anchored on the subscription start). Stripe Tax: `STRIPE_AUTOMATIC_TAX=true` adds `automatic_tax` + +`tax_id_collection` to Checkout; default off (activate Stripe Tax with the California origin address, SaaS tax code +and registrations first). + +## Identity — reuse Orca Cloud sign-in + +- Desktop PKCE login against `login.onorca.dev` already mints audience-scoped short-lived ES256 JWTs for other + services (relay, connector). Billing uses the same: `POST /v1/desktop/auth/billing-token` (**to add in the auth + service**) returns a ≤15-min JWT, `aud=orca-billing`, `purpose=billing`, `sub`, `org?`, `orgRole?`, `email?`. +- Billing account = `org:`, else `user:`. Only owners/admins buy, manage billing, or change overage. +- Users/orgs/membership/invites stay owned by auth (provider roster: PropelAuth). Billing stores only account ids. +- **Live role:** the billing-token route re-reads the caller's role from the live provider roster + (stablyai/orca-cloud#690; before it, the role came from a mirror that rebuilds only at sign-in). Billing also + requires checkout/portal/overage to use a token minted in the last 6 minutes, so a demotion or removal stops + billing actions within that window. + +## Team seats follow membership + +- **Source of truth:** auth's provider roster. Seats = members; pending invites never count; never fewer than 1. +- **Pull:** `POST {AUTH_INTERNAL_URL}/v1/internal/billing/org-members {orgId}` → `{orgId, members}` (shared secret + `MEMBERSHIP_SYNC_TOKEN`). Billing pulls at Checkout (the quantity ignores the desktop's request) and on the + 10-minute cron (`reconcileSeats`), which also picks up invites accepted at the provider. +- **Push:** auth calls `POST /v1/internal/seats {accountId, members}` after Orca-side removals (best effort). +- **Fail closed:** if auth is configured but unreachable, Checkout answers 503 `membership_unavailable`; malformed or + missing counts are 400; nothing ever bills 0 seats. No token claim carries a count (tokens can be stale). +- **Proration:** see "Seat fairness" below (peak seats; adds charged now, removals at renewal). +- Desktop: Settings → Orca account → Team (also "Team" in the account menu): members with roles, invite by email + (accepted at the sign-in provider), change role, remove (with confirm), revoke invites; owners/admins only, + mirroring auth's role rules. + +## Seat fairness and abuse resistance (decision) + +How others bill seats: GitHub charges added seats immediately, prorated, and applies removals at the +next billing cycle with no refund (docs: "Impact of changing your plan on billing"). Cursor charges +adds prorated but treats remove-then-re-add as a brand-new seat, which customers report as +double-charging. Slack's Fair Billing Policy credits inactive members automatically. Linear charges +everyone not suspended at renewal and prorates later additions. + +**Decision: seats billed = the period's peak membership** (GitHub model plus churn protection): +- **Adds** above the seats already paid this period are invoiced and charged now (`always_invoice`). +- **Removals** never credit mid-period. Seats drop to the member count only at renewal: within + `seatTrueDownHours` (6 h, config) of the period end, billing lowers the quantity with + `proration_behavior: none` (cron every 10 min, or the next membership push), so the renewal + invoice bills the smaller team. If the cron misses the window, the org renews at the peak and is + corrected at the following renewal (errs toward charging what was used). +- **Re-adding** someone removed earlier in the period is free. Membership up to the peak is already + paid, so remove/re-add churn can neither dodge charges nor double-charge honest teams. +- **Included hours and caps derive from billed seats**, not raw membership. Adding members never + grants hours or concurrency the org hasn't paid for, and the paid peak keeps its hours until renewal. +- **Per-member concurrency:** at most 3 running sandboxes per member (`member_concurrency_limit`), on + top of the org cap of 3 × billed seats. One seat can't run the whole team's sandboxes. +- **Attribution:** usage segments carry the `userId` that started the sandbox (`usage_events.user_id`) + for per-member caps, reporting and abuse review. +- **Membership changes are rate-limited** in auth: 10 per actor and 30 per org per minute + (stablyai/orca-cloud#692). **The last owner is protected:** owners can't remove or demote + themselves and admins can't act on owners, so an org always keeps an owner (pinned by tests there). +- **Org switching can't double-dip:** hours belong to the org account the sandbox was started under + (the control plane's token names it). A person in two orgs is a seat in both and each pays, and a + personal account is its own one-seat plan. +- **No free trial or free plan**, and Checkout always collects a card, so throwaway orgs get nothing + for free. +- **Seat counts never come from the client:** Checkout and reconcile read auth's live count + (pull-only in production for now, stablyai/orca-cloud#691). Missing or malformed counts are + rejected, and seats are never below 1. + +## Payment failure + +- On `invoice.payment_failed` (status `past_due`, later `unpaid` when retries are exhausted) billing records + `past_due_since` (first time seen; a later failed retry does not restart it). +- New sandbox starts stop immediately (`payment_past_due`). After `paymentGraceHours` (96 h = 4 days, config) + running sandboxes must be suspended: reason `payment_grace_expired`, `suspendRunning: true` on + `/v1/internal/entitlements/check` and `/v1/internal/usage`, and `GET /v1/internal/suspensions` lists every + account past its grace (for a control-plane cron). Once the invoice is paid the status returns to `active`, + `past_due_since` clears, and starts/resume are allowed again. +- Desktop shows a banner with the suspension date and an "Update payment method" button (Portal). +- Stripe sends the dunning emails and retries (Smart Retries) — dashboard settings, see "What the user sets". + +## Why Cloudflare Worker + D1 + +Extends the existing Workers setup (relay-edge); a separate D1 keeps the Stripe key and billing ledger isolated +from auth/relay data (least privilege); load is small; Cron Trigger retries the meter outbox. A Durable Object per +account is the upgrade path for strict per-account start locking. + +## Data model (D1 migrations 0001, 0002) + +| Table | Purpose | +|---|---| +| `billing_accounts` | account → Stripe customer; `overage_opt_in`, `spend_cap_cents` | +| `subscriptions` | Stripe mirror: status, seats, interval, period, overage item id | +| `stripe_events` | webhook idempotency | +| `usage_events` | usage segments keyed by caller `usageId`; weighted seconds; allowance-window start; overage fixed at insert | +| `meter_reports` | outbox of billable overage → Stripe meter events, capped at the spend cap in SQL | + +## Stripe integration + +- **Checkout** (`POST /v1/billing/checkout {plan:'team', interval, seats}`): hosted, `mode=subscription`, seat price + (quantity adjustable on the page), `payment_method_collection=always`, `subscription_data.metadata.account_id`. + One customer per account. 409 if a subscription is open (changes go through the Portal). +- **Customer Portal**: configuration `STRIPE_PORTAL_CONFIGURATION` allows seat quantity changes, monthly↔annual + switch (prorated), cancel at period end, payment method, invoices. +- **Overage opt-in** (`POST /v1/billing/overage {enabled, spendCapCents?}`): first opt-in adds the monthly metered + overage price to the subscription (idempotent); opting out stops metering immediately (the item stays so + already-reported usage still invoices). Annual subscriptions accept the monthly metered item (verified). +- **Webhooks**: HMAC-SHA256 over the raw body (5-min tolerance, rotation), event-id dedupe, then re-read the + 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`. + Only billable overage is sent, at most the remaining spend cap; `identifier=usageId` + Idempotency-Key. + +## Entitlements + +- Desktop `GET /v1/billing/entitlements`: plan, status, interval, seats, period, monthly usage window, + included/used/remaining, `usageWarningPercent`, overage {optedIn, active, cap, spent, rate}, limits, + `canStartSandbox`, `reason`, `canManageBilling`, and `offers` (Team + Enterprise, for the plan cards). +- Control plane (bearer `CONTROL_PLANE_TOKEN`): + - `POST /v1/internal/entitlements/check {accountId, machineSize: standard|large, runningSandboxes}` → `{allow, reason}`. + - `POST /v1/internal/usage {usageId, accountId, sandboxId, machineSize, startedAt, endedAt}` (segments ≤24 h). + - `GET /v1/internal/suspensions` → `{accounts: [{accountId, pastDueSince, graceEndsAt}]}`. + - Reasons: `no_subscription | subscription_inactive | payment_past_due | payment_grace_expired | + included_hours_exhausted | overage_cap_reached | machine_size_not_in_plan | concurrency_limit`. + Stop running sandboxes when `suspendRunning` is true (and for the reasons the control plane already stops on). + +## Security + +No card data touches us (hosted pages; desktop opens only `checkout.stripe.com` / `billing.stripe.com`, and an https +Enterprise contact URL). Secrets only via `wrangler secret put` / untracked `.dev.vars`. Worker refuses live keys and +live events unless `STRIPE_ALLOW_LIVEMODE=true`. Pinned `Stripe-Version`; strict zod bodies; size limits; redirects +never followed with the key; JWT max age 15 min. + +## Tests + +- Unit/flow: `cd cloud && pnpm --filter @orca-cloud/billing test` (D1 fake over node:sqlite with real migrations, + in-memory Stripe behind fetch, Stripe-shaped fixtures, Stripe-identical signatures). +- Live TEST-mode e2e: `scripts/stripe-test-setup.ts` (catalog + portal) and `scripts/e2e-test-mode.ts run` against + `wrangler dev` + `stripe listen --project-name orca`: Checkout with the test card for 2 seats, overage opt-in, + usage past included hours → meter events (checked against Stripe's meter summaries), spend cap blocks starts, + annual + overage, seat add/remove proration, test-clock renewal, `invoice.payment_failed` → past_due → grace + expiry → suspend → pay → resume (36 s grace via `BILLING_PLANS` locally), cancel. 21/21 passed locally. + +## Dev deployment (TEST MODE, for the sandbox control plane) + +- **Billing dev URL:** `https://orca-billing-dev.lovecast.workers.dev` (Cloudflare account "Stably", + `wrangler.dev.jsonc`, `BILLING_ENV=dev`). Control plane config: `BILLING_URL=https://orca-billing-dev.lovecast.workers.dev`, + `CONTROL_PLANE_TOKEN` = the same secret set on both Workers, and send `machineSize` `standard|large` only. +- D1 `orca-billing-dev` (`12aa0820-642a-4174-8504-f42d32043959`), migrations 0001–0002 applied. +- Stripe test webhook endpoint `we_1UZmcXKb73TzRkjkJMKg0jso` → `/v1/stripe/webhook` (API `2025-09-30.clover`). +- Secrets (stdin only): `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `CONTROL_PLANE_TOKEN`, `BILLING_DEV_JWKS`. +- Desktop auth: JWKS `https://login.onorca.dev/.well-known/jwks.json`, issuer `https://login.onorca.dev`. Until auth mints + billing tokens, dev also accepts the operator key set `BILLING_DEV_JWKS` (issuer `orca-billing-dev-test`; refused + when `BILLING_ENV=prod`). +- Same e2e run against dev: 18/18 passed (grace expiry is exercised locally; dev keeps the 96 h grace). + +## What the user sets + +| Where | Name | Value | +|---|---|---| +| billing Worker (dev/prod) secret | `MEMBERSHIP_SYNC_TOKEN` | random ≥32 chars; same value as auth's `ORCA_CLOUD_BILLING_MEMBERSHIP_TOKEN` | +| billing Worker var | `AUTH_INTERNAL_URL` | auth origin, e.g. `https://login.onorca.dev` (after orca-cloud#690 deploys) | +| auth (Cloud Run) secret | `ORCA_CLOUD_BILLING_MEMBERSHIP_TOKEN` | same value as above | +| auth (Cloud Run) env | `ORCA_CLOUD_BILLING_INTERNAL_URL` | unset for now (pull-only, orca-cloud#691); `https://billing.onorca.dev` once production billing exists | +| sandbox-control-dev secret | `CONTROL_PLANE_TOKEN` | same value as billing dev's `CONTROL_PLANE_TOKEN` | +| sandbox-control-dev var | `BILLING_URL` | `https://orca-billing-dev.lovecast.workers.dev` (and drop `ENTITLEMENTS_MODE=stub`) | +| Stripe dashboard (test, then live) | Billing → Revenue recovery | Smart Retries on; "Send emails when card payments fail" on; after retries → mark subscription unpaid | + +## Open decisions + +1. Real Enterprise contact URL (placeholder `https://onorca.dev/contact-sales`). 2. When to enable Stripe Tax. diff --git a/cloud/pnpm-lock.yaml b/cloud/pnpm-lock.yaml index 4faad933bb0..e073c58b84b 100644 --- a/cloud/pnpm-lock.yaml +++ b/cloud/pnpm-lock.yaml @@ -185,6 +185,31 @@ importers: specifier: ^5.0.5 version: 5.0.5(@types/node@24.19.2)(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15)) + apps/billing: + dependencies: + jose: + specifier: ^6.2.13 + version: 6.2.13 + zod: + specifier: ^4.6.7 + version: 4.6.7 + devDependencies: + '@cloudflare/workers-types': + specifier: ^5.20261103.1 + version: 5.20261104.1 + '@types/node': + specifier: ^24.19.2 + version: 24.19.2 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^5.0.5 + version: 5.0.5(@types/node@24.19.2)(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15)) + wrangler: + specifier: ^4.157.0 + version: 4.157.0(@cloudflare/workers-types@5.20261104.1)(@types/node@24.19.2) + apps/push: dependencies: '@hono/node-server': diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index 4b3f4c25346..c516a2d6722 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -100,8 +100,8 @@ 1 src/renderer/src/components/sidebar/SetupGuideSidebarEntry.tsx 1 src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx 1 src/renderer/src/components/sidebar/SidebarAgentsList.tsx -1 src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx 1 src/renderer/src/components/sidebar/SidebarTaskNavButton.tsx +1 src/renderer/src/components/sidebar/account-menu/SidebarAccountMenu.tsx 1 src/renderer/src/components/sidebar/linear-agent-skill-runtime.ts 1 src/renderer/src/components/sidebar/project-admission/drop/sidebar-project-drop.ts 2 src/renderer/src/components/sidebar/project-admission/drop/useSidebarProjectDrop.ts diff --git a/config/scripts/ci/unit-selection/check-test-file-runners.mjs b/config/scripts/ci/unit-selection/check-test-file-runners.mjs index 09f620398b7..dfe90fc72be 100644 --- a/config/scripts/ci/unit-selection/check-test-file-runners.mjs +++ b/config/scripts/ci/unit-selection/check-test-file-runners.mjs @@ -55,6 +55,12 @@ const NON_UNIT_TEST_RUNNERS = [ sources: ['cloud/apps/relay-edge/vitest.config.ts'], mirrors: ["include: ['test/**/*.test.ts']"] }, + { + name: 'cloud billing', + include: ['cloud/apps/billing/test/**/*.test.ts'], + sources: ['cloud/apps/billing/vitest.config.ts'], + mirrors: ["include: ['test/**/*.test.ts']"] + }, ...[ 'cloud/apps/relay-fence-broker', 'cloud/packages/cloud-database', diff --git a/src/main/billing/billing-client.test.ts b/src/main/billing/billing-client.test.ts new file mode 100644 index 00000000000..1bf5c759c24 --- /dev/null +++ b/src/main/billing/billing-client.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from 'vitest' +import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client' +import { + BillingServiceError, + assertStripeHostedUrl, + createCheckoutUrl, + createPortalUrl, + fetchEntitlement, + mintBillingToken +} from './billing-client' + +const endpoints = { + billingApiUrl: 'https://billing.example.test', + billingTokenEndpoint: 'https://login.example.test/v1/desktop/auth/billing-token' +} + +type Seen = { url: string; init: RequestInit | undefined } + +function fakeFetch(responses: Record, seen: Seen[] = []): typeof fetch { + return async (input, init) => { + const url = String(input) + seen.push({ url, init }) + const response = responses[url] + if (!response) { + throw new Error(`unexpected ${url}`) + } + return response + } +} + +describe('billing client', () => { + it('mints a billing token with the Orca access token and refuses redirects', async () => { + const seen: Seen[] = [] + const token = await mintBillingToken( + endpoints, + 'access-1', + fakeFetch( + { + [endpoints.billingTokenEndpoint]: Response.json({ + billingToken: 'bt', + expiresAt: 1 + }) + }, + seen + ) + ) + expect(token).toBe('bt') + expect(new Headers(seen[0]?.init?.headers).get('authorization')).toBe('Bearer access-1') + expect(seen[0]?.init?.redirect).toBe('error') + }) + + it('surfaces an auth rejection as an Orca session failure so the session refreshes', async () => { + const call = mintBillingToken( + endpoints, + 'stale', + fakeFetch({ + [endpoints.billingTokenEndpoint]: new Response(null, { status: 401 }) + }) + ) + await expect(call).rejects.toBeInstanceOf(OrcaCloudRequestError) + }) + + it('reports billing refusals as BillingServiceError, never as a session failure', async () => { + const call = createCheckoutUrl( + endpoints, + 'bt', + { plan: 'team', interval: 'month', seats: 1 }, + fakeFetch({ + 'https://billing.example.test/v1/billing/checkout': Response.json( + { error: 'already_subscribed' }, + { status: 409 } + ) + }) + ) + await expect(call).rejects.toMatchObject({ + statusCode: 409, + errorCode: 'already_subscribed' + }) + await expect(call).rejects.toBeInstanceOf(BillingServiceError) + }) + + it('sends plan, interval and seats and returns the hosted Checkout URL', async () => { + const seen: Seen[] = [] + const url = await createCheckoutUrl( + endpoints, + 'bt', + { plan: 'team', interval: 'year', seats: 3 }, + fakeFetch( + { + 'https://billing.example.test/v1/billing/checkout': Response.json({ + url: 'https://checkout.stripe.com/c/pay/cs_test_1' + }) + }, + seen + ) + ) + expect(url).toBe('https://checkout.stripe.com/c/pay/cs_test_1') + expect(JSON.parse(String(seen[0]?.init?.body))).toEqual({ + plan: 'team', + interval: 'year', + seats: 3 + }) + }) + + it('only opens Stripe-hosted pages', async () => { + expect(assertStripeHostedUrl('https://billing.stripe.com/p/session/x')).toBe( + 'https://billing.stripe.com/p/session/x' + ) + for (const bad of [ + 'http://checkout.stripe.com/x', + 'https://checkout.stripe.com.evil.test/x', + 'https://evil.test/x', + 'https://user@billing.stripe.com/x', + 'javascript:alert(1)', + 42 + ]) { + expect(() => assertStripeHostedUrl(bad)).toThrow() + } + const portal = createPortalUrl( + endpoints, + 'bt', + fakeFetch({ + 'https://billing.example.test/v1/billing/portal': Response.json({ + url: 'https://evil.test/' + }) + }) + ) + await expect(portal).rejects.toThrow('invalid_billing_url') + }) + + it('normalizes the entitlement and drops unknown or unsafe fields', async () => { + const offers = { + team: { + label: 'Team', + monthlyPriceLabel: '$50/seat/mo', + annualPriceLabel: '$480/seat/yr', + includedHoursPerSeat: 60, + concurrentSandboxesPerSeat: 3, + idleSleepMinutes: 15, + overageCentsPerHour: 75, + defaultSpendCapCents: 2500 + }, + enterprise: { + label: 'Enterprise', + priceLabel: 'Custom', + contactUrl: 'https://onorca.dev/sales' + } + } + const response = (extra: Record) => + fakeFetch({ + 'https://billing.example.test/v1/billing/entitlements': Response.json({ + plan: 'team', + planLabel: 'Team', + status: 'active', + interval: 'year', + seats: 2, + period: { start: 1, end: 2 }, + usageWindow: { start: 1, end: 2 }, + includedSeconds: 432000, + usedSeconds: 36000, + remainingIncludedSeconds: 396000, + usageWarningPercent: null, + overageSeconds: 0, + overage: { + optedIn: true, + active: true, + spendCapCents: 2500, + spentCents: 75, + centsPerHour: 75 + }, + canStartSandbox: true, + reason: 'something_new', + canManageBilling: true, + accountId: 'org:o1', + offers, + ...extra + }) + }) + const entitlement = await fetchEntitlement(endpoints, 'bt', response({})) + expect(entitlement).toMatchObject({ + plan: 'team', + interval: 'year', + usedSeconds: 36000, + reason: null, + canManageBilling: true, + overage: { optedIn: true, spentCents: 75 }, + offers + }) + expect(entitlement).not.toHaveProperty('accountId') + // A non-https contact link is never handed to the renderer. + const unsafe = await fetchEntitlement( + endpoints, + 'bt', + response({ + offers: { + ...offers, + enterprise: { ...offers.enterprise, contactUrl: 'javascript:alert(1)' } + } + }) + ) + expect(unsafe.offers).toBeNull() + expect( + (await fetchEntitlement(endpoints, 'bt', response({ plan: 'pro', offers: undefined }))).plan + ).toBeNull() + }) +}) diff --git a/src/main/billing/billing-client.ts b/src/main/billing/billing-client.ts new file mode 100644 index 00000000000..809aee5d515 --- /dev/null +++ b/src/main/billing/billing-client.ts @@ -0,0 +1,255 @@ +// HTTP calls behind "Plan & billing": mint a short-lived billing token from Orca Cloud auth, +// then call the billing service with it. Pure (fetch injected) so it is testable without Electron. +import { z } from 'zod' +import type { + OrcaBillingCheckoutArgs, + OrcaBillingEntitlement, + OrcaBillingOverageArgs +} from '../../shared/orca-billing' +import { ORCA_BILLING_INTERVALS, ORCA_BILLING_PLAN_IDS } from '../../shared/orca-billing' +import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client' + +const REQUEST_TIMEOUT_MS = 20_000 +const MAX_TOKEN_LENGTH = 8 * 1024 + +export type BillingEndpoints = { + billingApiUrl: string + billingTokenEndpoint: string +} + +/** A billing-service refusal (never an Orca session failure: it must not sign the user out). */ +export class BillingServiceError extends Error { + constructor( + public readonly statusCode: number, + public readonly errorCode: string | null + ) { + super(errorCode ?? `billing_request_failed_${statusCode}`) + this.name = 'BillingServiceError' + } +} + +const ErrorBody = z.object({ error: z.string().max(64) }) +const TokenBody = z.object({ + billingToken: z.string().min(1).max(MAX_TOKEN_LENGTH) +}) +const UrlBody = z.object({ url: z.string() }) +const JsonObject = z.record(z.string(), z.unknown()) + +async function readErrorCode(response: Response): Promise { + const parsed = ErrorBody.safeParse(await response.json().catch(() => null)) + return parsed.success ? parsed.data.error : null +} + +/** POST to Orca Cloud auth. A 401/403 is surfaced as OrcaCloudRequestError so the caller's + * session-refresh wrapper refreshes the Orca session once and retries. */ +export async function mintBillingToken( + endpoints: BillingEndpoints, + accessToken: string, + fetchImpl: typeof fetch = fetch +): Promise { + const response = await fetchImpl(endpoints.billingTokenEndpoint, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${accessToken}` + }, + body: '{}', + redirect: 'error', + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }) + if (!response.ok) { + throw new OrcaCloudRequestError(response.status, (await readErrorCode(response)) ?? undefined) + } + const body = TokenBody.safeParse(await response.json()) + if (!body.success) { + throw new Error('invalid_billing_token') + } + return body.data.billingToken +} + +async function billingRequest( + endpoints: BillingEndpoints, + token: string, + method: 'GET' | 'POST', + path: string, + body: unknown, + fetchImpl: typeof fetch +): Promise> { + const response = await fetchImpl(new URL(path, `${endpoints.billingApiUrl}/`).toString(), { + method, + headers: { + authorization: `Bearer ${token}`, + ...(method === 'POST' ? { 'content-type': 'application/json' } : {}) + }, + body: method === 'POST' ? JSON.stringify(body ?? {}) : undefined, + redirect: 'error', + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }) + if (!response.ok) { + throw new BillingServiceError(response.status, await readErrorCode(response)) + } + const json = JsonObject.safeParse(await response.json()) + if (!json.success) { + throw new Error('invalid_billing_response') + } + return json.data +} + +const seconds = z.number().catch(0) +const window = z.object({ start: z.number(), end: z.number() }).nullable().catch(null) + +const OffersSchema = z + .object({ + team: z.object({ + label: z.string().max(40), + monthlyPriceLabel: z.string().max(40), + annualPriceLabel: z.string().max(40), + monthlyPriceCentsPerSeat: z.number().int().nonnegative().nullable().catch(null), + annualPriceCentsPerSeat: z.number().int().nonnegative().nullable().catch(null), + largeMachineWeight: z.number().positive().catch(2), + includedHoursPerSeat: z.number().nonnegative(), + concurrentSandboxesPerSeat: z.number().int().nonnegative(), + idleSleepMinutes: z.number().int().positive(), + overageCentsPerHour: z.number().int().positive(), + defaultSpendCapCents: z.number().int().nonnegative() + }), + enterprise: z.object({ + label: z.string().max(40), + priceLabel: z.string().max(40), + // Opened in the browser: https only. + contactUrl: z.string().url().startsWith('https://') + }) + }) + .nullable() + .catch(null) + +// Lenient by design: an unknown plan/reason or a missing field degrades to "none"/0, never an error, +// so a newer billing service cannot break an older desktop. +const EntitlementSchema = z.object({ + plan: z.enum(ORCA_BILLING_PLAN_IDS).nullable().catch(null), + planLabel: z.string().nullable().catch(null), + status: z.string().catch('none'), + interval: z.enum(ORCA_BILLING_INTERVALS).nullable().catch(null), + seats: seconds, + seatChange: z + .object({ seatsAtRenewal: z.number().int().min(1), effectiveAt: z.number() }) + .nullable() + .catch(null), + period: window, + usageWindow: window, + cancelAtPeriodEnd: z.boolean().catch(false), + includedSeconds: seconds, + usedSeconds: seconds, + remainingIncludedSeconds: seconds, + usageWarningPercent: z.number().nullable().catch(null), + overageSeconds: seconds, + overage: z + .object({ + optedIn: z.boolean().catch(false), + active: z.boolean().catch(false), + spendCapCents: seconds, + spentCents: seconds, + centsPerHour: seconds + }) + .catch({ optedIn: false, active: false, spendCapCents: 0, spentCents: 0, centsPerHour: 0 }), + payment: z + .object({ + pastDueSince: z.number().nullable().catch(null), + graceEndsAt: z.number().nullable().catch(null), + suspendRunning: z.boolean().catch(false) + }) + .catch({ pastDueSince: null, graceEndsAt: null, suspendRunning: false }), + canStartSandbox: z.boolean().catch(false), + reason: z + .enum([ + 'no_subscription', + 'subscription_inactive', + 'payment_past_due', + 'payment_grace_expired', + 'included_hours_exhausted', + 'overage_cap_reached' + ]) + .nullable() + .catch(null), + canManageBilling: z.boolean().catch(false), + offers: OffersSchema.optional().transform((value) => value ?? null) +}) + +export function normalizeEntitlement(value: Record): OrcaBillingEntitlement { + return EntitlementSchema.parse(value) +} + +// Only Stripe's hosted pages may be opened from a billing response. +const STRIPE_HOSTS = new Set(['checkout.stripe.com', 'billing.stripe.com']) + +export function assertStripeHostedUrl(value: unknown): string { + if (typeof value !== 'string' || !URL.canParse(value)) { + throw new Error('invalid_billing_url') + } + const url = new URL(value) + if ( + url.protocol !== 'https:' || + !STRIPE_HOSTS.has(url.hostname) || + url.username || + url.password + ) { + throw new Error('invalid_billing_url') + } + return url.toString() +} + +export async function fetchEntitlement( + endpoints: BillingEndpoints, + token: string, + fetchImpl: typeof fetch = fetch +): Promise { + return normalizeEntitlement( + await billingRequest(endpoints, token, 'GET', 'v1/billing/entitlements', undefined, fetchImpl) + ) +} + +export async function createCheckoutUrl( + endpoints: BillingEndpoints, + token: string, + args: OrcaBillingCheckoutArgs, + fetchImpl: typeof fetch = fetch +): Promise { + const body = await billingRequest( + endpoints, + token, + 'POST', + 'v1/billing/checkout', + { plan: args.plan, interval: args.interval, seats: args.seats }, + fetchImpl + ) + return assertStripeHostedUrl(UrlBody.safeParse(body).data?.url) +} + +export async function createPortalUrl( + endpoints: BillingEndpoints, + token: string, + fetchImpl: typeof fetch = fetch +): Promise { + const body = await billingRequest(endpoints, token, 'POST', 'v1/billing/portal', {}, fetchImpl) + return assertStripeHostedUrl(UrlBody.safeParse(body).data?.url) +} + +export async function updateOverage( + endpoints: BillingEndpoints, + token: string, + args: OrcaBillingOverageArgs, + fetchImpl: typeof fetch = fetch +): Promise { + return normalizeEntitlement( + await billingRequest( + endpoints, + token, + 'POST', + 'v1/billing/overage', + args.spendCapCents === undefined + ? { enabled: args.enabled } + : { enabled: args.enabled, spendCapCents: args.spendCapCents }, + fetchImpl + ) + ) +} diff --git a/src/main/billing/billing-config.test.ts b/src/main/billing/billing-config.test.ts new file mode 100644 index 00000000000..108572d2ec7 --- /dev/null +++ b/src/main/billing/billing-config.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ app: { isPackaged: false } })) + +import { getBillingEndpoints } from './billing-config' +import { + billingCheckoutArgsFromUnknown, + billingOverageArgsFromUnknown +} from '../ipc/profiles/orca-profile-billing-handlers' + +const authConfig = { apiBaseUrl: 'https://login.onorca.dev' } + +describe('billing config', () => { + it('is unconfigured until a billing URL is set', () => { + expect(getBillingEndpoints(authConfig, {}, true)).toBeNull() + }) + + it('derives the token endpoint from Orca Cloud auth', () => { + expect( + getBillingEndpoints(authConfig, { ORCA_BILLING_API_URL: 'https://billing.onorca.dev' }, true) + ).toEqual({ + billingApiUrl: 'https://billing.onorca.dev', + billingTokenEndpoint: 'https://login.onorca.dev/v1/desktop/auth/billing-token' + }) + }) + + it('allows loopback http only in development builds', () => { + const env = { ORCA_BILLING_API_URL: 'http://127.0.0.1:8790' } + expect(getBillingEndpoints(authConfig, env, false)?.billingApiUrl).toBe('http://127.0.0.1:8790') + expect(getBillingEndpoints(authConfig, env, true)).toBeNull() + }) +}) + +describe('billing IPC args', () => { + it('accepts Team checkout with an interval and 1..500 seats', () => { + expect(billingCheckoutArgsFromUnknown({ plan: 'team', interval: 'year', seats: 5 })).toEqual({ + plan: 'team', + interval: 'year', + seats: 5 + }) + for (const bad of [ + { plan: 'pro', interval: 'month', seats: 1 }, + { plan: 'team', interval: 'week', seats: 1 }, + { plan: 'team', interval: 'month', seats: 0 }, + { plan: 'team', interval: 'month', seats: 501 }, + { plan: 'team', interval: 'month', seats: 1, extra: true }, + null + ]) { + expect(() => billingCheckoutArgsFromUnknown(bad)).toThrow('invalid_orca_billing_checkout') + } + }) + + it('accepts overage toggles with an optional whole-cent cap', () => { + expect(billingOverageArgsFromUnknown({ enabled: false })).toEqual({ enabled: false }) + expect(billingOverageArgsFromUnknown({ enabled: true, spendCapCents: 5000 })).toEqual({ + enabled: true, + spendCapCents: 5000 + }) + for (const bad of [ + { enabled: 'yes' }, + { enabled: true, spendCapCents: -1 }, + { enabled: true, spendCapCents: 1.5 } + ]) { + expect(() => billingOverageArgsFromUnknown(bad)).toThrow('invalid_orca_billing_overage') + } + }) +}) diff --git a/src/main/billing/billing-config.ts b/src/main/billing/billing-config.ts new file mode 100644 index 00000000000..c2ce29d02d2 --- /dev/null +++ b/src/main/billing/billing-config.ts @@ -0,0 +1,23 @@ +import { cleanCloudServiceUrl } from '../../shared/cloud-service-url' +import type { OrcaCloudAuthConfig } from '../orca-profiles/profile-cloud-auth-config' +import type { BillingEndpoints } from './billing-client' + +/** + * Billing is opt-in per build until the service is deployed: no production default yet, so + * packaged builds report 'unconfigured' and the menu item stays hidden behind its capability flag. + */ +export function getBillingEndpoints( + authConfig: Pick, + env: NodeJS.ProcessEnv, + packaged: boolean +): BillingEndpoints | null { + const allowLoopbackHttp = !packaged + const billingApiUrl = cleanCloudServiceUrl(env.ORCA_BILLING_API_URL, allowLoopbackHttp) + if (!billingApiUrl) { + return null + } + const billingTokenEndpoint = + cleanCloudServiceUrl(env.ORCA_CLOUD_BILLING_TOKEN_URL, allowLoopbackHttp) ?? + new URL('/v1/desktop/auth/billing-token', `${authConfig.apiBaseUrl}/`).toString() + return { billingApiUrl, billingTokenEndpoint } +} diff --git a/src/main/billing/billing-service.ts b/src/main/billing/billing-service.ts new file mode 100644 index 00000000000..aac2bf8bbe4 --- /dev/null +++ b/src/main/billing/billing-service.ts @@ -0,0 +1,149 @@ +// Plan & billing for the active Orca profile's signed-in account. Results are values, never +// throws, so the renderer can show a precise state. A billing-service refusal never signs the +// user out; only Orca Cloud auth rejecting the session does (via runWithFreshOrcaCloudSession). +import { app, shell } from 'electron' +import type { + OrcaBillingCheckoutArgs, + OrcaBillingOpenResult, + OrcaBillingOverageArgs, + OrcaBillingOverageResult, + OrcaBillingStatusResult +} from '../../shared/orca-billing' +import { getOrcaCloudAuthConfig } from '../orca-profiles/profile-cloud-auth-config' +import { runWithFreshOrcaCloudSession } from '../orca-profiles/profile-cloud-session-refresh' +import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store' +import { + BillingServiceError, + createCheckoutUrl, + createPortalUrl, + fetchEntitlement, + mintBillingToken, + updateOverage, + type BillingEndpoints +} from './billing-client' +import { getBillingEndpoints } from './billing-config' + +type Ready = + | { + ok: true + run: ( + call: (token: string, endpoints: BillingEndpoints) => Promise + ) => Promise + } + | { ok: false; status: 'unconfigured' | 'signed-out' } + +function prepare(userDataPath: string): Ready { + const auth = getOrcaCloudAuthConfig() + if (!auth.configured) { + return { ok: false, status: 'unconfigured' } + } + const endpoints = getBillingEndpoints(auth.config, process.env, app.isPackaged) + if (!endpoints) { + return { ok: false, status: 'unconfigured' } + } + const active = ensureActiveOrcaProfile(userDataPath) + if (!active.profile.cloud) { + return { ok: false, status: 'signed-out' } + } + return { + ok: true, + run: async (call) => { + const result = await runWithFreshOrcaCloudSession( + auth.config, + active, + userDataPath, + async (session) => call(await mintBillingToken(endpoints, session.accessToken), endpoints) + ) + return result.status === 'ok' ? result.value : 'signed-out' + } + } +} + +const message = (error: unknown): string => (error instanceof Error ? error.message : String(error)) + +export async function getOrcaBillingStatus(userDataPath: string): Promise { + const ready = prepare(userDataPath) + if (!ready.ok) { + return { status: ready.status } + } + try { + const entitlement = await ready.run((token, endpoints) => fetchEntitlement(endpoints, token)) + return entitlement === 'signed-out' ? { status: 'signed-out' } : { status: 'ok', entitlement } + } catch (error) { + return { status: 'failed', error: message(error) } + } +} + +function openResultForError(error: unknown): OrcaBillingOpenResult { + if (error instanceof BillingServiceError) { + if (error.statusCode === 403) { + return { status: 'forbidden' } + } + if (error.errorCode === 'already_subscribed') { + return { status: 'already-subscribed' } + } + if (error.errorCode === 'no_billing_account') { + return { status: 'no-billing-account' } + } + } + return { status: 'failed', error: message(error) } +} + +async function openHosted( + userDataPath: string, + createUrl: (token: string, endpoints: BillingEndpoints) => Promise +): Promise { + const ready = prepare(userDataPath) + if (!ready.ok) { + return { status: ready.status } + } + try { + const url = await ready.run(createUrl) + if (url === 'signed-out') { + return { status: 'signed-out' } + } + // createUrl already restricted this to Stripe's hosted Checkout/Portal hosts. + await shell.openExternal(url) + return { status: 'opened' } + } catch (error) { + return openResultForError(error) + } +} + +export function openOrcaBillingCheckout( + userDataPath: string, + args: OrcaBillingCheckoutArgs +): Promise { + return openHosted(userDataPath, (token, endpoints) => createCheckoutUrl(endpoints, token, args)) +} + +export function openOrcaBillingPortal(userDataPath: string): Promise { + return openHosted(userDataPath, (token, endpoints) => createPortalUrl(endpoints, token)) +} + +export async function setOrcaBillingOverage( + userDataPath: string, + args: OrcaBillingOverageArgs +): Promise { + const ready = prepare(userDataPath) + if (!ready.ok) { + return { status: ready.status } + } + try { + const entitlement = await ready.run((token, endpoints) => updateOverage(endpoints, token, args)) + return entitlement === 'signed-out' ? { status: 'signed-out' } : { status: 'ok', entitlement } + } catch (error) { + if (error instanceof BillingServiceError) { + if (error.statusCode === 403) { + return { status: 'forbidden' } + } + if (error.errorCode === 'no_subscription') { + return { status: 'no-subscription' } + } + if (error.errorCode === 'spend_cap_too_high') { + return { status: 'cap-too-high' } + } + } + return { status: 'failed', error: message(error) } + } +} diff --git a/src/main/ipc/profiles/orca-profile-billing-handlers.ts b/src/main/ipc/profiles/orca-profile-billing-handlers.ts new file mode 100644 index 00000000000..19f5487b322 --- /dev/null +++ b/src/main/ipc/profiles/orca-profile-billing-handlers.ts @@ -0,0 +1,72 @@ +import { ipcMain } from 'electron' +import { z } from 'zod' +import type { + OrcaBillingCheckoutArgs, + OrcaBillingOpenResult, + OrcaBillingOverageArgs, + OrcaBillingOverageResult, + OrcaBillingStatusResult +} from '../../../shared/orca-billing' +import { + ORCA_BILLING_INTERVALS, + ORCA_BILLING_MAX_SEATS, + ORCA_BILLING_PLAN_IDS +} from '../../../shared/orca-billing' +import { + getOrcaBillingStatus, + openOrcaBillingCheckout, + openOrcaBillingPortal, + setOrcaBillingOverage +} from '../../billing/billing-service' +import { getProfileUserDataPath } from '../../orca-profiles/profile-storage-paths' + +const CheckoutArgs = z + .object({ + plan: z.enum(ORCA_BILLING_PLAN_IDS), + interval: z.enum(ORCA_BILLING_INTERVALS), + seats: z.number().int().min(1).max(ORCA_BILLING_MAX_SEATS) + }) + .strict() + +// Cents; the billing service enforces the plan's maximum. +const OverageArgs = z + .object({ + enabled: z.boolean(), + spendCapCents: z.number().int().min(0).max(100_000_000).optional() + }) + .strict() + +export function billingCheckoutArgsFromUnknown(args: unknown): OrcaBillingCheckoutArgs { + const parsed = CheckoutArgs.safeParse(args) + if (!parsed.success) { + throw new Error('invalid_orca_billing_checkout') + } + return parsed.data +} + +export function billingOverageArgsFromUnknown(args: unknown): OrcaBillingOverageArgs { + const parsed = OverageArgs.safeParse(args) + if (!parsed.success) { + throw new Error('invalid_orca_billing_overage') + } + return parsed.data +} + +export function registerOrcaProfileBillingHandlers(): void { + ipcMain.handle('orcaProfiles:billingStatus', async (): Promise => + getOrcaBillingStatus(getProfileUserDataPath()) + ) + ipcMain.handle( + 'orcaProfiles:billingOpenCheckout', + async (_event, rawArgs: unknown): Promise => + openOrcaBillingCheckout(getProfileUserDataPath(), billingCheckoutArgsFromUnknown(rawArgs)) + ) + ipcMain.handle( + 'orcaProfiles:billingSetOverage', + async (_event, rawArgs: unknown): Promise => + setOrcaBillingOverage(getProfileUserDataPath(), billingOverageArgsFromUnknown(rawArgs)) + ) + ipcMain.handle('orcaProfiles:billingOpenPortal', async (): Promise => + openOrcaBillingPortal(getProfileUserDataPath()) + ) +} diff --git a/src/main/ipc/profiles/orca-profiles.ts b/src/main/ipc/profiles/orca-profiles.ts index 4c77c741c56..24b35961726 100644 --- a/src/main/ipc/profiles/orca-profiles.ts +++ b/src/main/ipc/profiles/orca-profiles.ts @@ -47,6 +47,7 @@ import { } from '../../orca-profiles/profile-cloud-service' import { refreshCurrentOrcaProfileAuth } from '../../orca-profiles/profile-cloud-capability-refresh' import { registerOrcaProfileOrgMemberHandlers } from './orca-profile-org-members-handlers' +import { registerOrcaProfileBillingHandlers } from './orca-profile-billing-handlers' import { onOrcaCloudSessionInvalidated } from '../../orca-profiles/profile-cloud-session-invalidation' import { broadcastOrcaProfileAuthStatusChanged, @@ -319,4 +320,5 @@ export function registerOrcaProfileHandlers( ) registerOrcaProfileOrgMemberHandlers() + registerOrcaProfileBillingHandlers() } diff --git a/src/preload/api/orca-profile-api.ts b/src/preload/api/orca-profile-api.ts index 8fe63dd6375..69b76af50a1 100644 --- a/src/preload/api/orca-profile-api.ts +++ b/src/preload/api/orca-profile-api.ts @@ -25,6 +25,13 @@ import type { TransferOrcaProfileProjectArgs, TransferOrcaProfileProjectResult } from '../../shared/orca-profiles' +import type { + OrcaBillingCheckoutArgs, + OrcaBillingOpenResult, + OrcaBillingOverageArgs, + OrcaBillingOverageResult, + OrcaBillingStatusResult +} from '../../shared/orca-billing' export type OrcaProfileApi = { list: () => Promise @@ -64,4 +71,12 @@ export type OrcaProfileApi = { orgMemberRemove: ( args: OrcaProfileOrgMemberRemoveArgs ) => Promise + /** Plan & billing for the signed-in account (Orca Cloud billing service). */ + billingStatus: () => Promise + /** Opens hosted Stripe Checkout in the browser. */ + billingOpenCheckout: (args: OrcaBillingCheckoutArgs) => Promise + /** Opens the Stripe Customer Portal in the browser. */ + billingOpenPortal: () => Promise + /** Opt in/out of sandbox overage and set the monthly spend cap (org admins). */ + billingSetOverage: (args: OrcaBillingOverageArgs) => Promise } diff --git a/src/preload/api/orca-profiles-bridge.ts b/src/preload/api/orca-profiles-bridge.ts index f144c9d7697..7063a998329 100644 --- a/src/preload/api/orca-profiles-bridge.ts +++ b/src/preload/api/orca-profiles-bridge.ts @@ -64,5 +64,9 @@ export const orcaProfilesApi = { orgMemberInvite: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberInvite', args), orgInviteRevoke: (args) => ipcRenderer.invoke('orcaProfiles:orgInviteRevoke', args), orgMemberChangeRole: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberChangeRole', args), - orgMemberRemove: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberRemove', args) + orgMemberRemove: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberRemove', args), + billingStatus: () => ipcRenderer.invoke('orcaProfiles:billingStatus'), + billingOpenCheckout: (args) => ipcRenderer.invoke('orcaProfiles:billingOpenCheckout', args), + billingOpenPortal: () => ipcRenderer.invoke('orcaProfiles:billingOpenPortal'), + billingSetOverage: (args) => ipcRenderer.invoke('orcaProfiles:billingSetOverage', args) } satisfies PreloadApi['orcaProfiles'] diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b3eaaea1d6e..7f2e61cae7b 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -5,6 +5,7 @@ import { TooltipProvider } from '@/components/ui/tooltip' import { ConfirmationDialogProvider } from './components/confirmation-dialog' import { BrowserWebAuthnAccountDialog } from './components/browser-webauthn-account-dialog' import { BrowserExtensionInstallDialog } from './components/browser-extension-install-dialog' +import { PlansDialogHost } from './components/billing/plans/PlansDialogHost' import { BrowserPagePrompts } from './components/browser-pane/browser-page-prompts' import { LinkRoutingPreferenceDialogProvider } from './components/link-routing-preference-dialog' import { SkillFreshnessNudge } from './components/skills/SkillFreshnessNudge' @@ -117,6 +118,7 @@ function App(): React.JSX.Element { /> + {createPortal( diff --git a/src/renderer/src/components/billing/PaymentIssueNotice.tsx b/src/renderer/src/components/billing/PaymentIssueNotice.tsx new file mode 100644 index 00000000000..a7672fd1435 --- /dev/null +++ b/src/renderer/src/components/billing/PaymentIssueNotice.tsx @@ -0,0 +1,73 @@ +import { AlertTriangle, ExternalLink } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useIntlLocale, useTranslate } from '@/i18n/use-translate' +import type { OrcaBillingEntitlement } from '../../../../shared/orca-billing' + +/** + * Failed renewal: new sandboxes are paused at once; running ones are suspended when the grace + * ends. Stripe keeps retrying the card and emails the billing contact meanwhile. + */ +export function PaymentIssueNotice({ + entitlement, + busy, + onUpdatePayment +}: { + entitlement: OrcaBillingEntitlement + busy: boolean + onUpdatePayment: () => void +}): React.JSX.Element | null { + const t = useTranslate() + const locale = useIntlLocale() + const { payment } = entitlement + if (payment.pastDueSince === null) { + return null + } + const when = (ms: number): string => + new Intl.DateTimeFormat(locale, { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'short' + }).format(ms) + return ( +
+ +
+

+ {payment.suspendRunning + ? t('auto.components.billing.paymentSuspended', 'Sandboxes suspended: payment overdue') + : t('auto.components.billing.paymentFailed', 'Payment failed')} +

+

+ {payment.suspendRunning || payment.graceEndsAt === null + ? t( + 'auto.components.billing.paymentSuspendedBody', + 'Running sandboxes were suspended and new ones cannot start. Update your payment method; everything resumes once the invoice is paid.' + ) + : t( + 'auto.components.billing.paymentGraceBody', + 'New sandboxes are paused. Running sandboxes will be suspended on {{value0}} unless the payment goes through. Stripe will retry the card and email your billing contact.', + { value0: when(payment.graceEndsAt) } + )} +

+ {entitlement.canManageBilling ? ( + + ) : null} +
+
+ ) +} diff --git a/src/renderer/src/components/billing/PlanBillingSection.fixture.tsx b/src/renderer/src/components/billing/PlanBillingSection.fixture.tsx new file mode 100644 index 00000000000..e97031b8cbf --- /dev/null +++ b/src/renderer/src/components/billing/PlanBillingSection.fixture.tsx @@ -0,0 +1,128 @@ +import { defineFixture } from '../../component-fixtures/component-fixture' +import { PlanBillingView } from './PlanBillingSection' +import { sampleTeam } from './plan-billing-fixtures' + +const HOUR = 3600 + +const handlers = { + loading: false, + busy: false, + notice: null, + onRefresh: () => {}, + onPortal: () => {}, + onSetOverage: () => {}, + onSeePlans: () => {} +} + +export const activeTeam = defineFixture({ + width: 600, + render: () => +}) + +export const noPlan = defineFixture({ + width: 680, + render: () => ( + + ) +}) + +export const overageCapReached = defineFixture({ + width: 600, + render: () => ( + + ) +}) + +export const pastDueMember = defineFixture({ + width: 600, + render: () => ( + + ) +}) + +export const suspendedAdmin = defineFixture({ + width: 600, + render: () => ( + + ) +}) diff --git a/src/renderer/src/components/billing/PlanBillingSection.tsx b/src/renderer/src/components/billing/PlanBillingSection.tsx new file mode 100644 index 00000000000..00a67976c03 --- /dev/null +++ b/src/renderer/src/components/billing/PlanBillingSection.tsx @@ -0,0 +1,228 @@ +import { useState } from 'react' +import { CreditCard, ExternalLink, RefreshCw, Sparkles } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { useIntlLocale, useTranslate } from '@/i18n/use-translate' +import { formatCents } from './plans/plans-pricing' +import { cn } from '@/lib/utils' +import type { + OrcaBillingOpenResult, + OrcaBillingOverageArgs, + OrcaBillingOverageResult, + OrcaBillingStatusResult +} from '../../../../shared/orca-billing' +import { openPlansPage } from './plans/plans-page-store' +import { PaymentIssueNotice } from './PaymentIssueNotice' +import { OverageControls, UsageSummary } from './PlanBillingUsage' +import { + denyReasonCopy, + openFailureCopy, + overageFailureCopy, + statusLabel +} from './plan-billing-copy' +import { planBillingTone } from './plan-billing-format' +import { useOrcaBillingStatus } from './use-orca-billing-status' + +export function PlanBillingSection(): React.JSX.Element | null { + const t = useTranslate() + const { loading, result, refresh } = useOrcaBillingStatus(true) + const [busy, setBusy] = useState(false) + const [notice, setNotice] = useState(null) + + const run = async (open: () => Promise): Promise => { + setBusy(true) + setNotice(null) + const outcome = await open().catch((error: unknown): OrcaBillingOpenResult => ({ + status: 'failed', + error: String(error) + })) + setBusy(false) + setNotice( + outcome.status === 'opened' + ? t( + 'auto.components.billing.continueInBrowser', + 'Continue in your browser. This updates when you come back.' + ) + : openFailureCopy(outcome, t) + ) + } + + const setOverage = async (args: OrcaBillingOverageArgs): Promise => { + setBusy(true) + setNotice(null) + const outcome = await window.api.orcaProfiles + .billingSetOverage(args) + .catch((error: unknown): OrcaBillingOverageResult => ({ + status: 'failed', + error: String(error) + })) + setBusy(false) + if (outcome.status === 'ok') { + refresh() + } else { + setNotice(overageFailureCopy(outcome, t)) + } + } + + return ( + void run(() => window.api.orcaProfiles.billingOpenPortal())} + onSetOverage={(args) => void setOverage(args)} + onSeePlans={() => openPlansPage('settings')} + /> + ) +} + +export type PlanBillingViewProps = { + result: OrcaBillingStatusResult | null + loading: boolean + busy: boolean + notice: string | null + onRefresh: () => void + onPortal: () => void + onSetOverage: (args: OrcaBillingOverageArgs) => void + /** Opens the plans page (pricing, Checkout, Enterprise contact). */ + onSeePlans: () => void +} + +export function PlanBillingView({ + result, + loading, + busy, + notice, + onRefresh, + onPortal, + onSetOverage, + onSeePlans +}: PlanBillingViewProps): React.JSX.Element | null { + const t = useTranslate() + const locale = useIntlLocale() + if (!result || result.status === 'unconfigured' || result.status === 'signed-out') { + // The pane above already handles sign-in; billing stays out of builds without a service. + return null + } + + const header = ( +
+

+ {t('auto.components.billing.title', 'Plan & billing')} +

+ +
+ ) + + if (result.status === 'failed') { + return ( +
+ {header} +

+ {t('auto.components.billing.loadFailed', 'Could not load your plan. Try again.')} +

+
+ ) + } + + const entitlement = result.entitlement + const tone = planBillingTone(entitlement) + const hasPlan = entitlement.plan !== null && entitlement.status !== 'canceled' + const annualCents = entitlement.offers?.team.annualPriceCentsPerSeat ?? null + const teamFrom = annualCents === null ? null : formatCents(Math.round(annualCents / 12), locale) + + return ( +
+ {header} + {hasPlan ? ( +
+
+ +

{entitlement.planLabel}

+ + {t('auto.components.billing.seatsInterval', '{{value0}} seats · {{value1}}', { + value0: String(entitlement.seats), + value1: + entitlement.interval === 'year' + ? t('auto.components.billing.annual', 'Annual') + : t('auto.components.billing.monthly', 'Monthly') + })} + + + {statusLabel(entitlement.status, t)} + +
+ + +
+ ) : null} + + + + {entitlement.reason && entitlement.payment.pastDueSince === null ? ( +

{denyReasonCopy(entitlement.reason, t)}

+ ) : null} + + {!hasPlan && teamFrom ? ( +

+ {t( + 'auto.components.billing.teamFrom', + 'Team starts at {{value0}} per seat per month, billed annually.', + { + value0: teamFrom + } + )} +

+ ) : null} + +
+ {hasPlan ? null : ( + + )} + {hasPlan && entitlement.canManageBilling && entitlement.payment.pastDueSince === null ? ( + + ) : null} + {hasPlan ? ( + + ) : null} +
+ + {!entitlement.canManageBilling ? ( +

+ {t('auto.components.billing.askAdmin', 'Ask an org owner or admin to change the plan.')} +

+ ) : null} + + {notice ?

{notice}

: null} +
+ ) +} diff --git a/src/renderer/src/components/billing/PlanBillingUsage.tsx b/src/renderer/src/components/billing/PlanBillingUsage.tsx new file mode 100644 index 00000000000..e83bf716a91 --- /dev/null +++ b/src/renderer/src/components/billing/PlanBillingUsage.tsx @@ -0,0 +1,159 @@ +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Progress } from '@/components/ui/progress' +import { Switch } from '@/components/ui/switch' +import { useIntlLocale, useTranslate } from '@/i18n/use-translate' +import type { + OrcaBillingEntitlement, + OrcaBillingOverageArgs +} from '../../../../shared/orca-billing' +import { formatDollars, formatHours, includedUsagePercent } from './plan-billing-format' + +export function UsageSummary({ + entitlement +}: { + entitlement: OrcaBillingEntitlement +}): React.JSX.Element { + const t = useTranslate() + const locale = useIntlLocale() + const date = (ms: number): string => + new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(ms) + return ( +
+
+ + {t( + 'auto.components.billing.hoursUsedMonth', + '{{value0}} of {{value1}} hours used this month', + { + value0: formatHours(entitlement.usedSeconds), + value1: formatHours(entitlement.includedSeconds) + } + )} + + + {t('auto.components.billing.hoursLeft', '{{value0}} hours left', { + value0: formatHours(entitlement.remainingIncludedSeconds) + })} + +
+ + {entitlement.usageWarningPercent !== null ? ( +

+ {entitlement.usageWarningPercent >= 100 + ? t('auto.components.billing.warn100', 'All included hours for this month are used.') + : t( + 'auto.components.billing.warnPercent', + 'You have used {{value0}}% of this month’s included hours.', + { + value0: String(entitlement.usageWarningPercent) + } + )} +

+ ) : null} +

+ {entitlement.usageWindow + ? t('auto.components.billing.resets', 'Hours reset {{value0}}', { + value0: date(entitlement.usageWindow.end) + }) + : null} + {entitlement.period + ? ` · ${ + entitlement.cancelAtPeriodEnd + ? t('auto.components.billing.endsOn', 'Plan ends {{value0}}', { + value0: date(entitlement.period.end) + }) + : t('auto.components.billing.renewsOn', 'Renews {{value0}}', { + value0: date(entitlement.period.end) + }) + }` + : null} +

+
+ ) +} + +export function OverageControls({ + entitlement, + busy, + onSetOverage +}: { + entitlement: OrcaBillingEntitlement + busy: boolean + onSetOverage: (args: OrcaBillingOverageArgs) => void +}): React.JSX.Element { + const t = useTranslate() + const { overage } = entitlement + const [capDollars, setCapDollars] = useState(String(Math.round(overage.spendCapCents / 100))) + const parsedCap = Number(capDollars) + const capValid = capDollars.trim() !== '' && Number.isInteger(parsedCap) && parsedCap >= 0 + const rate = formatDollars(overage.centsPerHour) + return ( +
+
+
+

+ {t('auto.components.billing.overageTitle', 'Overage')} +

+

+ {t( + 'auto.components.billing.overageDescription', + 'Keep starting sandboxes after included hours at {{value0}}/hr Standard ({{value1}}/hr Large).', + { value0: rate, value1: formatDollars(overage.centsPerHour * 2) } + )} +

+
+ onSetOverage({ enabled })} + aria-label={t('auto.components.billing.overageToggle', 'Allow overage')} + /> +
+ {overage.optedIn ? ( +
+

+ {t( + 'auto.components.billing.overageSpent', + '{{value0}} of {{value1}} monthly cap spent', + { + value0: formatDollars(overage.spentCents), + value1: formatDollars(overage.spendCapCents) + } + )} +

+ {entitlement.canManageBilling ? ( +
{ + event.preventDefault() + if (capValid) { + onSetOverage({ enabled: true, spendCapCents: parsedCap * 100 }) + } + }} + > + + {t('auto.components.billing.capLabel', 'Monthly cap ($)')} + +
+ setCapDollars(event.target.value)} + aria-label={t('auto.components.billing.capLabel', 'Monthly cap ($)')} + /> +
+ +
+ ) : null} +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/billing/plan-billing-copy.ts b/src/renderer/src/components/billing/plan-billing-copy.ts new file mode 100644 index 00000000000..5c86a00aa36 --- /dev/null +++ b/src/renderer/src/components/billing/plan-billing-copy.ts @@ -0,0 +1,104 @@ +// User-facing copy for billing states, kept apart from the components that render it. +import type { Translate } from '@/i18n/use-translate' +import type { + OrcaBillingDenyReason, + OrcaBillingOpenResult, + OrcaBillingOverageResult +} from '../../../../shared/orca-billing' + +export function denyReasonCopy(reason: OrcaBillingDenyReason, t: Translate): string { + switch (reason) { + case 'no_subscription': + return t('auto.components.billing.reasonNoPlan', 'Hosted sandboxes need a paid plan.') + case 'subscription_inactive': + return t( + 'auto.components.billing.reasonInactive', + 'Your plan is not active. Choose a plan to start sandboxes.' + ) + case 'payment_grace_expired': + return t( + 'auto.components.billing.reasonGraceExpired', + 'Your payment is overdue, so sandboxes are suspended. Update your payment method to resume.' + ) + case 'payment_past_due': + return t( + 'auto.components.billing.reasonPastDue', + 'Your last payment failed. Update your payment method to start new sandboxes.' + ) + case 'included_hours_exhausted': + return t( + 'auto.components.billing.reasonExhaustedOptIn', + 'You have used all included hours this month. Turn on overage to keep starting sandboxes.' + ) + case 'overage_cap_reached': + return t( + 'auto.components.billing.reasonCapRaise', + 'You reached this month’s overage spend cap. An admin can raise it.' + ) + } +} + +export function statusLabel(status: string, t: Translate): string { + switch (status) { + case 'active': + return t('auto.components.billing.statusActive', 'Active') + case 'trialing': + return t('auto.components.billing.statusTrial', 'Trial') + case 'past_due': + return t('auto.components.billing.statusPastDue', 'Payment due') + case 'canceled': + return t('auto.components.billing.statusCanceled', 'Canceled') + default: + return t('auto.components.billing.statusInactive', 'Inactive') + } +} + +export function openFailureCopy(result: OrcaBillingOpenResult, t: Translate): string | null { + switch (result.status) { + case 'opened': + return null + case 'forbidden': + return t( + 'auto.components.billing.openForbidden', + 'Only an org owner or admin can manage billing.' + ) + case 'already-subscribed': + return t( + 'auto.components.billing.openAlreadySubscribed', + 'This account already has a plan. Use Manage billing to change it.' + ) + case 'no-billing-account': + return t( + 'auto.components.billing.openNoAccount', + 'There is no billing account yet. Choose a plan first.' + ) + case 'signed-out': + return t('auto.components.billing.openSignedOut', 'Sign in to Orca again to manage billing.') + case 'unconfigured': + return t('auto.components.billing.openUnconfigured', 'Billing is unavailable in this build.') + case 'failed': + return t('auto.components.billing.openFailed', 'Could not reach billing. Try again.') + } +} + +export function overageFailureCopy(result: OrcaBillingOverageResult, t: Translate): string | null { + switch (result.status) { + case 'ok': + return null + case 'cap-too-high': + return t('auto.components.billing.capTooHigh', 'That spend cap is above the allowed maximum.') + case 'no-subscription': + return t('auto.components.billing.overageNoPlan', 'Overage needs an active plan.') + case 'forbidden': + return t( + 'auto.components.billing.openForbidden', + 'Only an org owner or admin can manage billing.' + ) + case 'signed-out': + return t('auto.components.billing.openSignedOut', 'Sign in to Orca again to manage billing.') + case 'unconfigured': + return t('auto.components.billing.openUnconfigured', 'Billing is unavailable in this build.') + case 'failed': + return t('auto.components.billing.openFailed', 'Could not reach billing. Try again.') + } +} diff --git a/src/renderer/src/components/billing/plan-billing-fixtures.ts b/src/renderer/src/components/billing/plan-billing-fixtures.ts new file mode 100644 index 00000000000..396ba70166d --- /dev/null +++ b/src/renderer/src/components/billing/plan-billing-fixtures.ts @@ -0,0 +1,48 @@ +// Shared sample entitlements for billing tests and component fixtures. +import type { OrcaBillingEntitlement, OrcaBillingOffers } from '../../../../shared/orca-billing' + +const HOUR = 3600 + +export const sampleOffers: OrcaBillingOffers = { + team: { + label: 'Team', + monthlyPriceLabel: '$50/seat/mo', + annualPriceLabel: '$480/seat/yr', + monthlyPriceCentsPerSeat: 5000, + annualPriceCentsPerSeat: 48000, + largeMachineWeight: 2, + includedHoursPerSeat: 60, + concurrentSandboxesPerSeat: 3, + idleSleepMinutes: 15, + overageCentsPerHour: 75, + defaultSpendCapCents: 2500 + }, + enterprise: { + label: 'Enterprise', + priceLabel: 'Custom', + contactUrl: 'https://onorca.dev/contact-sales' + } +} + +export const sampleTeam: OrcaBillingEntitlement = { + plan: 'team', + planLabel: 'Team', + status: 'active', + interval: 'month', + seats: 2, + seatChange: null, + period: { start: Date.UTC(2026, 10, 3), end: Date.UTC(2026, 11, 3) }, + usageWindow: { start: Date.UTC(2026, 10, 3), end: Date.UTC(2026, 11, 3) }, + cancelAtPeriodEnd: false, + includedSeconds: 120 * HOUR, + usedSeconds: 41.5 * HOUR, + remainingIncludedSeconds: 78.5 * HOUR, + usageWarningPercent: null, + overageSeconds: 0, + overage: { optedIn: false, active: false, spendCapCents: 2500, spentCents: 0, centsPerHour: 75 }, + payment: { pastDueSince: null, graceEndsAt: null, suspendRunning: false }, + canStartSandbox: true, + reason: null, + canManageBilling: true, + offers: sampleOffers +} diff --git a/src/renderer/src/components/billing/plan-billing-format.test.ts b/src/renderer/src/components/billing/plan-billing-format.test.ts new file mode 100644 index 00000000000..37ba055838b --- /dev/null +++ b/src/renderer/src/components/billing/plan-billing-format.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { + formatDollars, + formatHours, + includedUsagePercent, + planBillingTone +} from './plan-billing-format' +import { sampleTeam as base } from './plan-billing-fixtures' + +describe('plan billing format', () => { + it('formats hours with at most one decimal', () => { + expect(formatHours(0)).toBe('0') + expect(formatHours(3600)).toBe('1') + expect(formatHours(5400)).toBe('1.5') + expect(formatHours(3600 + 1)).toBe('1') + expect(formatHours(-5)).toBe('0') + }) + + it('formats dollars, dropping cents for whole amounts', () => { + expect(formatDollars(75)).toBe('$0.75') + expect(formatDollars(150)).toBe('$1.50') + expect(formatDollars(2500)).toBe('$25') + }) + + it('clamps the usage bar', () => { + expect(includedUsagePercent({ ...base, usedSeconds: 24 * 3600 })).toBe(20) + expect(includedUsagePercent({ ...base, usedSeconds: 200 * 3600 })).toBe(100) + expect(includedUsagePercent({ ...base, includedSeconds: 0 })).toBe(0) + }) + + it('flags exhausted, ending and blocked plans', () => { + expect(planBillingTone(base)).toBe('ok') + expect(planBillingTone({ ...base, remainingIncludedSeconds: 0 })).toBe('warning') + expect(planBillingTone({ ...base, cancelAtPeriodEnd: true })).toBe('warning') + expect(planBillingTone({ ...base, canStartSandbox: false, reason: 'payment_past_due' })).toBe( + 'blocked' + ) + }) +}) diff --git a/src/renderer/src/components/billing/plan-billing-format.ts b/src/renderer/src/components/billing/plan-billing-format.ts new file mode 100644 index 00000000000..d839ccc4908 --- /dev/null +++ b/src/renderer/src/components/billing/plan-billing-format.ts @@ -0,0 +1,33 @@ +import type { OrcaBillingEntitlement } from '../../../../shared/orca-billing' + +/** Hours with at most one decimal; whole numbers drop the decimal. */ +export function formatHours(seconds: number): string { + const hours = Math.max(0, seconds) / 3600 + const rounded = Math.round(hours * 10) / 10 + return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1) +} + +/** Percent of included hours used, clamped to 0..100 (overage shows as full). */ +export function includedUsagePercent(entitlement: OrcaBillingEntitlement): number { + if (entitlement.includedSeconds <= 0) { + return 0 + } + return Math.min(100, Math.round((entitlement.usedSeconds / entitlement.includedSeconds) * 100)) +} + +export type PlanBillingTone = 'ok' | 'warning' | 'blocked' + +export function planBillingTone(entitlement: OrcaBillingEntitlement): PlanBillingTone { + if (!entitlement.canStartSandbox) { + return 'blocked' + } + return entitlement.remainingIncludedSeconds === 0 || entitlement.cancelAtPeriodEnd + ? 'warning' + : 'ok' +} + +/** "$0.75", "$25" — whole dollars drop the cents. */ +export function formatDollars(cents: number): string { + const dollars = Math.max(0, cents) / 100 + return Number.isInteger(dollars) ? `$${dollars}` : `$${dollars.toFixed(2)}` +} diff --git a/src/renderer/src/components/billing/plans/EnterprisePlanCard.tsx b/src/renderer/src/components/billing/plans/EnterprisePlanCard.tsx new file mode 100644 index 00000000000..9ab831628d7 --- /dev/null +++ b/src/renderer/src/components/billing/plans/EnterprisePlanCard.tsx @@ -0,0 +1,49 @@ +import { Building2, ExternalLink } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useTranslate } from '@/i18n/use-translate' +import type { OrcaBillingOffers } from '../../../../../shared/orca-billing' +import { PlanCardShell } from './PlanCardShell' + +export function EnterprisePlanCard({ + enterprise, + onContactSales +}: { + enterprise: OrcaBillingOffers['enterprise'] + onContactSales: () => void +}): React.JSX.Element { + const t = useTranslate() + return ( + + + {enterprise.label} + + } + price={

{enterprise.priceLabel}

} + priceNote={t('auto.components.billing.plans.entPriced', 'Priced for your organization')} + featuresLead={t('auto.components.billing.plans.entLead', 'Everything in Team, plus:')} + features={[ + t('auto.components.billing.plans.entByoc', 'Run sandboxes in your own cloud account'), + t('auto.components.billing.plans.entSso', 'SSO, SCIM provisioning and audit logs'), + t('auto.components.billing.plans.entNetwork', 'Network policy and egress controls'), + t('auto.components.billing.plans.entTerms', 'Custom limits, invoicing and terms') + ]} + footer={ + <> + +

+ {t( + 'auto.components.billing.plans.entHint', + 'Tell us about your team and where you run.' + )} +

+ + } + /> + ) +} diff --git a/src/renderer/src/components/billing/plans/PlanCardShell.tsx b/src/renderer/src/components/billing/plans/PlanCardShell.tsx new file mode 100644 index 00000000000..22c941d7479 --- /dev/null +++ b/src/renderer/src/components/billing/plans/PlanCardShell.tsx @@ -0,0 +1,64 @@ +import { Check } from 'lucide-react' +import type { ReactNode } from 'react' + +/** + * Shared plan card layout. Both cards use the same rows (title, price, details, features, + * footer) and stretch to the same height, so their dividers and buttons line up. + */ +export function PlanCardShell({ + id, + title, + badge, + price, + priceNote, + details, + featuresLead, + features, + footer +}: { + id: string + title: ReactNode + badge?: ReactNode + price: ReactNode + priceNote: ReactNode + details?: ReactNode + featuresLead?: string + features: string[] + footer: ReactNode +}): React.JSX.Element { + return ( +
+
+

+ {title} +

+ {badge} +
+
+ {price} +

{priceNote}

+
+ {details} +
+ {featuresLead ? ( +

{featuresLead}

+ ) : null} +
    + {features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+ {/* Fixed footer height (button + up to two lines) keeps both cards' dividers level. */} +
+ {footer} +
+
+ ) +} diff --git a/src/renderer/src/components/billing/plans/PlansDialogContent.tsx b/src/renderer/src/components/billing/plans/PlansDialogContent.tsx new file mode 100644 index 00000000000..fbd68354417 --- /dev/null +++ b/src/renderer/src/components/billing/plans/PlansDialogContent.tsx @@ -0,0 +1,45 @@ +import { DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { useTranslate } from '@/i18n/use-translate' +import { PlansView, isSubscribed, type PlansViewProps } from './PlansView' + +/** The plans page surface: heading plus plans. Rendered inside a . */ +export function PlansDialogContent(props: PlansViewProps): React.JSX.Element { + const t = useTranslate() + const subscribed = isSubscribed(props.result) + return ( + { + event.preventDefault() + if (event.currentTarget instanceof HTMLElement) { + event.currentTarget.focus() + } + }} + > + +

+ {t('auto.components.billing.plans.eyebrow', 'Orca Cloud')} +

+ + {subscribed + ? t('auto.components.billing.plans.titleSubscribed', 'Your Orca Cloud plan') + : t('auto.components.billing.plans.title', 'Cloud sandboxes for your whole team')} + + + {subscribed + ? t( + 'auto.components.billing.plans.subtitleSubscribed', + 'Usage this month, your plan, and what Enterprise adds.' + ) + : t( + 'auto.components.billing.plans.subtitle2', + 'Run Orca agents in hosted sandboxes that keep going when you step away.' + )} + +
+ +
+ ) +} diff --git a/src/renderer/src/components/billing/plans/PlansDialogHost.tsx b/src/renderer/src/components/billing/plans/PlansDialogHost.tsx new file mode 100644 index 00000000000..7e32192da75 --- /dev/null +++ b/src/renderer/src/components/billing/plans/PlansDialogHost.tsx @@ -0,0 +1,73 @@ +import { useState } from 'react' +import { Dialog } from '@/components/ui/dialog' +import { useTranslate } from '@/i18n/use-translate' +import type { OrcaBillingOpenResult } from '../../../../../shared/orca-billing' +import { openFailureCopy } from '../plan-billing-copy' +import { useOrcaBillingStatus } from '../use-orca-billing-status' +import { PlansDialogContent } from './PlansDialogContent' +import { closePlansPage, usePlansPageState } from './plans-page-store' +import { useOrgSeatCount } from './use-org-seat-count' +import { useAppStore } from '@/store/index' + +/** + * The plans page. A dialog because choosing a plan is a decision step in front of Checkout; + * it reads the billing service's offers, so prices and limits are never hardcoded here. + */ +export function PlansDialogHost(): React.JSX.Element { + const t = useTranslate() + const { open } = usePlansPageState() + const billing = useOrcaBillingStatus(open) + const orgId = useAppStore((state) => state.orcaProfileAuthStatus?.cloud?.activeOrgId ?? null) + const seats = useOrgSeatCount(open ? orgId : null) + const [busy, setBusy] = useState(false) + const [notice, setNotice] = useState(null) + + const run = async (action: () => Promise): Promise => { + setBusy(true) + setNotice(null) + const outcome = await action().catch((error: unknown): OrcaBillingOpenResult => ({ + status: 'failed', + error: String(error) + })) + setBusy(false) + setNotice( + outcome.status === 'opened' + ? t( + 'auto.components.billing.continueInBrowser', + 'Continue in your browser. This updates when you come back.' + ) + : openFailureCopy(outcome, t) + ) + } + + return ( + { + if (!next) { + closePlansPage() + setNotice(null) + } + }} + > + {open ? ( + + void run(() => + window.api.orcaProfiles.billingOpenCheckout({ plan: 'team', interval, seats: count }) + ) + } + onManage={() => void run(() => window.api.orcaProfiles.billingOpenPortal())} + onContactSales={(url) => void window.api.shell.openUrl(url)} + onRetry={billing.refresh} + /> + ) : null} + + ) +} diff --git a/src/renderer/src/components/billing/plans/PlansIntervalToggle.tsx b/src/renderer/src/components/billing/plans/PlansIntervalToggle.tsx new file mode 100644 index 00000000000..553f601513f --- /dev/null +++ b/src/renderer/src/components/billing/plans/PlansIntervalToggle.tsx @@ -0,0 +1,70 @@ +import { useTranslate } from '@/i18n/use-translate' +import { cn } from '@/lib/utils' +import type { OrcaBillingInterval } from '../../../../../shared/orca-billing' + +/** Monthly / Annual switch with a sliding thumb (still under reduced motion). */ +export function PlansIntervalToggle({ + value, + savingsPercent, + disabled, + onChange +}: { + value: OrcaBillingInterval + savingsPercent: number | null + disabled: boolean + onChange: (interval: OrcaBillingInterval) => void +}): React.JSX.Element { + const t = useTranslate() + const options: { interval: OrcaBillingInterval; label: string; hint: string | null }[] = [ + { interval: 'month', label: t('auto.components.billing.plans.monthly', 'Monthly'), hint: null }, + { + interval: 'year', + label: t('auto.components.billing.plans.annual', 'Annual'), + hint: + savingsPercent === null + ? null + : t('auto.components.billing.plans.saveShort', '−{{value0}}%', { + value0: String(savingsPercent) + }) + } + ] + return ( +
+
+ + {options.map((option) => ( + + ))} +
+
+ ) +} diff --git a/src/renderer/src/components/billing/plans/PlansView.fixture.tsx b/src/renderer/src/components/billing/plans/PlansView.fixture.tsx new file mode 100644 index 00000000000..2ccf272d2a2 --- /dev/null +++ b/src/renderer/src/components/billing/plans/PlansView.fixture.tsx @@ -0,0 +1,118 @@ +import { defineFixture } from '../../../component-fixtures/component-fixture' +import type { OrcaBillingEntitlement } from '../../../../../shared/orca-billing' +import { sampleTeam } from '../plan-billing-fixtures' +import { Dialog } from '@/components/ui/dialog' +import { PlansDialogContent } from './PlansDialogContent' +import type { PlansViewProps } from './PlansView' + +const HOUR = 3600 + +const handlers = { + busy: false, + notice: null, + seats: 4, + onCheckout: () => {}, + onManage: () => {}, + onContactSales: () => {}, + onRetry: () => {} +} + +const noPlan: OrcaBillingEntitlement = { + ...sampleTeam, + plan: null, + planLabel: null, + status: 'none', + interval: null, + seats: 0, + period: null, + usageWindow: null, + includedSeconds: 0, + usedSeconds: 0, + remainingIncludedSeconds: 0, + canStartSandbox: false, + reason: 'no_subscription' +} + +const width = 1280 + +function Page(props: Pick & Partial): React.JSX.Element { + return ( + + + + ) +} + +export const choosePlan = defineFixture({ + width, + render: () => +}) + +export const memberCannotBuy = defineFixture({ + width, + render: () => ( + + ) +}) + +export const currentPlan = defineFixture({ + width, + render: () => ( + + ) +}) + +export const paymentFailed = defineFixture({ + width, + render: () => ( + + ) +}) + +export const checkoutOpened = defineFixture({ + width, + render: () => ( + + ) +}) + +export const loading = defineFixture({ + width, + render: () => +}) + +export const loadFailed = defineFixture({ + width, + render: () => +}) diff --git a/src/renderer/src/components/billing/plans/PlansView.tsx b/src/renderer/src/components/billing/plans/PlansView.tsx new file mode 100644 index 00000000000..a1cbb39c4df --- /dev/null +++ b/src/renderer/src/components/billing/plans/PlansView.tsx @@ -0,0 +1,273 @@ +import { useState } from 'react' +import { KeyRound, LaptopMinimal, RefreshCw, Zap } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Progress } from '@/components/ui/progress' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import { useIntlLocale, useTranslate } from '@/i18n/use-translate' +import { + ORCA_BILLING_MAX_SEATS, + type OrcaBillingEntitlement, + type OrcaBillingInterval, + type OrcaBillingStatusResult +} from '../../../../../shared/orca-billing' +import { PaymentIssueNotice } from '../PaymentIssueNotice' +import { statusLabel } from '../plan-billing-copy' +import { formatHours, includedUsagePercent } from '../plan-billing-format' +import { EnterprisePlanCard } from './EnterprisePlanCard' +import { PlansIntervalToggle } from './PlansIntervalToggle' +import { TeamPlanCard, type TeamPlanCardMode } from './TeamPlanCard' +import { annualSavingsPercent, clampSeats } from './plans-pricing' + +export type PlansViewProps = { + result: OrcaBillingStatusResult | null + busy: boolean + notice: string | null + /** Seats Checkout will bill: org members, or 1 for a personal account. */ + seats: number + onCheckout: (args: { interval: OrcaBillingInterval; seats: number }) => void + onManage: () => void + onContactSales: (url: string) => void + onRetry: () => void +} + +export function isSubscribed(result: OrcaBillingStatusResult | null): boolean { + return ( + result?.status === 'ok' && + result.entitlement.plan !== null && + result.entitlement.status !== 'canceled' + ) +} + +function ValueProps(): React.JSX.Element { + const t = useTranslate() + // Same promises as the marketing site's /cloud page. + const items = [ + { + icon: LaptopMinimal, + title: t('auto.components.billing.plans.vpRunningTitle', 'Agents keep running'), + body: t( + 'auto.components.billing.plans.vpRunningBody2', + 'Sandboxes keep working while you are away and report back when they finish.' + ) + }, + { + icon: Zap, + title: t('auto.components.billing.plans.vpWakeTitle', 'Wake in seconds'), + body: t( + 'auto.components.billing.plans.vpWakeBody', + 'Idle sandboxes sleep to save hours and resume right where they left off.' + ) + }, + { + icon: KeyRound, + title: t('auto.components.billing.plans.vpSecretsTitle', 'Secrets never stored in the VM'), + body: t( + 'auto.components.billing.plans.vpSecretsBody2', + 'Credentials are injected into outbound requests and never touch the VM’s disk.' + ) + } + ] + return ( +
    + {items.map((item) => ( +
  • + + + +
    +

    {item.title}

    +

    {item.body}

    +
    +
  • + ))} +
+ ) +} + +function CurrentPlanStrip({ + entitlement +}: { + entitlement: OrcaBillingEntitlement +}): React.JSX.Element { + const t = useTranslate() + const locale = useIntlLocale() + const paymentIssue = entitlement.payment.pastDueSince !== null + const resets = entitlement.usageWindow + ? new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(entitlement.usageWindow.end) + : null + return ( +
+
+ {entitlement.planLabel} + + {t('auto.components.billing.plans.seatsAndPeriod', 'Seats: {{value0}} · {{value1}}', { + value0: String(entitlement.seats), + value1: + entitlement.interval === 'year' + ? t('auto.components.billing.annual', 'Annual') + : t('auto.components.billing.monthly', 'Monthly') + })} + + {/* The payment banner carries the alarm; the badge stays quiet. */} + + {statusLabel(entitlement.status, t)} + +
+ +
+ + {t( + 'auto.components.billing.hoursUsedMonth', + '{{value0}} of {{value1}} hours used this month', + { + value0: formatHours(entitlement.usedSeconds), + value1: formatHours(entitlement.includedSeconds) + } + )} + + + {resets + ? t( + 'auto.components.billing.plans.leftResets', + '{{value0}} hours left · resets {{value1}}', + { + value0: formatHours(entitlement.remainingIncludedSeconds), + value1: resets + } + ) + : null} + +
+
+ ) +} + +function PlaceholderCards({ + failed, + onRetry +}: { + failed: boolean + onRetry: () => void +}): React.JSX.Element { + const t = useTranslate() + return ( +
+ +
+
+
+
+ {failed ? ( + <> +

+ {t( + 'auto.components.billing.plans.loadFailed', + 'Could not load plans. Try again in a moment.' + )} +

+ + + ) : ( + <> + +

{t('auto.components.billing.plans.loading', 'Loading plans…')}

+ + )} +
+
+
+ ) +} + +export function PlansView(props: PlansViewProps): React.JSX.Element { + const t = useTranslate() + const [interval, setInterval] = useState('month') + const seats = clampSeats(props.seats, ORCA_BILLING_MAX_SEATS) + const result = props.result + + if (result?.status === 'signed-out' || result?.status === 'unconfigured') { + return ( +

+ {result.status === 'signed-out' + ? t('auto.components.billing.openSignedOut', 'Sign in to Orca again to manage billing.') + : t('auto.components.billing.openUnconfigured', 'Billing is unavailable in this build.')} +

+ ) + } + if (!result || result.status !== 'ok' || !result.entitlement.offers) { + return + } + + const entitlement = result.entitlement + const offers = result.entitlement.offers + const subscribed = isSubscribed(result) + const mode: TeamPlanCardMode = subscribed + ? 'current' + : entitlement.canManageBilling + ? 'choose' + : 'locked' + const paymentIssue = entitlement.payment.pastDueSince !== null + + return ( +
+ {subscribed ? ( + <> + + + + ) : ( + + )} + {mode === 'choose' ? ( + + ) : null} +
+ props.onCheckout({ interval, seats })} + onManage={props.onManage} + /> + props.onContactSales(offers.enterprise.contactUrl)} + /> +
+
+

+ {t( + 'auto.components.billing.plans.footHours', + 'Hours reset every month, including on annual plans. When they run out, new sandboxes wait until the next month unless overage is on.' + )} +

+

+ {t( + 'auto.components.billing.plans.footTerms', + 'Prices in USD, before tax. Overage stays off until an owner or admin turns it on and sets a spend cap. Cancel anytime; your plan runs to the end of the period.' + )} +

+
+
+ ) +} diff --git a/src/renderer/src/components/billing/plans/TeamPlanCard.tsx b/src/renderer/src/components/billing/plans/TeamPlanCard.tsx new file mode 100644 index 00000000000..dd83ba9fd66 --- /dev/null +++ b/src/renderer/src/components/billing/plans/TeamPlanCard.tsx @@ -0,0 +1,219 @@ +import { ExternalLink, Lock, Users } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import { useIntlLocale, useTranslate } from '@/i18n/use-translate' +import type { OrcaBillingInterval, OrcaBillingOffers } from '../../../../../shared/orca-billing' +import { PlanCardShell } from './PlanCardShell' +import { formatCents, perSeatMonthlyCents, periodTotalCents } from './plans-pricing' + +export type TeamPlanCardMode = 'choose' | 'current' | 'locked' + +export function TeamPlanCard({ + team, + interval, + seats, + mode, + busy, + notice, + hideManage, + onCheckout, + onManage +}: { + team: OrcaBillingOffers['team'] + interval: OrcaBillingInterval + seats: number + /** choose: can buy; current: already on Team; locked: only owners/admins can buy. */ + mode: TeamPlanCardMode + busy: boolean + notice: string | null + /** A payment banner already offers the same Portal action. */ + hideManage: boolean + onCheckout: () => void + onManage: () => void +}): React.JSX.Element { + const t = useTranslate() + const locale = useIntlLocale() + const monthly = perSeatMonthlyCents(team, interval) + const fullMonthly = team.monthlyPriceCentsPerSeat + const total = periodTotalCents(team, interval, seats) + const discounted = + interval === 'year' && monthly !== null && fullMonthly !== null && monthly < fullMonthly + + const features = [ + t( + 'auto.components.billing.plans.featureHours2', + '{{value0}} Standard hours (4 vCPU / 8 GB) per seat each month, pooled across your team', + { value0: String(team.includedHoursPerSeat) } + ), + t( + 'auto.components.billing.plans.featureLarge2', + 'Large (8 vCPU / 16 GB) uses hours at {{value0}}×', + { + value0: String(team.largeMachineWeight) + } + ), + t('auto.components.billing.plans.featureConcurrency', '{{value0}} running sandboxes per seat', { + value0: String(team.concurrentSandboxesPerSeat) + }), + t('auto.components.billing.plans.featureSleep2', 'Sleeps after {{value0}} minutes idle', { + value0: String(team.idleSleepMinutes) + }), + t( + 'auto.components.billing.plans.featureOverage', + 'Optional overage at {{value0}}/hr ({{value1}}/hr Large) with a monthly spend cap', + { + value0: formatCents(team.overageCentsPerHour, locale), + value1: formatCents(team.overageCentsPerHour * team.largeMachineWeight, locale) + } + ) + ] + + const price = ( + // Keyed so the figure fades in again when the period changes. +

+ {discounted && fullMonthly !== null ? ( + + {formatCents(fullMonthly, locale)} + + ) : null} + + {monthly === null + ? interval === 'month' + ? team.monthlyPriceLabel + : team.annualPriceLabel + : formatCents(monthly, locale)} + + {monthly === null ? null : ( + + {t('auto.components.billing.plans.perSeatMonth', 'per seat / month')} + + )} +

+ ) + + const priceNote = + interval === 'year' + ? team.annualPriceCentsPerSeat === null + ? t('auto.components.billing.plans.billedYearly', 'Billed yearly') + : t( + 'auto.components.billing.plans.billedYearlyAmount', + 'Billed {{value0}} per seat yearly', + { + value0: formatCents(team.annualPriceCentsPerSeat, locale) + } + ) + : t('auto.components.billing.plans.billedMonthly', 'Billed monthly') + + const details = + mode === 'choose' ? ( +
+

+ {t('auto.components.billing.plans.seatsOne', 'Seats: {{value0}} (one per team member)', { + value0: String(seats) + })} +

+ {total === null ? null : ( +

+ {interval === 'year' + ? t('auto.components.billing.plans.totalYear', '{{value0}} / year', { + value0: formatCents(total, locale) + }) + : t('auto.components.billing.plans.totalMonth', '{{value0}} / month', { + value0: formatCents(total, locale) + })} +

+ )} +
+ ) : null + + let footer: React.ReactNode + if (mode === 'choose') { + footer = ( + <> + +

+ {notice ?? + t( + 'auto.components.billing.plans.seatsFollow2', + 'Seats follow your team: new members add a seat now; removed seats drop off at renewal.' + )} +

+ + ) + } else if (mode === 'current') { + footer = hideManage ? ( +

+ {t( + 'auto.components.billing.plans.fixPaymentAbove', + 'Update your payment method above to keep your plan active.' + )} +

+ ) : ( + <> + +

+ {notice ?? + t( + 'auto.components.billing.plans.manageHint', + 'Switch monthly or annual, update payment, or cancel.' + )} +

+ + ) + } else { + footer = ( +
+ +

+ {t( + 'auto.components.billing.plans.lockedBody', + 'Only an owner or admin of your org can choose a plan. Ask one of them to open Plan & billing in Orca.' + )} +

+
+ ) + } + + return ( + + + {team.label} + + } + badge={ + mode === 'current' ? ( + + {t('auto.components.billing.plans.currentPlan', 'Current plan')} + + ) : null + } + price={price} + priceNote={priceNote} + details={details} + features={features} + footer={footer} + /> + ) +} diff --git a/src/renderer/src/components/billing/plans/plans-page-store.ts b/src/renderer/src/components/billing/plans/plans-page-store.ts new file mode 100644 index 00000000000..2872728f2f9 --- /dev/null +++ b/src/renderer/src/components/billing/plans/plans-page-store.ts @@ -0,0 +1,38 @@ +// Open state for the plans page. Any entry point (account menu, Settings, a "start a cloud +// sandbox" upsell) calls openPlansPage(); PlansDialogHost renders it. +import { useSyncExternalStore } from 'react' + +export type PlansPageSource = 'account-menu' | 'settings' | 'sandbox' | 'other' + +type PlansPageState = { open: boolean; source: PlansPageSource | null } + +let state: PlansPageState = { open: false, source: null } +const listeners = new Set<() => void>() + +function set(next: PlansPageState): void { + state = next + for (const listener of listeners) { + listener() + } +} + +export function openPlansPage(source: PlansPageSource = 'other'): void { + set({ open: true, source }) +} + +export function closePlansPage(): void { + set({ open: false, source: state.source }) +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function getPlansPageState(): PlansPageState { + return state +} + +export function usePlansPageState(): PlansPageState { + return useSyncExternalStore(subscribe, getPlansPageState) +} diff --git a/src/renderer/src/components/billing/plans/plans-pricing.test.ts b/src/renderer/src/components/billing/plans/plans-pricing.test.ts new file mode 100644 index 00000000000..19dd7bb2c5b --- /dev/null +++ b/src/renderer/src/components/billing/plans/plans-pricing.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { sampleOffers } from '../plan-billing-fixtures' +import { + annualSavingsPercent, + clampSeats, + formatCents, + perSeatMonthlyCents, + periodTotalCents +} from './plans-pricing' +import { closePlansPage, getPlansPageState, openPlansPage } from './plans-page-store' + +const team = sampleOffers.team + +describe('plans pricing', () => { + it('shows annual as its monthly equivalent and totals per period', () => { + expect(perSeatMonthlyCents(team, 'month')).toBe(5000) + expect(perSeatMonthlyCents(team, 'year')).toBe(4000) + expect(periodTotalCents(team, 'month', 3)).toBe(15000) + expect(periodTotalCents(team, 'year', 3)).toBe(144000) + expect(periodTotalCents(team, 'year', 0)).toBe(48000) + }) + + it('computes the annual saving and hides it when annual is not cheaper', () => { + expect(annualSavingsPercent(team)).toBe(20) + expect(annualSavingsPercent({ ...team, annualPriceCentsPerSeat: 60000 })).toBeNull() + expect(annualSavingsPercent({ ...team, monthlyPriceCentsPerSeat: null })).toBeNull() + }) + + it('falls back to labels when an older service sends no cents', () => { + const legacy = { ...team, monthlyPriceCentsPerSeat: null, annualPriceCentsPerSeat: null } + expect(perSeatMonthlyCents(legacy, 'month')).toBeNull() + expect(periodTotalCents(legacy, 'year', 2)).toBeNull() + }) + + it('formats whole and fractional dollars', () => { + expect(formatCents(5000, 'en-US')).toBe('$50') + expect(formatCents(75, 'en-US')).toBe('$0.75') + expect(formatCents(144000, 'en-US')).toBe('$1,440') + }) + + it('clamps seats to 1..max', () => { + expect(clampSeats(0, 500)).toBe(1) + expect(clampSeats(Number.NaN, 500)).toBe(1) + expect(clampSeats(900, 500)).toBe(500) + expect(clampSeats(3.6, 500)).toBe(4) + }) +}) + +describe('plans page store', () => { + it('opens from any entry point and closes', () => { + openPlansPage('sandbox') + expect(getPlansPageState()).toEqual({ open: true, source: 'sandbox' }) + closePlansPage() + expect(getPlansPageState().open).toBe(false) + }) +}) diff --git a/src/renderer/src/components/billing/plans/plans-pricing.ts b/src/renderer/src/components/billing/plans/plans-pricing.ts new file mode 100644 index 00000000000..5e94f0aac1b --- /dev/null +++ b/src/renderer/src/components/billing/plans/plans-pricing.ts @@ -0,0 +1,56 @@ +// Price math for the plans page. Every number comes from the billing service's offers. +import type { OrcaBillingInterval, OrcaBillingOffers } from '../../../../../shared/orca-billing' + +type TeamOffer = OrcaBillingOffers['team'] + +/** What one seat costs per month on this interval (annual shown as its monthly equivalent). */ +export function perSeatMonthlyCents(team: TeamOffer, interval: OrcaBillingInterval): number | null { + if (interval === 'month') { + return team.monthlyPriceCentsPerSeat + } + return team.annualPriceCentsPerSeat === null + ? null + : Math.round(team.annualPriceCentsPerSeat / 12) +} + +/** The amount billed each period for `seats` seats. */ +export function periodTotalCents( + team: TeamOffer, + interval: OrcaBillingInterval, + seats: number +): number | null { + const perSeat = + interval === 'month' ? team.monthlyPriceCentsPerSeat : team.annualPriceCentsPerSeat + return perSeat === null ? null : perSeat * Math.max(1, seats) +} + +/** Whole-percent saving of annual over twelve monthly payments, or null when not cheaper. */ +export function annualSavingsPercent(team: TeamOffer): number | null { + const monthly = team.monthlyPriceCentsPerSeat + const annual = team.annualPriceCentsPerSeat + if (monthly === null || annual === null || monthly <= 0) { + return null + } + const percent = Math.round((1 - annual / (monthly * 12)) * 100) + return percent > 0 ? percent : null +} + +/** "$50", "$40", "$0.75", "$1,200" in the given locale. */ +export function formatCents(cents: number, locale: string): string { + const whole = cents % 100 === 0 + return new Intl.NumberFormat(locale, { + style: 'currency', + currency: 'USD', + minimumFractionDigits: whole ? 0 : 2, + maximumFractionDigits: whole ? 0 : 2 + }).format(cents / 100) +} + +export const MIN_SEATS = 1 + +export function clampSeats(value: number, max: number): number { + if (!Number.isFinite(value)) { + return MIN_SEATS + } + return Math.min(max, Math.max(MIN_SEATS, Math.round(value))) +} diff --git a/src/renderer/src/components/billing/plans/use-org-seat-count.ts b/src/renderer/src/components/billing/plans/use-org-seat-count.ts new file mode 100644 index 00000000000..a64bca3c338 --- /dev/null +++ b/src/renderer/src/components/billing/plans/use-org-seat-count.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react' + +/** + * Seats Team bills at Checkout: the active org's members (pending invites excluded), or 1 for a + * personal account. Billing re-reads the count from auth; this is only for showing the total. + */ +export function useOrgSeatCount(orgId: string | null): number { + const [members, setMembers] = useState<{ orgId: string; count: number } | null>(null) + + useEffect(() => { + if (!orgId) { + return + } + let disposed = false + void window.api.orcaProfiles + .orgMembersList({ orgId }) + .then((result) => { + if (!disposed && result.status === 'ok') { + setMembers({ orgId, count: result.roster.members.length }) + } + }) + .catch(() => undefined) + return () => { + disposed = true + } + }, [orgId]) + + if (!orgId) { + return 1 + } + return members?.orgId === orgId ? Math.max(1, members.count) : 1 +} diff --git a/src/renderer/src/components/billing/register-plan-billing-menu-item.test.ts b/src/renderer/src/components/billing/register-plan-billing-menu-item.test.ts new file mode 100644 index 00000000000..534d06c7b35 --- /dev/null +++ b/src/renderer/src/components/billing/register-plan-billing-menu-item.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest' + +const openSettingsTarget = vi.fn() +const openSettingsPage = vi.fn() +vi.mock('@/store/index', () => ({ + useAppStore: { getState: () => ({ openSettingsTarget, openSettingsPage }) } +})) + +import { + getRegisteredAccountMenuItems, + selectVisibleAccountMenuItems +} from '../sidebar/account-menu/account-menu-items' +import { BILLING_CAPABILITY, registerPlanBillingMenuItem } from './register-plan-billing-menu-item' +import { getPlansPageState } from './plans/plans-page-store' + +describe('Plan & billing and Team account menu items', () => { + it('register behind the billing capability and open Orca account settings', () => { + const dispose = registerPlanBillingMenuItem() + const items = getRegisteredAccountMenuItems() + expect(items.map((item) => [item.id, item.label(), item.requiredCapability])).toEqual([ + ['orca.plan-billing', 'Plan & billing', BILLING_CAPABILITY], + ['orca.team', 'Team', BILLING_CAPABILITY] + ]) + items[0]?.onSelect() + expect(getPlansPageState()).toEqual({ open: true, source: 'account-menu' }) + items[1]?.onSelect() + expect(openSettingsTarget).toHaveBeenCalledWith({ pane: 'orca-account', repoId: null }) + expect(openSettingsPage).toHaveBeenCalled() + + const visible = (flags: Record): string[] => + selectVisibleAccountMenuItems(getRegisteredAccountMenuItems(), { + activeProfileId: 'p1', + configured: true, + state: 'connected', + persistence: 'encrypted', + capabilities: { flags, refreshedAt: 0 } + }).map((entry) => entry.id) + expect(visible({})).toEqual([]) + expect(visible({ [BILLING_CAPABILITY]: true })).toEqual(['orca.plan-billing', 'orca.team']) + + dispose() + expect(getRegisteredAccountMenuItems()).toHaveLength(0) + }) +}) diff --git a/src/renderer/src/components/billing/register-plan-billing-menu-item.ts b/src/renderer/src/components/billing/register-plan-billing-menu-item.ts new file mode 100644 index 00000000000..67c0333424c --- /dev/null +++ b/src/renderer/src/components/billing/register-plan-billing-menu-item.ts @@ -0,0 +1,38 @@ +import { CreditCard, Users } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store/index' +import { registerAccountMenuItem } from '../sidebar/account-menu/account-menu-items' +import { openPlansPage } from './plans/plans-page-store' + +/** Cloud capability flag that turns billing on per account (server-controlled rollout). */ +export const BILLING_CAPABILITY = 'billing' + +function openAccountSettings(): void { + const state = useAppStore.getState() + state.openSettingsTarget({ pane: 'orca-account', repoId: null }) + state.openSettingsPage() +} + +/** Account menu: "Plan & billing" opens the plans page; "Team" opens the Orca account settings. */ +export function registerPlanBillingMenuItem(): () => void { + const disposeBilling = registerAccountMenuItem({ + id: 'orca.plan-billing', + label: () => translate('auto.components.billing.menuItem', 'Plan & billing'), + icon: CreditCard, + order: 10, + requiredCapability: BILLING_CAPABILITY, + onSelect: () => openPlansPage('account-menu') + }) + const disposeTeam = registerAccountMenuItem({ + id: 'orca.team', + label: () => translate('auto.components.billing.team.menuItem', 'Team'), + icon: Users, + order: 11, + requiredCapability: BILLING_CAPABILITY, + onSelect: openAccountSettings + }) + return () => { + disposeBilling() + disposeTeam() + } +} diff --git a/src/renderer/src/components/billing/team/TeamMembersSection.tsx b/src/renderer/src/components/billing/team/TeamMembersSection.tsx new file mode 100644 index 00000000000..91cc2d242c4 --- /dev/null +++ b/src/renderer/src/components/billing/team/TeamMembersSection.tsx @@ -0,0 +1,114 @@ +import { useState } from 'react' +import { useConfirmationDialog } from '@/components/confirmation-dialog-context' +import { useTranslate } from '@/i18n/use-translate' +import type { OrcaProfileOrgMemberMutationResult } from '../../../../../shared/orca-profiles' +import { useOrcaBillingStatus } from '../use-orca-billing-status' +import { TeamMembersView } from './TeamMembersView' +import { mutationFailureCopy } from './team-roles' +import { useOrgRoster } from './use-org-roster' + +/** + * Team members for the active org: list, invite by email (accepted at Orca's sign-in provider), + * change roles, remove. Membership lives in Orca Cloud auth; Team seats follow it automatically + * (auth pushes removals, billing re-reads the count), so nothing here touches Stripe. + */ +export function TeamMembersSection({ + orgId, + viewerUserId +}: { + orgId: string + viewerUserId: string | null +}): React.JSX.Element { + const t = useTranslate() + const confirm = useConfirmationDialog() + const roster = useOrgRoster(orgId) + const billing = useOrcaBillingStatus(true) + const [busy, setBusy] = useState(false) + const [notice, setNotice] = useState(null) + + const run = async ( + action: () => Promise, + success: string + ): Promise => { + setBusy(true) + setNotice(null) + const outcome = await action().catch((error: unknown): OrcaProfileOrgMemberMutationResult => ({ + status: 'failed', + error: String(error) + })) + setBusy(false) + setNotice(outcome.status === 'ok' ? success : mutationFailureCopy(outcome, t)) + if (outcome.status === 'ok') { + roster.refresh() + billing.refresh() + } + } + + const entitlement = billing.result?.status === 'ok' ? billing.result.entitlement : null + const billedSeats = entitlement && entitlement.plan !== null ? entitlement.seats : null + + return ( + { + roster.refresh() + billing.refresh() + }} + onInvite={(email, role) => + void run( + () => window.api.orcaProfiles.orgMemberInvite({ orgId, email, role }), + t( + 'auto.components.billing.team.invited', + 'Invite sent to {{value0}}. They join (and become a billed seat) when they accept.', + { value0: email } + ) + ) + } + onRevoke={(email) => + void run( + () => window.api.orcaProfiles.orgInviteRevoke({ orgId, email }), + t('auto.components.billing.team.revoked', 'Invite revoked.') + ) + } + onChangeRole={(member, role) => { + if (member.userId) { + const userId = member.userId + void run( + () => window.api.orcaProfiles.orgMemberChangeRole({ orgId, userId, role }), + t('auto.components.billing.team.roleChanged', 'Role updated.') + ) + } + }} + onRemove={(member) => { + const userId = member.userId + if (!userId) { + return + } + void confirm({ + title: t('auto.components.billing.team.removeTitle', 'Remove {{value0}}?', { + value0: member.displayName ?? member.email + }), + description: t( + 'auto.components.billing.team.removeDescription2', + 'They lose access to the org right away. Their seat stays paid until your plan renews, then drops off.' + ), + confirmLabel: t('auto.components.billing.team.removeConfirm', 'Remove member'), + confirmVariant: 'destructive' + }).then((accepted) => { + if (accepted) { + void run( + () => window.api.orcaProfiles.orgMemberRemove({ orgId, userId }), + t('auto.components.billing.team.removed', 'Member removed.') + ) + } + }) + }} + /> + ) +} diff --git a/src/renderer/src/components/billing/team/TeamMembersView.fixture.tsx b/src/renderer/src/components/billing/team/TeamMembersView.fixture.tsx new file mode 100644 index 00000000000..534925037be --- /dev/null +++ b/src/renderer/src/components/billing/team/TeamMembersView.fixture.tsx @@ -0,0 +1,58 @@ +import { defineFixture } from '../../../component-fixtures/component-fixture' +import type { OrcaOrgMembersRoster } from '../../../../../shared/orca-profiles' +import { TeamMembersView } from './TeamMembersView' + +const roster: OrcaOrgMembersRoster = { + members: [ + { userId: 'u1', email: 'ada@acme.dev', displayName: 'Ada Lovelace', role: 'owner' }, + { userId: 'u2', email: 'grace@acme.dev', displayName: 'Grace Hopper', role: 'admin' }, + { userId: 'u3', email: 'linus@acme.dev', role: 'member' }, + { userId: null, email: 'sso-only@acme.dev', role: 'member' } + ], + pendingInvites: [ + { email: 'margaret@acme.dev', role: 'member', createdAt: Date.UTC(2026, 10, 7) } + ], + viewerRole: 'owner', + canManageMembers: true +} + +const handlers = { + loading: false, + busy: false, + onRefresh: () => {}, + onInvite: () => {}, + onRevoke: () => {}, + onChangeRole: () => {}, + onRemove: () => {} +} + +export const ownerView = defineFixture({ + width: 600, + render: () => ( + + ) +}) + +export const memberView = defineFixture({ + width: 600, + render: () => ( + + ) +}) diff --git a/src/renderer/src/components/billing/team/TeamMembersView.tsx b/src/renderer/src/components/billing/team/TeamMembersView.tsx new file mode 100644 index 00000000000..0eaa955521c --- /dev/null +++ b/src/renderer/src/components/billing/team/TeamMembersView.tsx @@ -0,0 +1,280 @@ +import { useState } from 'react' +import { Mail, RefreshCw, UserMinus, UserPlus } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { useIntlLocale, useTranslate } from '@/i18n/use-translate' +import { cn } from '@/lib/utils' +import type { + OrcaOrgMember, + OrcaOrgRole, + OrcaProfileOrgMembersListResult +} from '../../../../../shared/orca-profiles' +import { assignableRoles, canActOnRole, roleLabel } from './team-roles' + +export type TeamMembersViewProps = { + result: OrcaProfileOrgMembersListResult | null + loading: boolean + busy: boolean + notice: string | null + /** Seats Team bills (from billing), or null when there is no plan or billing is off. */ + billedSeats: number | null + /** Seats paid this period stay until renewal, then drop to the member count. */ + seatChange: { seatsAtRenewal: number; effectiveAt: number } | null + viewerUserId: string | null + onRefresh: () => void + onInvite: (email: string, role: OrcaOrgRole) => void + onRevoke: (email: string) => void + onChangeRole: (member: OrcaOrgMember, role: OrcaOrgRole) => void + onRemove: (member: OrcaOrgMember) => void +} + +const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +function RoleSelect({ + value, + roles, + disabled, + onChange, + label +}: { + value: OrcaOrgRole + roles: OrcaOrgRole[] + disabled: boolean + onChange: (role: OrcaOrgRole) => void + label: string +}): React.JSX.Element { + const t = useTranslate() + return ( + + ) +} + +export function TeamMembersView(props: TeamMembersViewProps): React.JSX.Element { + const t = useTranslate() + const { result, loading, busy, notice, billedSeats } = props + const locale = useIntlLocale() + const [email, setEmail] = useState('') + const [inviteRole, setInviteRole] = useState('member') + + const header = ( +
+

+ {t('auto.components.billing.team.title', 'Team')} +

+ +
+ ) + + if (!result || result.status !== 'ok') { + return ( +
+ {header} +

+ {result === null + ? t('auto.components.billing.team.loading', 'Loading your team…') + : t('auto.components.billing.team.loadFailed', 'Could not load your team. Try again.')} +

+
+ ) + } + + const roster = result.roster + const manager = roster.canManageMembers + const roles = assignableRoles(roster.viewerRole) + const emailValid = EMAIL.test(email.trim()) + + return ( +
+ {header} +

+ {billedSeats === null + ? t('auto.components.billing.team.memberCount', '{{value0}} members', { + value0: String(roster.members.length) + }) + : t( + 'auto.components.billing.team.memberSeats', + '{{value0}} members · {{value1}} seats billed. Seats follow membership; pending invites are free until accepted.', + { value0: String(roster.members.length), value1: String(billedSeats) } + )} + {props.seatChange + ? ` ${t( + 'auto.components.billing.team.seatsDrop', + 'Seats paid this period stay until {{value0}}, then drop to {{value1}}.', + { + value0: new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format( + props.seatChange.effectiveAt + ), + value1: String(props.seatChange.seatsAtRenewal) + } + )}` + : null} +

+ +
    + {roster.members.map((member) => { + const self = member.userId !== null && member.userId === props.viewerUserId + const actionable = + manager && + !self && + member.userId !== null && + canActOnRole(roster.viewerRole, member.role) + return ( +
  • +
    +

    + {member.displayName ?? member.email} + {self ? ( + + {t('auto.components.billing.team.you', '(you)')} + + ) : null} +

    + {member.displayName ? ( +

    {member.email}

    + ) : null} +
    + {actionable ? ( + props.onChangeRole(member, role)} + label={t('auto.components.billing.team.roleFor', 'Role for {{value0}}', { + value0: member.email + })} + /> + ) : ( + {roleLabel(member.role, t)} + )} + {actionable ? ( + + ) : null} +
  • + ) + })} +
+ + {manager && roster.pendingInvites.length > 0 ? ( +
+

+ {t('auto.components.billing.team.pending', 'Pending invites')} +

+
    + {roster.pendingInvites.map((invite) => ( +
  • + +

    {invite.email}

    + {roleLabel(invite.role, t)} + {canActOnRole(roster.viewerRole, invite.role) ? ( + + ) : null} +
  • + ))} +
+
+ ) : null} + + {manager ? ( +
{ + event.preventDefault() + if (emailValid) { + props.onInvite(email.trim(), inviteRole) + setEmail('') + } + }} + > +
+ setEmail(event.target.value)} + aria-label={t('auto.components.billing.team.inviteEmail', 'Email to invite')} + /> +
+ + + + ) : ( +

+ {t( + 'auto.components.billing.team.askAdmin', + 'Only owners and admins can invite or remove members.' + )} +

+ )} + + {notice ?

{notice}

: null} +
+ ) +} diff --git a/src/renderer/src/components/billing/team/team-roles.test.ts b/src/renderer/src/components/billing/team/team-roles.test.ts new file mode 100644 index 00000000000..c903cbe9e46 --- /dev/null +++ b/src/renderer/src/components/billing/team/team-roles.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { assignableRoles, canActOnRole, canManageMembers } from './team-roles' + +describe('team roles (mirrors auth org-roles)', () => { + it('lets owners act on everyone, admins on admins and members, members on no one', () => { + expect(canActOnRole('owner', 'owner')).toBe(true) + expect(canActOnRole('admin', 'owner')).toBe(false) + expect(canActOnRole('admin', 'admin')).toBe(true) + expect(canActOnRole('admin', 'member')).toBe(true) + expect(canActOnRole('member', 'member')).toBe(false) + expect(canManageMembers('member')).toBe(false) + }) + + it('offers only assignable roles', () => { + expect(assignableRoles('owner')).toEqual(['owner', 'admin', 'member']) + expect(assignableRoles('admin')).toEqual(['admin', 'member']) + expect(assignableRoles('member')).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/billing/team/team-roles.ts b/src/renderer/src/components/billing/team/team-roles.ts new file mode 100644 index 00000000000..505bed8fae4 --- /dev/null +++ b/src/renderer/src/components/billing/team/team-roles.ts @@ -0,0 +1,67 @@ +// Mirrors Orca Cloud auth's role rules (apps/auth/src/accounts/org-roles.ts) so the UI only offers +// actions the server will accept: owner > admin > member; only owners act on owners. +import type { Translate } from '@/i18n/use-translate' +import type { + OrcaOrgRole, + OrcaProfileOrgMemberMutationResult +} from '../../../../../shared/orca-profiles' + +export const ORG_ROLES: readonly OrcaOrgRole[] = ['owner', 'admin', 'member'] + +export function canManageMembers(role: OrcaOrgRole): boolean { + return role === 'owner' || role === 'admin' +} + +export function canActOnRole(actor: OrcaOrgRole, target: OrcaOrgRole): boolean { + if (!canManageMembers(actor)) { + return false + } + return target !== 'owner' || actor === 'owner' +} + +/** Roles the viewer may assign (to an invite or an existing member). */ +export function assignableRoles(actor: OrcaOrgRole): OrcaOrgRole[] { + return ORG_ROLES.filter((role) => canActOnRole(actor, role)) +} + +export function roleLabel(role: OrcaOrgRole, t: Translate): string { + switch (role) { + case 'owner': + return t('auto.components.billing.team.roleOwner', 'Owner') + case 'admin': + return t('auto.components.billing.team.roleAdmin', 'Admin') + case 'member': + return t('auto.components.billing.team.roleMember', 'Member') + } +} + +export function mutationFailureCopy( + result: OrcaProfileOrgMemberMutationResult, + t: Translate +): string | null { + switch (result.status) { + case 'ok': + return null + case 'conflict': + return result.reason === 'already_member' + ? t('auto.components.billing.team.alreadyMember', 'That person is already a member.') + : t('auto.components.billing.team.alreadyInvited', 'That person already has an invite.') + case 'invalid': + return result.reason === 'cannot_remove_self' + ? t('auto.components.billing.team.cannotRemoveSelf', 'You cannot remove yourself.') + : t('auto.components.billing.team.cannotChangeOwnRole', 'You cannot change your own role.') + case 'forbidden': + return t( + 'auto.components.billing.team.forbidden', + 'Only owners and admins can manage members, and only owners can manage owners.' + ) + case 'not-found': + return t('auto.components.billing.team.notFound', 'That member or invite no longer exists.') + case 'reconnect-required': + return t('auto.components.billing.openSignedOut', 'Sign in to Orca again to manage billing.') + case 'unconfigured': + return t('auto.components.billing.openUnconfigured', 'Billing is unavailable in this build.') + case 'failed': + return t('auto.components.billing.team.failed', 'Could not update the team. Try again.') + } +} diff --git a/src/renderer/src/components/billing/team/use-org-roster.ts b/src/renderer/src/components/billing/team/use-org-roster.ts new file mode 100644 index 00000000000..ee0869fd5c6 --- /dev/null +++ b/src/renderer/src/components/billing/team/use-org-roster.ts @@ -0,0 +1,35 @@ +import { useCallback, useEffect, useState } from 'react' +import type { OrcaProfileOrgMembersListResult } from '../../../../../shared/orca-profiles' + +type RosterState = { loading: boolean; result: OrcaProfileOrgMembersListResult | null } + +function readRoster(orgId: string): Promise { + return window.api.orcaProfiles + .orgMembersList({ orgId }) + .catch((error: unknown): OrcaProfileOrgMembersListResult => ({ + status: 'failed', + error: error instanceof Error ? error.message : String(error) + })) +} + +/** The org's live roster (members, pending invites, viewer role), re-read on `refresh()`. */ +export function useOrgRoster(orgId: string): RosterState & { refresh: () => void } { + const [state, setState] = useState({ loading: true, result: null }) + const [reloadKey, setReloadKey] = useState(0) + const refresh = useCallback(() => setReloadKey((key) => key + 1), []) + + useEffect(() => { + let disposed = false + setState((previous) => ({ ...previous, loading: true })) + void readRoster(orgId).then((result) => { + if (!disposed) { + setState({ loading: false, result }) + } + }) + return () => { + disposed = true + } + }, [orgId, reloadKey]) + + return { ...state, refresh } +} diff --git a/src/renderer/src/components/billing/use-orca-billing-status.ts b/src/renderer/src/components/billing/use-orca-billing-status.ts new file mode 100644 index 00000000000..465a745b341 --- /dev/null +++ b/src/renderer/src/components/billing/use-orca-billing-status.ts @@ -0,0 +1,54 @@ +import { useCallback, useEffect, useState } from 'react' +import type { OrcaBillingStatusResult } from '../../../../shared/orca-billing' + +type BillingStatusState = { + loading: boolean + result: OrcaBillingStatusResult | null +} + +function readBillingStatus(): Promise { + return window.api.orcaProfiles + .billingStatus() + .catch((error: unknown): OrcaBillingStatusResult => ({ + status: 'failed', + error: error instanceof Error ? error.message : String(error) + })) +} + +/** + * Reads the billing status while `enabled`, again on `refresh()`, and whenever the window regains + * focus (the user comes back from Checkout or the Portal in their browser). + */ +export function useOrcaBillingStatus( + enabled: boolean +): BillingStatusState & { refresh: () => void } { + const [state, setState] = useState({ loading: enabled, result: null }) + const [reloadKey, setReloadKey] = useState(0) + const refresh = useCallback(() => setReloadKey((key) => key + 1), []) + + useEffect(() => { + if (!enabled) { + return + } + let disposed = false + let latest = 0 + const load = (): void => { + const request = ++latest + setState((previous) => ({ ...previous, loading: true })) + void readBillingStatus().then((result) => { + // A newer request, or unmounting, supersedes this one. + if (!disposed && request === latest) { + setState({ loading: false, result }) + } + }) + } + load() + window.addEventListener('focus', load) + return () => { + disposed = true + window.removeEventListener('focus', load) + } + }, [enabled, reloadKey]) + + return { ...state, refresh } +} diff --git a/src/renderer/src/components/settings/accounts/orca/OrcaAccountSettingsPane.tsx b/src/renderer/src/components/settings/accounts/orca/OrcaAccountSettingsPane.tsx index c4c5645da55..b086409dc40 100644 --- a/src/renderer/src/components/settings/accounts/orca/OrcaAccountSettingsPane.tsx +++ b/src/renderer/src/components/settings/accounts/orca/OrcaAccountSettingsPane.tsx @@ -9,6 +9,8 @@ import { useTranslate, type Translate } from '@/i18n/use-translate' import { useAppStore } from '@/store/index' import { OrcaProfileSignOutConfirmDialog } from '../../../orca-profiles/OrcaProfileSignOutConfirmDialog' import { OrcaSignInButton, OrcaSignInWaitNotice } from '../../../OrcaSignInProgress' +import { PlanBillingSection } from '../../../billing/PlanBillingSection' +import { TeamMembersSection } from '../../../billing/team/TeamMembersSection' function accountStatusCopy( state: 'local' | 'unconfigured' | 'connected' | 'reconnect-required' | undefined, @@ -128,6 +130,14 @@ export function OrcaAccountSettingsPane(): React.JSX.Element { {connected ? null : }
+ {connected ? : null} + {connected && authStatus?.cloud?.activeOrgId ? ( + + ) : null} +

{t('auto.components.settings.orcaAccount.benefitsTitle', 'Included with your account')} diff --git a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx deleted file mode 100644 index 7bf57319fe7..00000000000 --- a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx +++ /dev/null @@ -1,330 +0,0 @@ -import React, { useState } from 'react' -import { - BookOpen, - CircleHelp, - ExternalLink, - Github, - Keyboard, - Loader2, - MessageSquareText, - RefreshCw, - RotateCw, - School, - ScrollText, - Settings -} from 'lucide-react' -import { toast } from 'sonner' -import logo from '../../../../../resources/logo.svg' -import { useAppStore } from '@/store' -import { Button } from '@/components/ui/button' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { useMountedRef } from '@/hooks/useMountedRef' -import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' -import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' -import { showOnboardingFromRenderer } from '../onboarding/show-onboarding-event' -import { SetupGuideProgressRing } from '../setup-guide/SetupGuideProgressRing' -import { useSetupGuideProgress } from '../setup-guide/use-setup-guide-progress' -import { lazyWithRetry } from '@/lib/lazy-with-retry' -import type * as SidebarFeedbackDialogModule from './sidebar-feedback/SidebarFeedbackDialog' -import { useTranslate } from '@/i18n/use-translate' -import { getUpdateCheckClickOptions, getUpdateCheckHint } from '@/lib/update-check-click-options' -import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open' - -// Keep feedback off the boot graph; menu warming and selection share the same lazy module entry. -const loadSidebarFeedbackDialog = (): Promise => - import('./sidebar-feedback/SidebarFeedbackDialog') - -const SidebarFeedbackDialog = lazyWithRetry( - () => loadSidebarFeedbackDialog().then((module) => ({ default: module.SidebarFeedbackDialog })), - { reloadKey: 'sidebar-feedback-dialog' } -) - -const DOCS_URL = 'https://www.onorca.dev/docs' -const CHANGELOG_URL = 'https://onorca.dev/changelog' -const GITHUB_URL = 'https://github.com/stablyai/orca' -const DISCORD_URL = 'https://discord.gg/fzjDKHxv8Q' -const X_URL = 'https://x.com/orca_build' -const NO_UPDATE_CHECK_MODIFIERS = { - altKey: false, - ctrlKey: false, - metaKey: false, - shiftKey: false -} - -function DiscordIcon(): React.JSX.Element { - return ( - - ) -} - -function XIcon(): React.JSX.Element { - return ( - - ) -} - -function ExternalMenuItem({ - label, - url, - icon -}: { - label: string - url: string - icon: React.ReactNode -}): React.JSX.Element { - return ( - openAppHttpLink(url)}> - {icon} - {label} - - - ) -} - -export function SidebarSettingsHelpMenu(): React.JSX.Element { - const t = useTranslate() - const openModal = useAppStore((s) => s.openModal) - const openSettingsPage = useAppStore((s) => s.openSettingsPage) - const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) - const updateStatus = useAppStore((s) => s.updateStatus) - const setupProgress = useSetupGuideProgress(true, false, false) - - const settingsShortcut = useShortcutKeyDetails('app.settings') - const [menuOpen, setMenuOpen] = useState(false) - const [feedbackOpen, setFeedbackOpen] = useState(false) - // Retain the mounted dialog through its closing animation. - const [feedbackDialogMounted, setFeedbackDialogMounted] = useState(false) - const [isRestartingOrca, setIsRestartingOrca] = useState(false) - const lastShowOnboardingAtRef = React.useRef(0) - const updateCheckModifiersRef = React.useRef(NO_UPDATE_CHECK_MODIFIERS) - const mountedRef = useMountedRef() - const updateCheckHint = getUpdateCheckHint() - - const showMilestones = - setupProgress.ready && setupProgress.coreDoneCount < setupProgress.coreTotal - - const handleMenuOpenChange = (open: boolean): void => { - setMenuOpen(open) - updateCheckModifiersRef.current = NO_UPDATE_CHECK_MODIFIERS - if (open) { - // Warm while users read the menu so feedback is loaded before selection. - void loadSidebarFeedbackDialog().catch(() => {}) - } - } - - const handleOpenFeedback = (): void => { - setFeedbackDialogMounted(true) - setFeedbackOpen(true) - } - - const handleShowOnboarding = (): void => { - const now = Date.now() - if (now - lastShowOnboardingAtRef.current < 500) { - return - } - lastShowOnboardingAtRef.current = now - void showOnboardingFromRenderer() - } - - const handleRestartOrca = (): void => { - if (isRestartingOrca) { - return - } - setIsRestartingOrca(true) - toast.info(t('auto.components.sidebar.SidebarSettingsHelpMenu.5161eef55d', 'Restarting Orca…')) - void window.api.app.restart().catch((error) => { - if (mountedRef.current) { - setIsRestartingOrca(false) - toast.error( - t('auto.components.sidebar.SidebarSettingsHelpMenu.4e8f5710d3', "Couldn't restart Orca."), - { - description: error instanceof Error ? error.message : undefined - } - ) - } - }) - } - - const openShortcutsSettings = (): void => { - openSettingsTarget({ pane: 'shortcuts', repoId: null }) - openSettingsPage() - } - - const handleCheckForUpdatesPointerDown = (event: React.PointerEvent): void => { - updateCheckModifiersRef.current = { - altKey: event.altKey, - ctrlKey: event.ctrlKey, - metaKey: event.metaKey, - shiftKey: event.shiftKey - } - } - - const handleCheckForUpdates = (): void => { - const modifiers = updateCheckModifiersRef.current - updateCheckModifiersRef.current = NO_UPDATE_CHECK_MODIFIERS - void window.api.updater.check(getUpdateCheckClickOptions(modifiers)) - } - - const openMilestones = (): void => { - openModal('setup-guide', { telemetrySource: 'help_menu' }) - } - - return ( - <> -

- - - - - - {t('auto.components.sidebar.SidebarSettingsHelpMenu.a428c25998', 'Settings')} - {settingsShortcut.keys.length > 0 ? ( - - ) : null} - - - - - - - - - - - {t('auto.components.sidebar.SidebarSettingsHelpMenu.2991a0106c', 'Help')} - - - - - - {t( - 'auto.components.sidebar.SidebarSettingsHelpMenu.e565171a7c', - 'Keyboard Shortcuts' - )} - - - - - {t('auto.components.sidebar.SidebarSettingsHelpMenu.4cf5b868d7', 'Send Feedback')} - - {showMilestones ? ( - - - {t('auto.components.sidebar.SidebarSettingsHelpMenu.f8a2c91d4e', 'Milestones')} - - - ) : null} - - - {t('auto.components.sidebar.SidebarSettingsHelpMenu.b7e4d2a19c', 'Onboarding')} - - } - /> - } - /> - - } - /> - openAppHttpLink(DISCORD_URL)}> - - {t('auto.components.sidebar.SidebarSettingsHelpMenu.eb9884e55b', 'Discord')} - - - openAppHttpLink(X_URL)}> - - {t('auto.components.sidebar.SidebarSettingsHelpMenu.c4f8e1b72a', 'X')} - - - - - {updateStatus.state === 'checking' ? ( - - ) : ( - - )} - {t('auto.components.sidebar.SidebarSettingsHelpMenu.29c56f30ee', 'Check for Updates')} - - - - - {t('auto.components.sidebar.SidebarSettingsHelpMenu.ad3d3ed7f1', 'Restart Orca')} - - - -
- {feedbackDialogMounted ? ( - - - - ) : null} - - ) -} diff --git a/src/renderer/src/components/sidebar/SidebarToolbar.fixture.tsx b/src/renderer/src/components/sidebar/SidebarToolbar.fixture.tsx new file mode 100644 index 00000000000..8724aa2ce31 --- /dev/null +++ b/src/renderer/src/components/sidebar/SidebarToolbar.fixture.tsx @@ -0,0 +1,80 @@ +import { defineFixture } from '../../component-fixtures/component-fixture' +import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles' +import SidebarToolbar from './SidebarToolbar' + +const settle = (ms = 300): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + +const openAccountMenu = async (frame: HTMLElement): Promise => { + frame + .querySelector('[data-account-menu-trigger]') + ?.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0 })) + await settle() +} + +const openHelpSubmenu = async (frame: HTMLElement): Promise => { + await openAccountMenu(frame) + const help = Array.from( + document.querySelectorAll('[data-slot="dropdown-menu-sub-trigger"]') + ).find((element) => element.textContent?.includes('Help')) + help?.focus() + help?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })) + await settle() +} + +const signedIn: OrcaProfileAuthStatus = { + activeProfileId: 'local-default', + configured: true, + state: 'connected', + persistence: 'encrypted', + cloud: { + cloudProfileId: 'fixture', + userId: 'fixture-user', + email: 'ada@example.com', + displayName: 'Ada Lovelace', + linkedAt: 0 + } +} + +const signedOut: OrcaProfileAuthStatus = { + activeProfileId: 'local-default', + configured: true, + state: 'local', + persistence: 'encrypted' +} + +const footer = ({ + width = 303, + height = 96, + authStatus = signedOut, + interact +}: { + width?: number + height?: number + authStatus?: OrcaProfileAuthStatus + interact?: (frame: HTMLElement) => Promise +}) => + defineFixture({ + width, + store: { persistedUIReady: true, orcaProfileAuthStatus: authStatus }, + interact, + render: () => ( +
+ {}} /> +
+ ) + }) + +export const signedOutFooter = footer({}) +export const signedInFooter = footer({ authStatus: signedIn }) +export const narrowSignedInFooter = footer({ width: 220, authStatus: signedIn }) +export const signedOutMenu = footer({ height: 260, interact: openAccountMenu }) +export const signedInMenu = footer({ height: 260, authStatus: signedIn, interact: openAccountMenu }) +export const helpSubmenu = footer({ + width: 560, + height: 480, + authStatus: signedIn, + interact: openHelpSubmenu +}) diff --git a/src/renderer/src/components/sidebar/SidebarToolbar.tsx b/src/renderer/src/components/sidebar/SidebarToolbar.tsx index ca55a74416b..a88f7526ea6 100644 --- a/src/renderer/src/components/sidebar/SidebarToolbar.tsx +++ b/src/renderer/src/components/sidebar/SidebarToolbar.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import { ScrollToCurrentWorkspaceToolbarButton } from './worktree-list/navigation/ScrollToCurrentWorkspaceToolbarButton' -import { SidebarSettingsHelpMenu } from './SidebarSettingsHelpMenu' +import { SidebarAccountMenu } from './account-menu/SidebarAccountMenu' import { useTranslate } from '@/i18n/use-translate' import { useAppStore } from '@/store' import { hasFeatureInteraction } from '../../../../shared/feature-interactions' @@ -69,7 +69,7 @@ const SidebarToolbar = React.memo(function SidebarToolbar({
- +
diff --git a/src/renderer/src/components/sidebar/account-menu/SidebarAccountMenu.test.tsx b/src/renderer/src/components/sidebar/account-menu/SidebarAccountMenu.test.tsx new file mode 100644 index 00000000000..1bbf4bf0627 --- /dev/null +++ b/src/renderer/src/components/sidebar/account-menu/SidebarAccountMenu.test.tsx @@ -0,0 +1,138 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { OrcaProfileAuthStatus } from '../../../../../shared/orca-profiles' +import { registerAccountMenuItem } from './account-menu-items' +import { SidebarAccountMenu } from './SidebarAccountMenu' + +vi.mock('../../setup-guide/use-setup-guide-progress', () => ({ + useSetupGuideProgress: () => ({ ready: false, coreDoneCount: 0, coreTotal: 0 }) +})) + +const initialState = useAppStore.getState() +const disposers: (() => void)[] = [] + +const connected: OrcaProfileAuthStatus = { + activeProfileId: 'local-default', + configured: true, + state: 'connected', + persistence: 'encrypted', + cloud: { + cloudProfileId: 'cp', + userId: 'u', + email: 'ada@example.com', + displayName: 'Ada Lovelace', + linkedAt: 0 + }, + capabilities: { flags: { 'team-admin': true }, refreshedAt: 0 } +} + +afterEach(() => { + cleanup() + for (const dispose of disposers.splice(0)) { + dispose() + } + useAppStore.setState(initialState, true) + vi.restoreAllMocks() +}) + +function renderOpenMenu(): HTMLElement { + render( + + + + ) + const trigger = screen.getByRole('button', { name: 'Account' }) + fireEvent.pointerDown(trigger, { button: 0, pointerType: 'mouse', ctrlKey: false }) + return trigger +} + +it('groups the account header, Settings, and Help, and opens Settings', () => { + const openSettingsPage = vi.fn() + useAppStore.setState({ orcaProfileAuthStatus: connected, openSettingsPage }) + renderOpenMenu() + + expect(screen.getByRole('menuitem', { name: /Ada Lovelace/ })).toHaveTextContent( + 'ada@example.com' + ) + expect(screen.getByRole('menuitem', { name: 'Help' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('menuitem', { name: 'Settings' })) + expect(openSettingsPage).toHaveBeenCalledTimes(1) +}) + +it('starts sign-in from the header when signed out', () => { + const connectCurrentOrcaProfile = vi.fn(async () => null) + useAppStore.setState({ + orcaProfileAuthStatus: { + activeProfileId: 'local-default', + configured: true, + state: 'local', + persistence: 'encrypted' + }, + connectCurrentOrcaProfile + }) + renderOpenMenu() + + fireEvent.click(screen.getByRole('menuitem', { name: /Sign in/ })) + expect(connectCurrentOrcaProfile).toHaveBeenCalledTimes(1) +}) + +it('renders registered items only when their capability flag is on', () => { + const onSelect = vi.fn() + disposers.push( + registerAccountMenuItem({ + id: 'gated', + label: () => 'Gated item', + requiredCapability: 'team-admin', + onSelect + }), + registerAccountMenuItem({ + id: 'hidden', + label: () => 'Hidden item', + requiredCapability: 'not-granted', + onSelect: vi.fn() + }) + ) + useAppStore.setState({ orcaProfileAuthStatus: connected }) + renderOpenMenu() + + expect(screen.queryByRole('menuitem', { name: 'Hidden item' })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('menuitem', { name: 'Gated item' })) + expect(onSelect).toHaveBeenCalledTimes(1) +}) + +it('hides capability-gated items while signed out', () => { + disposers.push( + registerAccountMenuItem({ + id: 'gated', + label: () => 'Gated item', + requiredCapability: 'team-admin', + onSelect: vi.fn() + }) + ) + useAppStore.setState({ + orcaProfileAuthStatus: { ...connected, state: 'reconnect-required' } + }) + renderOpenMenu() + + expect(screen.getByRole('menuitem', { name: /Sign in again/ })).toBeInTheDocument() + expect(screen.queryByRole('menuitem', { name: 'Gated item' })).not.toBeInTheDocument() +}) + +it('omits the account header in builds without Orca sign-in', () => { + useAppStore.setState({ + orcaProfileAuthStatus: { + activeProfileId: 'local-default', + configured: false, + state: 'unconfigured', + persistence: 'none' + } + }) + renderOpenMenu() + + expect(screen.queryByRole('menuitem', { name: /Sign in/ })).not.toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: 'Settings' })).toBeInTheDocument() +}) diff --git a/src/renderer/src/components/sidebar/account-menu/SidebarAccountMenu.tsx b/src/renderer/src/components/sidebar/account-menu/SidebarAccountMenu.tsx new file mode 100644 index 00000000000..089779bb6f5 --- /dev/null +++ b/src/renderer/src/components/sidebar/account-menu/SidebarAccountMenu.tsx @@ -0,0 +1,221 @@ +import React, { useState } from 'react' +import { Settings, UserRound } from 'lucide-react' +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { useMountedRef } from '@/hooks/useMountedRef' +import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' +import { useTranslate } from '@/i18n/use-translate' +import { cn } from '@/lib/utils' +import { useSetupGuideProgress } from '../../setup-guide/use-setup-guide-progress' +import { getAccountMenuIdentity, type AccountMenuIdentity } from './account-menu-identity' +import { selectVisibleAccountMenuItems, useRegisteredAccountMenuItems } from './account-menu-items' +import { + loadSidebarFeedbackDialog, + SidebarFeedbackDialog, + SidebarHelpSubmenu +} from './SidebarHelpSubmenu' + +function AccountAvatar({ + identity, + className +}: { + identity: AccountMenuIdentity + className?: string +}): React.JSX.Element { + return ( + + ) +} + +export function SidebarAccountMenu(): React.JSX.Element { + const t = useTranslate() + const authStatus = useAppStore((s) => s.orcaProfileAuthStatus) + const signingIn = useAppStore((s) => Boolean(s.orcaProfileSignInProgress)) + const connectCurrentOrcaProfile = useAppStore((s) => s.connectCurrentOrcaProfile) + const openSettingsPage = useAppStore((s) => s.openSettingsPage) + const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) + const setupProgress = useSetupGuideProgress(true, false, false) + const settingsShortcut = useOptionalShortcutLabel('app.settings') + const registeredItems = useRegisteredAccountMenuItems() + const [menuOpen, setMenuOpen] = useState(false) + const [feedbackOpen, setFeedbackOpen] = useState(false) + // Retain the mounted dialog through its closing animation. + const [feedbackDialogMounted, setFeedbackDialogMounted] = useState(false) + const [isRestartingOrca, setIsRestartingOrca] = useState(false) + const mountedRef = useMountedRef() + + const identity = getAccountMenuIdentity(authStatus) + const extensionItems = selectVisibleAccountMenuItems(registeredItems, authStatus) + const milestones = + setupProgress.ready && setupProgress.coreDoneCount < setupProgress.coreTotal + ? { done: setupProgress.coreDoneCount, total: setupProgress.coreTotal } + : null + const accountLabel = t('auto.components.sidebar.SidebarAccountMenu.account', 'Account') + + const handleMenuOpenChange = (open: boolean): void => { + setMenuOpen(open) + if (open) { + // Warm while users read the menu so feedback is loaded before selection. + void loadSidebarFeedbackDialog().catch(() => {}) + } + } + + const openAccountSettings = (): void => { + openSettingsTarget({ pane: 'orca-account', repoId: null }) + openSettingsPage() + } + + // Builds without Orca sign-in (and the unloaded state) have no account to show or connect. + const showAccountHeader = identity.signedIn || authStatus?.configured === true + + const handleAccountHeaderSelect = (): void => { + // The account pane hosts sign-out and the pending sign-in link. + if (identity.signedIn || signingIn) { + openAccountSettings() + return + } + void connectCurrentOrcaProfile() + } + + const handleOpenFeedback = (): void => { + setFeedbackDialogMounted(true) + setFeedbackOpen(true) + } + + const handleRestartOrca = (): void => { + if (isRestartingOrca) { + return + } + setIsRestartingOrca(true) + toast.info(t('auto.components.sidebar.SidebarSettingsHelpMenu.5161eef55d', 'Restarting Orca…')) + void window.api.app.restart().catch((error) => { + if (mountedRef.current) { + setIsRestartingOrca(false) + toast.error( + t('auto.components.sidebar.SidebarSettingsHelpMenu.4e8f5710d3', "Couldn't restart Orca."), + { + description: error instanceof Error ? error.message : undefined + } + ) + } + }) + } + + return ( + <> + + + + + + + + + {identity.signedIn ? identity.name : accountLabel} + + + + {showAccountHeader ? ( + <> + + + + {identity.signedIn ? ( + <> + {identity.name} + {identity.name !== identity.email ? ( + + {identity.email} + + ) : null} + + ) : ( + <> + + {identity.needsReconnect + ? t('auto.components.settings.orcaAccount.signInAgain', 'Sign in again') + : t('auto.components.sidebar.SidebarAccountMenu.signIn', 'Sign in')} + + + {signingIn + ? t('auto.components.OrcaSignInProgress.signingIn', 'Signing in…') + : t('auto.components.settings.orcaAccount.account', 'Orca account')} + + + )} + + + + + ) : null} + {extensionItems.length > 0 ? ( + <> + {extensionItems.map((item) => { + const Icon = item.icon + return ( + + {Icon ? : null} + {item.label()} + + ) + })} + + + ) : null} + + + {t('auto.components.sidebar.SidebarSettingsHelpMenu.a428c25998', 'Settings')} + {settingsShortcut ? ( + {settingsShortcut} + ) : null} + + + + + {feedbackDialogMounted ? ( + + + + ) : null} + + ) +} diff --git a/src/renderer/src/components/sidebar/account-menu/SidebarHelpSubmenu.tsx b/src/renderer/src/components/sidebar/account-menu/SidebarHelpSubmenu.tsx new file mode 100644 index 00000000000..fc9e95a4fff --- /dev/null +++ b/src/renderer/src/components/sidebar/account-menu/SidebarHelpSubmenu.tsx @@ -0,0 +1,232 @@ +import React from 'react' +import { + BookOpen, + CircleHelp, + ExternalLink, + Github, + Keyboard, + Loader2, + MessageSquareText, + RefreshCw, + RotateCw, + School, + ScrollText +} from 'lucide-react' +import logo from '../../../../../../resources/logo.svg' +import { useAppStore } from '@/store' +import { + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger +} from '@/components/ui/dropdown-menu' +import { showOnboardingFromRenderer } from '../../onboarding/show-onboarding-event' +import { SetupGuideProgressRing } from '../../setup-guide/SetupGuideProgressRing' +import { lazyWithRetry } from '@/lib/lazy-with-retry' +import type * as SidebarFeedbackDialogModule from '../sidebar-feedback/SidebarFeedbackDialog' +import { useTranslate } from '@/i18n/use-translate' +import { getUpdateCheckClickOptions, getUpdateCheckHint } from '@/lib/update-check-click-options' +import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open' + +// Keep feedback off the boot graph; menu warming and selection share the same lazy module entry. +export const loadSidebarFeedbackDialog = (): Promise => + import('../sidebar-feedback/SidebarFeedbackDialog') + +export const SidebarFeedbackDialog = lazyWithRetry( + () => loadSidebarFeedbackDialog().then((module) => ({ default: module.SidebarFeedbackDialog })), + { reloadKey: 'sidebar-feedback-dialog' } +) + +const DOCS_URL = 'https://www.onorca.dev/docs' +const CHANGELOG_URL = 'https://onorca.dev/changelog' +const GITHUB_URL = 'https://github.com/stablyai/orca' +const DISCORD_URL = 'https://discord.gg/fzjDKHxv8Q' +const X_URL = 'https://x.com/orca_build' +const NO_UPDATE_CHECK_MODIFIERS = { + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false +} + +function DiscordIcon(): React.JSX.Element { + return ( + + ) +} + +function XIcon(): React.JSX.Element { + return ( + + ) +} + +function ExternalMenuItem({ + label, + url, + icon +}: { + label: string + url: string + icon: React.ReactNode +}): React.JSX.Element { + return ( + openAppHttpLink(url)}> + {icon} + {label} + + + ) +} + +export type SidebarHelpMilestones = { done: number; total: number } + +/** Every entry the sidebar's former "?" menu offered, now nested under Help in the account menu. */ +export function SidebarHelpSubmenu({ + milestones, + onOpenFeedback, + restartPending, + onRestart +}: { + milestones: SidebarHelpMilestones | null + onOpenFeedback: () => void + restartPending: boolean + onRestart: () => void +}): React.JSX.Element { + const t = useTranslate() + const openModal = useAppStore((s) => s.openModal) + const openSettingsPage = useAppStore((s) => s.openSettingsPage) + const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) + const updateStatus = useAppStore((s) => s.updateStatus) + const lastShowOnboardingAtRef = React.useRef(0) + const updateCheckModifiersRef = React.useRef(NO_UPDATE_CHECK_MODIFIERS) + const updateCheckHint = getUpdateCheckHint() + + const handleShowOnboarding = (): void => { + const now = Date.now() + if (now - lastShowOnboardingAtRef.current < 500) { + return + } + lastShowOnboardingAtRef.current = now + void showOnboardingFromRenderer() + } + + const openShortcutsSettings = (): void => { + openSettingsTarget({ pane: 'shortcuts', repoId: null }) + openSettingsPage() + } + + const handleCheckForUpdatesPointerDown = (event: React.PointerEvent): void => { + updateCheckModifiersRef.current = { + altKey: event.altKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + shiftKey: event.shiftKey + } + } + + const handleCheckForUpdates = (): void => { + const modifiers = updateCheckModifiersRef.current + updateCheckModifiersRef.current = NO_UPDATE_CHECK_MODIFIERS + void window.api.updater.check(getUpdateCheckClickOptions(modifiers)) + } + + const openMilestones = (): void => { + openModal('setup-guide', { telemetrySource: 'help_menu' }) + } + + return ( + + + + {t('auto.components.sidebar.SidebarSettingsHelpMenu.2991a0106c', 'Help')} + + + + + {t('auto.components.sidebar.SidebarSettingsHelpMenu.e565171a7c', 'Keyboard Shortcuts')} + + + + + {t('auto.components.sidebar.SidebarSettingsHelpMenu.4cf5b868d7', 'Send Feedback')} + + {milestones ? ( + + + {t('auto.components.sidebar.SidebarSettingsHelpMenu.f8a2c91d4e', 'Milestones')} + + + ) : null} + + + {t('auto.components.sidebar.SidebarSettingsHelpMenu.b7e4d2a19c', 'Onboarding')} + + } + /> + } + /> + + } + /> + } + /> + } + /> + + + {updateStatus.state === 'checking' ? ( + + ) : ( + + )} + {t('auto.components.sidebar.SidebarSettingsHelpMenu.29c56f30ee', 'Check for Updates')} + + + + + {t('auto.components.sidebar.SidebarSettingsHelpMenu.ad3d3ed7f1', 'Restart Orca')} + + + + ) +} diff --git a/src/renderer/src/components/sidebar/account-menu/account-menu-identity.ts b/src/renderer/src/components/sidebar/account-menu/account-menu-identity.ts new file mode 100644 index 00000000000..a803249901f --- /dev/null +++ b/src/renderer/src/components/sidebar/account-menu/account-menu-identity.ts @@ -0,0 +1,18 @@ +import type { OrcaProfileAuthStatus } from '../../../../../shared/orca-profiles' + +export type AccountMenuIdentity = + | { signedIn: false; needsReconnect: boolean } + | { signedIn: true; initial: string; name: string; email: string } + +export function getAccountMenuIdentity( + authStatus: OrcaProfileAuthStatus | null +): AccountMenuIdentity { + const cloud = authStatus?.cloud + if (authStatus?.state !== 'connected' || !cloud) { + return { signedIn: false, needsReconnect: authStatus?.state === 'reconnect-required' } + } + const name = cloud.displayName?.trim() || cloud.email + // Array.from keeps a leading astral character (e.g. emoji) whole instead of half a surrogate pair. + const initial = (Array.from(name.trim())[0] ?? '?').toLocaleUpperCase() + return { signedIn: true, initial, name, email: cloud.email } +} diff --git a/src/renderer/src/components/sidebar/account-menu/account-menu-items.test.ts b/src/renderer/src/components/sidebar/account-menu/account-menu-items.test.ts new file mode 100644 index 00000000000..a52c004498d --- /dev/null +++ b/src/renderer/src/components/sidebar/account-menu/account-menu-items.test.ts @@ -0,0 +1,43 @@ +import { afterEach, expect, it } from 'vitest' +import { + getRegisteredAccountMenuItems, + registerAccountMenuItem, + selectVisibleAccountMenuItems, + type AccountMenuItem +} from './account-menu-items' + +const disposers: (() => void)[] = [] + +afterEach(() => { + for (const dispose of disposers.splice(0)) { + dispose() + } +}) + +function register(id: string, order?: number): () => void { + const entry: AccountMenuItem = { id, label: () => id, order, onSelect: () => {} } + const dispose = registerAccountMenuItem(entry) + disposers.push(dispose) + return dispose +} + +function visibleIds(): string[] { + return selectVisibleAccountMenuItems(getRegisteredAccountMenuItems(), null).map( + (entry) => entry.id + ) +} + +it('sorts by order, keeping registration order for ties', () => { + register('b', 10) + register('a', 10) + register('c', 5) + expect(visibleIds()).toEqual(['c', 'b', 'a']) +}) + +it('replaces an entry with the same id and ignores the replaced entry disposer', () => { + const disposeOriginal = register('item', 1) + register('other', 2) + register('item', 3) + disposeOriginal() + expect(visibleIds()).toEqual(['other', 'item']) +}) diff --git a/src/renderer/src/components/sidebar/account-menu/account-menu-items.ts b/src/renderer/src/components/sidebar/account-menu/account-menu-items.ts new file mode 100644 index 00000000000..c2b6dbc6c0f --- /dev/null +++ b/src/renderer/src/components/sidebar/account-menu/account-menu-items.ts @@ -0,0 +1,63 @@ +import { useSyncExternalStore } from 'react' +import type { ComponentType } from 'react' +import type { OrcaProfileAuthStatus } from '../../../../../shared/orca-profiles' + +export type AccountMenuItem = { + /** Stable id; registering the same id again replaces the earlier entry. */ + id: string + /** Called at render time so registered labels follow the active locale. */ + label: () => string + icon?: ComponentType<{ className?: string }> + /** Lower sorts first; ties keep registration order. */ + order?: number + /** Hide the item unless the signed-in account reports this capability flag as true. */ + requiredCapability?: string + onSelect: () => void +} + +let items: readonly AccountMenuItem[] = [] +const listeners = new Set<() => void>() + +function emit(): void { + for (const listener of listeners) { + listener() + } +} + +/** Adds an entry to the sidebar account menu; returns a disposer that removes exactly this entry. */ +export function registerAccountMenuItem(item: AccountMenuItem): () => void { + items = [...items.filter((existing) => existing.id !== item.id), item] + emit() + return () => { + if (!items.includes(item)) { + return + } + items = items.filter((existing) => existing !== item) + emit() + } +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function getRegisteredAccountMenuItems(): readonly AccountMenuItem[] { + return items +} + +export function selectVisibleAccountMenuItems( + registered: readonly AccountMenuItem[], + authStatus: OrcaProfileAuthStatus | null +): AccountMenuItem[] { + const flags = authStatus?.state === 'connected' ? authStatus.capabilities?.flags : undefined + return registered + .filter((item) => !item.requiredCapability || flags?.[item.requiredCapability] === true) + .map((item, index) => ({ item, index })) + .sort((a, b) => (a.item.order ?? 0) - (b.item.order ?? 0) || a.index - b.index) + .map(({ item }) => item) +} + +export function useRegisteredAccountMenuItems(): readonly AccountMenuItem[] { + return useSyncExternalStore(subscribe, getRegisteredAccountMenuItems) +} diff --git a/src/renderer/src/i18n/locales/de.json b/src/renderer/src/i18n/locales/de.json index 6ef3112131e..c02f719dc08 100644 --- a/src/renderer/src/i18n/locales/de.json +++ b/src/renderer/src/i18n/locales/de.json @@ -6272,6 +6272,10 @@ "selectedProjects_one": "{{count}} Projekt", "selectedProjects_other": "{{count}} Projekte" }, + "SidebarAccountMenu": { + "account": "Konto", + "signIn": "Anmelden" + }, "SidebarSettingsHelpMenu": { "ad3d3ed7f1": "Orca neu starten", "29c56f30ee": "Nach Updates suchen", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9a2acc7c060..935905cb929 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6581,6 +6581,10 @@ "selectedProjects_one": "{{count}} project", "selectedProjects_other": "{{count}} projects" }, + "SidebarAccountMenu": { + "account": "Account", + "signIn": "Sign in" + }, "SidebarSettingsHelpMenu": { "ad3d3ed7f1": "Restart Orca", "29c56f30ee": "Check for Updates", @@ -19524,6 +19528,147 @@ "setting-global-off": "Folder trust is turned off in Agents settings, so agents always ask.", "keyword-trust": "trust", "keyword-hooks": "hooks" + }, + "billing": { + "reasonNoPlan": "Hosted sandboxes need a paid plan.", + "reasonInactive": "Your plan is not active. Choose a plan to start sandboxes.", + "reasonPastDue": "Your last payment failed. Update your payment method to start new sandboxes.", + "openForbidden": "Only an org owner or admin can manage billing.", + "openAlreadySubscribed": "This account already has a plan. Use Manage billing to change it.", + "openNoAccount": "There is no billing account yet. Choose a plan first.", + "openSignedOut": "Sign in to Orca again to manage billing.", + "openUnconfigured": "Billing is unavailable in this build.", + "openFailed": "Could not reach billing. Try again.", + "hoursLeft": "{{value0}} hours left", + "usageLabel": "Included hours used", + "endsOn": "Plan ends {{value0}}", + "renewsOn": "Renews {{value0}}", + "continueInBrowser": "Continue in your browser. This updates when you come back.", + "title": "Plan & billing", + "refresh": "Refresh plan", + "loadFailed": "Could not load your plan. Try again.", + "askAdmin": "Ask an org owner or admin to change the plan.", + "menuItem": "Plan & billing", + "statusActive": "Active", + "statusTrial": "Trial", + "statusPastDue": "Payment due", + "statusCanceled": "Canceled", + "statusInactive": "Inactive", + "reasonExhaustedOptIn": "You have used all included hours this month. Turn on overage to keep starting sandboxes.", + "reasonCapRaise": "You reached this month’s overage spend cap. An admin can raise it.", + "capTooHigh": "That spend cap is above the allowed maximum.", + "overageNoPlan": "Overage needs an active plan.", + "hoursUsedMonth": "{{value0}} of {{value1}} hours used this month", + "warn100": "All included hours for this month are used.", + "warnPercent": "You have used {{value0}}% of this month’s included hours.", + "resets": "Hours reset {{value0}}", + "overageTitle": "Overage", + "overageDescription": "Keep starting sandboxes after included hours at {{value0}}/hr Standard ({{value1}}/hr Large).", + "overageToggle": "Allow overage", + "overageSpent": "{{value0}} of {{value1}} monthly cap spent", + "capLabel": "Monthly cap ($)", + "saveCap": "Save", + "monthly": "Monthly", + "annual": "Annual", + "seatsInterval": "{{value0}} seats · {{value1}}", + "paymentSuspended": "Sandboxes suspended: payment overdue", + "paymentFailed": "Payment failed", + "paymentSuspendedBody": "Running sandboxes were suspended and new ones cannot start. Update your payment method; everything resumes once the invoice is paid.", + "paymentGraceBody": "New sandboxes are paused. Running sandboxes will be suspended on {{value0}} unless the payment goes through. Stripe will retry the card and email your billing contact.", + "updatePayment": "Update payment method", + "reasonGraceExpired": "Your payment is overdue, so sandboxes are suspended. Update your payment method to resume.", + "team": { + "menuItem": "Team", + "invited": "Invite sent to {{value0}}. They join (and become a billed seat) when they accept.", + "revoked": "Invite revoked.", + "roleChanged": "Role updated.", + "removeTitle": "Remove {{value0}}?", + "removeConfirm": "Remove member", + "removed": "Member removed.", + "title": "Team", + "refresh": "Refresh team", + "loading": "Loading your team…", + "loadFailed": "Could not load your team. Try again.", + "memberCount": "{{value0}} members", + "memberSeats": "{{value0}} members · {{value1}} seats billed. Seats follow membership; pending invites are free until accepted.", + "you": "(you)", + "roleFor": "Role for {{value0}}", + "remove": "Remove {{value0}}", + "pending": "Pending invites", + "revoke": "Revoke", + "invitePlaceholder": "teammate@company.com", + "inviteEmail": "Email to invite", + "inviteRole": "Role for the invite", + "invite": "Invite", + "askAdmin": "Only owners and admins can invite or remove members.", + "roleOwner": "Owner", + "roleAdmin": "Admin", + "roleMember": "Member", + "alreadyMember": "That person is already a member.", + "alreadyInvited": "That person already has an invite.", + "cannotRemoveSelf": "You cannot remove yourself.", + "cannotChangeOwnRole": "You cannot change your own role.", + "forbidden": "Only owners and admins can manage members, and only owners can manage owners.", + "notFound": "That member or invite no longer exists.", + "failed": "Could not update the team. Try again.", + "seatsDrop": "Seats paid this period stay until {{value0}}, then drop to {{value1}}.", + "removeDescription2": "They lose access to the org right away. Their seat stays paid until your plan renews, then drops off." + }, + "manageBilling": "Manage billing", + "choosePlan": "Choose a plan", + "comparePlans": "Compare plans", + "plans": { + "entByoc": "Run sandboxes in your own cloud account", + "entSso": "SSO, SCIM provisioning and audit logs", + "entNetwork": "Network policy and egress controls", + "entTerms": "Custom limits, invoicing and terms", + "entPriced": "Priced for your organization", + "contactSales": "Contact sales", + "eyebrow": "Orca Cloud", + "title": "Cloud sandboxes for your whole team", + "monthly": "Monthly", + "annual": "Annual", + "billingPeriod": "Billing period", + "vpRunningTitle": "Agents keep running", + "vpWakeTitle": "Wake in seconds", + "vpWakeBody": "Idle sandboxes sleep to save hours and resume right where they left off.", + "vpSecretsTitle": "Secrets never stored in the VM", + "loading": "Loading plans…", + "loadFailed": "Could not load plans. Try again in a moment.", + "featureConcurrency": "{{value0}} running sandboxes per seat", + "featureOverage": "Optional overage at {{value0}}/hr ({{value1}}/hr Large) with a monthly spend cap", + "currentPlan": "Current plan", + "perSeatMonth": "per seat / month", + "billedMonthly": "Billed monthly", + "totalYear": "{{value0}} / year", + "totalMonth": "{{value0}} / month", + "checkout": "Continue to checkout", + "entLead": "Everything in Team, plus:", + "entHint": "Tell us about your team and where you run.", + "titleSubscribed": "Your Orca Cloud plan", + "subtitleSubscribed": "Usage this month, your plan, and what Enterprise adds.", + "subtitle2": "Run Orca agents in hosted sandboxes that keep going when you step away.", + "saveShort": "−{{value0}}%", + "vpRunningBody2": "Sandboxes keep working while you are away and report back when they finish.", + "vpSecretsBody2": "Credentials are injected into outbound requests and never touch the VM’s disk.", + "seatsAndPeriod": "Seats: {{value0}} · {{value1}}", + "leftResets": "{{value0}} hours left · resets {{value1}}", + "retry": "Try again", + "footHours": "Hours reset every month, including on annual plans. When they run out, new sandboxes wait until the next month unless overage is on.", + "footTerms": "Prices in USD, before tax. Overage stays off until an owner or admin turns it on and sets a spend cap. Cancel anytime; your plan runs to the end of the period.", + "featureHours2": "{{value0}} Standard hours (4 vCPU / 8 GB) per seat each month, pooled across your team", + "featureLarge2": "Large (8 vCPU / 16 GB) uses hours at {{value0}}×", + "featureSleep2": "Sleeps after {{value0}} minutes idle", + "billedYearly": "Billed yearly", + "billedYearlyAmount": "Billed {{value0}} per seat yearly", + "seatsOne": "Seats: {{value0}} (one per team member)", + "waiting": "Waiting for checkout…", + "fixPaymentAbove": "Update your payment method above to keep your plan active.", + "manageHint": "Switch monthly or annual, update payment, or cancel.", + "lockedBody": "Only an owner or admin of your org can choose a plan. Ask one of them to open Plan & billing in Orca.", + "seatsFollow2": "Seats follow your team: new members add a seat now; removed seats drop off at renewal." + }, + "teamFrom": "Team starts at {{value0}} per seat per month, billed annually." } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 99b87eef29e..05edc458bd7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -5872,6 +5872,10 @@ "selectedProjects_other": "{{count}} proyectos", "selectedProjects_many": "{{count}} proyectos" }, + "SidebarAccountMenu": { + "account": "Cuenta", + "signIn": "Iniciar sesión" + }, "SidebarSettingsHelpMenu": { "ad3d3ed7f1": "Reiniciar Orca", "29c56f30ee": "Buscar actualizaciones", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 1c9b7e6bc21..1a02de9fc16 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -6112,6 +6112,10 @@ "selectedProjects_other": "{{count}} projets", "selectedProjects_many": "{{count}} projets" }, + "SidebarAccountMenu": { + "account": "Compte", + "signIn": "Se connecter" + }, "SidebarSettingsHelpMenu": { "ad3d3ed7f1": "Redémarrer Orca", "29c56f30ee": "Rechercher des mises à jour", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 4b50f6f899b..692998a2139 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -5626,6 +5626,10 @@ "allProjects": "すべてのプロジェクト", "selectedProjects_other": "{{count}} 件のプロジェクト" }, + "SidebarAccountMenu": { + "account": "アカウント", + "signIn": "サインイン" + }, "SidebarSettingsHelpMenu": { "ad3d3ed7f1": "Orca を再起動", "29c56f30ee": "アップデートをチェックする", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 1cb5d7959ad..7863064d72c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -5626,6 +5626,10 @@ "allProjects": "모든 프로젝트", "selectedProjects_other": "프로젝트 {{count}}개" }, + "SidebarAccountMenu": { + "account": "계정", + "signIn": "로그인" + }, "SidebarSettingsHelpMenu": { "ad3d3ed7f1": "Orca 재시작", "29c56f30ee": "업데이트 확인", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 8fd26db8efa..bb42c45bc0d 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -11960,6 +11960,10 @@ "cloneFolderTakenByRepo": "\"{{value0}}\" nesta pasta já é outro repositório, então este clone precisa da sua própria pasta.", "cloneFolderNotEmpty": "\"{{value0}}\" já existe nesta pasta, então este clone precisa da sua própria pasta." }, + "SidebarAccountMenu": { + "account": "Conta", + "signIn": "Entrar" + }, "SidebarSettingsHelpMenu": { "ad3d3ed7f1": "Reiniciar o Orca", "29c56f30ee": "Verificar Atualizações", diff --git a/src/renderer/src/i18n/locales/ru.json b/src/renderer/src/i18n/locales/ru.json index 234a841efd0..38242c869e1 100644 --- a/src/renderer/src/i18n/locales/ru.json +++ b/src/renderer/src/i18n/locales/ru.json @@ -6558,6 +6558,10 @@ "selectedProjects_few": "{{count}} проекта", "selectedProjects_many": "{{count}} проектов" }, + "SidebarAccountMenu": { + "account": "Аккаунт", + "signIn": "Войти" + }, "SidebarSettingsHelpMenu": { "ad3d3ed7f1": "Перезапустить Orca", "29c56f30ee": "Проверить наличие обновлений", diff --git a/src/renderer/src/i18n/locales/tr.json b/src/renderer/src/i18n/locales/tr.json index ff788dba404..943037f8f22 100644 --- a/src/renderer/src/i18n/locales/tr.json +++ b/src/renderer/src/i18n/locales/tr.json @@ -6256,6 +6256,10 @@ "selectedProjects_one": "{{count}} proje", "selectedProjects_other": "{{count}} proje" }, + "SidebarAccountMenu": { + "account": "Hesap", + "signIn": "Oturum aç" + }, "SidebarSettingsHelpMenu": { "ad3d3ed7f1": "Orca'yı Yeniden Başlat", "29c56f30ee": "Güncellemeleri Kontrol Et", diff --git a/src/renderer/src/i18n/locales/zh-Hant.json b/src/renderer/src/i18n/locales/zh-Hant.json index 53ab837158d..9217f8cb3a2 100644 --- a/src/renderer/src/i18n/locales/zh-Hant.json +++ b/src/renderer/src/i18n/locales/zh-Hant.json @@ -6112,6 +6112,10 @@ "allProjects": "所有專案", "selectedProjects_other": "{{count}} 個專案" }, + "SidebarAccountMenu": { + "account": "帳戶", + "signIn": "登入" + }, "SidebarSettingsHelpMenu": { "ad3d3ed7f1": "重新啟動 Orca", "29c56f30ee": "檢查更新", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 15f85644cfd..e5daca6c626 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -5626,6 +5626,10 @@ "allProjects": "所有项目", "selectedProjects_other": "{{count}} 个项目" }, + "SidebarAccountMenu": { + "account": "账户", + "signIn": "登录" + }, "SidebarSettingsHelpMenu": { "ad3d3ed7f1": "重启 Orca", "29c56f30ee": "检查更新", diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index f57ca3035ab..f1f0f387c01 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -31,11 +31,14 @@ import { handleGuestWindowCloseMessage } from './components/browser-pane/host-gu import { installOsFileDropCancellationGuard } from './lib/os-file-drop-cancellation-guard' import { installBodyOverlayAttributes } from './lib/body-overlay-attributes' import { applyWindowMaterialToDocument } from './lib/window-material-runtime' +import { registerPlanBillingMenuItem } from './components/billing/register-plan-billing-menu-item' const disposeOsFileDropGuard = installOsFileDropCancellationGuard() import.meta.hot?.dispose(disposeOsFileDropGuard) const disposeBodyOverlayAttributes = installBodyOverlayAttributes() import.meta.hot?.dispose(disposeBodyOverlayAttributes) +const disposePlanBillingMenuItem = registerPlanBillingMenuItem() +import.meta.hot?.dispose(disposePlanBillingMenuItem) recordRendererCrashBreadcrumb('renderer_bootstrap_started', { dev: import.meta.env.DEV }) installRendererCrashDiagnostics() installTypingLatencyDiagnostic() diff --git a/src/renderer/src/web/preload-api/web-orca-profiles-api.ts b/src/renderer/src/web/preload-api/web-orca-profiles-api.ts index 16ff0d1c356..7a06b13026b 100644 --- a/src/renderer/src/web/preload-api/web-orca-profiles-api.ts +++ b/src/renderer/src/web/preload-api/web-orca-profiles-api.ts @@ -69,7 +69,11 @@ export function createWebOrcaProfilesApi(): Partial { orgMemberInvite: async () => ({ status: 'unconfigured' }), orgInviteRevoke: async () => ({ status: 'unconfigured' }), orgMemberChangeRole: async () => ({ status: 'unconfigured' }), - orgMemberRemove: async () => ({ status: 'unconfigured' }) + orgMemberRemove: async () => ({ status: 'unconfigured' }), + billingStatus: async () => ({ status: 'unconfigured' }), + billingOpenCheckout: async () => ({ status: 'unconfigured' }), + billingOpenPortal: async () => ({ status: 'unconfigured' }), + billingSetOverage: async () => ({ status: 'unconfigured' }) } } } diff --git a/src/shared/orca-billing.ts b/src/shared/orca-billing.ts new file mode 100644 index 00000000000..ff572e3638c --- /dev/null +++ b/src/shared/orca-billing.ts @@ -0,0 +1,116 @@ +// Orca Cloud plan & billing, as the desktop sees it. The billing service +// (cloud/apps/billing) is the authority; these mirror its /v1/billing responses. +// One self-serve plan (Team, per seat, monthly or annual) plus Enterprise (contact sales). + +export const ORCA_BILLING_PLAN_IDS = ['team'] as const +export type OrcaBillingPlanId = (typeof ORCA_BILLING_PLAN_IDS)[number] +export const ORCA_BILLING_INTERVALS = ['month', 'year'] as const +export type OrcaBillingInterval = (typeof ORCA_BILLING_INTERVALS)[number] +export const ORCA_BILLING_MAX_SEATS = 500 + +export type OrcaBillingDenyReason = + | 'no_subscription' + | 'subscription_inactive' + | 'payment_past_due' + | 'payment_grace_expired' + | 'included_hours_exhausted' + | 'overage_cap_reached' + +export type OrcaBillingOffers = { + team: { + label: string + /** Display only, e.g. "$50/seat/mo"; Stripe charges the configured price. */ + monthlyPriceLabel: string + annualPriceLabel: string + /** Per-seat prices in cents for totals and the annual saving; null from older services. */ + monthlyPriceCentsPerSeat: number | null + annualPriceCentsPerSeat: number | null + /** How many Standard hours one Large hour uses. */ + largeMachineWeight: number + includedHoursPerSeat: number + concurrentSandboxesPerSeat: number + idleSleepMinutes: number + overageCentsPerHour: number + defaultSpendCapCents: number + } + enterprise: { label: string; priceLabel: string; contactUrl: string } +} + +export type OrcaBillingEntitlement = { + plan: OrcaBillingPlanId | null + planLabel: string | null + /** Stripe subscription status, or 'none'. */ + status: string + interval: OrcaBillingInterval | null + seats: number + /** Seats stay at the period's peak; they come down to the member count at renewal. */ + seatChange: { seatsAtRenewal: number; effectiveAt: number } | null + period: { start: number; end: number } | null + /** Monthly window the hours count against (a slice of the year on annual plans). */ + usageWindow: { start: number; end: number } | null + cancelAtPeriodEnd: boolean + includedSeconds: number + usedSeconds: number + remainingIncludedSeconds: number + /** Highest included-hours warning level reached (80, 100), or null. */ + usageWarningPercent: number | null + overageSeconds: number + overage: { + optedIn: boolean + active: boolean + spendCapCents: number + spentCents: number + centsPerHour: number + } + /** Failed payment: new starts stop at once; running sandboxes suspend after `graceEndsAt`. */ + payment: { pastDueSince: number | null; graceEndsAt: number | null; suspendRunning: boolean } + canStartSandbox: boolean + reason: OrcaBillingDenyReason | null + canManageBilling: boolean + /** What is on offer, from the billing service's catalog (prices are config there, not here). */ + offers: OrcaBillingOffers | null +} + +export type OrcaBillingStatusResult = + | { status: 'ok'; entitlement: OrcaBillingEntitlement } + | { status: 'unconfigured' } + | { status: 'signed-out' } + | { status: 'failed'; error: string } + +export type OrcaBillingCheckoutArgs = { + plan: OrcaBillingPlanId + interval: OrcaBillingInterval + seats: number +} + +export type OrcaBillingOverageArgs = { + enabled: boolean + /** Whole cents; omitted keeps the current cap. */ + spendCapCents?: number +} + +export type OrcaBillingOpenResult = + | { status: 'opened' } + | { status: 'unconfigured' } + | { status: 'signed-out' } + | { status: 'forbidden' } + | { status: 'already-subscribed' } + | { status: 'no-billing-account' } + | { status: 'failed'; error: string } + +export type OrcaBillingOverageResult = + | { status: 'ok'; entitlement: OrcaBillingEntitlement } + | { status: 'unconfigured' } + | { status: 'signed-out' } + | { status: 'forbidden' } + | { status: 'no-subscription' } + | { status: 'cap-too-high' } + | { status: 'failed'; error: string } + +export function isOrcaBillingPlanId(value: unknown): value is OrcaBillingPlanId { + return ORCA_BILLING_PLAN_IDS.some((plan) => plan === value) +} + +export function isOrcaBillingInterval(value: unknown): value is OrcaBillingInterval { + return ORCA_BILLING_INTERVALS.some((interval) => interval === value) +} diff --git a/tests/e2e/agent-auto-permissions.spec.ts b/tests/e2e/agent-auto-permissions.spec.ts index 34ccb07a7b0..c1a2aac820d 100644 --- a/tests/e2e/agent-auto-permissions.spec.ts +++ b/tests/e2e/agent-auto-permissions.spec.ts @@ -7,7 +7,8 @@ test('global Auto preserves other agents’ choices and the single permission se orcaPage }) => { await waitForSessionReady(orcaPage) - await orcaPage.getByRole('button', { name: 'Settings', exact: true }).click() + await orcaPage.getByRole('button', { name: 'Account', exact: true }).click() + await orcaPage.getByRole('menuitem', { name: 'Settings', exact: true }).click() await orcaPage.getByRole('button', { name: 'Agents', exact: true }).click() const permissions = orcaPage.getByRole('button', { name: 'Permissions', exact: true }) const group = orcaPage.getByRole('group', { name: 'Permissions', exact: true }) diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts index 4d403803885..76d3ea2a8a6 100644 --- a/tests/e2e/onboarding.spec.ts +++ b/tests/e2e/onboarding.spec.ts @@ -351,7 +351,8 @@ test.describe('Onboarding flow', () => { .poll(async () => (await getSettings(orcaPage)).defaultTuiAgent, { timeout: 5_000 }) .toBe('codex') - await orcaPage.getByRole('button', { name: 'Help', exact: true }).click() + await orcaPage.getByRole('button', { name: 'Account', exact: true }).click() + await orcaPage.getByRole('menuitem', { name: 'Help', exact: true }).click() await orcaPage.getByRole('menuitem', { name: 'Onboarding', exact: true }).click() await onboardingFooterButton(orcaPage, SET_UP_LATER_BUTTON).click() await expectMainPage(orcaPage)