Skip to content

Commit 30933ba

Browse files
Merge pull request #20419 from mozilla/PAY-3661-block-new-customers-from-checking-out-for-free-trial-with-prepaid-card
fix(payments-next): Block new customers from checking out for free trial with prepaid card
2 parents 5e35f3b + 2a79209 commit 30933ba

18 files changed

Lines changed: 435 additions & 1 deletion

File tree

apps/payments/next/app/[locale]/en.ftl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ intent-payment-error-generic = An unexpected error has occurred while processing
3838
intent-payment-error-insufficient-funds = It looks like your card has insufficient funds. Try another card.
3939
general-paypal-error = An unexpected error has occurred while processing your payment, please try again.
4040
paypal-active-subscription-no-billing-agreement-error = It looks like there was a problem billing your { -brand-paypal } account. Please re-enable automatic payments for your subscription.
41+
new-account-prepaid-card-free-trial-not-allowed = Prepaid cards cannot be used to start a free trial on new accounts. Please try a different payment method.
4142
4243
## Processing page and Needs Input page - /checkout and /upgrade
4344
## Common strings used in multiple pages

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
InvoiceManager,
2828
InvoicePreviewFactory,
2929
PaymentIntentManager,
30+
ConfirmationTokenManager,
3031
PaymentMethodManager,
3132
PriceManager,
3233
PricingForCurrencyFactory,
@@ -198,6 +199,7 @@ describe('CartService', () => {
198199
CartManager,
199200
CartService,
200201
CheckoutService,
202+
ConfirmationTokenManager,
201203
CustomerManager,
202204
CustomerSessionManager,
203205
EligibilityManager,

libs/payments/cart/src/lib/checkout.error.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,19 @@ export class IntentInsufficientFundsError extends IntentFailedHandledError {
261261
}
262262
}
263263

264+
export class NewAccountPrepaidCardFreeTrialNotAllowedError extends CheckoutError {
265+
constructor(cartId: string, uid: string) {
266+
super(
267+
'New accounts cannot start a free trial with a prepaid card',
268+
{
269+
cartId,
270+
uid,
271+
}
272+
);
273+
this.name = 'NewAccountPrepaidCardFreeTrialNotAllowedError';
274+
}
275+
}
276+
264277
export class UnexpectedSubscriptionStatusForTrialError extends CheckoutError {
265278
constructor(cartId: string, subscriptionId: string, actualStatus: string) {
266279
super(

libs/payments/cart/src/lib/checkout.factories.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const PrePayStepsResultFactory = (
1616
return {
1717
version: cart.version,
1818
uid: faker.string.uuid(),
19+
accountCreatedAt: faker.date.past().getTime(),
1920
customer: StripeCustomerFactory(),
2021
enableAutomaticTax: true,
2122
price: StripePriceFactory(),

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

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
CartManager,
1313
CartService,
1414
InvalidInvoiceStateCheckoutError,
15+
NewAccountPrepaidCardFreeTrialNotAllowedError,
1516
ResultCartFactory,
1617
SubscriptionAttributionFactory,
1718
UnexpectedSubscriptionStatusForTrialError,
@@ -33,6 +34,7 @@ import {
3334
ResultPaypalCustomerFactory,
3435
} from '@fxa/payments/paypal';
3536
import {
37+
ConfirmationTokenManager,
3638
CustomerManager,
3739
CustomerSessionManager,
3840
InvoiceManager,
@@ -67,6 +69,8 @@ import {
6769
AccountCustomerManager,
6870
AccountCustomerNotFoundError,
6971
StripeConfirmationTokenFactory,
72+
StripeConfirmationTokenPaymentMethodPreviewCardFactory,
73+
StripeConfirmationTokenPaymentMethodPreviewFactory,
7074
StripeSetupIntentFactory,
7175
} from '@fxa/payments/stripe';
7276
import {
@@ -152,6 +156,7 @@ describe('CheckoutService', () => {
152156
let asyncLocalStorage: AsyncLocalStorage<CartStore>;
153157
let cartManager: CartManager;
154158
let checkoutService: CheckoutService;
159+
let confirmationTokenManager: ConfirmationTokenManager;
155160
let customerManager: CustomerManager;
156161
let eligibilityService: EligibilityService;
157162
let invoiceManager: InvoiceManager;
@@ -185,6 +190,7 @@ describe('CheckoutService', () => {
185190
CartManager,
186191
CartService,
187192
CheckoutService,
193+
ConfirmationTokenManager,
188194
CustomerManager,
189195
CustomerSessionManager,
190196
CurrencyManager,
@@ -251,6 +257,7 @@ describe('CheckoutService', () => {
251257
asyncLocalStorage = moduleRef.get(AsyncLocalStorageCart);
252258
cartManager = moduleRef.get(CartManager);
253259
checkoutService = moduleRef.get(CheckoutService);
260+
confirmationTokenManager = moduleRef.get(ConfirmationTokenManager);
254261
customerManager = moduleRef.get(CustomerManager);
255262
eligibilityService = moduleRef.get(EligibilityService);
256263
invoiceManager = moduleRef.get(InvoiceManager);
@@ -1403,6 +1410,257 @@ describe('CheckoutService', () => {
14031410
});
14041411
});
14051412

1413+
it('throws NewAccountPrepaidCardFreeTrialNotAllowedError when new account uses a prepaid card for a free trial', async () => {
1414+
const mockNewAccountPrePayStepsResult = PrePayStepsResultFactory({
1415+
uid: mockCart.uid,
1416+
accountCreatedAt: Date.now() - 60 * 60 * 1000,
1417+
customer: mockCustomer,
1418+
promotionCode: mockPromotionCode,
1419+
price: mockPrice,
1420+
eligibility: mockEligibilityResult,
1421+
freeTrial: mockFreeTrial,
1422+
});
1423+
const mockPrepaidConfirmationToken = StripeResponseFactory(
1424+
StripeConfirmationTokenFactory({
1425+
payment_method_preview:
1426+
StripeConfirmationTokenPaymentMethodPreviewFactory({
1427+
card: StripeConfirmationTokenPaymentMethodPreviewCardFactory({
1428+
funding: 'prepaid',
1429+
}),
1430+
}),
1431+
})
1432+
);
1433+
1434+
jest
1435+
.spyOn(checkoutService, 'prePaySteps')
1436+
.mockResolvedValue(mockNewAccountPrePayStepsResult);
1437+
jest
1438+
.spyOn(priceManager, 'retrievePricingForCurrency')
1439+
.mockResolvedValue(mockPricingForCurrency);
1440+
jest
1441+
.spyOn(checkoutService, 'getFreeTrialEligibility')
1442+
.mockResolvedValue(mockFreeTrial);
1443+
jest
1444+
.spyOn(confirmationTokenManager, 'retrieve')
1445+
.mockResolvedValue(mockPrepaidConfirmationToken);
1446+
jest.spyOn(subscriptionManager, 'create');
1447+
jest.spyOn(statsd, 'increment');
1448+
1449+
await expect(
1450+
checkoutService.payWithStripe(
1451+
mockCart,
1452+
mockConfirmationToken.id,
1453+
mockAttributionData,
1454+
mockRequestArgs,
1455+
mockCart.uid
1456+
)
1457+
).rejects.toBeInstanceOf(NewAccountPrepaidCardFreeTrialNotAllowedError);
1458+
1459+
expect(subscriptionManager.create).not.toHaveBeenCalled();
1460+
});
1461+
1462+
it('does not block prepaid card for free trial when account is older than 24 hours', async () => {
1463+
const mockOldAccountPrePayStepsResult = PrePayStepsResultFactory({
1464+
uid: mockCart.uid,
1465+
accountCreatedAt: Date.now() - 25 * 60 * 60 * 1000,
1466+
customer: mockCustomer,
1467+
promotionCode: mockPromotionCode,
1468+
price: mockPrice,
1469+
eligibility: mockEligibilityResult,
1470+
freeTrial: mockFreeTrial,
1471+
});
1472+
const mockTrialSubscription = StripeResponseFactory(
1473+
StripeSubscriptionFactory({ status: 'trialing' })
1474+
);
1475+
const mockTrialInvoice = StripeResponseFactory(
1476+
StripeInvoiceFactory({
1477+
payment_intent: null,
1478+
amount_due: 0,
1479+
})
1480+
);
1481+
const mockSetupIntent = StripeResponseFactory(
1482+
StripeSetupIntentFactory({
1483+
status: 'succeeded',
1484+
payment_method: mockPaymentMethod.id,
1485+
})
1486+
);
1487+
1488+
jest
1489+
.spyOn(checkoutService, 'prePaySteps')
1490+
.mockResolvedValue(mockOldAccountPrePayStepsResult);
1491+
jest
1492+
.spyOn(priceManager, 'retrievePricingForCurrency')
1493+
.mockResolvedValue(mockPricingForCurrency);
1494+
jest
1495+
.spyOn(checkoutService, 'getFreeTrialEligibility')
1496+
.mockResolvedValue(mockFreeTrial);
1497+
jest
1498+
.spyOn(subscriptionManager, 'create')
1499+
.mockResolvedValue(mockTrialSubscription);
1500+
jest.spyOn(cartManager, 'updateFreshCart').mockResolvedValue();
1501+
jest
1502+
.spyOn(invoiceManager, 'retrieve')
1503+
.mockResolvedValue(mockTrialInvoice);
1504+
jest
1505+
.spyOn(setupIntentManager, 'createAndConfirm')
1506+
.mockResolvedValue(mockSetupIntent);
1507+
jest.spyOn(customerManager, 'update').mockResolvedValue(mockCustomer);
1508+
jest.spyOn(checkoutService, 'postPaySteps').mockResolvedValue();
1509+
jest
1510+
.spyOn(paymentMethodManager, 'retrieve')
1511+
.mockResolvedValue(mockPaymentMethod);
1512+
jest.spyOn(freeTrialManager, 'recordFreeTrial').mockResolvedValue();
1513+
jest.spyOn(statsd, 'increment');
1514+
1515+
await checkoutService.payWithStripe(
1516+
mockCart,
1517+
mockConfirmationToken.id,
1518+
mockAttributionData,
1519+
mockRequestArgs,
1520+
mockCart.uid
1521+
);
1522+
1523+
expect(subscriptionManager.create).toHaveBeenCalled();
1524+
});
1525+
1526+
it('does not block new account with a non-prepaid card for a free trial', async () => {
1527+
const mockNewAccountPrePayStepsResult = PrePayStepsResultFactory({
1528+
uid: mockCart.uid,
1529+
accountCreatedAt: Date.now() - 60 * 60 * 1000,
1530+
customer: mockCustomer,
1531+
promotionCode: mockPromotionCode,
1532+
price: mockPrice,
1533+
eligibility: mockEligibilityResult,
1534+
freeTrial: mockFreeTrial,
1535+
});
1536+
const mockCreditConfirmationToken = StripeResponseFactory(
1537+
StripeConfirmationTokenFactory({
1538+
payment_method_preview:
1539+
StripeConfirmationTokenPaymentMethodPreviewFactory({
1540+
card: StripeConfirmationTokenPaymentMethodPreviewCardFactory({
1541+
funding: 'credit',
1542+
}),
1543+
}),
1544+
})
1545+
);
1546+
const mockTrialSubscription = StripeResponseFactory(
1547+
StripeSubscriptionFactory({ status: 'trialing' })
1548+
);
1549+
const mockTrialInvoice = StripeResponseFactory(
1550+
StripeInvoiceFactory({
1551+
payment_intent: null,
1552+
amount_due: 0,
1553+
})
1554+
);
1555+
const mockSetupIntent = StripeResponseFactory(
1556+
StripeSetupIntentFactory({
1557+
status: 'succeeded',
1558+
payment_method: mockPaymentMethod.id,
1559+
})
1560+
);
1561+
1562+
jest
1563+
.spyOn(checkoutService, 'prePaySteps')
1564+
.mockResolvedValue(mockNewAccountPrePayStepsResult);
1565+
jest
1566+
.spyOn(priceManager, 'retrievePricingForCurrency')
1567+
.mockResolvedValue(mockPricingForCurrency);
1568+
jest
1569+
.spyOn(checkoutService, 'getFreeTrialEligibility')
1570+
.mockResolvedValue(mockFreeTrial);
1571+
jest
1572+
.spyOn(confirmationTokenManager, 'retrieve')
1573+
.mockResolvedValue(mockCreditConfirmationToken);
1574+
jest
1575+
.spyOn(subscriptionManager, 'create')
1576+
.mockResolvedValue(mockTrialSubscription);
1577+
jest.spyOn(cartManager, 'updateFreshCart').mockResolvedValue();
1578+
jest
1579+
.spyOn(invoiceManager, 'retrieve')
1580+
.mockResolvedValue(mockTrialInvoice);
1581+
jest
1582+
.spyOn(setupIntentManager, 'createAndConfirm')
1583+
.mockResolvedValue(mockSetupIntent);
1584+
jest.spyOn(customerManager, 'update').mockResolvedValue(mockCustomer);
1585+
jest.spyOn(checkoutService, 'postPaySteps').mockResolvedValue();
1586+
jest
1587+
.spyOn(paymentMethodManager, 'retrieve')
1588+
.mockResolvedValue(mockPaymentMethod);
1589+
jest.spyOn(freeTrialManager, 'recordFreeTrial').mockResolvedValue();
1590+
jest.spyOn(statsd, 'increment');
1591+
1592+
await checkoutService.payWithStripe(
1593+
mockCart,
1594+
mockConfirmationToken.id,
1595+
mockAttributionData,
1596+
mockRequestArgs,
1597+
mockCart.uid
1598+
);
1599+
1600+
expect(subscriptionManager.create).toHaveBeenCalled();
1601+
});
1602+
1603+
it('does not retrieve the confirmation token when new account with prepaid card is not on a free trial', async () => {
1604+
const mockNewAccountPrePayStepsResult = PrePayStepsResultFactory({
1605+
uid: mockCart.uid,
1606+
accountCreatedAt: Date.now() - 60 * 60 * 1000,
1607+
customer: mockCustomer,
1608+
promotionCode: mockPromotionCode,
1609+
price: mockPrice,
1610+
eligibility: mockEligibilityResult,
1611+
});
1612+
const mockSubscription = StripeResponseFactory(
1613+
StripeSubscriptionFactory()
1614+
);
1615+
const mockPaymentIntent = StripeResponseFactory(
1616+
StripePaymentIntentFactory({
1617+
status: 'succeeded',
1618+
payment_method: mockPaymentMethod.id,
1619+
})
1620+
);
1621+
const mockInvoice = StripeResponseFactory(
1622+
StripeInvoiceFactory({
1623+
payment_intent: mockPaymentIntent.id,
1624+
})
1625+
);
1626+
1627+
jest
1628+
.spyOn(checkoutService, 'prePaySteps')
1629+
.mockResolvedValue(mockNewAccountPrePayStepsResult);
1630+
jest
1631+
.spyOn(priceManager, 'retrievePricingForCurrency')
1632+
.mockResolvedValue(mockPricingForCurrency);
1633+
jest
1634+
.spyOn(checkoutService, 'getFreeTrialEligibility')
1635+
.mockResolvedValue(null);
1636+
jest.spyOn(confirmationTokenManager, 'retrieve');
1637+
jest
1638+
.spyOn(subscriptionManager, 'create')
1639+
.mockResolvedValue(mockSubscription);
1640+
jest.spyOn(cartManager, 'updateFreshCart').mockResolvedValue();
1641+
jest.spyOn(invoiceManager, 'retrieve').mockResolvedValue(mockInvoice);
1642+
jest
1643+
.spyOn(paymentIntentManager, 'confirm')
1644+
.mockResolvedValue(mockPaymentIntent);
1645+
jest.spyOn(customerManager, 'update').mockResolvedValue(mockCustomer);
1646+
jest.spyOn(checkoutService, 'postPaySteps').mockResolvedValue();
1647+
jest
1648+
.spyOn(paymentMethodManager, 'retrieve')
1649+
.mockResolvedValue(mockPaymentMethod);
1650+
jest.spyOn(statsd, 'increment');
1651+
1652+
await checkoutService.payWithStripe(
1653+
mockCart,
1654+
mockConfirmationToken.id,
1655+
mockAttributionData,
1656+
mockRequestArgs,
1657+
mockCart.uid
1658+
);
1659+
1660+
expect(confirmationTokenManager.retrieve).not.toHaveBeenCalled();
1661+
expect(subscriptionManager.create).toHaveBeenCalled();
1662+
});
1663+
14061664
it('throws UnexpectedSubscriptionStatusForTrialError when trial subscription is not trialing', async () => {
14071665
const mockNonTrialingSubscription = StripeResponseFactory(
14081666
StripeSubscriptionFactory({ status: 'active' })

0 commit comments

Comments
 (0)