Skip to content

Commit 5c9d335

Browse files
authored
feat: fetch stripe entity automatically (#139)
1 parent 53cb3d2 commit 5c9d335

File tree

4 files changed

+119
-24
lines changed

4 files changed

+119
-24
lines changed

packages/fastify-app/.env.sample

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,7 @@ BACKFILL_RELATED_ENTITIES=true
3232
# optional, default 10
3333
# Max number of connections for the Postgres connection pool, higher value lead to more concurrent queries, but also more load on the database (connections are expensive)
3434
MAX_POSTGRES_CONNECTIONS=20
35+
36+
# If true, the webhook data is not used and instead the webhook is just a trigger to fetch the entity from Stripe again. This ensures that a race condition with failed webhooks can never accidentally overwrite the data with an older state.
37+
# Default: false
38+
REVALIDATE_ENTITY_VIA_STRIPE_API=false

packages/fastify-app/src/utils/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export type StripeSyncServerConfig = {
4141

4242
maxPostgresConnections?: number
4343

44+
revalidateEntityViaStripeApi: boolean
45+
4446
port: number
4547
}
4648

@@ -58,5 +60,7 @@ export function getConfig(): StripeSyncServerConfig {
5860
autoExpandLists: getConfigFromEnv('AUTO_EXPAND_LISTS', 'false') === 'true',
5961
backfillRelatedEntities: getConfigFromEnv('BACKFILL_RELATED_ENTITIES', 'true') === 'true',
6062
maxPostgresConnections: Number(getConfigFromEnv('MAX_POSTGRES_CONNECTIONS', '10')),
63+
revalidateEntityViaStripeApi:
64+
getConfigFromEnv('REVALIDATE_ENTITY_VIA_STRIPE_API', 'false') === 'true',
6165
}
6266
}

packages/sync-engine/src/stripeSync.ts

