import Stripe from 'stripe' import { inject } from 'vite-plus/test' import * as ApiKeys from '../../../ApiKeys.js' import * as Billing from '../Billing.js' import * as Invoice from './billing.js' import type * as Log from '../../../internal/Log.js' import * as Memberships from '../../../db/tables/memberships.js' import * as Organizations from '../../../db/tables/organizations.js' import * as Store from '../../../internal/Store.js' import * as StripeCustomers from '../../../db/tables/stripeCustomers.js' import * as TestAccounts from '../../../../test/Accounts.js' import * as TestApp from '../../../../test/App.js' import * as TestStripe from '../../../../test/Stripe.js' import * as Users from '../../../db/tables/users.js' const orgId = 'org_invoice' const path = `/v1/orgs/${orgId}/billing/invoice-preview` const secret = `tempo:sk:${'b1'.repeat(24)}` const headers = { 'tempo-api-key': secret } afterAll(() => TestStripe.sweep()) describe('GET /v1/orgs/:orgId/billing/invoice-preview', () => { test('allows members to read estimates without granting portal access', async () => { const { app, db } = await setup() const account = TestAccounts.create(inject('tempoTestMnemonic'))[3] const { cookie } = await TestApp.signIn(app, account) if (!cookie) throw new Error('Expected a session cookie') const user = await Users.getByAddress(db, account.address) if (!user) throw new Error('Expected a signed-in user') await Memberships.create(db, { orgId, role: 'member', userId: user.id }) expect((await app.request(path, { headers: { cookie } })).status).toBe(200) expect( ( await app.request(`/v1/orgs/${orgId}/billing/stripe/manage`, { headers: { cookie }, method: 'POST', }) ).status, ).toBe(403) }) test('distinguishes missing accounts and missing environment configuration', async () => { const { app } = await setup() const absent = await app.request(path, { headers }) expect(absent.status).toBe(200) expect(await absent.json()).toMatchInlineSnapshot(` { "status": "no_account", } `) expect(absent.headers.get('cache-control')).toBe('no-store') const unconfigured = await app.request(`${path}?environment=sandbox`, { headers }) expect(unconfigured.status).toBe(501) expect((await unconfigured.json()).error.code).toBe('billing_unconfigured') }) test('requires organization access and rejects project credentials', async () => { const store = TestApp.kvStore() const { app } = await setup({ store }) expect((await app.request(path)).status).toBe(401) for (const [attribution, status] of [ [{ orgId }, 200], [{ orgId, projectId: 'prj_invoice' }, 403], [{ orgId: 'org_other' }, 404], ] as const) { const { token } = await ApiKeys.mint(store, { ...attribution, scopes: ['management:read'] }) const response = await app.request(path, { headers: { 'tempo-api-key': token } }) expect(response.status).toBe(status) } expect((await app.request(`${path}?environment=invalid`, { headers })).status).toBe(400) }) test('reports dependency failures without blocking billing settings or caching errors', async () => { const entries: Log.Entry[] = [] const causes: Error[] = [] const { app, db } = await setup({ logger: (entry, cause) => { entries.push(entry) if (cause) causes.push(cause) }, }) await StripeCustomers.create(db, { orgId, stripeCustomerId: 'cus_unreachable' }) const response = await app.request(path, { headers }) expect(response.status).toBe(502) expect(response.headers.get('cache-control')).toBe('no-store') expect((await response.json()).error.code).toBe('upstream_error') expect(entries).toEqual( expect.arrayContaining([ expect.objectContaining({ errorCode: 'upstream_error', level: 'error', status: 502 }), ]), ) expect(causes[0]).toBeInstanceOf(Stripe.errors.StripeConnectionError) expect((await app.request(`/v1/orgs/${orgId}/billing`, { headers })).status).toBe(200) }) }) describe.runIf(TestStripe.secretKey)('previewInvoice', () => { test('reads an empty account without provisioning a subscription', async () => { const stripe = TestStripe.client() const { app, db } = await setup({ stripe }) const customer = await stripe.customers.create({ name: 'Invoice preview empty account' }) TestStripe.track(customer.id) await StripeCustomers.create(db, { orgId, stripeCustomerId: customer.id }) const response = await app.request(path, { headers }) expect(response.status).toBe(200) expect(await response.json()).toMatchInlineSnapshot(` { "status": "no_invoice", } `) expect( await Billing.previewInvoice(stripe, { customer: customer.id, environment: 'production' }), ).toBeNull() expect((await stripe.subscriptions.list({ customer: customer.id })).data).toHaveLength(0) }) test('preserves Stripe lines, totals, pagination, discounts, and account credit', async () => { const stripe = TestStripe.client() const { app, db } = await setup({ stripe }) const customer = await stripe.customers.create({ name: 'Invoice preview adjustments' }) TestStripe.track(customer.id) await StripeCustomers.create(db, { orgId, stripeCustomerId: customer.id }) await Billing.ensureSubscription(stripe, customer.id, Billing.kindsFor('production')) const subscription = (await stripe.subscriptions.list({ customer: customer.id })).data[0]! const initial = await app.request(path, { headers }) expect(initial.headers.get('cache-control')).toBe('private, max-age=60') expect(initial.headers.get('vary')).toContain('Cookie') const empty = await TestApp.json(initial, Invoice.schema.InvoicePreview) expect(empty.status).toBe('ready') if (empty.status !== 'ready') throw new Error('Expected a zero invoice') expect(empty.total).toBe('0') expect(empty.lineItems).toHaveLength(3) const coupon = await stripe.coupons.create({ duration: 'once', percent_off: 10 }) try { await stripe.subscriptions.update(subscription.id, { discounts: [{ coupon: coupon.id }] }) await stripe.customers.createBalanceTransaction(customer.id, { amount: -200, currency: 'usd', }) for (let index = 0; index < 12; index++) await stripe.invoiceItems.create({ amount: 100, currency: 'usd', customer: customer.id, description: `Extra charge ${index}`, }) const cached = await TestApp.json( await app.request(path, { headers }), Invoice.schema.InvoicePreview, ) expect(cached).toEqual(empty) const fresh = await TestApp.json( await app.request(path, { headers: { ...headers, 'cache-control': 'no-cache' } }), Invoice.schema.InvoicePreview, ) if (fresh.status !== 'ready') throw new Error('Expected an invoice') expect({ amountDue: fresh.amountDue, discounts: fresh.discounts, startingBalance: fresh.startingBalance, total: fresh.total, }).toMatchInlineSnapshot(` { "amountDue": "8.8", "discounts": "1.2", "startingBalance": "-2", "total": "10.8", } `) expect(fresh.lineItems).toHaveLength(15) expect( fresh.lineItems.filter( (line) => line.description.startsWith('Extra charge') && line.kind === null, ), ).toHaveLength(12) const invoice = await stripe.invoices.createPreview({ customer: customer.id, subscription: subscription.id, }) const lines = await stripe.invoices .listLineItems(invoice.id, { limit: 100 }) .autoPagingToArray({ limit: 100 }) expect( fresh.lineItems.map(({ amount, description, product }) => ({ amount, description, productId: product?.id ?? null, })), ).toEqual( lines.map((line) => ({ amount: String(line.amount / 100), description: line.description, productId: line.pricing?.price_details?.product ?? null, })), ) expect(Number(fresh.total) * 100).toBe(invoice.total) expect(Number(fresh.amountDue) * 100).toBeCloseTo(invoice.amount_due) expect((await stripe.invoices.list({ customer: customer.id })).data).toHaveLength(0) } finally { await stripe.coupons.del(coupon.id) } }) test('preserves each service period and exact decimal unit prices', async () => { const stripe = TestStripe.client() const { app, db } = await setup({ stripe }) const customer = await stripe.customers.create({ name: 'Invoice preview line details' }) TestStripe.track(customer.id) await StripeCustomers.create(db, { orgId, stripeCustomerId: customer.id }) await Billing.ensureSubscription(stripe, customer.id) const subscription = (await stripe.subscriptions.list({ customer: customer.id })).data[0]! const { meterId, priceId } = await Billing.ensureFixtures(stripe) const product = await stripe.products.create({ name: 'Invoice preview custom product' }) const price = await stripe.prices.create({ currency: 'usd', product: product.id, recurring: { interval: 'month', meter: meterId, usage_type: 'metered' }, unit_amount_decimal: '0.123456789012', }) try { await stripe.subscriptionItems.create({ price: price.id, subscription: subscription.id }) await stripe.invoiceItems.create({ amount: 100, currency: 'usd', customer: customer.id, description: 'Historical service', period: { end: Date.parse('2026-08-31T23:59:59.000Z') / 1_000, start: Date.parse('2026-08-01T00:00:00.000Z') / 1_000, }, }) const preview = await TestApp.json( await app.request(path, { headers }), Invoice.schema.InvoicePreview, ) if (preview.status !== 'ready') throw new Error('Expected an invoice') const precise = preview.lineItems.find((line) => line.priceId === price.id) expect({ kind: precise?.kind, quantity: precise?.quantity, unitAmount: precise?.unitAmount }) .toMatchInlineSnapshot(` { "kind": "feePayerSpend", "quantity": 0, "unitAmount": "0.00123456789012", } `) expect(precise?.product).toEqual({ id: product.id, name: 'Invoice preview custom product' }) await stripe.products.update(product.id, { name: 'Renamed invoice product' }) const renamed = await TestApp.json( await app.request(path, { headers: { ...headers, 'cache-control': 'no-store' } }), Invoice.schema.InvoicePreview, ) if (renamed.status !== 'ready') throw new Error('Expected an invoice') expect(renamed.lineItems.find((line) => line.priceId === price.id)?.product).toEqual({ id: product.id, name: 'Renamed invoice product', }) expect(preview.lineItems.find((line) => line.priceId === priceId)?.unitAmount).toBe( '0.000001', ) expect(preview.lineItems.find((line) => line.description === 'Historical service')?.period) .toMatchInlineSnapshot(` { "end": "2026-08-31T23:59:59.000Z", "start": "2026-08-01T00:00:00.000Z", } `) expect(precise?.period.start).not.toBe('2026-08-01T00:00:00.000Z') } finally { await stripe.prices.update(price.id, { active: false }) await stripe.products.update(product.id, { active: false }) } }) test.each([false, true])('combines discounts and tax (inclusive: %s)', async (inclusive) => { const stripe = TestStripe.client() const customer = await stripe.customers.create({ name: 'Invoice preview tax' }) TestStripe.track(customer.id) await Billing.ensureSubscription(stripe, customer.id) const subscription = (await stripe.subscriptions.list({ customer: customer.id })).data[0]! const coupon = await stripe.coupons.create({ duration: 'once', percent_off: 10 }) const rate = await stripe.taxRates.create({ display_name: 'Test tax', inclusive, percentage: 10, }) try { await stripe.subscriptions.update(subscription.id, { discounts: [{ coupon: coupon.id }] }) await stripe.invoiceItems.create({ amount: inclusive ? 1100 : 1000, currency: 'usd', customer: customer.id, tax_rates: [rate.id], }) const preview = await Billing.previewInvoice(stripe, { customer: customer.id, environment: 'production', }) if (!preview) throw new Error('Expected an invoice') expect({ billingCredits: preview.billingCredits, discounts: preview.discounts, exclusiveTax: preview.exclusiveTax, taxIncluded: preview.taxIncluded, total: preview.total, }).toEqual({ billingCredits: '0', discounts: inclusive ? '1.1' : '1', exclusiveTax: inclusive ? '0' : '0.9', taxIncluded: inclusive ? '0.9' : '0', total: '9.9', }) expect( preview.lineItems.reduce((sum, line) => sum + Billing.toBaseUnits(line.amount), 0n) - Billing.toBaseUnits(preview.discounts) - Billing.toBaseUnits(preview.billingCredits) + Billing.toBaseUnits(preview.exclusiveTax), ).toBe(Billing.toBaseUnits(preview.total)) } finally { await stripe.coupons.del(coupon.id) await stripe.taxRates.update(rate.id, { active: false }) } }) test( 'separates billing credits from discounts and customer balance', { timeout: 240_000 }, async () => { const stripe = TestStripe.client() const { app, db } = await setup({ stripe }) const customer = await stripe.customers.create({ name: 'Invoice preview billing credits' }) TestStripe.track(customer.id) await StripeCustomers.create(db, { orgId, stripeCustomerId: customer.id }) await Billing.ensureSubscription(stripe, customer.id) const subscription = (await stripe.subscriptions.list({ customer: customer.id })).data[0]! const coupon = await stripe.coupons.create({ duration: 'once', percent_off: 10 }) const rate = await stripe.taxRates.create({ display_name: 'Test tax', inclusive: false, percentage: 10, }) const grant = await stripe.billing.creditGrants.create({ amount: { monetary: { currency: 'usd', value: 200 }, type: 'monetary' }, applicability_config: { scope: { price_type: 'metered' } }, category: 'promotional', customer: customer.id, name: 'Invoice preview test credit', }) try { await stripe.subscriptions.update(subscription.id, { default_tax_rates: [rate.id], discounts: [{ coupon: coupon.id }], }) await stripe.customers.createBalanceTransaction(customer.id, { amount: -100, currency: 'usd', }) await stripe.billing.meterEvents.create({ event_name: Billing.meters.feePayerSpend.eventName, identifier: crypto.randomUUID(), payload: { stripe_customer_id: customer.id, value: '10000000' }, }) await expect .poll( async () => { const preview = await TestApp.json( await app.request(path, { headers: { ...headers, 'cache-control': 'no-store' } }), Invoice.schema.InvoicePreview, ) return preview.status === 'ready' ? preview.total : preview.status }, { interval: 5_000, timeout: 180_000 }, ) .toBe('7.7') const preview = await TestApp.json( await app.request(path, { headers }), Invoice.schema.InvoicePreview, ) if (preview.status !== 'ready') throw new Error('Expected an invoice') expect({ amountDue: preview.amountDue, billingCredits: preview.billingCredits, discounts: preview.discounts, exclusiveTax: preview.exclusiveTax, startingBalance: preview.startingBalance, taxIncluded: preview.taxIncluded, total: preview.total, }).toMatchInlineSnapshot(` { "amountDue": "6.7", "billingCredits": "2", "discounts": "1", "exclusiveTax": "0.7", "startingBalance": "-1", "taxIncluded": "0", "total": "7.7", } `) expect( preview.lineItems.reduce((sum, line) => sum + Billing.toBaseUnits(line.amount), 0n) - Billing.toBaseUnits(preview.discounts) - Billing.toBaseUnits(preview.billingCredits) + Billing.toBaseUnits(preview.exclusiveTax), ).toBe(Billing.toBaseUnits(preview.total)) } finally { await stripe.billing.creditGrants.voidGrant(grant.id) await stripe.coupons.del(coupon.id) await stripe.taxRates.update(rate.id, { active: false }) } }, ) test( 'includes usage from all existing meters and separates sandbox customers', { timeout: 240_000 }, async () => { const stripe = TestStripe.client() const { app, db } = await setup({ stripe }) const customers = await Promise.all( ['production', 'sandbox'].map(async (environment) => { const customer = await stripe.customers.create({ name: `Invoice preview ${environment}` }) TestStripe.track(customer.id) return customer }), ) const customer = customers[0]! const sandbox = customers[1]! await StripeCustomers.create(db, { orgId, stripeCustomerId: customer.id }) await StripeCustomers.create(db, { environment: 'sandbox', orgId, stripeCustomerId: sandbox.id, }) await Billing.ensureSubscription(stripe, customer.id, Billing.kindsFor('production')) await Billing.ensureSubscription(stripe, sandbox.id, Billing.kindsFor('sandbox')) for (const [kind, value] of [ ['apiRequests', '2000'], ['feePayerSpend', '500000'], ['routeSubsidy', '300000'], ] as const) await stripe.billing.meterEvents.create({ event_name: Billing.meters[kind].eventName, identifier: crypto.randomUUID(), payload: { stripe_customer_id: customer.id, value }, }) await expect .poll( async () => { const body = await TestApp.json( await app.request(path, { headers: { ...headers, 'cache-control': 'no-store' } }), Invoice.schema.InvoicePreview, ) return body.status === 'ready' ? body.total : body.status }, { interval: 5_000, timeout: 180_000 }, ) .toBe('1') const body = await TestApp.json( await app.request(path, { headers }), Invoice.schema.InvoicePreview, ) if (body.status !== 'ready') throw new Error('Expected an invoice') expect(Object.fromEntries(body.lineItems.map(({ amount, kind }) => [kind, amount]))) .toMatchInlineSnapshot(` { "apiRequests": "0.2", "feePayerSpend": "0.5", "routeSubsidy": "0.3", } `) for (const line of body.lineItems) { if (!line.product) throw new Error('Expected a Stripe product') const product = await stripe.products.retrieve(line.product.id) expect(line.product).toEqual({ id: product.id, name: product.name }) } const testInvoice = await TestApp.json( await app.request(`${path}?environment=sandbox`, { headers }), Invoice.schema.InvoicePreview, ) if (testInvoice.status !== 'ready') throw new Error('Expected a sandbox invoice') expect(testInvoice.total).toBe('0') expect(testInvoice.lineItems).toHaveLength(1) expect(testInvoice.lineItems[0]?.kind).toBe('apiRequests') expect(testInvoice.lineItems[0]?.product).toEqual( body.lineItems.find((line) => line.amount === '0.2')?.product, ) }, ) test('rejects duplicate managed subscriptions and recognizes cancellation', async () => { const stripe = TestStripe.client() const customer = await stripe.customers.create({ name: 'Invoice preview subscription state' }) TestStripe.track(customer.id) await Billing.ensureSubscription(stripe, customer.id) const original = (await stripe.subscriptions.list({ customer: customer.id })).data[0]! const { priceId } = await Billing.ensureFixtures(stripe) const duplicate = await stripe.subscriptions.create({ customer: customer.id, items: [{ price: priceId }], }) await expect( Billing.previewInvoice(stripe, { customer: customer.id, environment: 'production' }), ).rejects.toBeInstanceOf(Billing.DuplicateSubscriptionError) await stripe.subscriptions.cancel(duplicate.id) await stripe.subscriptions.cancel(original.id) expect( await Billing.previewInvoice(stripe, { customer: customer.id, environment: 'production' }), ).toBeNull() }) }) async function setup( options: { logger?: Log.Emit | undefined store?: Store.State | undefined stripe?: Stripe | undefined } = {}, ) { const db = TestApp.database() await Organizations.create(db, { id: orgId, name: 'Invoice preview organization' }) const stripe = options.stripe ?? new Stripe(`sk_test_${'a'.repeat(24)}`, { host: '127.0.0.1', maxNetworkRetries: 0, port: 9, protocol: 'http', timeout: 2_000, }) const app = TestApp.create({ auth: { superAdmin: { secret } }, billing: { stripe: { client: stripe, ...(options.stripe ? { sandbox: { client: stripe, webhookSecret: 'whsec_test' } } : {}), webhookSecret: 'whsec_test', }, }, db, session: { wallet: { origin: 'http://localhost' } }, kv: { store: options.store ?? TestApp.kvStore() }, ...(options.logger ? { logger: options.logger } : {}), }) return { app, db } }