Skip to content

Commit 08d76f3

Browse files
committed
feat(next): add percentage based redirects to landing
Because: - Landing page needs to redirect to either `payments-server` or `payments-next` depending on configuration per offering. This commit: - Adds SP2 redirect config to `payments-next`. - Adds a function to determine whether SP3 landing page should redirect to SP2 based on per offeringId percentage configuration. Closes #FXA-10968
1 parent 5f781ad commit 08d76f3

10 files changed

Lines changed: 373 additions & 14 deletions

File tree

apps/payments/next/.env

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,4 +117,10 @@ PAYMENTS_NEXT_HOSTED_URL=http://localhost:3035
117117
SUBSCRIPTIONS_UNSUPPORTED_LOCATIONS='["CN", "KP", "IR", "SY", "CU", "SD", "BY", "IQ", "OM", "RU", "TR", "TM", "AE"]'
118118

119119
SP2MAP__OFFERINGS={"123donepro":{"USD":{"monthly":"prod_GqM9ToKK62qjkK,plan_GqM9N6qyhvxaVk","halfyearly":"prod_GqM9ToKK62qjkK,price_1LTAC5BVqmGyQTManGVoSBsc","yearly":"prod_GqM9ToKK62qjkK,price_1KbomlBVqmGyQTMaa0Tq7UaW"},"EUR":{"monthly":"prod_GqM9ToKK62qjkK,price_1H8NnnBVqmGyQTMaLwLRKbF3"}},"123doneproplus":{"USD":{"monthly":"prod_GyHm8uwOIjr6k5,price_1NsBeHBVqmGyQTMa0o3zMSH3"}},"123foxkeh":{"USD":{"monthly":"prod_OfV6ko0QPHotas,price_1NsA5qBVqmGyQTMapXvSdxYC"}},"foxkeh":{"USD":{"daily":"prod_GvH2k78kKusAlV,price_1Pe1GiBVqmGyQTMaaPVElv5S","monthly":"prod_GvH2k78kKusAlV,price_1LxakKBVqmGyQTMas2fZaSCG"}},"foxkeh2":{"USD":{"monthly":"prod_OfWo3Xmsn2dOpA,price_1NsBknBVqmGyQTMaXvfEARm2"}},"vpn":{"USD":{"monthly":"prod_JYy0wNbTbA5fDv,price_1Ivq4gBVqmGyQTMaplHcFEGO"}}}
120+
121+
SP2REDIRECT__ENABLED=true
122+
SP2REDIRECT__SHADOW_MODE=true
123+
SP2REDIRECT__DEFAULT_REDIRECT_PERCENTAGE=100
124+
SP2REDIRECT__OFFERINGS={"123donepro":100,"123doneproplus":100,"123foxkeh":100,"foxkeh":100,"foxkeh2":100,"vpn":100}
125+
120126
# Nextjs Public Environment Variables

