From c7e61e80ca9f78645c4a3db7baee7c925180fbcc Mon Sep 17 00:00:00 2001 From: Davey Alvarez Date: Fri, 13 Mar 2026 13:02:49 -0700 Subject: [PATCH] feat(payments-next): Add eligibility check for free trials Because: * Free trial eligibility is a complex check that requires data from several locations This commit: * Adds in the free trial repository and manager to handle requests for free trial records * Adds an eligibility checking method to the checkout service * Adds a free trial firestore collection * Adds in configs and tests to support the changes * Updates a test that was outdated and failing Closes #PAY-3554 --- apps/payments/next/.env | 3 + libs/payments/cart/src/index.ts | 3 + .../cart/src/lib/cart.service.spec.ts | 4 + .../cart/src/lib/checkout.service.spec.ts | 220 +++++++++++++++++- .../payments/cart/src/lib/checkout.service.ts | 64 ++++- .../cart/src/lib/free-trial.config.ts | 21 ++ .../cart/src/lib/free-trial.manager.spec.ts | 107 +++++++++ .../cart/src/lib/free-trial.manager.ts | 48 ++++ .../src/lib/free-trial.repository.spec.ts | 103 ++++++++ .../cart/src/lib/free-trial.repository.ts | 41 ++++ .../payments/cart/src/lib/free-trial.types.ts | 11 + .../payments/ui/src/lib/nestapp/app.module.ts | 2 + libs/payments/ui/src/lib/nestapp/config.ts | 7 +- 13 files changed, 625 insertions(+), 9 deletions(-) create mode 100644 libs/payments/cart/src/lib/free-trial.config.ts create mode 100644 libs/payments/cart/src/lib/free-trial.manager.spec.ts create mode 100644 libs/payments/cart/src/lib/free-trial.manager.ts create mode 100644 libs/payments/cart/src/lib/free-trial.repository.spec.ts create mode 100644 libs/payments/cart/src/lib/free-trial.repository.ts create mode 100644 libs/payments/cart/src/lib/free-trial.types.ts diff --git a/apps/payments/next/.env b/apps/payments/next/.env index 872de851e37..d291182acc5 100644 --- a/apps/payments/next/.env +++ b/apps/payments/next/.env @@ -96,6 +96,9 @@ CURRENCY_CONFIG__CURRENCIES_TO_COUNTRIES={"USD":["AS","CA","GB","GU","MP","MY"," CHURN_INTERVENTION_CONFIG__COLLECTION_NAME=churnInterventions CHURN_INTERVENTION_CONFIG__ENABLED= +# Free Trial Config +FREE_TRIAL_CONFIG__FIRESTORE_COLLECTION_NAME=freeTrials + # StatsD Config STATS_D_CONFIG__SAMPLE_RATE= STATS_D_CONFIG__MAX_BUFFER_SIZE= diff --git a/libs/payments/cart/src/index.ts b/libs/payments/cart/src/index.ts index 6288836eb2d..5bf584ea3d8 100644 --- a/libs/payments/cart/src/index.ts +++ b/libs/payments/cart/src/index.ts @@ -17,3 +17,6 @@ export * from './lib/churn-intervention.config'; export * from './lib/churn-intervention.manager'; export * from './lib/churn-intervention.error'; export * from './lib/churn-intervention.factories'; +export * from './lib/free-trial.config'; +export * from './lib/free-trial.manager'; +export * from './lib/free-trial.types'; diff --git a/libs/payments/cart/src/lib/cart.service.spec.ts b/libs/payments/cart/src/lib/cart.service.spec.ts index ec8862c4049..ed3d4c6a068 100644 --- a/libs/payments/cart/src/lib/cart.service.spec.ts +++ b/libs/payments/cart/src/lib/cart.service.spec.ts @@ -94,6 +94,8 @@ import { import { CartManager } from './cart.manager'; import { CartService } from './cart.service'; import { CheckoutService } from './checkout.service'; +import { FreeTrialManager } from './free-trial.manager'; +import { MockFreeTrialConfigProvider } from './free-trial.config'; import { CartError, CartCouldNotRetrievePriceForCurrencyWhenAttemptingToGetCartCartError, @@ -199,6 +201,8 @@ describe('CartService', () => { CustomerSessionManager, EligibilityManager, EligibilityService, + FreeTrialManager, + MockFreeTrialConfigProvider, AppleIapPurchaseManager, AppleIapClient, MockAppleIapClientConfigProvider, diff --git a/libs/payments/cart/src/lib/checkout.service.spec.ts b/libs/payments/cart/src/lib/checkout.service.spec.ts index 69cb42650f7..66726f8dd67 100644 --- a/libs/payments/cart/src/lib/checkout.service.spec.ts +++ b/libs/payments/cart/src/lib/checkout.service.spec.ts @@ -45,6 +45,7 @@ import { SetupIntentManager, PromotionCodeNotFoundError, STRIPE_SUBSCRIPTION_METADATA, + SubplatInterval, SubscriptionManager, TaxAddressFactory, SubPlatPaymentMethodType, @@ -137,6 +138,9 @@ import { MockNimbusClientConfigProvider, NimbusClient, } from '@fxa/shared/experiments'; +import { FreeTrialManager } from './free-trial.manager'; +import { MockFreeTrialConfigProvider } from './free-trial.config'; +import type { FreeTrial } from '@fxa/shared/cms'; describe('CheckoutService', () => { let accountCustomerManager: AccountCustomerManager; @@ -160,6 +164,8 @@ describe('CheckoutService', () => { let subscriptionManager: SubscriptionManager; let paymentMethodManager: PaymentMethodManager; let gleanService: PaymentsGleanService; + let freeTrialManager: FreeTrialManager; + let nimbusManager: NimbusManager; const mockLogger = { error: jest.fn(), @@ -180,6 +186,8 @@ describe('CheckoutService', () => { CurrencyManager, EligibilityManager, EligibilityService, + FreeTrialManager, + MockFreeTrialConfigProvider, AppleIapPurchaseManager, AppleIapClient, MockAppleIapClientConfigProvider, @@ -259,6 +267,8 @@ describe('CheckoutService', () => { subscriptionManager = moduleRef.get(SubscriptionManager); paymentMethodManager = moduleRef.get(PaymentMethodManager); gleanService = moduleRef.get(PaymentsGleanService); + freeTrialManager = moduleRef.get(FreeTrialManager); + nimbusManager = moduleRef.get(NimbusManager); }); describe('prePaySteps', () => { @@ -805,7 +815,7 @@ describe('CheckoutService', () => { }); }); - it('handles free payments for customers with default payment method', async () => { + it('handles zero-amount invoices by creating a setup intent', async () => { const freeInvoice = StripeResponseFactory( StripeInvoiceFactory({ payment_intent: mockPaymentIntent.id, @@ -813,14 +823,17 @@ describe('CheckoutService', () => { status: 'paid', }) ); - const existingPayentMethod = StripeResponseFactory( - StripePaymentMethodFactory() + const mockSetupIntent = StripeResponseFactory( + StripeSetupIntentFactory({ + status: 'succeeded', + payment_method: StripePaymentMethodFactory().id, + }) ); jest.spyOn(invoiceManager, 'retrieve').mockResolvedValue(freeInvoice); jest - .spyOn(customerManager, 'getDefaultPaymentMethod') - .mockResolvedValue(existingPayentMethod); - expect( + .spyOn(setupIntentManager, 'createAndConfirm') + .mockResolvedValue(mockSetupIntent); + await expect( checkoutService.payWithStripe( mockCart, mockConfirmationToken.id, @@ -828,7 +841,7 @@ describe('CheckoutService', () => { mockRequestArgs, mockCart.uid ) - ).resolves; + ).resolves.not.toThrow(); }); describe('upgrade', () => { @@ -1640,4 +1653,197 @@ describe('CheckoutService', () => { ).rejects.toThrow(/DetermineCheckoutAmountSubscriptionRequiredError/); }); }); + + describe('getFreeTrialEligibility', () => { + const mockUid = faker.string.uuid(); + const mockOfferingConfigId = faker.string.alphanumeric(10); + const mockCountryCode = 'US'; + const mockInterval = 'monthly' as SubplatInterval; + + const mockFreeTrial: FreeTrial = { + internalName: 'test-free-trial', + intervals: ['monthly'], + trialLengthDays: 30, + countries: ['US', 'CA'], + cooldownPeriodMonths: 6, + }; + + const mockNimbusResult = { + Features: { + 'welcome-feature': { enabled: false }, + 'free-trial-feature': { enabled: true }, + }, + Enrollments: [], + }; + + const mockFreeTrialUtil = { + getResult: jest.fn().mockReturnValue([mockFreeTrial]), + freeTrial: { freeTrials: [mockFreeTrial] }, + }; + + const baseArgs = { + uid: mockUid, + offeringConfigId: mockOfferingConfigId, + countryCode: mockCountryCode, + interval: mockInterval, + eligibilityStatus: EligibilityStatus.CREATE, + }; + + it('returns null when eligibilityStatus is UPGRADE', async () => { + const result = await checkoutService.getFreeTrialEligibility({ + ...baseArgs, + eligibilityStatus: EligibilityStatus.UPGRADE, + }); + + expect(result).toBeNull(); + }); + + it('returns null when Nimbus returns null', async () => { + jest + .spyOn(nimbusManager, 'fetchExperiments') + .mockResolvedValue(null); + jest + .spyOn(nimbusManager, 'generateNimbusId') + .mockReturnValue('nimbus-id'); + jest + .spyOn(productConfigurationManager, 'getFreeTrial') + .mockResolvedValue(mockFreeTrialUtil as any); + + const result = await checkoutService.getFreeTrialEligibility(baseArgs); + + expect(result).toBeNull(); + }); + + it('returns null when Nimbus flag is disabled', async () => { + jest.spyOn(nimbusManager, 'fetchExperiments').mockResolvedValue({ + ...mockNimbusResult, + Features: { + ...mockNimbusResult.Features, + 'free-trial-feature': { enabled: false }, + }, + }); + jest + .spyOn(nimbusManager, 'generateNimbusId') + .mockReturnValue('nimbus-id'); + jest + .spyOn(productConfigurationManager, 'getFreeTrial') + .mockResolvedValue(mockFreeTrialUtil as any); + + const result = await checkoutService.getFreeTrialEligibility(baseArgs); + + expect(result).toBeNull(); + }); + + it('returns null when no Strapi free trial config exists', async () => { + jest + .spyOn(nimbusManager, 'fetchExperiments') + .mockResolvedValue(mockNimbusResult); + jest + .spyOn(nimbusManager, 'generateNimbusId') + .mockReturnValue('nimbus-id'); + jest.spyOn(productConfigurationManager, 'getFreeTrial').mockResolvedValue({ + getResult: jest.fn().mockReturnValue(undefined), + freeTrial: { freeTrials: [] }, + } as any); + + const result = await checkoutService.getFreeTrialEligibility(baseArgs); + + expect(result).toBeNull(); + }); + + it('returns null when country not in config countries', async () => { + jest + .spyOn(nimbusManager, 'fetchExperiments') + .mockResolvedValue(mockNimbusResult); + jest + .spyOn(nimbusManager, 'generateNimbusId') + .mockReturnValue('nimbus-id'); + jest + .spyOn(productConfigurationManager, 'getFreeTrial') + .mockResolvedValue(mockFreeTrialUtil as any); + + const result = await checkoutService.getFreeTrialEligibility({ + ...baseArgs, + countryCode: 'DE', + }); + + expect(result).toBeNull(); + }); + + it('returns null when interval not in config intervals', async () => { + jest + .spyOn(nimbusManager, 'fetchExperiments') + .mockResolvedValue(mockNimbusResult); + jest + .spyOn(nimbusManager, 'generateNimbusId') + .mockReturnValue('nimbus-id'); + jest + .spyOn(productConfigurationManager, 'getFreeTrial') + .mockResolvedValue(mockFreeTrialUtil as any); + + const result = await checkoutService.getFreeTrialEligibility({ + ...baseArgs, + interval: 'yearly' as SubplatInterval, + }); + + expect(result).toBeNull(); + }); + + it('returns null when trialLengthDays is 0', async () => { + jest + .spyOn(nimbusManager, 'fetchExperiments') + .mockResolvedValue(mockNimbusResult); + jest + .spyOn(nimbusManager, 'generateNimbusId') + .mockReturnValue('nimbus-id'); + jest.spyOn(productConfigurationManager, 'getFreeTrial').mockResolvedValue({ + getResult: jest.fn().mockReturnValue([ + { ...mockFreeTrial, trialLengthDays: 0 }, + ]), + freeTrial: { freeTrials: [{ ...mockFreeTrial, trialLengthDays: 0 }] }, + } as any); + + const result = await checkoutService.getFreeTrialEligibility(baseArgs); + + expect(result).toBeNull(); + }); + + it('returns null when blocked by cooldown', async () => { + jest + .spyOn(nimbusManager, 'fetchExperiments') + .mockResolvedValue(mockNimbusResult); + jest + .spyOn(nimbusManager, 'generateNimbusId') + .mockReturnValue('nimbus-id'); + jest + .spyOn(productConfigurationManager, 'getFreeTrial') + .mockResolvedValue(mockFreeTrialUtil as any); + jest + .spyOn(freeTrialManager, 'isBlockedByCooldown') + .mockResolvedValue(true); + + const result = await checkoutService.getFreeTrialEligibility(baseArgs); + + expect(result).toBeNull(); + }); + + it('returns the FreeTrial object when all criteria pass', async () => { + jest + .spyOn(nimbusManager, 'fetchExperiments') + .mockResolvedValue(mockNimbusResult); + jest + .spyOn(nimbusManager, 'generateNimbusId') + .mockReturnValue('nimbus-id'); + jest + .spyOn(productConfigurationManager, 'getFreeTrial') + .mockResolvedValue(mockFreeTrialUtil as any); + jest + .spyOn(freeTrialManager, 'isBlockedByCooldown') + .mockResolvedValue(false); + + const result = await checkoutService.getFreeTrialEligibility(baseArgs); + + expect(result).toEqual(mockFreeTrial); + }); + }); }); diff --git a/libs/payments/cart/src/lib/checkout.service.ts b/libs/payments/cart/src/lib/checkout.service.ts index a1e7296163d..d97631b836f 100644 --- a/libs/payments/cart/src/lib/checkout.service.ts +++ b/libs/payments/cart/src/lib/checkout.service.ts @@ -13,6 +13,7 @@ import { type SubscriptionEligibilityResult, type SubscriptionEligibilityUpgradeDowngradeResult, } from '@fxa/payments/eligibility'; +import { NimbusManager } from '@fxa/payments/experiments'; import { PaypalBillingAgreementManager, PaypalCustomerManager, @@ -43,7 +44,7 @@ import { } from '@fxa/payments/stripe'; import { AccountManager } from '@fxa/shared/account/account'; import { ProfileClient } from '@fxa/profile/client'; -import { ProductConfigurationManager } from '@fxa/shared/cms'; +import { ProductConfigurationManager, type FreeTrial } from '@fxa/shared/cms'; import { StatsDService } from '@fxa/shared/metrics/statsd'; import { NotifierService } from '@fxa/shared/notifier'; import { @@ -90,6 +91,7 @@ import { PaymentsGleanService, } from '@fxa/payments/metrics'; import { isCancelInterstitialOffer } from './util/isCancelInterstitialOffer'; +import { FreeTrialManager } from './free-trial.manager'; @Injectable() export class CheckoutService { @@ -113,6 +115,8 @@ export class CheckoutService { private promotionCodeManager: PromotionCodeManager, private subscriptionManager: SubscriptionManager, private paymentMethodManager: PaymentMethodManager, + private freeTrialManager: FreeTrialManager, + private nimbusManager: NimbusManager, private gleanService: PaymentsGleanService, @Inject(StatsDService) private statsd: StatsD ) {} @@ -865,4 +869,62 @@ export class CheckoutService { return upcomingInvoice.subtotal; } } + + async getFreeTrialEligibility(args: { + uid: string; + offeringConfigId: string; + countryCode: string; + interval: SubplatInterval; + eligibilityStatus: EligibilityStatus; + }): Promise { + const { uid, offeringConfigId, countryCode, interval, eligibilityStatus } = + args; + + if (eligibilityStatus !== EligibilityStatus.CREATE) { + return null; + } + + const [nimbusResult, freeTrialUtil] = await Promise.all([ + this.nimbusManager.fetchExperiments({ + nimbusUserId: this.nimbusManager.generateNimbusId(uid), + preview: false, + }), + this.productConfigurationManager.getFreeTrial(offeringConfigId), + ]); + + if ( + !nimbusResult || + !nimbusResult.Features['free-trial-feature'].enabled + ) { + return null; + } + + const freeTrials = freeTrialUtil.getResult(); + if (!freeTrials) { + return null; + } + + const matchingTrial = freeTrials.find( + (trial) => + trial.trialLengthDays > 0 && + trial.countries.includes(countryCode) && + trial.intervals.includes(interval) + ); + + if (!matchingTrial) { + return null; + } + + const isBlockedByCooldown = await this.freeTrialManager.isBlockedByCooldown( + uid, + matchingTrial.internalName, + matchingTrial.cooldownPeriodMonths + ); + + if (isBlockedByCooldown) { + return null; + } + + return matchingTrial; + } } diff --git a/libs/payments/cart/src/lib/free-trial.config.ts b/libs/payments/cart/src/lib/free-trial.config.ts new file mode 100644 index 00000000000..f0ce9cd84a4 --- /dev/null +++ b/libs/payments/cart/src/lib/free-trial.config.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 { faker } from '@faker-js/faker'; +import { Provider } from '@nestjs/common'; +import { IsString } from 'class-validator'; + +export class FreeTrialConfig { + @IsString() + public readonly firestoreCollectionName!: string; +} + +export const MockFreeTrialConfig = { + firestoreCollectionName: faker.string.uuid(), +} satisfies FreeTrialConfig; + +export const MockFreeTrialConfigProvider = { + provide: FreeTrialConfig, + useValue: MockFreeTrialConfig, +} satisfies Provider; diff --git a/libs/payments/cart/src/lib/free-trial.manager.spec.ts b/libs/payments/cart/src/lib/free-trial.manager.spec.ts new file mode 100644 index 00000000000..4716d2eb02f --- /dev/null +++ b/libs/payments/cart/src/lib/free-trial.manager.spec.ts @@ -0,0 +1,107 @@ +/* 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 { Timestamp } from '@google-cloud/firestore'; +import { FreeTrialManager } from './free-trial.manager'; +import { FreeTrialConfig } from './free-trial.config'; +import { + getLatestFreeTrialRecordData, + insertFreeTrialRecord, +} from './free-trial.repository'; + +const mockCollection = { name: 'mockCollection' } as any; +const mockFirestore = { + collection: jest.fn().mockReturnValue(mockCollection), +} as any; + +jest.mock('./free-trial.repository', () => ({ + getLatestFreeTrialRecordData: jest.fn(), + insertFreeTrialRecord: jest.fn(), +})); + +describe('FreeTrialManager', () => { + let manager: FreeTrialManager; + const mockConfig = { + firestoreCollectionName: 'testCollection', + } as FreeTrialConfig; + + beforeEach(() => { + jest.clearAllMocks(); + manager = new FreeTrialManager(mockConfig, mockFirestore); + }); + + describe('collectionRef', () => { + it('returns the correct collection reference', () => { + const ref = manager.collectionRef; + expect(mockFirestore.collection).toHaveBeenCalledWith('testCollection'); + expect(ref).toBe(mockCollection); + }); + }); + + describe('recordFreeTrial', () => { + it('delegates to repository function', async () => { + (insertFreeTrialRecord as jest.Mock).mockResolvedValue(undefined); + + await manager.recordFreeTrial('test-uid', 'test-config'); + + expect(insertFreeTrialRecord).toHaveBeenCalledWith(mockCollection, { + uid: 'test-uid', + freeTrialConfigId: 'test-config', + startedAt: expect.any(Timestamp), + }); + }); + }); + + describe('isBlockedByCooldown', () => { + it('returns false when no record exists', async () => { + (getLatestFreeTrialRecordData as jest.Mock).mockResolvedValue(null); + + const result = await manager.isBlockedByCooldown( + 'test-uid', + 'test-config', + 6 + ); + + expect(result).toBe(false); + }); + + it('returns false when record is older than cooldown period', async () => { + const sevenMonthsAgo = new Date(); + sevenMonthsAgo.setMonth(sevenMonthsAgo.getMonth() - 7); + + (getLatestFreeTrialRecordData as jest.Mock).mockResolvedValue({ + uid: 'test-uid', + freeTrialConfigId: 'test-config', + startedAt: Timestamp.fromDate(sevenMonthsAgo), + }); + + const result = await manager.isBlockedByCooldown( + 'test-uid', + 'test-config', + 6 + ); + + expect(result).toBe(false); + }); + + it('returns true when record is within cooldown period', async () => { + const oneMonthAgo = new Date(); + oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1); + + (getLatestFreeTrialRecordData as jest.Mock).mockResolvedValue({ + uid: 'test-uid', + freeTrialConfigId: 'test-config', + startedAt: Timestamp.fromDate(oneMonthAgo), + }); + + const result = await manager.isBlockedByCooldown( + 'test-uid', + 'test-config', + 6 + ); + + expect(result).toBe(true); + }); + }); +}); diff --git a/libs/payments/cart/src/lib/free-trial.manager.ts b/libs/payments/cart/src/lib/free-trial.manager.ts new file mode 100644 index 00000000000..b784bdbdfbd --- /dev/null +++ b/libs/payments/cart/src/lib/free-trial.manager.ts @@ -0,0 +1,48 @@ +/* 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 { Timestamp, type CollectionReference, type Firestore } from '@google-cloud/firestore'; +import { FirestoreService } from '@fxa/shared/db/firestore'; +import { FreeTrialConfig } from './free-trial.config'; +import { + getLatestFreeTrialRecordData, + insertFreeTrialRecord, +} from './free-trial.repository'; + +@Injectable() +export class FreeTrialManager { + constructor( + private config: FreeTrialConfig, + @Inject(FirestoreService) private firestore: Firestore + ) {} + + get collectionRef(): CollectionReference { + return this.firestore.collection(this.config.firestoreCollectionName); + } + + async recordFreeTrial(uid: string, freeTrialConfigId: string): Promise { + await insertFreeTrialRecord(this.collectionRef, { + uid, + freeTrialConfigId, + startedAt: Timestamp.now(), + }); + } + + async isBlockedByCooldown( + uid: string, + freeTrialConfigId: string, + cooldownPeriodMonths: number + ): Promise { + const record = await getLatestFreeTrialRecordData(this.collectionRef, uid, freeTrialConfigId); + if (!record) { + return false; + } + + const cooldownEnd = new Date(record.startedAt.toMillis()); + cooldownEnd.setMonth(cooldownEnd.getMonth() + cooldownPeriodMonths); + + return cooldownEnd.getTime() >= Date.now(); + } +} diff --git a/libs/payments/cart/src/lib/free-trial.repository.spec.ts b/libs/payments/cart/src/lib/free-trial.repository.spec.ts new file mode 100644 index 00000000000..f8c1ecd0a79 --- /dev/null +++ b/libs/payments/cart/src/lib/free-trial.repository.spec.ts @@ -0,0 +1,103 @@ +/* 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 { + Timestamp, + type CollectionReference, + type QuerySnapshot, +} from '@google-cloud/firestore'; +import { faker } from '@faker-js/faker'; +import { + insertFreeTrialRecord, + getLatestFreeTrialRecordData, +} from './free-trial.repository'; + +describe('Free Trial Repository', () => { + let mockDb: jest.Mocked; + + const mockRecord = { + uid: faker.string.uuid(), + freeTrialConfigId: faker.string.uuid(), + startedAt: Timestamp.now(), + }; + + const emptyQueryResult = { + empty: true, + size: 0, + docs: [], + } as unknown as QuerySnapshot; + + const nonEmptyQueryResult = (data: any) => + ({ + empty: false, + size: 1, + docs: [ + { + data: () => data, + }, + ], + } as unknown as QuerySnapshot); + + beforeEach(() => { + mockDb = { + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + get: jest.fn(), + add: jest.fn().mockResolvedValue({ id: 'newDocId' }), + } as unknown as jest.Mocked; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('insertFreeTrialRecord', () => { + it('creates a new record', async () => { + await insertFreeTrialRecord(mockDb, mockRecord); + + expect(mockDb.add).toHaveBeenCalledWith({ + uid: mockRecord.uid, + freeTrialConfigId: mockRecord.freeTrialConfigId, + startedAt: mockRecord.startedAt, + }); + }); + }); + + describe('getLatestFreeTrialRecordData', () => { + it('returns most recent record when found', async () => { + (mockDb.get as jest.Mock).mockResolvedValueOnce( + nonEmptyQueryResult(mockRecord) + ); + + const result = await getLatestFreeTrialRecordData( + mockDb, + mockRecord.uid, + mockRecord.freeTrialConfigId + ); + + expect(result).toEqual(mockRecord); + expect(mockDb.where).toHaveBeenCalledWith('uid', '==', mockRecord.uid); + expect(mockDb.where).toHaveBeenCalledWith( + 'freeTrialConfigId', + '==', + mockRecord.freeTrialConfigId + ); + expect(mockDb.orderBy).toHaveBeenCalledWith('startedAt', 'desc'); + expect(mockDb.limit).toHaveBeenCalledWith(1); + }); + + it('returns null when no record exists', async () => { + (mockDb.get as jest.Mock).mockResolvedValueOnce(emptyQueryResult); + + const result = await getLatestFreeTrialRecordData( + mockDb, + mockRecord.uid, + mockRecord.freeTrialConfigId + ); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/libs/payments/cart/src/lib/free-trial.repository.ts b/libs/payments/cart/src/lib/free-trial.repository.ts new file mode 100644 index 00000000000..28f623b8c43 --- /dev/null +++ b/libs/payments/cart/src/lib/free-trial.repository.ts @@ -0,0 +1,41 @@ +/* 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 { Timestamp, type CollectionReference } from '@google-cloud/firestore'; +import type { FreeTrialRecord } from './free-trial.types'; + +export async function insertFreeTrialRecord( + db: CollectionReference, + data: { uid: string; freeTrialConfigId: string; startedAt: Timestamp } +): Promise { + await db.add({ + uid: data.uid, + freeTrialConfigId: data.freeTrialConfigId, + startedAt: data.startedAt, + }); +} + +export async function getLatestFreeTrialRecordData( + db: CollectionReference, + uid: string, + freeTrialConfigId: string +): Promise { + const result = await db + .where('uid', '==', uid) + .where('freeTrialConfigId', '==', freeTrialConfigId) + .orderBy('startedAt', 'desc') + .limit(1) + .get(); + + if (result.empty) { + return null; + } + + const docData = result.docs[0].data(); + return { + uid: docData['uid'], + freeTrialConfigId: docData['freeTrialConfigId'], + startedAt: docData['startedAt'], + }; +} diff --git a/libs/payments/cart/src/lib/free-trial.types.ts b/libs/payments/cart/src/lib/free-trial.types.ts new file mode 100644 index 00000000000..f75b57e2274 --- /dev/null +++ b/libs/payments/cart/src/lib/free-trial.types.ts @@ -0,0 +1,11 @@ +/* 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 { Timestamp } from '@google-cloud/firestore'; + +export interface FreeTrialRecord { + uid: string; + freeTrialConfigId: string; + startedAt: Timestamp; +} diff --git a/libs/payments/ui/src/lib/nestapp/app.module.ts b/libs/payments/ui/src/lib/nestapp/app.module.ts index 05fc933d360..cc375045b49 100644 --- a/libs/payments/ui/src/lib/nestapp/app.module.ts +++ b/libs/payments/ui/src/lib/nestapp/app.module.ts @@ -13,6 +13,7 @@ import { CheckoutService, TaxService, ChurnInterventionManager, + FreeTrialManager, } from '@fxa/payments/cart'; import { EligibilityManager, @@ -113,6 +114,7 @@ import { NimbusManager } from '@fxa/payments/experiments'; CheckoutTokenManager, ChurnInterventionManager, ChurnInterventionService, + FreeTrialManager, ContentServerManager, ContentServerClient, CustomerManager, diff --git a/libs/payments/ui/src/lib/nestapp/config.ts b/libs/payments/ui/src/lib/nestapp/config.ts index 5f09081c2dd..15f4ab4219f 100644 --- a/libs/payments/ui/src/lib/nestapp/config.ts +++ b/libs/payments/ui/src/lib/nestapp/config.ts @@ -19,7 +19,7 @@ import { ProfileClientConfig } from '@fxa/profile/client'; import { ContentServerClientConfig } from '@fxa/payments/content-server'; import { NotifierSnsConfig } from '@fxa/shared/notifier'; import { AppleIapClientConfig, GoogleIapClientConfig } from '@fxa/payments/iap'; -import { ChurnInterventionConfig } from '@fxa/payments/cart'; +import { ChurnInterventionConfig, FreeTrialConfig } from '@fxa/payments/cart'; import { TracingConfig } from './tracing.config'; import { StripeEventConfig } from '@fxa/payments/webhooks'; import { FirestoreConfig } from 'libs/shared/db/firestore/src/lib/firestore.config'; @@ -81,6 +81,11 @@ export class RootConfig { @IsDefined() public readonly churnInterventionConfig!: Partial; + @Type(() => FreeTrialConfig) + @ValidateNested() + @IsDefined() + public readonly freeTrialConfig!: Partial; + @Type(() => FirestoreConfig) @ValidateNested() @IsDefined()