-
-
- {purchase.productName}
-
-
-
- {l10n.getString(
- 'subscription-management-button-support',
- 'Get help'
- )}
-
-
+ {appleIapSubscriptions.length > 0 && (
+
+ {appleIapSubscriptions.map((purchase, index: number) => {
+ let nextBillDate: string | undefined;
+ if (purchase.expiresDate) {
+ const dateExpired = new Date(purchase.expiresDate);
+ nextBillDate = l10n.getLocalizedDateString(
+ Math.floor(dateExpired.getTime() / 1000),
+ false,
+ locale
+ );
+ }
+ return (
+ -
+
+
+
+
-
-
-
-
- {l10n.getString(
- 'subscription-management-google-in-app-purchase-2',
- 'Google in-app purchase'
+
+
+
+ {purchase.productName}
+
+
+ data-testid={`link-external-support-${purchase.productName}`}
+ >
+
+ {l10n.getString(
+ 'subscription-management-button-support',
+ 'Get help'
+ )}
+
+
- {!!purchase.expiryTimeMillis && (
- <>
-
+
+
-
- {purchase.autoRenewing ? (
-
- {l10n.getFragmentWithSource(
- 'subscription-management-iap-sub-next-bill-1',
- {
- vars: { date: nextBillDate },
- },
-
Next bill • {nextBillDate}
- )}
-
- ) : (
+ />
+
+ {l10n.getString(
+ 'subscription-management-apple-in-app-purchase-2',
+ 'Apple in-app purchase'
+ )}
+
+
+ {nextBillDate && (
+ <>
+
- )}
- >
- )}
-
-
-
)}
- >
-
- {l10n.getString(
- 'subscription-management-button-manage-subscription-1',
- 'Manage subscription'
+
+
+
-
-
+ >
+
+ {l10n.getString(
+ 'subscription-management-button-manage-subscription-1',
+ 'Manage subscription'
+ )}
+
+
+
+
-
-
- );
- })}
-
- )}
- >
- )}
+
+ );
+ })}
+
+ )}
+
+ {googleIapSubscriptions.length > 0 && (
+
+ {googleIapSubscriptions.map((purchase, index: number) => {
+ const nextBillDate = l10n.getLocalizedDateString(
+ purchase.expiryTimeMillis / 1000,
+ false,
+ locale
+ );
+ return (
+ -
+
+
+
+
+
+
+
+
+ {purchase.productName}
+
+
+
+ {l10n.getString(
+ 'subscription-management-button-support',
+ 'Get help'
+ )}
+
+
+
+
+
+
+
+ {l10n.getString(
+ 'subscription-management-google-in-app-purchase-2',
+ 'Google in-app purchase'
+ )}
+
+
+ {!!purchase.expiryTimeMillis && (
+ <>
+
+
+ {purchase.autoRenewing ? (
+
+ {l10n.getFragmentWithSource(
+ 'subscription-management-iap-sub-next-bill-1',
+ {
+ vars: { date: nextBillDate },
+ },
+
Next bill • {nextBillDate}
+ )}
+
+ ) : (
+
+
+
+ {l10n.getString(
+ 'subscription-management-iap-sub-expires-on-expiry-date',
+ {
+ date: nextBillDate,
+ },
+ `Expires on ${nextBillDate}`
+ )}
+
+
+ )}
+ >
+ )}
+
+
+
+
+ {l10n.getString(
+ 'subscription-management-button-manage-subscription-1',
+ 'Manage subscription'
+ )}
+
+
+
+
+
+
+
+
+ );
+ })}
+
+ )}
+ >
+ )}
);
diff --git a/apps/payments/next/middleware.ts b/apps/payments/next/middleware.ts
index b93a2fa23c..4b351f2e2d 100644
--- a/apps/payments/next/middleware.ts
+++ b/apps/payments/next/middleware.ts
@@ -69,6 +69,16 @@ export function middleware(request: NextRequest) {
contentSecurityPolicyHeaderValue
);
+ // If the user is not logged in, `getExperimentationId` uses the value we
+ // set here to determine which experiments to show the user. We read it from
+ // the `experimentationId` cookie, and initialise that cookie if it's unset.
+ // (The reason we do this in middleware, is to ensure that every call to
+ // `getExperimentationId` results in the same ID.)
+ const existingExperimentationId = request.cookies.get('experimentationId');
+ const experimentationId =
+ existingExperimentationId?.value ?? `guest-${crypto.randomUUID()}`;
+ requestHeaders.set('x-experimentation-id', experimentationId);
+
const response = NextResponse.next({
request: {
headers: requestHeaders,
@@ -79,6 +89,12 @@ export function middleware(request: NextRequest) {
contentSecurityPolicyHeaderValue
);
+ response.cookies.set({
+ name: 'experimentationId',
+ value: experimentationId,
+ path: '/',
+ });
+
return response;
}
diff --git a/libs/payments/events/src/lib/emitter.factories.ts b/libs/payments/events/src/lib/emitter.factories.ts
index 9dc7649a49..0feccbe238 100644
--- a/libs/payments/events/src/lib/emitter.factories.ts
+++ b/libs/payments/events/src/lib/emitter.factories.ts
@@ -18,15 +18,21 @@ import { SubplatInterval } from '@fxa/payments/customer';
export const AuthEventsFactory = (
override?: Partial
): AuthEvents => ({
- type: faker.helpers.arrayElement(['signin', 'signout', 'prompt_none_fail', 'error']),
- ...override
-})
+ type: faker.helpers.arrayElement([
+ 'signin',
+ 'signout',
+ 'prompt_none_fail',
+ 'error',
+ ]),
+ ...override,
+});
export const AdditionalMetricsDataFactory = (
override?: AdditionalMetricsData
): AdditionalMetricsData => ({
cmsMetricsData: CmsMetricsDataFactory(),
cartMetricsData: CartMetricsFactory(),
+ locale: faker.helpers.arrayElement(['en-US', 'de', 'es', 'fr-FR']),
...override,
});
diff --git a/libs/payments/events/src/lib/emitter.service.spec.ts b/libs/payments/events/src/lib/emitter.service.spec.ts
index b9ff80fddc..a6db31a08a 100644
--- a/libs/payments/events/src/lib/emitter.service.spec.ts
+++ b/libs/payments/events/src/lib/emitter.service.spec.ts
@@ -64,6 +64,16 @@ import {
PaypalClientConfig,
PaypalCustomerManager,
} from '@fxa/payments/paypal';
+import {
+ MockNimbusClientConfigProvider,
+ NimbusClient,
+ NimbusEnrollmentFactory,
+} from '@fxa/shared/experiments';
+import {
+ MockNimbusManagerConfigProvider,
+ NimbusManager,
+ SubPlatNimbusResultFactory,
+} from '@fxa/payments/experiments';
jest.mock('./util/retrieveAdditionalMetricsData');
const mockedRetrieveAdditionalMetricsData = jest.mocked(
@@ -78,6 +88,7 @@ describe('PaymentsEmitterService', () => {
let paymentsGleanManager: PaymentsGleanManager;
let paymentMethodManager: PaymentMethodManager;
let productConfigurationManager: ProductConfigurationManager;
+ let nimbusManager: NimbusManager;
let statsd: StatsD;
let logger: Logger;
let subscriptionManager: SubscriptionManager;
@@ -125,6 +136,10 @@ describe('PaymentsEmitterService', () => {
PaymentsEmitterService,
PaymentMethodManager,
SubscriptionManager,
+ NimbusClient,
+ MockNimbusClientConfigProvider,
+ NimbusManager,
+ MockNimbusManagerConfigProvider,
],
}).compile();
@@ -134,6 +149,7 @@ describe('PaymentsEmitterService', () => {
paymentsGleanManager = moduleRef.get(PaymentsGleanManager);
paymentMethodManager = moduleRef.get(PaymentMethodManager);
productConfigurationManager = moduleRef.get(ProductConfigurationManager);
+ nimbusManager = moduleRef.get(NimbusManager);
cartManager = moduleRef.get(CartManager);
statsd = moduleRef.get(StatsDService);
logger = moduleRef.get(Logger);
@@ -182,10 +198,23 @@ describe('PaymentsEmitterService', () => {
});
describe('handleCheckoutView', () => {
+ const mockEnrollment = NimbusEnrollmentFactory();
+ const mockSubPlatExperiments = SubPlatNimbusResultFactory({
+ Enrollments: [mockEnrollment],
+ });
+ const nimbusUserId = mockEnrollment.nimbus_user_id;
+ const generatedNimbusUserId = NimbusEnrollmentFactory().nimbus_user_id;
+
beforeEach(() => {
jest
.spyOn(paymentsGleanManager, 'recordFxaPaySetupView')
.mockReturnValue();
+ jest
+ .spyOn(nimbusManager, 'fetchExperiments')
+ .mockResolvedValue(mockSubPlatExperiments);
+ jest
+ .spyOn(nimbusManager, 'generateNimbusId')
+ .mockReturnValue(generatedNimbusUserId);
});
it('should call manager record method', async () => {
@@ -198,8 +227,11 @@ describe('PaymentsEmitterService', () => {
);
expect(paymentsGleanManager.recordFxaPaySetupView).toHaveBeenCalledWith({
commonMetricsData: mockCommonMetricsData,
+ experimentationData: { nimbusUserId },
...additionalMetricsData,
});
+ expect(nimbusManager.fetchExperiments).toHaveBeenCalled();
+ expect(nimbusManager.generateNimbusId).toHaveBeenCalled();
});
it('should not record glean event if user opts out', async () => {
@@ -215,6 +247,8 @@ describe('PaymentsEmitterService', () => {
mockCommonMetricsData.params
);
expect(paymentsGleanManager.recordFxaPaySetupView).not.toHaveBeenCalled();
+ expect(nimbusManager.fetchExperiments).not.toHaveBeenCalled();
+ expect(nimbusManager.generateNimbusId).not.toHaveBeenCalled();
});
});
diff --git a/libs/payments/events/src/lib/emitter.service.ts b/libs/payments/events/src/lib/emitter.service.ts
index 8db88b3c65..7d82f0b51e 100644
--- a/libs/payments/events/src/lib/emitter.service.ts
+++ b/libs/payments/events/src/lib/emitter.service.ts
@@ -27,6 +27,7 @@ import {
import * as Sentry from '@sentry/nestjs';
import { StatsD, StatsDService } from '@fxa/shared/metrics/statsd';
import { EmitterServiceHandleAuthError } from './emitter.error';
+import { NimbusManager } from '@fxa/payments/experiments';
@Injectable()
export class PaymentsEmitterService {
@@ -37,6 +38,7 @@ export class PaymentsEmitterService {
private cartManager: CartManager,
private customerManager: CustomerManager,
private log: Logger,
+ private nimbusManager: NimbusManager,
private paymentsGleanManager: PaymentsGleanManager,
private paymentMethodManager: PaymentMethodManager,
private productConfigurationManager: ProductConfigurationManager,
@@ -83,9 +85,30 @@ export class PaymentsEmitterService {
);
if (!metricsOptOut) {
+ let experiments;
+ const generatedNimbusUserId = this.nimbusManager.generateNimbusId(
+ additionalData.cartMetricsData.uid,
+ eventData.experimentationId
+ );
+ try {
+ experiments = await this.nimbusManager.fetchExperiments({
+ nimbusUserId: generatedNimbusUserId,
+ language: additionalData.locale,
+ region: additionalData.cartMetricsData.taxAddress?.countryCode,
+ });
+ } catch (error) {
+ this.log.error(error);
+ Sentry.captureException(error);
+ }
+
+ const nimbusUserId =
+ experiments?.Enrollments?.at(0)?.nimbus_user_id ||
+ generatedNimbusUserId;
+
this.paymentsGleanManager.recordFxaPaySetupView({
commonMetricsData: eventData,
...additionalData,
+ experimentationData: { nimbusUserId },
});
}
}
diff --git a/libs/payments/events/src/lib/emitter.types.ts b/libs/payments/events/src/lib/emitter.types.ts
index 59457b8e45..fc486f4c5e 100644
--- a/libs/payments/events/src/lib/emitter.types.ts
+++ b/libs/payments/events/src/lib/emitter.types.ts
@@ -10,9 +10,7 @@ import {
} from '@fxa/payments/metrics';
import { LocationStatus } from '@fxa/payments/eligibility';
import { TaxChangeAllowedStatus } from '@fxa/payments/cart';
-import {
- SubPlatPaymentMethodType,
-} from '@fxa/payments/customer';
+import { SubPlatPaymentMethodType } from '@fxa/payments/customer';
export type CheckoutEvents = CommonMetrics;
export type CheckoutPaymentEvents = CommonMetrics & {
@@ -50,7 +48,7 @@ export type SP3RolloutEvent = {
export type AuthEvents = {
type: 'signin' | 'signout' | 'prompt_none_fail' | 'error';
errorMessage?: string;
-}
+};
export type PaymentsEmitterEvents = {
checkoutView: CheckoutEvents;
@@ -67,4 +65,5 @@ export type PaymentsEmitterEvents = {
export type AdditionalMetricsData = {
cmsMetricsData: CmsMetricsData;
cartMetricsData: CartMetrics;
+ locale: string;
};
diff --git a/libs/payments/events/src/lib/util/retrieveAdditionalMetricsData.ts b/libs/payments/events/src/lib/util/retrieveAdditionalMetricsData.ts
index 9cc154d5ee..2480410e91 100644
--- a/libs/payments/events/src/lib/util/retrieveAdditionalMetricsData.ts
+++ b/libs/payments/events/src/lib/util/retrieveAdditionalMetricsData.ts
@@ -11,6 +11,7 @@ export async function retrieveAdditionalMetricsData(
cartManager: CartManager,
params: Record
): Promise {
+ const locale = params['locale'] || 'en';
const offeringId = params['offeringId'];
const interval = params['interval'];
const cartId = params['cartId'];
@@ -54,6 +55,7 @@ export async function retrieveAdditionalMetricsData(
couponCode: cartData.value.couponCode,
currency: cartData.value.currency,
stripeCustomerId: cartData.value.stripeCustomerId,
+ taxAddress: cartData.value.taxAddress,
}
: {
uid: '',
@@ -61,10 +63,12 @@ export async function retrieveAdditionalMetricsData(
couponCode: '',
currency: '',
stripeCustomerId: '',
+ taxAddress: { countryCode: '', postalCode: '' },
};
return {
cmsMetricsData,
cartMetricsData,
+ locale,
};
}
diff --git a/libs/payments/events/src/lib/util/retrieveAdditionalMetricsdata.spec.ts b/libs/payments/events/src/lib/util/retrieveAdditionalMetricsdata.spec.ts
index dc1711ad34..c724f7ee27 100644
--- a/libs/payments/events/src/lib/util/retrieveAdditionalMetricsdata.spec.ts
+++ b/libs/payments/events/src/lib/util/retrieveAdditionalMetricsdata.spec.ts
@@ -31,6 +31,7 @@ const expectedCartMetricsData = {
couponCode: mockCart.couponCode,
currency: mockCart.currency,
stripeCustomerId: mockCart.stripeCustomerId,
+ taxAddress: mockCart.taxAddress,
};
const emptyCmsMetricsData = {
@@ -43,6 +44,10 @@ const emptyCartMetricsData = {
couponCode: '',
currency: '',
stripeCustomerId: '',
+ taxAddress: {
+ countryCode: '',
+ postalCode: '',
+ },
};
describe('retrieveAdditionalMetricsData', () => {
diff --git a/libs/payments/experiments/.eslintrc.json b/libs/payments/experiments/.eslintrc.json
new file mode 100644
index 0000000000..3456be9b90
--- /dev/null
+++ b/libs/payments/experiments/.eslintrc.json
@@ -0,0 +1,18 @@
+{
+ "extends": ["../../../.eslintrc.json"],
+ "ignorePatterns": ["!**/*"],
+ "overrides": [
+ {
+ "files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
+ "rules": {}
+ },
+ {
+ "files": ["*.ts", "*.tsx"],
+ "rules": {}
+ },
+ {
+ "files": ["*.js", "*.jsx"],
+ "rules": {}
+ }
+ ]
+}
diff --git a/libs/payments/experiments/.swcrc b/libs/payments/experiments/.swcrc
new file mode 100644
index 0000000000..42938ddcef
--- /dev/null
+++ b/libs/payments/experiments/.swcrc
@@ -0,0 +1,15 @@
+{
+ "jsc": {
+ "target": "es2017",
+ "parser": {
+ "syntax": "typescript",
+ "decorators": true,
+ "dynamicImport": true
+ },
+ "transform": {
+ "decoratorMetadata": true,
+ "legacyDecorator": true
+ }
+ }
+}
+
diff --git a/libs/payments/experiments/README.md b/libs/payments/experiments/README.md
new file mode 100644
index 0000000000..6494ff526b
--- /dev/null
+++ b/libs/payments/experiments/README.md
@@ -0,0 +1,11 @@
+# experiments
+
+This library was generated with [Nx](https://nx.dev).
+
+## Building
+
+Run `nx build experiments` to build the library.
+
+## Running unit tests
+
+Run `nx test-unit experiments` to execute the unit tests via [Jest](https://jestjs.io).
diff --git a/libs/payments/experiments/jest.config.ts b/libs/payments/experiments/jest.config.ts
new file mode 100644
index 0000000000..8f32661a96
--- /dev/null
+++ b/libs/payments/experiments/jest.config.ts
@@ -0,0 +1,43 @@
+/* eslint-disable */
+import { readFileSync } from 'fs';
+import { Config } from 'jest';
+
+// Reading the SWC compilation config and remove the "exclude"
+// for the test files to be compiled by SWC
+const { exclude: _, ...swcJestConfig } = JSON.parse(
+ readFileSync(`${__dirname}/.swcrc`, 'utf-8')
+);
+
+// disable .swcrc look-up by SWC core because we're passing in swcJestConfig ourselves.
+// If we do not disable this, SWC Core will read .swcrc and won't transform our test files due to "exclude"
+if (swcJestConfig.swcrc === undefined) {
+ swcJestConfig.swcrc = false;
+}
+
+// Uncomment if using global setup/teardown files being transformed via swc
+// https://nx.dev/packages/jest/documents/overview#global-setup/teardown-with-nx-libraries
+// jest needs EsModule Interop to find the default exported setup/teardown functions
+// swcJestConfig.module.noInterop = false;
+
+const config: Config = {
+ displayName: 'payments-experiments',
+ preset: '../../../jest.preset.js',
+ testEnvironment: 'node',
+ transform: {
+ '^.+\\.[tj]s$': ['@swc/jest', swcJestConfig],
+ },
+ moduleFileExtensions: ['ts', 'js', 'html'],
+ coverageDirectory: '../../../coverage/libs/payments/experiments',
+ reporters: [
+ 'default',
+ [
+ 'jest-junit',
+ {
+ outputDirectory: 'artifacts/tests/payments-experiments',
+ outputName: 'payments-experiments-jest-unit-results.xml',
+ },
+ ],
+ ],
+};
+
+export default config;
diff --git a/libs/payments/experiments/package.json b/libs/payments/experiments/package.json
new file mode 100644
index 0000000000..5b05d51170
--- /dev/null
+++ b/libs/payments/experiments/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@fxa/payments/experiments",
+ "version": "0.0.1",
+ "private": true,
+ "type": "commonjs",
+ "main": "./index.cjs",
+ "types": "./index.d.ts",
+ "dependencies": {}
+}
diff --git a/libs/payments/experiments/project.json b/libs/payments/experiments/project.json
new file mode 100644
index 0000000000..0608604b4e
--- /dev/null
+++ b/libs/payments/experiments/project.json
@@ -0,0 +1,29 @@
+{
+ "name": "payments-experiments",
+ "$schema": "../../../node_modules/nx/schemas/project-schema.json",
+ "sourceRoot": "libs/payments/experiments/src",
+ "projectType": "library",
+ "tags": [],
+ "targets": {
+ "build": {
+ "executor": "@nx/esbuild:esbuild",
+ "outputs": ["{options.outputPath}"],
+ "options": {
+ "outputPath": "dist/libs/payments/experiments",
+ "main": "libs/payments/experiments/src/index.ts",
+ "tsConfig": "libs/payments/experiments/tsconfig.lib.json",
+ "assets": ["libs/payments/experiments/*.md"],
+ "declaration": true,
+ "generatePackageJson": true
+ }
+ },
+ "test-unit": {
+ "executor": "@nx/jest:jest",
+ "outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
+ "options": {
+ "jestConfig": "libs/payments/experiments/jest.config.ts",
+ "testPathPattern": ["^(?!.*\\.in\\.spec\\.ts$).*$"]
+ }
+ }
+ }
+}
diff --git a/libs/payments/experiments/src/index.ts b/libs/payments/experiments/src/index.ts
new file mode 100644
index 0000000000..153d1e6a64
--- /dev/null
+++ b/libs/payments/experiments/src/index.ts
@@ -0,0 +1,8 @@
+/* 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 * from './lib/nimbus.factories';
+export * from './lib/nimbus.manager';
+export * from './lib/nimbus.manager.config';
+export * from './lib/nimbus.types';
diff --git a/libs/payments/experiments/src/lib/nimbus.factories.ts b/libs/payments/experiments/src/lib/nimbus.factories.ts
new file mode 100644
index 0000000000..12b8a819b2
--- /dev/null
+++ b/libs/payments/experiments/src/lib/nimbus.factories.ts
@@ -0,0 +1,30 @@
+/* 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 { faker } from '@faker-js/faker';
+import type {
+ WelcomeFeature,
+ Features,
+ SubPlatNimbusResult,
+} from './nimbus.types';
+
+export const WelcomeFeatureFactory = (
+ override?: Partial
+): WelcomeFeature => ({
+ enabled: faker.datatype.boolean(),
+ ...override,
+});
+
+export const FeaturesFactory = (override?: Partial): Features => ({
+ 'welcome-feature': WelcomeFeatureFactory(),
+ ...override,
+});
+
+export const SubPlatNimbusResultFactory = (
+ override?: Partial
+): SubPlatNimbusResult => ({
+ Features: FeaturesFactory(),
+ Enrollments: [],
+ ...override,
+});
diff --git a/libs/payments/experiments/src/lib/nimbus.manager.config.ts b/libs/payments/experiments/src/lib/nimbus.manager.config.ts
new file mode 100644
index 0000000000..e2cd29e981
--- /dev/null
+++ b/libs/payments/experiments/src/lib/nimbus.manager.config.ts
@@ -0,0 +1,25 @@
+/* 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 { faker } from '@faker-js/faker';
+import { Provider } from '@nestjs/common';
+import { IsBoolean, IsString } from 'class-validator';
+
+export class NimbusManagerConfig {
+ @IsBoolean()
+ public readonly enabled!: boolean;
+
+ @IsString()
+ public readonly namespace!: string;
+}
+
+export const MockNimbusManagerConfig = {
+ enabled: faker.datatype.boolean(),
+ namespace: faker.string.uuid(),
+} satisfies NimbusManagerConfig;
+
+export const MockNimbusManagerConfigProvider = {
+ provide: NimbusManagerConfig,
+ useValue: MockNimbusManagerConfig,
+} satisfies Provider;
diff --git a/libs/payments/experiments/src/lib/nimbus.manager.spec.ts b/libs/payments/experiments/src/lib/nimbus.manager.spec.ts
new file mode 100644
index 0000000000..9ab68dbba4
--- /dev/null
+++ b/libs/payments/experiments/src/lib/nimbus.manager.spec.ts
@@ -0,0 +1,156 @@
+/* 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 { Test } from '@nestjs/testing';
+import {
+ NimbusClient,
+ MockNimbusClientConfigProvider,
+ NimbusEnrollmentFactory,
+ NimbusContextFactory,
+ generateNimbusId,
+} from '@fxa/shared/experiments';
+import { MockStatsDProvider } from '@fxa/shared/metrics/statsd';
+import { NimbusManager } from './nimbus.manager';
+import { SubPlatNimbusResultFactory } from './nimbus.factories';
+import { faker } from '@faker-js/faker/.';
+import {
+ MockNimbusManagerConfig,
+ NimbusManagerConfig,
+} from './nimbus.manager.config';
+import { Logger } from '@nestjs/common';
+
+jest.mock('@fxa/shared/experiments', () => {
+ const originalModule = jest.requireActual('@fxa/shared/experiments');
+
+ //Mock the default export and named export 'foo'
+ return {
+ __esModule: true,
+ ...originalModule,
+ generateNimbusId: jest.fn(),
+ };
+});
+const mockedGenerateNimbusId = jest.mocked(generateNimbusId);
+
+describe('NimbusClient', () => {
+ let nimbusClient: NimbusClient;
+ let nimbusManager: NimbusManager;
+
+ const mockNimbusManagerConfig = MockNimbusManagerConfig;
+
+ beforeEach(async () => {
+ global.fetch = jest.fn();
+
+ const module = await Test.createTestingModule({
+ providers: [
+ NimbusClient,
+ MockNimbusClientConfigProvider,
+ MockStatsDProvider,
+ {
+ provide: NimbusManagerConfig,
+ useValue: mockNimbusManagerConfig,
+ },
+ NimbusManager,
+ {
+ provide: Logger,
+ useValue: {
+ error: jest.fn(),
+ log: jest.fn(),
+ },
+ },
+ ],
+ }).compile();
+
+ nimbusClient = module.get(NimbusClient);
+ nimbusManager = module.get(NimbusManager);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('fetchExperiments', () => {
+ const mockContext = NimbusContextFactory();
+ const mockEnrollment = NimbusEnrollmentFactory();
+ const mockFetchExperimentsResult = SubPlatNimbusResultFactory({
+ Enrollments: [mockEnrollment],
+ });
+ const nimbusUserId = mockEnrollment.nimbus_user_id;
+ const mockFetchExperimentsParams = {
+ nimbusUserId,
+ language: mockContext.language || undefined,
+ region: mockContext.region || undefined,
+ };
+
+ beforeEach(() => {
+ mockNimbusManagerConfig.enabled = true;
+ jest
+ .spyOn(nimbusClient, 'fetchExperiments')
+ .mockResolvedValue(mockFetchExperimentsResult);
+ });
+
+ it('successfully returns experiments data', async () => {
+ const result = await nimbusManager.fetchExperiments(
+ mockFetchExperimentsParams
+ );
+ expect(result).toEqual(mockFetchExperimentsResult);
+ expect(nimbusClient.fetchExperiments).toHaveBeenCalledWith({
+ clientId: nimbusUserId,
+ context: mockContext,
+ });
+ });
+
+ it('returns null if not enabled', async () => {
+ mockNimbusManagerConfig.enabled = false;
+ const result = await nimbusManager.fetchExperiments(
+ mockFetchExperimentsParams
+ );
+ expect(result).toBeNull();
+ });
+
+ it('throws an error', async () => {
+ const expectedError = new Error('unexpected error');
+ jest
+ .spyOn(nimbusClient, 'fetchExperiments')
+ .mockRejectedValue(expectedError);
+ await expect(
+ nimbusManager.fetchExperiments(mockFetchExperimentsParams)
+ ).rejects.toThrow(expectedError);
+ });
+ });
+
+ describe('generateNimbusId', () => {
+ const mockFxaUid = faker.string.uuid();
+ const mockNimbusUserId = faker.string.uuid();
+ const mockHeaderExperimentId = faker.string.uuid();
+ beforeEach(() => {
+ mockedGenerateNimbusId.mockReturnValue(mockNimbusUserId);
+ });
+
+ it('successfully returns nimbus user id using fxaUid', () => {
+ const result = nimbusManager.generateNimbusId(mockFxaUid);
+ expect(result).toEqual(mockNimbusUserId);
+ expect(mockedGenerateNimbusId).toHaveBeenCalledWith(
+ mockNimbusManagerConfig.namespace,
+ mockFxaUid
+ );
+ });
+
+ it('successfully returns nimbus user id using header experiment id', () => {
+ const result = nimbusManager.generateNimbusId(
+ undefined,
+ mockHeaderExperimentId
+ );
+ expect(result).toEqual(mockHeaderExperimentId);
+ expect(mockedGenerateNimbusId).not.toHaveBeenCalled();
+ });
+
+ it('successfully returns nimbus user id newly generated', () => {
+ const result = nimbusManager.generateNimbusId();
+ expect(result).toEqual(mockNimbusUserId);
+ expect(mockedGenerateNimbusId).toHaveBeenCalledWith(
+ mockNimbusManagerConfig.namespace
+ );
+ });
+ });
+});
diff --git a/libs/payments/experiments/src/lib/nimbus.manager.ts b/libs/payments/experiments/src/lib/nimbus.manager.ts
new file mode 100644
index 0000000000..3075fcd9a4
--- /dev/null
+++ b/libs/payments/experiments/src/lib/nimbus.manager.ts
@@ -0,0 +1,52 @@
+/* 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 { Injectable, Logger } from '@nestjs/common';
+import { generateNimbusId, NimbusClient } from '@fxa/shared/experiments';
+import { NimbusManagerConfig } from './nimbus.manager.config';
+import { SubPlatNimbusResult } from './nimbus.types';
+
+@Injectable()
+export class NimbusManager {
+ constructor(
+ private log: Logger,
+ private nimbusClient: NimbusClient,
+ private nimbusManagerConfig: NimbusManagerConfig
+ ) {}
+
+ async fetchExperiments({
+ nimbusUserId,
+ language,
+ region,
+ }: {
+ nimbusUserId: string;
+ language?: string;
+ region?: string;
+ }) {
+ if (!this.nimbusManagerConfig.enabled) {
+ return null;
+ }
+
+ const results =
+ await this.nimbusClient.fetchExperiments({
+ clientId: nimbusUserId,
+ context: { language: language || null, region: region || null },
+ });
+
+ // Temporarily log results for debugging purposes
+ this.log.log(JSON.stringify(results));
+
+ return results;
+ }
+
+ generateNimbusId(fxaUid?: string, headerExperimentId?: string) {
+ if (fxaUid) {
+ return generateNimbusId(this.nimbusManagerConfig.namespace, fxaUid);
+ } else if (headerExperimentId) {
+ return headerExperimentId;
+ } else {
+ return generateNimbusId(this.nimbusManagerConfig.namespace);
+ }
+ }
+}
diff --git a/libs/payments/experiments/src/lib/nimbus.types.ts b/libs/payments/experiments/src/lib/nimbus.types.ts
new file mode 100644
index 0000000000..d2a725e90d
--- /dev/null
+++ b/libs/payments/experiments/src/lib/nimbus.types.ts
@@ -0,0 +1,17 @@
+/* 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 type { NimbusResult } from '@fxa/shared/experiments';
+
+export interface WelcomeFeature {
+ enabled: boolean;
+}
+
+export interface Features {
+ 'welcome-feature': WelcomeFeature;
+}
+
+export interface SubPlatNimbusResult extends NimbusResult {
+ Features: Features;
+}
diff --git a/libs/payments/experiments/tsconfig.json b/libs/payments/experiments/tsconfig.json
new file mode 100644
index 0000000000..86622aca55
--- /dev/null
+++ b/libs/payments/experiments/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "module": "commonjs",
+ "forceConsistentCasingInFileNames": true,
+ "strict": true,
+ "importHelpers": true,
+ "noImplicitOverride": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "noPropertyAccessFromIndexSignature": true
+ },
+ "files": [],
+ "include": [],
+ "references": [
+ {
+ "path": "./tsconfig.lib.json"
+ },
+ {
+ "path": "./tsconfig.spec.json"
+ }
+ ]
+}
diff --git a/libs/payments/experiments/tsconfig.lib.json b/libs/payments/experiments/tsconfig.lib.json
new file mode 100644
index 0000000000..4befa7f099
--- /dev/null
+++ b/libs/payments/experiments/tsconfig.lib.json
@@ -0,0 +1,10 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../../dist/out-tsc",
+ "declaration": true,
+ "types": ["node"]
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
+}
diff --git a/libs/payments/experiments/tsconfig.spec.json b/libs/payments/experiments/tsconfig.spec.json
new file mode 100644
index 0000000000..ab55b7c7ac
--- /dev/null
+++ b/libs/payments/experiments/tsconfig.spec.json
@@ -0,0 +1,15 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../../dist/out-tsc",
+ "module": "commonjs",
+ "moduleResolution": "node10",
+ "types": ["jest", "node"]
+ },
+ "include": [
+ "jest.config.ts",
+ "src/**/*.test.ts",
+ "src/**/*.spec.ts",
+ "src/**/*.d.ts"
+ ]
+}
diff --git a/libs/payments/metrics/src/lib/glean/glean.factory.ts b/libs/payments/metrics/src/lib/glean/glean.factory.ts
index e50caf0470..20d7e76a84 100644
--- a/libs/payments/metrics/src/lib/glean/glean.factory.ts
+++ b/libs/payments/metrics/src/lib/glean/glean.factory.ts
@@ -8,6 +8,7 @@ import {
CmsMetricsData,
CommonMetrics,
SubscriptionCancellationData,
+ type ExperimentationData,
} from './glean.types';
import { ResultCartFactory } from '@fxa/payments/cart';
import { SubplatInterval } from '@fxa/payments/customer';
@@ -36,6 +37,7 @@ export const CommonMetricsFactory = (
userAgent: faker.internet.userAgent(),
params: {},
searchParams: {},
+ experimentationId: faker.string.uuid(),
...override,
});
@@ -52,6 +54,7 @@ export const CartMetricsFactory = (
couponCode: resultCart.couponCode,
currency: faker.finance.currencyCode().toLowerCase(),
stripeCustomerId: `cus_${faker.string.alphanumeric({ length: 14 })}`,
+ taxAddress: resultCart.taxAddress,
...override,
};
};
@@ -81,3 +84,10 @@ export const SubscriptionCancellationDataFactory = (
...override,
};
};
+
+export const ExperimentationDataFactory = (
+ override?: Partial
+): ExperimentationData => ({
+ nimbusUserId: faker.string.uuid(),
+ ...override,
+});
diff --git a/libs/payments/metrics/src/lib/glean/glean.manager.ts b/libs/payments/metrics/src/lib/glean/glean.manager.ts
index 0439a0692e..b57a0f2cd6 100644
--- a/libs/payments/metrics/src/lib/glean/glean.manager.ts
+++ b/libs/payments/metrics/src/lib/glean/glean.manager.ts
@@ -10,6 +10,7 @@ import {
PaymentProvidersType,
PaymentsGleanProvider,
SubscriptionCancellationData,
+ type ExperimentationData,
} from './glean.types';
import { Inject, Injectable } from '@nestjs/common';
import { type PaymentsGleanServerEventsLogger } from './glean.provider';
@@ -34,6 +35,7 @@ export class PaymentsGleanManager {
commonMetricsData: CommonMetrics;
cartMetricsData: CartMetrics;
cmsMetricsData: CmsMetricsData;
+ experimentationData: ExperimentationData;
}) {
if (this.isEnabled) {
this.paymentsGleanServerEventsLogger.recordPaySetupView(
@@ -131,12 +133,14 @@ export class PaymentsGleanManager {
commonMetricsData?: CommonMetrics;
cartMetricsData?: CartMetrics;
cmsMetricsData?: CmsMetricsData;
+ experimentationData?: ExperimentationData;
subscriptionCancellationData?: SubscriptionCancellationData;
}) {
const emptyCommonMetricsData: CommonMetrics = {
ipAddress: '',
deviceType: '',
userAgent: '',
+ experimentationId: '',
params: {},
searchParams: {},
};
@@ -146,6 +150,7 @@ export class PaymentsGleanManager {
errorReasonId: null,
couponCode: '',
currency: '',
+ taxAddress: { countryCode: '', postalCode: '' },
};
const emptyCmsMetricsData: CmsMetricsData = {
priceId: '',
@@ -157,12 +162,17 @@ export class PaymentsGleanManager {
cancellationReason: CancellationReason.Involuntary,
providerEventId: '',
};
+ const emptyExperimentationData: ExperimentationData = {
+ nimbusUserId: '',
+ };
const commonMetricsData =
metrics.commonMetricsData || emptyCommonMetricsData;
const cartMetricsData = metrics.cartMetricsData || emptyCartMetricsData;
const cmsMetricsData = metrics.cmsMetricsData || emptyCmsMetricsData;
const subscriptionCancellationData =
metrics.subscriptionCancellationData || emptySubscriptionCancellationData;
+ const experimentationData =
+ metrics.experimentationData || emptyExperimentationData;
return {
user_agent: commonMetricsData.userAgent,
ip_address: commonMetricsData.ipAddress,
@@ -178,7 +188,7 @@ export class PaymentsGleanManager {
}),
...mapUtm(commonMetricsData.searchParams),
...mapSubscriptionCancellation(subscriptionCancellationData),
- nimbus_user_id: '',
+ nimbus_user_id: experimentationData.nimbusUserId,
};
}
diff --git a/libs/payments/metrics/src/lib/glean/glean.types.ts b/libs/payments/metrics/src/lib/glean/glean.types.ts
index e25c5431aa..55dd925e24 100644
--- a/libs/payments/metrics/src/lib/glean/glean.types.ts
+++ b/libs/payments/metrics/src/lib/glean/glean.types.ts
@@ -26,15 +26,25 @@ export type CommonMetrics = {
ipAddress: string;
deviceType: string;
userAgent: string;
+ experimentationId: string;
params: Record;
searchParams: Record;
};
export type CartMetrics = Pick<
ResultCart,
- 'uid' | 'errorReasonId' | 'couponCode' | 'currency' | 'stripeCustomerId'
+ | 'uid'
+ | 'errorReasonId'
+ | 'couponCode'
+ | 'currency'
+ | 'stripeCustomerId'
+ | 'taxAddress'
>;
+export type ExperimentationData = {
+ nimbusUserId: string;
+};
+
export type CmsMetricsData = {
productId: string;
priceId: string;
diff --git a/libs/payments/ui/src/lib/actions/getExperimentsAction.ts b/libs/payments/ui/src/lib/actions/getExperimentsAction.ts
new file mode 100644
index 0000000000..ad34c5a5f8
--- /dev/null
+++ b/libs/payments/ui/src/lib/actions/getExperimentsAction.ts
@@ -0,0 +1,31 @@
+/* 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/. */
+
+'use server';
+
+import { getApp } from '../nestapp/app';
+import { getIpAddress } from '../utils/getIpAddress';
+import { headers } from 'next/headers';
+
+export const getExperimentsAction = async (args: {
+ language: string;
+ fxaUid?: string;
+}) => {
+ const ip = getIpAddress();
+ const experimentationId = headers().get('x-experimentation-id') || '';
+
+ try {
+ const experiments = await getApp()
+ .getActionsService()
+ .getExperiments({
+ ...args,
+ ip,
+ experimentationId,
+ });
+
+ return experiments?.experiments;
+ } catch (error) {
+ return undefined;
+ }
+};
diff --git a/libs/payments/ui/src/lib/actions/index.ts b/libs/payments/ui/src/lib/actions/index.ts
index 6069ae8a24..84868abe2d 100644
--- a/libs/payments/ui/src/lib/actions/index.ts
+++ b/libs/payments/ui/src/lib/actions/index.ts
@@ -35,3 +35,4 @@ export { serverLogAction } from './serverLog';
export { getStripeClientSession } from './getStripeClientSession';
export { updateStripePaymentDetails } from './updateStripePaymentDetails';
export { setDefaultStripePaymentDetails } from './setDefaultStripePaymentDetails';
+export { getExperimentsAction } from './getExperimentsAction';
diff --git a/libs/payments/ui/src/lib/nestapp/app.module.ts b/libs/payments/ui/src/lib/nestapp/app.module.ts
index ef8f63e76e..8e39cc7094 100644
--- a/libs/payments/ui/src/lib/nestapp/app.module.ts
+++ b/libs/payments/ui/src/lib/nestapp/app.module.ts
@@ -69,6 +69,8 @@ import {
GoogleIapClient,
GoogleIapPurchaseManager,
} from '@fxa/payments/iap';
+import { NimbusClient } from '@fxa/shared/experiments';
+import { NimbusManager } from '@fxa/payments/experiments';
@Module({
imports: [
@@ -144,6 +146,8 @@ import {
SubscriptionEventsService,
StripeWebhookService,
SubscriptionManagementService,
+ NimbusClient,
+ NimbusManager,
{ provide: LOGGER_PROVIDER, useValue: logger },
],
})
diff --git a/libs/payments/ui/src/lib/nestapp/config.ts b/libs/payments/ui/src/lib/nestapp/config.ts
index 3686952ff9..f8616e04fd 100644
--- a/libs/payments/ui/src/lib/nestapp/config.ts
+++ b/libs/payments/ui/src/lib/nestapp/config.ts
@@ -22,6 +22,8 @@ import { AppleIapClientConfig, GoogleIapClientConfig } from '@fxa/payments/iap';
import { TracingConfig } from './tracing.config';
import { StripeEventConfig } from '@fxa/payments/webhooks';
import { FirestoreConfig } from 'libs/shared/db/firestore/src/lib/firestore.config';
+import { NimbusClientConfig } from 'libs/shared/experiments/src/lib/nimbus.config';
+import { NimbusManagerConfig } from '@fxa/payments/experiments';
export class RootConfig {
@Type(() => MySQLConfig)
@@ -117,4 +119,14 @@ export class RootConfig {
@ValidateNested()
@IsDefined()
location!: LocationConfig;
+
+ @Type(() => NimbusClientConfig)
+ @ValidateNested()
+ @IsDefined()
+ nimbusClient!: NimbusClientConfig;
+
+ @Type(() => NimbusManagerConfig)
+ @ValidateNested()
+ @IsDefined()
+ nimbusManager!: NimbusManagerConfig;
}
diff --git a/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts b/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts
index c7d7b21c56..7d6170cc9c 100644
--- a/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts
+++ b/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts
@@ -18,7 +18,6 @@ import { SubscriptionManagementService } from '@fxa/payments/management';
import {
CheckoutTokenManager,
PaypalBillingAgreementManager,
- PaypalCustomerManager,
} from '@fxa/payments/paypal';
import {
ProductConfigError,
@@ -109,6 +108,9 @@ import { GetPaypalBillingAgreementActiveIdResult } from './validators/GetPaypalB
import { CreatePaypalBillingAgreementIdArgs } from './validators/CreatePaypalBillingAgreementIdArgs';
import { DetermineCurrencyForCustomerActionArgs } from './validators/DetermineCurrencyForCustomerActionArgs';
import { DetermineCurrencyForCustomerActionResult } from './validators/DetermineCurrencyForCustomerActionResult';
+import { NimbusManager } from '@fxa/payments/experiments';
+import { GetExperimentsActionArgs } from './validators/GetExperimentsActionArgs';
+import { GetExperimentsActionResult } from './validators/GetExperimentsActionResult';
/**
* ANY AND ALL methods exposed via this service should be considered publicly accessible and callable with any arguments.
@@ -131,7 +133,7 @@ export class NextJSActionsService {
private profileClient: ProfileClient,
private subscriptionManagementService: SubscriptionManagementService,
private paypalBillingAgreementManager: PaypalBillingAgreementManager,
- private paypalCustomerManager: PaypalCustomerManager,
+ private nimbusManager: NimbusManager,
@Inject(StatsDService) public statsd: StatsD,
@Inject(Logger) private log: LoggerService
) {}
@@ -181,6 +183,36 @@ export class NextJSActionsService {
return this.cartService.getCart(args.cartId);
}
+ @SanitizeExceptions()
+ @NextIOValidator(GetExperimentsActionArgs, GetExperimentsActionResult)
+ @WithTypeCachableAsyncLocalStorage()
+ @CaptureTimingWithStatsD()
+ async getExperiments(args: {
+ language: string;
+ ip: string;
+ fxaUid?: string;
+ experimentationId?: string;
+ }) {
+ const nimbusUserId = this.nimbusManager.generateNimbusId(
+ args.fxaUid,
+ args.experimentationId
+ );
+ const location = this.geodbManager.getTaxAddress(args.ip);
+ const experiments = await this.nimbusManager.fetchExperiments({
+ nimbusUserId,
+ language: args.language,
+ region: location?.countryCode,
+ });
+
+ if (experiments) {
+ return {
+ experiments,
+ };
+ } else {
+ return undefined;
+ }
+ }
+
@SanitizeExceptions({
allowlist: [CartInvalidStateForActionError],
})
diff --git a/libs/payments/ui/src/lib/nestapp/validators/GetExperimentsActionArgs.ts b/libs/payments/ui/src/lib/nestapp/validators/GetExperimentsActionArgs.ts
new file mode 100644
index 0000000000..277d5190b4
--- /dev/null
+++ b/libs/payments/ui/src/lib/nestapp/validators/GetExperimentsActionArgs.ts
@@ -0,0 +1,21 @@
+/* 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 { IsOptional, IsString } from 'class-validator';
+
+export class GetExperimentsActionArgs {
+ @IsString()
+ language!: string;
+
+ @IsString()
+ ip!: string;
+
+ @IsString()
+ @IsOptional()
+ experimentationId?: string;
+
+ @IsString()
+ @IsOptional()
+ fxaUid?: string;
+}
diff --git a/libs/payments/ui/src/lib/nestapp/validators/GetExperimentsActionResult.ts b/libs/payments/ui/src/lib/nestapp/validators/GetExperimentsActionResult.ts
new file mode 100644
index 0000000000..67d6e0b861
--- /dev/null
+++ b/libs/payments/ui/src/lib/nestapp/validators/GetExperimentsActionResult.ts
@@ -0,0 +1,19 @@
+/* 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 type { Features } from '@fxa/payments/experiments';
+import type { NimbusEnrollment } from '@fxa/shared/experiments';
+import { IsArray, IsDefined } from 'class-validator';
+
+class Experiments {
+ @IsDefined()
+ Features!: Features;
+
+ @IsArray()
+ Enrollments!: Array;
+}
+
+export class GetExperimentsActionResult {
+ experiments?: Experiments;
+}
diff --git a/libs/payments/ui/src/lib/nestapp/validators/RecordEmitterEvent.ts b/libs/payments/ui/src/lib/nestapp/validators/RecordEmitterEvent.ts
index 7b85a677e9..ecc91ba1c1 100644
--- a/libs/payments/ui/src/lib/nestapp/validators/RecordEmitterEvent.ts
+++ b/libs/payments/ui/src/lib/nestapp/validators/RecordEmitterEvent.ts
@@ -30,6 +30,9 @@ class RequestArgs {
@IsString()
userAgent!: string;
+ @IsString()
+ experimentationId!: string;
+
@IsObject()
params!: Record;
diff --git a/libs/payments/ui/src/lib/utils/getAdditionalRequestArgs.ts b/libs/payments/ui/src/lib/utils/getAdditionalRequestArgs.ts
index af075fc1e6..9ed3ab611c 100644
--- a/libs/payments/ui/src/lib/utils/getAdditionalRequestArgs.ts
+++ b/libs/payments/ui/src/lib/utils/getAdditionalRequestArgs.ts
@@ -9,9 +9,12 @@ import { getIpAddress } from './getIpAddress';
export function getAdditionalRequestArgs() {
const userAgentString = headers().get('user-agent') || '';
const userAgent = userAgentFromString(userAgentString);
+ const experimentationId = headers().get('x-experimentation-id') || '';
+
return {
ipAddress: getIpAddress(),
userAgent: userAgentString,
deviceType: userAgent.device.type || 'desktop',
+ experimentationId,
};
}
diff --git a/libs/payments/webhooks/src/lib/stripe-event.manager.spec.ts b/libs/payments/webhooks/src/lib/stripe-event.manager.spec.ts
index 6413088a94..30d3e9a56b 100644
--- a/libs/payments/webhooks/src/lib/stripe-event.manager.spec.ts
+++ b/libs/payments/webhooks/src/lib/stripe-event.manager.spec.ts
@@ -56,6 +56,14 @@ import {
StripeEventStoreEntryAlreadyExistsError,
StripeEventStoreEntryNotFoundError,
} from './stripe-event-store.error';
+import {
+ MockNimbusClientConfigProvider,
+ NimbusClient,
+} from '@fxa/shared/experiments';
+import {
+ MockNimbusManagerConfigProvider,
+ NimbusManager,
+} from '@fxa/payments/experiments';
jest.mock('./stripe-event-store.repository');
const mockedGetStripeEventStoreEntry = jest.mocked(getStripeEventStoreEntry);
@@ -115,6 +123,10 @@ describe('StripeEventManager', () => {
MockStatsDProvider,
PriceManager,
MockAccountDatabaseNestFactory,
+ NimbusClient,
+ MockNimbusClientConfigProvider,
+ NimbusManager,
+ MockNimbusManagerConfigProvider,
],
}).compile();
diff --git a/libs/payments/webhooks/src/lib/stripe-webhooks.controller.spec.ts b/libs/payments/webhooks/src/lib/stripe-webhooks.controller.spec.ts
index 7ee64962c1..9282498500 100644
--- a/libs/payments/webhooks/src/lib/stripe-webhooks.controller.spec.ts
+++ b/libs/payments/webhooks/src/lib/stripe-webhooks.controller.spec.ts
@@ -41,6 +41,14 @@ import { MockFirestoreProvider } from '@fxa/shared/db/firestore';
import { MockStatsDProvider } from '@fxa/shared/metrics/statsd';
import { MockAccountDatabaseNestFactory } from '@fxa/shared/db/mysql/account';
import { Logger } from '@nestjs/common';
+import {
+ MockNimbusClientConfigProvider,
+ NimbusClient,
+} from '@fxa/shared/experiments';
+import {
+ MockNimbusManagerConfigProvider,
+ NimbusManager,
+} from '@fxa/payments/experiments';
describe('StripeWebhooksController', () => {
let stripeWebhooksController: StripeWebhooksController;
@@ -96,6 +104,10 @@ describe('StripeWebhooksController', () => {
StripeWebhookService,
StripeEventManager,
SubscriptionEventsService,
+ NimbusClient,
+ MockNimbusClientConfigProvider,
+ NimbusManager,
+ MockNimbusManagerConfigProvider,
],
}).compile();
diff --git a/libs/payments/webhooks/src/lib/stripe-webhooks.service.spec.ts b/libs/payments/webhooks/src/lib/stripe-webhooks.service.spec.ts
index 6d621619f4..a98258343b 100644
--- a/libs/payments/webhooks/src/lib/stripe-webhooks.service.spec.ts
+++ b/libs/payments/webhooks/src/lib/stripe-webhooks.service.spec.ts
@@ -43,6 +43,14 @@ import { MockAccountDatabaseNestFactory } from '@fxa/shared/db/mysql/account';
import * as Sentry from '@sentry/node';
import { Logger } from '@nestjs/common';
import { MockStripeEventConfigProvider } from './stripe-event.config';
+import {
+ MockNimbusClientConfigProvider,
+ NimbusClient,
+} from '@fxa/shared/experiments';
+import {
+ MockNimbusManagerConfigProvider,
+ NimbusManager,
+} from '@fxa/payments/experiments';
jest.mock('@sentry/node', () => ({
captureException: jest.fn(),
@@ -99,6 +107,10 @@ describe('StripeWebhookService', () => {
MockStatsDProvider,
PriceManager,
MockAccountDatabaseNestFactory,
+ NimbusClient,
+ MockNimbusClientConfigProvider,
+ NimbusManager,
+ MockNimbusManagerConfigProvider,
],
}).compile();
diff --git a/libs/payments/webhooks/src/lib/subscription-handler.service.spec.ts b/libs/payments/webhooks/src/lib/subscription-handler.service.spec.ts
index 41b0c06ddd..3172aedc51 100644
--- a/libs/payments/webhooks/src/lib/subscription-handler.service.spec.ts
+++ b/libs/payments/webhooks/src/lib/subscription-handler.service.spec.ts
@@ -55,6 +55,14 @@ import {
} from './util/determineCancellation';
import { Logger } from '@nestjs/common';
import { MockStripeEventConfigProvider } from './stripe-event.config';
+import {
+ MockNimbusClientConfigProvider,
+ NimbusClient,
+} from '@fxa/shared/experiments';
+import {
+ MockNimbusManagerConfigProvider,
+ NimbusManager,
+} from '@fxa/payments/experiments';
jest.mock('@fxa/payments/customer');
jest.mock('./util/determineCancellation');
@@ -120,6 +128,10 @@ describe('SubscriptionEventsService', () => {
MockStatsDProvider,
PriceManager,
MockAccountDatabaseNestFactory,
+ NimbusClient,
+ MockNimbusClientConfigProvider,
+ NimbusManager,
+ MockNimbusManagerConfigProvider,
],
}).compile();
diff --git a/libs/shared/experiments/.eslintrc.json b/libs/shared/experiments/.eslintrc.json
new file mode 100644
index 0000000000..3456be9b90
--- /dev/null
+++ b/libs/shared/experiments/.eslintrc.json
@@ -0,0 +1,18 @@
+{
+ "extends": ["../../../.eslintrc.json"],
+ "ignorePatterns": ["!**/*"],
+ "overrides": [
+ {
+ "files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
+ "rules": {}
+ },
+ {
+ "files": ["*.ts", "*.tsx"],
+ "rules": {}
+ },
+ {
+ "files": ["*.js", "*.jsx"],
+ "rules": {}
+ }
+ ]
+}
diff --git a/libs/shared/experiments/.swcrc b/libs/shared/experiments/.swcrc
new file mode 100644
index 0000000000..42938ddcef
--- /dev/null
+++ b/libs/shared/experiments/.swcrc
@@ -0,0 +1,15 @@
+{
+ "jsc": {
+ "target": "es2017",
+ "parser": {
+ "syntax": "typescript",
+ "decorators": true,
+ "dynamicImport": true
+ },
+ "transform": {
+ "decoratorMetadata": true,
+ "legacyDecorator": true
+ }
+ }
+}
+
diff --git a/libs/shared/experiments/README.md b/libs/shared/experiments/README.md
new file mode 100644
index 0000000000..6df81062ee
--- /dev/null
+++ b/libs/shared/experiments/README.md
@@ -0,0 +1,12 @@
+# shared-experiments
+
+This library was generated with [Nx](https://nx.dev).
+
+## Building
+
+Run `nx build shared-experiments` to build the library.
+
+## Running unit tests
+
+Run `nx run shared-experiments:test-unit` to execute the unit tests via [Jest](https://jestjs.io).
+
diff --git a/libs/shared/experiments/jest.config.ts b/libs/shared/experiments/jest.config.ts
new file mode 100644
index 0000000000..6af61d967a
--- /dev/null
+++ b/libs/shared/experiments/jest.config.ts
@@ -0,0 +1,43 @@
+/* eslint-disable */
+import { readFileSync } from 'fs';
+import { Config } from 'jest';
+
+// Reading the SWC compilation config and remove the "exclude"
+// for the test files to be compiled by SWC
+const { exclude: _, ...swcJestConfig } = JSON.parse(
+ readFileSync(`${__dirname}/.swcrc`, 'utf-8')
+);
+
+// disable .swcrc look-up by SWC core because we're passing in swcJestConfig ourselves.
+// If we do not disable this, SWC Core will read .swcrc and won't transform our test files due to "exclude"
+if (swcJestConfig.swcrc === undefined) {
+ swcJestConfig.swcrc = false;
+}
+
+// Uncomment if using global setup/teardown files being transformed via swc
+// https://nx.dev/packages/jest/documents/overview#global-setup/teardown-with-nx-libraries
+// jest needs EsModule Interop to find the default exported setup/teardown functions
+// swcJestConfig.module.noInterop = false;
+
+const config: Config = {
+ displayName: 'shared-experiments',
+ preset: '../../../jest.preset.js',
+ testEnvironment: 'node',
+ transform: {
+ '^.+\\.[tj]s$': ['@swc/jest', swcJestConfig],
+ },
+ moduleFileExtensions: ['ts', 'js', 'html'],
+ coverageDirectory: '../../../coverage/libs/shared/experiments',
+ reporters: [
+ 'default',
+ [
+ 'jest-junit',
+ {
+ outputDirectory: 'artifacts/tests/shared-experiments',
+ outputName: 'shared-experiments-jest-unit-results.xml',
+ },
+ ],
+ ],
+};
+
+export default config;
diff --git a/libs/shared/experiments/package.json b/libs/shared/experiments/package.json
new file mode 100644
index 0000000000..320007c223
--- /dev/null
+++ b/libs/shared/experiments/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@fxa/shared/experiments",
+ "version": "0.0.1",
+ "private": true,
+ "type": "commonjs",
+ "main": "./index.cjs",
+ "types": "./index.d.ts",
+ "dependencies": {}
+}
diff --git a/libs/shared/experiments/project.json b/libs/shared/experiments/project.json
new file mode 100644
index 0000000000..9c011479f3
--- /dev/null
+++ b/libs/shared/experiments/project.json
@@ -0,0 +1,29 @@
+{
+ "name": "shared-experiments",
+ "$schema": "../../../node_modules/nx/schemas/project-schema.json",
+ "sourceRoot": "libs/shared/experiments/src",
+ "projectType": "library",
+ "tags": [],
+ "targets": {
+ "build": {
+ "executor": "@nx/esbuild:esbuild",
+ "outputs": ["{options.outputPath}"],
+ "options": {
+ "outputPath": "dist/libs/shared/experiments",
+ "main": "libs/shared/experiments/src/index.ts",
+ "tsConfig": "libs/shared/experiments/tsconfig.lib.json",
+ "assets": ["libs/shared/experiments/*.md"],
+ "declaration": true,
+ "generatePackageJson": true
+ }
+ },
+ "test-unit": {
+ "executor": "@nx/jest:jest",
+ "outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
+ "options": {
+ "jestConfig": "libs/shared/experiments/jest.config.ts",
+ "testPathPattern": ["^(?!.*\\.in\\.spec\\.ts$).*$"]
+ }
+ }
+ }
+}
diff --git a/libs/shared/experiments/src/index.ts b/libs/shared/experiments/src/index.ts
new file mode 100644
index 0000000000..159ff2f381
--- /dev/null
+++ b/libs/shared/experiments/src/index.ts
@@ -0,0 +1,6 @@
+export * from './lib/nimbus.config';
+export * from './lib/nimbus.client';
+export * from './lib/nimbus.errors';
+export * from './lib/nimbus.types';
+export * from './lib/nimbus.factories';
+export * from './lib/utils/generateNimbusId';
diff --git a/libs/shared/experiments/src/lib/nimbus.client.spec.ts b/libs/shared/experiments/src/lib/nimbus.client.spec.ts
new file mode 100644
index 0000000000..c918ac6026
--- /dev/null
+++ b/libs/shared/experiments/src/lib/nimbus.client.spec.ts
@@ -0,0 +1,124 @@
+/* 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 { Test } from '@nestjs/testing';
+import { NimbusClient } from './nimbus.client';
+import { MockNimbusClientConfig, NimbusClientConfig } from './nimbus.config';
+import { MockStatsDProvider } from '@fxa/shared/metrics/statsd';
+import {
+ NimbusContextFactory,
+ NimbusEnrollmentFactory,
+ NimbusResultFactory,
+} from './nimbus.factories';
+import {
+ NimbusClientFetchExperimentsHandledError,
+ NimbusClientFetchExperimentsUnexpectedError,
+} from './nimbus.errors';
+
+describe('NimbusClient', () => {
+ let nimbusClient: NimbusClient;
+
+ const mockNimbusClientConfig = MockNimbusClientConfig;
+ const mockContext = NimbusContextFactory();
+ const mockEnrollment = NimbusEnrollmentFactory();
+ const mockResult = NimbusResultFactory({
+ Enrollments: [mockEnrollment],
+ });
+ const nimbusUserId = mockEnrollment.nimbus_user_id;
+ const mockFetchExperimentsParams = {
+ clientId: nimbusUserId,
+ context: mockContext,
+ };
+
+ beforeEach(async () => {
+ global.fetch = jest.fn();
+
+ const module = await Test.createTestingModule({
+ providers: [
+ NimbusClient,
+ {
+ provide: NimbusClientConfig,
+ useValue: mockNimbusClientConfig,
+ },
+ MockStatsDProvider,
+ ],
+ }).compile();
+
+ nimbusClient = module.get(NimbusClient);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('fetchExperiments', () => {
+ beforeEach(() => {
+ (fetch as jest.Mock).mockResolvedValue({
+ json: () => Promise.resolve(mockResult),
+ ok: true,
+ status: 200,
+ });
+ });
+
+ it('successfully returns experiments from Nimbus', async () => {
+ const result = await nimbusClient.fetchExperiments(
+ mockFetchExperimentsParams
+ );
+ expect(result).toEqual(mockResult);
+ expect(fetch).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ body: JSON.stringify({
+ client_id: nimbusUserId,
+ context: mockContext,
+ }),
+ })
+ );
+ });
+
+ it('successfully calls with preview enabled', async () => {
+ mockNimbusClientConfig.previewEnabled = true;
+ const expectedUrl =
+ mockNimbusClientConfig.apiUrl + '?nimbus_preview=true';
+ await nimbusClient.fetchExperiments(mockFetchExperimentsParams);
+ expect(fetch).toHaveBeenCalledWith(expectedUrl, expect.anything());
+ });
+
+ it('successfully calls with preview disabled', async () => {
+ mockNimbusClientConfig.previewEnabled = false;
+ const expectedUrl =
+ mockNimbusClientConfig.apiUrl + '?nimbus_preview=false';
+ await nimbusClient.fetchExperiments(mockFetchExperimentsParams);
+ expect(fetch).toHaveBeenCalledWith(expectedUrl, expect.anything());
+ });
+
+ it('throws a handled error', async () => {
+ const errorData = { message: 'Handled error' };
+ const expectedError = new NimbusClientFetchExperimentsHandledError(
+ errorData,
+ mockFetchExperimentsParams
+ );
+ (fetch as jest.Mock).mockResolvedValue({
+ json: () => Promise.resolve(errorData),
+ ok: false,
+ status: 400,
+ });
+ await expect(
+ nimbusClient.fetchExperiments(mockFetchExperimentsParams)
+ ).rejects.toThrow(expectedError);
+ });
+
+ it('throws unhandled error', async () => {
+ const unexpectedError = new Error('unexpected error');
+ const expectedError = new NimbusClientFetchExperimentsUnexpectedError(
+ unexpectedError,
+ mockFetchExperimentsParams
+ );
+ (fetch as jest.Mock).mockRejectedValue(unexpectedError);
+ await expect(
+ nimbusClient.fetchExperiments(mockFetchExperimentsParams)
+ ).rejects.toThrow(expectedError);
+ });
+ });
+});
diff --git a/libs/shared/experiments/src/lib/nimbus.client.ts b/libs/shared/experiments/src/lib/nimbus.client.ts
new file mode 100644
index 0000000000..33f7f88026
--- /dev/null
+++ b/libs/shared/experiments/src/lib/nimbus.client.ts
@@ -0,0 +1,80 @@
+/* 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 { Inject, Injectable } from '@nestjs/common';
+import { NimbusClientConfig } from './nimbus.config';
+import type { NimbusContext } from './nimbus.types';
+import {
+ NimbusClientFetchExperimentsUnexpectedError,
+ NimbusClientFetchExperimentsHandledError,
+} from './nimbus.errors';
+import {
+ CaptureTimingWithStatsD,
+ StatsD,
+ StatsDService,
+} from '@fxa/shared/metrics/statsd';
+
+@Injectable()
+export class NimbusClient {
+ constructor(
+ private config: NimbusClientConfig,
+ @Inject(StatsDService) public statsd: StatsD
+ ) {}
+
+ @CaptureTimingWithStatsD()
+ async fetchExperiments(params: {
+ clientId: string;
+ context: NimbusContext;
+ }) {
+ const { clientId, context } = params;
+
+ const body = JSON.stringify({
+ client_id: clientId,
+ context,
+ });
+
+ const queryParams = new URLSearchParams({
+ nimbus_preview: this.config.previewEnabled === true ? 'true' : 'false',
+ });
+
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => {
+ controller.abort();
+ }, this.config.timeoutMs);
+
+ try {
+ const resp = await fetch(
+ `${this.config.apiUrl}?${queryParams.toString()}`,
+ {
+ method: 'POST',
+ body,
+ // A request to cirrus should not be more than 50ms,
+ // but we give it a large enough padding.
+ signal: controller.signal,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ }
+ );
+
+ if (!resp.ok) {
+ const errorResponse = await resp.json();
+ throw new NimbusClientFetchExperimentsHandledError(
+ errorResponse,
+ params
+ );
+ }
+
+ return (await resp.json()) as ResultT;
+ } catch (err) {
+ if (err instanceof NimbusClientFetchExperimentsHandledError) {
+ throw err;
+ } else {
+ throw new NimbusClientFetchExperimentsUnexpectedError(err, params);
+ }
+ } finally {
+ clearTimeout(timeoutId);
+ }
+ }
+}
diff --git a/libs/shared/experiments/src/lib/nimbus.config.ts b/libs/shared/experiments/src/lib/nimbus.config.ts
new file mode 100644
index 0000000000..ad852d6403
--- /dev/null
+++ b/libs/shared/experiments/src/lib/nimbus.config.ts
@@ -0,0 +1,29 @@
+/* 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 { faker } from '@faker-js/faker';
+import { Provider } from '@nestjs/common';
+import { IsBoolean, IsNumber, IsUrl } from 'class-validator';
+
+export class NimbusClientConfig {
+ @IsUrl({ require_tld: false })
+ public readonly apiUrl!: string;
+
+ @IsBoolean()
+ public readonly previewEnabled!: boolean;
+
+ @IsNumber()
+ public readonly timeoutMs!: number;
+}
+
+export const MockNimbusClientConfig = {
+ apiUrl: faker.internet.url(),
+ previewEnabled: faker.datatype.boolean(),
+ timeoutMs: faker.number.int({ min: 100, max: 2000 }),
+} satisfies NimbusClientConfig;
+
+export const MockNimbusClientConfigProvider = {
+ provide: NimbusClientConfig,
+ useValue: MockNimbusClientConfig,
+} satisfies Provider;
diff --git a/libs/shared/experiments/src/lib/nimbus.errors.ts b/libs/shared/experiments/src/lib/nimbus.errors.ts
new file mode 100644
index 0000000000..5814718eaf
--- /dev/null
+++ b/libs/shared/experiments/src/lib/nimbus.errors.ts
@@ -0,0 +1,46 @@
+/* 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 { BaseError } from '@fxa/shared/error';
+import type { NimbusContext } from './nimbus.types';
+
+/**
+ * Nimbus is not intended for direct use, except for type-checking errors.
+ * When throwing a new AppleIapError, create a unique extension of the class.
+ */
+export class NimbusError extends BaseError {
+ constructor(message: string, info: Record, cause?: Error) {
+ super(message, { info, cause });
+ this.name = 'NimbusError';
+ }
+}
+
+export class NimbusClientFetchExperimentsHandledError extends NimbusError {
+ constructor(
+ errorResponse: any,
+ params: {
+ clientId: string;
+ context: NimbusContext;
+ }
+ ) {
+ super('Error ocurred while fetching experiments', {
+ errorResponse,
+ params,
+ });
+ this.name = 'NimbusClientFetchExperimentsError';
+ }
+}
+
+export class NimbusClientFetchExperimentsUnexpectedError extends NimbusError {
+ constructor(
+ cause: Error,
+ params: {
+ clientId: string;
+ context: NimbusContext;
+ }
+ ) {
+ super('Error ocurred while fetching experiments', { params }, cause);
+ this.name = 'NimbusClientFetchExperimentsUnexpectedError';
+ }
+}
diff --git a/libs/shared/experiments/src/lib/nimbus.factories.ts b/libs/shared/experiments/src/lib/nimbus.factories.ts
new file mode 100644
index 0000000000..317084ba55
--- /dev/null
+++ b/libs/shared/experiments/src/lib/nimbus.factories.ts
@@ -0,0 +1,38 @@
+/* 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 { faker } from '@faker-js/faker';
+import type {
+ NimbusContext,
+ NimbusEnrollment,
+ NimbusResult,
+} from './nimbus.types';
+
+export const NimbusContextFactory = (
+ override?: Partial
+): NimbusContext => ({
+ language: faker.location.language().alpha2,
+ region: faker.location.countryCode('alpha-2'),
+ ...override,
+});
+
+export const NimbusEnrollmentFactory = (
+ override?: Partial
+): NimbusEnrollment => ({
+ nimbus_user_id: faker.string.uuid(),
+ app_id: faker.string.uuid(),
+ experiment: faker.lorem.word(),
+ branch: faker.helpers.arrayElement(['develop', 'stage', 'prod']),
+ experiment_type: faker.helpers.arrayElement(['feature', 'abtest']),
+ is_preview: faker.datatype.boolean().toString(),
+ ...override,
+});
+
+export const NimbusResultFactory = (
+ override?: Partial
+): NimbusResult => ({
+ Features: {},
+ Enrollments: [],
+ ...override,
+});
diff --git a/libs/shared/experiments/src/lib/nimbus.types.ts b/libs/shared/experiments/src/lib/nimbus.types.ts
new file mode 100644
index 0000000000..39e57ccd0c
--- /dev/null
+++ b/libs/shared/experiments/src/lib/nimbus.types.ts
@@ -0,0 +1,32 @@
+/* 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/. */
+
+/**
+ * A collection of attributes about the client that will be used for
+ * targeting an experiment.
+ */
+export type NimbusContext = {
+ language: string | null;
+ region: string | null;
+};
+
+/**
+ * The nimbus experiments and enrollment information needed for applying a feature experiment.
+ */
+export interface NimbusEnrollment {
+ nimbus_user_id: string;
+ app_id: string;
+ experiment: string;
+ branch: string;
+ experiment_type: string;
+ is_preview: string;
+}
+
+/**
+ * The nimbus experiments and enrollment information needed for applying a feature experiment.
+ */
+export interface NimbusResult {
+ Features: Record;
+ Enrollments: Array;
+}
diff --git a/libs/shared/experiments/src/lib/utils/generateNimbusId.spec.ts b/libs/shared/experiments/src/lib/utils/generateNimbusId.spec.ts
new file mode 100644
index 0000000000..3d4b1a065b
--- /dev/null
+++ b/libs/shared/experiments/src/lib/utils/generateNimbusId.spec.ts
@@ -0,0 +1,21 @@
+/* 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 { generateNimbusId } from './generateNimbusId';
+
+describe('generateNimbusId', () => {
+ const namespace = '2b94b5a8-407f-5ff4-8d6a-e53ada958c9d';
+
+ it('returns a uuid v5', () => {
+ const id = 'test-id';
+ const expected = 'a9e87f95-efa5-575e-bac2-e297abb1e597';
+
+ expect(generateNimbusId(namespace, id)).toEqual(expected);
+ });
+
+ it('returns a guest id with random uuid', () => {
+ const result = generateNimbusId(namespace);
+ expect(result.startsWith('guest-')).toBeTruthy();
+ });
+});
diff --git a/libs/shared/experiments/src/lib/utils/generateNimbusId.ts b/libs/shared/experiments/src/lib/utils/generateNimbusId.ts
new file mode 100644
index 0000000000..62496bac8a
--- /dev/null
+++ b/libs/shared/experiments/src/lib/utils/generateNimbusId.ts
@@ -0,0 +1,13 @@
+/* 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 { v5 as uuidv5 } from 'uuid';
+
+export function generateNimbusId(namespace: string, id?: string) {
+ if (id) {
+ return uuidv5(id, namespace);
+ } else {
+ return `guest-${crypto.randomUUID()}`;
+ }
+}
diff --git a/libs/shared/experiments/tsconfig.json b/libs/shared/experiments/tsconfig.json
new file mode 100644
index 0000000000..86622aca55
--- /dev/null
+++ b/libs/shared/experiments/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "module": "commonjs",
+ "forceConsistentCasingInFileNames": true,
+ "strict": true,
+ "importHelpers": true,
+ "noImplicitOverride": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "noPropertyAccessFromIndexSignature": true
+ },
+ "files": [],
+ "include": [],
+ "references": [
+ {
+ "path": "./tsconfig.lib.json"
+ },
+ {
+ "path": "./tsconfig.spec.json"
+ }
+ ]
+}
diff --git a/libs/shared/experiments/tsconfig.lib.json b/libs/shared/experiments/tsconfig.lib.json
new file mode 100644
index 0000000000..4befa7f099
--- /dev/null
+++ b/libs/shared/experiments/tsconfig.lib.json
@@ -0,0 +1,10 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../../dist/out-tsc",
+ "declaration": true,
+ "types": ["node"]
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
+}
diff --git a/libs/shared/experiments/tsconfig.spec.json b/libs/shared/experiments/tsconfig.spec.json
new file mode 100644
index 0000000000..ab55b7c7ac
--- /dev/null
+++ b/libs/shared/experiments/tsconfig.spec.json
@@ -0,0 +1,15 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../../dist/out-tsc",
+ "module": "commonjs",
+ "moduleResolution": "node10",
+ "types": ["jest", "node"]
+ },
+ "include": [
+ "jest.config.ts",
+ "src/**/*.test.ts",
+ "src/**/*.spec.ts",
+ "src/**/*.d.ts"
+ ]
+}
diff --git a/tsconfig.base.json b/tsconfig.base.json
index e823613d23..07bcb9bcbf 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -41,6 +41,7 @@
"@fxa/payments/customer": ["libs/payments/customer/src/index.ts"],
"@fxa/payments/eligibility": ["libs/payments/eligibility/src/index.ts"],
"@fxa/payments/events": ["libs/payments/events/src/index.ts"],
+ "@fxa/payments/experiments": ["libs/payments/experiments/src/index.ts"],
"@fxa/payments/iap": ["libs/payments/iap/src/index.ts"],
"@fxa/payments/legacy": ["libs/payments/legacy/src/index.ts"],
"@fxa/payments/management": ["libs/payments/management/src/index.ts"],
@@ -75,6 +76,7 @@
],
"@fxa/shared/error": ["libs/shared/error/src/index.ts"],
"@fxa/shared/error/error": ["libs/shared/error/src/lib/error.ts"],
+ "@fxa/shared/experiments": ["libs/shared/experiments/src/index.ts"],
"@fxa/shared/geodb": ["libs/shared/geodb/src/index.ts"],
"@fxa/shared/glean": ["libs/shared/metrics/glean/src/index.ts"],
"@fxa/shared/guards": ["libs/shared/guards/src/index.ts"],