From 2acba1c0a1716cba8e31c40decf22cc833765e24 Mon Sep 17 00:00:00 2001 From: Matin Gathani Date: Mon, 30 Mar 2026 18:05:45 -0700 Subject: [PATCH] Fix entitlement pagination and skip invoice.upcoming events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two separate issues addressed in stripeSync.ts: **Issue #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 #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 #280 Fixes #121 --- packages/sync-engine/src/stripeSync.ts | 34 +++++++++++++++++++++----- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/sync-engine/src/stripeSync.ts b/packages/sync-engine/src/stripeSync.ts index 441bc90d4..874fe5a52 100644 --- a/packages/sync-engine/src/stripeSync.ts +++ b/packages/sync-engine/src/stripeSync.ts @@ -233,7 +233,13 @@ export class StripeSync { 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 + } case 'invoice.sent': case 'invoice.voided': case 'invoice.marked_uncollectible': @@ -529,12 +535,28 @@ export class StripeSync { .object as Stripe.Entitlements.ActiveEntitlementSummary let entitlements = activeEntitlementSummary.entitlements let refetched = false - if (this.config.revalidateObjectsViaStripeApi?.includes('entitlements')) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { lastResponse, ...rest } = await this.stripe.entitlements.activeEntitlements.list({ + if ( + this.config.revalidateObjectsViaStripeApi?.includes('entitlements') || + entitlements.has_more + ) { + // 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) + } + entitlements = { ...entitlements, data: allData, has_more: false } refetched = true }