Skip to content

Commit 32d3aeb

Browse files
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)
1 parent 4f0c168 commit 32d3aeb

13 files changed

Lines changed: 1020 additions & 786 deletions

File tree

apps/payments/next/app/[locale]/subscriptions/[subscriptionId]/loyalty-discount/cancel/error/page.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,16 @@ export default async function LoyaltyDiscountCancelErrorPage({
4141

4242
const uid = session.user.id;
4343

44-
const pageContent = await determineChurnCancelEligibilityAction(
45-
uid,
46-
subscriptionId,
47-
acceptLanguage
48-
);
44+
let pageContent;
45+
try {
46+
pageContent = await determineChurnCancelEligibilityAction(
47+
uid,
48+
subscriptionId,
49+
acceptLanguage
50+
);
51+
} catch (error) {
52+
notFound();
53+
}
4954

5055
if (!pageContent) {
5156
notFound();
@@ -59,13 +64,10 @@ export default async function LoyaltyDiscountCancelErrorPage({
5964
}
6065

6166
const { cmsOfferingContent, reason } = churnCancelContentEligibility;
62-
if (!cmsOfferingContent) {
63-
notFound();
64-
}
6567

6668
const cancelContent = pageContent.cancelContent;
6769

68-
if (cancelContent.flowType !== 'cancel') {
70+
if (cancelContent.flowType !== 'cancel' || !cmsOfferingContent) {
6971
return (
7072
<ChurnError
7173
cmsOfferingContent={cmsOfferingContent}

apps/payments/next/app/[locale]/subscriptions/[subscriptionId]/loyalty-discount/cancel/page.tsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,18 @@ export default async function LoyaltyDiscountCancelPage({
3636

3737
const uid = session.user.id;
3838

39-
const pageContent = await determineChurnCancelEligibilityAction(
40-
uid,
41-
subscriptionId,
42-
acceptLanguage
43-
);
39+
let pageContent;
40+
try {
41+
pageContent = await determineChurnCancelEligibilityAction(
42+
uid,
43+
subscriptionId,
44+
acceptLanguage
45+
);
46+
} catch (error) {
47+
redirect(
48+
`/${locale}/subscriptions/${subscriptionId}/loyalty-discount/cancel/error`
49+
);
50+
}
4451

4552
if (!pageContent) notFound();
4653

apps/payments/next/app/[locale]/subscriptions/[subscriptionId]/loyalty-discount/stay-subscribed/error/page.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,16 @@ export default async function LoyaltyDiscountStaySubscribedErrorPage({
3737

3838
const uid = session.user.id;
3939

40-
const pageContent = await determineStaySubscribedEligibilityAction(
41-
uid,
42-
subscriptionId,
43-
acceptLanguage
44-
);
40+
let pageContent;
41+
try {
42+
pageContent = await determineStaySubscribedEligibilityAction(
43+
uid,
44+
subscriptionId,
45+
acceptLanguage
46+
);
47+
} catch (error) {
48+
notFound();
49+
}
4550

4651
if (!pageContent) {
4752
notFound();
@@ -55,9 +60,6 @@ export default async function LoyaltyDiscountStaySubscribedErrorPage({
5560
}
5661

5762
const { cmsOfferingContent, reason } = churnStaySubscribedEligibility;
58-
if (!cmsOfferingContent) {
59-
notFound();
60-
}
6163

6264
return (
6365
<ChurnError

apps/payments/next/app/[locale]/subscriptions/[subscriptionId]/loyalty-discount/stay-subscribed/page.tsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,18 @@ export default async function LoyaltyDiscountStaySubscribedPage({
3636

3737
const uid = session.user.id;
3838

39-
const pageContent = await determineStaySubscribedEligibilityAction(
40-
uid,
41-
subscriptionId,
42-
acceptLanguage
43-
);
39+
let pageContent;
40+
try {
41+
pageContent = await determineStaySubscribedEligibilityAction(
42+
uid,
43+
subscriptionId,
44+
acceptLanguage
45+
);
46+
} catch (error) {
47+
redirect(
48+
`/${locale}/subscriptions/${subscriptionId}/loyalty-discount/stay-subscribed/error`
49+
);
50+
}
4451

4552
if (!pageContent) notFound();
4653

apps/payments/next/app/[locale]/subscriptions/[subscriptionId]/offer/error/en.ftl

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@
22
interstitial-offer-error-subscription-not-found-heading = We couldn’t find an active subscription
33
interstitial-offer-error-subscription-not-found-message = It looks like this subscription may no longer be active.
44
5+
interstitial-offer-error-customer-mismatch-heading = Coupon can’t be redeemed
6+
interstitial-offer-error-customer-mismatch-message = This coupon was issued for a different subscription and can only be redeemed by the original recipient.
7+
58
interstitial-offer-error-general-heading = Offer isn’t available
69
interstitial-offer-error-general-message = It looks like this offer is not available at this time.
710
811
interstitial-offer-error-button-back-to-subscriptions = Back to subscriptions
912
interstitial-offer-error-button-cancel-subscription = Continue to cancel
13+
interstitial-offer-error-button-sign-in = Sign in
14+
interstitial-offer-error-button-contact-support = Contact Support

apps/payments/next/app/[locale]/subscriptions/[subscriptionId]/offer/error/page.tsx

Lines changed: 70 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import Link from 'next/link';
88
import Image from 'next/image';
99
import { getApp } from '@fxa/payments/ui/server';
1010
import { getInterstitialOfferContentAction } from '@fxa/payments/ui/actions';
11+
import { LinkExternal } from '@fxa/shared/react';
1112
import { auth } from 'apps/payments/next/auth';
1213
import { config } from 'apps/payments/next/config';
1314

@@ -63,7 +64,7 @@ export default async function InterstitialOfferErrorPage({
6364
const reason = interstitialOfferContent.reason ?? 'general_error';
6465

6566
if (webIcon && !productName) {
66-
throw new Error('Missing productName for interstitial offer icon');
67+
notFound();
6768
}
6869

6970
const l10n = getApp().getL10n(acceptLanguage, locale);
@@ -81,7 +82,40 @@ export default async function InterstitialOfferErrorPage({
8182
'interstitial-offer-error-subscription-not-found-message',
8283
'It looks like this subscription may no longer be active.'
8384
),
84-
showContinueToCancelButton: false,
85+
primaryButton: {
86+
label: l10n.getString(
87+
'interstitial-offer-error-button-back-to-subscriptions',
88+
'Back to subscriptions'
89+
),
90+
href: `/${locale}/subscriptions/landing`,
91+
},
92+
secondaryButton: null,
93+
};
94+
case 'customer_mismatch':
95+
return {
96+
heading: l10n.getString(
97+
'interstitial-offer-error-customer-mismatch-heading',
98+
'Coupon can’t be redeemed'
99+
),
100+
message: l10n.getString(
101+
'interstitial-offer-error-customer-mismatch-message',
102+
'This coupon was issued for a different subscription and can only be redeemed by the original recipient.'
103+
),
104+
primaryButton: {
105+
label: l10n.getString(
106+
'interstitial-offer-error-button-sign-in',
107+
'Sign in'
108+
),
109+
href: `/${locale}/subscriptions/landing`,
110+
},
111+
secondaryButton: {
112+
label: l10n.getString(
113+
'interstitial-offer-error-button-contact-support',
114+
'Contact Support'
115+
),
116+
href: 'https://support.mozilla.org/',
117+
isExternal: true,
118+
},
85119
};
86120
default:
87121
return {
@@ -93,12 +127,26 @@ export default async function InterstitialOfferErrorPage({
93127
'interstitial-offer-error-general-message',
94128
'It looks like this offer is not available at this time.'
95129
),
96-
showContinueToCancelButton: true,
130+
primaryButton: {
131+
label: l10n.getString(
132+
'interstitial-offer-error-button-back-to-subscriptions',
133+
'Back to subscriptions'
134+
),
135+
href: `/${locale}/subscriptions/landing`,
136+
},
137+
secondaryButton: {
138+
label: l10n.getString(
139+
'interstitial-offer-error-button-cancel-subscription',
140+
'Continue to cancel'
141+
),
142+
href: `/${locale}/subscriptions/${subscriptionId}/cancel`,
143+
isExternal: false,
144+
},
97145
};
98146
}
99147
};
100148

101-
const { heading, message, showContinueToCancelButton } = getErrorContent(reason);
149+
const { heading, message, primaryButton, secondaryButton } = getErrorContent(reason);
102150

103151
return (
104152
<section
@@ -128,23 +176,26 @@ export default async function InterstitialOfferErrorPage({
128176
<div className="w-full flex flex-col gap-3 mt-10">
129177
<Link
130178
className="border box-border font-header h-14 items-center justify-center rounded-md text-white text-center font-bold py-4 px-6 bg-blue-500 hover:bg-blue-700 flex w-full"
131-
href={`/${locale}/subscriptions/landing`}
179+
href={primaryButton.href}
132180
>
133-
{l10n.getString(
134-
'interstitial-offer-error-button-back-to-subscriptions',
135-
'Back to subscriptions'
136-
)}
181+
{primaryButton.label}
137182
</Link>
138-
{showContinueToCancelButton && (
139-
<Link
140-
className="border box-border font-header h-14 items-center justify-center rounded-md text-center font-bold py-4 px-6 bg-grey-10 border-grey-200 hover:bg-grey-50 flex w-full"
141-
href={`/${locale}/subscriptions/${subscriptionId}/cancel`}
142-
>
143-
<span>{l10n.getString(
144-
'interstitial-offer-error-button-cancel-subscription',
145-
'Continue to cancel'
146-
)}</span>
147-
</Link>
183+
{secondaryButton && (
184+
secondaryButton.isExternal ? (
185+
<LinkExternal
186+
className="border box-border font-header h-14 items-center justify-center rounded-md text-center font-bold py-4 px-6 bg-grey-10 border-grey-200 hover:bg-grey-50 flex w-full"
187+
href={secondaryButton.href}
188+
>
189+
{secondaryButton.label}
190+
</LinkExternal>
191+
) : (
192+
<Link
193+
className="border box-border font-header h-14 items-center justify-center rounded-md text-center font-bold py-4 px-6 bg-grey-10 border-grey-200 hover:bg-grey-50 flex w-full"
194+
href={secondaryButton.href}
195+
>
196+
<span>{secondaryButton.label}</span>
197+
</Link>
198+
)
148199
)}
149200
</div>
150201
</div>

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

Lines changed: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@ import {
5454
NotifierSnsProvider,
5555
} from '@fxa/shared/notifier';
5656
import { ChurnInterventionService } from './churn-intervention.service';
57-
import { ChurnSubscriptionCustomerMismatchError } from './churn-intervention.error';
5857

5958
describe('ChurnInterventionService', () => {
6059
let accountCustomerManager: AccountCustomerManager;
@@ -180,6 +179,43 @@ describe('ChurnInterventionService', () => {
180179
jest.resetAllMocks();
181180
});
182181

182+
it('returns ineligible when customer does not match subscription', async () => {
183+
const mockStripeCustomer = StripeCustomerFactory();
184+
const mockAccountCustomer = ResultAccountCustomerFactory({
185+
stripeCustomerId: mockStripeCustomer.id,
186+
});
187+
const mockSubscription = StripeResponseFactory(
188+
StripeSubscriptionFactory()
189+
);
190+
191+
jest
192+
.spyOn(accountCustomerManager, 'getAccountCustomerByUid')
193+
.mockResolvedValue(mockAccountCustomer);
194+
jest
195+
.spyOn(subscriptionManager, 'retrieve')
196+
.mockResolvedValue(mockSubscription);
197+
198+
const result =
199+
await churnInterventionService.determineStaySubscribedEligibility(
200+
uid,
201+
subscriptionId
202+
);
203+
204+
expect(result).toEqual({
205+
isEligible: false,
206+
reason: 'customer_mismatch',
207+
cmsChurnInterventionEntry: null,
208+
cmsOfferingContent: null,
209+
});
210+
expect(mockStatsD.increment).toHaveBeenCalledWith(
211+
'stay_subscribed_eligibility',
212+
{
213+
eligibility: 'ineligible',
214+
reason: 'customer_mismatch',
215+
}
216+
);
217+
});
218+
183219
it('returns ineligible when no churn intervention entries are found', async () => {
184220
const rawResult = ChurnInterventionByProductIdRawResultFactory();
185221
const util = new ChurnInterventionByProductIdResultUtil(rawResult);
@@ -1047,7 +1083,7 @@ describe('ChurnInterventionService', () => {
10471083
});
10481084

10491085
describe('determineCancelInterstitialOfferEligibility', () => {
1050-
it('throws if customer does not match', async () => {
1086+
it('returns ineligible when customer does not match', async () => {
10511087
const mockUid = faker.string.uuid();
10521088
const mockStripeCustomer = StripeCustomerFactory();
10531089
const mockSubscription = StripeResponseFactory(
@@ -1065,13 +1101,19 @@ describe('ChurnInterventionService', () => {
10651101
.spyOn(subscriptionManager, 'retrieve')
10661102
.mockResolvedValue(StripeResponseFactory(mockSubscription));
10671103

1068-
expect(mockStatsD.increment).not.toHaveBeenCalled();
1069-
await expect(
1070-
churnInterventionService.determineCancelInterstitialOfferEligibility({
1104+
const result =
1105+
await churnInterventionService.determineCancelInterstitialOfferEligibility({
10711106
uid: mockUid,
10721107
subscriptionId: mockSubscription.id,
1073-
})
1074-
).rejects.toBeInstanceOf(ChurnSubscriptionCustomerMismatchError);
1108+
});
1109+
1110+
expect(result).toEqual({
1111+
isEligible: false,
1112+
reason: 'customer_mismatch',
1113+
cmsCancelInterstitialOfferResult: null,
1114+
webIcon: null,
1115+
productName: null,
1116+
});
10751117
});
10761118

10771119
it('returns not eligible if subscription not active', async () => {
@@ -1776,7 +1818,7 @@ describe('ChurnInterventionService', () => {
17761818
});
17771819

17781820
describe('determineCancelChurnContentEligibility', () => {
1779-
it('throws if customer does not match', async () => {
1821+
it('returns ineligible when customer does not match', async () => {
17801822
const mockUid = faker.string.uuid();
17811823
const mockStripeCustomer = StripeCustomerFactory();
17821824
const mockSubscription = StripeResponseFactory(
@@ -1794,14 +1836,18 @@ describe('ChurnInterventionService', () => {
17941836
.spyOn(subscriptionManager, 'retrieve')
17951837
.mockResolvedValue(StripeResponseFactory(mockSubscription));
17961838

1797-
await expect(
1798-
churnInterventionService.determineCancelChurnContentEligibility({
1839+
const result =
1840+
await churnInterventionService.determineCancelChurnContentEligibility({
17991841
uid: mockUid,
18001842
subscriptionId: mockSubscription.id,
1801-
})
1802-
).rejects.toBeInstanceOf(ChurnSubscriptionCustomerMismatchError);
1843+
});
18031844

1804-
expect(mockStatsD.increment).not.toHaveBeenCalled();
1845+
expect(result).toEqual({
1846+
isEligible: false,
1847+
reason: 'customer_mismatch',
1848+
cmsChurnInterventionEntry: null,
1849+
cmsOfferingContent: null,
1850+
});
18051851
});
18061852

18071853
it('returns not eligible if subscription not active', async () => {

0 commit comments

Comments
 (0)