Skip to content

Commit 0ce2304

Browse files
Merge pull request #20190 from mozilla/PAY-3554
feat(payments-next): Add eligibility check for free trials
2 parents bdb9e34 + c7e61e8 commit 0ce2304

13 files changed

Lines changed: 625 additions & 9 deletions

apps/payments/next/.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,9 @@ CURRENCY_CONFIG__CURRENCIES_TO_COUNTRIES={"USD":["AS","CA","GB","GU","MP","MY","
9696
CHURN_INTERVENTION_CONFIG__COLLECTION_NAME=churnInterventions
9797
CHURN_INTERVENTION_CONFIG__ENABLED=
9898

99+
# Free Trial Config
100+
FREE_TRIAL_CONFIG__FIRESTORE_COLLECTION_NAME=freeTrials
101+
99102
# StatsD Config
100103
STATS_D_CONFIG__SAMPLE_RATE=
101104
STATS_D_CONFIG__MAX_BUFFER_SIZE=

libs/payments/cart/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,6 @@ export * from './lib/churn-intervention.config';
1717
export * from './lib/churn-intervention.manager';
1818
export * from './lib/churn-intervention.error';
1919
export * from './lib/churn-intervention.factories';
20+
export * from './lib/free-trial.config';
21+
export * from './lib/free-trial.manager';
22+
export * from './lib/free-trial.types';

libs/payments/cart/src/lib/cart.service.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ import {
9494
import { CartManager } from './cart.manager';
9595
import { CartService } from './cart.service';
9696
import { CheckoutService } from './checkout.service';
97+
import { FreeTrialManager } from './free-trial.manager';
98+
import { MockFreeTrialConfigProvider } from './free-trial.config';
9799
import {
98100
CartError,
99101
CartCouldNotRetrievePriceForCurrencyWhenAttemptingToGetCartCartError,
@@ -199,6 +201,8 @@ describe('CartService', () => {
199201
CustomerSessionManager,
200202
EligibilityManager,
201203
EligibilityService,
204+
FreeTrialManager,
205+
MockFreeTrialConfigProvider,
202206
AppleIapPurchaseManager,
203207
AppleIapClient,
204208
MockAppleIapClientConfigProvider,

libs/payments/cart/src/lib/checkout.service.spec.ts

Lines changed: 213 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
SetupIntentManager,
4646
PromotionCodeNotFoundError,
4747
STRIPE_SUBSCRIPTION_METADATA,
48+
SubplatInterval,
4849
SubscriptionManager,
4950
TaxAddressFactory,
5051
SubPlatPaymentMethodType,
@@ -137,6 +138,9 @@ import {
137138
MockNimbusClientConfigProvider,
138139
NimbusClient,
139140
} from '@fxa/shared/experiments';
141+
import { FreeTrialManager } from './free-trial.manager';
142+
import { MockFreeTrialConfigProvider } from './free-trial.config';
143+
import type { FreeTrial } from '@fxa/shared/cms';
140144

141145
describe('CheckoutService', () => {
142146
let accountCustomerManager: AccountCustomerManager;
@@ -160,6 +164,8 @@ describe('CheckoutService', () => {
160164
let subscriptionManager: SubscriptionManager;
161165
let paymentMethodManager: PaymentMethodManager;
162166
let gleanService: PaymentsGleanService;
167+
let freeTrialManager: FreeTrialManager;
168+
let nimbusManager: NimbusManager;
163169

164170
const mockLogger = {
165171
error: jest.fn(),
@@ -180,6 +186,8 @@ describe('CheckoutService', () => {
180186
CurrencyManager,
181187
EligibilityManager,
182188
EligibilityService,
189+
FreeTrialManager,
190+
MockFreeTrialConfigProvider,
183191
AppleIapPurchaseManager,
184192
AppleIapClient,
185193
MockAppleIapClientConfigProvider,
@@ -259,6 +267,8 @@ describe('CheckoutService', () => {
259267
subscriptionManager = moduleRef.get(SubscriptionManager);
260268
paymentMethodManager = moduleRef.get(PaymentMethodManager);
261269
gleanService = moduleRef.get(PaymentsGleanService);
270+
freeTrialManager = moduleRef.get(FreeTrialManager);
271+
nimbusManager = moduleRef.get(NimbusManager);
262272
});
263273

264274
describe('prePaySteps', () => {
@@ -805,30 +815,33 @@ describe('CheckoutService', () => {
805815
});
806816
});
807817

808-
it('handles free payments for customers with default payment method', async () => {
818+
it('handles zero-amount invoices by creating a setup intent', async () => {
809819
const freeInvoice = StripeResponseFactory(
810820
StripeInvoiceFactory({
811821
payment_intent: mockPaymentIntent.id,
812822
amount_due: 0,
813823
status: 'paid',
814824
})
815825
);
816-
const existingPayentMethod = StripeResponseFactory(
817-
StripePaymentMethodFactory()
826+
const mockSetupIntent = StripeResponseFactory(
827+
StripeSetupIntentFactory({
828+
status: 'succeeded',
829+
payment_method: StripePaymentMethodFactory().id,
830+
})
818831
);
819832
jest.spyOn(invoiceManager, 'retrieve').mockResolvedValue(freeInvoice);
820833
jest
821-
.spyOn(customerManager, 'getDefaultPaymentMethod')
822-
.mockResolvedValue(existingPayentMethod);
823-
expect(
834+
.spyOn(setupIntentManager, 'createAndConfirm')
835+
.mockResolvedValue(mockSetupIntent);
836+
await expect(
824837
checkoutService.payWithStripe(
825838
mockCart,
826839
mockConfirmationToken.id,
827840
mockAttributionData,
828841
mockRequestArgs,
829842
mockCart.uid
830843
)
831-
).resolves;
844+
).resolves.not.toThrow();
832845
});
833846

834847
describe('upgrade', () => {
@@ -1640,4 +1653,197 @@ describe('CheckoutService', () => {
16401653
).rejects.toThrow(/DetermineCheckoutAmountSubscriptionRequiredError/);
16411654
});
16421655
});
1656+
1657+
describe('getFreeTrialEligibility', () => {
1658+
const mockUid = faker.string.uuid();
1659+
const mockOfferingConfigId = faker.string.alphanumeric(10);
1660+
const mockCountryCode = 'US';
1661+
const mockInterval = 'monthly' as SubplatInterval;
1662+
1663+
const mockFreeTrial: FreeTrial = {
1664+
internalName: 'test-free-trial',
1665+
intervals: ['monthly'],
1666+
trialLengthDays: 30,
1667+
countries: ['US', 'CA'],
1668+
cooldownPeriodMonths: 6,
1669+
};
1670+
1671+
const mockNimbusResult = {
1672+
Features: {
1673+
'welcome-feature': { enabled: false },
1674+
'free-trial-feature': { enabled: true },
1675+
},
1676+
Enrollments: [],
1677+
};
1678+
1679+
const mockFreeTrialUtil = {
1680+
getResult: jest.fn().mockReturnValue([mockFreeTrial]),
1681+
freeTrial: { freeTrials: [mockFreeTrial] },
1682+
};
1683+
1684+
const baseArgs = {
1685+
uid: mockUid,
1686+
offeringConfigId: mockOfferingConfigId,
1687+
countryCode: mockCountryCode,
1688+
interval: mockInterval,
1689+
eligibilityStatus: EligibilityStatus.CREATE,
1690+
};
1691+
1692+
it('returns null when eligibilityStatus is UPGRADE', async () => {
1693+
const result = await checkoutService.getFreeTrialEligibility({
1694+
...baseArgs,
1695+
eligibilityStatus: EligibilityStatus.UPGRADE,
1696+
});
1697+
1698+
expect(result).toBeNull();
1699+
});
1700+
1701+
it('returns null when Nimbus returns null', async () => {
1702+
jest
1703+
.spyOn(nimbusManager, 'fetchExperiments')
1704+
.mockResolvedValue(null);
1705+
jest
1706+
.spyOn(nimbusManager, 'generateNimbusId')
1707+
.mockReturnValue('nimbus-id');
1708+
jest
1709+
.spyOn(productConfigurationManager, 'getFreeTrial')
1710+
.mockResolvedValue(mockFreeTrialUtil as any);
1711+
1712+
const result = await checkoutService.getFreeTrialEligibility(baseArgs);
1713+
1714+
expect(result).toBeNull();
1715+
});
1716+
1717+
it('returns null when Nimbus flag is disabled', async () => {
1718+
jest.spyOn(nimbusManager, 'fetchExperiments').mockResolvedValue({
1719+
...mockNimbusResult,
1720+
Features: {
1721+
...mockNimbusResult.Features,
1722+
'free-trial-feature': { enabled: false },
1723+
},
1724+
});
1725+
jest
1726+
.spyOn(nimbusManager, 'generateNimbusId')
1727+
.mockReturnValue('nimbus-id');
1728+
jest
1729+
.spyOn(productConfigurationManager, 'getFreeTrial')
1730+
.mockResolvedValue(mockFreeTrialUtil as any);
1731+
1732+
const result = await checkoutService.getFreeTrialEligibility(baseArgs);
1733+
1734+
expect(result).toBeNull();
1735+
});
1736+
1737+
it('returns null when no Strapi free trial config exists', async () => {
1738+
jest
1739+
.spyOn(nimbusManager, 'fetchExperiments')
1740+
.mockResolvedValue(mockNimbusResult);
1741+
jest
1742+
.spyOn(nimbusManager, 'generateNimbusId')
1743+
.mockReturnValue('nimbus-id');
1744+
jest.spyOn(productConfigurationManager, 'getFreeTrial').mockResolvedValue({
1745+
getResult: jest.fn().mockReturnValue(undefined),
1746+
freeTrial: { freeTrials: [] },
1747+
} as any);
1748+
1749+
const result = await checkoutService.getFreeTrialEligibility(baseArgs);
1750+
1751+
expect(result).toBeNull();
1752+
});
1753+
1754+
it('returns null when country not in config countries', async () => {
1755+
jest
1756+
.spyOn(nimbusManager, 'fetchExperiments')
1757+
.mockResolvedValue(mockNimbusResult);
1758+
jest
1759+
.spyOn(nimbusManager, 'generateNimbusId')
1760+
.mockReturnValue('nimbus-id');
1761+
jest
1762+
.spyOn(productConfigurationManager, 'getFreeTrial')
1763+
.mockResolvedValue(mockFreeTrialUtil as any);
1764+
1765+
const result = await checkoutService.getFreeTrialEligibility({
1766+
...baseArgs,
1767+
countryCode: 'DE',
1768+
});
1769+
1770+
expect(result).toBeNull();
1771+
});
1772+
1773+
it('returns null when interval not in config intervals', async () => {
1774+
jest
1775+
.spyOn(nimbusManager, 'fetchExperiments')
1776+
.mockResolvedValue(mockNimbusResult);
1777+
jest
1778+
.spyOn(nimbusManager, 'generateNimbusId')
1779+
.mockReturnValue('nimbus-id');
1780+
jest
1781+
.spyOn(productConfigurationManager, 'getFreeTrial')
1782+
.mockResolvedValue(mockFreeTrialUtil as any);
1783+
1784+
const result = await checkoutService.getFreeTrialEligibility({
1785+
...baseArgs,
1786+
interval: 'yearly' as SubplatInterval,
1787+
});
1788+
1789+
expect(result).toBeNull();
1790+
});
1791+
1792+
it('returns null when trialLengthDays is 0', async () => {
1793+
jest
1794+
.spyOn(nimbusManager, 'fetchExperiments')
1795+
.mockResolvedValue(mockNimbusResult);
1796+
jest
1797+
.spyOn(nimbusManager, 'generateNimbusId')
1798+
.mockReturnValue('nimbus-id');
1799+
jest.spyOn(productConfigurationManager, 'getFreeTrial').mockResolvedValue({
1800+
getResult: jest.fn().mockReturnValue([
1801+
{ ...mockFreeTrial, trialLengthDays: 0 },
1802+
]),
1803+
freeTrial: { freeTrials: [{ ...mockFreeTrial, trialLengthDays: 0 }] },
1804+
} as any);
1805+
1806+
const result = await checkoutService.getFreeTrialEligibility(baseArgs);
1807+
1808+
expect(result).toBeNull();
1809+
});
1810+
1811+
it('returns null when blocked by cooldown', async () => {
1812+
jest
1813+
.spyOn(nimbusManager, 'fetchExperiments')
1814+
.mockResolvedValue(mockNimbusResult);
1815+
jest
1816+
.spyOn(nimbusManager, 'generateNimbusId')
1817+
.mockReturnValue('nimbus-id');
1818+
jest
1819+
.spyOn(productConfigurationManager, 'getFreeTrial')
1820+
.mockResolvedValue(mockFreeTrialUtil as any);
1821+
jest
1822+
.spyOn(freeTrialManager, 'isBlockedByCooldown')
1823+
.mockResolvedValue(true);
1824+
1825+
const result = await checkoutService.getFreeTrialEligibility(baseArgs);
1826+
1827+
expect(result).toBeNull();
1828+
});
1829+
1830+
it('returns the FreeTrial object when all criteria pass', async () => {
1831+
jest
1832+
.spyOn(nimbusManager, 'fetchExperiments')
1833+
.mockResolvedValue(mockNimbusResult);
1834+
jest
1835+
.spyOn(nimbusManager, 'generateNimbusId')
1836+
.mockReturnValue('nimbus-id');
1837+
jest
1838+
.spyOn(productConfigurationManager, 'getFreeTrial')
1839+
.mockResolvedValue(mockFreeTrialUtil as any);
1840+
jest
1841+
.spyOn(freeTrialManager, 'isBlockedByCooldown')
1842+
.mockResolvedValue(false);
1843+
1844+
const result = await checkoutService.getFreeTrialEligibility(baseArgs);
1845+
1846+
expect(result).toEqual(mockFreeTrial);
1847+
});
1848+
});
16431849
});

0 commit comments

Comments
 (0)