Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 38 additions & 9 deletions packages/sync-engine/src/stripeSyncWebhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +195 to +204
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

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

Copilot uses AI. Check for mistakes.
entitlementItems.push(...(page.data as EntitlementItem[]))
}
fetched = true
}
Comment on lines +187 to +208
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

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

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@copilot apply changes based on this feedback


const activeEntitlements = entitlementItems.map((entitlement) => ({
id: entitlement.id,
object: entitlement.object,
feature:
Expand All @@ -197,7 +226,7 @@ export class StripeSyncWebhook {
activeEntitlements,
accountId,
false,
this.getSyncTimestamp(event, false)
this.getSyncTimestamp(event, fetched)
)
}
}
Expand Down
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)
)
})
})
Loading