-
Notifications
You must be signed in to change notification settings - Fork 14
fix(webhook): paginate entitlements when has_more=true in summary event #197
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -165,20 +165,49 @@ export class StripeSyncWebhook { | |
| } | ||
|
|
||
| async handleEntitlementSummaryEvent(event: Stripe.Event, accountId: string): Promise<void> { | ||
| 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 | ||
| } | ||
|
Comment on lines
+187
to
+208
|
||
|
|
||
| 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) | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| ) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pagination loop assumes
page.datais non-empty whenpage.has_moreis true. If Stripe ever returns an empty page withhas_more: true(or a filtering edge case yieldsdata.length === 0),page.data[page.data.length - 1].idwill throw and webhook processing will fail. Add a guard before computinglastId(e.g., break/throw with a clear error whenpage.data.length === 0) and only setstarting_afterwhen a last item exists (similar to the existing manual pagination pattern in tests).