Skip to content

Fix entitlement pagination truncation and invoice.upcoming crash#307

Open
matingathani wants to merge 1 commit intostripe:mainfrom
matingathani:issue-280-121-stripeSync-fixes
Open

Fix entitlement pagination truncation and invoice.upcoming crash#307
matingathani wants to merge 1 commit intostripe:mainfrom
matingathani:issue-280-121-stripeSync-fixes

Conversation

@matingathani
Copy link
Copy Markdown

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.updated event arrives with entitlements.has_more: true, but the handler only reads from the API when revalidateObjectsViaStripeApi contains 'entitlements'. Without that config flag the handler silently uses the truncated webhook list, deleting the entitlements that weren't included.

Fix

Check entitlements.has_more in addition to the revalidation flag. When either is true, paginate the full list from the Stripe API before upserting:

if (
  this.config.revalidateObjectsViaStripeApi?.includes('entitlements') ||
  entitlements.has_more
) {
  // paginate all pages via stripe.entitlements.activeEntitlements.list(...)
}

Issue #121invoice.upcoming crashes with NOT NULL violation

Problem

invoice.upcoming events represent preview invoices that have no id field. The case 'invoice.upcoming': fell through to the invoice.updated handler which called upsertInvoices, hitting a NOT NULL constraint on the id column and throwing an unhandled error.

Fix

Break invoice.upcoming into its own case that logs the event and returns early — preview invoices are not persistable:

case 'invoice.upcoming': {
  this.config.logger?.info(`... skipping (preview invoice has no id)`)
  break
}

Fixes #280
Fixes #121

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
Copilot AI review requested due to automatic review settings March 31, 2026 01:07
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.upcoming events to skip persistence (preview invoice has no id).
  • When handling entitlements.active_entitlement_summary.updated, refetch and paginate entitlements from the Stripe API if entitlements.has_more is 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.

Comment on lines 233 to +242
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
}
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +542 to +558
// 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)
}
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants