import { sql } from 'kysely' import { nanoid } from 'nanoid' import * as http from 'node:http' import * as https from 'node:https' import Stripe from 'stripe' import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' import * as ApiKeys from '../../../ApiKeys.js' import * as TestApp from '../../../../test/App.js' import * as TestStripe from '../../../../test/Stripe.js' import * as EnabledBillingSources from '../../../db/tables/enabledBillingSources.js' import * as Memberships from '../../../db/tables/memberships.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 * as App from '../../../App.js' /** Origin pinned for SIWE domain binding; Hono test requests use this host. */ const origin = 'http://localhost' /** Super admin secret configured on the test app. */ const secret = 'tempo:sk:b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1' /** Webhook signing secret configured on the test app. */ const webhookSecret = `whsec_${'b'.repeat(32)}` /** Distinct webhook signing secret for the test app's sandbox source. */ const sandboxWebhookSecret = `whsec_${'d'.repeat(32)}` afterAll(() => TestStripe.sweep()) /** * Real Stripe SDK pointed at an unreachable host — nothing is faked. Guarded * paths never issue a request; the paths that do observe a real network * failure (mapped to the upstream error envelope). Stripe-flow coverage lives * in the sandbox-gated suites below. */ function billing(): App.Billing { return { stripe: { client: new Stripe(`sk_test_${'a'.repeat(24)}`, { host: '127.0.0.1', maxNetworkRetries: 0, port: 9, protocol: 'http', timeout: 2_000, }), webhookSecret, }, } } /** * Adds a sandbox source (same unreachable client) to the base capability. Reads * and writes never touch Stripe, so an unreachable client suffices for the * DB-only environment-isolation and webhook-verification suites. */ function billingWithSandbox(): App.Billing { const base = billing() return { stripe: { ...base.stripe, sandbox: { client: base.stripe.client, webhookSecret: sandboxWebhookSecret } }, // prettier-ignore } } function createApp( options: { billing?: App.Billing | undefined db?: TestApp.create.Options['db'] kv?: { store: ReturnType } zones?: TestApp.create.Options['zones'] | undefined } = {}, ) { // prettier-ignore return TestApp.create({ auth: { superAdmin: { secret } }, billing: options.billing ?? billing(), db: options.db ?? TestApp.database(), ...(options.kv ? { kv: options.kv } : {}), session: { wallet: { origin } }, zones: options.zones, }) } test('publishes generator-ready OpenAPI contracts', async () => { const spec = await (await createApp().request('/openapi.json')).json() const billing = spec.paths['/v1/orgs/{orgId}/billing'] const checkout = spec.paths['/v1/orgs/{orgId}/billing/stripe/checkout'].post const manage = spec.paths['/v1/orgs/{orgId}/billing/stripe/manage'].post const methods = spec.paths['/v1/orgs/{orgId}/billing/payment-methods'].get const remove = spec.paths['/v1/orgs/{orgId}/billing/payment-methods/{methodId}'].delete expect({ checkout: { errors: { 400: checkout.responses[400].content['application/json'].schema, 403: checkout.responses[403].content['application/json'].schema, 404: checkout.responses[404].content['application/json'].schema, 501: checkout.responses[501].content['application/json'].schema, }, operationId: checkout.operationId, response: checkout.responses[200].content['application/json'].schema, }, components: [ 'Billing', 'BillingPaymentMethod', 'BillingPaymentMethodList', 'BillingSession', 'BillingSpendLimit', 'BillingTransactionFeeLimit', 'UpdateBillingRequest', ].filter((name) => spec.components.schemas[name]), get: { errors: { 400: billing.get.responses[400].content['application/json'].schema, 404: billing.get.responses[404].content['application/json'].schema, }, operationId: billing.get.operationId, response: billing.get.responses[200].content['application/json'].schema, }, manage: { errors: { 400: manage.responses[400].content['application/json'].schema, 403: manage.responses[403].content['application/json'].schema, 404: manage.responses[404].content['application/json'].schema, 501: manage.responses[501].content['application/json'].schema, }, operationId: manage.operationId, response: manage.responses[200].content['application/json'].schema, }, methods: { errors: { 400: methods.responses[400].content['application/json'].schema, 404: methods.responses[404].content['application/json'].schema, 501: methods.responses[501].content['application/json'].schema, }, operationId: methods.operationId, response: methods.responses[200].content['application/json'].schema, }, remove: { errors: { 400: remove.responses[400].content['application/json'].schema, 403: remove.responses[403].content['application/json'].schema, 404: remove.responses[404].content['application/json'].schema, 501: remove.responses[501].content['application/json'].schema, }, operationId: remove.operationId, response: remove.responses[200].content['application/json'].schema, }, timestamps: { billing: spec.components.schemas.Billing.properties.updatedAt.format, paymentMethod: spec.components.schemas.BillingPaymentMethod.properties.createdAt.format, }, update: { errors: { 400: billing.patch.responses[400].content['application/json'].schema, 403: billing.patch.responses[403].content['application/json'].schema, 404: billing.patch.responses[404].content['application/json'].schema, }, operationId: billing.patch.operationId, request: billing.patch.requestBody.content['application/json'].schema, response: billing.patch.responses[200].content['application/json'].schema, }, }).toMatchInlineSnapshot(` { "checkout": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrParamInvalidOrQueryInvalidError", }, "403": { "$ref": "#/components/schemas/ApiKeyForbiddenOrApiKeyIpForbiddenOrBillingSourceDisabledOrForbiddenError", }, "404": { "$ref": "#/components/schemas/OrganizationNotFoundError", }, "501": { "$ref": "#/components/schemas/NotImplementedError", }, }, "operationId": "createStripeCheckout", "response": { "$ref": "#/components/schemas/BillingSession", }, }, "components": [ "Billing", "BillingPaymentMethod", "BillingPaymentMethodList", "BillingSession", "BillingSpendLimit", "BillingTransactionFeeLimit", "UpdateBillingRequest", ], "get": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrParamInvalidOrQueryInvalidError", }, "404": { "$ref": "#/components/schemas/OrganizationNotFoundError", }, }, "operationId": "getBilling", "response": { "$ref": "#/components/schemas/Billing", }, }, "manage": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrParamInvalidOrQueryInvalidError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/BillingNotFoundOrOrganizationNotFoundError", }, "501": { "$ref": "#/components/schemas/NotImplementedError", }, }, "operationId": "createStripeManage", "response": { "$ref": "#/components/schemas/BillingSession", }, }, "methods": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrParamInvalidOrQueryInvalidError", }, "404": { "$ref": "#/components/schemas/OrganizationNotFoundError", }, "501": { "$ref": "#/components/schemas/NotImplementedError", }, }, "operationId": "getBillingPaymentMethods", "response": { "$ref": "#/components/schemas/BillingPaymentMethodList", }, }, "remove": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrParamInvalidOrQueryInvalidError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/BillingNotFoundOrOrganizationNotFoundOrPaymentMethodNotFoundError", }, "501": { "$ref": "#/components/schemas/NotImplementedError", }, }, "operationId": "deleteBillingPaymentMethod", "response": { "$ref": "#/components/schemas/Billing", }, }, "timestamps": { "billing": "date-time", "paymentMethod": "date-time", }, "update": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrBodyInvalidOrParamInvalidOrQueryInvalidError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/OrganizationNotFoundError", }, }, "operationId": "updateBilling", "request": { "$ref": "#/components/schemas/UpdateBillingRequest", }, "response": { "$ref": "#/components/schemas/Billing", }, }, } `) }) /** RequestInit presenting the given token. */ function as(token: string) { return { headers: { 'tempo-api-key': token } } as const } /** Signs in a fresh scripted account; returns its session cookie. */ async function session(app: TestApp.signIn.App) { const account = privateKeyToAccount(generatePrivateKey()) const { cookie } = await TestApp.signIn(app, account) return cookie! } /** * Signs in and creates an org owned by that session; returns the cookie and * org id. Org names are unique so sandbox customers are tellable apart in the * Stripe dashboard. */ async function seeded(app: ReturnType) { const cookie = await session(app) const response = await app.request('/v1/orgs', { body: JSON.stringify({ name: `Test Org ${nanoid(10)}` }), headers: { 'content-type': 'application/json', cookie }, method: 'POST', }) const { id } = (await response.json()) as { id: string } return { cookie, orgId: id } } /** Seeds an owned organization with Stripe setup enabled. */ async function seededWithStripe( app: ReturnType, db: ReturnType, ) { const result = await seeded(app) await EnabledBillingSources.add(db, { createdBy: 'test', orgId: result.orgId, source: 'stripe', }) return result } describe('GET /orgs/:orgId/billing', () => { test('behavior: synthesizes none for orgs without a billing source', async () => { const app = createApp() const { cookie, orgId } = await seeded(app) const response = await app.request(`/v1/orgs/${orgId}/billing`, { headers: { cookie } }) expect(response.status).toBe(200) expect(await response.json()).toMatchInlineSnapshot(` { "enabledSources": [], "spend": { "amount": "0", "currency": "usd", "period": "month", }, "status": "none", } `) }) test('behavior: reflects the stored status', async () => { const db = TestApp.database() const app = createApp({ db }) const { cookie, orgId } = await seeded(app) await StripeCustomers.create(db, { orgId, stripeCustomerId: 'cus_a' }) await StripeCustomers.setStatus(db, orgId, 'active') const response = await app.request(`/v1/orgs/${orgId}/billing`, { headers: { cookie } }) expect(response.status).toBe(200) const body = (await response.json()) as { status: string; updatedAt: string } expect(body.status).toMatchInlineSnapshot(`"active"`) expect(body.updatedAt).toBeDefined() }) test('behavior: lists organization-wide sources in canonical order', async () => { const db = TestApp.database() const app = createApp({ billing: billingWithSandbox(), db }) const { cookie, orgId } = await seeded(app) await EnabledBillingSources.add(db, { createdBy: 'test', orgId, source: 'tempo' }) await EnabledBillingSources.add(db, { createdBy: 'test', orgId, source: 'stripe' }) const production = await app.request(`/v1/orgs/${orgId}/billing`, { headers: { cookie } }) const sandbox = await app.request(`/v1/orgs/${orgId}/billing?environment=sandbox`, { headers: { cookie }, }) expect(await production.json()).toMatchObject({ enabledSources: ['stripe', 'tempo'], status: 'none', }) expect(await sandbox.json()).toMatchObject({ enabledSources: ['stripe', 'tempo'], status: 'none', }) }) test('behavior: the super admin reads any org', async () => { const db = TestApp.database() const app = createApp({ db }) const { orgId } = await seeded(app) const response = await app.request(`/v1/orgs/${orgId}/billing`, as(secret)) expect(response.status).toBe(200) expect(((await response.json()) as { status: string }).status).toBe('none') }) test('behavior: foreign sessions read 404', async () => { const app = createApp() const { orgId } = await seeded(app) const other = await session(app) const response = await app.request(`/v1/orgs/${orgId}/billing`, { headers: { cookie: other }, }) expect(response.status).toBe(404) }) test('behavior: 401 without a session', async () => { const app = createApp() const { orgId } = await seeded(app) const response = await app.request(`/v1/orgs/${orgId}/billing`) expect(response.status).toBe(401) }) test('behavior: reads database-backed state when Stripe is unconfigured', async () => { const app = TestApp.create({ auth: { superAdmin: { secret } }, db: TestApp.database(), session: { wallet: { origin } }, }) const { cookie, orgId } = await seeded(app) const response = await app.request(`/v1/orgs/${orgId}/billing`, { headers: { cookie } }) expect(response.status).toBe(200) expect(await response.json()).toMatchInlineSnapshot(` { "enabledSources": [], "spend": { "amount": "0", "currency": "usd", "period": "month", }, "status": "none", } `) }) }) /** RequestInit for a JSON PATCH carrying the session cookie. */ function patch(cookie: string, body: unknown) { return { body: JSON.stringify(body), headers: { 'content-type': 'application/json', cookie }, method: 'PATCH', } as const } describe('PATCH /orgs/:orgId/billing', () => { test('behavior: sets both limits and reads them back', async () => { const app = createApp() const { cookie, orgId } = await seeded(app) const response = await app.request( `/v1/orgs/${orgId}/billing`, patch(cookie, { spendLimit: { amount: '250' }, txFeeLimit: { amount: '0.50', currency: 'usd' }, }), ) expect(response.status).toBe(200) expect(await response.json()).toMatchInlineSnapshot(` { "enabledSources": [], "spend": { "amount": "0", "currency": "usd", "period": "month", }, "spendLimit": { "amount": "250", "currency": "usd", "period": "month", }, "status": "none", "txFeeLimit": { "amount": "0.50", "currency": "usd", }, } `) const read = await app.request(`/v1/orgs/${orgId}/billing`, { headers: { cookie } }) expect(((await read.json()) as { spendLimit: object }).spendLimit).toBeDefined() }) test('behavior: absent fields stay unchanged; null clears', async () => { const app = createApp() const { cookie, orgId } = await seeded(app) await app.request(`/v1/orgs/${orgId}/billing`, patch(cookie, { spendLimit: { amount: '250' }, txFeeLimit: { amount: '1' } })) // prettier-ignore const cleared = await app.request(`/v1/orgs/${orgId}/billing`, patch(cookie, { spendLimit: null })) // prettier-ignore expect(cleared.status).toBe(200) const body = (await cleared.json()) as { spendLimit?: object; txFeeLimit?: object } expect(body.spendLimit).toBeUndefined() expect(body.txFeeLimit).toStrictEqual({ amount: '1', currency: 'usd' }) }) test('behavior: reports current-window spend from the sponsorship ledger', async () => { const db = TestApp.database() const app = createApp({ db }) const { cookie, orgId } = await seeded(app) // Finalized fee plus a pending signed cap; sandbox rows stay invisible. const finalized = await SponsoredTransactions.upsert(db, { ...sponsorship(orgId), signPayload: `0x${'21'.repeat(32)}` }) // prettier-ignore await SponsoredTransactions.finalize(db, finalized.id, { feeAmount: '100000', finalizedAt: new Date().toISOString() }) // prettier-ignore await SponsoredTransactions.upsert(db, { ...sponsorship(orgId), feeMax: '250000', signPayload: `0x${'22'.repeat(32)}` }) // prettier-ignore await SponsoredTransactions.upsert(db, { ...sponsorship(orgId), billable: false, environment: 'sandbox', feeMax: '900000', signPayload: `0x${'23'.repeat(32)}` }) // prettier-ignore const response = await app.request(`/v1/orgs/${orgId}/billing`, { headers: { cookie } }) const body = (await response.json()) as { spend: { amount: string } } expect(body.spend).toStrictEqual({ amount: '0.35', currency: 'usd', period: 'month' }) }) test('behavior: requires the owner role', async () => { const db = TestApp.database() const app = createApp({ db }) const { orgId } = await seeded(app) const member = await session(app) const me = await app.request('/v1/me', { headers: { cookie: member } }) const { id: userId } = (await me.json()) as { id: string } await Memberships.create(db, { orgId, role: 'member', userId }) const response = await app.request( `/v1/orgs/${orgId}/billing`, patch(member, { spendLimit: null }), ) expect(response.status).toBe(403) }) test('behavior: rejects malformed limits', async () => { const app = createApp() const { cookie, orgId } = await seeded(app) for (const body of [ { spendLimit: { amount: '12.345' } }, // too many fraction digits { spendLimit: { amount: '1000000000' } }, // over 9 integer digits { spendLimit: { amount: 'abc' } }, { spendLimit: { amount: '10', currency: 'eur' } }, { spendLimit: { amount: '10', period: 'week' } }, { spendLimit: { amount: '0' } }, // zero blocks everything; null clears instead { txFeeLimit: { amount: '-1' } }, { txFeeLimit: { amount: '0.00' } }, ]) { const response = await app.request(`/v1/orgs/${orgId}/billing`, patch(cookie, body)) expect(response.status).toBe(400) expect(((await response.json()) as { error: { code: string } }).error.code).toBe('body_invalid') // prettier-ignore } }) test('behavior: foreign sessions read 404', async () => { const app = createApp() const { orgId } = await seeded(app) const other = await session(app) const response = await app.request( `/v1/orgs/${orgId}/billing`, patch(other, { spendLimit: null }), ) expect(response.status).toBe(404) }) test('behavior: updates database-backed state when Stripe is unconfigured', async () => { const app = TestApp.create({ auth: { superAdmin: { secret } }, db: TestApp.database(), session: { wallet: { origin } }, }) const { cookie, orgId } = await seeded(app) const response = await app.request( `/v1/orgs/${orgId}/billing`, patch(cookie, { spendLimit: { amount: '250' } }), ) expect(response.status).toBe(200) expect( ((await response.json()) as { spendLimit?: { amount: string } }).spendLimit?.amount, ).toBe('250') }) }) /** Baseline mainnet production sponsorship row attributed to the org. */ function sponsorship(orgId: string) { return { apiKeyId: 'key_1', billable: true, chainId: Viem.chainId.mainnet, environment: 'production', orgId, projectId: 'prj_1', transaction: `0x76${'cc'.repeat(16)}`, transactionHash: `0x${'aa'.repeat(32)}`, } as const } /** RequestInit for a bodyless POST carrying the session cookie. */ function post(cookie: string) { return { headers: { cookie }, method: 'POST' } as const } describe('POST /orgs/:orgId/billing/stripe/checkout', () => { test('behavior: requires the owner role', async () => { const db = TestApp.database() const app = createApp({ db }) const { orgId } = await seeded(app) // Seed a non-owner membership directly; roles gate writes, not reads. const member = await session(app) const me = await app.request('/v1/me', { headers: { cookie: member } }) const { id: userId } = (await me.json()) as { id: string } await Memberships.create(db, { orgId, role: 'member', userId }) const read = await app.request(`/v1/orgs/${orgId}/billing`, { headers: { cookie: member } }) expect(read.status).toBe(200) const write = await app.request(`/v1/orgs/${orgId}/billing/stripe/checkout`, post(member)) expect(write.status).toBe(403) }) test('behavior: foreign sessions read 404', async () => { const app = createApp() const { orgId } = await seeded(app) const other = await session(app) expect( (await app.request(`/v1/orgs/${orgId}/billing/stripe/checkout`, post(other))).status, ).toBe(404) }) test('behavior: an unreachable Stripe maps to the upstream error envelope', async () => { const db = TestApp.database() const app = createApp({ db }) const { cookie, orgId } = await seededWithStripe(app, db) // First Stripe call the route makes is the customer create; the connection // is refused for real (no transport fake). const response = await app.request(`/v1/orgs/${orgId}/billing/stripe/checkout`, post(cookie)) expect(response.status).toBe(502) const body = (await response.json()) as { error: { code: string } } expect(body.error.code).toMatchInlineSnapshot(`"upstream_error"`) }) }) describe('billing source access', () => { test('behavior: checkout requires Stripe to be enabled', async () => { const app = TestApp.create({ auth: { superAdmin: { secret } }, db: TestApp.database(), session: { wallet: { origin } }, }) const { cookie, orgId } = await seeded(app) const response = await app.request(`/v1/orgs/${orgId}/billing/stripe/checkout`, post(cookie)) expect(response.status).toBe(403) const { requestId, ...body } = (await response.json()) as { requestId?: string } expect(requestId).toBeDefined() expect(body).toMatchInlineSnapshot(` { "error": { "code": "billing_source_disabled", "message": "Stripe billing is not enabled for this organization", }, } `) }) test('behavior: the source gate replaces temporary billing early access', async () => { const db = TestApp.database() const app = createApp({ db }) const { cookie, orgId } = await seededWithStripe(app, db) // The unreachable Stripe upstream proves source access, not user early access, is the gate. const response = await app.request(`/v1/orgs/${orgId}/billing/stripe/checkout`, post(cookie)) expect(response.status).toBe(502) expect(((await response.json()) as { error: { code: string } }).error.code).toBe('upstream_error') // prettier-ignore }) test('behavior: an enabled management key reaches Stripe', async () => { const db = TestApp.database() const store = TestApp.kvStore({ keys: [] }) const app = createApp({ db, kv: { store } }) const { orgId } = await seededWithStripe(app, db) const { token } = await ApiKeys.mint(store, { orgId, scopes: ['management:write'] }) const response = await app.request(`/v1/orgs/${orgId}/billing/stripe/checkout`, { headers: { 'tempo-api-key': token }, method: 'POST', }) expect(response.status).toBe(502) }) test('behavior: an enabled super admin reaches Stripe', async () => { const db = TestApp.database() const app = createApp({ db }) const { orgId } = await seededWithStripe(app, db) const response = await app.request(`/v1/orgs/${orgId}/billing/stripe/checkout`, { ...as(secret), method: 'POST', }) expect(response.status).toBe(502) }) }) describe('POST /orgs/:orgId/billing/stripe/manage', () => { test('behavior: 404 before any billing exists', async () => { const app = createApp() const { cookie, orgId } = await seeded(app) const response = await app.request(`/v1/orgs/${orgId}/billing/stripe/manage`, post(cookie)) expect(response.status).toBe(404) const body = (await response.json()) as { error: { code: string } } expect(body.error.code).toMatchInlineSnapshot(`"billing_not_found"`) }) }) describe('GET /orgs/:orgId/billing/payment-methods', () => { test('behavior: empty before any billing exists; members can read', async () => { const db = TestApp.database() const app = createApp({ db }) const { cookie, orgId } = await seeded(app) const response = await app.request(`/v1/orgs/${orgId}/billing/payment-methods`, { headers: { cookie }, }) expect(response.status).toBe(200) expect(await response.json()).toMatchInlineSnapshot(` { "data": [], } `) // Reads follow GET /billing: any member, not just owners. const member = await session(app) const me = await app.request('/v1/me', { headers: { cookie: member } }) const { id: userId } = (await me.json()) as { id: string } await Memberships.create(db, { orgId, role: 'member', userId }) const read = await app.request(`/v1/orgs/${orgId}/billing/payment-methods`, { headers: { cookie: member }, }) expect(read.status).toBe(200) }) test('behavior: foreign sessions read 404', async () => { const app = createApp() const { orgId } = await seeded(app) const other = await session(app) const response = await app.request(`/v1/orgs/${orgId}/billing/payment-methods`, { headers: { cookie: other }, }) expect(response.status).toBe(404) }) }) describe('DELETE /orgs/:orgId/billing/payment-methods/:methodId', () => { test('behavior: 404 before any billing exists', async () => { const app = createApp() const { cookie, orgId } = await seeded(app) const response = await app.request( `/v1/orgs/${orgId}/billing/payment-methods/pm_${'0'.repeat(24)}`, { headers: { cookie }, method: 'DELETE' }, ) expect(response.status).toBe(404) const body = (await response.json()) as { error: { code: string } } expect(body.error.code).toMatchInlineSnapshot(`"billing_not_found"`) }) test('behavior: requires the owner role', async () => { const db = TestApp.database() const app = createApp({ db }) const { orgId } = await seeded(app) const member = await session(app) const me = await app.request('/v1/me', { headers: { cookie: member } }) const { id: userId } = (await me.json()) as { id: string } await Memberships.create(db, { orgId, role: 'member', userId }) const response = await app.request( `/v1/orgs/${orgId}/billing/payment-methods/pm_${'0'.repeat(24)}`, { headers: { cookie: member }, method: 'DELETE' }, ) expect(response.status).toBe(403) }) }) describe('DELETE /orgs/:orgId', () => { test('behavior: deleting the organization removes its billing source', async () => { const db = TestApp.database() const app = createApp({ db }) const { cookie, orgId } = await seeded(app) await EnabledBillingSources.add(db, { createdBy: 'test', orgId, source: 'stripe' }) await StripeCustomers.create(db, { orgId, stripeCustomerId: 'cus_cascade' }) const response = await app.request(`/v1/orgs/${orgId}`, { headers: { cookie }, method: 'DELETE', }) expect(response.status).toBe(200) expect(await EnabledBillingSources.listByOrg(db, orgId)).toEqual([]) expect(await StripeCustomers.get(db, orgId)).toBeUndefined() }) }) describe('environment scoping', () => { test('behavior: sandbox billing state remains readable without a Stripe source', async () => { const app = createApp() const { cookie, orgId } = await seeded(app) const response = await app.request(`/v1/orgs/${orgId}/billing?environment=sandbox`, { headers: { cookie }, }) expect(response.status).toBe(200) expect(await response.json()).toMatchInlineSnapshot(` { "enabledSources": [], "spend": { "amount": "0", "currency": "usd", "period": "month", }, "status": "none", } `) }) test('behavior: rejects an unknown environment', async () => { const app = createApp({ billing: billingWithSandbox() }) const { cookie, orgId } = await seeded(app) const response = await app.request(`/v1/orgs/${orgId}/billing?environment=staging`, { headers: { cookie }, }) expect(response.status).toBe(400) expect(((await response.json()) as { error: { code: string } }).error.code).toBe( 'query_invalid', ) }) test('behavior: status reads are isolated per environment', async () => { const db = TestApp.database() const app = createApp({ billing: billingWithSandbox(), db }) const { cookie, orgId } = await seeded(app) // One org, two Stripe customers — production active, sandbox untouched. await StripeCustomers.create(db, { orgId, stripeCustomerId: 'cus_prod' }) await StripeCustomers.setStatus(db, orgId, 'active') await StripeCustomers.create(db, { environment: 'sandbox', orgId, stripeCustomerId: 'cus_sbx' }) const prod = await app.request(`/v1/orgs/${orgId}/billing`, { headers: { cookie } }) const sbx = await app.request(`/v1/orgs/${orgId}/billing?environment=sandbox`, { headers: { cookie } }) // prettier-ignore expect(((await prod.json()) as { status: string }).status).toBe('active') expect(((await sbx.json()) as { status: string }).status).toBe('none') }) test('behavior: PATCH writes only the targeted environment', async () => { const db = TestApp.database() const app = createApp({ billing: billingWithSandbox(), db }) const { cookie, orgId } = await seeded(app) await app.request(`/v1/orgs/${orgId}/billing?environment=sandbox`, patch(cookie, { spendLimit: { amount: '99' } })) // prettier-ignore const prod = await app.request(`/v1/orgs/${orgId}/billing`, { headers: { cookie } }) const sbx = await app.request(`/v1/orgs/${orgId}/billing?environment=sandbox`, { headers: { cookie } }) // prettier-ignore expect(((await prod.json()) as { spendLimit?: object }).spendLimit).toBeUndefined() expect(((await sbx.json()) as { spendLimit?: object }).spendLimit).toStrictEqual({ amount: '99', currency: 'usd', period: 'month', }) }) test('behavior: spend is scoped per environment', async () => { const db = TestApp.database() const app = createApp({ billing: billingWithSandbox(), db }) const { cookie, orgId } = await seeded(app) // Production: a finalized billable mainnet row (0.10). Sandbox: a pending // non-billable testnet row (0.25) — counted despite billable=false. const finalized = await SponsoredTransactions.upsert(db, { ...sponsorship(orgId), signPayload: `0x${'31'.repeat(32)}` }) // prettier-ignore await SponsoredTransactions.finalize(db, finalized.id, { feeAmount: '100000', finalizedAt: new Date().toISOString() }) // prettier-ignore await SponsoredTransactions.upsert(db, { ...sponsorship(orgId), billable: false, chainId: Viem.chainId.testnet, environment: 'sandbox', feeMax: '250000', signPayload: `0x${'32'.repeat(32)}` }) // prettier-ignore const prod = await app.request(`/v1/orgs/${orgId}/billing`, { headers: { cookie } }) const sbx = await app.request(`/v1/orgs/${orgId}/billing?environment=sandbox`, { headers: { cookie } }) // prettier-ignore expect(((await prod.json()) as { spend: { amount: string } }).spend.amount).toBe('0.1') expect(((await sbx.json()) as { spend: { amount: string } }).spend.amount).toBe('0.25') }) test('behavior: includes configured Zone charges in each environment', async () => { const db = TestApp.database() const zones = [ TestApp.zone({ chainId: 8001, rpcUrl: 'https://zone.example', sourceChainId: Viem.chainId.mainnet, }), TestApp.zone({ chainId: 8002, rpcUrl: 'https://zone.example', sourceChainId: Viem.chainId.mainnet, }), TestApp.zone({ chainId: 8003, rpcUrl: 'https://zone.example' }), ] const app = createApp({ billing: billingWithSandbox(), db, zones }) const { cookie, orgId } = await seeded(app) for (const [index, row] of [ { chainId: Viem.chainId.mainnet, feeAmount: '100000' }, { chainId: 8001, feeAmount: '200000' }, { chainId: 8002, feeMax: '250000' }, { chainId: 8003, feeMax: '900000' }, { billable: false, chainId: 8001, feeMax: '900000' }, { chainId: 8004, feeMax: '900000' }, { billable: false, chainId: Viem.chainId.testnet, environment: 'sandbox' as const, feeMax: '150000', }, { billable: false, chainId: 8003, environment: 'sandbox' as const, feeAmount: '350000' }, ].entries()) { const { feeAmount, ...input } = row const record = await SponsoredTransactions.upsert(db, { ...sponsorship(orgId), ...input, signPayload: `0x${String(index + 10).repeat(32)}`, }) if (feeAmount) await SponsoredTransactions.finalize(db, record.id, { feeAmount, finalizedAt: new Date().toISOString(), }) } const production = await app.request(`/v1/orgs/${orgId}/billing`, { headers: { cookie } }) const sandbox = await app.request(`/v1/orgs/${orgId}/billing?environment=sandbox`, { headers: { cookie }, }) expect(production.status).toBe(200) expect(sandbox.status).toBe(200) expect((await production.json()).spend).toMatchInlineSnapshot(` { "amount": "0.55", "currency": "usd", "period": "month", } `) expect((await sandbox.json()).spend).toMatchInlineSnapshot(` { "amount": "0.5", "currency": "usd", "period": "month", } `) }) }) describe('POST /stripe/webhook', () => { test.each(['invoice.finalized', 'invoice.overdue', 'invoice.updated'] as const)( 'behavior: %s reports refresh failures without clearing delinquency', async (type) => { const db = TestApp.database() const app = createApp({ db }) await StripeCustomers.create(db, { orgId: 'org_1', stripeCustomerId: 'cus_invoice' }) await StripeCustomers.setStatus(db, 'org_1', 'past_due') const payload = JSON.stringify({ data: { object: { customer: 'cus_invoice', id: 'in_invoice', object: 'invoice' } }, id: 'evt_invoice', object: 'event', type, }) const response = await deliver(app, payload, sign(payload)) expect(response.status).toBe(502) expect((await StripeCustomers.get(db, 'org_1'))?.status).toBe('past_due') }, ) /** Signs with the app's configured secret unless a test overrides it. */ function sign(payload: string, secret = webhookSecret) { return TestStripe.sign({ payload, secret }) } /** A minimal but shape-valid event addressing the given customer. */ function event(customer: string, type = 'customer.updated') { return JSON.stringify({ data: { object: { id: customer, object: 'customer' } }, id: 'evt_test_webhook', object: 'event', type, }) } function deliver(app: ReturnType, payload: string, signature?: string) { return app.request('/stripe/webhook', { body: payload, headers: signature ? { 'stripe-signature': signature } : {}, method: 'POST', }) } test('behavior: acks events for unknown customers without touching Stripe', async () => { const app = createApp() const payload = event('cus_unknown') const response = await deliver(app, payload, sign(payload)) expect(response.status).toBe(200) expect(await response.json()).toMatchInlineSnapshot(` { "received": true, } `) }) test('behavior: acks unhandled event types untouched', async () => { const app = createApp() const payload = event('cus_unknown', 'charge.succeeded') expect((await deliver(app, payload, sign(payload))).status).toBe(200) }) test('behavior: verifies events signed with the sandbox secret', async () => { const app = createApp({ billing: billingWithSandbox() }) const payload = event('cus_unknown') // Accepted (not signature_invalid) and acked as an unknown customer. const response = await deliver(app, payload, sign(payload, sandboxWebhookSecret)) expect(response.status).toBe(200) expect(((await response.json()) as { received: boolean }).received).toBe(true) }) test('behavior: rejects sandbox-signed events when sandbox is unconfigured', async () => { const app = createApp() const payload = event('cus_a') expect((await deliver(app, payload, sign(payload, sandboxWebhookSecret))).status).toBe(400) }) test('behavior: rejects a tampered payload', async () => { const app = createApp() const payload = event('cus_a') const response = await deliver(app, payload.replace('cus_a', 'cus_b'), sign(payload)) expect(response.status).toBe(400) const body = (await response.json()) as { error: { code: string } } expect(body.error.code).toMatchInlineSnapshot(`"signature_invalid"`) }) test('behavior: rejects a wrong secret and a missing header', async () => { const app = createApp() const payload = event('cus_a') expect((await deliver(app, payload, sign(payload, `whsec_${'c'.repeat(32)}`))).status).toBe(400) // prettier-ignore expect((await deliver(app, payload)).status).toBe(400) }) test('behavior: rejects a stale timestamp', async () => { const app = createApp() const payload = event('cus_a') const signature = TestStripe.sign({ payload, secret: webhookSecret, // Beyond the default 300s tolerance. timestamp: Math.floor(Date.now() / 1000) - 600, }) expect((await deliver(app, payload, signature)).status).toBe(400) }) test('behavior: 501 when billing is unconfigured', async () => { const app = TestApp.create({ auth: { superAdmin: { secret } }, db: TestApp.database(), session: { wallet: { origin } }, }) const payload = event('cus_a') expect((await deliver(app, payload, sign(payload))).status).toBe(501) }) }) /** Billing capability against the real sandbox. */ function sandboxBilling(): App.Billing { return { stripe: { client: TestStripe.client(), webhookSecret } } } /** * Real-Stripe capability with both sources configured (both hit the same * test-mode account, but each environment provisions its own customer row). */ function sandboxBillingBoth(): App.Billing { return { stripe: { client: TestStripe.client(), sandbox: { client: TestStripe.client(), webhookSecret: sandboxWebhookSecret }, webhookSecret, }, } } // Real-protocol suites against a Stripe sandbox (never faked, mirroring the // MPP-testnet convention). They run when `STRIPE_SECRET_KEY_DEV` is set; // CI always sets it. describe.runIf(TestStripe.secretKey)('stripe sandbox (direct)', () => { test.each(['webhook', 'delete'] as const)( 'a delayed active refresh cannot overwrite card removal through %s', async (path) => { const db = TestApp.databaseFactory()() const barrier = await subscriptionBarrier() onTestFinished(async () => { barrier.release() await barrier.close() }) const app = createApp({ billing: { stripe: { client: barrier.client, webhookSecret } }, db }) const { cookie, orgId } = await seeded(app) const stripe = TestStripe.client() const customer = await stripe.customers.create({ metadata: { orgId } }) TestStripe.track(customer.id) await StripeCustomers.create(db, { orgId, stripeCustomerId: customer.id }) const attached = await TestStripe.attachCard(customer.id) const payload = JSON.stringify({ data: { object: { id: customer.id, object: 'customer' } }, id: 'evt_concurrent_refresh', object: 'event', type: 'customer.updated', }) const active = Promise.resolve( app.request('/stripe/webhook', { body: payload, headers: { 'stripe-signature': TestStripe.sign({ payload, secret: webhookSecret }) }, method: 'POST', }), ) let removal: Promise | undefined try { // Pause a real Stripe response after status derivation, while the // active webhook is still provisioning its subscription. await Promise.race([ barrier.paused, active.then(() => { throw new Error('refresh finished before subscription provisioning') }), ]) expect(await core_Billing.status(db, orgId)).toBe('none') if (path === 'webhook') await stripe.paymentMethods.detach(attached.paymentMethod) const detached = JSON.stringify({ data: { object: { customer: null, id: attached.paymentMethod, object: 'payment_method' }, previous_attributes: { customer: customer.id }, }, id: 'evt_concurrent_detach', object: 'event', type: 'payment_method.detached', }) removal = path === 'webhook' ? Promise.resolve( app.request('/stripe/webhook', { body: detached, headers: { 'stripe-signature': TestStripe.sign({ payload: detached, secret: webhookSecret, }), }, method: 'POST', }), ) : Promise.resolve( app.request(`/v1/orgs/${orgId}/billing/payment-methods/${attached.paymentMethod}`, { headers: { cookie }, method: 'DELETE', }), ) let completed = false void removal.then(() => { completed = true }) // With serialization the removal waits for the row lock. Without it, // removal completes first and the delayed refresh restores stale active. await expect .poll( async () => { const { rows } = await sql<{ blocked: boolean }>` SELECT EXISTS ( SELECT 1 FROM pg_locks JOIN pg_stat_activity USING (pid) WHERE relation = 'stripe_customers'::regclass AND wait_event_type = 'Lock' ) AS blocked `.execute(db.kysely) return completed || rows[0]!.blocked }, { timeout: 20_000 }, ) .toBe(true) } finally { barrier.release() await Promise.allSettled([active, removal]) } expect((await active).status).toBe(200) expect((await removal)?.status).toBe(200) expect(await core_Billing.status(db, orgId)).toBe('none') expect( (await stripe.customers.listPaymentMethods(customer.id, { type: 'card' })).data, ).toHaveLength(0) const subscriptions = await stripe.subscriptions.list({ customer: customer.id }) expect(subscriptions.data).toHaveLength(1) expect(subscriptions.data[0]!.items.data).toHaveLength(3) }, ) test('checkout provisions distinct customers per environment', { timeout: 30_000 }, async () => { const db = TestApp.database() const app = createApp({ billing: sandboxBillingBoth(), db }) const { cookie, orgId } = await seededWithStripe(app, db) const prod = await app.request(`/v1/orgs/${orgId}/billing/stripe/checkout`, post(cookie)) const sbx = await app.request(`/v1/orgs/${orgId}/billing/stripe/checkout?environment=sandbox`, post(cookie)) // prettier-ignore // Track both before asserting so a failure never leaks a provisioned customer. const prodRecord = await StripeCustomers.get(db, orgId) const sbxRecord = await StripeCustomers.get(db, orgId, 'sandbox') if (prodRecord) TestStripe.track(prodRecord.stripeCustomerId) if (sbxRecord) TestStripe.track(sbxRecord.stripeCustomerId) expect(prod.status).toBe(200) expect(sbx.status).toBe(200) expect(prodRecord?.stripeCustomerId).toMatch(/^cus_/) expect(sbxRecord?.stripeCustomerId).toMatch(/^cus_/) // One org, two environments, two independent Stripe customers. expect(prodRecord!.stripeCustomerId).not.toBe(sbxRecord!.stripeCustomerId) // The sandbox session returns to the console in sandbox mode; the full-page // Stripe round-trip drops the client-retained `env`, so the URL carries it. const stripe = TestStripe.client() const sessions = await stripe.checkout.sessions.list({ customer: sbxRecord!.stripeCustomerId }) expect(sessions.data[0]?.success_url).toContain('env=sandbox') }) test('checkout provisions a real customer and setup session', { timeout: 30_000 }, async () => { const db = TestApp.database() const app = createApp({ billing: sandboxBilling(), db }) const { cookie, orgId } = await seededWithStripe(app, db) const response = await app.request(`/v1/orgs/${orgId}/billing/stripe/checkout`, post(cookie)) // Track for teardown before asserting: a failed assertion must not leak // the customer the route already provisioned. const record = await StripeCustomers.get(db, orgId) if (record) TestStripe.track(record.stripeCustomerId) expect(response.status).toBe(200) const { url } = (await response.json()) as { url: string } expect(url).toMatch(/^https:\/\/checkout\.stripe\.com\//) expect(record?.stripeCustomerId).toMatch(/^cus_/) // The customer is real and carries org attribution. const stripe = TestStripe.client() const customer = await stripe.customers.retrieve(record!.stripeCustomerId) expect(customer.deleted).not.toBe(true) expect((customer as { metadata: { orgId?: string } }).metadata.orgId).toBe(orgId) // A second checkout reuses the customer; a browser `Origin` overrides the // configured console URL so the caller returns to the host it came from. const callerOrigin = 'http://localhost:23145' const again = await app.request(`/v1/orgs/${orgId}/billing/stripe/checkout`, { headers: { cookie, origin: callerOrigin }, method: 'POST', }) expect(again.status).toBe(200) expect((await StripeCustomers.get(db, orgId))?.stripeCustomerId).toBe(record!.stripeCustomerId) const sessions = await stripe.checkout.sessions.list({ customer: record!.stripeCustomerId }) expect(sessions.data.length).toBeGreaterThanOrEqual(2) expect(sessions.data.every(({ mode }) => mode === 'setup')).toBe(true) const urls = sessions.data.map(({ cancel_url, success_url }) => ({ cancel_url, success_url })) expect(urls).toContainEqual({ cancel_url: `https://console.tempo.xyz/${orgId}/billing?billing=canceled`, success_url: `https://console.tempo.xyz/${orgId}/billing?billing=success`, }) expect(urls).toContainEqual({ cancel_url: `${callerOrigin}/${orgId}/billing?billing=canceled`, success_url: `${callerOrigin}/${orgId}/billing?billing=success`, }) }) test( 'payment methods list live state; detach by id parks status', { timeout: 30_000 }, async () => { // prettier-ignore const db = TestApp.database() const app = createApp({ billing: sandboxBilling(), db }) const { cookie, orgId } = await seededWithStripe(app, db) const checkout = await app.request(`/v1/orgs/${orgId}/billing/stripe/checkout`, post(cookie)) const customerId = (await StripeCustomers.get(db, orgId))?.stripeCustomerId if (customerId) TestStripe.track(customerId) expect(checkout.status).toBe(200) if (!customerId) throw new Error('checkout stored no customer') const attached = await TestStripe.attachCard(customerId) const stripe = TestStripe.client() await stripe.customers.update(customerId, { invoice_settings: { default_payment_method: attached.paymentMethod }, }) await StripeCustomers.setStatus(db, orgId, 'active') await EnabledBillingSources.remove(db, { orgId, source: 'stripe' }) // Disabling setup does not block management of the existing Stripe source. const list = await app.request(`/v1/orgs/${orgId}/billing/payment-methods`, { headers: { cookie }, }) expect(list.status).toBe(200) type Method = { card?: { brand: string; last4: string } default: boolean id: string provider: string type: string } const { data } = (await list.json()) as { data: Method[] } expect(data).toHaveLength(1) expect(data[0]!.id).toBe(attached.paymentMethod) expect(data[0]!.default).toBe(true) expect(data[0]!.provider).toBe('stripe') expect(data[0]!.type).toBe('card') expect(data[0]!.card?.last4).toMatch(/^\d{4}$/) // Ids outside the org's own list read as absent. const missing = await app.request( `/v1/orgs/${orgId}/billing/payment-methods/pm_${'0'.repeat(24)}`, { headers: { cookie }, method: 'DELETE' }, ) expect(missing.status).toBe(404) expect(((await missing.json()) as { error: { code: string } }).error.code).toBe( 'payment_method_not_found', ) const response = await app.request( `/v1/orgs/${orgId}/billing/payment-methods/${data[0]!.id}`, { headers: { cookie }, method: 'DELETE' }, ) expect(response.status).toBe(200) const body = (await response.json()) as { status: string } expect(body.status).toBe('none') expect((await StripeCustomers.get(db, orgId))?.status).toBe('none') // Nothing survives on the customer; the default cleared with the detach. const methods = await stripe.customers.listPaymentMethods(customerId, { limit: 100 }) expect(methods.data).toHaveLength(0) const customer = await stripe.customers.retrieve(customerId) expect(customer.deleted).not.toBe(true) if (!customer.deleted) expect(customer.invoice_settings.default_payment_method).toBeNull() }, ) test( 'checkout.session.completed promotes the payment method to default', { timeout: 30_000 }, async () => { // prettier-ignore const db = TestApp.database() const app = createApp({ billing: sandboxBilling(), db }) const { cookie, orgId } = await seededWithStripe(app, db) const checkout = await app.request(`/v1/orgs/${orgId}/billing/stripe/checkout`, post(cookie)) const customerId = (await StripeCustomers.get(db, orgId))?.stripeCustomerId if (customerId) TestStripe.track(customerId) expect(checkout.status).toBe(200) if (!customerId) throw new Error('checkout stored no customer') const attached = await TestStripe.completeCheckout(app, { customer: customerId, webhookSecret, }) const stripe = TestStripe.client() const customer = await stripe.customers.retrieve(customerId) expect(customer.deleted).not.toBe(true) expect( (customer as { invoice_settings: { default_payment_method: unknown } }).invoice_settings .default_payment_method, ) // prettier-ignore .toBe(attached.paymentMethod) const billing = await app.request(`/v1/orgs/${orgId}/billing`, { headers: { cookie } }) expect(((await billing.json()) as { status: string }).status).toBe('active') }, ) test('manage links to a live portal session', { timeout: 30_000 }, async () => { await TestStripe.ensurePortalConfiguration() const db = TestApp.database() const app = createApp({ billing: sandboxBilling(), db }) const { cookie, orgId } = await seededWithStripe(app, db) const checkout = await app.request(`/v1/orgs/${orgId}/billing/stripe/checkout`, post(cookie)) const customerId = (await StripeCustomers.get(db, orgId))?.stripeCustomerId if (customerId) TestStripe.track(customerId) expect(checkout.status).toBe(200) await EnabledBillingSources.remove(db, { orgId, source: 'stripe' }) const response = await app.request(`/v1/orgs/${orgId}/billing/stripe/manage`, post(cookie)) expect(response.status).toBe(200) const { url } = (await response.json()) as { url: string } expect(url).toMatch(/^https:\/\/billing\.stripe\.com\//) }) test('paused unattempted automatic invoices prevent replacement subscriptions', async () => { const db = TestApp.database() const stripe = TestStripe.client() const app = createApp({ billing: sandboxBilling(), db }) const customer = await stripe.customers.create({ metadata: { orgId: 'org_1' } }) TestStripe.track(customer.id) await StripeCustomers.create(db, { orgId: 'org_1', stripeCustomerId: customer.id }) await TestStripe.completeCheckout(app, { customer: customer.id, webhookSecret }) const subscriptions = await stripe.subscriptions.list({ customer: customer.id }) expect(subscriptions.data).toHaveLength(1) const subscription = subscriptions.data[0]!.id const draft = await stripe.invoices.create({ auto_advance: true, customer: customer.id, subscription, }) await stripe.invoiceItems.create({ amount: 500, currency: 'usd', customer: customer.id, invoice: draft.id, }) const invoice = await stripe.invoices.finalizeInvoice(draft.id, { auto_advance: true }) expect(invoice).toMatchObject({ amount_remaining: 500, attempted: false, auto_advance: true, collection_method: 'charge_automatically', due_date: null, status: 'open', }) await deliverEvent( app, await eventFor(stripe, { customerId: customer.id, objectId: invoice.id, type: 'invoice.finalized', }), ) expect((await StripeCustomers.get(db, 'org_1'))?.status).toBe('active') await stripe.invoices.update(invoice.id, { auto_advance: false }) await deliverEvent( app, await eventFor(stripe, { customerId: customer.id, objectId: invoice.id, type: 'invoice.updated', }), ) expect((await StripeCustomers.get(db, 'org_1'))?.status).toBe('past_due') await stripe.invoices.update(invoice.id, { auto_advance: true }) await deliverEvent( app, await eventFor(stripe, { customerId: customer.id, objectId: invoice.id, type: 'invoice.updated', }), ) expect((await StripeCustomers.get(db, 'org_1'))?.status).toBe('active') await stripe.invoices.update(invoice.id, { auto_advance: false }) await stripe.subscriptions.cancel(subscription) await deliverEvent( app, await eventFor(stripe, { customerId: customer.id, objectId: subscription, type: 'customer.subscription.deleted', }), ) expect((await StripeCustomers.get(db, 'org_1'))?.status).toBe('past_due') expect((await stripe.subscriptions.list({ customer: customer.id })).data).toHaveLength(0) await stripe.invoices.voidInvoice(invoice.id) await deliverEvent( app, await eventFor(stripe, { customerId: customer.id, objectId: invoice.id, type: 'invoice.voided', }), ) expect((await StripeCustomers.get(db, 'org_1'))?.status).toBe('active') }) test('invoice updates refresh overdue billing and voiding restores eligibility', async () => { const db = TestApp.database() const stripe = TestStripe.client() const app = createApp({ billing: sandboxBilling(), db }) const customer = await stripe.customers.create({ email: 'billing-1144@example.com', metadata: { orgId: 'org_1' }, }) TestStripe.track(customer.id) await StripeCustomers.create(db, { orgId: 'org_1', stripeCustomerId: customer.id }) await TestStripe.completeCheckout(app, { customer: customer.id, webhookSecret, }) const draft = await stripe.invoices.create({ auto_advance: false, collection_method: 'send_invoice', customer: customer.id, days_until_due: 30, }) await stripe.invoiceItems.create({ amount: 500, currency: 'usd', customer: customer.id, invoice: draft.id, }) const invoice = await stripe.invoices.finalizeInvoice(draft.id) expect(invoice.amount_remaining).toBe(500) expect(invoice.attempted).toBe(false) const event = await eventFor(stripe, { customerId: customer.id, objectId: invoice.id, type: 'invoice.updated', }) await deliverEvent(app, event) expect((await StripeCustomers.get(db, 'org_1'))?.status).toBe('active') vi.useFakeTimers({ toFake: ['Date'] }) try { vi.setSystemTime((invoice.due_date! + 1) * 1_000) await deliverEvent(app, event) expect((await StripeCustomers.get(db, 'org_1'))?.status).toBe('past_due') } finally { vi.useRealTimers() } await stripe.invoices.markUncollectible(invoice.id) await deliverEvent( app, await eventFor(stripe, { customerId: customer.id, objectId: invoice.id, type: 'invoice.marked_uncollectible', }), ) expect((await StripeCustomers.get(db, 'org_1'))?.status).toBe('past_due') await stripe.invoices.voidInvoice(invoice.id) await deliverEvent( app, await eventFor(stripe, { customerId: customer.id, objectId: invoice.id, type: 'invoice.voided', }), ) expect((await StripeCustomers.get(db, 'org_1'))?.status).toBe('active') }) }) describe.runIf(TestStripe.secretKey)('stripe sandbox (live delivery)', () => { let bridge: TestStripe.bridge.Bridge let app: ReturnType let db: ReturnType // Live webhook delivery: mutations fire real events, a `stripe listen` // container forwards them with Stripe's own signature, and the app // re-derives status from live state. beforeAll(async () => { bridge = await TestStripe.bridge() db = TestApp.database() app = createApp({ billing: { stripe: { client: TestStripe.client(), webhookSecret: bridge.secret } }, db }) // prettier-ignore bridge.connect((request) => app.fetch(request)) }, 180_000) afterAll(async () => { await bridge?.stop() }) test('attach → active, detach → none, delete → canceled', { timeout: 300_000 }, async () => { const { cookie, orgId } = await seededWithStripe(app, db) const checkout = await app.request(`/v1/orgs/${orgId}/billing/stripe/checkout`, post(cookie)) const customerId = (await StripeCustomers.get(db, orgId))?.stripeCustomerId if (customerId) TestStripe.track(customerId) expect(checkout.status).toBe(200) if (!customerId) throw new Error('checkout stored no customer') const status = async () => { const response = await app.request(`/v1/orgs/${orgId}/billing`, { headers: { cookie } }) const body = (await response.json()) as { status?: string } return body.status } // Attach a real card: `payment_method.attached` + `setup_intent.succeeded` // deliver through the bridge and flip the derived status. await TestStripe.attachCard(customerId) await until(async () => (await status()) === 'active') // Detach every card: delivery re-derives back to `none`. await TestStripe.detachCards(customerId) await until(async () => (await status()) === 'none') // Deleting the customer lands `canceled`. await TestStripe.client().customers.del(customerId) await until(async () => (await status()) === 'canceled') }) }) /** Polls until the condition holds; live delivery typically lands within seconds. */ async function until(condition: () => Promise, timeout = 120_000) { const deadline = Date.now() + timeout while (Date.now() < deadline) { if (await condition()) return await new Promise((resolve) => setTimeout(resolve, 1_000)) } throw new Error('condition never held within the timeout') } // Metering against the real sandbox: account fixtures, subscription // provisioning, the reporter's exactly-once pass, and the priced preview. describe.runIf(TestStripe.secretKey)('stripe sandbox (metering)', () => { test('ensureFixtures is idempotent across clients', { timeout: 30_000 }, async () => { const first = await core_Billing.ensureFixtures(TestStripe.client()) // A fresh client skips the memo and exercises the real lookup path. const second = await core_Billing.ensureFixtures(TestStripe.client()) expect(second).toStrictEqual(first) expect(first.meterId).toMatch(/^mtr_/) expect(first.priceId).toMatch(/^price_/) }) test( 'ensureSubscription provisions exactly one live subscription', { timeout: 60_000 }, async () => { // prettier-ignore const stripe = TestStripe.client() const customer = await stripe.customers.create({ metadata: { orgId: 'org_sub' }, name: `Test Org ${nanoid(10)}`, }) TestStripe.track(customer.id) await core_Billing.ensureSubscription(stripe, customer.id) await core_Billing.ensureSubscription(stripe, customer.id) const { priceId } = await core_Billing.ensureFixtures(stripe) const subscriptions = await stripe.subscriptions.list({ customer: customer.id, limit: 100, price: priceId, status: 'all', }) const live = subscriptions.data.filter((s) => !['canceled', 'incomplete_expired'].includes(s.status)) // prettier-ignore expect(live).toHaveLength(1) }, ) test('the reporter reports each finalized row exactly once', { timeout: 120_000 }, async () => { const db = TestApp.database() const stripe = TestStripe.client() const customer = await stripe.customers.create({ metadata: { orgId: 'org_1' }, name: `Test Org ${nanoid(10)}`, }) TestStripe.track(customer.id) await StripeCustomers.create(db, { orgId: 'org_1', stripeCustomerId: customer.id }) const finalize = async (input: { feeAmount: string; orgId: string; seed: string }) => { const row = await SponsoredTransactions.upsert(db, { ...meterable(input.orgId), signPayload: `0x${input.seed.repeat(32)}` }) // prettier-ignore await SponsoredTransactions.finalize(db, row.id, { feeAmount: input.feeAmount, finalizedAt: new Date().toISOString() }) // prettier-ignore } await finalize({ feeAmount: '100000', orgId: 'org_1', seed: '41' }) await finalize({ feeAmount: '250000', orgId: 'org_1', seed: '42' }) // A deleted org's row marks without a meter event; a pending row waits. await finalize({ feeAmount: '900000', orgId: 'org_ghost', seed: '43' }) await SponsoredTransactions.upsert(db, { ...meterable('org_1'), signPayload: `0x${'44'.repeat(32)}` }) // prettier-ignore const reporter = core_Billing.createReporter({ db, stripe }) expect(await reporter.tick()).toStrictEqual({ failed: 0, reported: 2, skipped: 1 }) expect(await reporter.tick()).toStrictEqual({ failed: 0, reported: 0, skipped: 0 }) }) test('the metered invoice preview matches reported spend', { timeout: 240_000 }, async () => { const db = TestApp.database() const stripe = TestStripe.client() const customer = await stripe.customers.create({ metadata: { orgId: 'org_1' }, name: `Test Org ${nanoid(10)}`, }) TestStripe.track(customer.id) await StripeCustomers.create(db, { orgId: 'org_1', stripeCustomerId: customer.id }) await core_Billing.ensureSubscription(stripe, customer.id) // $0.35 of finalized spend: 100k + 250k base units. for (const [seed, feeAmount] of [ ['51', '100000'], ['52', '250000'], ] as const) { const row = await SponsoredTransactions.upsert(db, { ...meterable('org_1'), signPayload: `0x${seed.repeat(32)}` }) // prettier-ignore await SponsoredTransactions.finalize(db, row.id, { feeAmount, finalizedAt: new Date().toISOString() }) // prettier-ignore } await core_Billing.createReporter({ db, stripe }).tick() const { priceId } = await core_Billing.ensureFixtures(stripe) const [subscription] = (await stripe.subscriptions.list({ customer: customer.id, price: priceId, limit: 1 })).data // prettier-ignore // Meter events process asynchronously; poll the preview until usage lands. const deadline = Date.now() + 180_000 let line: { amount: number; quantity: number | null } | undefined while (Date.now() < deadline) { const preview = await stripe.invoices.createPreview({ customer: customer.id, subscription: subscription!.id, }) line = preview.lines.data.find((l) => l.pricing?.price_details?.price === priceId) if (line?.quantity === 350_000) break await new Promise((resolve) => setTimeout(resolve, 5_000)) } expect(line?.quantity).toBe(350_000) // 350k units × 0.0001¢ = 35¢. expect(line?.amount).toBe(35) }) test.each(['past_due', 'canceled'] as const)( 'a failed metered invoice blocks a %s subscription until payment', { timeout: 600_000 }, async (status) => { // prettier-ignore const db = TestApp.database() const stripe = TestStripe.client() const app = createApp({ billing: sandboxBilling(), db }) // Frozen time lets the clock cross a real billing-period boundary. const clock = await stripe.testHelpers.testClocks.create({ frozen_time: Math.floor(Date.now() / 1000), }) try { const customer = await stripe.customers.create({ metadata: { orgId: 'org_1' }, name: `Test Org ${nanoid(10)}`, test_clock: clock.id, }) await StripeCustomers.create(db, { orgId: 'org_1', stripeCustomerId: customer.id }) // A card that saves fine but refuses charges: renewal will fail. const intent = await stripe.setupIntents.create({ confirm: true, customer: customer.id, payment_method: 'pm_card_chargeCustomerFail', payment_method_types: ['card'], }) await stripe.customers.update(customer.id, { invoice_settings: { default_payment_method: intent.payment_method as string }, }) await StripeCustomers.setStatus(db, 'org_1', 'active') await core_Billing.ensureSubscription( stripe, customer.id, core_Billing.kindsFor('production'), ) // $5 of finalized usage so the invoice is worth charging. const row = await SponsoredTransactions.upsert(db, { ...meterable('org_1'), signPayload: `0x${'61'.repeat(32)}` }) // prettier-ignore await SponsoredTransactions.finalize(db, row.id, { feeAmount: '5000000', finalizedAt: new Date().toISOString() }) // prettier-ignore await core_Billing.createReporter({ db, stripe }).tick() // Cross the period boundary: Stripe invoices the metered usage and the // charge fails on the saved card, emitting a real payment_failed event. await stripe.testHelpers.testClocks.advance(clock.id, { frozen_time: Math.floor(Date.now() / 1000) + 32 * 24 * 60 * 60, }) // Failure events can arrive before the clock permits subscription or invoice updates. await until(async () => (await stripe.testHelpers.testClocks.retrieve(clock.id)).status === 'ready', 240_000) // prettier-ignore const failed = await eventFor(stripe, { customerId: customer.id, type: 'invoice.payment_failed', }) await deliverEvent(app, failed) expect((await StripeCustomers.get(db, 'org_1'))?.status).toBe('past_due') const invoice = (failed.data.object as { id: string }).id if (status === 'canceled') { const subscriptions = await stripe.subscriptions.list({ customer: customer.id }) expect(subscriptions.data).toHaveLength(1) await stripe.subscriptions.cancel(subscriptions.data[0]!.id) const canceled = await eventFor(stripe, { customerId: customer.id, objectId: subscriptions.data[0]!.id, type: 'customer.subscription.deleted', }) await deliverEvent(app, canceled) expect((await StripeCustomers.get(db, 'org_1'))?.status).toBe('past_due') expect((await stripe.subscriptions.list({ customer: customer.id })).data).toHaveLength(0) // Test-clock cancellation does not reliably pause collection. Exercise that state explicitly. await stripe.invoices.update(invoice, { auto_advance: false }) await deliverEvent(app, canceled) expect((await StripeCustomers.get(db, 'org_1'))?.status).toBe('past_due') // Paying a different invoice must not erase the original debt. const unrelated = await stripe.invoices.create({ auto_advance: false, customer: customer.id, }) await stripe.invoices.finalizeInvoice(unrelated.id) await deliverEvent( app, await eventFor(stripe, { customerId: customer.id, objectId: unrelated.id, type: 'invoice.paid', }), ) const refreshed = await stripe.customers.retrieve(customer.id) expect(!refreshed.deleted && refreshed.delinquent).toBe(false) expect((await StripeCustomers.get(db, 'org_1'))?.status).toBe('past_due') await deliverEvent(app, canceled) expect((await StripeCustomers.get(db, 'org_1'))?.status).toBe('past_due') } // Pay the open invoice with a working card; `invoice.paid` re-derives. const paying = await TestStripe.attachCard(customer.id) await stripe.invoices.pay(invoice, { payment_method: paying.paymentMethod }) const paid = await eventFor(stripe, { customerId: customer.id, objectId: invoice, type: 'invoice.paid', }) await deliverEvent(app, paid) expect((await StripeCustomers.get(db, 'org_1'))?.status).toBe('active') } finally { // Deleting the clock deletes its customers and subscriptions. await stripe.testHelpers.testClocks.del(clock.id).catch(() => undefined) } }, ) }) /** Baseline mainnet production row the metering suites finalize per case. */ function meterable(orgId: string) { return { apiKeyId: 'key_1', billable: true, chainId: Viem.chainId.mainnet, environment: 'production', orgId, projectId: 'prj_1', transaction: `0x76${'cc'.repeat(16)}`, transactionHash: `0x${'ab'.repeat(32)}`, } as const } /** Polls the recorded event stream until one matches the customer. */ async function eventFor(stripe: Stripe, options: eventFor.Options) { const { customerId, objectId, type } = options const deadline = Date.now() + 240_000 while (Date.now() < deadline) { const events = await stripe.events.list({ limit: 100, type }) const match = events.data.find( (event) => (event.data.object as { customer?: string }).customer === customerId && (objectId === undefined || ('id' in event.data.object && event.data.object.id === objectId)), ) if (match) return match await new Promise((resolve) => setTimeout(resolve, 5_000)) } throw new Error(`no ${type} event recorded for ${customerId}`) } declare namespace eventFor { /** Identifies a recorded Stripe event for the test customer. */ type Options = { /** Customer whose event is delivered. */ customerId: string /** Resource whose event is required, excluding unrelated invoices. */ objectId?: string | undefined /** Stripe event type to await. */ type: Stripe.Event['type'] } } /** Delivers a recorded event to the app, signed with the suite's webhook secret. */ async function deliverEvent(app: ReturnType, event: Stripe.Event) { const payload = JSON.stringify(event) const response = await app.request('/stripe/webhook', { body: payload, headers: { 'stripe-signature': TestStripe.sign({ payload, secret: webhookSecret }) }, method: 'POST', }) if (response.status !== 200) throw new Error(`webhook delivery returned ${response.status}`) } /** Forwards real Stripe traffic unchanged, pausing the subscription lookup during activation. */ async function subscriptionBarrier() { const paused = Promise.withResolvers() const release = Promise.withResolvers() const server = http.createServer((request, response) => { const url = new URL(request.url!, 'https://api.stripe.com') const upstream = https.request( url, { headers: { ...request.headers, host: 'api.stripe.com' }, method: request.method, }, async (result) => { if (url.pathname === '/v1/subscriptions' && url.searchParams.get('status') === 'all') { paused.resolve() await release.promise } response.writeHead(result.statusCode!, result.headers) result.pipe(response) }, ) upstream.on('error', (error) => response.destroy(error)) request.pipe(upstream) }) await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) const address = server.address() if (!address || typeof address === 'string') throw new Error('missing Stripe proxy port') return { client: new Stripe(TestStripe.secretKey!, { host: '127.0.0.1', maxNetworkRetries: 0, port: address.port, protocol: 'http', timeout: 30_000, }), close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())), ), paused: paused.promise, release: release.resolve, } }