fix(webhook): paginate entitlements when has_more=true in summary event#197
fix(webhook): paginate entitlements when has_more=true in summary event#197matingathani wants to merge 1 commit intostripe:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Fixes entitlements.active_entitlement_summary.updated webhook handling so customers with >10 active entitlements are fully processed by paginating via the Stripe API when the webhook payload indicates has_more: true.
Changes:
- Adds a pagination path that fetches all active entitlements for the event’s customer when the webhook payload is truncated (
has_more: true). - Uses a “fresh” sync timestamp (current time) when entitlements were fetched from Stripe instead of relying on
event.created.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| while (page.has_more) { | ||
| 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) |
There was a problem hiding this comment.
Pagination loop assumes page.data is non-empty when page.has_more is true. If Stripe ever returns an empty page with has_more: true (or a filtering edge case yields data.length === 0), page.data[page.data.length - 1].id will throw and webhook processing will fail. Add a guard before computing lastId (e.g., break/throw with a clear error when page.data.length === 0) and only set starting_after when a last item exists (similar to the existing manual pagination pattern in tests).
| 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) { | ||
| 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 | ||
| } |
There was a problem hiding this comment.
This change introduces a new code path that calls the Stripe API and manually paginates entitlements when summary.entitlements.has_more is true, but there are no unit tests covering it. Add tests to verify: (1) activeEntitlements.list is called when has_more: true, (2) multiple pages are fetched and all entitlements are upserted, and (3) has_more: false does not trigger API calls (uses webhook body).
Summary
entitlements.active_entitlement_summary.updatedwebhook events include at most 10 entitlements in the event body. When a customer has more than 10 active entitlements, the webhook body setshas_more: truebut the handler only processed the embedded page, silently dropping the rest.Root cause:
handleEntitlementSummaryEvent()only mappedsummary.entitlements.datadirectly, with no pagination path.Fix: When
summary.entitlements.has_more === true, fetch the complete list of active entitlements for the customer from the Stripe API (paginating withstarting_afteruntilhas_moreis false). Use fresh timestamp when fetched from API.Test plan
has_more: trueand verifystripe.entitlements.activeEntitlements.listis called withcustomerfilterhas_more: falsestill uses webhook body data directly (no API call)Closes #118