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/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/stripe-test-setup.ts b/cloud/apps/billing/scripts/stripe-test-setup.ts new file mode 100644 index 00000000000..96b4ddecc70 --- /dev/null +++ b/cloud/apps/billing/scripts/stripe-test-setup.ts @@ -0,0 +1,83 @@ +// One-time TEST MODE catalog setup: products, flat plan prices, the sandbox-seconds meter and the +// metered overage price. Refuses any key that is not sk_test_/rk_test_. Prints the ids to put in +// the Worker vars; creates nothing else. +// +// STRIPE_SECRET_KEY=sk_test_... pnpm tsx scripts/stripe-test-setup.ts +// +// Re-running is safe within 24h (idempotency keys); after that it would create duplicates, so +// check the dashboard first. +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) +} + +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 & { livemode?: boolean; error?: { message: string } } + 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 plans = [ + { id: 'pro', name: 'Orca Pro', cents: 2500 }, + { id: 'max', name: 'Orca Max', cents: 10000 }, + { id: 'team', name: 'Orca Team (per seat)', cents: 6000 } +] as const + +const out: Record = {} +for (const plan of plans) { + const product = await post<{ id: string }>('/v1/products', { name: plan.name, metadata: { orca_plan: plan.id } }, `product:${plan.id}`) + const price = await post<{ id: string }>( + '/v1/prices', + { product: product.id, currency: 'usd', unit_amount: plan.cents, recurring: { interval: 'month' }, lookup_key: `orca_${plan.id}_monthly` }, + `price:${plan.id}` + ) + out[`STRIPE_PRICE_${plan.id.toUpperCase()}`] = price.id +} + +const meter = await post<{ id: string; event_name: string }>( + '/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<{ id: string }>('/v1/products', { name: 'Orca sandbox overage', unit_label: 'hour' }, 'product:overage') +// $0.45 per standard hour; the meter counts seconds and Stripe divides by 3600 (rounding up) on the invoice. +const overage = await post<{ id: string }>( + '/v1/prices', + { + product: overageProduct.id, + currency: 'usd', + unit_amount: 45, + recurring: { interval: 'month', usage_type: 'metered', meter: meter.id }, + transform_quantity: { divide_by: 3600, round: 'up' }, + lookup_key: 'orca_sandbox_overage_hourly' + }, + 'price:overage' +) +out.STRIPE_PRICE_OVERAGE = overage.id +out.STRIPE_METER_EVENT_NAME = meter.event_name + +console.log(JSON.stringify(out, null, 2)) diff --git a/cloud/apps/billing/src/auth.ts b/cloud/apps/billing/src/auth.ts new file mode 100644 index 00000000000..08d30944ae5 --- /dev/null +++ b/cloud/apps/billing/src/auth.ts @@ -0,0 +1,99 @@ +// 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 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}` +} + +export async function authenticateDesktop(env: Env, request: Request): Promise { + const token = readBearer(request.headers.get('authorization')) + if (!token) { + return null + } + try { + const { payload } = await jwtVerify(token, keysFor(env), { + audience: env.BILLING_JWT_AUDIENCE, + algorithms: ['ES256'], + // Billing tokens are minted per call; refuse anything long-lived. + maxTokenAge: '15m', + requiredClaims: ['exp', 'iat'], + ...(env.BILLING_AUTH_ISSUER ? { issuer: env.BILLING_AUTH_ISSUER } : {}) + }) + const claims = ClaimsSchema.parse(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 async function authenticateControlPlane(env: Env, request: Request): Promise { + const token = readBearer(request.headers.get('authorization')) + const accepted = (env.CONTROL_PLANE_TOKEN ?? '') + .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..9ee96b31b2a --- /dev/null +++ b/cloud/apps/billing/src/checkout.ts @@ -0,0 +1,96 @@ +// 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 { PLAN_IDS, priceForPlan, type PlanId } from './plans' +import type { StripeClient } from './stripe' +import { currentSubscription, customerFor, ensureAccount, linkCustomer } from './store' + +export const MAX_TEAM_SEATS = 500 + +export const CheckoutRequestSchema = z + .object({ + plan: z.enum(PLAN_IDS), + seats: z.number().int().min(1).max(MAX_TEAM_SEATS).optional() + }) + .strict() + +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; seats?: number }, + now: number +): 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)) { + // Plan changes and seat changes go through the portal, which prorates; a second + // subscription would double-bill. + return { ok: false, status: 409, error: 'already_subscribed' } + } + const seats = request.plan === 'team' ? (request.seats ?? 1) : 1 + const customer = await ensureCustomer(env, stripe, caller, now) + const trialDays = Number(env.STRIPE_TRIAL_DAYS ?? '0') + const session = await stripe.createCheckoutSession( + { + mode: 'subscription', + customer, + client_reference_id: caller.accountId, + line_items: [{ price: priceForPlan(env, request.plan), quantity: seats }, { price: env.STRIPE_PRICE_OVERAGE }], + // No free plan: a card is collected even for a trial. + 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: { account_id: caller.accountId, plan: request.plan }, + subscription_data: { + metadata: { account_id: caller.accountId, plan: request.plan }, + trial_period_days: Number.isInteger(trialDays) && trialDays > 0 ? trialDays : undefined + } + }, + `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' }) }, + `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..a807a9ef146 --- /dev/null +++ b/cloud/apps/billing/src/entitlements.ts @@ -0,0 +1,116 @@ +// "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 { MACHINE_SIZES, PLANS, includedSeconds, overageCapSeconds, type MachineSize, type PlanId } from './plans' +import { currentSubscription, periodUsage } from './store' + +export type DenyReason = + | 'no_subscription' + | 'subscription_inactive' + | 'payment_past_due' + | 'included_hours_exhausted' + | 'overage_cap_reached' + | 'machine_size_not_in_plan' + | 'concurrency_limit' + +export type Entitlement = { + accountId: string + plan: PlanId | null + planLabel: string | null + status: string + seats: number + period: { start: number; end: number } | null + cancelAtPeriodEnd: boolean + includedSeconds: number + usedSeconds: number + remainingIncludedSeconds: number + overageSeconds: number + overageEnabled: boolean + overageCapSeconds: number + limits: { maxConcurrentSandboxes: number; machineSizes: readonly MachineSize[] } + canStartSandbox: boolean + reason: DenyReason | null +} + +const LIVE_STATUSES = new Set(['active', 'trialing']) + +export async function computeEntitlement(db: D1Database, accountId: string): Promise { + const subscription = await currentSubscription(db, accountId) + if (!subscription) { + return { + accountId, + plan: null, + planLabel: null, + status: 'none', + seats: 0, + period: null, + cancelAtPeriodEnd: false, + includedSeconds: 0, + usedSeconds: 0, + remainingIncludedSeconds: 0, + overageSeconds: 0, + overageEnabled: false, + overageCapSeconds: 0, + limits: { maxConcurrentSandboxes: 0, machineSizes: [] }, + canStartSandbox: false, + reason: 'no_subscription' + } + } + const plan = PLANS[subscription.plan] + const included = includedSeconds(subscription.plan, subscription.seats) + const cap = overageCapSeconds(subscription.plan, subscription.seats) + const usage = await periodUsage(db, accountId, subscription.current_period_start) + const remaining = Math.max(0, included - usage.usedSeconds) + const overageEnabled = subscription.overage_enabled === 1 + + let reason: DenyReason | null = null + if (subscription.status === 'past_due') { + // Stripe is retrying the card; running sandboxes are left to the control plane, new starts wait. + reason = 'payment_past_due' + } else if (!LIVE_STATUSES.has(subscription.status)) { + reason = 'subscription_inactive' + } else if (remaining === 0 && !overageEnabled) { + reason = 'included_hours_exhausted' + } else if (remaining === 0 && usage.overageSeconds >= cap) { + reason = 'overage_cap_reached' + } + + return { + accountId, + plan: subscription.plan, + planLabel: plan.label, + status: subscription.status, + seats: subscription.seats, + period: { start: subscription.current_period_start, end: subscription.current_period_end }, + cancelAtPeriodEnd: subscription.cancel_at_period_end === 1, + includedSeconds: included, + usedSeconds: usage.usedSeconds, + remainingIncludedSeconds: remaining, + overageSeconds: usage.overageSeconds, + overageEnabled, + overageCapSeconds: cap, + limits: { maxConcurrentSandboxes: plan.maxConcurrentSandboxes, machineSizes: plan.machineSizes }, + 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 } +): { allow: boolean; reason: DenyReason | null } { + if (!entitlement.canStartSandbox) { + return { allow: false, reason: entitlement.reason } + } + if (!entitlement.limits.machineSizes.includes(request.machineSize)) { + return { allow: false, reason: 'machine_size_not_in_plan' } + } + if (request.runningSandboxes >= entitlement.limits.maxConcurrentSandboxes) { + return { allow: false, reason: 'concurrency_limit' } + } + return { allow: true, reason: null } +} diff --git a/cloud/apps/billing/src/env.ts b/cloud/apps/billing/src/env.ts new file mode 100644 index 00000000000..98736a016a3 --- /dev/null +++ b/cloud/apps/billing/src/env.ts @@ -0,0 +1,42 @@ +// 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 + + // Price ids (price_...). Flat recurring prices per plan; Team is per seat (quantity). + STRIPE_PRICE_PRO: string + STRIPE_PRICE_MAX: string + STRIPE_PRICE_TEAM: string + // Metered price attached to the sandbox-seconds meter (overage only; see usage.ts). + STRIPE_PRICE_OVERAGE: string + // Billing meter event_name the overage price aggregates. + STRIPE_METER_EVENT_NAME: string + // Optional card-required trial length for new subscriptions. + STRIPE_TRIAL_DAYS?: 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 + + // 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 +} diff --git a/cloud/apps/billing/src/index.ts b/cloud/apps/billing/src/index.ts new file mode 100644 index 00000000000..d8303c63b8f --- /dev/null +++ b/cloud/apps/billing/src/index.ts @@ -0,0 +1,144 @@ +// 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,seats} -> { url } hosted Stripe Checkout +// 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} +import { z } from 'zod' +import { authenticateControlPlane, authenticateDesktop } from './auth' +import { CheckoutRequestSchema, createCheckout, createPortal } from './checkout' +import { checkStart, computeEntitlement } from './entitlements' +import type { Env } from './env' +import { MACHINE_SIZES } from './plans' +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) + }) + .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 } + +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 route = `${request.method} ${url.pathname}` + try { + switch (route) { + 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.BILLING_DB, caller.accountId) + return json({ ...entitlement, canManageBilling: caller.canManage }) + } + + case 'POST /v1/billing/checkout': { + const caller = await authenticateDesktop(env, request) + 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) + return outcome.ok ? json({ url: outcome.url }) : json({ error: outcome.error }, outcome.status) + } + + case 'POST /v1/billing/portal': { + const caller = await authenticateDesktop(env, request) + 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.BILLING_DB, body.data.accountId) + return json({ ...checkStart(entitlement, body.data), 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.BILLING_DB, body.data.accountId) + return json({ ...result, canStartSandbox: entitlement.canStartSandbox, reason: entitlement.reason }) + } + + default: + return json({ error: 'not_found' }, 404) + } + } catch (error) { + if (error instanceof SyntaxError) { + return json({ error: 'invalid_json' }, 400) + } + if (error instanceof LiveModeRefusedError) { + return json({ error: 'billing_misconfigured' }, 503) + } + if (error instanceof StripeError) { + return json({ error: 'stripe_unavailable' }, 502) + } + 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 { + ctx.waitUntil(flushMeterReports(env, createStripeClient(env), Date.now()).then(() => undefined)) + } +} satisfies ExportedHandler diff --git a/cloud/apps/billing/src/plans.ts b/cloud/apps/billing/src/plans.ts new file mode 100644 index 00000000000..1c60388e402 --- /dev/null +++ b/cloud/apps/billing/src/plans.ts @@ -0,0 +1,78 @@ +// Plan catalog. Prices live in Stripe; this file owns what each plan entitles. +// 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. +import type { Env } from './env' + +export const PLAN_IDS = ['pro', 'max', 'team'] as const +export type PlanId = (typeof PLAN_IDS)[number] + +export const MACHINE_SIZES = ['standard', 'large', 'xlarge'] as const +export type MachineSize = (typeof MACHINE_SIZES)[number] + +export const MACHINE_SIZE_WEIGHT: Record = { standard: 1, large: 2, xlarge: 4 } + +export type PlanEntitlement = { + plan: PlanId + label: string + // Included standard hours per billing period; Team multiplies by seats (pooled). + includedHoursPerSeat: number + maxConcurrentSandboxes: number + machineSizes: readonly MachineSize[] + // Overage hours allowed per period before new starts are refused (spend cap). + overageCapHours: number +} + +export const PLANS: Record = { + pro: { + plan: 'pro', + label: 'Pro', + includedHoursPerSeat: 50, + maxConcurrentSandboxes: 2, + machineSizes: ['standard'], + overageCapHours: 100 + }, + max: { + plan: 'max', + label: 'Max', + includedHoursPerSeat: 300, + maxConcurrentSandboxes: 5, + machineSizes: ['standard', 'large', 'xlarge'], + overageCapHours: 500 + }, + team: { + plan: 'team', + label: 'Team', + includedHoursPerSeat: 150, + maxConcurrentSandboxes: 3, + machineSizes: ['standard', 'large'], + overageCapHours: 200 + } +} + +export function isPlanId(value: unknown): value is PlanId { + return typeof value === 'string' && (PLAN_IDS as readonly string[]).includes(value) +} + +export function priceForPlan(env: Env, plan: PlanId): string { + return { pro: env.STRIPE_PRICE_PRO, max: env.STRIPE_PRICE_MAX, team: env.STRIPE_PRICE_TEAM }[plan] +} + +/** The plan a subscription's base price maps to, or null for a price we did not configure. */ +export function planForPrice(env: Env, priceId: string): PlanId | null { + for (const plan of PLAN_IDS) { + if (priceForPlan(env, plan) === priceId) { + return plan + } + } + return null +} + +export function includedSeconds(plan: PlanId, seats: number): number { + const perSeat = PLANS[plan].includedHoursPerSeat * 3600 + return plan === 'team' ? perSeat * Math.max(1, seats) : perSeat +} + +export function overageCapSeconds(plan: PlanId, seats: number): number { + const cap = PLANS[plan].overageCapHours * 3600 + return plan === 'team' ? cap * Math.max(1, seats) : cap +} diff --git a/cloud/apps/billing/src/store.ts b/cloud/apps/billing/src/store.ts new file mode 100644 index 00000000000..86f75e1234e --- /dev/null +++ b/cloud/apps/billing/src/store.ts @@ -0,0 +1,247 @@ +// 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 { 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 +} + +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) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) + 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` + ) + .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 + ) + .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 + // Set when overage is billable: queues a meter report in the same transaction. + meterCustomer: string | null +} + +/** + * 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) + 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 + 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 + ) + 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, overage_seconds, ended_at, 'pending', ?3 + FROM usage_events WHERE usage_id = ?1 AND overage_seconds > 0` + ) + .bind(usage.usageId, usage.meterCustomer, usage.now) + ) + } + const [result] = await db.batch(statements) + return (result?.meta.changes ?? 0) > 0 +} + +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..3de8077b130 --- /dev/null +++ b/cloud/apps/billing/src/stripe.ts @@ -0,0 +1,169 @@ +// 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, + redirect: 'error', + 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 }, idempotencyKey: string) { + return request<{ id: string; url: string }>( + 'POST', + '/v1/billing_portal/sessions', + { customer: args.customer, return_url: args.returnUrl }, + 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..074449ff38f --- /dev/null +++ b/cloud/apps/billing/src/usage.ts @@ -0,0 +1,107 @@ +// Sandbox usage ingestion (control plane -> billing) and the Stripe meter outbox. +// Only overage reaches Stripe: included hours are tracked here (Team pools them across seats, +// which tiered Stripe prices cannot express), and 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, includedSeconds } from './plans' +import type { StripeClient } from './stripe' +import { 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() + }) + .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 subscription = await currentSubscription(db, report.accountId) + const wallSeconds = Math.ceil((report.endedAt - report.startedAt) / 1000) + const weightedSeconds = wallSeconds * MACHINE_SIZE_WEIGHT[report.machineSize] + const billable = subscription !== null && subscription.overage_enabled === 1 + 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: subscription?.current_period_start ?? null, + includedSeconds: subscription ? includedSeconds(subscription.plan, subscription.seats) : 0, + now, + meterCustomer: customer + }) + 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..b4ea2fd0ac5 --- /dev/null +++ b/cloud/apps/billing/src/webhook.ts @@ -0,0 +1,160 @@ +// 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 {} + +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 plan = null + let seats = 1 + let overageEnabled = false + 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) { + overageEnabled = true + continue + } + const itemPlan = planForPrice(env, item.price.id) + if (itemPlan) { + plan = itemPlan + seats = Math.max(1, item.quantity ?? 1) + periodStart = item.current_period_start ?? periodStart + periodEnd = item.current_period_end ?? periodEnd + } + } + if (!plan || !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, + seats, + overage_enabled: overageEnabled ? 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 + }) +} + +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/billing-flow.test.ts b/cloud/apps/billing/test/billing-flow.test.ts new file mode 100644 index 00000000000..1191ed6779d --- /dev/null +++ b/cloud/apps/billing/test/billing-flow.test.ts @@ -0,0 +1,290 @@ +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 + +async function subscribedPro(options: { overage?: boolean } = {}) { + const h = await createHarness() + const token = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner', email: 'a@example.com' }) + const checkout = await h.call('POST', '/v1/billing/checkout', { body: { plan: 'pro' }, headers: { authorization: `Bearer ${token}` } }) + expect(checkout.status).toBe(200) + const customer = [...h.stripe.customers.keys()][0]! + const subscription = h.stripe.putSubscription({ + customer, + accountId: 'org:o1', + basePrice: 'price_pro', + overagePrice: options.overage === false ? undefined : 'price_overage', + periodStart: PERIOD_START, + periodEnd: PERIOD_END + }) + expect((await h.webhook(checkoutCompleted({ customer, subscription: subscription.id, accountId: 'org:o1' }))).body).toEqual({ + received: true + }) + return { h, token, 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 subscription-mode Checkout with the overage price', 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 }, 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 session = h.stripe.calls.find((call) => call.path === '/v1/checkout/sessions')! + expect(session.form.get('mode')).toBe('subscription') + expect(session.form.get('line_items[0][price]')).toBe('price_team') + expect(session.form.get('line_items[0][quantity]')).toBe('4') + expect(session.form.get('line_items[1][price]')).toBe('price_overage') + expect(session.form.has('line_items[1][quantity]')).toBe(false) + expect(session.form.get('subscription_data[metadata][account_id]')).toBe('org:o1') + expect(session.form.get('payment_method_collection')).toBe('always') + expect(session.form.get('success_url')).toContain('session_id={CHECKOUT_SESSION_ID}') + expect(session.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 denied = await h.call('POST', '/v1/billing/checkout', { body: { plan: 'pro' }, headers: { authorization: `Bearer ${member}` } }) + expect(denied).toEqual({ status: 403, body: { error: 'billing_admin_required' } }) + const portal = await h.call('POST', '/v1/billing/portal', { headers: { authorization: `Bearer ${member}` } }) + expect(portal.status).toBe(403) + + const solo = await h.desktopToken({ sub: 'u3' }) + const ok = await h.call('POST', '/v1/billing/checkout', { body: { plan: 'max' }, headers: { authorization: `Bearer ${solo}` } }) + expect(ok.status).toBe(200) + expect(h.stripe.customers.values().next().value?.metadata.account_id).toBe('user:u3') + }) + + it('rejects bad tokens, bad bodies and a second subscription', async () => { + const { h, token } = await subscribedPro() + 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) + const auth = { authorization: `Bearer ${token}` } + expect((await h.call('POST', '/v1/billing/checkout', { body: { plan: 'free' }, headers: auth })).status).toBe(400) + expect((await h.call('POST', '/v1/billing/checkout', { body: { plan: 'pro', extra: 1 }, headers: auth })).status).toBe(400) + expect(await h.call('POST', '/v1/billing/checkout', { body: { plan: 'max' }, headers: auth })).toEqual({ + status: 409, + body: { error: 'already_subscribed' } + }) + }) + + it('opens the portal for the linked customer only', async () => { + const { h, token, customer } = await subscribedPro() + const portal = await h.call('POST', '/v1/billing/portal', { headers: { authorization: `Bearer ${token}` } }) + expect(portal.body.url).toMatch(/^https:\/\/billing\.stripe\.com\//) + expect(h.stripe.calls.find((call) => call.path === '/v1/billing_portal/sessions')?.form.get('customer')).toBe(customer) + 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 subscribedPro() + 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 subscribedPro() + 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, token, subscription, customer } = await subscribedPro() + 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: { authorization: `Bearer ${token}` } }) + expect(entitlement.body).toMatchObject({ status: 'canceled', canStartSandbox: false, reason: 'subscription_inactive' }) + expect(customer).toBeTruthy() + }) + + it('marks past_due from invoice.payment_failed (new invoice.parent shape)', async () => { + const { h, token, subscription, customer } = await subscribedPro() + 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: { authorization: `Bearer ${token}` } }) + 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 subscribedPro() + 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_max', + 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 plan FROM subscriptions WHERE account_id = ?').all('org:o1')).toEqual([{ plan: 'pro' }]) + }) + + it('returns 500 (so Stripe retries) when Stripe cannot be read, and succeeds on redelivery', async () => { + const { h, subscription, customer } = await subscribedPro() + 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 and usage', () => { + it('reports hours used and remaining for the desktop', async () => { + const { h, token } = await subscribedPro() + expect((await h.controlPlane('/v1/internal/usage', usage('u-1', 10))).status).toBe(200) + const entitlement = await h.call('GET', '/v1/billing/entitlements', { headers: { authorization: `Bearer ${token}` } }) + expect(entitlement.body).toMatchObject({ + accountId: 'org:o1', + plan: 'pro', + planLabel: 'Pro', + status: 'active', + includedSeconds: 50 * HOUR, + usedSeconds: 10 * HOUR, + remainingIncludedSeconds: 40 * HOUR, + overageSeconds: 0, + canStartSandbox: true, + canManageBilling: true, + period: { start: PERIOD_START * 1000, end: PERIOD_END * 1000 } + }) + }) + + it('is idempotent per usageId and weights larger machines', async () => { + const { h } = await subscribedPro() + 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('meters only the overage past included hours to Stripe, once per segment', async () => { + const { h, customer } = await subscribedPro() + await h.controlPlane('/v1/internal/usage', usage('u-1', 24)) + await h.controlPlane('/v1/internal/usage', usage('u-2', 24)) + const crossing = await h.controlPlane('/v1/internal/usage', usage('u-3', 4)) + expect(crossing.body).toMatchObject({ overageSeconds: 2 * HOUR, canStartSandbox: true }) + await h.controlPlane('/v1/internal/usage', usage('u-3', 4)) + expect(h.stripe.meterEvents).toEqual([ + expect.objectContaining({ + event_name: 'orca_sandbox_seconds', + identifier: 'u-3', + 'payload[stripe_customer_id]': customer, + 'payload[value]': String(2 * HOUR) + }) + ]) + }) + + it('retries failed meter reports from the outbox', async () => { + const { h } = await subscribedPro() + const env = h.env + await h.controlPlane('/v1/internal/usage', usage('u-1', 24)) + await h.controlPlane('/v1/internal/usage', usage('u-2', 24)) + h.stripe.failNextMeterEvents(1) + await h.controlPlane('/v1/internal/usage', usage('u-3', 4)) + 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(env, createStripeClient(env, h.stripe.fetchImpl), Date.now())).toEqual({ sent: 1, failed: 0 }) + expect(h.stripe.meterEvents[0]?.['payload[value]']).toBe(String(2 * HOUR)) + }) + + it('stops new starts when included hours run out without overage, or at the overage cap', async () => { + const noOverage = await subscribedPro({ overage: false }) + expect((await noOverage.h.controlPlane('/v1/internal/usage', usage('u-1', 24))).status).toBe(200) + await noOverage.h.controlPlane('/v1/internal/usage', usage('u-2', 24)) + const third = await noOverage.h.controlPlane('/v1/internal/usage', usage('u-3', 3)) + expect(third.body).toMatchObject({ canStartSandbox: false, reason: 'included_hours_exhausted' }) + expect(noOverage.h.stripe.meterEvents).toHaveLength(0) + + const capped = await subscribedPro() + for (let i = 0; i < 7; i++) { + await capped.h.controlPlane('/v1/internal/usage', usage(`c-${i}`, 22)) + } + const check = await capped.h.controlPlane('/v1/internal/entitlements/check', { accountId: 'org:o1', machineSize: 'standard', runningSandboxes: 0 }) + expect(check.body).toMatchObject({ allow: false, reason: 'overage_cap_reached' }) + }) + + it('gates starts by machine size and concurrency for the control plane', async () => { + const { h } = await subscribedPro() + const check = (body: Record) => h.controlPlane('/v1/internal/entitlements/check', { accountId: 'org:o1', ...body }) + expect((await check({ machineSize: 'standard', runningSandboxes: 1 })).body).toMatchObject({ allow: true, reason: null }) + expect((await check({ machineSize: 'large', runningSandboxes: 0 })).body).toMatchObject({ allow: false, reason: 'machine_size_not_in_plan' }) + expect((await check({ machineSize: 'standard', runningSandboxes: 2 })).body).toMatchObject({ allow: false, reason: 'concurrency_limit' }) + 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('pools Team hours across seats', async () => { + const h = await createHarness() + const token = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner' }) + await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team', seats: 3 }, headers: { authorization: `Bearer ${token}` } }) + const customer = [...h.stripe.customers.keys()][0]! + const sub = h.stripe.putSubscription({ customer, accountId: 'org:o1', basePrice: 'price_team', overagePrice: 'price_overage', quantity: 3, periodStart: PERIOD_START, periodEnd: PERIOD_END }) + await h.webhook(stripeEvent('customer.subscription.created', sub)) + const entitlement = await h.call('GET', '/v1/billing/entitlements', { headers: { authorization: `Bearer ${token}` } }) + expect(entitlement.body).toMatchObject({ plan: 'team', seats: 3, includedSeconds: 450 * HOUR }) + }) + + it('authenticates the control plane and validates segments', async () => { + const { h } = await subscribedPro() + 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: 'pro' }, 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..bd62b4f21f7 --- /dev/null +++ b/cloud/apps/billing/test/harness.ts @@ -0,0 +1,77 @@ +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_PRO: 'price_pro', + STRIPE_PRICE_MAX: 'price_max', + STRIPE_PRICE_TEAM: 'price_team', + 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() + 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 }) + 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 + }, + async desktopToken(claims: { sub: string; org?: string; orgRole?: string; email?: string }, 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 + 300) + .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/stripe-client.test.ts b/cloud/apps/billing/test/stripe-client.test.ts new file mode 100644 index 00000000000..59abfc95da1 --- /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('error') + }) +}) diff --git a/cloud/apps/billing/test/stripe-fake.ts b/cloud/apps/billing/test/stripe-fake.ts new file mode 100644 index 00000000000..c2d8743c9a6 --- /dev/null +++ b/cloud/apps/billing/test/stripe-fake.ts @@ -0,0 +1,166 @@ +// 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 === '/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.jsonc b/cloud/apps/billing/wrangler.jsonc new file mode 100644 index 00000000000..56db8ea2f87 --- /dev/null +++ b/cloud/apps/billing/wrangler.jsonc @@ -0,0 +1,31 @@ +{ + "$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_PRO": "price_local_pro", + "STRIPE_PRICE_MAX": "price_local_max", + "STRIPE_PRICE_TEAM": "price_local_team", + "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..f2f9b91b451 --- /dev/null +++ b/cloud/docs/accounts-billing-design.md @@ -0,0 +1,97 @@ +# Orca accounts + billing — design (v0) + +Status: foundation built in `nwparker/orca-private` (branch `nwparker/accounts-billing`). Stripe TEST MODE only. +Code: `cloud/apps/billing` (Worker), desktop `src/main/billing`, `src/renderer/src/components/billing`. + +## Identity — reuse Orca Cloud sign-in + +- Orca already has accounts/orgs: desktop PKCE login against `login.onorca.dev` (`src/main/orca-profiles/profile-cloud-*`), + which mints audience-scoped short-lived ES256 JWTs for other services (relay token, connector token), verified by JWKS. +- Billing follows the same pattern: new endpoint `POST /v1/desktop/auth/billing-token` on Orca Cloud auth returns + `{ billingToken, expiresAt }`, a JWT with `aud=orca-billing`, `purpose=billing`, `sub`, `org?`, `orgRole?`, `email?`, ≤15 min. + Billing never sees the user's access/refresh token. **Needs a change in the auth service (private orca-cloud repo).** +- Billing account = `org:` when the token names an org, else `user:`. Only org `owner`/`admin` + can open Checkout/Portal; any member can read entitlements. +- Users/orgs/membership/roles stay owned by auth. Billing stores only account ids. + +## Why Cloudflare Worker + D1 + +- Extends what exists (relay-edge is a Worker; wrangler/vitest tooling already in `cloud/`). +- Separate D1 database = small blast radius: the billing Worker holds the Stripe key and has no auth/relay DB access, + and nothing else holds the Stripe key (least privilege). +- Load is tiny (webhooks, settle-interval usage reports, entitlement reads). D1's single-writer SQLite gives atomic + single statements + `batch()` transactions, enough for the ledger. A Durable Object per account is the upgrade + path if we need strict per-account start-time locking (concurrency reservation). +- Cron Trigger retries the meter outbox. No new vendors. + +## Data model (D1, `cloud/apps/billing/migrations/0001_init.sql`) + +| Table | Purpose | +|---|---| +| `billing_accounts` | account_id → stripe_customer_id (unique) | +| `subscriptions` | mirror of Stripe sub: status, plan, seats, overage_enabled, current period | +| `stripe_events` | webhook idempotency (event id, processed_at, note) | +| `usage_events` | sandbox usage segments keyed by caller's `usageId`; weighted seconds; overage seconds fixed at insert | +| `meter_reports` | outbox of overage → Stripe meter events (pending/sent/failed, attempts) | + +Plan entitlements live in code (`src/plans.ts`): Pro 50 h / 2 concurrent / standard; Max 300 h / 5 / all sizes; +Team 150 h × seats pooled / 3 / standard+large. Overage spend cap per plan (Pro 100 h, Max 500 h, Team 200 h/seat). +Usage is metered in "standard seconds" (wall seconds × size weight 1/2/4). Enterprise/BYOC: out of scope (sales-led). + +## Stripe integration + +- **Checkout** (`POST /v1/billing/checkout {plan, seats?}`): hosted, `mode=subscription`, line items = flat plan price + (qty=seats) + metered overage price; `payment_method_collection=always` (no free plan; optional card-required trial via + `STRIPE_TRIAL_DAYS`); `subscription_data.metadata.account_id`. One Stripe customer per account (idempotency key + `customer-create:`). 409 if a subscription is already open — plan/seat changes go through the Portal. +- **Customer Portal** (`POST /v1/billing/portal`): upgrade/downgrade/cancel/payment methods/invoices. Portal config + (allowed plan switches) is set in the Stripe dashboard. +- **Webhooks** (`POST /v1/stripe/webhook`): HMAC-SHA256 verification of `Stripe-Signature` over the raw body + (5-min tolerance, multiple `v1`, secret rotation), dedupe by event id, then **re-fetch the subscription from the API** + (no reliance on payload order/staleness). Handles `checkout.session.completed`, `customer.subscription.*`, + `invoice.paid`, `invoice.payment_failed` (old + new `invoice.parent` shapes). A sub is credited only if billed to the + customer we created for that account (metadata alone is not trusted). Live-mode events are acked and ignored. + Failures return 500 so Stripe retries. +- **Meters**: only overage is sent. Included hours are tracked by us (Team pooling can't be expressed with tiered + Stripe prices). Overage is computed inside the INSERT from the running period total, so concurrent reports can't + double-claim included time. Meter `orca_sandbox_seconds` (sum, by customer id); overage price $0.45/h via + `transform_quantity.divide_by=3600, round=up`. `identifier=usageId` + Idempotency-Key dedupe retries. + Setup: `cloud/apps/billing/scripts/stripe-test-setup.ts` (refuses non-test keys). + +## Entitlement checks + +- Desktop: `GET /v1/billing/entitlements` → plan, status, seats, period, included/used/remaining/overage seconds, cap, + limits, `canStartSandbox`, `reason`, `canManageBilling`. +- Control plane (bearer `CONTROL_PLANE_TOKEN`; service binding later): + - `POST /v1/internal/entitlements/check {accountId, machineSize, runningSandboxes}` → `{allow, reason}`. + Reasons: `no_subscription | subscription_inactive | payment_past_due | included_hours_exhausted | + overage_cap_reached | machine_size_not_in_plan | concurrency_limit`. + - `POST /v1/internal/usage {usageId, accountId, sandboxId, machineSize, startedAt, endedAt}` (segments ≤24 h, settled + periodically per boot) → recorded, overage, whether new starts are still allowed (so the control plane can stop). +- past_due: new starts refused; running sandboxes are the control plane's call (open decision: grace period). +- Known gap: a segment reported after period end but before the renewal webhook lands counts toward the old period. + +## Security + +- No card data touches us (hosted Checkout/Portal only). Desktop opens only `https://checkout.stripe.com/` / + `https://billing.stripe.com/` URLs. +- Secrets only via `wrangler secret put`: `STRIPE_SECRET_KEY` (restricted `rk_test_`: Customers W, Checkout Sessions W, + Portal W, Subscriptions R, Meter events W), `STRIPE_WEBHOOK_SECRET`, `CONTROL_PLANE_TOKEN`. Never in git/wrangler.jsonc. +- Live-mode guard: Worker refuses `sk_live_/rk_live_` keys and live events unless `STRIPE_ALLOW_LIVEMODE=true`. +- Pinned `Stripe-Version`; strict zod bodies; size limits; redirects refused on Stripe calls; JWT `maxTokenAge` 15 m. + +## Test strategy + +- Flow + unit tests (`cd cloud && pnpm --filter @orca-cloud/billing test`): D1 fake over `node:sqlite` running the real + migrations; in-memory Stripe behind `fetch` (exercises encoding, headers, idempotency); Stripe-shaped event fixtures; + signatures generated exactly as Stripe/stripe-node do (node:crypto). Covers signature, idempotency, out-of-order, + livemode, foreign customer, overage math, outbox retry, caps, roles, auth. +- Local workerd smoke: `wrangler d1 migrations apply --local` + `wrangler dev` (verified against real D1). +- Next, with test keys: `stripe listen --forward-to 127.0.0.1:8790/v1/stripe/webhook`, `stripe trigger` fixtures, + Stripe test clocks for renewals/past_due, and a usage audit (our ledger vs meter event summaries). + +## Open decisions + +1. Final prices/included hours/overage caps; trial length. 2. past_due grace for running sandboxes. 3. Team seat ↔ +org member count sync (manual seats in Portal for now). 4. Tax (Stripe Tax) and invoicing for Team. 5. Billing URL +host (e.g. `billing.onorca.dev`) and return page that deep-links back to Orca. 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':