Просмотр исходного кода

[web] ensure Stripe & Recurly webhooks can handle a migrating subscription (#30787)

* return early from various event handlers if the subscription isn't controlled by the payment provider sending the event
* update `Subscription` type for webhook events

GitOrigin-RevId: f3fc345c37bbe134b7696ccde9d6d8c7608f8f12
Kristina 6 месяцев назад
Родитель
Сommit
1228eac81e

+ 12 - 0
services/web/app/src/Features/Subscription/RecurlyEventHandler.mjs

@@ -3,6 +3,7 @@ import AnalyticsManager from '../Analytics/AnalyticsManager.mjs'
 import SubscriptionEmailHandler from './SubscriptionEmailHandler.mjs'
 import SubscriptionEmailHandler from './SubscriptionEmailHandler.mjs'
 import { AI_ADD_ON_CODE } from './AiHelper.mjs'
 import { AI_ADD_ON_CODE } from './AiHelper.mjs'
 import mongodb from 'mongodb-legacy'
 import mongodb from 'mongodb-legacy'
+import SubscriptionLocator from './SubscriptionLocator.mjs'
 
 
 const { ObjectId } = mongodb
 const { ObjectId } = mongodb
 
 
@@ -14,6 +15,17 @@ async function sendRecurlyAnalyticsEvent(event, eventData) {
     return
     return
   }
   }
 
 
+  const subscription =
+    await SubscriptionLocator.promises.getUsersSubscription(userId)
+
+  if (
+    subscription?.paymentProvider?.service &&
+    subscription.paymentProvider.service.includes('stripe')
+  ) {
+    // do not send recurly events for subscriptions managed by stripe
+    return
+  }
+
   const customerIoEnabled =
   const customerIoEnabled =
     await SplitTestHandler.promises.hasUserBeenAssignedToVariant(
     await SplitTestHandler.promises.hasUserBeenAssignedToVariant(
       {},
       {},

+ 11 - 0
services/web/app/src/Features/Subscription/SubscriptionUpdater.mjs

@@ -333,6 +333,17 @@ async function updateSubscriptionFromRecurly(
   subscription,
   subscription,
   requesterData
   requesterData
 ) {
 ) {
+  if (
+    subscription?.paymentProvider?.service &&
+    subscription.paymentProvider.service.includes('stripe')
+  ) {
+    logger.warn(
+      { subscriptionId: subscription._id },
+      'attempted to update non-recurly subscription from Recurly data'
+    )
+    return
+  }
+
   if (recurlySubscription.state === 'expired') {
   if (recurlySubscription.state === 'expired') {
     await handleExpiredSubscription(subscription, requesterData)
     await handleExpiredSubscription(subscription, requesterData)
     return
     return

+ 50 - 0
services/web/test/unit/src/Subscription/RecurlyEventHandler.test.mjs

@@ -66,9 +66,59 @@ describe('RecurlyEventHandler', function () {
       })
       })
     )
     )
 
 
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/SubscriptionLocator',
+      () => ({
+        default: (ctx.SubscriptionLocator = {
+          promises: {
+            getUsersSubscription: sinon.stub().resolves(null),
+          },
+        }),
+      })
+    )
+
     ctx.RecurlyEventHandler = (await import(modulePath)).default
     ctx.RecurlyEventHandler = (await import(modulePath)).default
   })
   })
 
 
