Skip to content

Commit 5f781ad

Browse files
authored
Merge pull request #18279 from mozilla/fxa-10967-rollout-map-func
feat(next): add mapper function for sp3 to sp2 conversion
2 parents 70ba2cb + 619dc52 commit 5f781ad

12 files changed

Lines changed: 335 additions & 1 deletion

File tree

apps/payments/next/.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,5 @@ PAYMENTS_NEXT_HOSTED_URL=http://localhost:3035
116116

117117
SUBSCRIPTIONS_UNSUPPORTED_LOCATIONS='["CN", "KP", "IR", "SY", "CU", "SD", "BY", "IQ", "OM", "RU", "TR", "TM", "AE"]'
118118

119+
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"}}}
119120
# Nextjs Public Environment Variables

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

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,25 @@
44
import { signIn } from 'apps/payments/next/auth';
55
import { redirect } from 'next/navigation';
66
import { NextRequest } from 'next/server';
7-
import { getMetricsFlowAction } from '@fxa/payments/ui/actions';
7+
import {
8+
determineCurrencyAction,
9+
getMetricsFlowAction,
10+
} from '@fxa/payments/ui/actions';
811
import { BaseParams, buildRedirectUrl } from '@fxa/payments/ui';
912
import { config } from 'apps/payments/next/config';
13+
import { getIpAddress } from '@fxa/payments/ui/server';
14+
import { getSP2Params } from '@fxa/payments/legacy';
1015

1116
export const dynamic = 'force-dynamic'; // defaults to auto
1217

