mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
@@ -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);
|
||||
@@ -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))
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 }
|
||||
}>
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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'] }
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Generated
+25
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user