diff --git a/packages/sync-engine/src/stripeSyncWebhook.ts b/packages/sync-engine/src/stripeSyncWebhook.ts index 7acd083a6..c04d2cf1c 100644 --- a/packages/sync-engine/src/stripeSyncWebhook.ts +++ b/packages/sync-engine/src/stripeSyncWebhook.ts @@ -165,20 +165,49 @@ export class StripeSyncWebhook { } async handleEntitlementSummaryEvent(event: Stripe.Event, accountId: string): Promise { + type EntitlementItem = { + id: string + object: string + feature: string | { id: string } + livemode: boolean + lookup_key: string + } const summary = event.data.object as { customer: string entitlements: { - data: Array<{ - id: string - object: string - feature: string | { id: string } - livemode: boolean - lookup_key: string - }> + data: EntitlementItem[] + has_more: boolean } } const customerId = summary.customer - const activeEntitlements = summary.entitlements.data.map((entitlement) => ({ + + let entitlementItems: EntitlementItem[] = summary.entitlements.data + let fetched = false + + if (summary.entitlements.has_more) { + // Webhook body is truncated — page through all active entitlements for this customer + entitlementItems = [] + let page = await this.deps.stripe.entitlements.activeEntitlements.list({ + customer: customerId, + limit: 100, + } as Stripe.Entitlements.ActiveEntitlementListParams) + entitlementItems.push(...(page.data as EntitlementItem[])) + while (page.has_more) { + if (page.data.length === 0) { + throw new Error('Stripe returned has_more=true with an empty entitlements page') + } + const lastId = page.data[page.data.length - 1].id + page = await this.deps.stripe.entitlements.activeEntitlements.list({ + customer: customerId, + limit: 100, + starting_after: lastId, + } as Stripe.Entitlements.ActiveEntitlementListParams) + entitlementItems.push(...(page.data as EntitlementItem[])) + } + fetched = true + } + + const activeEntitlements = entitlementItems.map((entitlement) => ({ id: entitlement.id, object: entitlement.object, feature: @@ -197,7 +226,7 @@ export class StripeSyncWebhook { activeEntitlements, accountId, false, - this.getSyncTimestamp(event, false) + this.getSyncTimestamp(event, fetched) ) } } diff --git a/packages/sync-engine/src/tests/unit/stripeSync-entitlements-webhook.test.ts b/packages/sync-engine/src/tests/unit/stripeSync-entitlements-webhook.test.ts new file mode 100644 index 000000000..c80c9b7db --- /dev/null +++ b/packages/sync-engine/src/tests/unit/stripeSync-entitlements-webhook.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it, vi } from 'vitest' +import type Stripe from 'stripe' +import { createMockedStripeSync } from '../testSetup' + +describe('entitlements webhook pagination', () => { + it('calls activeEntitlements.list when the webhook summary is truncated', async () => { + const stripeSync = await createMockedStripeSync() + const listSpy = vi.fn().mockResolvedValue({ + data: [ + { + id: 'ae_1', + object: 'entitlements.active_entitlement', + feature: { id: 'feat_1' }, + livemode: false, + lookup_key: 'feature-1', + }, + ], + has_more: false, + }) + const upsertSpy = vi.fn().mockResolvedValue([]) + const deleteSpy = vi.fn().mockResolvedValue(undefined) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(stripeSync.webhook as any).deps.stripe.entitlements.activeEntitlements.list = listSpy + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(stripeSync.webhook as any).deps.upsertAny = upsertSpy + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(stripeSync.webhook as any).deps.postgresClient.deleteRemovedActiveEntitlements = deleteSpy + + const event = { + id: 'evt_entitlements_truncated', + type: 'entitlements.active_entitlement_summary.updated', + created: Math.floor(Date.now() / 1000), + data: { + object: { + customer: 'cus_123', + entitlements: { + data: [], + has_more: true, + }, + }, + }, + } as unknown as Stripe.Event + + await stripeSync.webhook.handleEntitlementSummaryEvent(event, 'acct_test') + + expect(listSpy).toHaveBeenCalledWith({ + customer: 'cus_123', + limit: 100, + }) + expect(deleteSpy).toHaveBeenCalledWith('cus_123', ['ae_1']) + expect(upsertSpy).toHaveBeenCalledWith( + [ + { + id: 'ae_1', + object: 'entitlements.active_entitlement', + feature: 'feat_1', + customer: 'cus_123', + livemode: false, + lookup_key: 'feature-1', + }, + ], + 'acct_test', + false, + expect.any(String) + ) + }) + + it('fetches all entitlement pages and upserts the combined results', async () => { + const stripeSync = await createMockedStripeSync() + const listSpy = vi + .fn() + .mockResolvedValueOnce({ + data: [ + { + id: 'ae_1', + object: 'entitlements.active_entitlement', + feature: { id: 'feat_1' }, + livemode: false, + lookup_key: 'feature-1', + }, + ], + has_more: true, + }) + .mockResolvedValueOnce({ + data: [ + { + id: 'ae_2', + object: 'entitlements.active_entitlement', + feature: { id: 'feat_2' }, + livemode: false, + lookup_key: 'feature-2', + }, + ], + has_more: false, + }) + const upsertSpy = vi.fn().mockResolvedValue([]) + const deleteSpy = vi.fn().mockResolvedValue(undefined) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(stripeSync.webhook as any).deps.stripe.entitlements.activeEntitlements.list = listSpy + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(stripeSync.webhook as any).deps.upsertAny = upsertSpy + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(stripeSync.webhook as any).deps.postgresClient.deleteRemovedActiveEntitlements = deleteSpy + + const event = { + id: 'evt_entitlements_multipage', + type: 'entitlements.active_entitlement_summary.updated', + created: Math.floor(Date.now() / 1000), + data: { + object: { + customer: 'cus_123', + entitlements: { + data: [], + has_more: true, + }, + }, + }, + } as unknown as Stripe.Event + + await stripeSync.webhook.handleEntitlementSummaryEvent(event, 'acct_test') + + expect(listSpy).toHaveBeenNthCalledWith(1, { + customer: 'cus_123', + limit: 100, + }) + expect(listSpy).toHaveBeenNthCalledWith(2, { + customer: 'cus_123', + limit: 100, + starting_after: 'ae_1', + }) + expect(deleteSpy).toHaveBeenCalledWith('cus_123', ['ae_1', 'ae_2']) + expect(upsertSpy).toHaveBeenCalledWith( + [ + { + id: 'ae_1', + object: 'entitlements.active_entitlement', + feature: 'feat_1', + customer: 'cus_123', + livemode: false, + lookup_key: 'feature-1', + }, + { + id: 'ae_2', + object: 'entitlements.active_entitlement', + feature: 'feat_2', + customer: 'cus_123', + livemode: false, + lookup_key: 'feature-2', + }, + ], + 'acct_test', + false, + expect.any(String) + ) + }) + + it('uses the webhook body directly when has_more is false', async () => { + const stripeSync = await createMockedStripeSync() + const listSpy = vi.fn() + const upsertSpy = vi.fn().mockResolvedValue([]) + const deleteSpy = vi.fn().mockResolvedValue(undefined) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(stripeSync.webhook as any).deps.stripe.entitlements.activeEntitlements.list = listSpy + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(stripeSync.webhook as any).deps.upsertAny = upsertSpy + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(stripeSync.webhook as any).deps.postgresClient.deleteRemovedActiveEntitlements = deleteSpy + + const event = { + id: 'evt_entitlements_inline', + type: 'entitlements.active_entitlement_summary.updated', + created: Math.floor(Date.now() / 1000), + data: { + object: { + customer: 'cus_123', + entitlements: { + data: [ + { + id: 'ae_inline', + object: 'entitlements.active_entitlement', + feature: { id: 'feat_inline' }, + livemode: false, + lookup_key: 'feature-inline', + }, + ], + has_more: false, + }, + }, + }, + } as unknown as Stripe.Event + + await stripeSync.webhook.handleEntitlementSummaryEvent(event, 'acct_test') + + expect(listSpy).not.toHaveBeenCalled() + expect(deleteSpy).toHaveBeenCalledWith('cus_123', ['ae_inline']) + expect(upsertSpy).toHaveBeenCalledWith( + [ + { + id: 'ae_inline', + object: 'entitlements.active_entitlement', + feature: 'feat_inline', + customer: 'cus_123', + livemode: false, + lookup_key: 'feature-inline', + }, + ], + 'acct_test', + false, + expect.any(String) + ) + }) +})