diff --git a/apps/payments/next/app/[locale]/subscriptions/[subscriptionId]/loyalty-discount/cancel/error/en.ftl b/apps/payments/next/app/[locale]/subscriptions/[subscriptionId]/loyalty-discount/cancel/error/en.ftl new file mode 100644 index 00000000000..b60b7542cb4 --- /dev/null +++ b/apps/payments/next/app/[locale]/subscriptions/[subscriptionId]/loyalty-discount/cancel/error/en.ftl @@ -0,0 +1,13 @@ +## Error page - churn cancel flow + +churn-cancel-flow-error-offer-expired-title = This offer has expired +churn-cancel-flow-error-offer-expired-message = There are currently no discounts available for this subscription. You can continue with cancellation if you’d like. +churn-cancel-flow-error-button-continue-to-cancel = Continue to cancel +churn-cancel-flow-error-page-button-back-to-subscriptions = Back to subscriptions +churn-cancel-flow-error-already-canceling-title = Your subscription is set to end +# $productName (String) - The name of the product to create subscription, e.g. Mozilla VPN +# $currentPeriodEnd (Date) - The end date of the subscription's current billing period (e.g., September, 8, 2025) +churn-cancel-flow-error-already-canceling-message = You’ll continue to have access to { $productName } until { $currentPeriodEnd }. +churn-cancel-flow-error-page-button-keep-subscription = Keep subscription + +## 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 new file mode 100644 index 00000000000..9c24cb61d39 --- /dev/null +++ b/apps/payments/next/app/[locale]/subscriptions/[subscriptionId]/loyalty-discount/cancel/error/page.tsx @@ -0,0 +1,202 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { headers } from 'next/headers'; +import Image from 'next/image'; +import Link from 'next/link'; +import { notFound, redirect } from 'next/navigation'; + +import { SubscriptionParams } from '@fxa/payments/ui'; +import { determineChurnCancelEligibilityAction } from '@fxa/payments/ui/actions'; +import { ChurnError, getApp } from '@fxa/payments/ui/server'; +import { getLocalizedDateString } from '@fxa/shared/l10n'; +import { auth } from 'apps/payments/next/auth'; +import { config } from 'apps/payments/next/config'; + +export default async function LoyaltyDiscountCancelErrorPage({ + params, + searchParams, +}: { + params: SubscriptionParams; + searchParams: Record | undefined; +}) { + const { locale, subscriptionId } = params; + + if (!config.churnInterventionConfig.enabled) { + redirect(`/${locale}/subscriptions/${subscriptionId}/cancel`); + } + + const acceptLanguage = headers().get('accept-language'); + const l10n = getApp().getL10n(acceptLanguage, locale); + + const session = await auth(); + if (!session?.user?.id) { + const redirectToUrl = new URL( + `${config.paymentsNextHostedUrl}/${locale}/subscriptions/landing` + ); + redirectToUrl.search = new URLSearchParams(searchParams).toString(); + redirect(redirectToUrl.href); + } + + const uid = session.user.id; + + const pageContent = await determineChurnCancelEligibilityAction( + uid, + subscriptionId, + acceptLanguage + ); + + if (!pageContent) { + notFound(); + } + + const { churnCancelContentEligibility } = pageContent; + const { cmsOfferingContent, reason } = churnCancelContentEligibility; + + if (!cmsOfferingContent) { + notFound(); + } + + const cancelContent = pageContent.cancelContent; + + if (cancelContent.flowType !== 'cancel') { + return ( + + ); + } + + if (reason === 'no_churn_intervention_found') { + const { productName, webIcon } = cmsOfferingContent; + return ( +
+
+
+ {productName} + +

+ {l10n.getString( + 'churn-cancel-flow-error-offer-expired-title', + 'This offer has expired' + )} +

+
+

+ {l10n.getString( + 'churn-cancel-flow-error-offer-expired-message', + `There are currently no discounts available for this subscription. You can continue with cancellation if you’d like.` + )} +

