From 08d76f3af5b30d837e55bc0c2feeacc93b7d3cc6 Mon Sep 17 00:00:00 2001 From: Reino Muhl <10620585+StaberindeZA@users.noreply.github.com> Date: Mon, 27 Jan 2025 09:18:36 -0500 Subject: [PATCH] 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 --- apps/payments/next/.env | 6 + .../[offeringId]/[interval]/landing/route.ts | 60 +++++++-- apps/payments/next/config/index.ts | 7 +- libs/payments/legacy/src/index.ts | 2 + libs/payments/legacy/src/lib/factories.ts | 28 +++- libs/payments/legacy/src/lib/sp2map.config.ts | 60 ++++++++- .../src/lib/utils/buildSp2RedirectUrl.spec.ts | 40 ++++++ .../src/lib/utils/buildSp2RedirectUrl.ts | 18 +++ .../src/lib/utils/redirectToSp2.spec.ts | 121 ++++++++++++++++++ .../legacy/src/lib/utils/redirectToSp2.ts | 45 +++++++ 10 files changed, 373 insertions(+), 14 deletions(-) create mode 100644 libs/payments/legacy/src/lib/utils/buildSp2RedirectUrl.spec.ts create mode 100644 libs/payments/legacy/src/lib/utils/buildSp2RedirectUrl.ts create mode 100644 libs/payments/legacy/src/lib/utils/redirectToSp2.spec.ts create mode 100644 libs/payments/legacy/src/lib/utils/redirectToSp2.ts diff --git a/apps/payments/next/.env b/apps/payments/next/.env index a9531b17e5a..87abd1c85ba 100644 --- a/apps/payments/next/.env +++ b/apps/payments/next/.env @@ -117,4 +117,10 @@ PAYMENTS_NEXT_HOSTED_URL=http://localhost:3035 SUBSCRIPTIONS_UNSUPPORTED_LOCATIONS='["CN", "KP", "IR", "SY", "CU", "SD", "BY", "IQ", "OM", "RU", "TR", "TM", "AE"]' 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"}}} + +SP2REDIRECT__ENABLED=true +SP2REDIRECT__SHADOW_MODE=true +SP2REDIRECT__DEFAULT_REDIRECT_PERCENTAGE=100 +SP2REDIRECT__OFFERINGS={"123donepro":100,"123doneproplus":100,"123foxkeh":100,"foxkeh":100,"foxkeh2":100,"vpn":100} + # Nextjs Public Environment Variables diff --git a/apps/payments/next/app/[locale]/[offeringId]/[interval]/landing/route.ts b/apps/payments/next/app/[locale]/[offeringId]/[interval]/landing/route.ts index fad3ab2ccef..99e090bce71 100644 --- a/apps/payments/next/app/[locale]/[offeringId]/[interval]/landing/route.ts +++ b/apps/payments/next/app/[locale]/[offeringId]/[interval]/landing/route.ts @@ -11,15 +11,23 @@ import { import { BaseParams, buildRedirectUrl } from '@fxa/payments/ui'; import { config } from 'apps/payments/next/config'; import { getIpAddress } from '@fxa/payments/ui/server'; -import { getSP2Params } from '@fxa/payments/legacy'; +import { + buildSp2RedirectUrl, + getSP2Params, + redirectToSp2, +} from '@fxa/payments/legacy'; +import crypto from 'crypto'; +import * as Sentry from '@sentry/nextjs'; export const dynamic = 'force-dynamic'; // defaults to auto function reportError(message: string, details?: any) { if (details) { console.error(message, details); + Sentry.captureMessage(message, details); } else { console.error(message); + Sentry.captureMessage(message, 'error'); } } @@ -45,17 +53,47 @@ export async function GET( request: NextRequest, { params }: { params: BaseParams } ) { - const currency = await determineCurrencyAction(getIpAddress()); - const { productId, priceId } = getSP2Params( - config.sp2map, - reportError, - params.offeringId, - params.interval, - currency - ); - console.log({ productId, priceId }); + const requestSearchParams = request.nextUrl.searchParams; + + if (config.sp2redirect.enabled) { + const queryCurrency = requestSearchParams.get('currency'); + const querySpVersion = requestSearchParams.get('spVersion'); + const isSp2Redirect = redirectToSp2( + config.sp2redirect, + params.offeringId, + crypto.randomInt(1, 100), + reportError + ); + + if (isSp2Redirect || querySpVersion === '2') { + const currency = queryCurrency + ? queryCurrency + : await determineCurrencyAction(getIpAddress()); + const { productId, priceId } = getSP2Params( + config.sp2map, + reportError, + params.offeringId, + params.interval, + currency + ); + + const sp2RedirectUrl = buildSp2RedirectUrl( + productId, + priceId, + config.contentServerUrl, + requestSearchParams + ); + + if (!config.sp2redirect.shadowMode) { + redirect(sp2RedirectUrl); + } else { + console.log('SP2 Redirect Shadow Mode enabled', { sp2RedirectUrl }); + } + } + } + + const searchParams = Object.fromEntries(requestSearchParams); - const searchParams = Object.fromEntries(request.nextUrl.searchParams); const redirectToUrl = new URL( buildRedirectUrl(params.offeringId, params.interval, 'new', 'checkout', { locale: params.locale, diff --git a/apps/payments/next/config/index.ts b/apps/payments/next/config/index.ts index c9267aeffb7..3cb4335c9a6 100644 --- a/apps/payments/next/config/index.ts +++ b/apps/payments/next/config/index.ts @@ -18,7 +18,7 @@ import { RootConfig as NestAppRootConfig, validate, } from '@fxa/payments/ui/server'; -import { SP2MapConfig } from '@fxa/payments/legacy'; +import { SP2MapConfig, SP2RedirectConfig } from '@fxa/payments/legacy'; class CspConfig { @IsUrl() @@ -104,6 +104,11 @@ export class PaymentsNextConfig extends NestAppRootConfig { @IsDefined() sp2map!: SP2MapConfig; + @Type(() => SP2RedirectConfig) + @ValidateNested() + @IsDefined() + sp2redirect!: SP2RedirectConfig; + @IsString() authSecret!: string; diff --git a/libs/payments/legacy/src/index.ts b/libs/payments/legacy/src/index.ts index 6ba492013d9..fc72f2df8e8 100644 --- a/libs/payments/legacy/src/index.ts +++ b/libs/payments/legacy/src/index.ts @@ -1,3 +1,5 @@ export * from './lib/stripe-mapper.service'; export * from './lib/sp2map.config'; +export * from './lib/utils/buildSp2RedirectUrl'; export * from './lib/utils/getSP2Params'; +export * from './lib/utils/redirectToSp2'; diff --git a/libs/payments/legacy/src/lib/factories.ts b/libs/payments/legacy/src/lib/factories.ts index eceaf1760cb..ff2362e23b7 100644 --- a/libs/payments/legacy/src/lib/factories.ts +++ b/libs/payments/legacy/src/lib/factories.ts @@ -2,7 +2,14 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { SP2MapConfig, Currency, Intervals } from './sp2map.config'; +import { faker } from '@faker-js/faker'; +import { + SP2MapConfig, + Currency, + Intervals, + SP2RedirectConfig, + RedirectParams, +} from './sp2map.config'; import { StripeMetadataWithCMS } from './types'; export const StripeMetadataWithCMSFactory = ( @@ -31,3 +38,22 @@ export const IntervalsFactory = (override?: Partial): Intervals => ({ monthly: ['prod_productid', 'price_priceId'], ...override, }); + +export const RedirectParamsFactory = ( + override?: Partial +): RedirectParams => ({ + sp2RedirectPercentage: faker.number.int({ min: 0, max: 100 }), + ...override, +}); + +export const SP2RedirectConfigFactory = ( + override?: Partial +): SP2RedirectConfig => ({ + enabled: true, + shadowMode: false, + defaultRedirectPercentage: 100, + offerings: { + vpn: RedirectParamsFactory(), + }, + ...override, +}); diff --git a/libs/payments/legacy/src/lib/sp2map.config.ts b/libs/payments/legacy/src/lib/sp2map.config.ts index 19704f6fecc..739f91c80d4 100644 --- a/libs/payments/legacy/src/lib/sp2map.config.ts +++ b/libs/payments/legacy/src/lib/sp2map.config.ts @@ -1,10 +1,14 @@ -import { plainToInstance, Transform } from 'class-transformer'; +import { plainToInstance, Transform, Type } from 'class-transformer'; import { IsString, IsDefined, IsObject, IsOptional, validateSync, + IsBoolean, + IsNumber, + Min, + Max, } from 'class-validator'; export class Intervals { @@ -89,3 +93,57 @@ export class SP2MapConfig { }) offerings!: Record; // Index signature to allow dynamic keys } + +export class RedirectParams { + @IsDefined() + @IsNumber() + @Min(0) + @Max(100) + @Type(() => Number) + sp2RedirectPercentage!: number; +} + +export class SP2RedirectConfig { + @IsDefined() + @IsBoolean() + enabled!: boolean; + + @IsDefined() + @IsBoolean() + shadowMode!: boolean; + + @IsDefined() + @IsNumber() + @Min(0) + @Max(100) + @Type(() => Number) + defaultRedirectPercentage!: number; + + @IsDefined() + @IsObject() + @Transform(({ value }) => { + const parsedValue = JSON.parse(value); + const transformedValue = Object.entries(parsedValue).reduce( + (acc, [key, val]) => { + const classVal = plainToInstance(RedirectParams, { + sp2RedirectPercentage: val, + }); + try { + const validation = validateSync(classVal); + if (validation.length > 0) { + throw new Error(`Validation errors: ${JSON.stringify(validation)}`); + } + } catch (err) { + throw new Error( + `Validation issue with value: ${JSON.stringify(val)}` + ); + } + acc[key] = classVal; + return acc; + }, + {} as any + ); + return transformedValue; + }) + offerings!: Record; +} diff --git a/libs/payments/legacy/src/lib/utils/buildSp2RedirectUrl.spec.ts b/libs/payments/legacy/src/lib/utils/buildSp2RedirectUrl.spec.ts new file mode 100644 index 00000000000..1b693d40d78 --- /dev/null +++ b/libs/payments/legacy/src/lib/utils/buildSp2RedirectUrl.spec.ts @@ -0,0 +1,40 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { buildSp2RedirectUrl } from './buildSp2RedirectUrl'; + +describe('buildSp2RedirectUrl', () => { + const defaultProductId = 'prod_123'; + const defaultPriceId = 'price_123'; + const defaultContentServerUrl = 'http://content-server.com'; + const defaultSearchParams = new URLSearchParams( + 'flow_id=one&flow_begin_time=123' + ); + + it('should return the correct URL', () => { + const result = buildSp2RedirectUrl( + defaultProductId, + defaultPriceId, + defaultContentServerUrl, + defaultSearchParams + ); + expect(result).toBe( + 'http://content-server.com/subscriptions/products/prod_123?plan=price_123&flow_id=one&flow_begin_time=123' + ); + }); + + it('should remove SP2 redirect logic specific query params', () => { + defaultSearchParams.append('currency', 'USD'); + defaultSearchParams.append('spVersion', '2'); + const result = buildSp2RedirectUrl( + defaultProductId, + defaultPriceId, + defaultContentServerUrl, + defaultSearchParams + ); + expect(result).toBe( + 'http://content-server.com/subscriptions/products/prod_123?plan=price_123&flow_id=one&flow_begin_time=123' + ); + }); +}); diff --git a/libs/payments/legacy/src/lib/utils/buildSp2RedirectUrl.ts b/libs/payments/legacy/src/lib/utils/buildSp2RedirectUrl.ts new file mode 100644 index 00000000000..e4768131a76 --- /dev/null +++ b/libs/payments/legacy/src/lib/utils/buildSp2RedirectUrl.ts @@ -0,0 +1,18 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export function buildSp2RedirectUrl( + productId: string, + priceId: string, + contentServerUrl: string, + searchParams: URLSearchParams +) { + // Remove SP2 redirect logic specific query params + searchParams.delete('currency'); + searchParams.delete('spVersion'); + + const remainingQueryParams = searchParams.toString(); + + return `${contentServerUrl}/subscriptions/products/${productId}?plan=${priceId}&${remainingQueryParams}`; +} diff --git a/libs/payments/legacy/src/lib/utils/redirectToSp2.spec.ts b/libs/payments/legacy/src/lib/utils/redirectToSp2.spec.ts new file mode 100644 index 00000000000..79328f82180 --- /dev/null +++ b/libs/payments/legacy/src/lib/utils/redirectToSp2.spec.ts @@ -0,0 +1,121 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { redirectToSp2 } from './redirectToSp2'; +import { RedirectParamsFactory, SP2RedirectConfigFactory } from '../factories'; +import { RedirectParams } from '../sp2map.config'; + +describe('redirectToSp2', () => { + const defaultOfferingId = 'vpn'; + const mockReportError = jest.fn(); + + beforeEach(() => { + mockReportError.mockClear(); + }); + + describe('should return true', () => { + const defaultOfferings = {} as Record; + defaultOfferings[defaultOfferingId] = RedirectParamsFactory({ + sp2RedirectPercentage: 100, + }); + const defaultConfig = SP2RedirectConfigFactory({ + offerings: defaultOfferings, + }); + const defaultRandomPercentage = 100; + it('uses percentage from config', () => { + const result = redirectToSp2( + defaultConfig, + defaultOfferingId, + defaultRandomPercentage, + mockReportError + ); + expect(result).toBe(true); + expect(mockReportError).not.toHaveBeenCalled(); + }); + + it('uses config default percentage if config not found', () => { + const result = redirectToSp2( + defaultConfig, + 'invalidOfferingId', + defaultRandomPercentage, + mockReportError + ); + expect(result).toBe(true); + expect(mockReportError).toHaveBeenCalled(); + }); + }); + + describe('should return false', () => { + const defaultOfferings = {} as Record; + defaultOfferings[defaultOfferingId] = RedirectParamsFactory({ + sp2RedirectPercentage: 0, + }); + const defaultConfig = SP2RedirectConfigFactory({ + offerings: defaultOfferings, + defaultRedirectPercentage: 0, + }); + const defaultRandomPercentage = 1; + + it('uses percentage from config', () => { + const result = redirectToSp2( + defaultConfig, + defaultOfferingId, + defaultRandomPercentage, + mockReportError + ); + expect(result).toBe(false); + expect(mockReportError).toHaveBeenCalled(); + }); + + it('uses config default percentage if config not found', () => { + const result = redirectToSp2( + defaultConfig, + 'invalidOfferingId', + defaultRandomPercentage, + mockReportError + ); + expect(result).toBe(false); + expect(mockReportError).toHaveBeenCalled(); + }); + }); + + describe('randomPercentage stays in bound', () => { + it('returns true even if randomPercentage is more than 100', () => { + const defaultOfferings = {} as Record; + defaultOfferings[defaultOfferingId] = RedirectParamsFactory({ + sp2RedirectPercentage: 100, + }); + const mockConfig = SP2RedirectConfigFactory({ + offerings: defaultOfferings, + }); + + const result = redirectToSp2( + mockConfig, + defaultOfferingId, + 200, + mockReportError + ); + expect(result).toBe(true); + }); + + it('returns false even if randomPercentage is less than 1', () => { + const defaultOfferings = {} as Record; + defaultOfferings[defaultOfferingId] = RedirectParamsFactory({ + sp2RedirectPercentage: 0, + }); + const mockConfig = SP2RedirectConfigFactory({ + offerings: defaultOfferings, + defaultRedirectPercentage: 0, + }); + + const result = redirectToSp2( + mockConfig, + defaultOfferingId, + 0, + mockReportError + ); + expect(result).toBe(false); + }); + }); +}); diff --git a/libs/payments/legacy/src/lib/utils/redirectToSp2.ts b/libs/payments/legacy/src/lib/utils/redirectToSp2.ts new file mode 100644 index 00000000000..ef0fa009dc6 --- /dev/null +++ b/libs/payments/legacy/src/lib/utils/redirectToSp2.ts @@ -0,0 +1,45 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { SP2RedirectConfig } from '../sp2map.config'; + +export function redirectToSp2( + config: SP2RedirectConfig, + offeringId: string, + randomPercentage: number, + reportError: (...args: any) => void +) { + const configPercentage = config.offerings[offeringId]?.sp2RedirectPercentage; + const sp2RedirectPercentage = + configPercentage ?? config.defaultRedirectPercentage; + + // Each offering should have a value indicating which percentage + // of traffic should redirect to SP2. If config is not found, then + // report the error. + if (!configPercentage) { + reportError('No SP2 redirect percentage found for offering', { + offeringId, + }); + } + + let validPercentage; + if (randomPercentage < 1) { + console.log('Random percentage is too low'); + validPercentage = 1; + } else if (randomPercentage > 100) { + console.log('Random percentage is too high'); + validPercentage = 100; + } else { + validPercentage = randomPercentage; + } + + // sp2RedirectPercentage indicates the percentage of traffic that + // should be redirected to SP2. + // validPercentage is a random number between 1 and 100 + if (validPercentage <= sp2RedirectPercentage) { + return true; + } else { + return false; + } +}