Files
orca/cloud/apps/billing/src/webhook.ts
T
Neil 612269c105 fix(billing): remove the overage item on opt-out once nothing is left to invoice
Opting out left the metered overage item on the Stripe subscription, so every later
invoice carried a '0 std hour x Orca sandbox overage' $0 line (confirmed on a
throwaway test-mode subscription). The item now comes off at once when nothing was
metered this Stripe period, or at the first sync after the period rolls over when
there is usage still to invoice. Re-opting in within 24h got the removed item's id
replayed by Stripe idempotency (also confirmed live); the add now retries under a
key naming the removed item. Nothing meters while opted out or with a $0 cap.
2026-11-10 20:51:23 -08:00

180 lines
7.2 KiB
TypeScript

// 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, currentSubscription, customerFor, finishEvent, upsertSubscription } from './store'
import { retireOverageItem } from './overage'
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 {}
/** Stripe statuses after a failed renewal: past_due while retrying, unpaid once retries are exhausted. */
export const PAYMENT_FAILED_STATUSES = new Set(['past_due', 'unpaid'])
export async function syncSubscription(env: Env, stripe: StripeClient, subscriptionId: string, now: number) {
const subscription: StripeSubscription = await stripe.retrieveSubscription(subscriptionId)
if (subscription.livemode && !allowsLiveMode(env)) {
throw new IgnoredSubscription('livemode_subscription')
}
const customer = idOf(subscription.customer)
if (!customer) {
throw new IgnoredSubscription('subscription_without_customer')
}
const db = env.BILLING_DB
// Metadata is ours (customers cannot edit it), but the customer link is the authority: an
// account is only credited with a subscription billed to the customer we created for it.
const claimed = subscription.metadata?.account_id
const accountId = claimed && (await customerFor(db, claimed)) === customer ? claimed : await accountForCustomer(db, customer)
if (!accountId) {
throw new IgnoredSubscription('unknown_customer')
}
let match: ReturnType<typeof planForPrice> = null
let seats = 1
let overageItemId: string | null = null
let periodStart = subscription.current_period_start
let periodEnd = subscription.current_period_end
for (const item of subscription.items.data) {
if (item.price.id === env.STRIPE_PRICE_OVERAGE) {
overageItemId = item.id
continue
}
const itemPlan = planForPrice(env, item.price.id)
if (itemPlan) {
match = itemPlan
seats = Math.max(1, item.quantity ?? 1)
periodStart = item.current_period_start ?? periodStart
periodEnd = item.current_period_end ?? periodEnd
}
}
if (!match || !periodStart || !periodEnd) {
throw new IgnoredSubscription('unrecognized_subscription_items')
}
await upsertSubscription(db, {
stripe_subscription_id: subscription.id,
account_id: accountId,
stripe_customer_id: customer,
status: subscription.status,
plan: match.plan,
seats,
overage_enabled: overageItemId ? 1 : 0,
current_period_start: periodStart * 1000,
current_period_end: periodEnd * 1000,
cancel_at_period_end: subscription.cancel_at_period_end ? 1 : 0,
updated_at: now,
billing_interval: match.interval,
overage_item_id: overageItemId,
// The upsert keeps the earliest value while it stays unpaid and clears it once paid.
past_due_since: PAYMENT_FAILED_STATUSES.has(subscription.status) ? now : null,
// Membership comes from auth, not Stripe: keep the stored count.
member_count: null
})
if (overageItemId) {
// An opted-out account's overage item goes once the period it was used in has been invoiced.
const stored = await currentSubscription(db, accountId)
if (stored?.stripe_subscription_id === subscription.id) {
await retireOverageItem(env, stripe, stored, now).catch((error: unknown) => {
console.error(JSON.stringify({ error: 'retire_overage_item', message: error instanceof Error ? error.message.slice(0, 200) : '' }))
})
}
}
}
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 })
}
}