From d7dd698094565e25543b79d6f25a211df0e40a1c Mon Sep 17 00:00:00 2001 From: elizabeth-ilina Date: Thu, 5 Feb 2026 17:11:41 -0500 Subject: [PATCH] fix(payments-next): "Something went wrong..." error displayed for a user that has access to another user's churn coupon email link Because: * We did not have general backend error catching for the churn stay subscribed page. This commit: * Updates the churn stay subscribed page (and maybe others? - todo: check others) with a try-catch to gracefully handle backend failures * Adds customer_mismatch error code / reason to handle customer mismatch on the stay subscribed (and other?? - TODO check) pages. Closes #[PAY-3513](https://mozilla-hub.atlassian.net/browse/PAY-3513) --- .../loyalty-discount/cancel/error/page.tsx | 20 +- .../loyalty-discount/cancel/page.tsx | 17 +- .../stay-subscribed/error/page.tsx | 18 +- .../loyalty-discount/stay-subscribed/page.tsx | 17 +- .../[subscriptionId]/offer/error/en.ftl | 5 + .../[subscriptionId]/offer/error/page.tsx | 89 +- .../lib/churn-intervention.service.spec.ts | 72 +- .../src/lib/churn-intervention.service.ts | 852 +++++++++--------- .../subscriptionManagement.service.spec.ts | 54 +- .../src/lib/subscriptionManagement.service.ts | 318 +++---- libs/payments/management/src/lib/types.ts | 1 + .../client/components/ChurnCancel/index.tsx | 31 +- .../components/ChurnStaySubscribed/index.tsx | 31 +- .../lib/server/components/ChurnError/en.ftl | 3 + .../server/components/ChurnError/index.tsx | 340 ++++--- 15 files changed, 1056 insertions(+), 812 deletions(-) diff --git a/apps/payments/next/app/[locale]/subscriptions/[subscriptionId]/loyalty-discount/cancel/error/page.tsx b/apps/payments/next/app/[locale]/subscriptions/[subscriptionId]/loyalty-discount/cancel/error/page.tsx index 04087c4cc97..3843280a76f 100644 --- a/apps/payments/next/app/[locale]/subscriptions/[subscriptionId]/loyalty-discount/cancel/error/page.tsx +++ b/apps/payments/next/app/[locale]/subscriptions/[subscriptionId]/loyalty-discount/cancel/error/page.tsx @@ -41,11 +41,16 @@ export default async function LoyaltyDiscountCancelErrorPage({ const uid = session.user.id; - const pageContent = await determineChurnCancelEligibilityAction( - uid, - subscriptionId, - acceptLanguage - ); + let pageContent; + try { + pageContent = await determineChurnCancelEligibilityAction( + uid, + subscriptionId, + acceptLanguage + ); + } catch (error) { + notFound(); + } if (!pageContent) { notFound(); @@ -59,13 +64,10 @@ export default async function LoyaltyDiscountCancelErrorPage({ } const { cmsOfferingContent, reason } = churnCancelContentEligibility; - if (!cmsOfferingContent) { - notFound(); - } const cancelContent = pageContent.cancelContent; - if (cancelContent.flowType !== 'cancel') { + if (cancelContent.flowType !== 'cancel' || !cmsOfferingContent) { return ( - {l10n.getString( - 'interstitial-offer-error-button-back-to-subscriptions', - 'Back to subscriptions' - )} + {primaryButton.label} - {showContinueToCancelButton && ( - - {l10n.getString( - 'interstitial-offer-error-button-cancel-subscription', - 'Continue to cancel' - )} - + {secondaryButton && ( + secondaryButton.isExternal ? ( + + {secondaryButton.label} + + ) : ( + + {secondaryButton.label} + + ) )} 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 68a3360ddce..79361b9051d 100644 --- a/libs/payments/management/src/lib/churn-intervention.service.spec.ts +++ b/libs/payments/management/src/lib/churn-intervention.service.spec.ts @@ -54,7 +54,6 @@ import { NotifierSnsProvider, } from '@fxa/shared/notifier'; import { ChurnInterventionService } from './churn-intervention.service'; -import { ChurnSubscriptionCustomerMismatchError } from './churn-intervention.error'; describe('ChurnInterventionService', () => { let accountCustomerManager: AccountCustomerManager; @@ -180,6 +179,43 @@ describe('ChurnInterventionService', () => { jest.resetAllMocks(); }); + it('returns ineligible when customer does not match subscription', async () => { + const mockStripeCustomer = StripeCustomerFactory(); + const mockAccountCustomer = ResultAccountCustomerFactory({ + stripeCustomerId: mockStripeCustomer.id, + }); + const mockSubscription = StripeResponseFactory( + StripeSubscriptionFactory() + ); + + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockResolvedValue(mockAccountCustomer); + jest + .spyOn(subscriptionManager, 'retrieve') + .mockResolvedValue(mockSubscription); + + const result = + await churnInterventionService.determineStaySubscribedEligibility( + uid, + subscriptionId + ); + + expect(result).toEqual({ + isEligible: false, + reason: 'customer_mismatch', + cmsChurnInterventionEntry: null, + cmsOfferingContent: null, + }); + expect(mockStatsD.increment).toHaveBeenCalledWith( + 'stay_subscribed_eligibility', + { + eligibility: 'ineligible', + reason: 'customer_mismatch', + } + ); + }); + it('returns ineligible when no churn intervention entries are found', async () => { const rawResult = ChurnInterventionByProductIdRawResultFactory(); const util = new ChurnInterventionByProductIdResultUtil(rawResult); @@ -1047,7 +1083,7 @@ describe('ChurnInterventionService', () => { }); describe('determineCancelInterstitialOfferEligibility', () => { - it('throws if customer does not match', async () => { + it('returns ineligible when customer does not match', async () => { const mockUid = faker.string.uuid(); const mockStripeCustomer = StripeCustomerFactory(); const mockSubscription = StripeResponseFactory( @@ -1065,13 +1101,19 @@ describe('ChurnInterventionService', () => { .spyOn(subscriptionManager, 'retrieve') .mockResolvedValue(StripeResponseFactory(mockSubscription)); - expect(mockStatsD.increment).not.toHaveBeenCalled(); - await expect( - churnInterventionService.determineCancelInterstitialOfferEligibility({ + const result = + await churnInterventionService.determineCancelInterstitialOfferEligibility({ uid: mockUid, subscriptionId: mockSubscription.id, - }) - ).rejects.toBeInstanceOf(ChurnSubscriptionCustomerMismatchError); + }); + + expect(result).toEqual({ + isEligible: false, + reason: 'customer_mismatch', + cmsCancelInterstitialOfferResult: null, + webIcon: null, + productName: null, + }); }); it('returns not eligible if subscription not active', async () => { @@ -1776,7 +1818,7 @@ describe('ChurnInterventionService', () => { }); describe('determineCancelChurnContentEligibility', () => { - it('throws if customer does not match', async () => { + it('returns ineligible when customer does not match', async () => { const mockUid = faker.string.uuid(); const mockStripeCustomer = StripeCustomerFactory(); const mockSubscription = StripeResponseFactory( @@ -1794,14 +1836,18 @@ describe('ChurnInterventionService', () => { .spyOn(subscriptionManager, 'retrieve') .mockResolvedValue(StripeResponseFactory(mockSubscription)); - await expect( - churnInterventionService.determineCancelChurnContentEligibility({ + const result = + await churnInterventionService.determineCancelChurnContentEligibility({ uid: mockUid, subscriptionId: mockSubscription.id, - }) - ).rejects.toBeInstanceOf(ChurnSubscriptionCustomerMismatchError); + }); - expect(mockStatsD.increment).not.toHaveBeenCalled(); + expect(result).toEqual({ + isEligible: false, + reason: 'customer_mismatch', + cmsChurnInterventionEntry: null, + cmsOfferingContent: null, + }); }); it('returns not eligible if subscription not active', async () => { diff --git a/libs/payments/management/src/lib/churn-intervention.service.ts b/libs/payments/management/src/lib/churn-intervention.service.ts index 1cac87c524f..972e60f3bc6 100644 --- a/libs/payments/management/src/lib/churn-intervention.service.ts +++ b/libs/payments/management/src/lib/churn-intervention.service.ts @@ -23,7 +23,6 @@ import { ProfileClient } from '@fxa/profile/client'; import { NotifierService } from '@fxa/shared/notifier'; import { ChurnInterventionProductIdentifierMissingError, - ChurnSubscriptionCustomerMismatchError, } from './churn-intervention.error'; @Injectable() @@ -130,146 +129,160 @@ 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( - subscriptionId, - 'stay_subscribed', - acceptLanguage || undefined, - selectedLanguage - ); - - const cmsContent = cmsChurnResult.cmsOfferingContent(); + const [accountCustomer, subscription] = await Promise.all([ + this.accountCustomerManager.getAccountCustomerByUid(uid), + this.subscriptionManager.retrieve(subscriptionId), + ]); - if (!subscriptionStatus.active) { + if (!subscription) { this.statsd.increment('stay_subscribed_eligibility', { eligibility: 'ineligible', - reason: 'subscription_not_active', + reason: 'subscription_not_found', }); return { isEligible: false, - reason: 'subscription_not_active', + reason: 'subscription_not_found', cmsChurnInterventionEntry: null, - cmsOfferingContent: cmsContent, + cmsOfferingContent: null, }; } - const cmsChurnInterventionEntries = - cmsChurnResult.getTransformedChurnInterventionByProductId(); - const cmsChurnInterventionEntry = cmsChurnInterventionEntries[0]; - - if (!subscriptionStatus.cancelAtPeriodEnd) { - this.statsd.increment('stay_subscribed_eligibility', { - eligibility: 'ineligible', - reason: 'subscription_still_active', - }); - return { - isEligible: false, - reason: 'subscription_still_active', - cmsChurnInterventionEntry, - cmsOfferingContent: cmsContent, - }; - } - - if (!cmsChurnInterventionEntries.length) { + if (subscription.customer !== accountCustomer.stripeCustomerId) { this.statsd.increment('stay_subscribed_eligibility', { eligibility: 'ineligible', - reason: 'no_churn_intervention_found', + reason: 'customer_mismatch', }); return { isEligible: false, - reason: 'no_churn_intervention_found', + reason: 'customer_mismatch', cmsChurnInterventionEntry: null, - cmsOfferingContent: cmsContent, + cmsOfferingContent: null, }; } - const redemptionCount = - await this.churnInterventionManager.getRedemptionCountForUid( - uid, - cmsChurnInterventionEntry.churnInterventionId + const subscriptionStatus = + await this.subscriptionManager.getSubscriptionStatus( + subscription.customer, + subscriptionId ); - const limit = cmsChurnInterventionEntry.redemptionLimit; + try { + const cmsChurnResult = + await this.productConfigurationManager.getChurnInterventionBySubscription( + subscriptionId, + 'stay_subscribed', + acceptLanguage || undefined, + selectedLanguage + ); + + const cmsContent = cmsChurnResult.cmsOfferingContent(); + + if (!subscriptionStatus.active) { + this.statsd.increment('stay_subscribed_eligibility', { + eligibility: 'ineligible', + 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('stay_subscribed_eligibility', { + eligibility: 'ineligible', + reason: 'subscription_still_active', + }); + return { + isEligible: false, + reason: 'subscription_still_active', + cmsChurnInterventionEntry, + cmsOfferingContent: cmsContent, + }; + } + + if (!cmsChurnInterventionEntries.length) { + this.statsd.increment('stay_subscribed_eligibility', { + eligibility: 'ineligible', + reason: 'no_churn_intervention_found', + }); + return { + isEligible: false, + reason: 'no_churn_intervention_found', + cmsChurnInterventionEntry: null, + cmsOfferingContent: cmsContent, + }; + } + + const redemptionCount = + await this.churnInterventionManager.getRedemptionCountForUid( + uid, + cmsChurnInterventionEntry.churnInterventionId + ); + + const limit = cmsChurnInterventionEntry.redemptionLimit; + + // redemptionLimit is allowed to be null/undefined but not 0 + // Coupon may be redeemed indefinitely + const hasLimit = typeof limit === 'number'; + + if (hasLimit && redemptionCount >= limit) { + this.statsd.increment('stay_subscribed_eligibility', { + eligibility: 'ineligible', + reason: 'redemption_limit_exceeded', + }); + return { + isEligible: false, + reason: 'redemption_limit_exceeded', + cmsChurnInterventionEntry: null, + cmsOfferingContent: cmsContent, + }; + } + + const churnCouponId = cmsChurnInterventionEntry.stripeCouponId; + const couponAlreadyApplied = await this.subscriptionManager.hasCouponId( + subscriptionId, + churnCouponId + ); - // redemptionLimit is allowed to be null/undefined but not 0 - // Coupon may be redeemed indefinitely - const hasLimit = typeof limit === 'number'; + if (couponAlreadyApplied) { + this.statsd.increment('stay_subscribed_eligibility', { + eligibility: 'ineligible', + reason: 'discount_already_applied', + }); + return { + isEligible: false, + reason: 'discount_already_applied', + cmsChurnInterventionEntry: null, + cmsOfferingContent: cmsContent, + }; + } - if (hasLimit && redemptionCount >= limit) { this.statsd.increment('stay_subscribed_eligibility', { - eligibility: 'ineligible', - reason: 'redemption_limit_exceeded', + eligibility: 'eligible', }); return { - isEligible: false, - reason: 'redemption_limit_exceeded', - cmsChurnInterventionEntry: null, - cmsOfferingContent: cmsContent, + isEligible: true, + reason: 'eligible', + cmsChurnInterventionEntry, + cmsOfferingContent: null, }; - } - - const churnCouponId = cmsChurnInterventionEntry.stripeCouponId; - const couponAlreadyApplied = await this.subscriptionManager.hasCouponId( - subscriptionId, - churnCouponId - ); - - if (couponAlreadyApplied) { - this.statsd.increment('stay_subscribed_eligibility', { - eligibility: 'ineligible', - reason: 'discount_already_applied', - }); + } catch (error) { + this.log.error(error); return { isEligible: false, - reason: 'discount_already_applied', + reason: 'general_error', cmsChurnInterventionEntry: null, - cmsOfferingContent: cmsContent, + cmsOfferingContent: null, }; } - - this.statsd.increment('stay_subscribed_eligibility', { - eligibility: 'eligible', - }); - return { - isEligible: true, - reason: 'eligible', - cmsChurnInterventionEntry, - cmsOfferingContent: null, - }; } catch (error) { this.log.error(error); return { @@ -332,12 +345,13 @@ export class ChurnInterventionService { await this.subscriptionManager.retrieve(subscriptionId); if (subscription.customer !== accountCustomer.stripeCustomerId) { - throw new ChurnSubscriptionCustomerMismatchError( - uid, - accountCustomer.uid, - subscription.customer, - subscriptionId - ); + return { + redeemed: false, + reason: 'customer_mismatch', + updatedChurnInterventionEntryData: null, + cmsChurnInterventionEntry: + eligibilityResult.cmsChurnInterventionEntry, + }; } const updatedSubscription = @@ -469,205 +483,221 @@ export class ChurnInterventionService { }; } - const [accountCustomer, subscription] = await Promise.all([ - this.accountCustomerManager.getAccountCustomerByUid(args.uid), - this.subscriptionManager.retrieve(args.subscriptionId), - ]); + try { + 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', - cmsCancelInterstitialOfferResult: null, - webIcon: null, - productName: null, - }; - } + if (!subscription) { + this.statsd.increment('cancel_intervention_decision', { + type: 'none', + reason: 'subscription_not_found', + }); + return { + isEligible: false, + reason: 'subscription_not_found', + cmsCancelInterstitialOfferResult: null, + webIcon: null, + productName: null, + }; + } - if (subscription.customer !== accountCustomer.stripeCustomerId) { - throw new ChurnSubscriptionCustomerMismatchError( - args.uid, - accountCustomer.uid, - subscription.customer, - args.subscriptionId - ); - } + if (subscription.customer !== accountCustomer.stripeCustomerId) { + this.statsd.increment('cancel_intervention_decision', { + type: 'none', + reason: 'customer_mismatch', + }); + return { + isEligible: false, + reason: 'customer_mismatch', + cmsCancelInterstitialOfferResult: null, + webIcon: null, + productName: null, + }; + } - const stripePriceId = subscription.items.data.at(0)?.price.id; + const stripePriceId = subscription.items.data.at(0)?.price.id; - if (!stripePriceId) { - this.statsd.increment('cancel_intervention_decision', { - type: 'none', - reason: 'stripe_price_id_not_found', - }); - return { - isEligible: false, - reason: 'stripe_price_id_not_found', - cmsCancelInterstitialOfferResult: null, - webIcon: null, - productName: null, - }; - } + if (!stripePriceId) { + this.statsd.increment('cancel_intervention_decision', { + type: 'none', + reason: 'stripe_price_id_not_found', + }); + return { + isEligible: false, + reason: 'stripe_price_id_not_found', + cmsCancelInterstitialOfferResult: null, + webIcon: null, + productName: null, + }; + } - const result = - await this.productConfigurationManager.getPageContentByPriceIds([ - stripePriceId, - ]); - const { offering, purchaseDetails } = result.purchaseForPriceId(stripePriceId); - const offeringId = offering?.apiIdentifier; - const { webIcon, productName } = purchaseDetails; - - const subscriptionStatus = - await this.subscriptionManager.getSubscriptionStatus( - subscription.customer, - args.subscriptionId - ); + const result = + await this.productConfigurationManager.getPageContentByPriceIds([ + stripePriceId, + ]); + const { offering, purchaseDetails } = result.purchaseForPriceId(stripePriceId); + const offeringId = offering?.apiIdentifier; + const { webIcon, productName } = purchaseDetails; - if (!subscriptionStatus.active) { - this.statsd.increment('cancel_intervention_decision', { - type: 'none', - reason: 'subscription_not_active', - }); - return { - isEligible: false, - reason: 'subscription_not_active', - cmsCancelInterstitialOfferResult: null, - webIcon, - productName, - }; - } + const subscriptionStatus = + await this.subscriptionManager.getSubscriptionStatus( + subscription.customer, + args.subscriptionId + ); - 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, - webIcon, - productName, - }; - } + if (!subscriptionStatus.active) { + this.statsd.increment('cancel_intervention_decision', { + type: 'none', + reason: 'subscription_not_active', + }); + return { + isEligible: false, + reason: 'subscription_not_active', + cmsCancelInterstitialOfferResult: null, + webIcon, + productName, + }; + } - const upgradeInterval = SubplatInterval.Yearly; + 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, + webIcon, + productName, + }; + } - let currentInterval; - try { - currentInterval = - await this.productConfigurationManager.getSubplatIntervalBySubscription( - subscription + 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', + }); + return { + isEligible: false, + reason: 'current_interval_not_found', + cmsCancelInterstitialOfferResult: null, + webIcon, + productName, + }; + } + + if (!offeringId) { + this.statsd.increment('cancel_intervention_decision', { + type: 'none', + reason: 'offering_id_not_found', + }); + return { + isEligible: false, + reason: 'offering_id_not_found', + cmsCancelInterstitialOfferResult: null, + webIcon, + productName, + }; + } + + const cmsCancelInterstitialOffer = + await this.productConfigurationManager.getCancelInterstitialOffer( + offeringId, + currentInterval, + upgradeInterval, + args.acceptLanguage || undefined, + args.selectedLanguage ); - } catch { - this.statsd.increment('cancel_intervention_decision', { - type: 'none', - reason: 'current_interval_not_found', - }); - return { - isEligible: false, - reason: 'current_interval_not_found', - cmsCancelInterstitialOfferResult: null, - webIcon, - productName, - }; - } + const cmsCancelInterstitialOfferResult = + cmsCancelInterstitialOffer.getTransformedResult(); - if (!offeringId) { - this.statsd.increment('cancel_intervention_decision', { - type: 'none', - reason: 'offering_id_not_found', - }); - return { - isEligible: false, - reason: 'offering_id_not_found', - cmsCancelInterstitialOfferResult: null, - webIcon, - productName, - }; - } + if (!cmsCancelInterstitialOfferResult) { + this.statsd.increment('cancel_intervention_decision', { + type: 'none', + reason: 'no_cancel_interstitial_offer_found', + }); + return { + isEligible: false, + reason: 'no_cancel_interstitial_offer_found', + cmsCancelInterstitialOfferResult: null, + webIcon, + productName, + }; + } - const cmsCancelInterstitialOffer = - await this.productConfigurationManager.getCancelInterstitialOffer( - offeringId, - currentInterval, + try { + await this.productConfigurationManager.retrieveStripePrice( + offeringId, + upgradeInterval + ); + } catch { + this.statsd.increment('cancel_intervention_decision', { + type: 'none', + reason: 'no_upgrade_plan_found', + }); + return { + isEligible: false, + reason: 'no_upgrade_plan_found', + cmsCancelInterstitialOfferResult: null, + webIcon, + productName, + }; + } + + const eligibility = await this.eligibilityService.checkEligibility( upgradeInterval, - args.acceptLanguage || undefined, - args.selectedLanguage + offeringId, + args.uid, + subscription.customer ); - const cmsCancelInterstitialOfferResult = - cmsCancelInterstitialOffer.getTransformedResult(); - if (!cmsCancelInterstitialOfferResult) { - this.statsd.increment('cancel_intervention_decision', { - type: 'none', - reason: 'no_cancel_interstitial_offer_found', - }); - return { - isEligible: false, - reason: 'no_cancel_interstitial_offer_found', - cmsCancelInterstitialOfferResult: null, - webIcon, - productName, - }; - } + if ( + eligibility.subscriptionEligibilityResult !== EligibilityStatus.UPGRADE + ) { + this.statsd.increment('cancel_intervention_decision', { + type: 'none', + reason: 'not_eligible_for_upgrade_interval', + }); + return { + isEligible: false, + reason: 'not_eligible_for_upgrade_interval', + cmsCancelInterstitialOfferResult: null, + webIcon, + productName, + }; + } - try { - await this.productConfigurationManager.retrieveStripePrice( - offeringId, - upgradeInterval - ); - } catch { this.statsd.increment('cancel_intervention_decision', { - type: 'none', - reason: 'no_upgrade_plan_found', + type: 'cancel_interstitial_offer', }); return { - isEligible: false, - reason: 'no_upgrade_plan_found', - cmsCancelInterstitialOfferResult: null, + isEligible: true, + reason: 'eligible', + cmsCancelInterstitialOfferResult, webIcon, productName, }; - } - - const eligibility = await this.eligibilityService.checkEligibility( - upgradeInterval, - offeringId, - args.uid, - subscription.customer - ); - - if ( - eligibility.subscriptionEligibilityResult !== EligibilityStatus.UPGRADE - ) { - this.statsd.increment('cancel_intervention_decision', { - type: 'none', - reason: 'not_eligible_for_upgrade_interval', - }); + } catch (error) { + this.log.error(error); return { isEligible: false, - reason: 'not_eligible_for_upgrade_interval', + reason: 'general_error', cmsCancelInterstitialOfferResult: null, - webIcon, - productName, + webIcon: null, + productName: null, }; } - - this.statsd.increment('cancel_intervention_decision', { - type: 'cancel_interstitial_offer', - }); - return { - isEligible: true, - reason: 'eligible', - cmsCancelInterstitialOfferResult, - webIcon, - productName, - }; } /** @@ -688,144 +718,158 @@ export class ChurnInterventionService { }; } - const [accountCustomer, subscription] = await Promise.all([ - this.accountCustomerManager.getAccountCustomerByUid(args.uid), - this.subscriptionManager.retrieve(args.subscriptionId), - ]); + try { + 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) { + 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 - ); - } + if (subscription.customer !== accountCustomer.stripeCustomerId) { + this.statsd.increment('cancel_intervention_decision', { + type: 'none', + reason: 'customer_mismatch', + }); + return { + isEligible: false, + reason: 'customer_mismatch', + cmsChurnInterventionEntry: null, + cmsOfferingContent: null, + } + } - const subscriptionStatus = - await this.subscriptionManager.getSubscriptionStatus( - subscription.customer, - args.subscriptionId - ); + const subscriptionStatus = + await this.subscriptionManager.getSubscriptionStatus( + subscription.customer, + args.subscriptionId + ); - const cmsChurnResult = - await this.productConfigurationManager.getChurnInterventionBySubscription( - args.subscriptionId, - 'cancel', - args.acceptLanguage || undefined, - args.selectedLanguage - ); + const cmsChurnResult = + await this.productConfigurationManager.getChurnInterventionBySubscription( + args.subscriptionId, + 'cancel', + args.acceptLanguage || undefined, + args.selectedLanguage + ); - const cmsContent = cmsChurnResult.cmsOfferingContent(); + 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, - }; - } + 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]; + 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 (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', - reason: 'no_churn_intervention_found', - }); - return { - isEligible: false, - reason: 'no_churn_intervention_found', - cmsChurnInterventionEntry: null, - cmsOfferingContent: cmsContent, - }; - } + if (!cmsChurnInterventionEntries.length) { + this.statsd.increment('cancel_intervention_decision', { + type: 'none', + reason: 'no_churn_intervention_found', + }); + return { + isEligible: false, + reason: 'no_churn_intervention_found', + cmsChurnInterventionEntry: null, + cmsOfferingContent: cmsContent, + }; + } - const redemptionCount = - await this.churnInterventionManager.getRedemptionCountForUid( - args.uid, - cmsChurnInterventionEntry.churnInterventionId - ); + const redemptionCount = + await this.churnInterventionManager.getRedemptionCountForUid( + args.uid, + cmsChurnInterventionEntry.churnInterventionId + ); + + const limit = cmsChurnInterventionEntry.redemptionLimit; + + // redemptionLimit is allowed to be null/undefined but not 0 + // Coupon may be redeemed indefinitely + const hasLimit = typeof limit === 'number'; - const limit = cmsChurnInterventionEntry.redemptionLimit; + if (hasLimit && redemptionCount >= limit) { + this.statsd.increment('cancel_intervention_decision', { + type: 'none', + reason: 'redemption_limit_exceeded', + }); + return { + isEligible: false, + reason: 'redemption_limit_exceeded', + cmsChurnInterventionEntry: null, + cmsOfferingContent: cmsContent, + }; + } - // redemptionLimit is allowed to be null/undefined but not 0 - // Coupon may be redeemed indefinitely - const hasLimit = typeof limit === 'number'; + const churnCouponId = cmsChurnInterventionEntry.stripeCouponId; + const couponAlreadyApplied = await this.subscriptionManager.hasCouponId( + args.subscriptionId, + churnCouponId + ); + + if (couponAlreadyApplied) { + this.statsd.increment('cancel_intervention_decision', { + type: 'none', + reason: 'discount_already_applied', + }); + return { + isEligible: false, + reason: 'discount_already_applied', + cmsChurnInterventionEntry, + cmsOfferingContent: cmsContent, + }; + } - if (hasLimit && redemptionCount >= limit) { this.statsd.increment('cancel_intervention_decision', { - type: 'none', - reason: 'redemption_limit_exceeded', + type: 'cancel_churn_intervention', }); return { - isEligible: false, - reason: 'redemption_limit_exceeded', - cmsChurnInterventionEntry: null, - cmsOfferingContent: cmsContent, + isEligible: true, + reason: 'eligible', + cmsChurnInterventionEntry, + cmsOfferingContent: null, }; - } - - const churnCouponId = cmsChurnInterventionEntry.stripeCouponId; - const couponAlreadyApplied = await this.subscriptionManager.hasCouponId( - args.subscriptionId, - churnCouponId - ); - - if (couponAlreadyApplied) { - this.statsd.increment('cancel_intervention_decision', { - type: 'none', - reason: 'discount_already_applied', - }); + } catch (error) { + this.log.error(error); return { isEligible: false, - reason: 'discount_already_applied', - cmsChurnInterventionEntry, - cmsOfferingContent: cmsContent, + reason: 'general_error', + cmsChurnInterventionEntry: null, + cmsOfferingContent: null, }; } - - this.statsd.increment('cancel_intervention_decision', { - type: 'cancel_churn_intervention', - }); - return { - isEligible: true, - reason: 'eligible', - cmsChurnInterventionEntry, - cmsOfferingContent: null, - }; } } diff --git a/libs/payments/management/src/lib/subscriptionManagement.service.spec.ts b/libs/payments/management/src/lib/subscriptionManagement.service.spec.ts index 241a170606c..a806c61072a 100644 --- a/libs/payments/management/src/lib/subscriptionManagement.service.spec.ts +++ b/libs/payments/management/src/lib/subscriptionManagement.service.spec.ts @@ -108,7 +108,6 @@ import { CreateBillingAgreementActiveBillingAgreement, CreateBillingAgreementCurrencyNotFound, CreateBillingAgreementPaypalSubscriptionNotFound, - SubscriptionManagementNoStripeCustomerFoundError, } from './subscriptionManagement.error'; import { MockNotifierSnsConfigProvider, @@ -1960,7 +1959,7 @@ describe('SubscriptionManagementService', () => { expect(result.flowType).toEqual('not_found'); }); - it('throws error - SubscriptionManagementCouldNotRetrieveProductNamesFromCMSError', async () => { + it('returns not_found when product names cannot be retrieved from CMS', async () => { const mockUid = faker.string.uuid(); const mockSubscription = StripeResponseFactory( StripeSubscriptionFactory() @@ -2009,14 +2008,12 @@ describe('SubscriptionManagementService', () => { PageContentByPriceIdsPurchaseResultFactory() ); - await expect( - subscriptionManagementService.getCancelFlowContent( - mockUid, - mockSubscription.id - ) - ).rejects.toBeInstanceOf( - SubscriptionManagementCouldNotRetrieveProductNamesFromCMSError + const result = await subscriptionManagementService.getCancelFlowContent( + mockUid, + mockSubscription.id ); + + expect(result).toEqual({ flowType: 'not_found' }); }); it('returns not_found when subscription customer does not match', async () => { @@ -2209,7 +2206,7 @@ describe('SubscriptionManagementService', () => { expect(result).toEqual({ flowType: 'not_found' }); }); - it('throws error - SubscriptionManagementNoStripeCustomerFoundError', async () => { + it('returns not_found when stripe customer is not found', async () => { const mockUid = faker.string.uuid(); const mockSubscription = StripeResponseFactory( StripeSubscriptionFactory() @@ -2225,17 +2222,16 @@ describe('SubscriptionManagementService', () => { .spyOn(customerManager, 'retrieve') .mockRejectedValue(new CustomerDeletedError(mockUid)); - await expect( - subscriptionManagementService.getStaySubscribedFlowContent( + const result = + await subscriptionManagementService.getStaySubscribedFlowContent( mockUid, mockSubscription.id - ) - ).rejects.toBeInstanceOf( - SubscriptionManagementNoStripeCustomerFoundError - ); + ); + + expect(result).toEqual({ flowType: 'not_found' }); }); - it('throws error - SubscriptionManagementCouldNotRetrieveProductNamesFromCMSError', async () => { + it('returns not_found when product names cannot be retrieved from CMS', async () => { const mockUid = faker.string.uuid(); const mockSubscription = StripeResponseFactory( StripeSubscriptionFactory() @@ -2278,17 +2274,16 @@ describe('SubscriptionManagementService', () => { PageContentByPriceIdsPurchaseResultFactory() ); - await expect( - subscriptionManagementService.getStaySubscribedFlowContent( + const result = + await subscriptionManagementService.getStaySubscribedFlowContent( mockUid, mockSubscription.id - ) - ).rejects.toBeInstanceOf( - SubscriptionManagementCouldNotRetrieveProductNamesFromCMSError - ); + ); + + expect(result).toEqual({ flowType: 'not_found' }); }); - it('throws error - SubscriptionContentMissingUpcomingInvoicePreviewError', async () => { + it('returns not_found when upcoming invoice preview is missing', async () => { const mockUid = faker.string.uuid(); const mockSubscription = StripeResponseFactory( StripeSubscriptionFactory() @@ -2334,14 +2329,13 @@ describe('SubscriptionManagementService', () => { PageContentByPriceIdsPurchaseResultFactory() ); - await expect( - subscriptionManagementService.getStaySubscribedFlowContent( + const result = + await subscriptionManagementService.getStaySubscribedFlowContent( mockUid, mockSubscription.id - ) - ).rejects.toBeInstanceOf( - SubscriptionContentMissingUpcomingInvoicePreviewError - ); + ); + + expect(result).toEqual({ flowType: 'not_found' }); }); }); }); diff --git a/libs/payments/management/src/lib/subscriptionManagement.service.ts b/libs/payments/management/src/lib/subscriptionManagement.service.ts index cc24abff49f..aeb5a57dae1 100644 --- a/libs/payments/management/src/lib/subscriptionManagement.service.ts +++ b/libs/payments/management/src/lib/subscriptionManagement.service.ts @@ -595,91 +595,98 @@ export class SubscriptionManagementService { }; } - const stripeCustomer = await this.customerManager - .retrieve(subscription.customer) - .catch((error) => { - if (!(error instanceof CustomerDeletedError)) { - throw error; - } - return undefined; - }); - - if (!stripeCustomer) - throw new SubscriptionManagementNoStripeCustomerFoundError( - uid, - subscriptionId - ); + try { + const stripeCustomer = await this.customerManager + .retrieve(subscription.customer) + .catch((error) => { + if (!(error instanceof CustomerDeletedError)) { + throw error; + } + return undefined; + }); - const defaultPaymentMethod = - await this.paymentMethodManager.getDefaultPaymentMethod( - stripeCustomer, - [subscription], - uid - ); + if (!stripeCustomer) + throw new SubscriptionManagementNoStripeCustomerFoundError( + uid, + subscriptionId + ); - const item = subscription.items.data[0]; - const price = item.price; - const priceId = price.id; - const productMap = - await this.productConfigurationManager.getPageContentByPriceIds( - [priceId], - acceptLanguage, - selectedLanguage - ); + const defaultPaymentMethod = + await this.paymentMethodManager.getDefaultPaymentMethod( + stripeCustomer, + [subscription], + uid + ); - if (!productMap) { - throw new SubscriptionManagementCouldNotRetrieveProductNamesFromCMSError([ - priceId, - ]); - } + const item = subscription.items.data[0]; + const price = item.price; + const priceId = price.id; + const productMap = + await this.productConfigurationManager.getPageContentByPriceIds( + [priceId], + acceptLanguage, + selectedLanguage + ); - const cmsPurchase = productMap.purchaseForPriceId(priceId); - const productName = - cmsPurchase.purchaseDetails.localizations[0]?.productName || - cmsPurchase.purchaseDetails.productName; - const supportUrl = cmsPurchase.offering.commonContent.supportUrl; - const webIcon = cmsPurchase.purchaseDetails.webIcon; - const currentPeriodEnd = subscription.current_period_end; - - const upcomingInvoice = - await this.invoiceManager.previewUpcomingSubscription({ - customer: stripeCustomer, - subscription, - }); + if (!productMap) { + throw new SubscriptionManagementCouldNotRetrieveProductNamesFromCMSError([ + priceId, + ]); + } - if (!upcomingInvoice) { - throw new SubscriptionContentMissingUpcomingInvoicePreviewError( - subscription.id, - stripeCustomer - ); - } + const cmsPurchase = productMap.purchaseForPriceId(priceId); + const productName = + cmsPurchase.purchaseDetails.localizations[0]?.productName || + cmsPurchase.purchaseDetails.productName; + const supportUrl = cmsPurchase.offering.commonContent.supportUrl; + const webIcon = cmsPurchase.purchaseDetails.webIcon; + const currentPeriodEnd = subscription.current_period_end; + + const upcomingInvoice = + await this.invoiceManager.previewUpcomingSubscription({ + customer: stripeCustomer, + subscription, + }); + + if (!upcomingInvoice) { + throw new SubscriptionContentMissingUpcomingInvoicePreviewError( + subscription.id, + stripeCustomer + ); + } - const { subsequentAmount, subsequentAmountExcludingTax, subsequentTax } = - upcomingInvoice; + const { subsequentAmount, subsequentAmountExcludingTax, subsequentTax } = + upcomingInvoice; - const nextInvoiceTotalExclusiveTax = - subsequentTax && - subsequentTax - .filter((tax) => !tax.inclusive) - .reduce((sum, tax) => sum + tax.amount, 0); + const nextInvoiceTotalExclusiveTax = + subsequentTax && + subsequentTax + .filter((tax) => !tax.inclusive) + .reduce((sum, tax) => sum + tax.amount, 0); - return { - flowType: 'cancel', - active: subscription.status === 'active', - cancelAtPeriodEnd: subscription.cancel_at_period_end, - currency: subscription.currency, - currentPeriodEnd, - defaultPaymentMethodType: defaultPaymentMethod?.type, - last4: defaultPaymentMethod?.last4, - nextInvoiceTax: nextInvoiceTotalExclusiveTax, - nextInvoiceTotal: - nextInvoiceTotalExclusiveTax && nextInvoiceTotalExclusiveTax > 0 - ? (subsequentAmountExcludingTax ?? subsequentAmount) - : subsequentAmount, - productName, - supportUrl, - webIcon, - }; + return { + flowType: 'cancel', + active: subscription.status === 'active', + cancelAtPeriodEnd: subscription.cancel_at_period_end, + currency: subscription.currency, + currentPeriodEnd, + defaultPaymentMethodType: defaultPaymentMethod?.type, + last4: defaultPaymentMethod?.last4, + nextInvoiceTax: nextInvoiceTotalExclusiveTax, + nextInvoiceTotal: + nextInvoiceTotalExclusiveTax && nextInvoiceTotalExclusiveTax > 0 + ? (subsequentAmountExcludingTax ?? subsequentAmount) + : subsequentAmount, + productName, + supportUrl, + webIcon, + }; + } catch (error) { + this.log.error(error); + return { + flowType: 'not_found', + }; + } } @SanitizeExceptions({ @@ -707,89 +714,96 @@ export class SubscriptionManagementService { }; } - const stripeCustomer = await this.customerManager - .retrieve(subscription.customer) - .catch((error) => { - if (!(error instanceof CustomerDeletedError)) { - throw error; - } - return undefined; - }); - - if (!stripeCustomer) - throw new SubscriptionManagementNoStripeCustomerFoundError( - uid, - subscriptionId - ); - - const defaultPaymentMethod = - await this.paymentMethodManager.getDefaultPaymentMethod( - stripeCustomer, - [subscription], - uid - ); + try { + const stripeCustomer = await this.customerManager + .retrieve(subscription.customer) + .catch((error) => { + if (!(error instanceof CustomerDeletedError)) { + throw error; + } + return undefined; + }); - const item = subscription.items.data[0]; - const price = item.price; - const priceId = price.id; - const productMap = - await this.productConfigurationManager.getPageContentByPriceIds( - [priceId], - acceptLanguage, - selectedLanguage - ); + if (!stripeCustomer) + throw new SubscriptionManagementNoStripeCustomerFoundError( + uid, + subscriptionId + ); - if (!productMap) { - throw new SubscriptionManagementCouldNotRetrieveProductNamesFromCMSError([ - priceId, - ]); - } + const defaultPaymentMethod = + await this.paymentMethodManager.getDefaultPaymentMethod( + stripeCustomer, + [subscription], + uid + ); - const cmsPurchase = productMap.purchaseForPriceId(priceId); - const productName = - cmsPurchase.purchaseDetails.localizations[0]?.productName || - cmsPurchase.purchaseDetails.productName; - const webIcon = cmsPurchase.purchaseDetails.webIcon; - const currentPeriodEnd = subscription.current_period_end; + const item = subscription.items.data[0]; + const price = item.price; + const priceId = price.id; + const productMap = + await this.productConfigurationManager.getPageContentByPriceIds( + [priceId], + acceptLanguage, + selectedLanguage + ); - const upcomingInvoice = - await this.invoiceManager.previewUpcomingSubscription({ - customer: stripeCustomer, - subscription, - }); + if (!productMap) { + throw new SubscriptionManagementCouldNotRetrieveProductNamesFromCMSError([ + priceId, + ]); + } - if (!upcomingInvoice) { - throw new SubscriptionContentMissingUpcomingInvoicePreviewError( - subscription.id, - stripeCustomer - ); - } + const cmsPurchase = productMap.purchaseForPriceId(priceId); + const productName = + cmsPurchase.purchaseDetails.localizations[0]?.productName || + cmsPurchase.purchaseDetails.productName; + const webIcon = cmsPurchase.purchaseDetails.webIcon; + const currentPeriodEnd = subscription.current_period_end; + + const upcomingInvoice = + await this.invoiceManager.previewUpcomingSubscription({ + customer: stripeCustomer, + subscription, + }); + + if (!upcomingInvoice) { + throw new SubscriptionContentMissingUpcomingInvoicePreviewError( + subscription.id, + stripeCustomer + ); + } - const { subsequentAmount, subsequentAmountExcludingTax, subsequentTax } = - upcomingInvoice; + const { subsequentAmount, subsequentAmountExcludingTax, subsequentTax } = + upcomingInvoice; - const nextInvoiceTotalExclusiveTax = - subsequentTax && - subsequentTax - .filter((tax) => !tax.inclusive) - .reduce((sum, tax) => sum + tax.amount, 0); + const nextInvoiceTotalExclusiveTax = + subsequentTax && + subsequentTax + .filter((tax) => !tax.inclusive) + .reduce((sum, tax) => sum + tax.amount, 0); - return { - flowType: 'stay_subscribed', - active: subscription.status === 'active', - cancelAtPeriodEnd: subscription.cancel_at_period_end, - currency: subscription.currency, - currentPeriodEnd, - defaultPaymentMethodType: defaultPaymentMethod?.type, - last4: defaultPaymentMethod?.last4, - nextInvoiceTax: nextInvoiceTotalExclusiveTax, - nextInvoiceTotal: - nextInvoiceTotalExclusiveTax && nextInvoiceTotalExclusiveTax > 0 - ? (subsequentAmountExcludingTax ?? subsequentAmount) - : subsequentAmount, - productName, - webIcon, - }; + return { + flowType: 'stay_subscribed', + active: subscription.status === 'active', + cancelAtPeriodEnd: subscription.cancel_at_period_end, + currency: subscription.currency, + currentPeriodEnd, + defaultPaymentMethodType: defaultPaymentMethod?.type, + last4: defaultPaymentMethod?.last4, + nextInvoiceTax: nextInvoiceTotalExclusiveTax, + nextInvoiceTotal: + nextInvoiceTotalExclusiveTax && nextInvoiceTotalExclusiveTax > 0 + ? (subsequentAmountExcludingTax ?? subsequentAmount) + : subsequentAmount, + productName, + webIcon, + }; + } catch (error) { + this.log.error(error); + return { + flowType: 'not_found', + }; + } } async getCurrencyForCustomer(uid: string) { diff --git a/libs/payments/management/src/lib/types.ts b/libs/payments/management/src/lib/types.ts index 5667c30caf1..c1e894d7fbc 100644 --- a/libs/payments/management/src/lib/types.ts +++ b/libs/payments/management/src/lib/types.ts @@ -109,4 +109,5 @@ export enum ChurnErrorReason { SubscriptionStillActive = 'subscription_still_active', GeneralError = 'general_error', RedemptionLimitExceeded = 'redemption_limit_exceeded', + CustomerMismatch = 'customer_mismatch', } diff --git a/libs/payments/ui/src/lib/client/components/ChurnCancel/index.tsx b/libs/payments/ui/src/lib/client/components/ChurnCancel/index.tsx index 9c30e8f1d3f..460c1f26705 100644 --- a/libs/payments/ui/src/lib/client/components/ChurnCancel/index.tsx +++ b/libs/payments/ui/src/lib/client/components/ChurnCancel/index.tsx @@ -108,22 +108,27 @@ export function ChurnCancel({ setLoading(true); setResubscribeActionError(false); - const result = await redeemChurnCouponAction( - uid, - subscriptionId, - 'cancel', - locale - ); + try { + const result = await redeemChurnCouponAction( + uid, + subscriptionId, + 'cancel', + locale + ); - if (result.redeemed) { - // TODO: This is a workaround to match existing legacy behavior. - // Fix as part of redesign - setShowSuccess(true); - await new Promise((resolve) => setTimeout(resolve, 500)); - } else { + if (result.redeemed) { + // TODO: This is a workaround to match existing legacy behavior. + // Fix as part of redesign + setShowSuccess(true); + await new Promise((resolve) => setTimeout(resolve, 500)); + } else { + setResubscribeActionError(true); + } + } catch { setResubscribeActionError(true); + } finally { + setLoading(false); } - setLoading(false); } const isOffer = reason === 'eligible' && !cancelAtPeriodEnd && active; 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 3824379e50b..eff63164783 100644 --- a/libs/payments/ui/src/lib/client/components/ChurnStaySubscribed/index.tsx +++ b/libs/payments/ui/src/lib/client/components/ChurnStaySubscribed/index.tsx @@ -111,22 +111,27 @@ export function ChurnStaySubscribed({ setLoading(true); setResubscribeActionError(false); - const result = await redeemChurnCouponAction( - uid, - subscriptionId, - 'stay_subscribed', - locale - ); + try { + const result = await redeemChurnCouponAction( + uid, + subscriptionId, + 'stay_subscribed', + locale + ); - if (result.redeemed) { - // TODO: This is a workaround to match existing legacy behavior. - // Fix as part of redesign - setShowSuccess(true); - await new Promise((resolve) => setTimeout(resolve, 500)); - } else { + if (result.redeemed) { + // TODO: This is a workaround to match existing legacy behavior. + // Fix as part of redesign + setShowSuccess(true); + await new Promise((resolve) => setTimeout(resolve, 500)); + } else { + setResubscribeActionError(true); + } + } catch { setResubscribeActionError(true); + } finally { + setLoading(false); } - setLoading(false); } async function resubscribe() { diff --git a/libs/payments/ui/src/lib/server/components/ChurnError/en.ftl b/libs/payments/ui/src/lib/server/components/ChurnError/en.ftl index 0b8ed348f9f..fb784ae3a70 100644 --- a/libs/payments/ui/src/lib/server/components/ChurnError/en.ftl +++ b/libs/payments/ui/src/lib/server/components/ChurnError/en.ftl @@ -6,6 +6,9 @@ churn-error-page-message-discount-already-applied = This discount was applied to churn-error-page-button-manage-subscriptions = Manage subscriptions churn-error-page-button-contact-support = Contact Support churn-error-page-button-try-again = Try again +churn-error-page-title-customer-mismatch = Coupon can't be redeemed +churn-error-page-message-customer-mismatch = This coupon was issued for a different subscription and can only be redeemed by the original recipient. +churn-error-page-button-sign-in = Sign in churn-error-page-title-general-error = There was an issue with renewing your subscription churn-error-page-message-general-error = Contact support or try again. # $productName (String) - The name of the product associated with the subscription. diff --git a/libs/payments/ui/src/lib/server/components/ChurnError/index.tsx b/libs/payments/ui/src/lib/server/components/ChurnError/index.tsx index 33ad69b4a29..a4d12c7f420 100644 --- a/libs/payments/ui/src/lib/server/components/ChurnError/index.tsx +++ b/libs/payments/ui/src/lib/server/components/ChurnError/index.tsx @@ -14,7 +14,12 @@ import { LinkExternal } from '@fxa/shared/react'; import { getApp } from '@fxa/payments/ui/server'; type ChurnErrorProps = { - cmsOfferingContent: any; + cmsOfferingContent: { + productName: string; + successActionButtonUrl: string; + supportUrl: string; + webIcon: string; + } | null | undefined; locale: string; reason: string; pageContent: @@ -45,170 +50,223 @@ export async function ChurnError({ const acceptLanguage = headers().get('accept-language'); const l10n = getApp().getL10n(acceptLanguage); - const { productName, successActionButtonUrl, supportUrl, webIcon } = - cmsOfferingContent; - - switch (reason) { - case 'discount_already_applied': - case 'redemption_limit_exceeded': - return ( -
-
-
- {productName} -

