From b6ce7c334a454d5869f0c7c6d37d0cd20ad9ee4f Mon Sep 17 00:00:00 2001 From: Yostra Date: Fri, 6 Mar 2026 23:38:18 +0100 Subject: [PATCH 1/2] coupon support --- packages/sync-engine/src/resourceRegistry.ts | 9 +++++++++ packages/sync-engine/src/stripeSyncWebhook.ts | 1 + packages/sync-engine/src/types.ts | 3 +++ 3 files changed, 13 insertions(+) diff --git a/packages/sync-engine/src/resourceRegistry.ts b/packages/sync-engine/src/resourceRegistry.ts index b430fcff..65bee273 100644 --- a/packages/sync-engine/src/resourceRegistry.ts +++ b/packages/sync-engine/src/resourceRegistry.ts @@ -33,6 +33,15 @@ const RESOURCE_MAP: Record = { supportsCreatedFilter: true, sync: true, }, + coupon: { + order: 1, + tableName: 'coupons', + list: (s) => (p) => s.coupons.list(p), + retrieve: (s) => (id) => s.coupons.retrieve(id), + supportsCreatedFilter: true, + sync: true, + isFinalState: (c: Stripe.Coupon | Stripe.DeletedCoupon) => 'deleted' in c && c.deleted === true, + }, price: { order: 2, tableName: 'prices', diff --git a/packages/sync-engine/src/stripeSyncWebhook.ts b/packages/sync-engine/src/stripeSyncWebhook.ts index 9396a5a9..5fb26458 100644 --- a/packages/sync-engine/src/stripeSyncWebhook.ts +++ b/packages/sync-engine/src/stripeSyncWebhook.ts @@ -183,6 +183,7 @@ export class StripeSyncWebhook { 'price.deleted', 'plan.deleted', 'invoice.deleted', + 'coupon.deleted', 'customer.tax_id.deleted', ]) diff --git a/packages/sync-engine/src/types.ts b/packages/sync-engine/src/types.ts index 574eb4df..b5f3945a 100644 --- a/packages/sync-engine/src/types.ts +++ b/packages/sync-engine/src/types.ts @@ -111,6 +111,9 @@ export const SUPPORTED_WEBHOOK_EVENTS: Stripe.WebhookEndpointCreateParams.Enable 'customer.deleted', 'customer.created', 'customer.updated', + 'coupon.created', + 'coupon.deleted', + 'coupon.updated', 'checkout.session.async_payment_failed', 'checkout.session.async_payment_succeeded', 'checkout.session.completed', From 00053dfa2d1f62c8181e9eb687c52503f4471388 Mon Sep 17 00:00:00 2001 From: Yostra Date: Fri, 6 Mar 2026 23:50:27 +0100 Subject: [PATCH 2/2] coupon test --- .../stripeSync-integration.test.ts | 108 +++++++++++++++++- packages/sync-engine/src/tests/testSetup.ts | 19 +++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/packages/sync-engine/src/tests/integration/stripeSync-integration.test.ts b/packages/sync-engine/src/tests/integration/stripeSync-integration.test.ts index fcd0403a..da43e9f1 100644 --- a/packages/sync-engine/src/tests/integration/stripeSync-integration.test.ts +++ b/packages/sync-engine/src/tests/integration/stripeSync-integration.test.ts @@ -5,6 +5,7 @@ import { upsertTestAccount, resetMockCounters, createMockCustomerBatch, + createMockCouponBatch, createPaginatedResponse, DatabaseValidator, type MockStripeObject, @@ -19,6 +20,7 @@ describe('StripeSync Integration Tests', () => { let db: TestDatabase let validator: DatabaseValidator let mockCustomers: MockStripeObject[] = [] + let mockCoupons: MockStripeObject[] = [] beforeAll(async () => { db = await setupTestDatabase() @@ -36,8 +38,13 @@ describe('StripeSync Integration Tests', () => { resetMockCounters() mockCustomers = [] + mockCoupons = [] - await validator.clearAccountData(TEST_ACCOUNT_ID, ['stripe.customers', 'stripe.plans']) + await validator.clearAccountData(TEST_ACCOUNT_ID, [ + 'stripe.customers', + 'stripe.plans', + 'stripe.coupons', + ]) sync = await createTestStripeSync({ databaseUrl: db.databaseUrl, @@ -59,6 +66,20 @@ describe('StripeSync Integration Tests', () => { Promise.resolve(mockCustomers.find((c) => c.id === id) ?? null) ), } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(sync.stripe as any).coupons = { + list: vi + .fn() + .mockImplementation((params) => + Promise.resolve(createPaginatedResponse(mockCoupons, params)) + ), + retrieve: vi + .fn() + .mockImplementation((id: string) => + Promise.resolve(mockCoupons.find((c) => c.id === id) ?? null) + ), + } }) it('should have validator connected to database', async () => { @@ -142,4 +163,89 @@ describe('StripeSync Integration Tests', () => { ) }) }) + + describe('coupon fullSync', () => { + it('should sync all coupons via fullSync', async () => { + mockCoupons = createMockCouponBatch(150) + + const result = await sync.fullSync(['coupon'], true, 1, 50, false) + + expect(result.totalSynced).toStrictEqual(150) + + const countInDb = await validator.getRowCount('stripe.coupons', TEST_ACCOUNT_ID) + expect(countInDb).toStrictEqual(150) + + const couponsInDb = await validator.getColumnValues('stripe.coupons', 'id', TEST_ACCOUNT_ID) + expect(couponsInDb).toStrictEqual(mockCoupons.map((c) => c.id)) + }) + + it('should sync new coupons for incremental consistency', async () => { + await sync.fullSync(['coupon'], true, 2, 50, false) + + mockCoupons = createMockCouponBatch(50) + + const result = await sync.fullSync(['coupon'], true, 2, 50, false, 0) + + expect(result.totalSynced).toStrictEqual(50) + + const countInDb = await validator.getRowCount('stripe.coupons', TEST_ACCOUNT_ID) + expect(countInDb).toStrictEqual(50) + + const couponsInDb = await validator.getColumnValues('stripe.coupons', 'id', TEST_ACCOUNT_ID) + expect(couponsInDb).toStrictEqual(mockCoupons.map((c) => c.id)) + }) + + it('should backfill historical coupons and then pick up new coupons on next sync', async () => { + const historicalStartTimestamp = Math.floor(Date.now() / 1000) - 10000 + const historicalCoupons = createMockCouponBatch(100, historicalStartTimestamp) + mockCoupons = historicalCoupons + + const result = await sync.fullSync(['coupon'], true, 2, 50, false, 0) + expect(result.totalSynced).toStrictEqual(100) + + let countInDb = await validator.getRowCount('stripe.coupons', TEST_ACCOUNT_ID) + expect(countInDb).toStrictEqual(100) + + const newStartTimestamp = Math.floor(Date.now() / 1000) + const newCoupons = createMockCouponBatch(3, newStartTimestamp) + mockCoupons = [...newCoupons, ...mockCoupons] + + const couponsAfterBackfill = await validator.getColumnValues( + 'stripe.coupons', + 'id', + TEST_ACCOUNT_ID + ) + expect(couponsAfterBackfill).toStrictEqual(historicalCoupons.map((c) => c.id)) + + await sync.fullSync(['coupon'], true, 2, 50, false, 0) + + countInDb = await validator.getRowCount('stripe.coupons', TEST_ACCOUNT_ID) + expect(countInDb).toStrictEqual(103) + + const couponsAfterIncremental = await validator.getColumnValues( + 'stripe.coupons', + 'id', + TEST_ACCOUNT_ID + ) + expect(couponsAfterIncremental).toStrictEqual( + [...historicalCoupons, ...newCoupons].map((c) => c.id) + ) + }) + + it('should handle deleted coupons during sync', async () => { + mockCoupons = createMockCouponBatch(10) + + await sync.fullSync(['coupon'], true, 1, 50, false) + + const countInDb = await validator.getRowCount('stripe.coupons', TEST_ACCOUNT_ID) + expect(countInDb).toStrictEqual(10) + + mockCoupons = mockCoupons.map((c, i) => (i < 3 ? { ...c, deleted: true } : c)) + + await sync.fullSync(['coupon'], true, 1, 50, false, 0) + + const finalCount = await validator.getRowCount('stripe.coupons', TEST_ACCOUNT_ID) + expect(finalCount).toStrictEqual(10) + }) + }) }) diff --git a/packages/sync-engine/src/tests/testSetup.ts b/packages/sync-engine/src/tests/testSetup.ts index 943c8b44..0e32aa12 100644 --- a/packages/sync-engine/src/tests/testSetup.ts +++ b/packages/sync-engine/src/tests/testSetup.ts @@ -226,10 +226,12 @@ type MockStripeObject = { id: string; created: number; [key: string]: unknown } let customerIdCounter = 0 let planIdCounter = 0 +let couponIdCounter = 0 export function resetMockCounters(): void { customerIdCounter = 0 planIdCounter = 0 + couponIdCounter = 0 } export function createMockCustomer( @@ -254,6 +256,23 @@ export function createMockPlan( } } +export function createMockCoupon( + overrides: { id?: string; created?: number; deleted?: boolean } = {} +): MockStripeObject { + couponIdCounter++ + return { + id: overrides.id ?? `coupon_test_${couponIdCounter.toString().padStart(6, '0')}`, + object: 'coupon', + created: overrides.created ?? Math.floor(Date.now() / 1000) - couponIdCounter, + ...(overrides.deleted != null ? { deleted: overrides.deleted } : {}), + } +} + +export function createMockCouponBatch(count: number, startTimestamp?: number): MockStripeObject[] { + const baseTimestamp = startTimestamp ?? Math.floor(Date.now() / 1000) + return Array.from({ length: count }, (_, i) => createMockCoupon({ created: baseTimestamp - i })) +} + export function createMockCustomerBatch( count: number, startTimestamp?: number