+  it('should not send events for subscriptions managed by stripe', async function (ctx) {
+    ctx.SubscriptionLocator.promises.getUsersSubscription.resolves({
+      _id: 'sub123',
+      paymentProvider: {
+        service: 'stripe-uk',
+      },
+    })
+
+    await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
+      'new_subscription_notification',
+      ctx.eventData
+    )
+
+    sinon.assert.notCalled(ctx.AnalyticsManager.recordEventForUserInBackground)
+    sinon.assert.notCalled(
+      ctx.AnalyticsManager.setUserPropertyForUserInBackground
+    )
+    sinon.assert.notCalled(
+      ctx.SubscriptionEmailHandler.sendTrialOnboardingEmail
+    )
+  })
+
+  it('should send events for subscriptions without stripe payment provider', async function (ctx) {
+    ctx.SubscriptionLocator.promises.getUsersSubscription.resolves({
+      _id: 'sub123',
+      paymentProvider: {
+        service: 'recurly',
+      },
+    })
+
+    await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
+      'new_subscription_notification',
+      ctx.eventData
+    )
+
+    sinon.assert.called(ctx.AnalyticsManager.recordEventForUserInBackground)
+    sinon.assert.called(ctx.AnalyticsManager.setUserPropertyForUserInBackground)
+  })
+
   it('with new_subscription_notification - free trial', async function (ctx) {
   it('with new_subscription_notification - free trial', async function (ctx) {
     await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
     await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
       'new_subscription_notification',
       'new_subscription_notification',

+ 15 - 0
services/web/test/unit/src/Subscription/SubscriptionUpdater.test.mjs

@@ -410,6 +410,7 @@ describe('SubscriptionUpdater', function () {
   describe('updateSubscriptionFromRecurly', function () {
   describe('updateSubscriptionFromRecurly', function () {
     afterEach(function (ctx) {
     afterEach(function (ctx) {
       ctx.subscription.member_ids = []
       ctx.subscription.member_ids = []
+      delete ctx.subscription.paymentProvider
     })
     })
 
 
     it('should update the subscription with token etc when not expired', async function (ctx) {
     it('should update the subscription with token etc when not expired', async function (ctx) {
@@ -465,6 +466,20 @@ describe('SubscriptionUpdater', function () {
       })
       })
     })
     })
 
 
+    it('should not update subscription when paymentProvider service contains stripe', async function (ctx) {
+      ctx.subscription.paymentProvider = {
+        service: 'stripe-uk',
+      }
+      await ctx.SubscriptionUpdater.promises.updateSubscriptionFromRecurly(
+        ctx.recurlySubscription,
+        ctx.subscription,
+        {}
+      )
+      ctx.subscription.save.called.should.equal(false)
+      expect(ctx.FeaturesUpdater.promises.scheduleRefreshFeatures).to.not.have
+        .been.called
+    })
+
     it('should remove the subscription when expired', async function (ctx) {
     it('should remove the subscription when expired', async function (ctx) {
       ctx.recurlySubscription.state = 'expired'
       ctx.recurlySubscription.state = 'expired'
       await ctx.SubscriptionUpdater.promises.updateSubscriptionFromRecurly(
       await ctx.SubscriptionUpdater.promises.updateSubscriptionFromRecurly(

+ 13 - 19
services/web/types/stripe/webhook-event.ts

@@ -1,15 +1,18 @@
 import Stripe from 'stripe'
 import Stripe from 'stripe'
 
 
+type StripeSubscription = Stripe.Subscription & {
+  metadata: {
+    billing_migration_id?: string
+    recurly_to_stripe_migration_status?: 'in_progress' | 'completed'
+  }
+  customer: string
+}
+
 export interface CustomerSubscriptionUpdatedWebhookEvent
 export interface CustomerSubscriptionUpdatedWebhookEvent
   extends Stripe.EventBase {
   extends Stripe.EventBase {
   type: 'customer.subscription.updated'
   type: 'customer.subscription.updated'
   data: {
   data: {
-    object: Stripe.Subscription & {
-      metadata: {
-        adminUserId?: string
-      }
-      customer: string
-    }
+    object: StripeSubscription
     // https://docs.stripe.com/api/events/object?api-version=2025-04-30.basil#event_object-data-previous_attributes
     // https://docs.stripe.com/api/events/object?api-version=2025-04-30.basil#event_object-data-previous_attributes
     previous_attributes: {
     previous_attributes: {
       cancel_at_period_end?: boolean // will only be present if the subscription was cancelled or reactivated
       cancel_at_period_end?: boolean // will only be present if the subscription was cancelled or reactivated
@@ -34,12 +37,7 @@ export interface CustomerSubscriptionCreatedWebhookEvent
   extends Stripe.EventBase {
   extends Stripe.EventBase {
   type: 'customer.subscription.created'
   type: 'customer.subscription.created'
   data: {
   data: {
-    object: Stripe.Subscription & {
-      metadata: {
-        adminUserId?: string
-      }
-      customer: string
-    }
+    object: StripeSubscription
   }
   }
 }
 }
 
 
@@ -47,12 +45,7 @@ export interface CustomerSubscriptionsDeletedWebhookEvent
   extends Stripe.EventBase {
   extends Stripe.EventBase {
   type: 'customer.subscription.deleted'
   type: 'customer.subscription.deleted'
   data: {
   data: {
-    object: Stripe.Subscription & {
-      metadata: {
-        adminUserId?: string
-      }
-      customer: string
-    }
+    object: StripeSubscription
   }
   }
 }
 }
 
 
@@ -63,7 +56,8 @@ export interface InvoicePaidWebhookEvent extends Stripe.EventBase {
       parent: Stripe.Invoice.Parent & {
       parent: Stripe.Invoice.Parent & {
         subscription_details: Stripe.Invoice.Parent.SubscriptionDetails & {
         subscription_details: Stripe.Invoice.Parent.SubscriptionDetails & {
           metadata: {
           metadata: {
-            adminUserId?: string
+            billing_migration_id?: string
+            recurly_to_stripe_migration_status?: 'in_progress' | 'completed'
           }
           }
         }
         }
       }
       }