Skip to content

Commit b4e40f2

Browse files
authored
Merge pull request #20408 from mozilla/PAY-3657
fix(payments-cart): validate isFreeTrial on cart
2 parents 7b662fe + 4e577d0 commit b4e40f2

18 files changed

Lines changed: 227 additions & 34 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ checkout-error-contact-support = Please contact support so we can help you.
2727
cart-error-currency-not-determined = We were unable to determine the currency for this purchase, please try again.
2828
checkout-processing-general-error = An unexpected error has occurred while processing your payment, please try again.
2929
cart-total-mismatch-error = The invoice amount has changed. Please try again.
30+
cart-free-trial-mismatch-error = Your free trial eligibility has changed. Please try again.
3031
3132
## Error pages - Payment method failure messages
3233
intent-card-error = Your transaction could not be processed. Please verify your credit card information and try again.

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,21 @@ export class CartEligibilityMismatchError extends CartError {
229229
}
230230
}
231231

232+
export class CartFreeTrialMismatchError extends CartError {
233+
constructor(
234+
cartId: string,
235+
cartIsFreeTrial: boolean,
236+
incomingIsFreeTrial: boolean
237+
) {
238+
super('Cart free trial eligibility mismatch', {
239+
cartId,
240+
cartIsFreeTrial,
241+
incomingIsFreeTrial,
242+
});
243+
this.name = 'CartFreeTrialMismatchError';
244+
}
245+
}
246+
232247
export class CartAccountNotFoundError extends CartError {
233248
constructor(cartId: string) {
234249
super('Cart account not found for uid', {

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export const SetupCartFactory = (override?: Partial<SetupCart>): SetupCart => ({
8181
postalCode: faker.location.zipCode(),
8282
},
8383
currency: faker.finance.currencyCode().toLowerCase(),
84+
isFreeTrial: false,
8485
...override,
8586
});
8687

@@ -150,6 +151,7 @@ export const ResultCartFactory = (
150151
amount: faker.number.int(),
151152
version: faker.number.int(),
152153
eligibilityStatus: faker.helpers.enumValue(CartEligibilityStatus),
154+
isFreeTrial: false,
153155
...override,
154156
});
155157

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,9 @@ describe('CartService', () => {
671671
jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({
672672
subscriptionEligibilityResult: EligibilityStatus.CREATE,
673673
});
674+
jest
675+
.spyOn(checkoutService, 'getFreeTrialEligibility')
676+
.mockResolvedValue(null);
674677
});
675678

676679
it('calls createCart with expected parameters', async () => {
@@ -699,6 +702,7 @@ describe('CartService', () => {
699702
currency: mockResolvedCurrency,
700703
eligibilityStatus: CartEligibilityStatus.CREATE,
701704
couponCode: args.promoCode,
705+
isFreeTrial: false,
702706
});
703707
expect(result).toEqual(mockResultCart);
704708
});
@@ -773,6 +777,7 @@ describe('CartService', () => {
773777
taxAddress,
774778
currency: mockResolvedCurrency,
775779
eligibilityStatus: CartEligibilityStatus.UPGRADE,
780+
isFreeTrial: false,
776781
});
777782
expect(result).toEqual(mockResultCart);
778783
expect(result.couponCode).toBeNull();
@@ -835,6 +840,7 @@ describe('CartService', () => {
835840
currency: mockResolvedCurrency,
836841
eligibilityStatus: CartEligibilityStatus.BLOCKED_IAP,
837842
couponCode: args.promoCode,
843+
isFreeTrial: false,
838844
},
839845
CartErrorReasonId.IAP_BLOCKED_CONTACT_SUPPORT
840846
);
@@ -877,6 +883,7 @@ describe('CartService', () => {
877883
currency: mockResolvedCurrency,
878884
eligibilityStatus: CartEligibilityStatus.DOWNGRADE,
879885
couponCode: args.promoCode,
886+
isFreeTrial: false,
880887
},
881888
CartErrorReasonId.CART_ELIGIBILITY_STATUS_DOWNGRADE
882889
);
@@ -917,6 +924,7 @@ describe('CartService', () => {
917924
currency: mockResolvedCurrency,
918925
eligibilityStatus: CartEligibilityStatus.INVALID,
919926
couponCode: args.promoCode,
927+
isFreeTrial: false,
920928
},
921929
CartErrorReasonId.CART_ELIGIBILITY_STATUS_INVALID
922930
);
@@ -957,6 +965,7 @@ describe('CartService', () => {
957965
currency: mockResolvedCurrency,
958966
eligibilityStatus: CartEligibilityStatus.INVALID,
959967
couponCode: args.promoCode,
968+
isFreeTrial: false,
960969
},
961970
CartErrorReasonId.CART_ELIGIBILITY_STATUS_SAME
962971
);
@@ -1053,6 +1062,7 @@ describe('CartService', () => {
10531062
stripeCustomerId: mockAccountCustomer.stripeCustomerId,
10541063
amount: mockOldCart.amount,
10551064
eligibilityStatus: mockOldCart.eligibilityStatus,
1065+
isFreeTrial: mockOldCart.isFreeTrial,
10561066
});
10571067
expect(result).toEqual(mockNewCart);
10581068
});
@@ -1348,6 +1358,7 @@ describe('CartService', () => {
13481358
const mockCart = ResultCartFactory({
13491359
stripeSubscriptionId: undefined,
13501360
currency: mockCurrency,
1361+
eligibilityStatus: CartEligibilityStatus.INVALID,
13511362
});
13521363
const mockUpdateCartInput = UpdateCartInputFactory({
13531364
taxAddress: {
@@ -1360,6 +1371,7 @@ describe('CartService', () => {
13601371
const expectedUpdateCart = {
13611372
...mockUpdateCartInput,
13621373
currency: mockCurrency,
1374+
isFreeTrial: false,
13631375
};
13641376
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
13651377

@@ -1380,6 +1392,9 @@ describe('CartService', () => {
13801392
jest
13811393
.spyOn(invoiceManager, 'previewUpcoming')
13821394
.mockResolvedValue(mockPreviewInvoice);
1395+
jest
1396+
.spyOn(checkoutService, 'getFreeTrialEligibility')
1397+
.mockResolvedValue(null);
13831398
});
13841399

13851400
it('calls cartManager.updateFreshCart with no currency change', async () => {
@@ -1487,6 +1502,7 @@ describe('CartService', () => {
14871502
});
14881503
const expectedUpdateCart = {
14891504
...mockUpdateCartInput,
1505+
isFreeTrial: false,
14901506
};
14911507

14921508
beforeEach(async () => {
@@ -1497,12 +1513,16 @@ describe('CartService', () => {
14971513
.spyOn(promotionCodeManager, 'assertValidForPriceAndCustomer')
14981514
.mockResolvedValue(undefined);
14991515
jest.spyOn(cartManager, 'updateFreshCart').mockResolvedValue();
1516+
jest
1517+
.spyOn(checkoutService, 'getFreeTrialEligibility')
1518+
.mockResolvedValue(null);
15001519
});
15011520

15021521
it('success if coupon is valid for new customer', async () => {
15031522
const mockCart = ResultCartFactory({
15041523
stripeCustomerId: undefined,
15051524
stripeSubscriptionId: undefined,
1525+
eligibilityStatus: CartEligibilityStatus.INVALID,
15061526
});
15071527

15081528
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
@@ -1525,6 +1545,7 @@ describe('CartService', () => {
15251545
stripeCustomerId: mockCustomer.id,
15261546
stripeSubscriptionId: undefined,
15271547
taxAddress: TaxAddressFactory(),
1548+
eligibilityStatus: CartEligibilityStatus.INVALID,
15281549
});
15291550
const mockPreviewInvoice = InvoicePreviewFactory();
15301551

@@ -2445,6 +2466,7 @@ describe('CartService', () => {
24452466
state: CartState.START,
24462467
stripeSubscriptionId: null,
24472468
eligibilityStatus: CartEligibilityStatus.CREATE,
2469+
isFreeTrial: true,
24482470
});
24492471
const mockInvoicePreview = InvoicePreviewFactory();
24502472

@@ -2470,6 +2492,33 @@ describe('CartService', () => {
24702492
});
24712493
});
24722494

2495+
it('does not call getFreeTrialEligibility when cart was not promised a trial', async () => {
2496+
const mockFreeTrial = FreeTrialFactory({ trialLengthDays: 7 });
2497+
const mockCart = ResultCartFactory({
2498+
uid: faker.string.uuid(),
2499+
state: CartState.START,
2500+
stripeSubscriptionId: null,
2501+
eligibilityStatus: CartEligibilityStatus.CREATE,
2502+
isFreeTrial: false,
2503+
});
2504+
const mockInvoicePreview = InvoicePreviewFactory();
2505+
2506+
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
2507+
jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({
2508+
subscriptionEligibilityResult: EligibilityStatus.CREATE,
2509+
});
2510+
jest
2511+
.spyOn(invoiceManager, 'previewUpcoming')
2512+
.mockResolvedValue(mockInvoicePreview);
2513+
jest
2514+
.spyOn(checkoutService, 'getFreeTrialEligibility')
2515+
.mockResolvedValue(mockFreeTrial);
2516+
2517+
const result = await cartService.getCart(mockCart.id);
2518+
expect(result.freeTrialEligibility).toBeNull();
2519+
expect(checkoutService.getFreeTrialEligibility).not.toHaveBeenCalled();
2520+
});
2521+
24732522
it('sets freeTrialEligibility to null when cart has no uid', async () => {
24742523
const mockCart = ResultCartFactory({
24752524
uid: undefined,

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

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,16 @@ export class CartService {
444444
}
445445
}
446446

447+
const freeTrial = args.uid
448+
? await this.checkoutService.getFreeTrialEligibility({
449+
uid: args.uid,
450+
offeringConfigId: args.offeringConfigId,
451+
countryCode: args.taxAddress.countryCode,
452+
interval: args.interval,
453+
eligibilityStatus: eligibility.subscriptionEligibilityResult,
454+
})
455+
: null;
456+
447457
const createCartParams: SetupCart = {
448458
interval: args.interval,
449459
offeringConfigId: args.offeringConfigId,
@@ -455,6 +465,7 @@ export class CartService {
455465
currency,
456466
eligibilityStatus: cartEligibilityStatus,
457467
couponCode,
468+
isFreeTrial: !!freeTrial,
458469
};
459470

460471
if (eligibility.subscriptionEligibilityResult === EligibilityStatus.SAME) {
@@ -547,6 +558,7 @@ export class CartService {
547558
stripeCustomerId: accountCustomer?.stripeCustomerId || undefined,
548559
amount: oldCart.amount,
549560
eligibilityStatus: oldCart.eligibilityStatus,
561+
isFreeTrial: oldCart.isFreeTrial,
550562
});
551563
});
552564
}
@@ -825,6 +837,27 @@ export class CartService {
825837
);
826838
}
827839

840+
const effectiveUid = cartDetailsInput.uid ?? oldCart.uid;
841+
if (
842+
effectiveUid &&
843+
oldCart.eligibilityStatus === CartEligibilityStatus.CREATE
844+
) {
845+
const freeTrial =
846+
await this.checkoutService.getFreeTrialEligibility({
847+
uid: effectiveUid,
848+
offeringConfigId: oldCart.offeringConfigId,
849+
countryCode:
850+
cartDetailsInput.taxAddress?.countryCode ??
851+
oldCart.taxAddress?.countryCode ??
852+
'',
853+
interval: oldCart.interval as SubplatInterval,
854+
eligibilityStatus: EligibilityStatus.CREATE,
855+
});
856+
cartDetails.isFreeTrial = !!freeTrial;
857+
} else {
858+
cartDetails.isFreeTrial = false;
859+
}
860+
828861
await this.cartManager.updateFreshCart(cartId, version, cartDetails);
829862

830863
return this.cartManager.fetchCartById(cartId);
@@ -897,7 +930,7 @@ export class CartService {
897930
const trialEndDate = trialSubscription?.trial_end ?? undefined;
898931

899932
let freeTrialEligibility: FreeTrial | null = null;
900-
if (cart.uid && cart.state === CartState.START) {
933+
if (cart.uid && cart.state === CartState.START && cart.isFreeTrial) {
901934
freeTrialEligibility = await this.checkoutService.getFreeTrialEligibility(
902935
{
903936
uid: cart.uid,

libs/payments/cart/src/lib/cart.types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ export type SetupCart = {
128128
stripeCustomerId?: string;
129129
amount: number;
130130
eligibilityStatus: CartEligibilityStatus;
131+
isFreeTrial: boolean;
131132
};
132133

133134
export interface TaxAmount {
@@ -145,6 +146,7 @@ export type UpdateCart = {
145146
stripeCustomerId?: string;
146147
stripeSubscriptionId?: string;
147148
stripeIntentId?: string;
149+
isFreeTrial?: boolean;
148150
};
149151

150152
export type UpdateCartInput = Pick<

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const PrePayStepsResultFactory = (
2222
eligibility: SubscriptionEligibilityResultFactory({
2323
subscriptionEligibilityResult: EligibilityStatus.CREATE,
2424
}),
25+
freeTrial: null,
2526
...override,
2627
};
2728
};

0 commit comments

Comments
 (0)