@@ -121,6 +121,198 @@ async function clearOrganizationCache(organizationId: string) {
121121 }
122122}
123123
124+ type PolarEvent = ReturnType < typeof validatePolarEvent > ;
125+ type PolarSubscriptionData = Extract <
126+ PolarEvent ,
127+ { type : 'subscription.updated' }
128+ > [ 'data' ] ;
129+
130+ const subscriptionMetadataSchema = z . object ( {
131+ organizationId : z . string ( ) ,
132+ // `userId` is only used for the `subscriptionCreatedByUserId` audit field, so
133+ // it is optional — a missing one must not block syncing the subscription.
134+ userId : z . string ( ) . optional ( ) ,
135+ } ) ;
136+
137+ // Org columns whose before→after transition is logged on every sync.
138+ const TRACKED_SUBSCRIPTION_FIELDS = [
139+ 'subscriptionId' ,
140+ 'subscriptionStatus' ,
141+ 'subscriptionProductId' ,
142+ 'subscriptionPriceId' ,
143+ 'subscriptionStartsAt' ,
144+ 'subscriptionEndsAt' ,
145+ 'subscriptionCanceledAt' ,
146+ 'subscriptionInterval' ,
147+ 'subscriptionPeriodEventsLimit' ,
148+ ] as const ;
149+
150+ const normalizeLogValue = ( value : unknown ) =>
151+ value instanceof Date ? value . toISOString ( ) : ( value ?? null ) ;
152+
153+ // Builds a `{ field: { from, to } }` map of changed columns. `undefined`
154+ // after-values are skipped — Prisma reads them as "leave the column untouched".
155+ function diffOrganizationFields (
156+ before : Record < string , unknown > ,
157+ after : Record < string , unknown > ,
158+ fields : readonly string [ ]
159+ ) {
160+ const changes : Record < string , { from : unknown ; to : unknown } > = { } ;
161+ for ( const field of fields ) {
162+ if ( after [ field ] === undefined ) {
163+ continue ;
164+ }
165+ const from = normalizeLogValue ( before [ field ] ) ;
166+ const to = normalizeLogValue ( after [ field ] ) ;
167+ if ( from !== to ) {
168+ changes [ field ] = { from, to } ;
169+ }
170+ }
171+ return changes ;
172+ }
173+
174+ /**
175+ * Syncs the full Polar subscription state onto the organization. Used for every
176+ * `subscription.*` event (created, active, updated, canceled, revoked,
177+ * past_due, uncanceled) since they all carry the same Subscription object and
178+ * `status` drives the rest. This covers new subscriptions, cancellations,
179+ * reactivations, plan changes and payment-state changes in one place.
180+ */
181+ async function syncSubscriptionToOrg (
182+ data : PolarSubscriptionData ,
183+ eventType : string ,
184+ log : FastifyRequest [ 'log' ]
185+ ) {
186+ const metadata = subscriptionMetadataSchema . parse ( data . metadata ) ;
187+ const isCanceled = data . status === 'canceled' ;
188+
189+ const organization = await db . organization . findUniqueOrThrow ( {
190+ where : {
191+ id : metadata . organizationId ,
192+ } ,
193+ } ) ;
194+
195+ // An organization maps to a single subscription in our DB, but can have
196+ // several in Polar (e.g. after re-subscribing). A canceled/revoked event for
197+ // a subscription that is no longer the org's current one must not clobber the
198+ // newer active subscription.
199+ if ( isCanceled && organization . subscriptionId !== data . id ) {
200+ log . info (
201+ {
202+ organizationId : metadata . organizationId ,
203+ eventType,
204+ eventSubscriptionId : data . id ,
205+ orgSubscriptionId : organization . subscriptionId ,
206+ } ,
207+ 'polar webhook: ignoring canceled event for non-current subscription'
208+ ) ;
209+ return ;
210+ }
211+
212+ // Polar can deliver subscription events out of order: at renewal the new
213+ // period can arrive first, then a stale event for the *previous* period lands
214+ // a few seconds later. A billing period never moves backwards, so for the
215+ // same subscription we ignore any event whose period starts before the one we
216+ // already stored — otherwise the stale event resets the org to the expired
217+ // period and the usage counter (recomputed over that window) gets stuck.
218+ if (
219+ organization . subscriptionId === data . id &&
220+ organization . subscriptionStartsAt &&
221+ data . currentPeriodStart < organization . subscriptionStartsAt
222+ ) {
223+ log . info (
224+ {
225+ organizationId : metadata . organizationId ,
226+ eventType,
227+ eventSubscriptionId : data . id ,
228+ eventPeriodStart : data . currentPeriodStart ,
229+ storedPeriodStart : organization . subscriptionStartsAt ,
230+ } ,
231+ 'polar webhook: ignoring stale subscription event (older billing period)'
232+ ) ;
233+ return ;
234+ }
235+
236+ const product = await getProduct ( data . productId ) ;
237+ const rawEventsLimit = product . metadata ?. eventsLimit ;
238+ const parsedEventsLimit =
239+ typeof rawEventsLimit === 'number'
240+ ? rawEventsLimit
241+ : typeof rawEventsLimit === 'string'
242+ ? Number ( rawEventsLimit )
243+ : Number . NaN ;
244+ const hasValidEventsLimit = Number . isFinite ( parsedEventsLimit ) ;
245+ const subscriptionPeriodEventsLimit = hasValidEventsLimit
246+ ? parsedEventsLimit
247+ : organization . subscriptionPeriodEventsLimit ;
248+
249+ if ( ! hasValidEventsLimit ) {
250+ log . warn (
251+ { product } ,
252+ 'No valid eventsLimit on product, preserving existing organization limit'
253+ ) ;
254+ }
255+
256+ const updateData = {
257+ subscriptionId : data . id ,
258+ subscriptionCustomerId : data . customer . id ,
259+ subscriptionPriceId : data . prices [ 0 ] ?. id ?? null ,
260+ subscriptionProductId : data . productId ,
261+ subscriptionStatus : data . status ,
262+ subscriptionStartsAt : data . currentPeriodStart ,
263+ subscriptionCanceledAt : data . canceledAt ,
264+ subscriptionEndsAt : isCanceled
265+ ? data . cancelAtPeriodEnd
266+ ? data . currentPeriodEnd
267+ : data . canceledAt
268+ : data . currentPeriodEnd ,
269+ subscriptionCreatedByUserId :
270+ metadata . userId ?? organization . subscriptionCreatedByUserId ,
271+ subscriptionInterval : data . recurringInterval ,
272+ subscriptionPeriodEventsLimit,
273+ subscriptionPeriodEventsCountExceededAt :
274+ typeof subscriptionPeriodEventsLimit === 'number' &&
275+ organization . subscriptionPeriodEventsCountExceededAt &&
276+ typeof organization . subscriptionPeriodEventsLimit === 'number' &&
277+ organization . subscriptionPeriodEventsLimit < subscriptionPeriodEventsLimit
278+ ? null
279+ : undefined ,
280+ } ;
281+
282+ const changes = diffOrganizationFields (
283+ organization as unknown as Record < string , unknown > ,
284+ updateData as unknown as Record < string , unknown > ,
285+ TRACKED_SUBSCRIPTION_FIELDS
286+ ) ;
287+
288+ await db . organization . update ( {
289+ where : {
290+ id : metadata . organizationId ,
291+ } ,
292+ data : updateData ,
293+ } ) ;
294+
295+ await clearOrganizationCache ( metadata . organizationId ) ;
296+
297+ await publishEvent ( 'organization' , 'subscription_updated' , {
298+ organizationId : metadata . organizationId ,
299+ } ) ;
300+
301+ log . info (
302+ {
303+ organizationId : metadata . organizationId ,
304+ eventType,
305+ subscriptionId : data . id ,
306+ previousStatus : organization . subscriptionStatus ,
307+ status : data . status ,
308+ changes,
309+ } ,
310+ Object . keys ( changes ) . length > 0
311+ ? `polar webhook: synced subscription for ${ metadata . organizationId } (${ Object . keys ( changes ) . join ( ', ' ) } changed)`
312+ : `polar webhook: synced subscription for ${ metadata . organizationId } (no field changes)`
313+ ) ;
314+ }
315+
124316export async function polarWebhook (
125317 request : FastifyRequest < {
126318 Querystring : unknown ;
@@ -147,11 +339,25 @@ export async function polarWebhook(
147339
148340 const event = validation . data ;
149341
342+ const eventOrganizationId =
343+ 'metadata' in event . data &&
344+ event . data . metadata &&
345+ typeof event . data . metadata === 'object' &&
346+ 'organizationId' in event . data . metadata
347+ ? String ( event . data . metadata . organizationId )
348+ : undefined ;
349+
150350 const eventCtx = {
151351 eventType : event . type ,
152352 eventId : 'id' in event . data ? event . data . id : undefined ,
353+ organizationId : eventOrganizationId ,
153354 } ;
154355
356+ request . log . info (
357+ eventCtx ,
358+ `polar webhook: processing ${ event . type } ${ eventOrganizationId ? ` for ${ eventOrganizationId } ` : '' } `
359+ ) ;
360+
155361 if (
156362 'data' in event &&
157363 'product' in event . data &&
@@ -163,118 +369,66 @@ export async function polarWebhook(
163369
164370 const handler = await tryCatch ( async ( ) => {
165371 switch ( event . type ) {
166- case 'order.created' : {
167- const metadata = z
168- . object ( {
169- organizationId : z . string ( ) ,
170- } )
171- . parse ( event . data . metadata ) ;
172-
173- if ( event . data . billingReason === 'subscription_cycle' ) {
174- await db . organization . update ( {
175- where : {
176- id : metadata . organizationId ,
177- } ,
178- data : {
179- subscriptionPeriodEventsCount : 0 ,
180- subscriptionPeriodEventsCountExceededAt : null ,
181- } ,
182- } ) ;
183-
184- await clearOrganizationCache ( metadata . organizationId ) ;
372+ // A new paid billing cycle resets the org's usage counter. Polar sends
373+ // `order.updated` (not `order.created`) and the order moves through
374+ // pending -> paid, so we only act on the `paid` + `subscription_cycle`
375+ // transition. Re-deliveries of the same paid order just reset to 0 again,
376+ // which is harmless (and safely under-counts at worst).
377+ case 'order.updated' : {
378+ if (
379+ event . data . billingReason !== 'subscription_cycle' ||
380+ event . data . status !== 'paid'
381+ ) {
382+ request . log . info (
383+ { ...eventCtx , billingReason : event . data . billingReason } ,
384+ 'polar webhook: order.updated ignored (not a paid billing cycle)'
385+ ) ;
386+ return ;
185387 }
186- return ;
187- }
188- case 'subscription.updated' : {
388+
189389 const metadata = z
190390 . object ( {
191391 organizationId : z . string ( ) ,
192- userId : z . string ( ) ,
193392 } )
194393 . parse ( event . data . metadata ) ;
195394
196- const product = await getProduct ( event . data . productId ) ;
197- const organization = await db . organization . findUniqueOrThrow ( {
198- where : {
199- id : metadata . organizationId ,
200- } ,
395+ const previous = await db . organization . findUnique ( {
396+ where : { id : metadata . organizationId } ,
397+ select : { subscriptionPeriodEventsCount : true } ,
201398 } ) ;
202- const rawEventsLimit = product . metadata ?. eventsLimit ;
203- const parsedEventsLimit =
204- typeof rawEventsLimit === 'number'
205- ? rawEventsLimit
206- : typeof rawEventsLimit === 'string'
207- ? Number ( rawEventsLimit )
208- : Number . NaN ;
209- const hasValidEventsLimit = Number . isFinite ( parsedEventsLimit ) ;
210- const subscriptionPeriodEventsLimit = hasValidEventsLimit
211- ? parsedEventsLimit
212- : organization . subscriptionPeriodEventsLimit ;
213-
214- if ( ! hasValidEventsLimit ) {
215- request . log . warn (
216- { product } ,
217- 'No valid eventsLimit on product, preserving existing organization limit'
218- ) ;
219- }
220- // If we get a cancel event and we cant find it we should ignore it
221- // Since we only have one subscription per organization but you can have several in polar
222- // we dont want to override the existing subscription with a canceled one
223- // TODO: might be other events that we should handle like this?!
224- if ( event . data . status === 'canceled' ) {
225- const orgSubscription = await db . organization . findFirst ( {
226- where : {
227- subscriptionCustomerId : event . data . customer . id ,
228- subscriptionId : event . data . id ,
229- subscriptionStatus : {
230- in : [ 'active' , 'past_due' , 'unpaid' ] ,
231- } ,
232- } ,
233- } ) ;
234-
235- if ( ! orgSubscription ) {
236- return ;
237- }
238- }
239399
240400 await db . organization . update ( {
241401 where : {
242402 id : metadata . organizationId ,
243403 } ,
244404 data : {
245- subscriptionId : event . data . id ,
246- subscriptionCustomerId : event . data . customer . id ,
247- subscriptionPriceId : event . data . prices [ 0 ] ?. id ?? null ,
248- subscriptionProductId : event . data . productId ,
249- subscriptionStatus : event . data . status ,
250- subscriptionStartsAt : event . data . currentPeriodStart ,
251- subscriptionCanceledAt : event . data . canceledAt ,
252- subscriptionEndsAt :
253- event . data . status === 'canceled'
254- ? event . data . cancelAtPeriodEnd
255- ? event . data . currentPeriodEnd
256- : event . data . canceledAt
257- : event . data . currentPeriodEnd ,
258- subscriptionCreatedByUserId : metadata . userId ,
259- subscriptionInterval : event . data . recurringInterval ,
260- subscriptionPeriodEventsLimit,
261- subscriptionPeriodEventsCountExceededAt :
262- typeof subscriptionPeriodEventsLimit === 'number' &&
263- organization . subscriptionPeriodEventsCountExceededAt &&
264- typeof organization . subscriptionPeriodEventsLimit === 'number' &&
265- organization . subscriptionPeriodEventsLimit <
266- subscriptionPeriodEventsLimit
267- ? null
268- : undefined ,
405+ subscriptionPeriodEventsCount : 0 ,
406+ subscriptionPeriodEventsCountExceededAt : null ,
269407 } ,
270408 } ) ;
271409
272410 await clearOrganizationCache ( metadata . organizationId ) ;
273411
274- await publishEvent ( 'organization' , 'subscription_updated' , {
275- organizationId : metadata . organizationId ,
276- } ) ;
277-
412+ request . log . info (
413+ {
414+ ...eventCtx ,
415+ previousEventsCount : previous ?. subscriptionPeriodEventsCount ,
416+ } ,
417+ `polar webhook: new billing cycle for ${ metadata . organizationId } , reset usage counter ${ previous ?. subscriptionPeriodEventsCount ?? 0 } -> 0`
418+ ) ;
419+ return ;
420+ }
421+ // All subscription lifecycle events carry the same Subscription object;
422+ // sync them through a single path (new subs, cancellations, revokes,
423+ // reactivations, plan changes, payment-state changes).
424+ case 'subscription.created' :
425+ case 'subscription.active' :
426+ case 'subscription.updated' :
427+ case 'subscription.uncanceled' :
428+ case 'subscription.canceled' :
429+ case 'subscription.revoked' :
430+ case 'subscription.past_due' : {
431+ await syncSubscriptionToOrg ( event . data , event . type , request . log ) ;
278432 return ;
279433 }
280434 default : {
0 commit comments