From 83ba97be7f77143abfb9784288b168c83de9f81e Mon Sep 17 00:00:00 2001 From: Lisa Chan Date: Sat, 24 Jan 2026 01:09:22 -0500 Subject: [PATCH] refactor(payments-management): Refactor cancellation intervention --- .../lib/churn-intervention.service.spec.ts | 1357 ++++++++++++++--- .../src/lib/churn-intervention.service.ts | 306 +++- .../ui/src/lib/actions/redeemChurnCoupon.ts | 2 + .../components/ChurnStaySubscribed/index.tsx | 7 +- .../src/lib/nestapp/nextjs-actions.service.ts | 2 + .../validators/RedeemChurnCouponActionArgs.ts | 3 + 6 files changed, 1374 insertions(+), 303 deletions(-) diff --git a/libs/payments/management/src/lib/churn-intervention.service.spec.ts b/libs/payments/management/src/lib/churn-intervention.service.spec.ts index b3ce161849f..0e3472aae7f 100644 --- a/libs/payments/management/src/lib/churn-intervention.service.spec.ts +++ b/libs/payments/management/src/lib/churn-intervention.service.spec.ts @@ -25,6 +25,9 @@ import { AccountCustomerManager, ResultAccountCustomerFactory, StripeCustomerFactory, + StripeSubscriptionItemFactory, + StripeApiListFactory, + StripePriceRecurringFactory, } from '@fxa/payments/stripe'; import { MockProfileClientConfigProvider, @@ -51,6 +54,7 @@ import { NotifierSnsProvider, } from '@fxa/shared/notifier'; import { ChurnInterventionService } from './churn-intervention.service'; +import { ChurnSubscriptionCustomerMismatchError } from './churn-intervention.error'; describe('ChurnInterventionService', () => { let accountCustomerManager: AccountCustomerManager; @@ -80,6 +84,8 @@ describe('ChurnInterventionService', () => { }; beforeEach(async () => { + jest.clearAllMocks(); + const moduleRef = await Test.createTestingModule({ providers: [ ChurnInterventionService, @@ -491,6 +497,24 @@ describe('ChurnInterventionService', () => { }); it('returns general_error for general errors', async () => { + const mockStripeCustomer = StripeCustomerFactory(); + const mockAccountCustomer = ResultAccountCustomerFactory({ + stripeCustomerId: mockStripeCustomer.id, + }); + const mockSubscription = StripeResponseFactory( + StripeSubscriptionFactory({ + customer: mockStripeCustomer.id, + }) + ); + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockResolvedValue(mockAccountCustomer); + jest + .spyOn(subscriptionManager, 'retrieve') + .mockResolvedValue(mockSubscription); + jest + .spyOn(subscriptionManager, 'getSubscriptionStatus') + .mockResolvedValue({ active: true, cancelAtPeriodEnd: false }); jest .spyOn( productConfigurationManager, @@ -576,7 +600,8 @@ describe('ChurnInterventionService', () => { const result = await churnInterventionService.redeemChurnCoupon( mockUid, - mockSubscription.id + mockSubscription.id, + 'stay_subscribed' ); expect( @@ -625,7 +650,8 @@ describe('ChurnInterventionService', () => { const result = await churnInterventionService.redeemChurnCoupon( mockUid, - mockSubscription.id + mockSubscription.id, + 'stay_subscribed' ); expect( @@ -660,7 +686,8 @@ describe('ChurnInterventionService', () => { const result = await churnInterventionService.redeemChurnCoupon( mockUid, - mockSubscription.id + mockSubscription.id, + 'stay_subscribed' ); expect( @@ -700,7 +727,8 @@ describe('ChurnInterventionService', () => { const result = await churnInterventionService.redeemChurnCoupon( mockUid, - mockSubscription.id + mockSubscription.id, + 'stay_subscribed' ); expect( @@ -729,9 +757,6 @@ describe('ChurnInterventionService', () => { }); const mockStripeCustomer = StripeCustomerFactory(); - const mockAccountCustomer = ResultAccountCustomerFactory({ - stripeCustomerId: mockStripeCustomer.id, - }); const mockSubscription = StripeResponseFactory( StripeSubscriptionFactory({ customer: mockStripeCustomer.id, @@ -743,16 +768,6 @@ describe('ChurnInterventionService', () => { }; it('returns cancel_interstitial_offer when one exists', async () => { - jest - .spyOn(accountCustomerManager, 'getAccountCustomerByUid') - .mockResolvedValue(mockAccountCustomer); - jest - .spyOn(subscriptionManager, 'retrieve') - .mockResolvedValue(mockSubscription); - jest - .spyOn(subscriptionManager, 'getSubscriptionStatus') - .mockResolvedValue({ active: true, cancelAtPeriodEnd: false }); - jest .spyOn( churnInterventionService, @@ -781,11 +796,6 @@ describe('ChurnInterventionService', () => { const result = await churnInterventionService.determineCancellationIntervention(args); - expect(subscriptionManager.getSubscriptionStatus).toHaveBeenCalledWith( - mockStripeCustomer.id, - args.subscriptionId - ); - expect(result).toEqual({ cancelChurnInterventionType: 'cancel_interstitial_offer', reason: 'eligible', @@ -794,16 +804,6 @@ describe('ChurnInterventionService', () => { }); it('returns cancel_churn_intervention when one exists', async () => { - jest - .spyOn(accountCustomerManager, 'getAccountCustomerByUid') - .mockResolvedValue(mockAccountCustomer); - jest - .spyOn(subscriptionManager, 'retrieve') - .mockResolvedValue(mockSubscription); - jest - .spyOn(subscriptionManager, 'getSubscriptionStatus') - .mockResolvedValue({ active: true, cancelAtPeriodEnd: false }); - jest .spyOn( churnInterventionService, @@ -825,16 +825,12 @@ describe('ChurnInterventionService', () => { isEligible: true, reason: 'eligible', cmsChurnInterventionEntry: mockCmsOffer, + cmsOfferingContent: null, }); const result = await churnInterventionService.determineCancellationIntervention(args); - expect(subscriptionManager.getSubscriptionStatus).toHaveBeenCalledWith( - mockStripeCustomer.id, - args.subscriptionId - ); - expect(result).toEqual({ cancelChurnInterventionType: 'cancel_churn_intervention', reason: 'eligible', @@ -843,16 +839,6 @@ describe('ChurnInterventionService', () => { }); it('returns none when no cancel offers exist', async () => { - jest - .spyOn(accountCustomerManager, 'getAccountCustomerByUid') - .mockResolvedValue(mockAccountCustomer); - jest - .spyOn(subscriptionManager, 'retrieve') - .mockResolvedValue(mockSubscription); - jest - .spyOn(subscriptionManager, 'getSubscriptionStatus') - .mockResolvedValue({ active: true, cancelAtPeriodEnd: false }); - jest .spyOn( churnInterventionService, @@ -878,11 +864,6 @@ describe('ChurnInterventionService', () => { const result = await churnInterventionService.determineCancellationIntervention(args); - expect(subscriptionManager.getSubscriptionStatus).toHaveBeenCalledWith( - mockStripeCustomer.id, - args.subscriptionId - ); - expect( churnInterventionService.determineCancelInterstitialOfferEligibility ).toHaveBeenCalledWith(args); @@ -910,15 +891,6 @@ describe('ChurnInterventionService', () => { }); it('returns none when there is no upgrade plan', async () => { - jest - .spyOn(accountCustomerManager, 'getAccountCustomerByUid') - .mockResolvedValue(mockAccountCustomer); - jest - .spyOn(subscriptionManager, 'retrieve') - .mockResolvedValue(mockSubscription); - jest - .spyOn(subscriptionManager, 'getSubscriptionStatus') - .mockResolvedValue({ active: true, cancelAtPeriodEnd: false }); jest .spyOn( churnInterventionService, @@ -930,50 +902,18 @@ describe('ChurnInterventionService', () => { cmsChurnInterventionEntry: null, }); jest - .spyOn(productConfigurationManager, 'getSubplatIntervalBySubscription') - .mockResolvedValue(SubplatInterval.Monthly); - const mockPurchaseForPriceIdResultUtil = { - purchaseForPriceId: jest.fn(), - }; - jest - .spyOn(productConfigurationManager, 'getPageContentByPriceIds') - .mockResolvedValue( - mockPurchaseForPriceIdResultUtil as unknown as PageContentByPriceIdsResultUtil - ); - mockPurchaseForPriceIdResultUtil.purchaseForPriceId.mockReturnValue( - PageContentByPriceIdsPurchaseResultFactory({ - offering: { - ...PageContentByPriceIdsPurchaseResultFactory().offering, - apiIdentifier: 'offering_123', - }, - }) - ); - const rawResult = CancelInterstitialOfferResultFactory(); - const util = new CancelInterstitialOfferUtil(rawResult); - jest - .spyOn(productConfigurationManager, 'getCancelInterstitialOffer') - .mockResolvedValue(util); - jest - .spyOn(productConfigurationManager, 'retrieveStripePrice') - .mockRejectedValue(new Error('error')); + .spyOn( + churnInterventionService, + 'determineCancelInterstitialOfferEligibility' + ) + .mockResolvedValue({ + isEligible: false, + reason: 'no_upgrade_plan_found', + cmsCancelInterstitialOfferResult: null, + }); const result = await churnInterventionService.determineCancellationIntervention(args); - expect(subscriptionManager.getSubscriptionStatus).toHaveBeenCalledWith( - mockStripeCustomer.id, - args.subscriptionId - ); - expect( - productConfigurationManager.retrieveStripePrice - ).toHaveBeenCalledWith('offering_123', SubplatInterval.Yearly); - expect(mockStatsD.increment).toHaveBeenCalledWith( - 'cancel_intervention_decision', - { - type: 'none', - reason: 'no_upgrade_plan_found', - } - ); - expect(result).toEqual({ cancelChurnInterventionType: 'none', reason: 'no_churn_intervention_found', @@ -982,15 +922,6 @@ describe('ChurnInterventionService', () => { }); it('returns none when customer is not eligible for the upgrade interval plan', async () => { - jest - .spyOn(accountCustomerManager, 'getAccountCustomerByUid') - .mockResolvedValue(mockAccountCustomer); - jest - .spyOn(subscriptionManager, 'retrieve') - .mockResolvedValue(mockSubscription); - jest - .spyOn(subscriptionManager, 'getSubscriptionStatus') - .mockResolvedValue({ active: true, cancelAtPeriodEnd: false }); jest .spyOn( churnInterventionService, @@ -1002,56 +933,19 @@ describe('ChurnInterventionService', () => { cmsChurnInterventionEntry: null, }); jest - .spyOn(productConfigurationManager, 'getSubplatIntervalBySubscription') - .mockResolvedValue(SubplatInterval.Monthly); - const mockPurchaseForPriceIdResultUtil = { - purchaseForPriceId: jest.fn(), - }; - jest - .spyOn(productConfigurationManager, 'getPageContentByPriceIds') - .mockResolvedValue( - mockPurchaseForPriceIdResultUtil as unknown as PageContentByPriceIdsResultUtil - ); - mockPurchaseForPriceIdResultUtil.purchaseForPriceId.mockReturnValue( - PageContentByPriceIdsPurchaseResultFactory({ - offering: { - ...PageContentByPriceIdsPurchaseResultFactory().offering, - apiIdentifier: 'offering_123', - }, - }) - ); - const rawResult = CancelInterstitialOfferResultFactory(); - const util = new CancelInterstitialOfferUtil(rawResult); - jest - .spyOn(productConfigurationManager, 'getCancelInterstitialOffer') - .mockResolvedValue(util); - - const mockPrice = StripeResponseFactory(StripePriceFactory()); - jest - .spyOn(productConfigurationManager, 'retrieveStripePrice') - .mockResolvedValue(mockPrice); - jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({ - subscriptionEligibilityResult: EligibilityStatus.SAME, - }); + .spyOn( + churnInterventionService, + 'determineCancelInterstitialOfferEligibility' + ) + .mockResolvedValue({ + isEligible: false, + reason: 'not_eligible_for_upgrade_interval', + cmsCancelInterstitialOfferResult: null, + }); const result = await churnInterventionService.determineCancellationIntervention(args); - expect(subscriptionManager.getSubscriptionStatus).toHaveBeenCalledWith( - mockStripeCustomer.id, - args.subscriptionId - ); - expect( - productConfigurationManager.retrieveStripePrice - ).toHaveBeenCalledWith('offering_123', SubplatInterval.Yearly); - expect(mockStatsD.increment).toHaveBeenCalledWith( - 'cancel_intervention_decision', - { - type: 'none', - reason: 'not_eligible_for_upgrade_interval', - } - ); - expect(result).toEqual({ cancelChurnInterventionType: 'none', reason: 'no_churn_intervention_found', @@ -1060,49 +954,32 @@ describe('ChurnInterventionService', () => { }); it('returns none with discount_already_applied when customer is not eligible to redeem', async () => { - jest - .spyOn(accountCustomerManager, 'getAccountCustomerByUid') - .mockResolvedValue(mockAccountCustomer); - jest - .spyOn(subscriptionManager, 'retrieve') - .mockResolvedValue(mockSubscription); - jest - .spyOn(subscriptionManager, 'getSubscriptionStatus') - .mockResolvedValue({ active: true, cancelAtPeriodEnd: false }); - jest .spyOn( churnInterventionService, - 'determineCancelInterstitialOfferEligibility' + 'determineCancelChurnContentEligibility' ) .mockResolvedValue({ isEligible: false, - reason: 'no_cancel_interstitial_offer_found', - cmsCancelInterstitialOfferResult: null, + reason: 'discount_already_applied', + cmsChurnInterventionEntry: null, + cmsOfferingContent: null, }); - const raw = ChurnInterventionByProductIdRawResultFactory(); - const util = new ChurnInterventionByProductIdResultUtil(raw); - jest .spyOn( - productConfigurationManager, - 'getChurnInterventionBySubscription' + churnInterventionService, + 'determineCancelInterstitialOfferEligibility' ) - .mockResolvedValue(util); - jest.spyOn(subscriptionManager, 'hasCouponId').mockResolvedValue(true); + .mockResolvedValue({ + isEligible: false, + reason: 'no_upgrade_plan_found', + cmsCancelInterstitialOfferResult: null, + }); const result = await churnInterventionService.determineCancellationIntervention(args); - expect(mockStatsD.increment).toHaveBeenCalledWith( - 'cancel_intervention_decision', - { - type: 'none', - reason: 'discount_already_applied', - } - ); - expect(result).toEqual({ cancelChurnInterventionType: 'none', reason: 'discount_already_applied', @@ -1111,16 +988,6 @@ describe('ChurnInterventionService', () => { }); it('returns none with redemption_limit_exceeded when customer is not eligible to redeem', async () => { - jest - .spyOn(accountCustomerManager, 'getAccountCustomerByUid') - .mockResolvedValue(mockAccountCustomer); - jest - .spyOn(subscriptionManager, 'retrieve') - .mockResolvedValue(mockSubscription); - jest - .spyOn(subscriptionManager, 'getSubscriptionStatus') - .mockResolvedValue({ active: true, cancelAtPeriodEnd: false }); - jest .spyOn( churnInterventionService, @@ -1131,37 +998,21 @@ describe('ChurnInterventionService', () => { reason: 'no_cancel_interstitial_offer_found', cmsCancelInterstitialOfferResult: null, }); - - const raw = ChurnInterventionByProductIdRawResultFactory(); - const util = new ChurnInterventionByProductIdResultUtil(raw); - const cmsEntry = ChurnInterventionByProductIdResultFactory({ - redemptionLimit: 1, - }); - jest .spyOn( - productConfigurationManager, - 'getChurnInterventionBySubscription' + churnInterventionService, + 'determineCancelChurnContentEligibility' ) - .mockResolvedValue(util); - jest - .spyOn(util, 'getTransformedChurnInterventionByProductId') - .mockReturnValue([cmsEntry]); - jest - .spyOn(churnInterventionManager, 'getRedemptionCountForUid') - .mockResolvedValue(1); + .mockResolvedValue({ + isEligible: false, + reason: 'redemption_limit_exceeded', + cmsChurnInterventionEntry: null, + cmsOfferingContent: null, + }); const result = await churnInterventionService.determineCancellationIntervention(args); - expect(mockStatsD.increment).toHaveBeenCalledWith( - 'cancel_intervention_decision', - { - type: 'none', - reason: 'redemption_limit_exceeded', - } - ); - expect(result).toEqual({ cancelChurnInterventionType: 'none', reason: 'redemption_limit_exceeded', @@ -1170,6 +1021,1073 @@ describe('ChurnInterventionService', () => { }); }); + describe('determineCancelInterstitialOfferEligibility', () => { + it('throws if customer does not match', async () => { + const mockUid = faker.string.uuid(); + const mockStripeCustomer = StripeCustomerFactory(); + const mockSubscription = StripeResponseFactory( + StripeSubscriptionFactory() + ); + const mockAccountCustomer = ResultAccountCustomerFactory({ + uid: mockUid, + stripeCustomerId: mockStripeCustomer.id, + }); + + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockResolvedValue(mockAccountCustomer); + jest + .spyOn(subscriptionManager, 'retrieve') + .mockResolvedValue(StripeResponseFactory(mockSubscription)); + + expect(mockStatsD.increment).not.toHaveBeenCalled(); + await expect( + churnInterventionService.determineCancelInterstitialOfferEligibility({ + uid: mockUid, + subscriptionId: mockSubscription.id, + }) + ).rejects.toBeInstanceOf(ChurnSubscriptionCustomerMismatchError); + }); + + it('returns not eligible if subscription not active', async () => { + const mockUid = faker.string.uuid(); + const mockStripeCustomer = StripeCustomerFactory(); + const mockSubscription = StripeResponseFactory( + StripeSubscriptionFactory({ + customer: mockStripeCustomer.id, + status: 'canceled', + }) + ); + const mockAccountCustomer = ResultAccountCustomerFactory({ + uid: mockUid, + stripeCustomerId: mockStripeCustomer.id, + }); + + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockResolvedValue(mockAccountCustomer); + jest + .spyOn(subscriptionManager, 'retrieve') + .mockResolvedValue(StripeResponseFactory(mockSubscription)); + jest + .spyOn(subscriptionManager, 'getSubscriptionStatus') + .mockResolvedValue({ + active: false, + cancelAtPeriodEnd: false, + }); + + const result = + await churnInterventionService.determineCancelInterstitialOfferEligibility( + { + uid: mockUid, + subscriptionId: mockSubscription.id, + } + ); + expect(result).toEqual({ + isEligible: false, + reason: 'subscription_not_active', + cmsCancelInterstitialOfferResult: null, + }); + expect(mockStatsD.increment).toHaveBeenCalledWith( + 'cancel_intervention_decision', + { + type: 'none', + reason: 'subscription_not_active', + } + ); + }); + + it('returns not eligible if already canceling at period end', async () => { + const mockUid = faker.string.uuid(); + const mockStripeCustomer = StripeCustomerFactory(); + const mockSubscription = StripeResponseFactory( + StripeSubscriptionFactory({ + customer: mockStripeCustomer.id, + status: 'active', + }) + ); + const mockAccountCustomer = ResultAccountCustomerFactory({ + uid: mockUid, + stripeCustomerId: mockStripeCustomer.id, + }); + + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockResolvedValue(mockAccountCustomer); + jest + .spyOn(subscriptionManager, 'retrieve') + .mockResolvedValue(StripeResponseFactory(mockSubscription)); + jest + .spyOn(subscriptionManager, 'getSubscriptionStatus') + .mockResolvedValue({ + active: true, + cancelAtPeriodEnd: true, + }); + + const result = + await churnInterventionService.determineCancelInterstitialOfferEligibility( + { + uid: mockUid, + subscriptionId: mockSubscription.id, + } + ); + expect(result).toEqual({ + isEligible: false, + reason: 'already_canceling_at_period_end', + cmsCancelInterstitialOfferResult: null, + }); + expect(mockStatsD.increment).toHaveBeenCalledWith( + 'cancel_intervention_decision', + { + type: 'none', + reason: 'already_canceling_at_period_end', + } + ); + }); + + it('returns not eligible if current interval not found', async () => { + const mockUid = faker.string.uuid(); + const mockStripeCustomer = StripeCustomerFactory(); + const mockPrice = StripePriceFactory({ + recurring: StripePriceRecurringFactory({ + interval: 'month', + interval_count: 1, + }), + }); + const mockSubItem = StripeSubscriptionItemFactory({ + price: mockPrice, + }); + const mockSubscription = StripeResponseFactory( + StripeSubscriptionFactory({ + customer: mockStripeCustomer.id, + status: 'active', + items: StripeApiListFactory([mockSubItem]), + }) + ); + const mockAccountCustomer = ResultAccountCustomerFactory({ + uid: mockUid, + stripeCustomerId: mockStripeCustomer.id, + }); + + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockResolvedValue(mockAccountCustomer); + jest + .spyOn(subscriptionManager, 'retrieve') + .mockResolvedValue(StripeResponseFactory(mockSubscription)); + jest + .spyOn(subscriptionManager, 'getSubscriptionStatus') + .mockResolvedValue({ + active: true, + cancelAtPeriodEnd: false, + }); + jest + .spyOn(productConfigurationManager, 'getSubplatIntervalBySubscription') + .mockRejectedValue(new Error()); + + const result = + await churnInterventionService.determineCancelInterstitialOfferEligibility( + { + uid: mockUid, + subscriptionId: mockSubscription.id, + } + ); + expect(result).toEqual({ + isEligible: false, + reason: 'current_interval_not_found', + cmsCancelInterstitialOfferResult: null, + }); + expect(mockStatsD.increment).toHaveBeenCalledWith( + 'cancel_intervention_decision', + { + type: 'none', + reason: 'current_interval_not_found', + } + ); + }); + + it('returns not eligible if price id not found', async () => { + const mockUid = faker.string.uuid(); + const mockStripeCustomer = StripeCustomerFactory(); + const mockPrice = StripePriceFactory({ + id: undefined, + recurring: StripePriceRecurringFactory({ + interval: 'month', + interval_count: 1, + }), + }); + const mockSubItem = StripeSubscriptionItemFactory({ + price: mockPrice, + }); + const mockSubscription = StripeResponseFactory( + StripeSubscriptionFactory({ + customer: mockStripeCustomer.id, + status: 'active', + items: StripeApiListFactory([mockSubItem]), + }) + ); + const mockAccountCustomer = ResultAccountCustomerFactory({ + uid: mockUid, + stripeCustomerId: mockStripeCustomer.id, + }); + + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockResolvedValue(mockAccountCustomer); + jest + .spyOn(subscriptionManager, 'retrieve') + .mockResolvedValue(StripeResponseFactory(mockSubscription)); + jest + .spyOn(subscriptionManager, 'getSubscriptionStatus') + .mockResolvedValue({ + active: true, + cancelAtPeriodEnd: false, + }); + jest + .spyOn(productConfigurationManager, 'getSubplatIntervalBySubscription') + .mockResolvedValue(SubplatInterval.Monthly); + + const result = + await churnInterventionService.determineCancelInterstitialOfferEligibility( + { + uid: mockUid, + subscriptionId: mockSubscription.id, + } + ); + + expect(result).toEqual({ + isEligible: false, + reason: 'stripe_price_id_not_found', + cmsCancelInterstitialOfferResult: null, + }); + expect(mockStatsD.increment).toHaveBeenCalledWith( + 'cancel_intervention_decision', + { + type: 'none', + reason: 'stripe_price_id_not_found', + } + ); + }); + + it('returns not eligible if offering id not found', async () => { + const mockUid = faker.string.uuid(); + const mockStripeCustomer = StripeCustomerFactory(); + const mockPrice = StripePriceFactory({ + recurring: StripePriceRecurringFactory({ + interval: 'month', + interval_count: 1, + }), + }); + const mockSubItem = StripeSubscriptionItemFactory({ + price: mockPrice, + }); + const mockSubscription = StripeResponseFactory( + StripeSubscriptionFactory({ + customer: mockStripeCustomer.id, + status: 'active', + items: StripeApiListFactory([mockSubItem]), + }) + ); + const mockAccountCustomer = ResultAccountCustomerFactory({ + uid: mockUid, + stripeCustomerId: mockStripeCustomer.id, + }); + const mockProductNameByPriceIdsResultUtil = { + purchaseForPriceId: jest.fn(), + }; + + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockResolvedValue(mockAccountCustomer); + jest + .spyOn(subscriptionManager, 'retrieve') + .mockResolvedValue(StripeResponseFactory(mockSubscription)); + jest + .spyOn(subscriptionManager, 'getSubscriptionStatus') + .mockResolvedValue({ + active: true, + cancelAtPeriodEnd: false, + }); + jest + .spyOn(productConfigurationManager, 'getSubplatIntervalBySubscription') + .mockResolvedValue(SubplatInterval.Monthly); + jest + .spyOn(productConfigurationManager, 'getPageContentByPriceIds') + .mockResolvedValue( + mockProductNameByPriceIdsResultUtil as unknown as PageContentByPriceIdsResultUtil + ); + mockProductNameByPriceIdsResultUtil.purchaseForPriceId.mockReturnValue( + PageContentByPriceIdsPurchaseResultFactory({ + offering: undefined, + }) + ); + + const result = + await churnInterventionService.determineCancelInterstitialOfferEligibility( + { + uid: mockUid, + subscriptionId: mockSubscription.id, + } + ); + + expect(result).toEqual({ + isEligible: false, + reason: 'offering_id_not_found', + cmsCancelInterstitialOfferResult: null, + }); + expect(mockStatsD.increment).toHaveBeenCalledWith( + 'cancel_intervention_decision', + { + type: 'none', + reason: 'offering_id_not_found', + } + ); + }); + + it('returns not eligible if no interstitial offer found', async () => { + const mockUid = faker.string.uuid(); + const mockStripeCustomer = StripeCustomerFactory(); + const mockPrice = StripePriceFactory({ + recurring: StripePriceRecurringFactory({ + interval: 'month', + interval_count: 1, + }), + }); + const mockSubItem = StripeSubscriptionItemFactory({ + price: mockPrice, + }); + const mockSubscription = StripeResponseFactory( + StripeSubscriptionFactory({ + customer: mockStripeCustomer.id, + status: 'active', + items: StripeApiListFactory([mockSubItem]), + }) + ); + const mockAccountCustomer = ResultAccountCustomerFactory({ + uid: mockUid, + stripeCustomerId: mockStripeCustomer.id, + }); + const mockProductNameByPriceIdsResultUtil = { + purchaseForPriceId: jest.fn(), + }; + + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockResolvedValue(mockAccountCustomer); + jest + .spyOn(subscriptionManager, 'retrieve') + .mockResolvedValue(StripeResponseFactory(mockSubscription)); + jest + .spyOn(subscriptionManager, 'getSubscriptionStatus') + .mockResolvedValue({ + active: true, + cancelAtPeriodEnd: false, + }); + jest + .spyOn(productConfigurationManager, 'getSubplatIntervalBySubscription') + .mockResolvedValue(SubplatInterval.Monthly); + jest + .spyOn(productConfigurationManager, 'getPageContentByPriceIds') + .mockResolvedValue( + mockProductNameByPriceIdsResultUtil as unknown as PageContentByPriceIdsResultUtil + ); + mockProductNameByPriceIdsResultUtil.purchaseForPriceId.mockReturnValue( + PageContentByPriceIdsPurchaseResultFactory() + ); + jest + .spyOn(productConfigurationManager, 'getCancelInterstitialOffer') + .mockResolvedValue({ + getTransformedResult: () => null, + } as any); + + const result = + await churnInterventionService.determineCancelInterstitialOfferEligibility( + { + uid: mockUid, + subscriptionId: mockSubscription.id, + } + ); + + expect(result).toEqual({ + isEligible: false, + reason: 'no_cancel_interstitial_offer_found', + cmsCancelInterstitialOfferResult: null, + }); + expect(mockStatsD.increment).toHaveBeenCalledWith( + 'cancel_intervention_decision', + { + type: 'none', + reason: 'no_cancel_interstitial_offer_found', + } + ); + }); + + it('returns not eligible if no upgrade plan found', async () => { + const mockUid = faker.string.uuid(); + const mockStripeCustomer = StripeCustomerFactory(); + const mockPrice = StripePriceFactory({ + recurring: StripePriceRecurringFactory({ + interval: 'month', + interval_count: 1, + }), + }); + const mockSubItem = StripeSubscriptionItemFactory({ + price: mockPrice, + }); + const mockSubscription = StripeResponseFactory( + StripeSubscriptionFactory({ + customer: mockStripeCustomer.id, + status: 'active', + items: StripeApiListFactory([mockSubItem]), + }) + ); + const mockAccountCustomer = ResultAccountCustomerFactory({ + uid: mockUid, + stripeCustomerId: mockStripeCustomer.id, + }); + const mockProductNameByPriceIdsResultUtil = { + purchaseForPriceId: jest.fn(), + }; + const rawResult = CancelInterstitialOfferResultFactory(); + const util = new CancelInterstitialOfferUtil(rawResult); + + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockResolvedValue(mockAccountCustomer); + jest + .spyOn(subscriptionManager, 'retrieve') + .mockResolvedValue(StripeResponseFactory(mockSubscription)); + jest + .spyOn(subscriptionManager, 'getSubscriptionStatus') + .mockResolvedValue({ + active: true, + cancelAtPeriodEnd: false, + }); + jest + .spyOn(productConfigurationManager, 'getSubplatIntervalBySubscription') + .mockResolvedValue(SubplatInterval.Monthly); + jest + .spyOn(productConfigurationManager, 'getPageContentByPriceIds') + .mockResolvedValue( + mockProductNameByPriceIdsResultUtil as unknown as PageContentByPriceIdsResultUtil + ); + mockProductNameByPriceIdsResultUtil.purchaseForPriceId.mockReturnValue( + PageContentByPriceIdsPurchaseResultFactory() + ); + jest + .spyOn(productConfigurationManager, 'getCancelInterstitialOffer') + .mockResolvedValue(util); + jest + .spyOn(productConfigurationManager, 'retrieveStripePrice') + .mockRejectedValue(new Error('stripe price not found')); + + const result = + await churnInterventionService.determineCancelInterstitialOfferEligibility( + { + uid: mockUid, + subscriptionId: mockSubscription.id, + } + ); + + expect(result).toEqual({ + isEligible: false, + reason: 'no_upgrade_plan_found', + cmsCancelInterstitialOfferResult: null, + }); + expect(mockStatsD.increment).toHaveBeenCalledWith( + 'cancel_intervention_decision', + { + type: 'none', + reason: 'no_upgrade_plan_found', + } + ); + }); + + it('returns not eligible if not eligible for upgrade', async () => { + const mockUid = faker.string.uuid(); + const mockStripeCustomer = StripeCustomerFactory(); + const mockPrice = StripePriceFactory({ + recurring: StripePriceRecurringFactory({ + interval: 'month', + interval_count: 1, + }), + }); + const mockSubItem = StripeSubscriptionItemFactory({ + price: mockPrice, + }); + const mockSubscription = StripeResponseFactory( + StripeSubscriptionFactory({ + customer: mockStripeCustomer.id, + status: 'active', + items: StripeApiListFactory([mockSubItem]), + }) + ); + const mockAccountCustomer = ResultAccountCustomerFactory({ + uid: mockUid, + stripeCustomerId: mockStripeCustomer.id, + }); + const mockProductNameByPriceIdsResultUtil = { + purchaseForPriceId: jest.fn(), + }; + const rawResult = CancelInterstitialOfferResultFactory(); + const util = new CancelInterstitialOfferUtil(rawResult); + + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockResolvedValue(mockAccountCustomer); + jest + .spyOn(subscriptionManager, 'retrieve') + .mockResolvedValue(StripeResponseFactory(mockSubscription)); + jest + .spyOn(subscriptionManager, 'getSubscriptionStatus') + .mockResolvedValue({ + active: true, + cancelAtPeriodEnd: false, + }); + jest + .spyOn(productConfigurationManager, 'getSubplatIntervalBySubscription') + .mockResolvedValue(SubplatInterval.Monthly); + jest + .spyOn(productConfigurationManager, 'getPageContentByPriceIds') + .mockResolvedValue( + mockProductNameByPriceIdsResultUtil as unknown as PageContentByPriceIdsResultUtil + ); + mockProductNameByPriceIdsResultUtil.purchaseForPriceId.mockReturnValue( + PageContentByPriceIdsPurchaseResultFactory() + ); + jest + .spyOn(productConfigurationManager, 'getCancelInterstitialOffer') + .mockResolvedValue(util); + jest + .spyOn(productConfigurationManager, 'retrieveStripePrice') + .mockResolvedValue(mockPrice); + jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({ + subscriptionEligibilityResult: EligibilityStatus.SAME, + }); + + const result = + await churnInterventionService.determineCancelInterstitialOfferEligibility( + { + uid: mockUid, + subscriptionId: mockSubscription.id, + } + ); + + expect(result).toEqual({ + isEligible: false, + reason: 'not_eligible_for_upgrade_interval', + cmsCancelInterstitialOfferResult: null, + }); + expect(mockStatsD.increment).toHaveBeenCalledWith( + 'cancel_intervention_decision', + { + type: 'none', + reason: 'not_eligible_for_upgrade_interval', + } + ); + }); + + it('returns eligible for offer', async () => { + const mockUid = faker.string.uuid(); + const mockStripeCustomer = StripeCustomerFactory(); + const mockPrice = StripePriceFactory({ + recurring: StripePriceRecurringFactory({ + interval: 'month', + interval_count: 1, + }), + }); + const mockSubItem = StripeSubscriptionItemFactory({ + price: mockPrice, + }); + const mockSubscription = StripeResponseFactory( + StripeSubscriptionFactory({ + customer: mockStripeCustomer.id, + status: 'active', + items: StripeApiListFactory([mockSubItem]), + }) + ); + const mockAccountCustomer = ResultAccountCustomerFactory({ + uid: mockUid, + stripeCustomerId: mockStripeCustomer.id, + }); + const mockProductNameByPriceIdsResultUtil = { + purchaseForPriceId: jest.fn(), + }; + const rawResult = CancelInterstitialOfferResultFactory(); + const util = new CancelInterstitialOfferUtil(rawResult); + const mockCancelInterstitialOffer = + CancelInterstitialOfferTransformedFactory(); + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockResolvedValue(mockAccountCustomer); + jest + .spyOn(subscriptionManager, 'retrieve') + .mockResolvedValue(StripeResponseFactory(mockSubscription)); + jest + .spyOn(subscriptionManager, 'getSubscriptionStatus') + .mockResolvedValue({ + active: true, + cancelAtPeriodEnd: false, + }); + jest + .spyOn(productConfigurationManager, 'getSubplatIntervalBySubscription') + .mockResolvedValue(SubplatInterval.Monthly); + jest + .spyOn(productConfigurationManager, 'getPageContentByPriceIds') + .mockResolvedValue( + mockProductNameByPriceIdsResultUtil as unknown as PageContentByPriceIdsResultUtil + ); + mockProductNameByPriceIdsResultUtil.purchaseForPriceId.mockReturnValue( + PageContentByPriceIdsPurchaseResultFactory() + ); + jest + .spyOn(productConfigurationManager, 'getCancelInterstitialOffer') + .mockResolvedValue(util); + jest + .spyOn(util, 'getTransformedResult') + .mockReturnValue(mockCancelInterstitialOffer); + jest + .spyOn(productConfigurationManager, 'retrieveStripePrice') + .mockResolvedValue(mockPrice); + jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({ + subscriptionEligibilityResult: EligibilityStatus.UPGRADE, + fromOfferingConfigId: 'string', + fromPrice: mockPrice, + }); + + const result = + await churnInterventionService.determineCancelInterstitialOfferEligibility( + { + uid: mockUid, + subscriptionId: mockSubscription.id, + } + ); + + expect(result).toEqual({ + isEligible: true, + reason: 'eligible', + cmsCancelInterstitialOfferResult: mockCancelInterstitialOffer, + }); + }); + }); + + describe('determineCancelChurnContentEligibility', () => { + it('throws if customer does not match', async () => { + const mockUid = faker.string.uuid(); + const mockStripeCustomer = StripeCustomerFactory(); + const mockSubscription = StripeResponseFactory( + StripeSubscriptionFactory() + ); + const mockAccountCustomer = ResultAccountCustomerFactory({ + uid: mockUid, + stripeCustomerId: mockStripeCustomer.id, + }); + + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockResolvedValue(mockAccountCustomer); + jest + .spyOn(subscriptionManager, 'retrieve') + .mockResolvedValue(StripeResponseFactory(mockSubscription)); + + await expect( + churnInterventionService.determineCancelChurnContentEligibility({ + uid: mockUid, + subscriptionId: mockSubscription.id, + }) + ).rejects.toBeInstanceOf(ChurnSubscriptionCustomerMismatchError); + + expect(mockStatsD.increment).not.toHaveBeenCalled(); + }); + + it('returns not eligible if subscription not active', async () => { + const mockUid = faker.string.uuid(); + const mockStripeCustomer = StripeCustomerFactory(); + const mockSubscription = StripeResponseFactory( + StripeSubscriptionFactory({ + customer: mockStripeCustomer.id, + status: 'canceled', + }) + ); + const mockAccountCustomer = ResultAccountCustomerFactory({ + uid: mockUid, + stripeCustomerId: mockStripeCustomer.id, + }); + const rawResult = ChurnInterventionByProductIdRawResultFactory(); + const util = new ChurnInterventionByProductIdResultUtil(rawResult); + const mockCmsOfferingContent = CmsOfferingContentFactory(); + + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockResolvedValue(mockAccountCustomer); + jest + .spyOn(subscriptionManager, 'retrieve') + .mockResolvedValue(StripeResponseFactory(mockSubscription)); + jest + .spyOn(subscriptionManager, 'getSubscriptionStatus') + .mockResolvedValue({ + active: false, + cancelAtPeriodEnd: false, + }); + jest + .spyOn( + productConfigurationManager, + 'getChurnInterventionBySubscription' + ) + .mockResolvedValue(util); + jest + .spyOn(util, 'cmsOfferingContent') + .mockReturnValue(mockCmsOfferingContent); + + const result = + await churnInterventionService.determineCancelChurnContentEligibility({ + uid: mockUid, + subscriptionId: mockSubscription.id, + }); + + expect(result).toEqual({ + isEligible: false, + reason: 'subscription_not_active', + cmsChurnInterventionEntry: null, + cmsOfferingContent: mockCmsOfferingContent, + }); + expect(mockStatsD.increment).toHaveBeenCalledWith( + 'cancel_intervention_decision', + { + type: 'none', + reason: 'subscription_not_active', + } + ); + }); + + it('returns not eligible if already canceling at period end', async () => { + const mockUid = faker.string.uuid(); + const mockStripeCustomer = StripeCustomerFactory(); + const mockSubscription = StripeResponseFactory( + StripeSubscriptionFactory({ + customer: mockStripeCustomer.id, + }) + ); + const mockAccountCustomer = ResultAccountCustomerFactory({ + uid: mockUid, + stripeCustomerId: mockStripeCustomer.id, + }); + const rawResult = ChurnInterventionByProductIdRawResultFactory(); + const util = new ChurnInterventionByProductIdResultUtil(rawResult); + const mockCmsOfferingContent = CmsOfferingContentFactory(); + const mockCmsChurnEntry = ChurnInterventionByProductIdResultFactory(); + + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockResolvedValue(mockAccountCustomer); + jest + .spyOn(subscriptionManager, 'retrieve') + .mockResolvedValue(StripeResponseFactory(mockSubscription)); + jest + .spyOn(subscriptionManager, 'getSubscriptionStatus') + .mockResolvedValue({ + active: true, + cancelAtPeriodEnd: true, + }); + jest + .spyOn( + productConfigurationManager, + 'getChurnInterventionBySubscription' + ) + .mockResolvedValue(util); + jest + .spyOn(util, 'cmsOfferingContent') + .mockReturnValue(mockCmsOfferingContent); + jest + .spyOn(util, 'getTransformedChurnInterventionByProductId') + .mockReturnValue([mockCmsChurnEntry]); + + const result = + await churnInterventionService.determineCancelChurnContentEligibility({ + uid: mockUid, + subscriptionId: mockSubscription.id, + }); + + expect(result).toEqual({ + isEligible: false, + reason: 'already_canceling_at_period_end', + cmsChurnInterventionEntry: mockCmsChurnEntry, + cmsOfferingContent: mockCmsOfferingContent, + }); + expect(mockStatsD.increment).toHaveBeenCalledWith( + 'cancel_intervention_decision', + { + type: 'none', + reason: 'already_canceling_at_period_end', + } + ); + }); + + it('returns not eligible if no churn intervention found', async () => { + const mockUid = faker.string.uuid(); + const mockStripeCustomer = StripeCustomerFactory(); + const mockSubscription = StripeResponseFactory( + StripeSubscriptionFactory({ + customer: mockStripeCustomer.id, + }) + ); + const mockAccountCustomer = ResultAccountCustomerFactory({ + uid: mockUid, + stripeCustomerId: mockStripeCustomer.id, + }); + const rawResult = ChurnInterventionByProductIdRawResultFactory(); + const util = new ChurnInterventionByProductIdResultUtil(rawResult); + const mockCmsOfferingContent = CmsOfferingContentFactory(); + + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockResolvedValue(mockAccountCustomer); + jest + .spyOn(subscriptionManager, 'retrieve') + .mockResolvedValue(StripeResponseFactory(mockSubscription)); + jest + .spyOn(subscriptionManager, 'getSubscriptionStatus') + .mockResolvedValue({ + active: true, + cancelAtPeriodEnd: false, + }); + jest + .spyOn( + productConfigurationManager, + 'getChurnInterventionBySubscription' + ) + .mockResolvedValue(util); + jest + .spyOn(util, 'cmsOfferingContent') + .mockReturnValue(mockCmsOfferingContent); + jest + .spyOn(util, 'getTransformedChurnInterventionByProductId') + .mockReturnValue([]); + + const result = + await churnInterventionService.determineCancelChurnContentEligibility({ + uid: mockUid, + subscriptionId: mockSubscription.id, + }); + + expect(result).toEqual({ + isEligible: false, + reason: 'no_churn_intervention_found', + cmsChurnInterventionEntry: null, + cmsOfferingContent: mockCmsOfferingContent, + }); + expect(mockStatsD.increment).toHaveBeenCalledWith( + 'cancel_intervention_decision', + { + type: 'none', + reason: 'no_churn_intervention_found', + } + ); + }); + + it('returns not eligible if redemption limit exceeded', async () => { + const mockUid = faker.string.uuid(); + const mockStripeCustomer = StripeCustomerFactory(); + const mockSubscription = StripeResponseFactory( + StripeSubscriptionFactory({ + customer: mockStripeCustomer.id, + }) + ); + const mockAccountCustomer = ResultAccountCustomerFactory({ + uid: mockUid, + stripeCustomerId: mockStripeCustomer.id, + }); + const rawResult = ChurnInterventionByProductIdRawResultFactory(); + const util = new ChurnInterventionByProductIdResultUtil(rawResult); + const mockCmsOfferingContent = CmsOfferingContentFactory(); + const mockCmsChurnEntry = ChurnInterventionByProductIdResultFactory({ + churnInterventionId: 'churn_id', + redemptionLimit: 1, + }); + + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockResolvedValue(mockAccountCustomer); + jest + .spyOn(subscriptionManager, 'retrieve') + .mockResolvedValue(StripeResponseFactory(mockSubscription)); + jest + .spyOn(subscriptionManager, 'getSubscriptionStatus') + .mockResolvedValue({ + active: true, + cancelAtPeriodEnd: false, + }); + jest + .spyOn( + productConfigurationManager, + 'getChurnInterventionBySubscription' + ) + .mockResolvedValue(util); + jest + .spyOn(util, 'cmsOfferingContent') + .mockReturnValue(mockCmsOfferingContent); + jest + .spyOn(util, 'getTransformedChurnInterventionByProductId') + .mockReturnValue([mockCmsChurnEntry]); + jest + .spyOn(churnInterventionManager, 'getRedemptionCountForUid') + .mockResolvedValue(1); + + const result = + await churnInterventionService.determineCancelChurnContentEligibility({ + uid: mockUid, + subscriptionId: mockSubscription.id, + }); + + expect(result).toEqual({ + isEligible: false, + reason: 'redemption_limit_exceeded', + cmsChurnInterventionEntry: null, + cmsOfferingContent: mockCmsOfferingContent, + }); + expect(mockStatsD.increment).toHaveBeenCalledWith( + 'cancel_intervention_decision', + { + type: 'none', + reason: 'redemption_limit_exceeded', + } + ); + }); + + it('returns not eligible if discount already applied', async () => { + const mockUid = faker.string.uuid(); + const mockStripeCustomer = StripeCustomerFactory(); + const mockSubscription = StripeResponseFactory( + StripeSubscriptionFactory({ + customer: mockStripeCustomer.id, + }) + ); + const mockAccountCustomer = ResultAccountCustomerFactory({ + uid: mockUid, + stripeCustomerId: mockStripeCustomer.id, + }); + const rawResult = ChurnInterventionByProductIdRawResultFactory(); + const util = new ChurnInterventionByProductIdResultUtil(rawResult); + const mockCmsOfferingContent = CmsOfferingContentFactory(); + const mockCmsChurnEntry = ChurnInterventionByProductIdResultFactory({ + churnInterventionId: 'churn_id', + redemptionLimit: 4, + }); + + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockResolvedValue(mockAccountCustomer); + jest + .spyOn(subscriptionManager, 'retrieve') + .mockResolvedValue(StripeResponseFactory(mockSubscription)); + jest + .spyOn(subscriptionManager, 'getSubscriptionStatus') + .mockResolvedValue({ + active: true, + cancelAtPeriodEnd: false, + }); + jest + .spyOn( + productConfigurationManager, + 'getChurnInterventionBySubscription' + ) + .mockResolvedValue(util); + jest + .spyOn(util, 'cmsOfferingContent') + .mockReturnValue(mockCmsOfferingContent); + jest + .spyOn(util, 'getTransformedChurnInterventionByProductId') + .mockReturnValue([mockCmsChurnEntry]); + jest + .spyOn(churnInterventionManager, 'getRedemptionCountForUid') + .mockResolvedValue(1); + jest.spyOn(subscriptionManager, 'hasCouponId').mockResolvedValue(true); + + const result = + await churnInterventionService.determineCancelChurnContentEligibility({ + uid: mockUid, + subscriptionId: mockSubscription.id, + }); + + expect(result).toEqual({ + isEligible: false, + reason: 'discount_already_applied', + cmsChurnInterventionEntry: mockCmsChurnEntry, + cmsOfferingContent: mockCmsOfferingContent, + }); + expect(mockStatsD.increment).toHaveBeenCalledWith( + 'cancel_intervention_decision', + { + type: 'none', + reason: 'discount_already_applied', + } + ); + }); + + it('returns eligible for churn intervention', async () => { + const mockUid = faker.string.uuid(); + const mockStripeCustomer = StripeCustomerFactory(); + const mockSubscription = StripeResponseFactory( + StripeSubscriptionFactory({ + customer: mockStripeCustomer.id, + }) + ); + const mockAccountCustomer = ResultAccountCustomerFactory({ + uid: mockUid, + stripeCustomerId: mockStripeCustomer.id, + }); + const rawResult = ChurnInterventionByProductIdRawResultFactory(); + const util = new ChurnInterventionByProductIdResultUtil(rawResult); + const mockCmsOfferingContent = CmsOfferingContentFactory(); + const mockCmsChurnEntry = ChurnInterventionByProductIdResultFactory({ + churnInterventionId: 'churn_id', + redemptionLimit: 4, + }); + + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockResolvedValue(mockAccountCustomer); + jest + .spyOn(subscriptionManager, 'retrieve') + .mockResolvedValue(StripeResponseFactory(mockSubscription)); + jest + .spyOn(subscriptionManager, 'getSubscriptionStatus') + .mockResolvedValue({ + active: true, + cancelAtPeriodEnd: false, + }); + jest + .spyOn( + productConfigurationManager, + 'getChurnInterventionBySubscription' + ) + .mockResolvedValue(util); + jest + .spyOn(util, 'cmsOfferingContent') + .mockReturnValue(mockCmsOfferingContent); + jest + .spyOn(util, 'getTransformedChurnInterventionByProductId') + .mockReturnValue([mockCmsChurnEntry]); + jest + .spyOn(churnInterventionManager, 'getRedemptionCountForUid') + .mockResolvedValue(1); + jest.spyOn(subscriptionManager, 'hasCouponId').mockResolvedValue(false); + + const result = + await churnInterventionService.determineCancelChurnContentEligibility({ + uid: mockUid, + subscriptionId: mockSubscription.id, + }); + + expect(result).toEqual({ + isEligible: true, + reason: 'eligible', + cmsChurnInterventionEntry: mockCmsChurnEntry, + cmsOfferingContent: null, + }); + }); + }); + describe('when feature is disabled', () => { beforeEach(() => { mockChurnInterventionConfig.enabled = false; @@ -1201,6 +2119,7 @@ describe('ChurnInterventionService', () => { const result = await churnInterventionService.redeemChurnCoupon( 'uid123', 'sub_123', + 'stay_subscribed', 'en' ); diff --git a/libs/payments/management/src/lib/churn-intervention.service.ts b/libs/payments/management/src/lib/churn-intervention.service.ts index 545a03a7416..74edc2f35da 100644 --- a/libs/payments/management/src/lib/churn-intervention.service.ts +++ b/libs/payments/management/src/lib/churn-intervention.service.ts @@ -27,7 +27,7 @@ import { } from './churn-intervention.error'; export enum Enum_Churnintervention_Churntype { Cancel = 'cancel', - StaySubscribed = 'stay_subscribed' + StaySubscribed = 'stay_subscribed', } export enum Enum_Churnintervention_Interval { @@ -35,7 +35,7 @@ export enum Enum_Churnintervention_Interval { Halfyearly = 'halfyearly', Monthly = 'monthly', Weekly = 'weekly', - Yearly = 'yearly' + Yearly = 'yearly', } @Injectable() @@ -123,6 +123,19 @@ export class ChurnInterventionService { }; } + /** + * Determines whether a customer is eligible for churn intervention that encourages + * them to stay subscribed. + * + * It performs the following steps internally: + * 1. Fetches the account customer and subscription. + * 2. Verifies the subscription exists and belongs to the user. + * 3. Verifies the subscription is active. + * 4. Retrieves stay subscribed churn intervention CMS content for the subscription. + * 5. Ensures churn intervention content exists. + * 6. Enforces redemption limits. + * 7. Ensures the churn coupon has not already been applied. + */ async determineStaySubscribedEligibility( uid: string, subscriptionId: string, @@ -138,6 +151,39 @@ export class ChurnInterventionService { }; } + const [accountCustomer, subscription] = await Promise.all([ + this.accountCustomerManager.getAccountCustomerByUid(uid), + this.subscriptionManager.retrieve(subscriptionId), + ]); + + if (!subscription) { + this.statsd.increment('stay_subscribed_eligibility', { + eligibility: 'ineligible', + reason: 'subscription_not_found', + }); + return { + isEligible: false, + reason: 'subscription_not_found', + cmsChurnInterventionEntry: null, + cmsOfferingContent: null, + }; + } + + if (subscription.customer !== accountCustomer.stripeCustomerId) { + throw new ChurnSubscriptionCustomerMismatchError( + uid, + accountCustomer.uid, + subscription.customer, + subscriptionId + ); + } + + const subscriptionStatus = + await this.subscriptionManager.getSubscriptionStatus( + subscription.customer, + subscriptionId + ); + try { const cmsChurnResult = await this.productConfigurationManager.getChurnInterventionBySubscription( @@ -146,26 +192,9 @@ export class ChurnInterventionService { acceptLanguage || undefined, selectedLanguage ); - const cmsContent = cmsChurnResult.cmsOfferingContent(); - const accountCustomer = - await this.accountCustomerManager.getAccountCustomerByUid(uid); - const subscription = - await this.subscriptionManager.retrieve(subscriptionId); - if (subscription.customer !== accountCustomer.stripeCustomerId) { - throw new ChurnSubscriptionCustomerMismatchError( - uid, - accountCustomer.uid, - subscription.customer, - subscriptionId - ); - } + const cmsContent = cmsChurnResult.cmsOfferingContent(); - const subscriptionStatus = - await this.subscriptionManager.getSubscriptionStatus( - subscription.customer, - subscriptionId - ); if (!subscriptionStatus.active) { this.statsd.increment('stay_subscribed_eligibility', { eligibility: 'ineligible', @@ -182,6 +211,7 @@ export class ChurnInterventionService { const cmsChurnInterventionEntries = cmsChurnResult.getTransformedChurnInterventionByProductId(); const cmsChurnInterventionEntry = cmsChurnInterventionEntries[0]; + if (!subscriptionStatus.cancelAtPeriodEnd) { this.statsd.increment('stay_subscribed_eligibility', { eligibility: 'ineligible', @@ -275,6 +305,7 @@ export class ChurnInterventionService { async redeemChurnCoupon( uid: string, subscriptionId: string, + churnType: 'cancel' | 'stay_subscribed', acceptLanguage?: string | null, selectedLanguage?: string ) { @@ -284,23 +315,34 @@ export class ChurnInterventionService { errorCode: 'feature_disabled', }; } - - const eligibilityResult = await this.determineStaySubscribedEligibility( - uid, - subscriptionId, - acceptLanguage, - selectedLanguage - ); + let eligibilityResult; + if (churnType === 'cancel') { + eligibilityResult = await this.determineCancelChurnContentEligibility({ + uid, + subscriptionId, + acceptLanguage, + selectedLanguage, + }); + } + if (churnType === 'stay_subscribed') { + eligibilityResult = await this.determineStaySubscribedEligibility( + uid, + subscriptionId, + acceptLanguage, + selectedLanguage + ); + } if ( + !eligibilityResult || !eligibilityResult.isEligible || !eligibilityResult.cmsChurnInterventionEntry ) { return { redeemed: false, - reason: eligibilityResult.reason, + reason: eligibilityResult?.reason, updatedChurnInterventionEntryData: null, - cmsChurnInterventionEntry: eligibilityResult.cmsChurnInterventionEntry, + cmsChurnInterventionEntry: eligibilityResult?.cmsChurnInterventionEntry, }; } @@ -364,6 +406,11 @@ export class ChurnInterventionService { } } + /** + * Determines which cancellation intervention flow (either churn intervention + * or cancel interstitial offer) should be presented to the customer + * when attempting to cancel a subscription. + */ async determineCancellationIntervention(args: { uid: string; subscriptionId: string; @@ -378,49 +425,6 @@ export class ChurnInterventionService { } try { - const accountCustomer = - await this.accountCustomerManager.getAccountCustomerByUid(args.uid); - const subscription = await this.subscriptionManager.retrieve( - args.subscriptionId - ); - - if (subscription.customer !== accountCustomer.stripeCustomerId) { - throw new ChurnSubscriptionCustomerMismatchError( - args.uid, - accountCustomer.uid, - subscription.customer, - args.subscriptionId - ); - } - const subscriptionStatus = - await this.subscriptionManager.getSubscriptionStatus( - subscription.customer, - args.subscriptionId - ); - if (!subscriptionStatus.active) { - this.statsd.increment('cancel_intervention_decision', { - type: 'none', - reason: 'subscription_not_active', - }); - return { - cancelChurnInterventionType: 'none', - reason: 'subscription_not_active', - cmsOfferContent: null, - }; - } - - if (subscriptionStatus.cancelAtPeriodEnd) { - this.statsd.increment('cancel_intervention_decision', { - type: 'none', - reason: 'subscription_already_cancelling', - }); - return { - cancelChurnInterventionType: 'none', - reason: 'subscription_already_cancelling', - cmsOfferContent: null, - }; - } - const cancelChurnContentEligiblityResult = await this.determineCancelChurnContentEligibility({ uid: args.uid, @@ -428,6 +432,7 @@ export class ChurnInterventionService { acceptLanguage: args.acceptLanguage, selectedLanguage: args.selectedLanguage, }); + if (cancelChurnContentEligiblityResult.isEligible) { return { cancelChurnInterventionType: 'cancel_churn_intervention', @@ -439,6 +444,7 @@ export class ChurnInterventionService { const cancelInterstitialOfferEligiblityResult = await this.determineCancelInterstitialOfferEligibility(args); + if (cancelInterstitialOfferEligiblityResult.isEligible) { return { cancelChurnInterventionType: 'cancel_interstitial_offer', @@ -463,6 +469,19 @@ export class ChurnInterventionService { } } + /** + * Determines whether a customer is eligible for a cancel interstitial offer + * (e.g. switching from a monthly to a yearly plan) when attempting to cancel. + * + * It performs the following steps internally: + * 1. Fetches the account customer and subscription. + * 2. Verifies the subscription exists and belongs to the user. + * 3. Verifies the subscription is active and not already cancelling. + * 4. Determines the current subscription interval and offering. + * 5. Retrieves cancel interstitial CMS offer content. + * 6. Ensures an upgrade price exists for the target interval. + * 7. Checks eligibility for upgrading to the target interval. + */ async determineCancelInterstitialOfferEligibility(args: { uid: string; subscriptionId: string; @@ -478,10 +497,10 @@ export class ChurnInterventionService { }; } - const upgradeInterval = SubplatInterval.Yearly; - const subscription = await this.subscriptionManager.retrieve( - args.subscriptionId - ); + const [accountCustomer, subscription] = await Promise.all([ + this.accountCustomerManager.getAccountCustomerByUid(args.uid), + this.subscriptionManager.retrieve(args.subscriptionId), + ]); if (!subscription) { this.statsd.increment('cancel_intervention_decision', { @@ -495,12 +514,54 @@ export class ChurnInterventionService { }; } - const currentInterval = - await this.productConfigurationManager.getSubplatIntervalBySubscription( - subscription + if (subscription.customer !== accountCustomer.stripeCustomerId) { + throw new ChurnSubscriptionCustomerMismatchError( + args.uid, + accountCustomer.uid, + subscription.customer, + args.subscriptionId + ); + } + + const subscriptionStatus = + await this.subscriptionManager.getSubscriptionStatus( + subscription.customer, + args.subscriptionId ); - if (!currentInterval) { + if (!subscriptionStatus.active) { + this.statsd.increment('cancel_intervention_decision', { + type: 'none', + reason: 'subscription_not_active', + }); + return { + isEligible: false, + reason: 'subscription_not_active', + cmsCancelInterstitialOfferResult: null, + }; + } + + if (subscriptionStatus.cancelAtPeriodEnd) { + this.statsd.increment('cancel_intervention_decision', { + type: 'none', + reason: 'already_canceling_at_period_end', + }); + return { + isEligible: false, + reason: 'already_canceling_at_period_end', + cmsCancelInterstitialOfferResult: null, + }; + } + + const upgradeInterval = SubplatInterval.Yearly; + + let currentInterval; + try { + currentInterval = + await this.productConfigurationManager.getSubplatIntervalBySubscription( + subscription + ); + } catch { this.statsd.increment('cancel_intervention_decision', { type: 'none', reason: 'current_interval_not_found', @@ -589,7 +650,7 @@ export class ChurnInterventionService { upgradeInterval, offeringId, args.uid, - args.subscriptionId + subscription.customer ); if ( @@ -616,6 +677,19 @@ export class ChurnInterventionService { }; } + /** + * Determines whether a customer is eligible for churn intervention + * when attempting to cancel a subscription. + * + * It performs the following steps internally: + * 1. Fetches the account customer and subscription. + * 2. Verifies the subscription exists and belongs to the user. + * 3. Verifies the subscription is active and not already cancelling. + * 4. Retrieves churn intervention CMS content for the subscription. + * 5. Ensures a churn intervention exists. + * 6. Enforces redemption limits, if configured. + * 7. Ensures the churn coupon has not already been applied. + */ async determineCancelChurnContentEligibility(args: { uid: string; subscriptionId: string; @@ -630,6 +704,39 @@ export class ChurnInterventionService { }; } + const [accountCustomer, subscription] = await Promise.all([ + this.accountCustomerManager.getAccountCustomerByUid(args.uid), + this.subscriptionManager.retrieve(args.subscriptionId), + ]); + + if (!subscription) { + this.statsd.increment('cancel_intervention_decision', { + type: 'none', + reason: 'subscription_not_found', + }); + return { + isEligible: false, + reason: 'subscription_not_found', + cmsChurnInterventionEntry: null, + cmsOfferingContent: null, + }; + } + + if (subscription.customer !== accountCustomer.stripeCustomerId) { + throw new ChurnSubscriptionCustomerMismatchError( + args.uid, + accountCustomer.uid, + subscription.customer, + args.subscriptionId + ); + } + + const subscriptionStatus = + await this.subscriptionManager.getSubscriptionStatus( + subscription.customer, + args.subscriptionId + ); + const cmsChurnResult = await this.productConfigurationManager.getChurnInterventionBySubscription( args.subscriptionId, @@ -638,8 +745,38 @@ export class ChurnInterventionService { args.selectedLanguage ); + const cmsContent = cmsChurnResult.cmsOfferingContent(); + + if (!subscriptionStatus.active) { + this.statsd.increment('cancel_intervention_decision', { + type: 'none', + reason: 'subscription_not_active', + }); + return { + isEligible: false, + reason: 'subscription_not_active', + cmsChurnInterventionEntry: null, + cmsOfferingContent: cmsContent, + }; + } + const cmsChurnInterventionEntries = cmsChurnResult.getTransformedChurnInterventionByProductId(); + const cmsChurnInterventionEntry = cmsChurnInterventionEntries[0]; + + if (subscriptionStatus.cancelAtPeriodEnd) { + this.statsd.increment('cancel_intervention_decision', { + type: 'none', + reason: 'already_canceling_at_period_end', + }); + return { + isEligible: false, + reason: 'already_canceling_at_period_end', + cmsChurnInterventionEntry: cmsChurnInterventionEntry, + cmsOfferingContent: cmsContent, + }; + } + if (!cmsChurnInterventionEntries.length) { this.statsd.increment('cancel_intervention_decision', { type: 'none', @@ -649,10 +786,10 @@ export class ChurnInterventionService { isEligible: false, reason: 'no_churn_intervention_found', cmsChurnInterventionEntry: null, + cmsOfferingContent: cmsContent, }; } - const cmsChurnInterventionEntry = cmsChurnInterventionEntries[0]; const redemptionCount = await this.churnInterventionManager.getRedemptionCountForUid( args.uid, @@ -674,6 +811,7 @@ export class ChurnInterventionService { isEligible: false, reason: 'redemption_limit_exceeded', cmsChurnInterventionEntry: null, + cmsOfferingContent: cmsContent, }; } @@ -691,7 +829,8 @@ export class ChurnInterventionService { return { isEligible: false, reason: 'discount_already_applied', - cmsChurnInterventionEntry: null, + cmsChurnInterventionEntry, + cmsOfferingContent: cmsContent, }; } @@ -702,6 +841,7 @@ export class ChurnInterventionService { isEligible: true, reason: 'eligible', cmsChurnInterventionEntry, + cmsOfferingContent: null, }; } } diff --git a/libs/payments/ui/src/lib/actions/redeemChurnCoupon.ts b/libs/payments/ui/src/lib/actions/redeemChurnCoupon.ts index e8ff691d3a0..bd25d2cc9a5 100644 --- a/libs/payments/ui/src/lib/actions/redeemChurnCoupon.ts +++ b/libs/payments/ui/src/lib/actions/redeemChurnCoupon.ts @@ -9,12 +9,14 @@ import { getApp } from '../nestapp/app'; export const redeemChurnCouponAction = async ( uid: string, subscriptionId: string, + churnType: 'cancel' | 'stay_subscribed', acceptLanguage?: string | null, selectedLanguage?: string ) => { return await getApp().getActionsService().redeemChurnCoupon({ uid, subscriptionId, + churnType, acceptLanguage, selectedLanguage, }); diff --git a/libs/payments/ui/src/lib/client/components/ChurnStaySubscribed/index.tsx b/libs/payments/ui/src/lib/client/components/ChurnStaySubscribed/index.tsx index 6a9d5995009..44fc25ffd33 100644 --- a/libs/payments/ui/src/lib/client/components/ChurnStaySubscribed/index.tsx +++ b/libs/payments/ui/src/lib/client/components/ChurnStaySubscribed/index.tsx @@ -111,7 +111,12 @@ export function ChurnStaySubscribed({ setLoading(true); setResubscribeActionError(false); - const result = await redeemChurnCouponAction(uid, subscriptionId, locale); + const result = await redeemChurnCouponAction( + uid, + subscriptionId, + 'stay_subscribed', + locale + ); if (result.redeemed) { // TODO: This is a workaround to match existing legacy behavior. diff --git a/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts b/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts index d5436cd4c81..a9e161fa7af 100644 --- a/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts +++ b/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts @@ -338,12 +338,14 @@ export class NextJSActionsService { async redeemChurnCoupon(args: { uid: string; subscriptionId: string; + churnType: 'cancel' | 'stay_subscribed'; acceptLanguage?: string | null; selectedLanguage?: string; }) { return await this.churnInterventionService.redeemChurnCoupon( args.uid, args.subscriptionId, + args.churnType, args.acceptLanguage, args.selectedLanguage ); diff --git a/libs/payments/ui/src/lib/nestapp/validators/RedeemChurnCouponActionArgs.ts b/libs/payments/ui/src/lib/nestapp/validators/RedeemChurnCouponActionArgs.ts index 73f12debb79..1ccaec18be0 100644 --- a/libs/payments/ui/src/lib/nestapp/validators/RedeemChurnCouponActionArgs.ts +++ b/libs/payments/ui/src/lib/nestapp/validators/RedeemChurnCouponActionArgs.ts @@ -11,6 +11,9 @@ export class RedeemChurnCouponActionArgs { @IsString() subscriptionId!: string; + @IsString() + churnType!: 'cancel' | 'stay_subscribed'; + @IsString() @IsOptional() acceptLanguage?: string;