diff --git a/apps/sim/lib/billing/organization.test.ts b/apps/sim/lib/billing/organization.test.ts index 4f5fb2a51cb..fdc9275c5b4 100644 --- a/apps/sim/lib/billing/organization.test.ts +++ b/apps/sim/lib/billing/organization.test.ts @@ -113,6 +113,8 @@ describe('ensureOrganizationForTeamSubscription', () => { it('transfers a user-referenced team subscription onto the org the user administers', async () => { mockIsSubscriptionOrgScoped.mockResolvedValueOnce(false) queueWhereResponses([ + // referenced user lookup + [{ name: 'Owner', email: 'owner@example.com' }], // membership lookup: user owns an org [{ id: 'member-1', organizationId: 'org-owned', role: 'owner' }], // locked membership re-read inside the transfer transaction diff --git a/apps/sim/lib/billing/organization.ts b/apps/sim/lib/billing/organization.ts index c937b6d6eb9..637beabf1f2 100644 --- a/apps/sim/lib/billing/organization.ts +++ b/apps/sim/lib/billing/organization.ts @@ -12,6 +12,7 @@ import { acquireUserBillingIdentityLock } from '@/lib/billing/organizations/bill import { createOrganizationWithOwner } from '@/lib/billing/organizations/create-organization' import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership' import { isEnterprise, isOrgPlan, isPaid } from '@/lib/billing/plan-helpers' +import { SubscriptionReferenceNotFoundError } from '@/lib/billing/subscriptions/errors' import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { toDecimal } from '@/lib/billing/utils/decimal' import type { DbTransaction } from '@/lib/db/types' @@ -127,8 +128,21 @@ export async function ensureOrganizationForTeamSubscription( * checks under the org mutation lock) or creates a new organization. This * keeps re-homing deterministic in the webhook flow instead of depending on * a client-side transfer call after checkout. + * + * A reference that is neither an organization nor a user (an organization + * deleted under a live subscription) has no one to re-home onto, so it + * fails as a dangling reference instead of seeding an organization owned + * by a nonexistent user. */ const userId = subscription.referenceId + const [userData] = await db + .select({ name: user.name, email: user.email }) + .from(user) + .where(eq(user.id, userId)) + .limit(1) + if (!userData) { + throw new SubscriptionReferenceNotFoundError(subscription.referenceId) + } logger.info('Creating organization for team subscription', { subscriptionId: subscription.id, @@ -267,16 +281,10 @@ export async function ensureOrganizationForTeamSubscription( throw new Error('User is already member of another organization') } - const [userData] = await db - .select({ name: user.name, email: user.email }) - .from(user) - .where(eq(user.id, userId)) - .limit(1) - const orgId = await createOrganizationForTeamPlan( userId, - userData?.name || undefined, - userData?.email || undefined + userData.name || undefined, + userData.email || undefined ) await db.transaction(async (tx) => { @@ -509,9 +517,7 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData .limit(1) if (users.length === 0) { - throw new Error( - `Subscription reference ${subscription.referenceId} does not match a user or organization` - ) + throw new SubscriptionReferenceNotFoundError(subscription.referenceId) } await syncUsageLimitsFromSubscription(subscription.referenceId) diff --git a/apps/sim/lib/billing/subscriptions/errors.ts b/apps/sim/lib/billing/subscriptions/errors.ts new file mode 100644 index 00000000000..c4f0b967dd0 --- /dev/null +++ b/apps/sim/lib/billing/subscriptions/errors.ts @@ -0,0 +1,15 @@ +/** + * A subscription whose `referenceId` matches neither an organization nor a user — typically an + * organization deleted while its Stripe subscription stayed live. The condition is permanent: no + * retry can resolve the reference, so webhook paths acknowledge the event and log it instead of + * failing it into Stripe's retry loop. + */ +export class SubscriptionReferenceNotFoundError extends Error { + readonly referenceId: string + + constructor(referenceId: string) { + super(`Subscription reference ${referenceId} does not match a user or organization`) + this.name = 'SubscriptionReferenceNotFoundError' + this.referenceId = referenceId + } +} diff --git a/apps/sim/lib/billing/webhooks/enterprise-missing-organization.integration.ts b/apps/sim/lib/billing/webhooks/enterprise-missing-organization.integration.ts new file mode 100644 index 00000000000..ea72f5e8559 --- /dev/null +++ b/apps/sim/lib/billing/webhooks/enterprise-missing-organization.integration.ts @@ -0,0 +1,371 @@ +/** + * Stripe subscription webhooks for an Enterprise subscription whose referenced organization no + * longer exists, against real PostgreSQL and the real Better Auth Stripe webhook endpoint (plugin + * write first, then Sim's callbacks), with an in-memory Stripe. + * + * Failure modes covered: + * - a renewal for a live subscription whose organization was deleted fails into Stripe's retry + * loop forever instead of being acknowledged; + * - a dashboard-created Enterprise subscription naming a nonexistent organization does the same; + * - the update callback treats the dangling organization id as a user upgrading to Team and + * seeds an organization owned by that nonexistent user; + * - acknowledging permanent failures swallows a transient one (an issuance whose Stripe writes + * have not landed yet), so the event is never redelivered. + * + * The copied tables carry no foreign keys, so an attempted organization creation for a dangling + * reference persists here instead of being rolled back by the member foreign key: the suite + * checks the decision, not the constraint that backstops it. + */ + +import { stripe as stripePlugin } from '@better-auth/stripe' +import * as schema from '@sim/db/schema' +import { member, organization, outboxEvent, subscription, user } from '@sim/db/schema' +import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure' +import { withUtcTimestamps } from '@sim/db/timestamps' +import { + createInMemoryStripe, + createMockStripeEvent, + type InMemoryStripe, + stripeClientMock, +} from '@sim/testing/mocks/stripe.mock' +import { generateId } from '@sim/utils/id' +import { type BetterAuthOptions, betterAuth } from 'better-auth' +import { eq } from 'drizzle-orm' +import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import type Stripe from 'stripe' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const database = vi.hoisted(() => ({ + current: undefined as PostgresJsDatabase | undefined, +})) + +vi.mock('@sim/db', () => ({ + get db() { + if (!database.current) throw new Error('Enterprise webhook test database is not initialized') + return database.current + }, +})) +vi.mock('@/lib/billing/stripe-client', () => stripeClientMock) + +import { createSimAuthAdapter } from '@/lib/auth/sim-auth-adapter' +import { ENTERPRISE_PROVISION_EVENT_TYPE } from '@/lib/billing/enterprise-outbox-events' +import { ensureOrganizationForTeamSubscription } from '@/lib/billing/organization' +import { handleManualEnterpriseSubscription } from '@/lib/billing/webhooks/enterprise' +import { reconcileSubscriptionSyncFromStripe } from '@/lib/billing/webhooks/subscription-sync' +import { handleSubscriptionUsageUpdate } from '@/lib/billing/webhooks/subscription-usage' + +const schemaName = `enterprise_missing_org_${generateId().replaceAll('-', '')}` +const connection = postgres( + readTestDatabaseUrl(), + withUtcTimestamps({ + max: 6, + prepare: false, + fetch_types: false, + connection: { search_path: schemaName }, + onnotice: () => {}, + }) +) +const testDatabase = drizzle(connection, { schema }) + +const INVOICE_AMOUNT_CENTS = 100_000 +const USAGE_LIMIT_CREDITS = 50_000 + +let stripe: InMemoryStripe + +/** + * Sim's Better Auth Stripe wiring from `lib/auth/auth.ts`, reduced to the organization + * resolution in `onSubscriptionUpdate` and the subscription steps of `onEvent`, in order. + */ +function createTestAuth() { + return betterAuth({ + baseURL: 'http://localhost:3000', + secret: 'isolated-integration-fixture-secret-not-a-real-credential', + database: (options: BetterAuthOptions) => createSimAuthAdapter(options, testDatabase), + emailAndPassword: { enabled: true }, + plugins: [ + stripePlugin({ + stripeClient: stripe.client, + stripeWebhookSecret: 'whsec_fixture', + subscription: { + enabled: true, + plans: [], + onSubscriptionUpdate: async ({ event, subscription: updated }) => { + const stripeSubscription = event.data.object as Stripe.Subscription + await ensureOrganizationForTeamSubscription({ + ...updated, + enterpriseOperationId: stripeSubscription.metadata?.enterpriseOperationId ?? null, + }) + }, + }, + onEvent: async (event) => { + if ( + event.type !== 'customer.subscription.created' && + event.type !== 'customer.subscription.updated' + ) { + return + } + await handleManualEnterpriseSubscription(event) + await reconcileSubscriptionSyncFromStripe(event) + await handleSubscriptionUsageUpdate(event) + }, + }), + ], + }) +} + +let auth: ReturnType + +function deliver(event: Stripe.Event) { + return auth.handler( + new Request('http://localhost:3000/api/auth/stripe/webhook', { + method: 'POST', + headers: { 'content-type': 'application/json', 'stripe-signature': 't=1,v1=fixture' }, + body: JSON.stringify(event), + }) + ) +} + +beforeAll(async () => { + await connection`CREATE SCHEMA ${connection(schemaName)}` + for (const table of [ + 'subscription', + 'outbox_event', + 'idempotency_key', + 'member', + 'user', + 'organization', + 'invitation', + 'workspace', + 'permissions', + 'audit_log', + 'session', + 'account', + 'verification', + ]) { + await connection.unsafe(`CREATE TABLE "${table}" (LIKE public."${table}" INCLUDING ALL)`) + } + database.current = testDatabase +}) + +beforeEach(() => { + stripe = createInMemoryStripe() + stripeClientMock.requireStripeClient.mockReturnValue(stripe.client) + auth = createTestAuth() +}) + +afterAll(async () => { + try { + await connection`DROP SCHEMA ${connection(schemaName)} CASCADE` + } finally { + await connection.end() + database.current = undefined + } +}) + +/** An Enterprise owner whose Stripe customer carries a subscription for `organizationId`. */ +async function createEnterpriseCustomer( + organizationId: string, + options: { + metadata?: Record + collectionMethod?: Stripe.Subscription.CollectionMethod + } = {} +) { + const ownerId = generateId() + const stripeCustomerId = `cus_${generateId()}` + const stripeSubscriptionId = `sub_${generateId()}` + const now = new Date() + await testDatabase.insert(user).values({ + id: ownerId, + name: 'Owner', + email: `owner-${ownerId}@example.com`, + emailVerified: true, + stripeCustomerId, + createdAt: now, + updatedAt: now, + }) + stripe.addSubscription({ + id: stripeSubscriptionId, + customer: stripeCustomerId, + unitAmount: INVOICE_AMOUNT_CENTS, + collectionMethod: options.collectionMethod, + metadata: { + plan: 'enterprise', + referenceId: organizationId, + seats: '5', + invoiceAmountCents: String(INVOICE_AMOUNT_CENTS), + ...options.metadata, + }, + }) + return { ownerId, stripeCustomerId, stripeSubscriptionId } +} + +/** A live Enterprise subscription row whose organization was deleted after issuance. */ +async function createSubscriptionForDeletedOrganization() { + const deletedOrganizationId = `org_${generateId()}` + const customer = await createEnterpriseCustomer(deletedOrganizationId) + const subscriptionId = generateId() + await testDatabase.insert(subscription).values({ + id: subscriptionId, + plan: 'enterprise', + referenceId: deletedOrganizationId, + status: 'active', + seats: 1, + stripeSubscriptionId: customer.stripeSubscriptionId, + stripeCustomerId: customer.stripeCustomerId, + cancelAtPeriodEnd: false, + metadata: stripe.subscription(customer.stripeSubscriptionId).metadata, + }) + return { ...customer, deletedOrganizationId, subscriptionId } +} + +async function renew(stripeSubscriptionId: string) { + stripe.updateOutsideSim(stripeSubscriptionId, { metadata: { renewedAt: generateId() } }) + return deliver(stripe.events.at(-1) as Stripe.Event) +} + +async function storedSubscription(subscriptionId: string) { + const [row] = await testDatabase + .select({ + referenceId: subscription.referenceId, + plan: subscription.plan, + periodEnd: subscription.periodEnd, + }) + .from(subscription) + .where(eq(subscription.id, subscriptionId)) + if (!row) throw new Error(`Subscription ${subscriptionId} not found`) + return row +} + +function subscriptionRowsFor(stripeSubscriptionId: string) { + return testDatabase + .select({ referenceId: subscription.referenceId, plan: subscription.plan }) + .from(subscription) + .where(eq(subscription.stripeSubscriptionId, stripeSubscriptionId)) +} + +async function organizationsOwnedBy(referenceId: string) { + return testDatabase + .select({ organizationId: member.organizationId }) + .from(member) + .where(eq(member.userId, referenceId)) +} + +describe('Enterprise subscription whose organization was deleted', () => { + it('acknowledges a renewal and still mirrors the Stripe period onto the row', async () => { + const orphan = await createSubscriptionForDeletedOrganization() + + const response = await renew(orphan.stripeSubscriptionId) + + expect(response.status).toBe(200) + const currentPeriodEnd = stripe.subscription(orphan.stripeSubscriptionId).items.data[0] + .current_period_end + expect((await storedSubscription(orphan.subscriptionId)).periodEnd).toEqual( + new Date(currentPeriodEnd * 1000) + ) + }) + + it('does not create an organization for the dangling reference on renewal', async () => { + const orphan = await createSubscriptionForDeletedOrganization() + + await renew(orphan.stripeSubscriptionId) + + expect(await organizationsOwnedBy(orphan.deletedOrganizationId)).toEqual([]) + expect(await storedSubscription(orphan.subscriptionId)).toMatchObject({ + referenceId: orphan.deletedOrganizationId, + plan: 'enterprise', + }) + }) + + it('acknowledges a dashboard-created subscription and records nothing for it', async () => { + const missingOrganizationId = `org_${generateId()}` + const customer = await createEnterpriseCustomer(missingOrganizationId) + + const response = await deliver( + createMockStripeEvent( + 'customer.subscription.created', + stripe.subscription(customer.stripeSubscriptionId), + { id: `evt_${generateId()}` } + ) + ) + + expect(response.status).toBe(200) + expect( + await testDatabase + .select({ id: subscription.id }) + .from(subscription) + .where(eq(subscription.stripeSubscriptionId, customer.stripeSubscriptionId)) + ).toEqual([]) + expect( + await testDatabase + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.id, missingOrganizationId)) + ).toEqual([]) + }) +}) + +describe('Enterprise issuance that Stripe has not caught up with', () => { + it('fails the webhook until paused collection lands, then applies on redelivery', async () => { + const organizationId = `org_${generateId()}` + await testDatabase + .insert(organization) + .values({ id: organizationId, name: 'Org', slug: organizationId }) + const operationId = generateId() + const customer = await createEnterpriseCustomer(organizationId, { + collectionMethod: 'send_invoice', + metadata: { + enterpriseOperationId: operationId, + usageLimitCredits: String(USAGE_LIMIT_CREDITS), + }, + }) + await testDatabase.insert(outboxEvent).values({ + id: operationId, + eventType: ENTERPRISE_PROVISION_EVENT_TYPE, + payload: { + version: 2, + retryRevision: 0, + stripeProgress: { subscriptionId: customer.stripeSubscriptionId }, + request: { + requestKey: generateId(), + ownerUserId: customer.ownerId, + organizationId, + requestedByEmail: 'admin-api', + requestedByUserId: null, + invoiceAmountCents: INVOICE_AMOUNT_CENTS, + billingInterval: 'month', + usageLimitCredits: USAGE_LIMIT_CREDITS, + seats: 5, + pausePaymentCollection: true, + }, + }, + }) + const created = createMockStripeEvent( + 'customer.subscription.created', + stripe.subscription(customer.stripeSubscriptionId), + { id: `evt_${generateId()}` } + ) + + const beforePause = await deliver(created) + + expect(beforePause.ok).toBe(false) + expect(await subscriptionRowsFor(customer.stripeSubscriptionId)).toEqual([]) + + stripe.updateOutsideSim(customer.stripeSubscriptionId, { + pause_collection: { behavior: 'keep_as_draft' }, + }) + const redelivered = await deliver(created) + + expect(redelivered.status).toBe(200) + expect(await subscriptionRowsFor(customer.stripeSubscriptionId)).toEqual([ + { referenceId: organizationId, plan: 'enterprise' }, + ]) + const [operation] = await testDatabase + .select({ payload: outboxEvent.payload }) + .from(outboxEvent) + .where(eq(outboxEvent.id, operationId)) + expect(operation.payload).toMatchObject({ + applicationResult: { subscriptionId: customer.stripeSubscriptionId }, + }) + }) +}) diff --git a/apps/sim/lib/billing/webhooks/enterprise.ts b/apps/sim/lib/billing/webhooks/enterprise.ts index abe549dfaf2..6f42d30127c 100644 --- a/apps/sim/lib/billing/webhooks/enterprise.ts +++ b/apps/sim/lib/billing/webhooks/enterprise.ts @@ -53,6 +53,15 @@ import { parseEnterpriseSubscriptionMetadata } from '../types' const logger = createLogger('BillingEnterprise') +/** + * Reconciles an Enterprise Stripe subscription onto the organization its metadata references. + * + * Transient conditions (lease or lock contention, database errors, an issuance whose Stripe + * writes have not landed yet) throw so Stripe redelivers the event. A referenced organization + * that does not exist is permanent: issuance commits the organization before it creates the + * Stripe subscription, and a deleted organization never returns, so the event is acknowledged + * with an error log instead of being retried until Stripe drops it. + */ export async function handleManualEnterpriseSubscription(event: Stripe.Event) { return processManualEnterpriseSubscription(event) } @@ -63,6 +72,7 @@ async function processManualEnterpriseSubscription(event: Stripe.Event) { const previousAttributes: Record = toRecord(rawPreviousAttributes) return withEnterpriseReconciliationLease(eventSubscription.id, (lease) => reconcileManualEnterpriseSubscription(eventSubscription, lease, { + eventType: event.type, created: event.type === 'customer.subscription.created', previousStatus: typeof previousAttributes.status === 'string' ? previousAttributes.status : null, @@ -73,7 +83,7 @@ async function processManualEnterpriseSubscription(event: Stripe.Event) { async function reconcileManualEnterpriseSubscription( eventSubscription: Stripe.Subscription, reconciliationLease: EnterpriseReconciliationLease, - trigger: { created: boolean; previousStatus: string | null } + trigger: { eventType: string; created: boolean; previousStatus: string | null } ) { // Stripe does not promise webhook ordering. Read the current object before // taking DB locks so a delayed created/updated event cannot overwrite newer @@ -191,7 +201,7 @@ async function reconcileManualEnterpriseSubscription( .where(eq(organization.id, referenceId)) .for('update') .limit(1) - if (!organizationRow) throw new Error('Enterprise organization not found') + if (!organizationRow) return { outcome: 'organization-missing' as const } const operationId = metadata.enterpriseOperationId let correlatedOperation: EnterpriseProvisionPayload | null = null @@ -475,6 +485,7 @@ async function reconcileManualEnterpriseSubscription( } return { + outcome: 'reconciled' as const, subscriptionId: existing?.id ?? subscriptionRow.id, requestedByEmail: correlatedOperation?.request.requestedByEmail ?? null, requestedByUserId: correlatedOperation?.request.requestedByUserId ?? null, @@ -493,6 +504,20 @@ async function reconcileManualEnterpriseSubscription( } }) + if (coreResult.outcome === 'organization-missing') { + logger.error( + '[subscription] Enterprise subscription references an organization that does not exist; acknowledging without reconciling', + { + stripeSubscriptionId: stripeSubscription.id, + eventType: trigger.eventType, + referenceId, + stripeCustomerId, + status: stripeSubscription.status, + } + ) + return + } + const { subscriptionId, requestedByEmail, diff --git a/apps/sim/lib/billing/webhooks/subscription-usage.ts b/apps/sim/lib/billing/webhooks/subscription-usage.ts index fd1a229a646..c5ef82cccb2 100644 --- a/apps/sim/lib/billing/webhooks/subscription-usage.ts +++ b/apps/sim/lib/billing/webhooks/subscription-usage.ts @@ -1,13 +1,19 @@ import { db } from '@sim/db' import { subscription } from '@sim/db/schema' +import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import type Stripe from 'stripe' import { syncSubscriptionUsageLimits } from '@/lib/billing/organization' +import { SubscriptionReferenceNotFoundError } from '@/lib/billing/subscriptions/errors' + +const logger = createLogger('SubscriptionUsageWebhook') /** * Reconciles usage limits through the Stripe plugin's retryable onEvent hook. * Read the persisted reference after subscription callbacks may have moved it * to an organization. Callback exceptions alone are swallowed by the plugin. + * A subscription whose reference no longer resolves is acknowledged, since no + * redelivery can sync a billing pool that does not exist. */ export async function handleSubscriptionUsageUpdate(event: Stripe.Event): Promise { if (event.type !== 'customer.subscription.updated') return @@ -26,5 +32,15 @@ export async function handleSubscriptionUsageUpdate(event: Stripe.Event): Promis if (!persistedSubscription) return - await syncSubscriptionUsageLimits(persistedSubscription) + try { + await syncSubscriptionUsageLimits(persistedSubscription) + } catch (error) { + if (!(error instanceof SubscriptionReferenceNotFoundError)) throw error + logger.error('Skipping usage-limit sync for a subscription whose reference no longer exists', { + subscriptionId: persistedSubscription.id, + stripeSubscriptionId: event.data.object.id, + eventType: event.type, + referenceId: persistedSubscription.referenceId, + }) + } } diff --git a/packages/testing/src/mocks/stripe.mock.ts b/packages/testing/src/mocks/stripe.mock.ts index 6dfdd11cf2d..9557ae1a641 100644 --- a/packages/testing/src/mocks/stripe.mock.ts +++ b/packages/testing/src/mocks/stripe.mock.ts @@ -69,6 +69,9 @@ export interface InMemoryStripeSubscription { trial_start: number | null trial_end: number | null schedule: string | null + collection_method: Stripe.Subscription.CollectionMethod + days_until_due: number | null + pause_collection: InMemoryStripePauseCollection | null metadata: Record items: { object: 'list' @@ -77,11 +80,22 @@ export interface InMemoryStripeSubscription { quantity: number current_period_start: number current_period_end: number - price: { id: string; recurring: { interval: 'month' | 'year' } } + price: { + id: string + currency: string + unit_amount: number | null + recurring: { interval: 'month' | 'year' } + } }> } } +/** A subscription's paused invoice collection, as Stripe reports it. */ +interface InMemoryStripePauseCollection { + behavior: 'keep_as_draft' | 'mark_uncollectible' | 'void' + resumes_at: number | null +} + /** A Stripe customer as the in-memory fake stores it. */ export interface InMemoryStripeCustomer { id: string @@ -107,6 +121,10 @@ interface SubscriptionUpdateParams { cancel_at?: number | '' metadata?: Record items?: Array<{ id: string; quantity?: number; price?: string }> + /** `''` resumes collection, as in Stripe's API. */ + pause_collection?: + | { behavior: InMemoryStripePauseCollection['behavior']; resumes_at?: number } + | '' } interface CustomerUpdateParams { @@ -179,6 +197,19 @@ export function createInMemoryStripe() { next.cancel_at = cancelAt } } + if (params.pause_collection !== undefined) { + const pauseCollection = + params.pause_collection === '' + ? null + : { + behavior: params.pause_collection.behavior, + resumes_at: params.pause_collection.resumes_at ?? null, + } + if (JSON.stringify(pauseCollection) !== JSON.stringify(current.pause_collection)) { + previousAttributes.pause_collection = current.pause_collection + next.pause_collection = pauseCollection + } + } if (params.metadata) { const metadata = { ...current.metadata, ...params.metadata } if (JSON.stringify(metadata) !== JSON.stringify(current.metadata)) { @@ -190,7 +221,9 @@ export function createInMemoryStripe() { const target = next.items.data.find((existing) => existing.id === item.id) if (!target) throw new Error(`No such subscription item: '${item.id}'`) if (item.quantity !== undefined) target.quantity = item.quantity - if (item.price !== undefined) target.price = { ...target.price, id: item.price } + if (item.price !== undefined && item.price !== target.price.id) { + target.price = { ...target.price, id: item.price, unit_amount: null } + } } if (JSON.stringify(next.items) !== JSON.stringify(current.items)) { previousAttributes.items = current.items @@ -305,6 +338,11 @@ export function createInMemoryStripe() { > & { quantity?: number priceId?: string + /** Price amount in cents; omitted for prices whose amount no test reads. */ + unitAmount?: number + metadata?: Record + /** `send_invoice` subscriptions get Stripe's `days_until_due` of 30. */ + collectionMethod?: Stripe.Subscription.CollectionMethod } ) { const now = Math.floor(Date.now() / 1000) @@ -320,7 +358,10 @@ export function createInMemoryStripe() { trial_start: null, trial_end: null, schedule: null, - metadata: {}, + collection_method: subscription.collectionMethod ?? 'charge_automatically', + days_until_due: subscription.collectionMethod === 'send_invoice' ? 30 : null, + pause_collection: null, + metadata: { ...subscription.metadata }, items: { object: 'list', data: [ @@ -331,6 +372,8 @@ export function createInMemoryStripe() { current_period_end: now + 30 * 24 * 60 * 60, price: { id: subscription.priceId ?? `price_${subscription.id}`, + currency: 'usd', + unit_amount: subscription.unitAmount ?? null, recurring: { interval: 'month' }, }, },