Skip to content

Commit b86a4dd

Browse files
committed
feat(payments-next): Create Cancel churn page
This pull requests Adds the following churn pages and en.ftl: Error Not Found Cancel/component Updates ChurnInterventionService redeemCoupon to include churnType determineCancelChurnContentEligibility to include additional reasons (subscription_not_active, already_canceling_at_period_end, adds cmsOfferingContent when needed Updates SubscriptionManagementService to return subscription invoice information Closes PAY-3434
1 parent 253a8fd commit b86a4dd

21 files changed

Lines changed: 1219 additions & 57 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
## Error page - churn cancel flow
2+
3+
churn-cancel-flow-error-offer-expired-title = This offer has expired
4+
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.
5+
churn-cancel-flow-error-button-continue-to-cancel = Continue to cancel
6+
churn-cancel-flow-error-page-button-back-to-subscriptions = Back to subscriptions
7+
churn-cancel-flow-error-already-canceling-title = Your subscription is set to end
8+
# $productName (String) - The name of the product to create subscription, e.g. Mozilla VPN
9+
# $currentPeriodEnd (Date) - The end date of the subscription's current billing period (e.g., September, 8, 2025)
10+
churn-cancel-flow-error-already-canceling-message = You’ll continue to have access to { $productName } until { $currentPeriodEnd }.
11+
churn-cancel-flow-error-page-button-keep-subscription = Keep subscription
12+
13+
##
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { headers } from 'next/headers';
6+
import Image from 'next/image';
7+
import Link from 'next/link';
8+
import { notFound, redirect } from 'next/navigation';
9+
10+
import { SubscriptionParams } from '@fxa/payments/ui';
11+
import { determineChurnCancelEligibilityAction } from '@fxa/payments/ui/actions';
12+
import { ChurnError, getApp } from '@fxa/payments/ui/server';
13+
import { getLocalizedDateString } from '@fxa/shared/l10n';
14+
import { auth } from 'apps/payments/next/auth';
15+
import { config } from 'apps/payments/next/config';
16+
17+
export default async function LoyaltyDiscountCancelErrorPage({
18+
params,
19+
searchParams,
20+
}: {
21+
params: SubscriptionParams;
22+
searchParams: Record<string, string> | undefined;
23+
}) {
24+
const { locale, subscriptionId } = params;
25+
const acceptLanguage = headers().get('accept-language');
26+
const l10n = getApp().getL10n(acceptLanguage, locale);
27+
28+
const session = await auth();
29+
if (!session?.user?.id) {
30+
const redirectToUrl = new URL(
31+
`${config.paymentsNextHostedUrl}/${locale}/subscriptions/landing`
32+
);
33+
redirectToUrl.search = new URLSearchParams(searchParams).toString();
34+
redirect(redirectToUrl.href);
35+
}
36+
37+
const uid = session.user.id;
38+
39+
const pageContent = await determineChurnCancelEligibilityAction(
40+
uid,
41+
subscriptionId,
42+
acceptLanguage
43+
);
44+
45+
if (!pageContent) {
46+
notFound();
47+
}
48+
49+
const { cmsOfferingContent, reason } = pageContent;
50+
51+
if (!cmsOfferingContent) {
52+
notFound();
53+
}
54+
55+
const cancelContent = pageContent.cancelContent;
56+
57+
if (cancelContent.flowType !== 'cancel') {
58+
return (
59+
<ChurnError
60+
cmsOfferingContent={cmsOfferingContent}
61+
locale={locale}
62+
reason={reason}
63+
pageContent={cancelContent}
64+
subscriptionId={subscriptionId}
65+
/>
66+
);
67+
}
68+
69+
if (reason === 'no_churn_intervention_found') {
70+
const { productName, webIcon } = cmsOfferingContent;
71+
return (
72+
<section
73+
className="flex justify-center min-h-[calc(100vh_-_4rem)] tablet:items-center tablet:min-h-[calc(100vh_-_5rem)]"
74+
aria-labelledby="churn-cancel-flow-error-heading"
75+
>
76+
<div className="max-w-[480px] p-10 text-grey-600 tablet:bg-white tablet:rounded-xl tablet:border tablet:border-grey-200 tablet:shadow-[0_0_16px_0_rgba(0,0,0,0.08)]">
77+
<div className="flex flex-col items-center justify-center gap-4 text-center">
78+
<Image src={webIcon} alt={productName} height={64} width={64} />
79+
80+
<h1
81+
id="churn-cancel-flow-error-heading"
82+
className="font-bold leading-7 text-center text-xl"
83+
>
84+
{l10n.getString(
85+
'churn-cancel-flow-error-offer-expired-title',
86+
'This offer has expired'
87+
)}
88+
</h1>
89+
<div className="leading-6">
90+
<p className="my-2">
91+
{l10n.getString(
92+
'churn-cancel-flow-error-offer-expired-message',
93+
`There are currently no discounts available for this subscription. You can continue with cancellation if you’d like.`
94+
)}
95+
</p>
96+
</div>
97+
<div className="flex flex-col gap-3 w-full">
98+
<Link
99+
href={`/${locale}/subscriptions/${subscriptionId}/cancel`}
100+
className="border box-border flex font-bold font-header h-12 items-center justify-center rounded text-center py-2 px-5 bg-blue-500 border-blue-600 hover:bg-blue-700 text-white"
101+
>
102+
{l10n.getString(
103+
'churn-cancel-flow-error-button-continue-to-cancel',
104+
'Continue to cancel'
105+
)}
106+
</Link>
107+
<Link
108+
href={`/${locale}/subscriptions/landing`}
109+
className="border box-border flex font-bold font-header h-12 items-center justify-center rounded text-center py-2 px-5 bg-grey-10 border-grey-200 hover:bg-grey-50"
110+
>
111+
{l10n.getString(
112+
'churn-cancel-flow-error-page-button-back-to-subscriptions',
113+
'Back to subscriptions'
114+
)}
115+
</Link>
116+
</div>
117+
</div>
118+
</div>
119+
</section>
120+
);
121+
}
122+
123+
if (reason === 'already_canceling_at_period_end') {
124+
const { productName, webIcon } = cmsOfferingContent;
125+
const { currentPeriodEnd } = cancelContent;
126+
const currentPeriodEndLongFallback = getLocalizedDateString(
127+
currentPeriodEnd,
128+
false,
129+
locale
130+
);
131+
return (
132+
<section
133+
className="flex justify-center min-h-[calc(100vh_-_4rem)] tablet:items-center tablet:min-h-[calc(100vh_-_5rem)]"
134+
aria-labelledby="error-already-canceling-heading"
135+
>
136+
<div className="max-w-[480px] p-10 text-grey-600 tablet:bg-white tablet:rounded-xl tablet:border tablet:border-grey-200 tablet:shadow-[0_0_16px_0_rgba(0,0,0,0.08)]">
137+
<div className="flex flex-col items-center justify-center gap-4 text-center">
138+
<Image src={webIcon} alt={productName} height={64} width={64} />
139+
140+
<h1
141+
id="error-already-canceling-heading"
142+
className="font-bold leading-7 text-center text-xl"
143+
>
144+
{l10n.getString(
145+
'churn-cancel-flow-error-already-canceling-title',
146+
'Your subscription is set to end'
147+
)}
148+
</h1>
149+
<div className="leading-6">
150+
<p className="my-2">
151+
{l10n.getString(
152+
'churn-cancel-flow-error-already-canceling-message',
153+
{
154+
productName,
155+
currentPeriodEnd: currentPeriodEndLongFallback,
156+
},
157+
`You’ll continue to have access to ${productName} until ${currentPeriodEndLongFallback}.`
158+
)}
159+
</p>
160+
</div>
161+
<div className="flex flex-col gap-3 w-full">
162+
<Link
163+
href={`/${locale}/subscriptions/landing`}
164+
className="border box-border flex font-bold font-header h-12 items-center justify-center rounded text-center py-2 px-5 bg-blue-500 border-blue-600 hover:bg-blue-700 text-white"
165+
>
166+
{l10n.getString(
167+
'churn-cancel-flow-error-page-button-back-to-subscriptions',
168+
'Back to subscriptions'
169+
)}
170+
</Link>
171+
<Link
172+
href={`/${locale}/subscriptions/${subscriptionId}/stay-subscribed`}
173+
className="border box-border flex font-bold font-header h-12 items-center justify-center rounded text-center py-2 px-5 bg-grey-10 border-grey-200 hover:bg-grey-50"
174+
>
175+
{l10n.getString(
176+
'churn-cancel-flow-error-page-button-keep-subscription',
177+
'Keep subscription'
178+
)}
179+
</Link>
180+
</div>
181+
</div>
182+
</div>
183+
</section>
184+
);
185+
}
186+
187+
return (
188+
<ChurnError
189+
cmsOfferingContent={cmsOfferingContent}
190+
locale={locale}
191+
reason={reason}
192+
pageContent={cancelContent}
193+
subscriptionId={subscriptionId}
194+
/>
195+
);
196+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { headers } from 'next/headers';
6+
import { notFound, redirect } from 'next/navigation';
7+
8+
import { ChurnCancel, SubscriptionParams } from '@fxa/payments/ui';
9+
import { determineChurnCancelEligibilityAction } from '@fxa/payments/ui/actions';
10+
import { auth } from 'apps/payments/next/auth';
11+
import { config } from 'apps/payments/next/config';
12+
13+
enum ChurnCancelErrorReason {
14+
AlreadyCanceling = 'already_canceling_at_period_end',
15+
SubscriptionNotActive = 'subscription_not_active',
16+
SubscriptionStillActive = 'subscription_still_active',
17+
OfferExpired = 'no_churn_intervention_found',
18+
GeneralError = 'general_error',
19+
RedemptionLimitExceeded = 'redemption_limit_exceeded',
20+
}
21+
22+
export default async function LoyaltyDiscountCancelPage({
23+
params,
24+
searchParams,
25+
}: {
26+
params: SubscriptionParams;
27+
searchParams: Record<string, string> | undefined;
28+
}) {
29+
const { locale, subscriptionId } = params;
30+
const acceptLanguage = headers().get('accept-language');
31+
32+
const session = await auth();
33+
if (!session?.user?.id) {
34+
const redirectToUrl = new URL(
35+
`${config.paymentsNextHostedUrl}/${locale}/subscriptions/landing`
36+
);
37+
redirectToUrl.search = new URLSearchParams(searchParams).toString();
38+
redirect(redirectToUrl.href);
39+
}
40+
41+
const uid = session.user.id;
42+
43+
const pageContent = await determineChurnCancelEligibilityAction(
44+
uid,
45+
subscriptionId,
46+
acceptLanguage
47+
);
48+
49+
if (!pageContent) notFound();
50+
51+
const { cmsOfferingContent, reason, cancelContent } = pageContent;
52+
const reasonStr = typeof reason === 'string' ? reason : undefined;
53+
const isErrorReason =
54+
!!reasonStr &&
55+
(Object.values(ChurnCancelErrorReason) as string[]).includes(reasonStr);
56+
const isAllowedCancelReason =
57+
reasonStr === 'eligible' || reasonStr === 'discount_already_applied';
58+
59+
if (isErrorReason) {
60+
redirect(
61+
`/${locale}/subscriptions/${subscriptionId}/loyalty-discount/cancel/error`
62+
);
63+
}
64+
65+
if (!isAllowedCancelReason) {
66+
redirect(
67+
`/${locale}/subscriptions/${subscriptionId}/loyalty-discount/cancel/error`
68+
);
69+
}
70+
71+
if (!cancelContent || cancelContent.flowType !== 'cancel') {
72+
redirect(
73+
`/${locale}/subscriptions/${subscriptionId}/loyalty-discount/cancel/error`
74+
);
75+
}
76+
77+
const entry = pageContent.cmsChurnInterventionEntry;
78+
if (!entry) {
79+
redirect(
80+
`/${locale}/subscriptions/${subscriptionId}/loyalty-discount/cancel/error`
81+
);
82+
}
83+
return (
84+
<ChurnCancel
85+
uid={uid}
86+
subscriptionId={subscriptionId}
87+
locale={locale}
88+
reason={reason}
89+
cmsChurnInterventionEntry={entry}
90+
cmsOfferingContent={cmsOfferingContent}
91+
cancelContent={cancelContent}
92+
/>
93+
);
94+
}

libs/payments/management/src/lib/churn-intervention.service.spec.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -565,7 +565,8 @@ describe('ChurnInterventionService', () => {
565565

566566
const result = await churnInterventionService.redeemChurnCoupon(
567567
mockUid,
568-
mockSubscription.id
568+
mockSubscription.id,
569+
'stay_subscribed'
569570
);
570571

571572
expect(
@@ -614,7 +615,8 @@ describe('ChurnInterventionService', () => {
614615

615616
const result = await churnInterventionService.redeemChurnCoupon(
616617
mockUid,
617-
mockSubscription.id
618+
mockSubscription.id,
619+
'stay_subscribed'
618620
);
619621

620622
expect(
@@ -649,7 +651,8 @@ describe('ChurnInterventionService', () => {
649651

650652
const result = await churnInterventionService.redeemChurnCoupon(
651653
mockUid,
652-
mockSubscription.id
654+
mockSubscription.id,
655+
'stay_subscribed'
653656
);
654657

655658
expect(
@@ -689,7 +692,8 @@ describe('ChurnInterventionService', () => {
689692

690693
const result = await churnInterventionService.redeemChurnCoupon(
691694
mockUid,
692-
mockSubscription.id
695+
mockSubscription.id,
696+
'stay_subscribed'
693697
);
694698

695699
expect(
@@ -751,6 +755,7 @@ describe('ChurnInterventionService', () => {
751755
isEligible: false,
752756
reason: 'no_churn_intervention_found',
753757
cmsChurnInterventionEntry: null,
758+
cmsOfferingContent: null,
754759
});
755760

756761
const mockCancelInterstitialOffer =
@@ -814,6 +819,7 @@ describe('ChurnInterventionService', () => {
814819
isEligible: true,
815820
reason: 'eligible',
816821
cmsChurnInterventionEntry: mockCmsOffer,
822+
cmsOfferingContent: null,
817823
});
818824

819825
const result =
@@ -862,6 +868,7 @@ describe('ChurnInterventionService', () => {
862868
isEligible: false,
863869
reason: 'no_churn_intervention_found',
864870
cmsChurnInterventionEntry: null,
871+
cmsOfferingContent: null,
865872
});
866873

867874
const result =
@@ -917,6 +924,7 @@ describe('ChurnInterventionService', () => {
917924
isEligible: false,
918925
reason: 'no_churn_intervention_found',
919926
cmsChurnInterventionEntry: null,
927+
cmsOfferingContent: null,
920928
});
921929
jest
922930
.spyOn(productConfigurationManager, 'getSubplatIntervalBySubscription')
@@ -989,6 +997,7 @@ describe('ChurnInterventionService', () => {
989997
isEligible: false,
990998
reason: 'no_churn_intervention_found',
991999
cmsChurnInterventionEntry: null,
1000+
cmsOfferingContent: null,
9921001
});
9931002
jest
9941003
.spyOn(productConfigurationManager, 'getSubplatIntervalBySubscription')

0 commit comments

Comments
 (0)