Fix entitlement pagination truncation and invoice.upcoming crash#307
Fix entitlement pagination truncation and invoice.upcoming crash#307matingathani wants to merge 1 commit intostripe:mainfrom
Conversation
Two separate issues addressed in stripeSync.ts: **Issue stripe#280 — entitlements truncated at 10 items** The entitlements.active_entitlement_summary.updated handler only read entitlements from the API when revalidateObjectsViaStripeApi included 'entitlements'. Stripe webhook payloads are capped at 10 items per nested list, so customers with more than 10 entitlements would have the excess silently dropped during a sync. Fix: also paginate from the API when the webhook payload itself reports has_more: true, collecting all pages before upserting. **Issue stripe#121 — invoice.upcoming crashes on null id** invoice.upcoming events carry a preview invoice object with no id field. The case fell through to the invoice.updated handler which passed the object to fetchOrUseWebhookData and then upsertInvoices, ultimately hitting a NOT NULL constraint violation in Postgres. Fix: break out invoice.upcoming into its own case that logs the event and returns early without attempting any DB write. Fixes stripe#280 Fixes stripe#121
There was a problem hiding this comment.
Pull request overview
Fixes two webhook-handling edge cases in the Stripe Sync Engine: (1) active entitlements lists being truncated to 10 items in webhook payloads, and (2) invoice.upcoming preview invoices causing persistence crashes due to missing IDs.
Changes:
- Add special handling for
invoice.upcomingevents to skip persistence (preview invoice has noid). - When handling
entitlements.active_entitlement_summary.updated, refetch and paginate entitlements from the Stripe API ifentitlements.has_moreis true (or if revalidation is configured), preventing accidental deletion of entitlements beyond the first 10.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| case 'invoice.payment_action_required': | ||
| case 'invoice.payment_failed': | ||
| case 'invoice.payment_succeeded': | ||
| case 'invoice.upcoming': | ||
| case 'invoice.upcoming': { | ||
| // invoice.upcoming is a preview invoice with no id — it cannot be persisted. | ||
| this.config.logger?.info( | ||
| `Received webhook ${event.id}: ${event.type} — skipping (preview invoice has no id)` | ||
| ) | ||
| break | ||
| } |
There was a problem hiding this comment.
Because this case 'invoice.upcoming' now contains executable statements and a break, every preceding invoice.* case label (invoice.created/deleted/finalized/etc.) will fall through into this block and be skipped. This changes behavior for all invoice webhook types, not just invoice.upcoming. Move the invoice.upcoming handler to its own case position before the shared invoice cases (or otherwise restructure so only invoice.upcoming hits this break).
| // Fetch all pages from the API — the webhook payload is capped at 10 items | ||
| // and has_more signals that there are more entitlements than were included. | ||
| const allData: Stripe.Entitlements.ActiveEntitlement[] = [] | ||
| let page = await this.stripe.entitlements.activeEntitlements.list({ | ||
| customer: activeEntitlementSummary.customer, | ||
| }) | ||
| entitlements = rest | ||
| limit: 100, | ||
| } as Stripe.Entitlements.ActiveEntitlementListParams) | ||
| allData.push(...page.data) | ||
| while (page.has_more) { | ||
| const lastId = page.data[page.data.length - 1].id | ||
| page = await this.stripe.entitlements.activeEntitlements.list({ | ||
| customer: activeEntitlementSummary.customer, | ||
| limit: 100, | ||
| starting_after: lastId, | ||
| } as Stripe.Entitlements.ActiveEntitlementListParams) | ||
| allData.push(...page.data) | ||
| } |
There was a problem hiding this comment.
This manual pagination duplicates existing auto-pagination patterns in this file (e.g., for await (...) in expandEntity / fillCheckoutSessionsLineItems) and is more error-prone to maintain. Consider switching to Stripe's async-iterable auto-pagination (for await (const entitlement of this.stripe.entitlements.activeEntitlements.list({ ... }))) to avoid managing starting_after/lastId yourself and keep pagination logic consistent across the codebase.
Issue #280 — Active entitlements truncated at 10 items
Problem
Stripe webhook payloads cap nested lists at 10 items. When a customer has more than 10 active entitlements the
entitlements.active_entitlement_summary.updatedevent arrives withentitlements.has_more: true, but the handler only reads from the API whenrevalidateObjectsViaStripeApicontains'entitlements'. Without that config flag the handler silently uses the truncated webhook list, deleting the entitlements that weren't included.Fix
Check
entitlements.has_morein addition to the revalidation flag. When either is true, paginate the full list from the Stripe API before upserting:Issue #121 —
invoice.upcomingcrashes with NOT NULL violationProblem
invoice.upcomingevents represent preview invoices that have noidfield. Thecase 'invoice.upcoming':fell through to theinvoice.updatedhandler which calledupsertInvoices, hitting aNOT NULLconstraint on theidcolumn and throwing an unhandled error.Fix
Break
invoice.upcominginto its own case that logs the event and returns early — preview invoices are not persistable:Fixes #280
Fixes #121