diff --git a/packages/openapi/__tests__/listFnResolver.test.ts b/packages/openapi/__tests__/listFnResolver.test.ts index b0556b18..f16744bb 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 42c6d3c4..74ffc4aa 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.test.ts b/packages/source-stripe/src/resourceRegistry.test.ts index 621897af..59bc1966 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 7c4bc36b..5675617e 100644 --- a/packages/source-stripe/src/resourceRegistry.ts +++ b/packages/source-stripe/src/resourceRegistry.ts @@ -100,6 +100,17 @@ 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' }, + subscription_schedule: { scope: 'all' }, +} + export function buildResourceRegistry( spec: OpenApiSpec, apiKey: string, @@ -130,7 +141,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 = {