From 9c5c612f5bd7512292970353a59408b9c1576665 Mon Sep 17 00:00:00 2001 From: janpio Date: Tue, 19 May 2026 20:22:36 +0000 Subject: [PATCH 1/2] fix(source-stripe): include canceled subscriptions in initial backfill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stripe's /v1/subscriptions endpoint excludes canceled subscriptions by default. The list-based backfill therefore missed them entirely, and only canceled subscriptions that triggered webhook events made it into the destination database. Pass status=all on the subscriptions list endpoint so the backfill returns subscriptions in every status (including canceled). To support this, buildListFn now accepts optional extraQueryParams that are appended to every request — keeping endpoint-specific quirks like this expressible per-resource in the registry without leaking into the generic ListParams type. Fixes #336 Co-Authored-By: Claude --- .../openapi/__tests__/listFnResolver.test.ts | 34 +++++++++++++++++++ packages/openapi/listFnResolver.ts | 9 ++++- .../source-stripe/src/resourceRegistry.ts | 19 ++++++++++- 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/packages/openapi/__tests__/listFnResolver.test.ts b/packages/openapi/__tests__/listFnResolver.test.ts index b0556b185..f16744bb4 100644 --- a/packages/openapi/__tests__/listFnResolver.test.ts +++ b/packages/openapi/__tests__/listFnResolver.test.ts @@ -230,6 +230,40 @@ describe('buildListFn / buildRetrieveFn', () => { expect(parsed.searchParams.get('created[lt]')).toBe('2025-01-02T00:00:00.000Z') }) + it('appends extraQueryParams to v1 list calls', async () => { + const fetchMock = vi.fn( + async () => new Response(JSON.stringify({ data: [], has_more: false }), { status: 200 }) + ) + const list = buildListFn( + 'sk_test_fake', + '/v1/subscriptions', + fetchMock, + '2024-06-20', + undefined, + { status: 'all' } + ) + await list({ limit: 1 }) + const [url] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(new URL(url).searchParams.get('status')).toBe('all') + }) + + it('appends extraQueryParams to v2 list calls', async () => { + const fetchMock = vi.fn( + async () => new Response(JSON.stringify({ data: [], next_page_url: null }), { status: 200 }) + ) + const list = buildListFn( + 'sk_test_fake', + '/v2/core/events', + fetchMock, + '2024-06-20', + undefined, + { foo: 'bar' } + ) + await list({ limit: 1 }) + const [url] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(new URL(url).searchParams.get('foo')).toBe('bar') + }) + it('throws the Stripe error message for non-2xx retrieve responses', async () => { const fetchMock = vi.fn( async () => diff --git a/packages/openapi/listFnResolver.ts b/packages/openapi/listFnResolver.ts index 42c6d3c41..74ffc4aaf 100644 --- a/packages/openapi/listFnResolver.ts +++ b/packages/openapi/listFnResolver.ts @@ -148,7 +148,8 @@ export function buildListFn( apiPath: string, fetch: typeof globalThis.fetch, apiVersion: string, - baseUrl?: string + baseUrl?: string, + extraQueryParams?: Record ): ListFn { const base = baseUrl ?? DEFAULT_STRIPE_API_BASE @@ -162,6 +163,9 @@ export function buildListFn( if (val != null) qs.set(`created[${op}]`, toV2CreatedParam(val)) } } + if (extraQueryParams) { + for (const [k, v] of Object.entries(extraQueryParams)) qs.set(k, v) + } const headers = authHeaders(apiKey) headers['Stripe-Version'] = apiVersion @@ -193,6 +197,9 @@ export function buildListFn( if (val != null) qs.set(`created[${op}]`, String(val)) } } + if (extraQueryParams) { + for (const [k, v] of Object.entries(extraQueryParams)) qs.set(k, v) + } const headers = authHeaders(apiKey) headers['Stripe-Version'] = apiVersion diff --git a/packages/source-stripe/src/resourceRegistry.ts b/packages/source-stripe/src/resourceRegistry.ts index 7c4bc36b9..c649ce5e5 100644 --- a/packages/source-stripe/src/resourceRegistry.ts +++ b/packages/source-stripe/src/resourceRegistry.ts @@ -100,6 +100,16 @@ export const EXCLUDED_TABLES = new Set([ 'billing_credit_balance_transaction', ]) +/** + * Per-table extra query parameters to pass on every list call. + * Some Stripe list endpoints filter results unless an explicit status is sent. + * For example, /v1/subscriptions excludes canceled subscriptions by default, + * so the initial backfill misses them entirely (issue #336). + */ +const LIST_EXTRA_QUERY_PARAMS: Record> = { + subscription: { status: 'all' }, +} + export function buildResourceRegistry( spec: OpenApiSpec, apiKey: string, @@ -130,7 +140,14 @@ export function buildResourceRegistry( supportsPagination: n.supportsPagination, })) - const rawListFn = buildListFn(apiKey, endpoint.apiPath, apiFetch, apiVersion, baseUrl) + const rawListFn = buildListFn( + apiKey, + endpoint.apiPath, + apiFetch, + apiVersion, + baseUrl, + LIST_EXTRA_QUERY_PARAMS[tableName] + ) const rawRetrieveFn = buildRetrieveFn(apiKey, endpoint.apiPath, apiFetch, apiVersion, baseUrl) const config: ResourceConfig = { From da890f8c7165f496056c2607f9689ec65a0d71a3 Mon Sep 17 00:00:00 2001 From: janpio Date: Tue, 19 May 2026 20:52:51 +0000 Subject: [PATCH 2/2] fix(source-stripe): include canceled subscription schedules in backfill Stripe's /v1/subscription_schedules endpoint defaults to scope=not_canceled, mirroring the issue #336 problem on /v1/subscriptions. Pass scope=all so canceled schedules are picked up by the initial backfill too. Also add resourceRegistry tests that lock in the wiring for both subscription (status=all) and subscription_schedule (scope=all). Co-Authored-By: Claude --- .../src/resourceRegistry.test.ts | 104 +++++++++++++++++- .../source-stripe/src/resourceRegistry.ts | 1 + 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/packages/source-stripe/src/resourceRegistry.test.ts b/packages/source-stripe/src/resourceRegistry.test.ts index 621897afa..59bc19665 100644 --- a/packages/source-stripe/src/resourceRegistry.test.ts +++ b/packages/source-stripe/src/resourceRegistry.test.ts @@ -1,7 +1,75 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { OpenApiSpec } from '@stripe/sync-openapi' import { buildResourceRegistry } from './resourceRegistry.js' +const subscriptionSpec: OpenApiSpec = { + openapi: '3.0.0', + paths: { + '/v1/subscriptions': { + get: { + parameters: [{ name: 'limit', in: 'query' }], + responses: { + '200': { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + object: { type: 'string', enum: ['list'] }, + data: { + type: 'array', + items: { $ref: '#/components/schemas/subscription' }, + }, + has_more: { type: 'boolean' }, + }, + }, + }, + }, + }, + }, + }, + }, + '/v1/subscription_schedules': { + get: { + parameters: [{ name: 'limit', in: 'query' }], + responses: { + '200': { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + object: { type: 'string', enum: ['list'] }, + data: { + type: 'array', + items: { $ref: '#/components/schemas/subscription_schedule' }, + }, + has_more: { type: 'boolean' }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + subscription: { + 'x-resourceId': 'subscription', + type: 'object', + properties: { id: { type: 'string' } }, + }, + subscription_schedule: { + 'x-resourceId': 'subscription_schedule', + type: 'object', + properties: { id: { type: 'string' } }, + }, + }, + }, +} + const v2CreatedSpec: OpenApiSpec = { openapi: '3.0.0', paths: { @@ -95,4 +163,38 @@ describe('buildResourceRegistry', () => { expect(registry.v2_core_account?.supportsCreatedFilter).toBe(false) expect(registry.v2_core_event?.supportsCreatedFilter).toBe(true) }) + + describe('list extra query params (issue #336)', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('passes status=all when listing subscriptions so canceled rows are included', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue( + new Response(JSON.stringify({ data: [], has_more: false }), { status: 200 }) + ) + + const registry = buildResourceRegistry(subscriptionSpec, 'sk_test_fake', '2026-03-25.dahlia') + await registry.subscription!.listFn!({ limit: 10 }) + + const [url] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(new URL(url).searchParams.get('status')).toBe('all') + }) + + it('passes scope=all when listing subscription schedules so canceled rows are included', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue( + new Response(JSON.stringify({ data: [], has_more: false }), { status: 200 }) + ) + + const registry = buildResourceRegistry(subscriptionSpec, 'sk_test_fake', '2026-03-25.dahlia') + await registry.subscription_schedule!.listFn!({ limit: 10 }) + + const [url] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(new URL(url).searchParams.get('scope')).toBe('all') + }) + }) }) diff --git a/packages/source-stripe/src/resourceRegistry.ts b/packages/source-stripe/src/resourceRegistry.ts index c649ce5e5..5675617e6 100644 --- a/packages/source-stripe/src/resourceRegistry.ts +++ b/packages/source-stripe/src/resourceRegistry.ts @@ -108,6 +108,7 @@ export const EXCLUDED_TABLES = new Set([ */ const LIST_EXTRA_QUERY_PARAMS: Record> = { subscription: { status: 'all' }, + subscription_schedule: { scope: 'all' }, } export function buildResourceRegistry(