[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
This commit is contained in:
Kristina
2026-01-20 09:05:36 +00:00
committed by Copybot
parent 3786471135
commit 1228eac81e
5 changed files with 101 additions and 19 deletions
@@ -3,6 +3,7 @@ import AnalyticsManager from '../Analytics/AnalyticsManager.mjs'
import SubscriptionEmailHandler from './SubscriptionEmailHandler.mjs'
import { AI_ADD_ON_CODE } from './AiHelper.mjs'
import mongodb from 'mongodb-legacy'
import SubscriptionLocator from './SubscriptionLocator.mjs'
const { ObjectId } = mongodb
@@ -14,6 +15,17 @@ async function sendRecurlyAnalyticsEvent(event, eventData) {
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 =
await SplitTestHandler.promises.hasUserBeenAssignedToVariant(
{},
@@ -333,6 +333,17 @@ async function updateSubscriptionFromRecurly(
subscription,
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') {
await handleExpiredSubscription(subscription, requesterData)
return
@@ -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
})
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) {
await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
'new_subscription_notification',
@@ -410,6 +410,7 @@ describe('SubscriptionUpdater', function () {
describe('updateSubscriptionFromRecurly', function () {
afterEach(function (ctx) {
ctx.subscription.member_ids = []
delete ctx.subscription.paymentProvider
})
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) {
ctx.recurlySubscription.state = 'expired'
await ctx.SubscriptionUpdater.promises.updateSubscriptionFromRecurly(
+13 -19
View File
@@ -1,15 +1,18 @@
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
extends Stripe.EventBase {
type: 'customer.subscription.updated'
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
previous_attributes: {
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 {
type: 'customer.subscription.created'
data: {
object: Stripe.Subscription & {
metadata: {
adminUserId?: string
}
customer: string
}
object: StripeSubscription
}
}
@@ -47,12 +45,7 @@ export interface CustomerSubscriptionsDeletedWebhookEvent
extends Stripe.EventBase {
type: 'customer.subscription.deleted'
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 & {
subscription_details: Stripe.Invoice.Parent.SubscriptionDetails & {
metadata: {
adminUserId?: string
billing_migration_id?: string
recurly_to_stripe_migration_status?: 'in_progress' | 'completed'
}
}
}