Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/payments/next/.env
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
3 changes: 3 additions & 0 deletions libs/payments/cart/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
4 changes: 4 additions & 0 deletions libs/payments/cart/src/lib/cart.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -199,6 +201,8 @@ describe('CartService', () => {
CustomerSessionManager,
EligibilityManager,
EligibilityService,
FreeTrialManager,
MockFreeTrialConfigProvider,
AppleIapPurchaseManager,
AppleIapClient,
MockAppleIapClientConfigProvider,
Expand Down
220 changes: 213 additions & 7 deletions libs/payments/cart/src/lib/checkout.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
SetupIntentManager,
PromotionCodeNotFoundError,
STRIPE_SUBSCRIPTION_METADATA,
SubplatInterval,
SubscriptionManager,
TaxAddressFactory,
SubPlatPaymentMethodType,
Expand Down Expand Up @@ -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;
Expand All @@ -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(),
Expand All @@ -180,6 +186,8 @@ describe('CheckoutService', () => {
CurrencyManager,
EligibilityManager,
EligibilityService,
FreeTrialManager,
MockFreeTrialConfigProvider,
AppleIapPurchaseManager,
AppleIapClient,
MockAppleIapClientConfigProvider,
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -805,30 +815,33 @@ 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,
amount_due: 0,
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,
mockAttributionData,
mockRequestArgs,
mockCart.uid
)
).resolves;
).resolves.not.toThrow();
});
Comment on lines +836 to 845

describe('upgrade', () => {
Expand Down Expand Up @@ -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);
});
});
});
Loading
Loading