feat(billing): Cloudflare Worker for Stripe checkout, portal, webhooks, entitlements and usage (#7)

Test-mode-only foundation for paid Orca plans: hosted Checkout/Portal,
signed + idempotent Stripe webhooks that re-read subscriptions, a D1 ledger
for sandbox usage with overage-only Billing meter events, and entitlement
checks for the desktop and the sandbox control plane.
This commit is contained in:
Neil
2026-11-08 22:02:00 -08:00
committed by Neil
parent 7c1483278b
commit faaa842a98
26 changed files with 2381 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
.wrangler/
.dev.vars
@@ -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:<orgId>') or a signed-in user without an org
-- ('user:<userId>').
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);
+23
View File
@@ -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"
}
}
@@ -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<T>(path: string, params: Record<string, unknown>, idempotencyKey: string): Promise<T> {
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<string, string> = {}
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))
+99
View File
@@ -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<string, JWTVerifyGetKey>()
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<BillingCaller | null> {
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<string> {
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<boolean> {
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
}
+96
View File
@@ -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, string>): 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<string> {
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<CheckoutOutcome> {
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<CheckoutOutcome> {
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 }
}
+116
View File
@@ -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<Entitlement> {
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 }
}
+42
View File
@@ -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
}
+144
View File
@@ -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<unknown> {
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<Response> {
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<Response> {
return handleRequest(request, env, ctx)
},
async scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
ctx.waitUntil(flushMeterReports(env, createStripeClient(env), Date.now()).then(() => undefined))
}
} satisfies ExportedHandler<Env>
+78
View File
@@ -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<MachineSize, number> = { 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<PlanId, PlanEntitlement> = {
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
}
+247
View File
@@ -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<void> {
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<string | null> {
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<string | null> {
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<string> {
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<SubscriptionRow | null> {
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<SubscriptionRow>()
}
export async function upsertSubscription(db: D1Database, row: SubscriptionRow): Promise<void> {
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<boolean> {
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<number> {
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<MeterReportRow[]> {
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<MeterReportRow>()).results
}
export async function markMeterReport(
db: D1Database,
usageId: string,
outcome: { sent: true; now: number } | { sent: false; error: string; giveUp: boolean }
): Promise<void> {
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<void> {
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()
}
+169
View File
@@ -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<typeof createStripeClient>
export function createStripeClient(env: Env, fetchImpl: typeof fetch = fetch) {
const base = env.STRIPE_API_BASE ?? 'https://api.stripe.com'
const version = env.STRIPE_API_VERSION ?? DEFAULT_STRIPE_API_VERSION
async function request<T>(method: 'GET' | 'POST', path: string, params?: FormRecord, idempotencyKey?: string) {
assertKeyMode(env)
const headers: Record<string, string> = {
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<StripeSubscription>('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<string, string>
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 }
}>
}
}
+107
View File
@@ -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<typeof UsageReportSchema>
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 }
}
@@ -0,0 +1,84 @@
// Stripe webhook signature verification (https://docs.stripe.com/webhooks#verify-manually):
// header `Stripe-Signature: t=<unix>,v1=<hex hmac>[,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<string> {
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<SignatureResult> {
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)
}
+160
View File
@@ -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<string, unknown> }
}
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<Response> {
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 })
}
}
@@ -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<string, unknown> = {}) {
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<string, unknown>) => 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' }
})
})
})
+64
View File
@@ -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<T>(): Promise<T | null> {
return (this.db.prepare(this.sql).get(...this.params) as T | undefined) ?? null
}
async all<T>(): 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 }
}
+77
View File
@@ -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<Env> = {}) {
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<unknown>[] = []
const ctx = { waitUntil: (promise: Promise<unknown>) => waits.push(promise), passThroughOnException() {} } as unknown as ExecutionContext
async function call(method: string, path: string, init: { body?: unknown; rawBody?: string; headers?: Record<string, string> } = {}) {
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<string, any> }
}
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}` } })
}
}
}
@@ -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<Env> = {}) => ({ 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')
})
})
+166
View File
@@ -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<string, string>
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<string, { id: string; metadata: Record<string, string> }>()
const subscriptions = new Map<string, FakeSubscription>()
const idempotent = new Map<string, unknown>()
const meterEvents: Array<Record<string, string>> = []
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<string, unknown>, 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}`
}
@@ -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)
})
})
+14
View File
@@ -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"]
}
+7
View File
@@ -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'] }
})
+31
View File
@@ -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"
}
}
+97
View File
@@ -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:<orgId>` when the token names an org, else `user:<userId>`. 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:<account>`). 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.
+25
View File
@@ -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':