Skip to content

Commit 607202e

Browse files
Merge pull request #19905 from mozilla/PAY-3469-add-churn-feature-flag
feat(payments-next): Add Churn Intervention feature flag
2 parents efe5936 + e62ac23 commit 607202e

8 files changed

Lines changed: 202 additions & 5 deletions

File tree

apps/payments/next/.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ CURRENCY_CONFIG__CURRENCIES_TO_COUNTRIES={"USD":["AS","CA","GB","GU","MP","MY","
9494

9595
# Churn Intervention Config
9696
CHURN_INTERVENTION_CONFIG__COLLECTION_NAME=churnInterventions
97+
CHURN_INTERVENTION_CONFIG__ENABLED=false
9798

9899
# StatsD Config
99100
STATS_D_CONFIG__SAMPLE_RATE=

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { headers } from 'next/headers';
99
import { URLSearchParams } from 'url';
1010
import { SubplatInterval } from '@fxa/payments/customer';
1111
import { notFound } from 'next/navigation';
12+
import { config } from 'apps/payments/next/config';
1213

1314
export default async function ChurnTerms({
1415
params,
@@ -17,6 +18,10 @@ export default async function ChurnTerms({
1718
params: ChurnParams;
1819
searchParams: Record<string, string | string[]> | undefined;
1920
}) {
21+
if (!config.churnInterventionConfig.enabled) {
22+
notFound();
23+
}
24+
2025
const { locale, interval, churnType, offeringId } = params;
2126
const acceptLanguage = headers().get('accept-language');
2227
const l10n = getApp().getL10n(acceptLanguage, locale);

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ export default async function LoyaltyDiscountStaySubscribedErrorPage({
1919
searchParams: Record<string, string> | undefined;
2020
}) {
2121
const { locale, subscriptionId } = params;
22+
23+
if (!config.churnInterventionConfig.enabled) {
24+
redirect(`/${locale}/subscriptions/${subscriptionId}/stay-subscribed`);
25+
}
26+
2227
const acceptLanguage = headers().get('accept-language');
2328

2429
const session = await auth();

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ export default async function LoyaltyDiscountStaySubscribedPage({
2525
searchParams: Record<string, string> | undefined;
2626
}) {
2727
const { locale, subscriptionId } = params;
28+
29+
if (!config.churnInterventionConfig.enabled) {
30+
redirect(`/${locale}/subscriptions/${subscriptionId}/stay-subscribed`);
31+
}
32+
2833
const acceptLanguage = headers().get('accept-language');
2934

3035
const session = await auth();

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,19 @@
44

55
import { faker } from '@faker-js/faker';
66
import { Provider } from '@nestjs/common';
7-
import { IsString } from 'class-validator';
7+
import { IsBoolean, IsString } from 'class-validator';
88

99
export class ChurnInterventionConfig {
1010
@IsString()
1111
public readonly collectionName!: string;
12+
13+
@IsBoolean()
14+
public readonly enabled: boolean = false;
1215
}
1316

1417
export const MockChurnInterventionConfig = {
1518
collectionName: faker.string.uuid(),
19+
enabled: true,
1620
} satisfies ChurnInterventionConfig;
1721

1822
export const MockChurnInterventionConfigProvider = {

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

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Logger } from '@nestjs/common';
77
import { Test } from '@nestjs/testing';
88

99
import {
10+
ChurnInterventionConfig,
1011
ChurnInterventionManager,
1112
ChurnInterventionEntryFactory,
1213
} from '@fxa/payments/cart';
@@ -73,10 +74,19 @@ describe('ChurnInterventionService', () => {
7374
timing: jest.fn(),
7475
};
7576

77+
const mockChurnInterventionConfig = {
78+
collectionName: 'churn-interventions',
79+
enabled: true,
80+
};
81+
7682
beforeEach(async () => {
7783
const moduleRef = await Test.createTestingModule({
7884
providers: [
7985
ChurnInterventionService,
86+
{
87+
provide: ChurnInterventionConfig,
88+
useValue: mockChurnInterventionConfig,
89+
},
8090
{
8191
provide: EligibilityService,
8292
useValue: {
@@ -95,6 +105,7 @@ describe('ChurnInterventionService', () => {
95105
provide: ProductConfigurationManager,
96106
useValue: {
97107
getChurnInterventionBySubscription: jest.fn(),
108+
getChurnIntervention: jest.fn(),
98109
getCancelInterstitialOffer: jest.fn(),
99110
getPageContentByPriceIds: jest.fn(),
100111
getSubplatIntervalBySubscription: jest.fn(),
@@ -1158,4 +1169,101 @@ describe('ChurnInterventionService', () => {
11581169
});
11591170
});
11601171
});
1172+
1173+
describe('when feature is disabled', () => {
1174+
beforeEach(() => {
1175+
mockChurnInterventionConfig.enabled = false;
1176+
jest.clearAllMocks();
1177+
});
1178+
1179+
afterEach(() => {
1180+
mockChurnInterventionConfig.enabled = true;
1181+
});
1182+
1183+
it('returns feature_disabled when determineStaySubscribedEligibility is called', async () => {
1184+
const result =
1185+
await churnInterventionService.determineStaySubscribedEligibility(
1186+
'uid_123',
1187+
'sub_123',
1188+
'en'
1189+
);
1190+
1191+
expect(result.isEligible).toBe(false);
1192+
expect(result.reason).toBe('feature_disabled');
1193+
expect(result.cmsChurnInterventionEntry).toBeNull();
1194+
expect(result.cmsOfferingContent).toBeNull();
1195+
expect(
1196+
productConfigurationManager.getChurnInterventionBySubscription
1197+
).not.toHaveBeenCalled();
1198+
});
1199+
1200+
it('returns feature_disabled when redeemChurnCoupon is called', async () => {
1201+
const result = await churnInterventionService.redeemChurnCoupon(
1202+
'uid123',
1203+
'sub_123',
1204+
'en'
1205+
);
1206+
1207+
expect(result.redeemed).toBe(false);
1208+
expect(result.errorCode).toBe('feature_disabled');
1209+
});
1210+
1211+
it('determineCancellationIntervention returns none', async () => {
1212+
const result =
1213+
await churnInterventionService.determineCancellationIntervention({
1214+
uid: 'uid123',
1215+
subscriptionId: 'sub_123',
1216+
});
1217+
1218+
expect(result.cancelChurnInterventionType).toBe('none');
1219+
expect(result.cmsOfferContent).toBeNull();
1220+
});
1221+
1222+
it('returns feature_disabled when determineCancelChurnContentEligibility is called', async () => {
1223+
const result =
1224+
await churnInterventionService.determineCancelChurnContentEligibility({
1225+
uid: 'uid_123',
1226+
subscriptionId: 'sub_123',
1227+
});
1228+
1229+
expect(result.isEligible).toBe(false);
1230+
expect(result.reason).toBe('feature_disabled');
1231+
expect(result.cmsChurnInterventionEntry).toBeNull();
1232+
expect(
1233+
productConfigurationManager.getChurnInterventionBySubscription
1234+
).not.toHaveBeenCalled();
1235+
});
1236+
1237+
it('returns feature_disabled when determineCancelInterstitialOfferEligibility is called', async () => {
1238+
const result =
1239+
await churnInterventionService.determineCancelInterstitialOfferEligibility(
1240+
{
1241+
uid: 'uid_123',
1242+
subscriptionId: 'sub_123',
1243+
}
1244+
);
1245+
1246+
expect(result.isEligible).toBe(false);
1247+
expect(result.reason).toBe('feature_disabled');
1248+
expect(result.cmsOfferContent).toBeNull();
1249+
expect(result.cmsOfferingContent).toBeNull();
1250+
expect(
1251+
productConfigurationManager.getSubplatIntervalBySubscription
1252+
).not.toHaveBeenCalled();
1253+
});
1254+
1255+
it('returns empty array when getChurnInterventionForProduct is called', async () => {
1256+
const result =
1257+
await churnInterventionService.getChurnInterventionForProduct(
1258+
SubplatInterval.Monthly,
1259+
'cancel',
1260+
'prod_123'
1261+
);
1262+
1263+
expect(result.churnInterventions).toEqual([]);
1264+
expect(
1265+
productConfigurationManager.getChurnIntervention
1266+
).not.toHaveBeenCalled();
1267+
});
1268+
});
11611269
});

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

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

55
import { Inject, Injectable, Logger, type LoggerService } from '@nestjs/common';
6-
import { ChurnInterventionManager } from '@fxa/payments/cart';
6+
import {
7+
ChurnInterventionConfig,
8+
ChurnInterventionManager,
9+
} from '@fxa/payments/cart';
710
import {
811
ChurnInterventionByProductIdResultUtil,
912
ProductConfigurationManager,
@@ -22,6 +25,18 @@ import {
2225
ChurnInterventionProductIdentifierMissingError,
2326
ChurnSubscriptionCustomerMismatchError,
2427
} from './churn-intervention.error';
28+
export enum Enum_Churnintervention_Churntype {
29+
Cancel = 'cancel',
30+
StaySubscribed = 'stay_subscribed'
31+
}
32+
33+
export enum Enum_Churnintervention_Interval {
34+
Daily = 'daily',
35+
Halfyearly = 'halfyearly',
36+
Monthly = 'monthly',
37+
Weekly = 'weekly',
38+
Yearly = 'yearly'
39+
}
2540

2641
@Injectable()
2742
export class ChurnInterventionService {
@@ -34,9 +49,14 @@ export class ChurnInterventionService {
3449
private profileClient: ProfileClient,
3550
private subscriptionManager: SubscriptionManager,
3651
@Inject(StatsDService) private statsd: StatsD,
37-
@Inject(Logger) private log: LoggerService
52+
@Inject(Logger) private log: LoggerService,
53+
private churnInterventionConfig: ChurnInterventionConfig
3854
) {}
3955

56+
private isFeatureEnabled(): boolean {
57+
return this.churnInterventionConfig.enabled ?? false;
58+
}
59+
4060
/**
4161
* Reload the customer data to reflect a change.
4262
* NOTE: This is currently duplicated in subscriptionManagement.service.ts
@@ -71,6 +91,10 @@ export class ChurnInterventionService {
7191
acceptLanguage?: string,
7292
selectedLanguage?: string
7393
) {
94+
if (!this.isFeatureEnabled()) {
95+
return { churnInterventions: [] };
96+
}
97+
7498
let util: ChurnInterventionByProductIdResultUtil;
7599
if (stripeProductId) {
76100
util = await this.productConfigurationManager.getChurnIntervention(
@@ -105,6 +129,15 @@ export class ChurnInterventionService {
105129
acceptLanguage?: string | null,
106130
selectedLanguage?: string
107131
) {
132+
if (!this.isFeatureEnabled()) {
133+
return {
134+
isEligible: false,
135+
reason: 'feature_disabled',
136+
cmsChurnInterventionEntry: null,
137+
cmsOfferingContent: null,
138+
};
139+
}
140+
108141
try {
109142
const cmsChurnResult =
110143
await this.productConfigurationManager.getChurnInterventionBySubscription(
@@ -245,6 +278,13 @@ export class ChurnInterventionService {
245278
acceptLanguage?: string | null,
246279
selectedLanguage?: string
247280
) {
281+
if (!this.isFeatureEnabled()) {
282+
return {
283+
redeemed: false,
284+
errorCode: 'feature_disabled',
285+
};
286+
}
287+
248288
const eligibilityResult = await this.determineStaySubscribedEligibility(
249289
uid,
250290
subscriptionId,
@@ -330,6 +370,13 @@ export class ChurnInterventionService {
330370
acceptLanguage?: string | null;
331371
selectedLanguage?: string;
332372
}) {
373+
if (!this.isFeatureEnabled()) {
374+
return {
375+
cancelChurnInterventionType: 'none',
376+
cmsOfferContent: null,
377+
};
378+
}
379+
333380
try {
334381
const accountCustomer =
335382
await this.accountCustomerManager.getAccountCustomerByUid(args.uid);
@@ -422,6 +469,15 @@ export class ChurnInterventionService {
422469
acceptLanguage?: string | null;
423470
selectedLanguage?: string;
424471
}) {
472+
if (!this.isFeatureEnabled()) {
473+
return {
474+
isEligible: false,
475+
reason: 'feature_disabled',
476+
cmsOfferContent: null,
477+
cmsOfferingContent: null,
478+
};
479+
}
480+
425481
const upgradeInterval = SubplatInterval.Yearly;
426482
const subscription = await this.subscriptionManager.retrieve(
427483
args.subscriptionId
@@ -566,6 +622,14 @@ export class ChurnInterventionService {
566622
acceptLanguage?: string | null;
567623
selectedLanguage?: string;
568624
}) {
625+
if (!this.isFeatureEnabled()) {
626+
return {
627+
isEligible: false,
628+
reason: 'feature_disabled',
629+
cmsChurnInterventionEntry: null,
630+
};
631+
}
632+
569633
const cmsChurnResult =
570634
await this.productConfigurationManager.getChurnInterventionBySubscription(
571635
args.subscriptionId,

packages/fxa-auth-server/lib/payments/initSubplat.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,12 @@ export async function initSubplat({
149149
);
150150
const subscriptionManager = new SubscriptionManager(stripeClient);
151151
const customerManager = new CustomerManager(stripeClient);
152+
const churnInterventionConfig = {
153+
collectionName: 'churnCollection',
154+
enabled: true,
155+
};
152156
const churnInterventionManager = new ChurnInterventionManager(
153-
{ collectionName: 'churnCollection' },
157+
churnInterventionConfig,
154158
firestore
155159
);
156160
const eligibilityManager = new EligibilityManager(
@@ -196,7 +200,8 @@ export async function initSubplat({
196200
profileClient,
197201
subscriptionManager,
198202
statsd,
199-
logger
203+
logger,
204+
churnInterventionConfig
200205
);
201206

202207
const subscriptionManagementService = new SubscriptionManagementService(

0 commit comments

Comments
 (0)