Skip to content

Commit 1e67ccc

Browse files
committed
feat(payments-next): Create Stay subscribed churn page
This pull request: Adds the following churn pages: Error Not Found Stay Subscribed/component Updates Terms page to have a min-width value Updates ChurnInterventionManager to create entry if none exist Adds hasCouponId to SubscriptionManager to check if coupon exists on subscription Updates ChurnInterventionService.determineStaySubscribedEligibility Changes initial discount already applied to redemption limit exceeded Adds in discount already applied (checks subscription for coupon) Reorders reasons: From: No churn intervention found, discount already applied, sub not active, sub still active, eligible, general error To: Sub not active, sub still active, no churn intervention found, redemption limit exceeded, discount already applied, eligible, general error Updates subscriptionManagementService.applyStripeCouponToSubscription as it was not updating cancel_at_period_end when resubscribing Updates CMS to retrieve additional values (apiIdentifier, successActionButtonUrl, etc)
1 parent 184f8f6 commit 1e67ccc

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+3175
-669
lines changed

apps/payments/next/app/[locale]/[offeringId]/[interval]/[churnType]/loyalty-discount/terms/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export default async function ChurnTerms({
5353
className="flex tablet:items-center justify-center min-h-[calc(100vh_-_4rem)] tablet:min-h-[calc(100vh_-_5rem)]"
5454
aria-labelledby="loyalty-discount-terms"
5555
>
56-
<div className="max-w-xl flex flex-col p-6 pt-10 tablet:bg-white tablet:border tablet:border-grey-200 tablet:opacity-100 tablet:p-8 tablet:rounded-xl tablet:shadow-[0_0px_10px_rgba(0,0,0,0.08)]">
56+
<div className="max-w-xl min-w-[480px] flex flex-col p-6 pt-10 tablet:bg-white tablet:border tablet:border-grey-200 tablet:opacity-100 tablet:p-8 tablet:rounded-xl tablet:shadow-[0_0px_10px_rgba(0,0,0,0.08)]">
5757
<h1
5858
id="loyalty-discount-terms"
5959
className="font-semibold text-xl leading-8"
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
## Loyalty discount - Not found page
2+
3+
not-found-loyalty-discount-title = Page not found
4+
not-found-loyalty-discount-description = The page you are looking for does not exist.
5+
not-found-loyalty-discount-button-back-to-subscriptions = Back to subscriptions
6+
7+
##
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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 { PageNotFound } from '@fxa/payments/ui';
7+
import { getApp } from '@fxa/payments/ui/server';
8+
9+
export enum PaymentsPage {
10+
Subscriptions = 'subscriptions',
11+
}
12+
13+
export default function NotFound() {
14+
const acceptLanguage = headers().get('accept-language');
15+
const l10n = getApp().getL10n(acceptLanguage);
16+
return (
17+
<PageNotFound
18+
header={l10n.getString(
19+
'not-found-loyalty-discount-title',
20+
'Page not found'
21+
)}
22+
description={l10n.getString(
23+
'not-found-loyalty-discount-description',
24+
'The page you are looking for does not exist.'
25+
)}
26+
button={l10n.getString(
27+
'not-found-loyalty-discount-button-back-to-subscriptions',
28+
'Back to subscriptions'
29+
)}
30+
paymentsPage={PaymentsPage.Subscriptions}
31+
/>
32+
);
33+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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 { SubscriptionParams } from '@fxa/payments/ui';
9+
import { determineStaySubscribedEligibilityAction } from '@fxa/payments/ui/actions';
10+
import { ChurnError } from '@fxa/payments/ui/server';
11+
import { auth } from 'apps/payments/next/auth';
12+
import { config } from 'apps/payments/next/config';
13+
14+
export default async function LoyaltyDiscountStaySubscribedErrorPage({
15+
params,
16+
searchParams,
17+
}: {
18+
params: SubscriptionParams;
19+
searchParams: Record<string, string> | undefined;
20+
}) {
21+
const { locale, subscriptionId } = params;
22+
const acceptLanguage = headers().get('accept-language');
23+
24+
const session = await auth();
25+
if (!session?.user?.id) {
26+
const redirectToUrl = new URL(
27+
`${config.paymentsNextHostedUrl}/${locale}/subscriptions/landing`
28+
);
29+
redirectToUrl.search = new URLSearchParams(searchParams).toString();
30+
redirect(redirectToUrl.href);
31+
}
32+
33+
const uid = session.user.id;
34+
35+
const pageContent = await determineStaySubscribedEligibilityAction(
36+
uid,
37+
subscriptionId,
38+
acceptLanguage
39+
);
40+
41+
if (!pageContent) {
42+
notFound();
43+
}
44+
45+
const { cmsOfferingContent, reason } = pageContent;
46+
47+
if (!cmsOfferingContent) {
48+
notFound();
49+
}
50+
51+
const staySubscribedContent = pageContent.staySubscribedContent;
52+
53+
if (staySubscribedContent.flowType !== 'stay_subscribed') {
54+
notFound();
55+
}
56+
57+
return (
58+
<ChurnError
59+
cmsOfferingContent={cmsOfferingContent}
60+
locale={locale}
61+
reason={reason}
62+
pageContent={staySubscribedContent}
63+
subscriptionId={subscriptionId}
64+
/>
65+
);
66+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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 { ChurnStaySubscribed, SubscriptionParams } from '@fxa/payments/ui';
9+
import { determineStaySubscribedEligibilityAction } from '@fxa/payments/ui/actions';
10+
import { auth } from 'apps/payments/next/auth';
11+
import { config } from 'apps/payments/next/config';
12+
13+
enum ChurnStayErrorReason {
14+
DiscountAlreadyApplied = 'discount_already_applied',
15+
SubscriptionNotActive = 'subscription_not_active',
16+
GeneralError = 'general_error',
17+
RedemptionLimitExceeded = 'redemption_limit_exceeded',
18+
}
19+
20+
export default async function LoyaltyDiscountStaySubscribedPage({
21+
params,
22+
searchParams,
23+
}: {
24+
params: SubscriptionParams;
25+
searchParams: Record<string, string> | undefined;
26+
}) {
27+
const { locale, subscriptionId } = params;
28+
const acceptLanguage = headers().get('accept-language');
29+
30+
const session = await auth();
31+
if (!session?.user?.id) {
32+
const redirectToUrl = new URL(
33+
`${config.paymentsNextHostedUrl}/${locale}/subscriptions/landing`
34+
);
35+
redirectToUrl.search = new URLSearchParams(searchParams).toString();
36+
redirect(redirectToUrl.href);
37+
}
38+
39+
const uid = session.user.id;
40+
41+
const pageContent = await determineStaySubscribedEligibilityAction(
42+
uid,
43+
subscriptionId,
44+
acceptLanguage
45+
);
46+
47+
if (!pageContent) notFound();
48+
49+
const { cmsOfferingContent, reason, staySubscribedContent } = pageContent;
50+
const reasonStr = typeof reason === 'string' ? reason : undefined;
51+
const isErrorReason =
52+
!!reasonStr &&
53+
(Object.values(ChurnStayErrorReason) as string[]).includes(reasonStr);
54+
const isAllowedStayReason =
55+
reasonStr === 'eligible' ||
56+
reasonStr === 'no_churn_intervention_found' ||
57+
reasonStr === 'subscription_still_active';
58+
59+
if (isErrorReason) {
60+
redirect(
61+
`/${locale}/subscriptions/${subscriptionId}/loyalty-discount/stay-subscribed/error`
62+
);
63+
}
64+
65+
if (
66+
!staySubscribedContent ||
67+
staySubscribedContent.flowType !== 'stay_subscribed'
68+
) {
69+
notFound();
70+
}
71+
72+
if (reasonStr == null || isAllowedStayReason) {
73+
return (
74+
<ChurnStaySubscribed
75+
uid={uid}
76+
subscriptionId={subscriptionId}
77+
locale={locale}
78+
reason={reason}
79+
cmsChurnInterventionEntry={pageContent.cmsChurnInterventionEntry}
80+
cmsOfferingContent={cmsOfferingContent}
81+
staySubscribedContent={staySubscribedContent}
82+
/>
83+
);
84+
}
85+
86+
notFound();
87+
}

libs/payments/cart/src/lib/churn-intervention.manager.spec.ts

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ jest.mock('./churn-intervention.repository', () => ({
2626

2727
describe('ChurnInterventionManager', () => {
2828
let manager: ChurnInterventionManager;
29-
const mockConfig = { collectionName: 'testCollection' } as ChurnInterventionConfig;
29+
const mockConfig = {
30+
collectionName: 'testCollection',
31+
} as ChurnInterventionConfig;
3032
const mockEntry = ChurnInterventionEntryFactory();
3133

3234
beforeEach(() => {
@@ -48,21 +50,26 @@ describe('ChurnInterventionManager', () => {
4850

4951
const result = await manager.createEntry(mockEntry);
5052

51-
expect(createChurnInterventionEntry).toHaveBeenCalledWith(mockCollection, {
52-
customerId: mockEntry.customerId,
53-
churnInterventionId: mockEntry.churnInterventionId,
54-
redemptionCount: mockEntry.redemptionCount ?? 0,
55-
});
53+
expect(createChurnInterventionEntry).toHaveBeenCalledWith(
54+
mockCollection,
55+
{
56+
customerId: mockEntry.customerId,
57+
churnInterventionId: mockEntry.churnInterventionId,
58+
redemptionCount: mockEntry.redemptionCount ?? 0,
59+
}
60+
);
5661
expect(result).toEqual(mockEntry);
5762
});
5863
});
5964

60-
describe('getEntry', () => {
65+
describe('getOrCreateEntry', () => {
6166
it('successfully gets data', async () => {
6267
(getChurnInterventionEntryData as jest.Mock).mockResolvedValue(mockEntry);
6368

64-
const result = await manager.getEntry(mockEntry.customerId, mockEntry.churnInterventionId);
65-
69+
const result = await manager.getOrCreateEntry(
70+
mockEntry.customerId,
71+
mockEntry.churnInterventionId
72+
);
6673
expect(getChurnInterventionEntryData).toHaveBeenCalledWith(
6774
mockCollection,
6875
mockEntry.customerId,
@@ -78,7 +85,10 @@ describe('ChurnInterventionManager', () => {
7885

7986
describe('updateEntry', () => {
8087
it('successfully udpates', async () => {
81-
const updated = { ...mockEntry, redemptionCount: mockEntry.redemptionCount + 1 };
88+
const updated = {
89+
...mockEntry,
90+
redemptionCount: mockEntry.redemptionCount + 1,
91+
};
8292
(updateChurnInterventionEntry as jest.Mock).mockResolvedValue(updated);
8393

8494
const result = await manager.updateEntry(
@@ -105,7 +115,10 @@ describe('ChurnInterventionManager', () => {
105115
it('successfully deletes', async () => {
106116
(deleteChurnInterventionEntry as jest.Mock).mockResolvedValue(true);
107117

108-
const result = await manager.deleteEntry(mockEntry.customerId, mockEntry.churnInterventionId);
118+
const result = await manager.deleteEntry(
119+
mockEntry.customerId,
120+
mockEntry.churnInterventionId
121+
);
109122

110123
expect(deleteChurnInterventionEntry).toHaveBeenCalledWith(
111124
mockCollection,

libs/payments/cart/src/lib/churn-intervention.manager.ts

Lines changed: 41 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -19,49 +19,61 @@ import {
1919
export class ChurnInterventionManager {
2020
constructor(
2121
private config: ChurnInterventionConfig,
22-
@Inject(FirestoreService) private firestore: Firestore,
22+
@Inject(FirestoreService) private firestore: Firestore
2323
) {}
2424

2525
get collectionRef(): CollectionReference {
2626
return this.firestore.collection(this.config.collectionName);
2727
}
2828

29-
async createEntry(
30-
entry: ChurnInterventionEntry
31-
) {
32-
const data = await createChurnInterventionEntry(
33-
this.collectionRef,
34-
{
35-
customerId: entry.customerId,
36-
churnInterventionId: entry.churnInterventionId,
37-
redemptionCount: entry.redemptionCount ?? 0,
38-
}
39-
);
29+
async createEntry(entry: ChurnInterventionEntry) {
30+
const data = await createChurnInterventionEntry(this.collectionRef, {
31+
customerId: entry.customerId,
32+
churnInterventionId: entry.churnInterventionId,
33+
redemptionCount: entry.redemptionCount ?? 0,
34+
});
4035
return data;
4136
}
4237

43-
async getEntry(
44-
customerId: string,
45-
churnInterventionId: string
46-
) {
47-
const data = await getChurnInterventionEntryData(
48-
this.collectionRef,
49-
customerId,
50-
churnInterventionId
51-
);
52-
return {
53-
customerId: data.customerId,
54-
churnInterventionId: data.churnInterventionId,
55-
redemptionCount: data.redemptionCount,
56-
};
38+
async getOrCreateEntry(customerId: string, churnInterventionId: string) {
39+
try {
40+
const data = await getChurnInterventionEntryData(
41+
this.collectionRef,
42+
customerId,
43+
churnInterventionId
44+
);
45+
return {
46+
customerId: data.customerId,
47+
churnInterventionId: data.churnInterventionId,
48+
redemptionCount: data.redemptionCount,
49+
};
50+
} catch (error) {
51+
if (error instanceof ChurnInterventionEntryNotFoundError) {
52+
const created = await this.createEntry({
53+
customerId,
54+
churnInterventionId,
55+
redemptionCount: 0,
56+
});
57+
58+
return {
59+
customerId: created.customerId,
60+
churnInterventionId: created.churnInterventionId,
61+
redemptionCount: created.redemptionCount ?? 0,
62+
};
63+
}
64+
throw error;
65+
}
5766
}
5867

5968
async getRedemptionCountForUid(
6069
customerId: string,
6170
churnInterventionId: string
6271
) {
6372
try {
64-
const churnInterventionEntryData = await this.getEntry(customerId, churnInterventionId);
73+
const churnInterventionEntryData = await this.getOrCreateEntry(
74+
customerId,
75+
churnInterventionId
76+
);
6577
return churnInterventionEntryData.redemptionCount ?? 0;
6678
} catch (error) {
6779
if (error instanceof ChurnInterventionEntryNotFoundError) {
@@ -74,7 +86,7 @@ export class ChurnInterventionManager {
7486
async updateEntry(
7587
customerId: string,
7688
churnInterventionId: string,
77-
incrementBy: number,
89+
incrementBy: number
7890
) {
7991
const data = await updateChurnInterventionEntry(
8092
this.collectionRef,
@@ -89,10 +101,7 @@ export class ChurnInterventionManager {
89101
};
90102
}
91103

92-
async deleteEntry(
93-
customerId: string,
94-
churnInterventionId: string
95-
) {
104+
async deleteEntry(customerId: string, churnInterventionId: string) {
96105
return await deleteChurnInterventionEntry(
97106
this.collectionRef,
98107
customerId,

0 commit comments

Comments
 (0)