import { type Context, Hono } from 'hono' import Stripe from 'stripe' import * as z from 'zod/mini' import * as ApiKeys from '../../../ApiKeys.js' import * as Auth from '../../../internal/Auth.js' import * as BillingSettings from '../../../db/tables/billingSettings.js' import * as Db from '../../../db/Db.js' import * as EnabledBillingSources from '../../../db/tables/enabledBillingSources.js' import * as OpenApi from '../../../internal/OpenApi.js' import * as Response from '../../../internal/Response.js' import * as Schema from '../../../internal/Schema.js' import * as SponsoredTransactions from '../../../db/tables/sponsoredTransactions.js' import * as StripeCustomers from '../../../db/tables/stripeCustomers.js' import * as Viem from '../../../internal/Viem.js' import * as core_Billing from '../Billing.js' import type { Billing, Environment } from '../App.js' /** Zod schemas owned by the billing resource. */ export namespace schema { const OrgId = z .string() .check( z.describe('The organization id (`org_…`).'), z.meta({ examples: ['org_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ) /** Path parameters addressing one organization's billing. */ export const Params = z .object({ orgId: OrgId }) .check(z.describe("Path parameters for one organization's billing.")) /** Query parameters selecting the billing environment. */ export const Query = z .strictObject({ environment: Schema.Environment }) .check(z.describe("Query parameters for one organization's billing.")) /** Path parameters addressing one payment method. */ export const MethodParams = z .object({ methodId: z .string() .check( z.describe('The payment method id (`pm_…`).'), z.meta({ examples: ['pm_1NVChw2eZvKYlo2CHxiM5E2N'] }), ), orgId: OrgId, }) .check(z.describe('Path parameters addressing one payment method.')) /** A per-period spend limit. */ export const SpendLimit = OpenApi.component( Schema.describe( z.object({ amount: z.string().check( // The lookahead rejects zero: a zero limit would refuse all // sponsorship, which `null` (clear) already expresses. z.regex(/^(?!0+(\.0+)?$)\d{1,9}(\.\d{1,2})?$/), z.describe('Limit as a positive decimal string in `currency` units.'), z.meta({ examples: ['250'] }), ), currency: z ._default(z.enum(BillingSettings.currencies), 'usd') .check( z.describe('Currency the limit is denominated in; `usd` only today.'), z.meta({ examples: ['usd'] }), ), period: z ._default(z.enum(BillingSettings.periods), 'month') .check( z.describe('Window the limit applies over; `month` (UTC calendar) only today.'), z.meta({ examples: ['month'] }), ), }), 'A per-period spend limit.', ), 'BillingSpendLimit', ) /** A per-transaction fee cap. */ export const TxFeeLimit = OpenApi.component( Schema.describe( z.object({ amount: z.string().check( // The lookahead rejects zero: a zero cap would refuse every // transaction, which is not a supported configuration. z.regex(/^(?!0+(\.0+)?$)\d{1,9}(\.\d{1,2})?$/), z.describe('Cap as a positive decimal string in `currency` units.'), z.meta({ examples: ['0.50'] }), ), currency: z ._default(z.enum(BillingSettings.currencies), 'usd') .check( z.describe('Currency the cap is denominated in; `usd` only today.'), z.meta({ examples: ['usd'] }), ), }), 'A per-transaction fee cap.', ), 'BillingTransactionFeeLimit', ) /** PATCH body; absent fields stay unchanged, null clears a limit. */ export const Patch = OpenApi.component( Schema.describe( z.object({ spendLimit: z .optional(z.nullable(SpendLimit)) .check( z.describe('Spend limit per period; absent leaves it unchanged, null removes it.'), z.meta({ examples: [{ amount: '250', currency: 'usd', period: 'month' }] }), ), txFeeLimit: z.optional(z.nullable(TxFeeLimit)).check( z.describe('Per-transaction fee cap; absent leaves it unchanged, null restores the platform default.'), // prettier-ignore z.meta({ examples: [{ amount: '0.50', currency: 'usd' }] }), ), }), 'Billing settings to apply.', ), 'UpdateBillingRequest', ) /** An organization's billing state. */ export const Billing = OpenApi.component( Schema.describe( z.object({ enabledSources: z .array(z.enum(EnabledBillingSources.sources)) .check( z.describe('Billing sources this organization may set up.'), z.meta({ examples: [['stripe']] }), ), spend: z .optional( z.object({ amount: z.string().check( z.describe('Committed spend as a decimal string: finalized fees plus in-flight fee caps.'), // prettier-ignore z.meta({ examples: ['12.34'] }), ), currency: z .enum(BillingSettings.currencies) .check(z.describe('Currency of the spend figure.'), z.meta({ examples: ['usd'] })), period: z .enum(BillingSettings.periods) .check(z.describe('Window the figure covers.'), z.meta({ examples: ['month'] })), }), ) .check( z.describe('Billable production spend committed in the current window.'), z.meta({ examples: [{ amount: '12.34', currency: 'usd', period: 'month' }] }), ), spendLimit: z .optional(SpendLimit) .check( z.describe('Configured spend limit; absent when the organization has none.'), z.meta({ examples: [{ amount: '250', currency: 'usd', period: 'month' }] }), ), status: z.enum(StripeCustomers.statuses).check( z.describe("Billing status derived from the organization's billing source; `active` opens production fee sponsorship."), // prettier-ignore z.meta({ examples: ['active'] }), ), txFeeLimit: z .optional(TxFeeLimit) .check( z.describe( 'Configured per-transaction fee cap; absent when the platform default applies.', ), z.meta({ examples: [{ amount: '0.50', currency: 'usd' }] }), ), updatedAt: z.optional(z.iso.datetime()).check( z.describe('When the billing status last changed (ISO 8601); absent before first checkout.'), // prettier-ignore z.meta({ examples: ['2026-01-01T00:00:00.000Z'] }), ), }), "An organization's billing state.", ), 'Billing', ) /** A hosted Stripe session link. */ export const Session = OpenApi.component( Schema.describe( z.object({ url: z .url() .check( z.describe('Hosted Stripe session URL; navigate the browser here.'), z.meta({ examples: ['https://checkout.stripe.com/c/pay/cs_test_a1b2c3'] }), ), }), 'A hosted Stripe session link.', ), 'BillingSession', ) /** A payment method on file. */ export const PaymentMethod = OpenApi.component( Schema.describe( z.object({ card: z .optional( z.object({ brand: z .string() .check(z.describe('Card brand (e.g. `visa`).'), z.meta({ examples: ['visa'] })), expMonth: z .number() .check(z.describe('Expiry month (1-12).'), z.meta({ examples: [12] })), expYear: z.number().check(z.describe('Expiry year.'), z.meta({ examples: [2030] })), last4: z .string() .check(z.describe('Last four digits.'), z.meta({ examples: ['4242'] })), }), ) .check( z.describe('Card details; present when `type` is `card`.'), z.meta({ examples: [{ brand: 'visa', expMonth: 12, expYear: 2030, last4: '4242' }] }), ), createdAt: z.iso .datetime() .check( z.describe('When the method was attached (ISO 8601).'), z.meta({ examples: ['2026-01-01T00:00:00.000Z'] }), ), default: z .boolean() .check(z.describe('Whether invoices charge this method.'), z.meta({ examples: [true] })), id: z .string() .check( z.describe('The payment method id (`pm_…`).'), z.meta({ examples: ['pm_1NVChw2eZvKYlo2CHxiM5E2N'] }), ), provider: z .literal('stripe') .check( z.describe('Payment provider; `stripe` only today.'), z.meta({ examples: ['stripe'] }), ), type: z .string() .check( z.describe('Method type (e.g. `card`, `us_bank_account`).'), z.meta({ examples: ['card'] }), ), }), 'A payment method on file.', ), 'BillingPaymentMethod', ) /** An organization's payment methods. */ export const PaymentMethods = OpenApi.component( Schema.describe( z.object({ data: z .array(PaymentMethod) .check(z.describe('Payment methods on file, newest first.'), z.meta({ examples: [[]] })), }), "An organization's payment methods.", ), 'BillingPaymentMethodList', ) } /** * Mounts billing routes. Reads require membership or scoped API key access; organizations without provider state return `none`. */ export function billing() { return new Hono() .get( '/v1/orgs/:orgId{org_[A-Za-z0-9_-]+}/billing', Auth.policy({ apiKey: { scopes: ['management:read'] }, session: true }), Auth.ensureOrg(), OpenApi.validate('param', schema.Params, { code: 'param_invalid', message: 'Check the path parameters and try again.', }), OpenApi.validate('query', schema.Query, { code: 'query_invalid', message: 'Check the query parameters and try again.', }), OpenApi.describeRoute({ operationId: 'getBilling', responses: OpenApi.responses({ errors: { 400: { codes: ['param_invalid', 'query_invalid'], description: 'Malformed API key, invalid path, or invalid query.', }, 404: { codes: ['organization_not_found'], description: 'No accessible organization was found for this id.', }, }, success: { description: "The organization's billing state.", schema: schema.Billing }, }), summary: 'Get billing', tags: ['Billing'], }), async (c) => { if (Auth.narrowAccess) return Auth.accessError(c) if (Auth.narrowScope) return Auth.ensureOrgError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'param_invalid', message: 'Check the path parameters and try again.', }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Check the query parameters and try again.', }) const environment = c.req.valid('query').environment try { return c.json( Response.validated(schema.Billing, await billingBody(c, Auth.org(c).id, environment)), 200, ) } catch (cause) { return Response.upstream(c, cause) } }, ) .patch( '/v1/orgs/:orgId{org_[A-Za-z0-9_-]+}/billing', Auth.policy({ apiKey: { scopes: ['management:write'] }, session: true }), Auth.ensureOrg({ role: 'owner' }), OpenApi.validate('param', schema.Params, { code: 'param_invalid', message: 'Check the path parameters and try again.', }), OpenApi.validate('query', schema.Query, { code: 'query_invalid', message: 'Check the query parameters and try again.', }), OpenApi.validate('json', schema.Patch, { code: 'body_invalid', message: 'Check the request body and try again.', }), OpenApi.describeRoute({ operationId: 'updateBilling', responses: OpenApi.responses({ errors: { 400: { codes: ['body_invalid', 'param_invalid', 'query_invalid'], description: 'Malformed API key, invalid path, invalid query, or invalid body.', }, 404: { codes: ['organization_not_found'], description: 'No accessible organization was found for this id.', }, }, success: { description: "The organization's updated billing state.", schema: schema.Billing, }, }), summary: 'Update billing', tags: ['Billing'], }), async (c) => { if (Auth.narrowAccess) return Auth.accessError(c) if (Auth.narrowScope) return Auth.ensureOrgError(c) if (Auth.narrowOrgRole) return Auth.ensureOrgRoleError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'param_invalid', message: 'Check the path parameters and try again.', }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Check the query parameters and try again.', }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'body_invalid', message: 'Check the request body and try again.', }) const environment = c.req.valid('query').environment const body = c.req.valid('json') const org = Auth.org(c) const db = Db.get(c.get('db')) try { await BillingSettings.upsert(db, { environment, orgId: org.id, ...(body.spendLimit !== undefined ? body.spendLimit === null ? { spendLimit: null } : { currency: body.spendLimit.currency, period: body.spendLimit.period, spendLimit: body.spendLimit.amount, } : {}), ...(body.txFeeLimit !== undefined ? body.txFeeLimit === null ? { txFeeLimit: null } : { currency: body.txFeeLimit.currency, txFeeLimit: body.txFeeLimit.amount } : {}), }) return c.json( Response.validated(schema.Billing, await billingBody(c, org.id, environment)), 200, ) } catch (cause) { return Response.upstream(c, cause) } }, ) .post( '/v1/orgs/:orgId{org_[A-Za-z0-9_-]+}/billing/stripe/checkout', Auth.policy({ apiKey: { scopes: ['management:write'] }, session: true }), Auth.ensureOrg({ role: 'owner' }), OpenApi.validate('param', schema.Params, { code: 'param_invalid', message: 'Check the path parameters and try again.', }), OpenApi.validate('query', schema.Query, { code: 'query_invalid', message: 'Check the query parameters and try again.', }), OpenApi.describeRoute({ operationId: 'createStripeCheckout', responses: OpenApi.responses({ errors: { 400: { codes: ['param_invalid', 'query_invalid'], description: 'Malformed API key, invalid path, or invalid query.', }, 403: { codes: ['billing_source_disabled'], description: 'Requires the owner role and Stripe billing enabled.', }, 404: { codes: ['organization_not_found'], description: 'No accessible organization was found for this id.', }, 501: { codes: ['billing_unconfigured'], description: 'Billing is not configured on this deployment.', }, }, success: { description: 'A Stripe Checkout (setup mode) session for attaching a payment method.', schema: schema.Session, }, }), summary: 'Create checkout session', tags: ['Billing'], }), async (c) => { if (Auth.narrowAccess) return Auth.accessError(c) if (Auth.narrowScope) return Auth.ensureOrgError(c) if (Auth.narrowOrgRole) return Auth.ensureOrgRoleError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'param_invalid', message: 'Check the path parameters and try again.', }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Check the query parameters and try again.', }) const org = Auth.org(c) const db = Db.get(c.get('db')) try { if (!(await EnabledBillingSources.isEnabled(db, { orgId: org.id, source: 'stripe' }))) return billingSourceDisabled(c) const billing = c.get('billing') if (!billing) return billingUnconfigured(c) const environment = c.req.valid('query').environment const source = stripeFor(billing, environment) if (!source) return billingUnconfigured(c) const { client } = source const existing = await StripeCustomers.get(db, org.id, environment) const customerId = existing ? existing.stripeCustomerId : await (async () => { const customer = await client.customers.create({ metadata: { orgId: org.id }, name: org.name, }) // The insert resolves a concurrent first-checkout race to the // winning row; always use its customer, not the one just made. const record = await StripeCustomers.create(db, { environment, orgId: org.id, stripeCustomerId: customer.id, }) return record.stripeCustomerId })() const session = await client.checkout.sessions.create({ cancel_url: returnUrl(c, { environment, orgId: org.id, status: 'canceled', stripe: source }), // prettier-ignore // Setup mode with dynamic payment methods requires a currency; // fee-payer spend bills in USD. currency: 'usd', customer: customerId, mode: 'setup', success_url: returnUrl(c, { environment, orgId: org.id, status: 'success', stripe: source }), // prettier-ignore }) if (!session.url) return Response.upstream(c, new Error('checkout session has no url')) return c.json(Response.validated(schema.Session, { url: session.url }), 200) } catch (cause) { return Response.upstream(c, cause) } }, ) .post( '/v1/orgs/:orgId{org_[A-Za-z0-9_-]+}/billing/stripe/manage', Auth.policy({ apiKey: { scopes: ['management:write'] }, session: true }), Auth.ensureOrg({ role: 'owner' }), OpenApi.validate('param', schema.Params, { code: 'param_invalid', message: 'Check the path parameters and try again.', }), OpenApi.validate('query', schema.Query, { code: 'query_invalid', message: 'Check the query parameters and try again.', }), OpenApi.describeRoute({ operationId: 'createStripeManage', responses: OpenApi.responses({ errors: { 400: { codes: ['param_invalid', 'query_invalid'], description: 'Malformed API key, invalid path, or invalid query.', }, 404: { codes: ['billing_not_found', 'organization_not_found'], description: 'No accessible organization or billing account was found.', }, 501: { codes: ['billing_unconfigured'], description: 'Billing is not configured on this deployment.', }, }, success: { description: 'A Stripe billing-portal session for managing payment methods and invoices.', // prettier-ignore schema: schema.Session, }, }), summary: 'Create manage session', tags: ['Billing'], }), async (c) => { if (Auth.narrowAccess) return Auth.accessError(c) if (Auth.narrowScope) return Auth.ensureOrgError(c) if (Auth.narrowOrgRole) return Auth.ensureOrgRoleError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'param_invalid', message: 'Check the path parameters and try again.', }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Check the query parameters and try again.', }) const billing = c.get('billing') if (!billing) return billingUnconfigured(c) const environment = c.req.valid('query').environment const source = stripeFor(billing, environment) if (!source) return billingUnconfigured(c) const org = Auth.org(c) const db = Db.get(c.get('db')) try { const record = await StripeCustomers.get(db, org.id, environment) if (!record) return billingNotFound(c) const session = await source.client.billingPortal.sessions.create({ customer: record.stripeCustomerId, return_url: returnUrl(c, { environment, orgId: org.id, stripe: source }), }) return c.json(Response.validated(schema.Session, { url: session.url }), 200) } catch (cause) { return Response.upstream(c, cause) } }, ) .get( '/v1/orgs/:orgId{org_[A-Za-z0-9_-]+}/billing/payment-methods', Auth.policy({ apiKey: { scopes: ['management:read'] }, session: true }), Auth.ensureOrg(), OpenApi.validate('param', schema.Params, { code: 'param_invalid', message: 'Check the path parameters and try again.', }), OpenApi.validate('query', schema.Query, { code: 'query_invalid', message: 'Check the query parameters and try again.', }), OpenApi.describeRoute({ operationId: 'getBillingPaymentMethods', responses: OpenApi.responses({ errors: { 400: { codes: ['param_invalid', 'query_invalid'], description: 'Malformed API key, invalid path, or invalid query.', }, 404: { codes: ['organization_not_found'], description: 'No accessible organization was found for this id.', }, 501: { codes: ['billing_unconfigured'], description: 'Billing is not configured on this deployment.', }, }, success: { description: "The organization's payment methods.", schema: schema.PaymentMethods, }, }), summary: 'Get payment methods', tags: ['Billing'], }), async (c) => { if (Auth.narrowAccess) return Auth.accessError(c) if (Auth.narrowScope) return Auth.ensureOrgError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'param_invalid', message: 'Check the path parameters and try again.', }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Check the query parameters and try again.', }) const billing = c.get('billing') if (!billing) return billingUnconfigured(c) const environment = c.req.valid('query').environment const source = stripeFor(billing, environment) if (!source) return billingUnconfigured(c) const { client } = source const org = Auth.org(c) const db = Db.get(c.get('db')) try { // Orgs that never checked out simply have no methods yet. const record = await StripeCustomers.get(db, org.id, environment) if (!record) return c.json(Response.validated(schema.PaymentMethods, { data: [] }), 200) const customer = await client.customers.retrieve(record.stripeCustomerId) if (customer.deleted) return c.json(Response.validated(schema.PaymentMethods, { data: [] }), 200) const fallback = customer.invoice_settings.default_payment_method const methods = await client.customers.listPaymentMethods(record.stripeCustomerId, { limit: 100 }) // prettier-ignore return c.json( Response.validated(schema.PaymentMethods, { data: methods.data.map((method) => ({ ...(method.card ? { card: { brand: method.card.brand, expMonth: method.card.exp_month, expYear: method.card.exp_year, last4: method.card.last4, }, } : {}), createdAt: new Date(method.created * 1000).toISOString(), default: method.id === fallback, id: method.id, provider: 'stripe' as const, type: method.type, })), }), 200, ) } catch (cause) { return Response.upstream(c, cause) } }, ) .delete( '/v1/orgs/:orgId{org_[A-Za-z0-9_-]+}/billing/payment-methods/:methodId{pm_[A-Za-z0-9_-]+}', Auth.policy({ apiKey: { scopes: ['management:write'] }, session: true }), Auth.ensureOrg({ role: 'owner' }), OpenApi.validate('param', schema.MethodParams, { code: 'param_invalid', message: 'Check the path parameters and try again.', }), OpenApi.validate('query', schema.Query, { code: 'query_invalid', message: 'Check the query parameters and try again.', }), OpenApi.describeRoute({ operationId: 'deleteBillingPaymentMethod', responses: OpenApi.responses({ errors: { 400: { codes: ['param_invalid', 'query_invalid'], description: 'Malformed API key, invalid path, or invalid query.', }, 404: { codes: ['billing_not_found', 'organization_not_found', 'payment_method_not_found'], description: 'No accessible organization, billing account, or payment method was found.', // prettier-ignore }, 501: { codes: ['billing_unconfigured'], description: 'Billing is not configured on this deployment.', }, }, success: { description: "The organization's updated billing state.", schema: schema.Billing, }, }), summary: 'Remove payment method', tags: ['Billing'], }), async (c) => { if (Auth.narrowAccess) return Auth.accessError(c) if (Auth.narrowScope) return Auth.ensureOrgError(c) if (Auth.narrowOrgRole) return Auth.ensureOrgRoleError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'param_invalid', message: 'Check the path parameters and try again.', }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Check the query parameters and try again.', }) const billing = c.get('billing') if (!billing) return billingUnconfigured(c) const environment = c.req.valid('query').environment const source = stripeFor(billing, environment) if (!source) return billingUnconfigured(c) const { client } = source const org = Auth.org(c) const db = Db.get(c.get('db')) try { const record = await StripeCustomers.get(db, org.id, environment) if (!record) return billingNotFound(c) // Scoping the lookup to the org's own list is the ownership check; // ids belonging to other customers read as absent. const methods = await client.customers.listPaymentMethods(record.stripeCustomerId, { limit: 100 }) // prettier-ignore const method = methods.data.find((m) => m.id === c.req.valid('param').methodId) if (!method) return paymentMethodNotFound(c) await client.paymentMethods.detach(method.id) // The metered subscription stays: accrued usage still invoices at // period end, and re-adding a method reuses it. const status = await deriveStatus(client, record.stripeCustomerId) await StripeCustomers.setStatus(db, record.orgId, status, environment) await syncKeyBilling(c, { environment, orgId: record.orgId, status }) return c.json( Response.validated(schema.Billing, await billingBody(c, org.id, environment)), 200, ) } catch (cause) { return Response.upstream(c, cause) } }, ) .post( '/stripe/webhook', // Inbound Stripe callback: the signature is the gate; the public quota // only absorbs delivery bursts. Never a partner-facing API route. Auth.policy({ apiKey: false, mpp: false, public: { rateLimit: { limit: 600, period: 'minute' } }, session: false, }), OpenApi.describeRoute({ hide: true, summary: 'Stripe webhook' }), async (c) => { const billing = c.get('billing') if (!billing) return billingUnconfigured(c) const signature = c.req.header('stripe-signature') if (!signature) return signatureInvalid(c) const payload = await c.req.text() // One endpoint, two secrets: verify against the live secret, then the // sandbox secret. Test and live events are signed by different endpoint // secrets, so try-both is unambiguous; the customer row's environment // (below) picks the client for follow-up calls. const event = await verifyEvent(billing, payload, signature) if (!event) return signatureInvalid(c) // Events are triggers, not state carriers: on every handled event the // status re-derives from fetched Stripe truth, so duplicates and // out-of-order delivery are harmless. Unhandled types ack immediately. if (!handledEvents.has(event.type)) return c.json({ received: true }, 200) const customer = eventCustomer(event) if (!customer) return c.json({ received: true }, 200) const db = Db.get(c.get('db')) try { // Unknown customers ack with 200: other environments may share the // Stripe account, and their events are not ours to fail. const record = await StripeCustomers.getByCustomer(db, customer) if (!record) return c.json({ received: true }, 200) // The row's environment picks the mode's client; a sandbox row with // no sandbox source configured acks untouched. const source = stripeFor(billing, record.environment) if (!source) return c.json({ received: true }, 200) const { client } = source // A completed setup Checkout promotes its payment method to the // customer default; invoicing charges the default. if (event.type === 'checkout.session.completed') { const session = event.data.object if (session.mode === 'setup' && typeof session.setup_intent === 'string') { const intent = await client.setupIntents.retrieve(session.setup_intent) if (typeof intent.payment_method === 'string') await client.customers.update(customer, { invoice_settings: { default_payment_method: intent.payment_method }, }) } } const status = await deriveStatus(client, customer) // Provision the metered subscription before persisting `active`, so // the gate never opens (`Billing.status` reads the stored status) // without a subscription to invoice the spend against. A failure // here surfaces as 5xx and Stripe redelivers. if (status === 'active') await core_Billing.ensureSubscription( client, customer, core_Billing.kindsFor(record.environment), ) await StripeCustomers.setStatus(db, record.orgId, status, record.environment) await syncKeyBilling(c, { environment: record.environment, orgId: record.orgId, status }) return c.json({ received: true }, 200) } catch (cause) { return Response.upstream(c, cause) } }, ) } /** Web Crypto signature verification; Workers have no Node `crypto` module. */ const cryptoProvider = Stripe.createSubtleCryptoProvider() /** Resource environment a billing request targets. */ type ResourceEnvironment = StripeCustomers.Record['environment'] /** A resolved Stripe billing source: the live bag or the test-mode sandbox bag. */ type StripeSource = { client: Stripe; consoleUrl?: string | undefined; webhookSecret: string } /** * Resolves the Stripe source for an environment: the live bag for `production`, * the test-mode `sandbox` bag otherwise. Returns undefined when sandbox billing * is unconfigured, which the caller maps to `billing_unconfigured` (501). */ function stripeFor(billing: Billing, environment: ResourceEnvironment): StripeSource | undefined { return environment === 'production' ? billing.stripe : billing.stripe.sandbox } /** * Re-stamps `billingActive` onto the org's sandbox key records after a status * change, so the auth throttle reflects the new state without a per-request * billing read. Only sandbox is gated, and re-stamping is a no-op when the KV * store is unconfigured. */ async function syncKeyBilling( c: Context, options: { environment: ResourceEnvironment; orgId: string; status: StripeCustomers.Status }, ): Promise { if (options.environment !== 'sandbox') return const kv = c.get('kv') if (!kv) return await ApiKeys.setBillingActive(kv.store, { active: options.status === 'active', environment: 'sandbox', orgId: options.orgId, scopeCatalog: c.get('scopeCatalog'), }) } /** * Verifies a webhook signature against the live secret, then the sandbox * secret; returns the parsed event on the first match, or undefined. The * client instance is irrelevant to verification (the secret is the gate), so * both attempts reuse the live client. */ async function verifyEvent( billing: Billing, payload: string, signature: string, ): Promise { const secrets = [ billing.stripe.webhookSecret, ...(billing.stripe.sandbox ? [billing.stripe.sandbox.webhookSecret] : []), ] for (const secret of secrets) { try { return await billing.stripe.client.webhooks.constructEventAsync( payload, signature, secret, undefined, cryptoProvider, ) } catch {} } return undefined } /** Event types that funnel into status derivation; everything else acks untouched. */ const handledEvents: ReadonlySet = new Set([ 'checkout.session.completed', 'customer.deleted', 'customer.subscription.deleted', 'customer.subscription.updated', 'customer.updated', 'invoice.paid', 'invoice.payment_failed', 'payment_method.attached', 'payment_method.detached', 'setup_intent.succeeded', ] satisfies Stripe.Event['type'][]) /** Stripe customer id an event concerns; detached payment methods only carry it in `previous_attributes`. */ function eventCustomer(event: Stripe.Event): string | undefined { const object = event.data.object as { customer?: unknown; id?: string; object?: string } if (object.object === 'customer') return object.id if (typeof object.customer === 'string') return object.customer const previous = event.data.previous_attributes as { customer?: unknown } | undefined return typeof previous?.customer === 'string' ? previous.customer : undefined } /** * Billing status from live Stripe state: deleted customers are `canceled`; an * unpaid metered subscription parks the org at `past_due`; a default payment * method or any attached card is `active`; else `none`. */ async function deriveStatus(stripe: Stripe, customerId: string): Promise { const customer = await stripe.customers.retrieve(customerId) if (customer.deleted) return 'canceled' const subscriptions = await stripe.subscriptions.list({ customer: customerId, limit: 100 }) if (subscriptions.data.some((s) => s.status === 'past_due' || s.status === 'unpaid')) return 'past_due' if (customer.invoice_settings.default_payment_method) return 'active' const methods = await stripe.customers.listPaymentMethods(customerId, { limit: 1, type: 'card' }) // prettier-ignore return methods.data.length > 0 ? 'active' : 'none' } /** Builds the billing response body: source status, configured limits, current-window spend. */ async function billingBody( c: Context, orgId: string, environment: ResourceEnvironment, ) { const db = Db.get(c.get('db')) const [enabledSources, record, settings] = await Promise.all([ EnabledBillingSources.listByOrg(db, orgId), StripeCustomers.get(db, orgId, environment), BillingSettings.get(db, orgId, environment), ]) const currency = settings?.currency ?? 'usd' const period = settings?.period ?? 'month' // Mirrors the sponsorship gate's scope so the console shows the figure the // limit is enforced against; sandbox spend is testnet, display-only. const spend = await SponsoredTransactions.spend(Db.get(c.get('dbCached')), { chainIds: [environment === 'production' ? Viem.chainId.mainnet : Viem.chainId.testnet], environment, orgId, since: core_Billing.periodStart(period), }) return { enabledSources: enabledSources.map((record) => record.source), spend: { amount: core_Billing.fromBaseUnits(spend), currency, period }, ...(settings?.spendLimit != null ? { spendLimit: { amount: settings.spendLimit, currency, period } } : {}), status: record?.status ?? 'none', ...(settings?.txFeeLimit != null ? { txFeeLimit: { amount: settings.txFeeLimit, currency } } : {}), ...(record ? { updatedAt: record.updatedAt } : {}), } } /** * Console billing page a Stripe session returns to: the caller's `Origin` when * present, else the configured console URL. Sandbox sessions carry `env=sandbox` * so the full-page Stripe round-trip returns to the same console mode (the * client-side `retainSearchParams` can't survive it); `status` sets `billing`. */ function returnUrl( c: Context, options: { environment: ResourceEnvironment orgId: string status?: 'success' | 'canceled' | undefined stripe: StripeSource }, ) { const { environment, orgId, status, stripe } = options // Origin is safe to trust: SameSite=Lax session cookies never authenticate // cross-site POSTs, so a forged Origin only redirects the caller themselves. const origin = c.req.header('origin') const base = (() => { try { if (origin) return new URL(origin).origin } catch {} return (stripe.consoleUrl ?? 'https://console.tempo.xyz').replace(/\/$/, '') })() const url = new URL(`${base}/${orgId}/billing`) if (environment === 'sandbox') url.searchParams.set('env', 'sandbox') if (status) url.searchParams.set('billing', status) return url.toString() } function signatureInvalid(c: Context) { return Response.error(c, { code: 'signature_invalid', message: 'Missing or invalid Stripe signature', status: 400, }) } function billingNotFound(c: Context) { return Response.error(c, { code: 'billing_not_found', message: 'No billing to manage; attach a payment method first', status: 404, }) } function billingSourceDisabled(c: Context) { return Response.error(c, { code: 'billing_source_disabled', message: 'Stripe billing is not enabled for this organization', status: 403, }) } function billingUnconfigured(c: Context) { return Response.error(c, { code: 'billing_unconfigured', message: 'Billing is not configured on this deployment', status: 501, }) } function paymentMethodNotFound(c: Context) { return Response.error(c, { code: 'payment_method_not_found', message: 'No payment method was found for this id', status: 404, }) }