+
+
+ + {l10n.getString( + 'churn-cancel-flow-error-button-continue-to-cancel', + 'Continue to cancel' + )} + + + {l10n.getString( + 'churn-cancel-flow-error-page-button-back-to-subscriptions', + 'Back to subscriptions' + )} + +
+
+
+
+ ); + } + + if (reason === 'already_canceling_at_period_end') { + const { productName, webIcon } = cmsOfferingContent; + const { currentPeriodEnd } = cancelContent; + const currentPeriodEndLongFallback = getLocalizedDateString( + currentPeriodEnd, + false, + locale + ); + return ( +
+
+
+ {productName} + +

+ {l10n.getString( + 'churn-cancel-flow-error-already-canceling-title', + 'Your subscription is set to end' + )} +

+
+

+ {l10n.getString( + 'churn-cancel-flow-error-already-canceling-message', + { + productName, + currentPeriodEnd: currentPeriodEndLongFallback, + }, + `You’ll continue to have access to ${productName} until ${currentPeriodEndLongFallback}.` + )} +

+
+
+ + {l10n.getString( + 'churn-cancel-flow-error-page-button-back-to-subscriptions', + 'Back to subscriptions' + )} + + + {l10n.getString( + 'churn-cancel-flow-error-page-button-keep-subscription', + 'Keep subscription' + )} + +
+
+
+
+ ); + } + + return ( + + ); +} diff --git a/apps/payments/next/app/[locale]/subscriptions/[subscriptionId]/loyalty-discount/cancel/page.tsx b/apps/payments/next/app/[locale]/subscriptions/[subscriptionId]/loyalty-discount/cancel/page.tsx new file mode 100644 index 00000000000..d4227daa5db --- /dev/null +++ b/apps/payments/next/app/[locale]/subscriptions/[subscriptionId]/loyalty-discount/cancel/page.tsx @@ -0,0 +1,100 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { headers } from 'next/headers'; +import { notFound, redirect } from 'next/navigation'; + +import { ChurnCancel, SubscriptionParams } from '@fxa/payments/ui'; +import { determineChurnCancelEligibilityAction } from '@fxa/payments/ui/actions'; +import { auth } from 'apps/payments/next/auth'; +import { config } from 'apps/payments/next/config'; + +const churnCancelErrorReasons = [ + 'already_canceling_at_period_end', + 'subscription_not_active', + 'subscription_still_active', + 'no_churn_intervention_found', + 'general_error', + 'redemption_limit_exceeded', +]; + +export default async function LoyaltyDiscountCancelPage({ + params, + searchParams, +}: { + params: SubscriptionParams; + searchParams: Record | undefined; +}) { + const { locale, subscriptionId } = params; + + if (!config.churnInterventionConfig.enabled) { + redirect(`/${locale}/subscriptions/${subscriptionId}/cancel`); + } + + const acceptLanguage = headers().get('accept-language'); + + const session = await auth(); + if (!session?.user?.id) { + const redirectToUrl = new URL( + `${config.paymentsNextHostedUrl}/${locale}/subscriptions/landing` + ); + redirectToUrl.search = new URLSearchParams(searchParams).toString(); + redirect(redirectToUrl.href); + } + + const uid = session.user.id; + + const pageContent = await determineChurnCancelEligibilityAction( + uid, + subscriptionId, + acceptLanguage + ); + + if (!pageContent) notFound(); + + const { churnCancelContentEligibility, cancelContent } = pageContent; + const { cmsOfferingContent, reason, cmsChurnInterventionEntry } = + churnCancelContentEligibility; + + const reasonStr = typeof reason === 'string' ? reason : undefined; + const isErrorReason = + !!reasonStr && churnCancelErrorReasons.includes(reasonStr); + const isAllowedCancelReason = + reasonStr === 'eligible' || reasonStr === 'discount_already_applied'; + + if (isErrorReason) { + redirect( + `/${locale}/subscriptions/${subscriptionId}/loyalty-discount/cancel/error` + ); + } + + if (!isAllowedCancelReason) { + redirect( + `/${locale}/subscriptions/${subscriptionId}/loyalty-discount/cancel/error` + ); + } + + if (!cancelContent || cancelContent.flowType !== 'cancel') { + redirect( + `/${locale}/subscriptions/${subscriptionId}/loyalty-discount/cancel/error` + ); + } + + if (!cmsChurnInterventionEntry) { + redirect( + `/${locale}/subscriptions/${subscriptionId}/loyalty-discount/cancel/error` + ); + } + return ( + + ); +} diff --git a/apps/payments/next/app/[locale]/subscriptions/[subscriptionId]/loyalty-discount/stay-subscribed/error/page.tsx b/apps/payments/next/app/[locale]/subscriptions/[subscriptionId]/loyalty-discount/stay-subscribed/error/page.tsx index fde7e3f8ec5..ab390478d3f 100644 --- a/apps/payments/next/app/[locale]/subscriptions/[subscriptionId]/loyalty-discount/stay-subscribed/error/page.tsx +++ b/apps/payments/next/app/[locale]/subscriptions/[subscriptionId]/loyalty-discount/stay-subscribed/error/page.tsx @@ -47,7 +47,8 @@ export default async function LoyaltyDiscountStaySubscribedErrorPage({ notFound(); } - const { cmsOfferingContent, reason } = pageContent; + const { churnStaySubscribedEligibility } = pageContent; + const { cmsOfferingContent, reason } = churnStaySubscribedEligibility; if (!cmsOfferingContent) { notFound(); diff --git a/apps/payments/next/app/[locale]/subscriptions/[subscriptionId]/loyalty-discount/stay-subscribed/page.tsx b/apps/payments/next/app/[locale]/subscriptions/[subscriptionId]/loyalty-discount/stay-subscribed/page.tsx index ce73651c8b3..31c36171a2a 100644 --- a/apps/payments/next/app/[locale]/subscriptions/[subscriptionId]/loyalty-discount/stay-subscribed/page.tsx +++ b/apps/payments/next/app/[locale]/subscriptions/[subscriptionId]/loyalty-discount/stay-subscribed/page.tsx @@ -10,12 +10,12 @@ import { determineStaySubscribedEligibilityAction } from '@fxa/payments/ui/actio import { auth } from 'apps/payments/next/auth'; import { config } from 'apps/payments/next/config'; -enum ChurnStayErrorReason { - DiscountAlreadyApplied = 'discount_already_applied', - SubscriptionNotActive = 'subscription_not_active', - GeneralError = 'general_error', - RedemptionLimitExceeded = 'redemption_limit_exceeded', -} +const churnStayErrorReasons = [ + 'discount_already_applied', + 'subscription_not_active', + 'general_error', + 'redemption_limit_exceeded', +]; export default async function LoyaltyDiscountStaySubscribedPage({ params, @@ -51,11 +51,12 @@ export default async function LoyaltyDiscountStaySubscribedPage({ if (!pageContent) notFound(); - const { cmsOfferingContent, reason, staySubscribedContent } = pageContent; + const { churnStaySubscribedEligibility, staySubscribedContent } = pageContent; + const { cmsOfferingContent, reason, cmsChurnInterventionEntry } = + churnStaySubscribedEligibility; const reasonStr = typeof reason === 'string' ? reason : undefined; const isErrorReason = - !!reasonStr && - (Object.values(ChurnStayErrorReason) as string[]).includes(reasonStr); + !!reasonStr && churnStayErrorReasons.includes(reasonStr); const isAllowedStayReason = reasonStr === 'eligible' || reasonStr === 'no_churn_intervention_found' || @@ -81,7 +82,7 @@ export default async function LoyaltyDiscountStaySubscribedPage({ subscriptionId={subscriptionId} locale={locale} reason={reason} - cmsChurnInterventionEntry={pageContent.cmsChurnInterventionEntry} + cmsChurnInterventionEntry={cmsChurnInterventionEntry} cmsOfferingContent={cmsOfferingContent} staySubscribedContent={staySubscribedContent} /> 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..f8fecc4be03 100644 --- a/libs/payments/management/src/lib/churn-intervention.service.spec.ts +++ b/libs/payments/management/src/lib/churn-intervention.service.spec.ts @@ -576,7 +576,8 @@ describe('ChurnInterventionService', () => { const result = await churnInterventionService.redeemChurnCoupon( mockUid, - mockSubscription.id + mockSubscription.id, + 'stay_subscribed' ); expect( @@ -625,7 +626,8 @@ describe('ChurnInterventionService', () => { const result = await churnInterventionService.redeemChurnCoupon( mockUid, - mockSubscription.id + mockSubscription.id, + 'stay_subscribed' ); expect( @@ -660,7 +662,8 @@ describe('ChurnInterventionService', () => { const result = await churnInterventionService.redeemChurnCoupon( mockUid, - mockSubscription.id + mockSubscription.id, + 'stay_subscribed' ); expect( @@ -700,7 +703,8 @@ describe('ChurnInterventionService', () => { const result = await churnInterventionService.redeemChurnCoupon( mockUid, - mockSubscription.id + mockSubscription.id, + 'stay_subscribed' ); expect( @@ -762,6 +766,7 @@ describe('ChurnInterventionService', () => { isEligible: false, reason: 'no_churn_intervention_found', cmsChurnInterventionEntry: null, + cmsOfferingContent: null, }); const mockCancelInterstitialOffer = @@ -825,6 +830,7 @@ describe('ChurnInterventionService', () => { isEligible: true, reason: 'eligible', cmsChurnInterventionEntry: mockCmsOffer, + cmsOfferingContent: null, }); const result = @@ -873,6 +879,7 @@ describe('ChurnInterventionService', () => { isEligible: false, reason: 'no_churn_intervention_found', cmsChurnInterventionEntry: null, + cmsOfferingContent: null, }); const result = @@ -928,6 +935,7 @@ describe('ChurnInterventionService', () => { isEligible: false, reason: 'no_churn_intervention_found', cmsChurnInterventionEntry: null, + cmsOfferingContent: null, }); jest .spyOn(productConfigurationManager, 'getSubplatIntervalBySubscription') @@ -1000,6 +1008,7 @@ describe('ChurnInterventionService', () => { isEligible: false, reason: 'no_churn_intervention_found', cmsChurnInterventionEntry: null, + cmsOfferingContent: null, }); jest .spyOn(productConfigurationManager, 'getSubplatIntervalBySubscription') diff --git a/libs/payments/management/src/lib/churn-intervention.service.ts b/libs/payments/management/src/lib/churn-intervention.service.ts index 545a03a7416..8c2b7175b31 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() @@ -275,6 +275,7 @@ export class ChurnInterventionService { async redeemChurnCoupon( uid: string, subscriptionId: string, + churnType: 'cancel' | 'stay_subscribed', acceptLanguage?: string | null, selectedLanguage?: string ) { @@ -284,23 +285,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, }; } @@ -638,8 +650,44 @@ export class ChurnInterventionService { args.selectedLanguage ); + const cmsContent = cmsChurnResult.cmsOfferingContent(); + 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 { + isEligible: false, + reason: 'subscription_not_active', + cmsChurnInterventionEntry: null, + cmsOfferingContent: cmsContent, + }; + } + const cmsChurnInterventionEntries = cmsChurnResult.getTransformedChurnInterventionByProductId(); + if (!cmsChurnInterventionEntries.length) { this.statsd.increment('cancel_intervention_decision', { type: 'none', @@ -649,10 +697,25 @@ export class ChurnInterventionService { isEligible: false, reason: 'no_churn_intervention_found', cmsChurnInterventionEntry: null, + cmsOfferingContent: cmsContent, }; } 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, + cmsOfferingContent: cmsContent, + }; + } + const redemptionCount = await this.churnInterventionManager.getRedemptionCountForUid( args.uid, @@ -674,6 +737,7 @@ export class ChurnInterventionService { isEligible: false, reason: 'redemption_limit_exceeded', cmsChurnInterventionEntry: null, + cmsOfferingContent: cmsContent, }; } @@ -691,7 +755,8 @@ export class ChurnInterventionService { return { isEligible: false, reason: 'discount_already_applied', - cmsChurnInterventionEntry: null, + cmsChurnInterventionEntry, + cmsOfferingContent: cmsContent, }; } @@ -702,6 +767,7 @@ export class ChurnInterventionService { 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 1bd24e1e239..241a170606c 100644 --- a/libs/payments/management/src/lib/subscriptionManagement.service.spec.ts +++ b/libs/payments/management/src/lib/subscriptionManagement.service.spec.ts @@ -6,6 +6,10 @@ import { faker } from '@faker-js/faker'; import { Logger } from '@nestjs/common'; import { Test } from '@nestjs/testing'; +import { + ChurnInterventionManager, + MockChurnInterventionConfigProvider, +} from '@fxa/payments/cart'; import { CurrencyManager, MockCurrencyConfigProvider, @@ -23,6 +27,12 @@ import { CustomerSessionManager, SubPlatPaymentMethodType, } from '@fxa/payments/customer'; +import { + EligibilityManager, + EligibilityService, + LocationConfig, + MockLocationConfigProvider, +} from '@fxa/payments/eligibility'; import { AppleIapClient, AppleIapPurchaseManager, @@ -105,12 +115,10 @@ import { NotifierService, NotifierSnsProvider, } from '@fxa/shared/notifier'; - import { MockProfileClientConfigProvider, ProfileClient, } from '@fxa/profile/client'; - import { LOGGER_PROVIDER } from '@fxa/shared/log'; jest.mock('@fxa/shared/error', () => ({ @@ -150,11 +158,16 @@ describe('SubscriptionManagementService', () => { AccountCustomerManager, AppleIapClient, AppleIapPurchaseManager, + ChurnInterventionManager, + ChurnInterventionService, CurrencyManager, CustomerManager, + EligibilityManager, + EligibilityService, GoogleIapClient, GoogleIapPurchaseManager, InvoiceManager, + LocationConfig, MockAccountDatabaseNestFactory, MockAppleIapClientConfigProvider, MockCurrencyConfigProvider, @@ -184,16 +197,12 @@ describe('SubscriptionManagementService', () => { CustomerSessionManager, CurrencyManager, MockCurrencyConfigProvider, + MockChurnInterventionConfigProvider, + MockLocationConfigProvider, { provide: LOGGER_PROVIDER, useValue: mockLogger, }, - { - provide: ChurnInterventionService, - useValue: { - determineStaySubscribedEligibility: jest.fn(), - }, - }, ], }).compile(); @@ -1866,19 +1875,48 @@ describe('SubscriptionManagementService', () => { describe('getCancelFlowContent', () => { it('returns content (flowType - cancel)', async () => { const mockUid = faker.string.uuid(); + const mockStripeCustomer = StripeResponseFactory( + StripeCustomerFactory({ + currency: 'usd', + }) + ); const mockSubscription = StripeResponseFactory( - StripeSubscriptionFactory() + StripeSubscriptionFactory({ + customer: mockStripeCustomer.id, + status: 'active', + }) ); const mockProductNameByPriceIdsResultUtil = { purchaseForPriceId: jest.fn(), }; + const mockPaymentMethod = StripeResponseFactory( + StripePaymentMethodFactory({ + customer: mockStripeCustomer.id, + }) + ); + const mockPaymentMethodInformation = { + type: SubPlatPaymentMethodType.Card, + brand: mockPaymentMethod.card?.brand, + last4: mockPaymentMethod.card?.last4, + expMonth: mockPaymentMethod.card?.exp_month, + expYear: mockPaymentMethod.card?.exp_year, + hasPaymentMethodError: undefined, + }; + const mockUpcomingInvoicePreview = InvoicePreviewFactory(); + jest .spyOn( subscriptionManagementService as any, 'validateAndRetrieveSubscription' ) .mockResolvedValue(mockSubscription); + jest + .spyOn(customerManager, 'retrieve') + .mockResolvedValue(mockStripeCustomer); + jest + .spyOn(paymentMethodManager, 'getDefaultPaymentMethod') + .mockResolvedValue(mockPaymentMethodInformation); jest .spyOn(productConfigurationManager, 'getPageContentByPriceIds') .mockResolvedValue( @@ -1889,6 +1927,10 @@ describe('SubscriptionManagementService', () => { PageContentByPriceIdsPurchaseResultFactory() ); + jest + .spyOn(invoiceManager, 'previewUpcomingSubscription') + .mockResolvedValue(mockUpcomingInvoicePreview); + const result = await subscriptionManagementService.getCancelFlowContent( mockUid, mockSubscription.id @@ -1923,6 +1965,24 @@ describe('SubscriptionManagementService', () => { const mockSubscription = StripeResponseFactory( StripeSubscriptionFactory() ); + const mockStripeCustomer = StripeResponseFactory( + StripeCustomerFactory({ + currency: 'usd', + }) + ); + const mockPaymentMethod = StripeResponseFactory( + StripePaymentMethodFactory({ + customer: mockStripeCustomer.id, + }) + ); + const mockPaymentMethodInformation = { + type: SubPlatPaymentMethodType.Card, + brand: mockPaymentMethod.card?.brand, + last4: mockPaymentMethod.card?.last4, + expMonth: mockPaymentMethod.card?.exp_month, + expYear: mockPaymentMethod.card?.exp_year, + hasPaymentMethodError: undefined, + }; const mockProductNameByPriceIdsResultUtil = { purchaseForPriceId: jest.fn(), }; @@ -1933,6 +1993,12 @@ describe('SubscriptionManagementService', () => { 'validateAndRetrieveSubscription' ) .mockResolvedValue(mockSubscription); + jest + .spyOn(customerManager, 'retrieve') + .mockResolvedValue(mockStripeCustomer); + jest + .spyOn(paymentMethodManager, 'getDefaultPaymentMethod') + .mockResolvedValue(mockPaymentMethodInformation); jest .spyOn(productConfigurationManager, 'getPageContentByPriceIds') .mockResolvedValue( diff --git a/libs/payments/management/src/lib/subscriptionManagement.service.ts b/libs/payments/management/src/lib/subscriptionManagement.service.ts index 5f722555889..cc24abff49f 100644 --- a/libs/payments/management/src/lib/subscriptionManagement.service.ts +++ b/libs/payments/management/src/lib/subscriptionManagement.service.ts @@ -422,12 +422,29 @@ export class SubscriptionManagementService { const subplatInterval = getSubplatInterval(interval, intervalCount); - const [latestInvoice, upcomingInvoice] = await Promise.all([ + const [ + latestInvoice, + upcomingInvoice, + staySubscribedResult, + cancelIntervention, + ] = await Promise.all([ this.invoiceManager.preview(latestInvoiceId), this.invoiceManager.previewUpcomingSubscription({ customer, subscription, }), + this.churnInterventionService.determineStaySubscribedEligibility( + uid, + subscription.id, + acceptLanguage, + selectedLanguage + ), + this.churnInterventionService.determineCancellationIntervention({ + uid, + subscriptionId: subscription.id, + acceptLanguage, + selectedLanguage, + }), ]); if (!latestInvoice) { @@ -473,22 +490,6 @@ export class SubscriptionManagementService { .filter((tax) => !tax.inclusive) .reduce((sum, tax) => sum + tax.amount, 0); - const staySubscribedResult = - await this.churnInterventionService.determineStaySubscribedEligibility( - uid, - subscription.id, - acceptLanguage, - selectedLanguage - ); - - const cancelIntervention = - await this.churnInterventionService.determineCancellationIntervention({ - uid, - subscriptionId: subscription.id, - acceptLanguage, - selectedLanguage, - }); - return { id: subscription.id, productName, @@ -594,6 +595,28 @@ 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 + ); + const item = subscription.items.data[0]; const price = item.price; const priceId = price.id; @@ -618,11 +641,41 @@ export class SubscriptionManagementService { 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 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, diff --git a/libs/payments/management/src/lib/types.ts b/libs/payments/management/src/lib/types.ts index c03743cf0a6..5667c30caf1 100644 --- a/libs/payments/management/src/lib/types.ts +++ b/libs/payments/management/src/lib/types.ts @@ -48,7 +48,12 @@ export type CancelFlowResult = flowType: 'cancel'; active: boolean; cancelAtPeriodEnd: boolean; + currency: string; currentPeriodEnd: number; + defaultPaymentMethodType?: SubPlatPaymentMethodType; + last4?: string; + nextInvoiceTax?: number; + nextInvoiceTotal?: number; productName: string; supportUrl: string; webIcon: string; diff --git a/libs/payments/ui/src/index.ts b/libs/payments/ui/src/index.ts index 1be7933d48f..d683209fb4a 100644 --- a/libs/payments/ui/src/index.ts +++ b/libs/payments/ui/src/index.ts @@ -12,6 +12,7 @@ export * from './lib/client/components/Breadcrumbs'; export * from './lib/client/components/CancelSubscription'; export * from './lib/client/components/CheckoutForm'; export * from './lib/client/components/CheckoutCheckbox'; +export * from './lib/client/components/ChurnCancel'; export * from './lib/client/components/ChurnStaySubscribed'; export * from './lib/client/components/CouponForm'; export * from './lib/client/components/Header'; diff --git a/libs/payments/ui/src/lib/actions/determineChurnCancelEligibility.ts b/libs/payments/ui/src/lib/actions/determineChurnCancelEligibility.ts new file mode 100644 index 00000000000..341d07320d5 --- /dev/null +++ b/libs/payments/ui/src/lib/actions/determineChurnCancelEligibility.ts @@ -0,0 +1,21 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use server'; + +import { getApp } from '../nestapp/app'; + +export const determineChurnCancelEligibilityAction = async ( + uid: string, + subscriptionId: string, + acceptLanguage?: string | null, + selectedLanguage?: string +) => { + return await getApp().getActionsService().determineChurnCancelEligibility({ + uid, + subscriptionId, + acceptLanguage, + selectedLanguage, + }); +}; diff --git a/libs/payments/ui/src/lib/actions/index.ts b/libs/payments/ui/src/lib/actions/index.ts index f50a140f114..0ef9f6302d6 100644 --- a/libs/payments/ui/src/lib/actions/index.ts +++ b/libs/payments/ui/src/lib/actions/index.ts @@ -8,6 +8,7 @@ export { checkoutCartWithPaypal } from './checkoutCartWithPaypal'; export { checkoutCartWithStripe } from './checkoutCartWithStripe'; export { determineCurrencyAction } from './determineCurrency'; export { determineCurrencyForCustomerAction } from './determineCurrencyForCustomer'; +export { determineChurnCancelEligibilityAction } from './determineChurnCancelEligibility'; export { determineStaySubscribedEligibilityAction } from './determineStaySubscribedEligibility'; export { determineCancellationInterventionAction } from './determineCancellationIntervention'; export { fetchCMSData } from './fetchCMSData'; 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/ChurnCancel/en.ftl b/libs/payments/ui/src/lib/client/components/ChurnCancel/en.ftl new file mode 100644 index 00000000000..1b339e89639 --- /dev/null +++ b/libs/payments/ui/src/lib/client/components/ChurnCancel/en.ftl @@ -0,0 +1,24 @@ +## Churn flow - cancel + +churn-cancel-flow-success-title = You’re still subscribed +# $discountPercent (Number) - The discount amount between 1 and 100 as an integer (e.g, 'you’ll save 10% on your next bill', discountPercent = 10) +churn-cancel-flow-success-message = Your subscription will continue, and you’ll save { $discountPercent }% on your next bill. +# $productName (String) - The name of the product to create subscription, e.g. Mozilla VPN +churn-cancel-flow-thanks-valued-subscriber = Thanks for using { $productName }! +churn-cancel-flow-button-back-to-subscriptions = Back to subscriptions +churn-cancel-flow-action-error = An unexpected error occurred. Please try again. +# $discountPercent (Number) - The discount amount between 1 and 100 as an integer (e.g, 'Stay subscribed and save 10%', discountPercent = 10) +churn-cancel-flow-button-stay-subscribed-and-save-discount = Stay subscribed and save { $discountPercent }% +churn-cancel-flow-button-stay-subscribed-and-save = Stay subscribed and save +churn-cancel-flow-button-continue-to-cancel = Continue to cancel +churn-cancel-flow-link-terms-and-restrictions = Limited terms and restrictions apply +churn-cancel-flow-discount-already-applied-title = Discount code already applied +# $productName (String) - The name of the product to create subscription, e.g. Mozilla VPN +churn-cancel-flow-discount-already-applied-message = This discount was applied to a { $productName } subscription for your account. If you still need help, contact our Support team. +churn-cancel-flow-button-manage-subscriptions = Manage subscriptions +churn-cancel-flow-button-contact-support = Contact Support +## $productName (String) - The name of the product to create subscription, e.g. Mozilla VPN +churn-cancel-flow-subscription-active-title = Your { $productName } subscription is active +churn-cancel-flow-button-go-to-product-page = Go to { $productName } + +## diff --git a/libs/payments/ui/src/lib/client/components/ChurnCancel/index.tsx b/libs/payments/ui/src/lib/client/components/ChurnCancel/index.tsx new file mode 100644 index 00000000000..4c4f5240e0e --- /dev/null +++ b/libs/payments/ui/src/lib/client/components/ChurnCancel/index.tsx @@ -0,0 +1,400 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use client'; + +import { Localized } from '@fluent/react'; +import * as Form from '@radix-ui/react-form'; +import Image from 'next/image'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; + +import { SubPlatPaymentMethodType } from '@fxa/payments/customer'; +import { + getNextChargeChurnContent, + ButtonVariant, + SubmitButton, +} from '@fxa/payments/ui'; +import { redeemChurnCouponAction } from '@fxa/payments/ui/actions'; +import spinner from '@fxa/shared/assets/images/spinner.svg'; +import newWindowIcon from '@fxa/shared/assets/images/new-window.svg'; +import { LinkExternal } from '@fxa/shared/react'; + +interface ChurnCancelProps { + uid: string; + subscriptionId: string; + locale: string; + reason: string; + cmsChurnInterventionEntry: { + apiIdentifier: string; + churnInterventionId: string; + churnType: string; + ctaMessage: string; + discountAmount: number; + modalHeading: string; + modalMessage: string[]; + redemptionLimit?: number | null; + interval: string; + productPageUrl: string; + stripeCouponId: string; + supportUrl: string; + termsHeading: string; + termsDetails: string[]; + webIcon: string; + }; + cmsOfferingContent: { + successActionButtonUrl: string; + } | null; + cancelContent: { + flowType: 'cancel'; + active: boolean; + cancelAtPeriodEnd: boolean; + currency: string; + currentPeriodEnd: number; + defaultPaymentMethodType?: SubPlatPaymentMethodType; + last4?: string; + nextInvoiceTax?: number; + nextInvoiceTotal?: number; + productName: string; + supportUrl: string; + webIcon: string; + }; +} + +export function ChurnCancel({ + uid, + subscriptionId, + locale, + reason, + cancelContent, + cmsChurnInterventionEntry, + cmsOfferingContent, +}: ChurnCancelProps) { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [showResubscribeActionError, setResubscribeActionError] = + useState(false); + const [showSuccess, setShowSuccess] = useState(false); + const { + apiIdentifier, + discountAmount, + interval, + modalHeading, + modalMessage, + productPageUrl, + } = cmsChurnInterventionEntry || {}; + + const { successActionButtonUrl } = cmsOfferingContent || {}; + const goToProductUrl = productPageUrl ?? successActionButtonUrl; + + const { active, cancelAtPeriodEnd, productName, webIcon } = cancelContent; + + const nextChargeChurnContent = getNextChargeChurnContent({ + currency: cancelContent.currency, + currentPeriodEnd: cancelContent.currentPeriodEnd, + locale, + defaultPaymentMethodType: cancelContent.defaultPaymentMethodType, + discountAmount, + last4: cancelContent.last4, + nextInvoiceTax: cancelContent.nextInvoiceTax, + nextInvoiceTotal: cancelContent.nextInvoiceTotal, + }); + + async function churnCancelFlow() { + if (loading) return; + + setLoading(true); + setResubscribeActionError(false); + + 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 { + setResubscribeActionError(true); + } + setLoading(false); + } + + const isActiveNotCanceling = !!active && !cancelAtPeriodEnd; + const isOffer = reason === 'eligible' && isActiveNotCanceling; + const isDiscountAlreadyApplied = + reason === 'discount_already_applied' && isActiveNotCanceling; + + return ( +
+ {showSuccess ? ( +
+
+ {productName} + +

+ You’re still subscribed +

+
+
+ +

+ Your subscription will continue, and you’ll save{' '} + {discountAmount}% on your next bill. +

+
+ +

Thanks for using {productName}!

+
+
+ +
+ { + e.preventDefault(); + setLoading(true); + router.push(`/${locale}/subscriptions/landing`); + }} + > + {loading ? ( + + ) : ( + + Back to subscriptions + + )} + +
+
+
+ ) : isOffer ? ( + +
+ {productName} +

+ {modalHeading} +

+ +
+ {modalMessage && ( +
+ {modalMessage.map((line, i) => ( +

+ {line} + {i === modalMessage.length - 1 && ( + + )} +

+ ))} +
+ )} + + +

+
+
+ + {showResubscribeActionError && !loading && ( + +

+ An unexpected error occurred. Please try again. +

+
+ )} + + + + {typeof discountAmount === 'number' && discountAmount > 0 ? ( + + Stay subscribed and save {discountAmount}% + + ) : ( + + Stay subscribed and save + + )} + + + { + e.preventDefault(); + setLoading(true); + router.push( + `/${locale}/subscriptions/${subscriptionId}/cancel` + ); + }} + > + {loading ? ( + + ) : ( + + Continue to cancel + + )} + + + + + + Limited terms and restrictions apply + + + + +
+
+ ) : isDiscountAlreadyApplied ? ( +
+
+
+ {productName} +

+ + Discount code already applied + +

+
+ +

+ This discount was applied to a {productName} subscription + for your account. If you still need help, contact our + Support team. +

+
+
+
+ + + Manage subscriptions + + + + + Contact Support + + +
+
+
+
+ ) : ( +
+
+ {productName} + + +

+ Your {productName} subscription is active +

+
+ +
+ +

+
+
+
+ {goToProductUrl && ( + + + Go to {productName} + + + )} + + + Manage subscriptions + + +
+
+
+ )} +
+ ); +} 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..3824379e50b 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. @@ -231,14 +236,16 @@ export function ChurnStaySubscribed({
{modalMessage && ( -

+

{modalMessage.map((line, i) => ( - +

{line} - + {i === modalMessage.length - 1 && ( + + )} +

))} - -

+
)} , ref: React.Ref) { +export const SubmitButton = forwardRef(function SubmitButton( + { + children, + disabled, + className, + variant, + showLoadingSpinner = true, + ...otherProps + }: SubmitButtonProps & React.HTMLProps, + ref: React.Ref +) { const { pending } = useFormStatus(); - + const variantType = ButtonVariant.SubscriptionManagementSecondary; return ( @@ -50,4 +54,4 @@ export const SubmitButton = forwardRef(function SubmitButton({ )} ); -}) +}); 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..83fc6c641e0 100644 --- a/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts +++ b/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts @@ -59,7 +59,8 @@ import { SubmitNeedsInputActionArgs } from './validators/SubmitNeedsInputActionA import { GetNeedsInputActionArgs } from './validators/GetNeedsInputActionArgs'; import { ValidatePostalCodeActionArgs } from './validators/ValidatePostalCodeActionArgs'; import { DetermineCurrencyActionArgs } from './validators/DetermineCurrencyActionArgs'; -import { DetermineStaySubscribedEligibilityActionArgs } from './validators/DetermineStaySubscribedEligibilityActionArgs'; +import { DetermineChurnCancelEligibilityActionResult } from './validators/DetermineChurnCancelEligibilityActionResult'; +import { DetermineChurnEligibilityActionArgs } from './validators/DetermineChurnEligibilityActionArgs'; import { DetermineCancellationInterventionActionArgs } from './validators/DetermineCancellationInterventionActionArgs'; import { NextIOValidator } from './NextIOValidator'; import type { @@ -275,7 +276,44 @@ export class NextJSActionsService { @SanitizeExceptions() @NextIOValidator( - DetermineStaySubscribedEligibilityActionArgs, + DetermineChurnEligibilityActionArgs, + DetermineChurnCancelEligibilityActionResult + ) + @WithTypeCachableAsyncLocalStorage() + @CaptureTimingWithStatsD() + async determineChurnCancelEligibility(args: { + uid: string; + subscriptionId: string; + acceptLanguage?: string | null; + selectedLanguage?: string; + }) { + const churnCancelContentEligibility = + await this.churnInterventionService.determineCancelChurnContentEligibility( + { + uid: args.uid, + subscriptionId: args.subscriptionId, + acceptLanguage: args.acceptLanguage, + selectedLanguage: args.selectedLanguage, + } + ); + + const cancelContent = + await this.subscriptionManagementService.getCancelFlowContent( + args.uid, + args.subscriptionId, + args.acceptLanguage || undefined, + args.selectedLanguage + ); + + return { + churnCancelContentEligibility, + cancelContent, + }; + } + + @SanitizeExceptions() + @NextIOValidator( + DetermineChurnEligibilityActionArgs, DetermineStaySubscribedEligibilityActionResult ) @WithTypeCachableAsyncLocalStorage() @@ -286,7 +324,7 @@ export class NextJSActionsService { acceptLanguage?: string | null; selectedLanguage?: string; }) { - const result = + const churnStaySubscribedEligibility = await this.churnInterventionService.determineStaySubscribedEligibility( args.uid, args.subscriptionId, @@ -303,7 +341,7 @@ export class NextJSActionsService { ); return { - ...result, + churnStaySubscribedEligibility, staySubscribedContent, }; } @@ -338,12 +376,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/DetermineChurnCancelEligibilityActionResult.ts b/libs/payments/ui/src/lib/nestapp/validators/DetermineChurnCancelEligibilityActionResult.ts new file mode 100644 index 00000000000..140aa5ad835 --- /dev/null +++ b/libs/payments/ui/src/lib/nestapp/validators/DetermineChurnCancelEligibilityActionResult.ts @@ -0,0 +1,157 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + IsArray, + IsBoolean, + IsIn, + IsNumber, + IsOptional, + IsString, + ValidateIf, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class CmsChurnInterventionEntryResult { + @IsString() + webIcon!: string; + + @IsString() + apiIdentifier!: string; + + @IsString() + churnInterventionId!: string; + + @IsString() + churnType!: string; + + @IsOptional() + @IsNumber() + redemptionLimit?: number | null; + + @IsString() + stripeCouponId!: string; + + @IsString() + interval!: string; + + @IsNumber() + discountAmount!: number; + + @IsString() + ctaMessage!: string; + + @IsString() + modalHeading!: string; + + @IsArray() + @IsString({ each: true }) + modalMessage!: string[]; + + @IsString() + productPageUrl!: string; + + @IsString() + termsHeading!: string; + + @IsArray() + @IsString({ each: true }) + termsDetails!: string[]; + + @IsString() + supportUrl!: string; +} + +export class CmsOfferingContent { + @IsString() + productName!: string; + + @IsString() + successActionButtonUrl!: string; + + @IsString() + supportUrl!: string; + + @IsString() + webIcon!: string; +} + +export class ChurnCancelEligibilityResult { + @IsBoolean() + isEligible!: boolean; + + @IsString() + reason!: string; + + @IsOptional() + @ValidateNested() + @Type(() => CmsChurnInterventionEntryResult) + cmsChurnInterventionEntry!: CmsChurnInterventionEntryResult | null; + + @IsOptional() + @ValidateNested() + @Type(() => CmsOfferingContent) + cmsOfferingContent!: CmsOfferingContent | null; +} + +export class CancelFlowResult { + @IsString() + @IsIn(['not_found', 'cancel']) + flowType!: 'not_found' | 'cancel'; + + @ValidateIf((o) => o.flowType !== 'not_found') + @IsBoolean() + active!: boolean; + + @ValidateIf((o) => o.flowType !== 'not_found') + @IsBoolean() + cancelAtPeriodEnd!: boolean; + + @ValidateIf((o) => o.flowType !== 'not_found') + @IsString() + currency!: string; + + @ValidateIf((o) => o.flowType !== 'not_found') + @IsNumber() + currentPeriodEnd!: number; + + @ValidateIf((o) => o.flowType !== 'not_found') + @IsOptional() + @IsString() + defaultPaymentMethodType?: string; + + @ValidateIf((o) => o.flowType !== 'not_found') + @IsOptional() + @IsString() + last4?: string; + + @ValidateIf((o) => o.flowType !== 'not_found') + @IsOptional() + @IsNumber() + nextInvoiceTax?: number; + + @ValidateIf((o) => o.flowType !== 'not_found') + @IsOptional() + @IsNumber() + nextInvoiceTotal?: number; + + @ValidateIf((o) => o.flowType !== 'not_found') + @IsString() + productName!: string; + + @ValidateIf((o) => o.flowType !== 'not_found') + @IsString() + webIcon!: string; +} + +export class DetermineChurnCancelEligibilityActionResult { + @ValidateNested() + @Type(() => ChurnCancelEligibilityResult) + churnCancelEligibility!: ChurnCancelEligibilityResult; + + @ValidateNested() + @Type(() => CancelFlowResult) + cancelContent!: CancelFlowResult; +} diff --git a/libs/payments/ui/src/lib/nestapp/validators/DetermineStaySubscribedEligibilityActionArgs.ts b/libs/payments/ui/src/lib/nestapp/validators/DetermineChurnEligibilityActionArgs.ts similarity index 88% rename from libs/payments/ui/src/lib/nestapp/validators/DetermineStaySubscribedEligibilityActionArgs.ts rename to libs/payments/ui/src/lib/nestapp/validators/DetermineChurnEligibilityActionArgs.ts index 8d81b115438..f8e90693684 100644 --- a/libs/payments/ui/src/lib/nestapp/validators/DetermineStaySubscribedEligibilityActionArgs.ts +++ b/libs/payments/ui/src/lib/nestapp/validators/DetermineChurnEligibilityActionArgs.ts @@ -4,7 +4,7 @@ import { IsString, IsOptional } from 'class-validator'; -export class DetermineStaySubscribedEligibilityActionArgs { +export class DetermineChurnEligibilityActionArgs { @IsString() uid!: string; diff --git a/libs/payments/ui/src/lib/nestapp/validators/DetermineStaySubscribedEligibilityActionResult.ts b/libs/payments/ui/src/lib/nestapp/validators/DetermineStaySubscribedEligibilityActionResult.ts index 861f3ec2b9b..b4a2b524e2d 100644 --- a/libs/payments/ui/src/lib/nestapp/validators/DetermineStaySubscribedEligibilityActionResult.ts +++ b/libs/payments/ui/src/lib/nestapp/validators/DetermineStaySubscribedEligibilityActionResult.ts @@ -78,6 +78,24 @@ export class CmsOfferingContent { webIcon!: string; } +export class StaySubscribedChurnEligibilityResult { + @IsBoolean() + isEligible!: boolean; + + @IsString() + reason!: string; + + @IsOptional() + @ValidateNested() + @Type(() => CmsChurnInterventionEntryResult) + cmsChurnInterventionEntry!: CmsChurnInterventionEntryResult | null; + + @IsOptional() + @ValidateNested() + @Type(() => CmsOfferingContent) + cmsOfferingContent!: CmsOfferingContent | null; +} + export class StaySubscribedFlowResult { @IsString() @IsIn(['not_found', 'stay_subscribed']) @@ -129,23 +147,10 @@ export class StaySubscribedFlowResult { } export class DetermineStaySubscribedEligibilityActionResult { - @IsBoolean() - isEligible!: boolean; - - @IsString() - reason!: string; - - @IsOptional() @ValidateNested() - @Type(() => CmsChurnInterventionEntryResult) - cmsChurnInterventionEntry!: CmsChurnInterventionEntryResult | null; - - @IsOptional() - @ValidateNested() - @Type(() => CmsOfferingContent) - cmsOfferingContent!: CmsOfferingContent | null; + @Type(() => StaySubscribedChurnEligibilityResult) + churnStaySubscribedEligibility!: StaySubscribedChurnEligibilityResult; - @IsOptional() @ValidateNested() @Type(() => StaySubscribedFlowResult) staySubscribedContent!: StaySubscribedFlowResult | null; 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; 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 fb21392690f..cf2f6f40249 100644 --- a/libs/payments/ui/src/lib/server/components/ChurnError/index.tsx +++ b/libs/payments/ui/src/lib/server/components/ChurnError/index.tsx @@ -17,19 +17,21 @@ type ChurnErrorProps = { cmsOfferingContent: any; locale: string; reason: string; - pageContent: { - flowType: 'cancel' | 'stay_subscribed'; - active: boolean; - cancelAtPeriodEnd: boolean; - currency: string; - currentPeriodEnd: number; - defaultPaymentMethodType?: SubPlatPaymentMethodType; - last4?: string; - nextInvoiceTax?: number; - nextInvoiceTotal?: number; - productName: string; - webIcon: string; - }; + pageContent: + | { + flowType: 'cancel' | 'stay_subscribed'; + active: boolean; + cancelAtPeriodEnd: boolean; + currency: string; + currentPeriodEnd: number; + defaultPaymentMethodType?: SubPlatPaymentMethodType; + last4?: string; + nextInvoiceTax?: number; + nextInvoiceTotal?: number; + productName: string; + webIcon: string; + } + | { flowType: 'not_found' }; subscriptionId: string; }; @@ -43,6 +45,9 @@ export async function ChurnError({ const acceptLanguage = headers().get('accept-language'); const l10n = getApp().getL10n(acceptLanguage); + if (pageContent.flowType === 'not_found') { + reason = 'general_error'; + } const { productName, successActionButtonUrl, supportUrl, webIcon } = cmsOfferingContent; @@ -71,7 +76,7 @@ export async function ChurnError({ {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, please contact our Support team.` + `This discount was applied to a ${productName} subscription for your account. If you still need help, contact our Support team.` )}

@@ -136,7 +141,7 @@ export async function ChurnError({ ); case 'subscription_still_active': - if (!pageContent) { + if (!pageContent || pageContent.flowType === 'not_found') { // Re-render as general error section below break; }