Lines changed: 104 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,9 @@ export class StripeSync {
7272
case 'charge.refunded':
7373
case 'charge.succeeded':
7474
case 'charge.updated': {
75-
const charge = event.data.object as Stripe.Charge
75+
const charge = await this.fetchOrUseWebhookData(event.data.object as Stripe.Charge, (id) =>
76+
this.stripe.charges.retrieve(id)
77+
)
7678

7779
this.config.logger?.info(
7880
`Received webhook ${event.id}: ${event.type} for charge ${charge.id}`
@@ -84,7 +86,10 @@ export class StripeSync {
8486
case 'customer.created':
8587
case 'customer.deleted':
8688
case 'customer.updated': {
87-
const customer = event.data.object as Stripe.Customer
89+
const customer = await this.fetchOrUseWebhookData(
90+
event.data.object as Stripe.Customer | Stripe.DeletedCustomer,
91+
(id) => this.stripe.customers.retrieve(id)
92+
)
8893

8994
this.config.logger?.info(
9095
`Received webhook ${event.id}: ${event.type} for customer ${customer.id}`
@@ -101,7 +106,10 @@ export class StripeSync {
101106
case 'customer.subscription.trial_will_end':
102107
case 'customer.subscription.resumed':
103108
case 'customer.subscription.updated': {
104-
const subscription = event.data.object as Stripe.Subscription
109+
const subscription = await this.fetchOrUseWebhookData(
110+
event.data.object as Stripe.Subscription,
111+
(id) => this.stripe.subscriptions.retrieve(id)
112+
)
105113

106114
this.config.logger?.info(
107115
`Received webhook ${event.id}: ${event.type} for subscription ${subscription.id}`
@@ -112,7 +120,9 @@ export class StripeSync {
112120
}
113121
case 'customer.tax_id.updated':
114122
case 'customer.tax_id.created': {
115-
const taxId = event.data.object as Stripe.TaxId
123+
const taxId = await this.fetchOrUseWebhookData(event.data.object as Stripe.TaxId, (id) =>
124+
this.stripe.taxIds.retrieve(id)
125+
)
116126

117127
this.config.logger?.info(
118128
`Received webhook ${event.id}: ${event.type} for taxId ${taxId.id}`
@@ -144,7 +154,10 @@ export class StripeSync {
144154
case 'invoice.voided':
145155
case 'invoice.marked_uncollectible':
146156
case 'invoice.updated': {
147-
const invoice = event.data.object as Stripe.Invoice
157+
const invoice = await this.fetchOrUseWebhookData(
158+
event.data.object as Stripe.Invoice,
159+
(id) => this.stripe.invoices.retrieve(id)
160+
)
148161

149162
this.config.logger?.info(
150163
`Received webhook ${event.id}: ${event.type} for invoice ${invoice.id}`
@@ -155,13 +168,25 @@ export class StripeSync {
155168
}
156169
case 'product.created':
157170
case 'product.updated': {
158-
const product = event.data.object as Stripe.Product
171+
try {
172+
const product = await this.fetchOrUseWebhookData(
173+
event.data.object as Stripe.Product,
174+
(id) => this.stripe.products.retrieve(id)
175+
)
159176

160-
this.config.logger?.info(
161-
`Received webhook ${event.id}: ${event.type} for product ${product.id}`
162-
)
177+
this.config.logger?.info(
178+
`Received webhook ${event.id}: ${event.type} for product ${product.id}`
179+
)
180+
181+
await this.upsertProducts([product])
182+
} catch (err) {
183+
if (err instanceof Stripe.errors.StripeAPIError && err.code === 'resource_missing') {
184+
await this.deleteProduct(event.data.object.id)
185+
} else {
186+
throw err
187+
}
188+
}
163189

164-
await this.upsertProducts([product])
165190
break
166191
}
167192
case 'product.deleted': {
@@ -176,13 +201,24 @@ export class StripeSync {
176201
}
177202
case 'price.created':
178203
case 'price.updated': {
179-
const price = event.data.object as Stripe.Price
204+
try {
205+
const price = await this.fetchOrUseWebhookData(event.data.object as Stripe.Price, (id) =>
206+
this.stripe.prices.retrieve(id)
207+
)
180208

181-
this.config.logger?.info(
182-
`Received webhook ${event.id}: ${event.type} for price ${price.id}`
183-
)
209+
this.config.logger?.info(
210+
`Received webhook ${event.id}: ${event.type} for price ${price.id}`
211+
)
212+
213+
await this.upsertPrices([price])
214+
} catch (err) {
215+
if (err instanceof Stripe.errors.StripeAPIError && err.code === 'resource_missing') {
216+
await this.deletePrice(event.data.object.id)
217+
} else {
218+
throw err
219+
}
220+
}
184221

185-
await this.upsertPrices([price])
186222
break
187223
}
188224
case 'price.deleted': {
@@ -197,11 +233,24 @@ export class StripeSync {
197233
}
198234
case 'plan.created':
199235
case 'plan.updated': {
200-
const plan = event.data.object as Stripe.Plan
236+
try {
237+
const plan = await this.fetchOrUseWebhookData(event.data.object as Stripe.Plan, (id) =>
238+
this.stripe.plans.retrieve(id)
239+
)
201240

202-
this.config.logger?.info(`Received webhook ${event.id}: ${event.type} for plan ${plan.id}`)
241+
this.config.logger?.info(
242+
`Received webhook ${event.id}: ${event.type} for plan ${plan.id}`
243+
)
244+
245+
await this.upsertPlans([plan])
246+
} catch (err) {
247+
if (err instanceof Stripe.errors.StripeAPIError && err.code === 'resource_missing') {
248+
await this.deletePlan(event.data.object.id)
249+
} else {
250+
throw err
251+
}
252+
}
203253

204-
await this.upsertPlans([plan])
205254
break
206255
}
207256
case 'plan.deleted': {
@@ -217,7 +266,10 @@ export class StripeSync {
217266
case 'setup_intent.requires_action':
218267
case 'setup_intent.setup_failed':
219268
case 'setup_intent.succeeded': {
220-
const setupIntent = event.data.object as Stripe.SetupIntent
269+
const setupIntent = await this.fetchOrUseWebhookData(
270+
event.data.object as Stripe.SetupIntent,
271+
(id) => this.stripe.setupIntents.retrieve(id)
272+
)
221273

222274
this.config.logger?.info(
223275
`Received webhook ${event.id}: ${event.type} for setupIntent ${setupIntent.id}`
@@ -233,7 +285,10 @@ export class StripeSync {
233285
case 'subscription_schedule.expiring':
234286
case 'subscription_schedule.released':
235287
case 'subscription_schedule.updated': {
236-
const subscriptionSchedule = event.data.object as Stripe.SubscriptionSchedule
288+
const subscriptionSchedule = await this.fetchOrUseWebhookData(
289+
event.data.object as Stripe.SubscriptionSchedule,
290+
(id) => this.stripe.subscriptionSchedules.retrieve(id)
291+
)
237292

238293
this.config.logger?.info(
239294
`Received webhook ${event.id}: ${event.type} for subscriptionSchedule ${subscriptionSchedule.id}`
@@ -246,7 +301,10 @@ export class StripeSync {
246301
case 'payment_method.automatically_updated':
247302
case 'payment_method.detached':
248303
case 'payment_method.updated': {
249-
const paymentMethod = event.data.object as Stripe.PaymentMethod
304+
const paymentMethod = await this.fetchOrUseWebhookData(
305+
event.data.object as Stripe.PaymentMethod,
306+
(id) => this.stripe.paymentMethods.retrieve(id)
307+
)
250308

251309
this.config.logger?.info(
252310
`Received webhook ${event.id}: ${event.type} for paymentMethod ${paymentMethod.id}`
@@ -260,7 +318,10 @@ export class StripeSync {
260318
case 'charge.dispute.funds_withdrawn':
261319
case 'charge.dispute.updated':
262320
case 'charge.dispute.closed': {
263-
const dispute = event.data.object as Stripe.Dispute
321+
const dispute = await this.fetchOrUseWebhookData(
322+
event.data.object as Stripe.Dispute,
323+
(id) => this.stripe.disputes.retrieve(id)
324+
)
264325

265326
this.config.logger?.info(
266327
`Received webhook ${event.id}: ${event.type} for dispute ${dispute.id}`
@@ -277,7 +338,10 @@ export class StripeSync {
277338
case 'payment_intent.processing':
278339
case 'payment_intent.requires_action':
279340
case 'payment_intent.succeeded': {
280-
const paymentIntent = event.data.object as Stripe.PaymentIntent
341+
const paymentIntent = await this.fetchOrUseWebhookData(
342+
event.data.object as Stripe.PaymentIntent,
343+
(id) => this.stripe.paymentIntents.retrieve(id)
344+
)
281345

282346
this.config.logger?.info(
283347
`Received webhook ${event.id}: ${event.type} for paymentIntent ${paymentIntent.id}`
@@ -290,7 +354,10 @@ export class StripeSync {
290354
case 'credit_note.created':
291355
case 'credit_note.updated':
292356
case 'credit_note.voided': {
293-
const creditNote = event.data.object as Stripe.CreditNote
357+
const creditNote = await this.fetchOrUseWebhookData(
358+
event.data.object as Stripe.CreditNote,
359+
(id) => this.stripe.creditNotes.retrieve(id)
360+
)
294361

295362
this.config.logger?.info(
296363
`Received webhook ${event.id}: ${event.type} for creditNote ${creditNote.id}`
@@ -305,6 +372,19 @@ export class StripeSync {
305372
}
306373
}
307374

375+
private async fetchOrUseWebhookData<T extends { id?: string }>(
376+
entity: T,
377+
fetchFn: (id: string) => Promise<T>
378+
): Promise<T> {
379+
if (!entity.id) return entity
380+
381+
if (this.config.revalidateEntityViaStripeApi) {
382+
return fetchFn(entity.id)
383+
}
384+
385+
return entity
386+
}
387+
308388
async syncSingleEntity(stripeId: string) {
309389
if (stripeId.startsWith('cus_')) {
310390
return this.stripe.customers.retrieve(stripeId).then((it) => {

packages/sync-engine/src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ export type StripeSyncConfig = {
2828
*/
2929
backfillRelatedEntities?: boolean
3030

31+
/**
32+
* If true, the webhook data is not used and instead the webhook is just a trigger to fetch the entity from Stripe again. This ensures that a race condition with failed webhooks can never accidentally overwrite the data with an older state.
33+
*
34+
* Default: false
35+
*/
36+
revalidateEntityViaStripeApi?: boolean
37+
3138
maxPostgresConnections?: number
3239

3340
logger?: pino.Logger

0 commit comments

Comments
 (0)