18+
function reportError(message: string, details?: any) {
19+
if (details) {
20+
console.error(message, details);
21+
} else {
22+
console.error(message);
23+
}
24+
}
25+
1326
/**
1427
* This landing route will initiate the OAuth no prompt signin
1528
* attempt with FxA.
@@ -32,6 +45,16 @@ export async function GET(
3245
request: NextRequest,
3346
{ params }: { params: BaseParams }
3447
) {
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 });
57+
3558
const searchParams = Object.fromEntries(request.nextUrl.searchParams);
3659
const redirectToUrl = new URL(
3760
buildRedirectUrl(params.offeringId, params.interval, 'new', 'checkout', {

apps/payments/next/config/index.ts

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

2223
class CspConfig {
2324
@IsUrl()
@@ -98,6 +99,11 @@ export class PaymentsNextConfig extends NestAppRootConfig {
9899
@IsDefined()
99100
sentry!: SentryServerConfig;
100101

102+
@Type(() => SP2MapConfig)
103+
@ValidateNested()
104+
@IsDefined()
105+
sp2map!: SP2MapConfig;
106+
101107
@IsString()
102108
authSecret!: string;
103109

libs/payments/legacy/src/index.ts

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

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,32 @@
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';
56
import { StripeMetadataWithCMS } from './types';
67

78
export const StripeMetadataWithCMSFactory = (
89
override?: Partial<StripeMetadataWithCMS>
910
): StripeMetadataWithCMS => ({
1011
...override,
1112
});
13+
14+
export const SP2MapConfigFactory = (
15+
override?: Partial<SP2MapConfig>
16+
): SP2MapConfig => ({
17+
offerings: {
18+
vpn: CurrencyFactory(),
19+
},
20+
...override,
21+
});
22+
23+
export const CurrencyFactory = (override?: Partial<Currency>): Currency => ({
24+
currencies: {
25+
USD: IntervalsFactory(),
26+
},
27+
...override,
28+
});
29+
30+
export const IntervalsFactory = (override?: Partial<Intervals>): Intervals => ({
31+
monthly: ['prod_productid', 'price_priceId'],
32+
...override,
33+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { plainToInstance, Transform } from 'class-transformer';
2+
import {
3+
IsString,
4+
IsDefined,
5+
IsObject,
6+
IsOptional,
7+
validateSync,
8+
} from 'class-validator';
9+
10+
export class Intervals {
11+
@IsOptional()
12+
@IsString({ each: true })
13+
@Transform(({ value }) => value.split(','))
14+
daily?: string[];
15+
16+
@IsOptional()
17+
@IsString({ each: true })
18+
@Transform(({ value }) => value.split(','))
19+
monthly?: string[];
20+
21+
@IsOptional()
22+
@IsString({ each: true })
23+
@Transform(({ value }) => value.split(','))
24+
halfyearly?: string[];
25+
26+
@IsOptional()
27+
@IsString({ each: true })
28+
@Transform(({ value }) => value.split(','))
29+
yearly?: string[];
30+
}
31+
32+
export class Currency {
33+
@IsDefined()
34+
@IsObject()
35+
@Transform(({ value }) => {
36+
let usdFound = false;
37+
const transformedValue = Object.entries(value).reduce((acc, [key, val]) => {
38+
if (key === 'USD') {
39+
usdFound = true;
40+
}
41+
const interval = plainToInstance(Intervals, val);
42+
try {
43+
const validation = validateSync(interval);
44+
if (validation.length > 0) {
45+
throw new Error(`Validation erorrs: ${JSON.stringify(validation)}`);
46+
}
47+
} catch (err) {
48+
console.log(err);
49+
throw new Error(`Validation issue with value: ${JSON.stringify(val)}`);
50+
}
51+
acc[key] = interval;
52+
return acc;
53+
}, {} as any);
54+
55+
if (!usdFound) {
56+
console.log(value);
57+
throw new Error('USD is required');
58+
}
59+
60+
return transformedValue;
61+
})
62+
currencies!: Record<string, Intervals | undefined>; // Index signature to allow dynamic keys
63+
}
64+
65+
export class SP2MapConfig {
66+
@IsDefined()
67+
@IsObject()
68+
@Transform(({ value }) => {
69+
const parsedValue = JSON.parse(value);
70+
const transformedValue = Object.entries(parsedValue).reduce(
71+
(acc, [key, val]) => {
72+
const classVal = plainToInstance(Currency, { currencies: val });
73+
try {
74+
const validation = validateSync(classVal);
75+
if (validation.length > 0) {
76+
throw new Error(`Validation erorrs: ${JSON.stringify(validation)}`);
77+
}
78+
} catch (err) {
79+
throw new Error(
80+
`Validation issue with value: ${JSON.stringify(val)}`
81+
);
82+
}
83+
acc[key] = classVal;
84+
return acc;
85+
},
86+
{} as any
87+
);
88+
return transformedValue;
89+
})
90+
offerings!: Record<string, Currency | undefined>; // Index signature to allow dynamic keys
91+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import assert from 'assert';
2+
import { SP2MapConfigFactory } from '../factories';
3+
import { getSP2Params } from './getSP2Params';
4+
5+
describe('getSP2Params', () => {
6+
const reportErrorMock = jest.fn();
7+
const sp2mapConfig = SP2MapConfigFactory();
8+
9+
beforeEach(() => {
10+
reportErrorMock.mockClear();
11+
});
12+
13+
describe('success', () => {
14+
it('successfully returns productId and planId', () => {
15+
const { productId, priceId } = getSP2Params(
16+
sp2mapConfig,
17+
reportErrorMock,
18+
'vpn',
19+
'monthly',
20+
'USD'
21+
);
22+
expect(reportErrorMock).not.toHaveBeenCalled();
23+
expect(productId).toBe('prod_productid');
24+
expect(priceId).toBe('price_priceId');
25+
});
26+
});
27+
28+
describe('success - with default fallbacks', () => {
29+
it('successfully returns productId and planId if no currency is provided', () => {
30+
const { productId, priceId } = getSP2Params(
31+
sp2mapConfig,
32+
reportErrorMock,
33+
'vpn',
34+
'monthly'
35+
);
36+
expect(reportErrorMock).toHaveBeenCalledWith('Currency is missing');
37+
expect(productId).toBe('prod_productid');
38+
expect(priceId).toBe('price_priceId');
39+
});
40+
41+
it('successfully returns productId and planId if invalid interval is provided', () => {
42+
const { productId, priceId } = getSP2Params(
43+
sp2mapConfig,
44+
reportErrorMock,
45+
'vpn',
46+
'invalid',
47+
'USD'
48+
);
49+
expect(reportErrorMock).toHaveBeenCalledWith(
50+
'Interval is not supported',
51+
{ interval: 'invalid' }
52+
);
53+
expect(productId).toBe('prod_productid');
54+
expect(priceId).toBe('price_priceId');
55+
});
56+
});
57+
58+
describe('failure', () => {
59+
it('throws an error if offering could not be found in config', () => {
60+
try {
61+
getSP2Params(
62+
sp2mapConfig,
63+
reportErrorMock,
64+
'invalid',
65+
'monthly',
66+
'USD'
67+
);
68+
assert('should have thrown an error');
69+
} catch (err) {
70+
expect(reportErrorMock).toHaveBeenCalledWith(
71+
'Missing or invalid offering',
72+
{ offeringId: 'invalid' }
73+
);
74+
expect(err).toBeInstanceOf(Error);
75+
}
76+
});
77+
78+
it('throws an error if interval could not be found in config', () => {
79+
try {
80+
getSP2Params(sp2mapConfig, reportErrorMock, 'vpn', 'daily', 'USD');
81+
assert('should have thrown an error');
82+
} catch (err) {
83+
expect(reportErrorMock).toHaveBeenCalledWith(
84+
'Invalid interval for offering',
85+
{ offeringId: 'vpn', interval: 'daily' }
86+
);
87+
expect(err).toBeInstanceOf(Error);
88+
}
89+
});
90+
});
91+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { SP2MapConfig } from '../sp2map.config';
2+
3+
type ValidInterval = 'daily' | 'monthly' | 'halfyearly' | 'yearly';
4+
5+
function isValidInterval(interval: string): interval is ValidInterval {
6+
return !(
7+
interval !== 'daily' &&
8+
interval !== 'monthly' &&
9+
interval !== 'halfyearly' &&
10+
interval !== 'yearly'
11+
);
12+
}
13+
14+
export function getSP2Params(
15+
sp2map: SP2MapConfig,
16+
reportError: (...args: any) => void,
17+
offeringId: string,
18+
interval: string,
19+
currency?: string
20+
) {
21+
if (!isValidInterval(interval)) {
22+
reportError('Interval is not supported', { interval });
23+
}
24+
25+
if (!currency) {
26+
reportError('Currency is missing');
27+
}
28+
29+
const calcInterval = isValidInterval(interval) ? interval : 'monthly';
30+
const calcCurrency = currency ? currency : 'USD';
31+
32+
const offering = sp2map.offerings[offeringId];
33+
if (!offering) {
34+
reportError('Missing or invalid offering', { offeringId });
35+
throw new Error(`Missing or invalid offering: ${offeringId}`);
36+
}
37+
38+
const intervalValues = offering.currencies[calcCurrency]?.[calcInterval];
39+
if (intervalValues?.length === 2) {
40+
return {
41+
productId: intervalValues[0],
42+
priceId: intervalValues[1],
43+
};
44+
} else {
45+
reportError('Invalid interval for offering', { offeringId, interval });
46+
throw new Error(
47+
`Invalid interval for offfering: ${interval}, ${offeringId}`
48+
);
49+
}
50+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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+
'use server';
6+
7+
import { plainToClass } from 'class-transformer';
8+
import { getApp } from '../nestapp/app';
9+
import { DetermineCurrencyActionArgs } from '../nestapp/validators/DetermineCurrencyActionArgs';
10+
11+
export const determineCurrencyAction = async (ip: string) => {
12+
const currency = await getApp().getActionsService().determineCurrency(
13+
plainToClass(DetermineCurrencyActionArgs, {
14+
ip,
15+
})
16+
);
17+
18+
return currency;
19+
};

libs/payments/ui/src/lib/actions/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
export { checkoutCartWithPaypal } from './checkoutCartWithPaypal';
66
export { checkoutCartWithStripe } from './checkoutCartWithStripe';
7+
export { determineCurrencyAction } from './determineCurrency';
78
export { fetchCMSData } from './fetchCMSData';
89
export { getCartAction } from './getCart';
910
export { getCartOrRedirectAction } from './getCartOrRedirect';

0 commit comments

Comments
 (0)