- {l10n.getString( - 'churn-error-page-title-discount-already-applied', - 'Discount code already applied' - )} -

-
-

+ if (!cmsOfferingContent || !pageContent || pageContent.flowType === 'not_found') { + switch (reason) { + case 'customer_mismatch': + return ( +

+
+
+

{l10n.getString( - 'churn-error-page-message-discount-already-applied', - { productName }, - `This discount was applied to a ${productName} subscription for your account. If you still need help, contact our Support team.` + 'churn-error-page-title-customer-mismatch', + "Coupon can't be redeemed" )} -

-

-
- + +

{l10n.getString( - 'churn-error-page-button-manage-subscriptions', - 'Manage subscriptions' + 'churn-error-page-message-customer-mismatch', + 'This coupon was issued for a different subscription and can only be redeemed by the original recipient.' )} - - +

+ + {l10n.getString( + 'churn-error-page-button-sign-in', + 'Sign in' + )} + + + {l10n.getString( + 'churn-error-page-button-contact-support', + 'Contact Support' + )} + +
+
+
+
+ ); + default: + break; + } + } else { + const { productName, successActionButtonUrl, supportUrl, webIcon } = + cmsOfferingContent; + + switch (reason) { + case 'discount_already_applied': + case 'redemption_limit_exceeded': + return ( +
+
+
+ {productName} +

{l10n.getString( - 'churn-error-page-button-contact-support', - 'Contact Support' + 'churn-error-page-title-discount-already-applied', + 'Discount code already applied' )} - +

+
+

+ {l10n.getString( + 'churn-error-page-message-discount-already-applied', + { productName }, + `This discount was applied to a ${productName} subscription for your account. If you still need help, contact our Support team.` + )} +

+
+
+ + {l10n.getString( + 'churn-error-page-button-manage-subscriptions', + 'Manage subscriptions' + )} + + + {l10n.getString( + 'churn-error-page-button-contact-support', + 'Contact Support' + )} + +
-
-
- ); - case 'subscription_not_active': - return ( -
-
-
- {productName} -

- {l10n.getString( - 'churn-error-page-title-subscription-not-active', - { productName }, - `This discount is only available to current ${productName} - subscribers` - )} -

-
- + ); + case 'subscription_not_active': + return ( +
+
+
+ {productName} +

{l10n.getString( - 'churn-error-page-button-go-to-product-page', + 'churn-error-page-title-subscription-not-active', { productName }, - `Go to ${productName}` + `This discount is only available to current ${productName} + subscribers` )} - +

+
+ + {l10n.getString( + 'churn-error-page-button-go-to-product-page', + { productName }, + `Go to ${productName}` + )} + +
-
-
- ); - case 'subscription_still_active': - if (!pageContent || pageContent.flowType === 'not_found') { - // Re-render as general error section below - break; - } + + ); + case 'subscription_still_active': + if (!pageContent) { + // Re-render as general error section below + break; + } - const nextChargeChurnContent = getNextChargeChurnContent({ - currency: pageContent.currency, - currentPeriodEnd: pageContent.currentPeriodEnd, - locale, - defaultPaymentMethodType: pageContent.defaultPaymentMethodType, - last4: pageContent.last4, - nextInvoiceTotal: pageContent.nextInvoiceTotal, - nextInvoiceTax: pageContent.nextInvoiceTax, - }); + const nextChargeChurnContent = getNextChargeChurnContent({ + currency: pageContent.currency, + currentPeriodEnd: pageContent.currentPeriodEnd, + locale, + defaultPaymentMethodType: pageContent.defaultPaymentMethodType, + last4: pageContent.last4, + nextInvoiceTotal: pageContent.nextInvoiceTotal, + nextInvoiceTax: pageContent.nextInvoiceTax, + }); - return ( -
-
-
- {productName} + return ( +
+
+
+ {productName} -

- {l10n.getString( - 'churn-error-page-title-subscription-still-active', - { productName }, - `Your ${productName} subscription is still active` - )} -

-
-

- {l10n.getString( - nextChargeChurnContent.l10nId, - nextChargeChurnContent.l10nVars, - nextChargeChurnContent.fallback - )} -

-
-
- {l10n.getString( - 'churn-error-page-button-go-to-product-page', + 'churn-error-page-title-subscription-still-active', { productName }, - `Go to ${productName}` + `Your ${productName} subscription is still active` )} - - - {l10n.getString( - 'churn-error-page-button-manage-subscriptions', - 'Manage subscriptions' - )} - + +
+

+ {l10n.getString( + nextChargeChurnContent.l10nId, + nextChargeChurnContent.l10nVars, + nextChargeChurnContent.fallback + )} +

+
+
+ + {l10n.getString( + 'churn-error-page-button-go-to-product-page', + { productName }, + `Go to ${productName}` + )} + + + {l10n.getString( + 'churn-error-page-button-manage-subscriptions', + 'Manage subscriptions' + )} + +
-
-
- ); - case 'general_error': - default: - break; +
+ ); + default: + break; + } } return (