apps/payments/next/app/[locale]/[offeringId]/[interval]/landing/route.ts

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,23 @@ import {
1111
import { BaseParams, buildRedirectUrl } from '@fxa/payments/ui';
1212
import { config } from 'apps/payments/next/config';
1313
import { getIpAddress } from '@fxa/payments/ui/server';
14-
import { getSP2Params } from '@fxa/payments/legacy';
14+
import {
15+
buildSp2RedirectUrl,
16+
getSP2Params,
17+
redirectToSp2,
18+
} from '@fxa/payments/legacy';
19+
import crypto from 'crypto';
20+
import * as Sentry from '@sentry/nextjs';
1521

1622
export const dynamic = 'force-dynamic'; // defaults to auto
1723

1824
function reportError(message: string, details?: any) {
1925
if (details) {
2026
console.error(message, details);
27+
Sentry.captureMessage(message, details);
2128
} else {
2229
console.error(message);
30+
Sentry.captureMessage(message, 'error');
2331
}
2432
}
2533

@@ -45,17 +53,47 @@ export async function GET(
4553
request: NextRequest,
4654
{ params }: { params: BaseParams }
4755
) {
48-
const currency = await determineCurrencyAction(getIpAddress());
49-
const { productId, priceId } = getSP2Params(
50-
config.sp2map,
51-
reportError,
52-
params.offeringId,
53-
params.interval,
54-
currency
55-
);
56-
console.log({ productId, priceId });
56+
const requestSearchParams = request.nextUrl.searchParams;
57+
58+
if (config.sp2redirect.enabled) {
59+
const queryCurrency = requestSearchParams.get('currency');
60+
const querySpVersion = requestSearchParams.get('spVersion');
61+
const isSp2Redirect = redirectToSp2(
62+
config.sp2redirect,
63+
params.offeringId,
64+
crypto.randomInt(1, 100),
65+
reportError
66+
);
67+
68+
if (isSp2Redirect || querySpVersion === '2') {
69+
const currency = queryCurrency
70+
? queryCurrency
71+
: await determineCurrencyAction(getIpAddress());
72+
const { productId, priceId } = getSP2Params(
73+
config.sp2map,
74+
reportError,
75+
params.offeringId,
76+
params.interval,
77+
currency
78+
);
79+
80+
const sp2RedirectUrl = buildSp2RedirectUrl(
81+
productId,
82+
priceId,
83+
config.contentServerUrl,
84+
requestSearchParams
85+
);
86+
87+
if (!config.sp2redirect.shadowMode) {
88+
redirect(sp2RedirectUrl);
89+
} else {
90+
console.log('SP2 Redirect Shadow Mode enabled', { sp2RedirectUrl });
91+
}
92+
}
93+
}
94+
95+
const searchParams = Object.fromEntries(requestSearchParams);
5796

58-
const searchParams = Object.fromEntries(request.nextUrl.searchParams);
5997
const redirectToUrl = new URL(
6098
buildRedirectUrl(params.offeringId, params.interval, 'new', 'checkout', {
6199
locale: params.locale,

apps/payments/next/config/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
RootConfig as NestAppRootConfig,
1919
validate,
2020
} from '@fxa/payments/ui/server';
21-
import { SP2MapConfig } from '@fxa/payments/legacy';
21+
import { SP2MapConfig, SP2RedirectConfig } from '@fxa/payments/legacy';
2222

2323
class CspConfig {
2424
@IsUrl()
@@ -104,6 +104,11 @@ export class PaymentsNextConfig extends NestAppRootConfig {
104104
@IsDefined()
105105
sp2map!: SP2MapConfig;
106106

107+
@Type(() => SP2RedirectConfig)
108+
@ValidateNested()
109+
@IsDefined()
110+
sp2redirect!: SP2RedirectConfig;
111+
107112
@IsString()
108113
authSecret!: string;
109114

libs/payments/legacy/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
export * from './lib/stripe-mapper.service';
22
export * from './lib/sp2map.config';
3+
export * from './lib/utils/buildSp2RedirectUrl';
34
export * from './lib/utils/getSP2Params';
5+
export * from './lib/utils/redirectToSp2';

libs/payments/legacy/src/lib/factories.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,14 @@
22
* License, v. 2.0. If a copy of the MPL was not distributed with this
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

5-
import { SP2MapConfig, Currency, Intervals } from './sp2map.config';
5+
import { faker } from '@faker-js/faker';
6+
import {
7+
SP2MapConfig,
8+
Currency,
9+
Intervals,
10+
SP2RedirectConfig,
11+
RedirectParams,
12+
} from './sp2map.config';
613
import { StripeMetadataWithCMS } from './types';
714

815
export const StripeMetadataWithCMSFactory = (
@@ -31,3 +38,22 @@ export const IntervalsFactory = (override?: Partial<Intervals>): Intervals => ({
3138
monthly: ['prod_productid', 'price_priceId'],
3239
...override,
3340
});
41+
42+
export const RedirectParamsFactory = (
43+
override?: Partial<RedirectParams>
44+
): RedirectParams => ({
45+
sp2RedirectPercentage: faker.number.int({ min: 0, max: 100 }),
46+
...override,
47+
});
48+
49+
export const SP2RedirectConfigFactory = (
50+
override?: Partial<SP2RedirectConfig>
51+
): SP2RedirectConfig => ({
52+
enabled: true,
53+
shadowMode: false,
54+
defaultRedirectPercentage: 100,
55+
offerings: {
56+
vpn: RedirectParamsFactory(),
57+
},
58+
...override,
59+
});

libs/payments/legacy/src/lib/sp2map.config.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1-
import { plainToInstance, Transform } from 'class-transformer';
1+
import { plainToInstance, Transform, Type } from 'class-transformer';
22
import {
33
IsString,
44
IsDefined,
55
IsObject,
66
IsOptional,
77
validateSync,
8+
IsBoolean,
9+
IsNumber,
10+
Min,
11+
Max,
812
} from 'class-validator';
913

1014
export class Intervals {
@@ -89,3 +93,57 @@ export class SP2MapConfig {
8993
})
9094
offerings!: Record<string, Currency | undefined>; // Index signature to allow dynamic keys
9195
}
96+
97+
export class RedirectParams {
98+
@IsDefined()
99+
@IsNumber()
100+
@Min(0)
101+
@Max(100)
102+
@Type(() => Number)
103+
sp2RedirectPercentage!: number;
104+
}
105+
106+
export class SP2RedirectConfig {
107+
@IsDefined()
108+
@IsBoolean()
109+
enabled!: boolean;
110+
111+
@IsDefined()
112+
@IsBoolean()
113+
shadowMode!: boolean;
114+
115+
@IsDefined()
116+
@IsNumber()
117+
@Min(0)
118+
@Max(100)
119+
@Type(() => Number)
120+
defaultRedirectPercentage!: number;
121+
122+
@IsDefined()
123+
@IsObject()
124+
@Transform(({ value }) => {
125+
const parsedValue = JSON.parse(value);
126+
const transformedValue = Object.entries(parsedValue).reduce(
127+
(acc, [key, val]) => {
128+
const classVal = plainToInstance(RedirectParams, {
129+
sp2RedirectPercentage: val,
130+
});
131+
try {
132+
const validation = validateSync(classVal);
133+
if (validation.length > 0) {
134+
throw new Error(`Validation errors: ${JSON.stringify(validation)}`);
135+
}
136+
} catch (err) {
137+
throw new Error(
138+
`Validation issue with value: ${JSON.stringify(val)}`
139+
);
140+
}
141+
acc[key] = classVal;
142+
return acc;
143+
},
144+
{} as any
145+
);
146+
return transformedValue;
147+
})
148+
offerings!: Record<string, RedirectParams | undefined>;
149+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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 { buildSp2RedirectUrl } from './buildSp2RedirectUrl';
6+
7+
describe('buildSp2RedirectUrl', () => {
8+
const defaultProductId = 'prod_123';
9+
const defaultPriceId = 'price_123';
10+
const defaultContentServerUrl = 'http://content-server.com';
11+
const defaultSearchParams = new URLSearchParams(
12+
'flow_id=one&flow_begin_time=123'
13+
);
14+
15+
it('should return the correct URL', () => {
16+
const result = buildSp2RedirectUrl(
17+
defaultProductId,
18+
defaultPriceId,
19+
defaultContentServerUrl,
20+
defaultSearchParams
21+
);
22+
expect(result).toBe(
23+
'http://content-server.com/subscriptions/products/prod_123?plan=price_123&flow_id=one&flow_begin_time=123'
24+
);
25+
});
26+
27+
it('should remove SP2 redirect logic specific query params', () => {
28+
defaultSearchParams.append('currency', 'USD');
29+
defaultSearchParams.append('spVersion', '2');
30+
const result = buildSp2RedirectUrl(
31+
defaultProductId,
32+
defaultPriceId,
33+
defaultContentServerUrl,
34+
defaultSearchParams
35+
);
36+
expect(result).toBe(
37+
'http://content-server.com/subscriptions/products/prod_123?plan=price_123&flow_id=one&flow_begin_time=123'
38+
);
39+
});
40+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
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+
export function buildSp2RedirectUrl(
6+
productId: string,
7+
priceId: string,
8+
contentServerUrl: string,
9+
searchParams: URLSearchParams
10+
) {
11+
// Remove SP2 redirect logic specific query params
12+
searchParams.delete('currency');
13+
searchParams.delete('spVersion');
14+
15+
const remainingQueryParams = searchParams.toString();
16+
17+
return `${contentServerUrl}/subscriptions/products/${productId}?plan=${priceId}&${remainingQueryParams}`;
18+
}

0 commit comments

Comments
 (0)