Skip to content

Commit 67558a8

Browse files
authored
Merge pull request #18304 from mozilla/FXA-11023
feat(libs): Update libs for subscription upgrades
2 parents fd10a8f + 5889005 commit 67558a8

14 files changed

Lines changed: 440 additions & 78 deletions

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

Lines changed: 94 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -479,9 +479,9 @@ describe('CartService', () => {
479479
jest
480480
.spyOn(invoiceManager, 'previewUpcoming')
481481
.mockResolvedValue(mockInvoicePreview);
482-
jest
483-
.spyOn(eligibilityService, 'checkEligibility')
484-
.mockResolvedValue(EligibilityStatus.CREATE);
482+
jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({
483+
subscriptionEligibilityResult: EligibilityStatus.CREATE,
484+
});
485485
});
486486

487487
it('calls createCart with expected parameters', async () => {
@@ -569,9 +569,9 @@ describe('CartService', () => {
569569
.mockReturnValue(mockResolvedCurrency);
570570
jest.spyOn(cartManager, 'createCart').mockResolvedValue(mockResultCart);
571571
jest.spyOn(accountManager, 'getAccounts').mockResolvedValue([]);
572-
jest
573-
.spyOn(eligibilityService, 'checkEligibility')
574-
.mockResolvedValue(EligibilityStatus.DOWNGRADE);
572+
jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({
573+
subscriptionEligibilityResult: EligibilityStatus.DOWNGRADE,
574+
});
575575
jest.spyOn(cartService, 'finalizeCartWithError').mockResolvedValue();
576576

577577
const result = await cartService.setupCart(args);
@@ -603,9 +603,9 @@ describe('CartService', () => {
603603
.mockReturnValue(mockResolvedCurrency);
604604
jest.spyOn(cartManager, 'createCart').mockResolvedValue(mockResultCart);
605605
jest.spyOn(accountManager, 'getAccounts').mockResolvedValue([]);
606-
jest
607-
.spyOn(eligibilityService, 'checkEligibility')
608-
.mockResolvedValue(EligibilityStatus.INVALID);
606+
jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({
607+
subscriptionEligibilityResult: EligibilityStatus.INVALID,
608+
});
609609
jest.spyOn(cartService, 'finalizeCartWithError').mockResolvedValue();
610610

611611
const result = await cartService.setupCart(args);
@@ -1042,7 +1042,9 @@ describe('CartService', () => {
10421042

10431043
it('returns cart and upcomingInvoicePreview', async () => {
10441044
const mockCart = ResultCartFactory({
1045+
state: CartState.START,
10451046
stripeSubscriptionId: null,
1047+
eligibilityStatus: CartEligibilityStatus.CREATE,
10461048
});
10471049
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
10481050
const mockPrice = StripePriceFactory();
@@ -1056,6 +1058,9 @@ describe('CartService', () => {
10561058
jest
10571059
.spyOn(invoiceManager, 'previewUpcoming')
10581060
.mockResolvedValue(mockInvoicePreview);
1061+
jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({
1062+
subscriptionEligibilityResult: EligibilityStatus.CREATE,
1063+
});
10591064

10601065
const result = await cartService.getCart(mockCart.id);
10611066
expect(result).toEqual({
@@ -1088,6 +1093,7 @@ describe('CartService', () => {
10881093
it('returns cart and upcomingInvoicePreview and latestInvoicePreview', async () => {
10891094
const mockCart = ResultCartFactory({
10901095
stripeSubscriptionId: mockSubscription.id,
1096+
eligibilityStatus: CartEligibilityStatus.CREATE,
10911097
});
10921098
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
10931099
const mockPrice = StripePriceFactory();
@@ -1096,6 +1102,9 @@ describe('CartService', () => {
10961102
const mockPaymentMethod = StripeResponseFactory(
10971103
StripePaymentMethodFactory({})
10981104
);
1105+
jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({
1106+
subscriptionEligibilityResult: EligibilityStatus.CREATE,
1107+
});
10991108

11001109
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
11011110
jest
@@ -1150,6 +1159,7 @@ describe('CartService', () => {
11501159
it('returns cart and upcomingInvoicePreview if customer is undefined', async () => {
11511160
const mockCart = ResultCartFactory({
11521161
stripeCustomerId: null,
1162+
eligibilityStatus: CartEligibilityStatus.CREATE,
11531163
});
11541164
const mockPrice = StripePriceFactory();
11551165
const mockInvoicePreview = InvoicePreviewFactory();
@@ -1162,6 +1172,9 @@ describe('CartService', () => {
11621172
jest
11631173
.spyOn(invoiceManager, 'previewUpcoming')
11641174
.mockResolvedValue(mockInvoicePreview);
1175+
jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({
1176+
subscriptionEligibilityResult: EligibilityStatus.CREATE,
1177+
});
11651178

11661179
const result = await cartService.getCart(mockCart.id);
11671180
expect(result).toEqual({
@@ -1183,6 +1196,63 @@ describe('CartService', () => {
11831196
});
11841197
});
11851198

1199+
it('returns cart with upgrade eligibility status', async () => {
1200+
const mockCart = ResultCartFactory({
1201+
state: CartState.START,
1202+
stripeSubscriptionId: null,
1203+
eligibilityStatus: CartEligibilityStatus.UPGRADE,
1204+
});
1205+
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
1206+
const mockPrice = StripePriceFactory();
1207+
const mockInvoicePreview = InvoicePreviewFactory();
1208+
const mockFromOfferingId = faker.string.uuid();
1209+
const mockFromPrice = StripePriceFactory();
1210+
1211+
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
1212+
jest
1213+
.spyOn(productConfigurationManager, 'retrieveStripePrice')
1214+
.mockResolvedValue(mockPrice);
1215+
jest.spyOn(customerManager, 'retrieve').mockResolvedValue(mockCustomer);
1216+
jest
1217+
.spyOn(invoiceManager, 'previewUpcomingForUpgrade')
1218+
.mockResolvedValue(mockInvoicePreview);
1219+
jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({
1220+
subscriptionEligibilityResult: EligibilityStatus.UPGRADE,
1221+
fromOfferingConfigId: mockFromOfferingId,
1222+
fromPrice: mockFromPrice,
1223+
});
1224+
1225+
const result = await cartService.getCart(mockCart.id);
1226+
expect(result).toEqual({
1227+
...mockCart,
1228+
upcomingInvoicePreview: mockInvoicePreview,
1229+
paymentInfo: {
1230+
type: mockPaymentMethod.type,
1231+
last4: mockPaymentMethod.card?.last4,
1232+
brand: mockPaymentMethod.card?.brand,
1233+
customerSessionClientSecret: mockCustomerSession.client_secret,
1234+
},
1235+
metricsOptedOut: false,
1236+
fromOfferingConfigId: mockFromOfferingId,
1237+
fromPrice: mockFromPrice,
1238+
});
1239+
1240+
expect(cartManager.fetchCartById).toHaveBeenCalledWith(mockCart.id);
1241+
expect(
1242+
productConfigurationManager.retrieveStripePrice
1243+
).toHaveBeenCalledWith(mockCart.offeringConfigId, mockCart.interval);
1244+
expect(customerManager.retrieve).toHaveBeenCalledWith(
1245+
mockCart.stripeCustomerId
1246+
);
1247+
expect(invoiceManager.previewUpcomingForUpgrade).toHaveBeenCalledWith({
1248+
priceId: mockPrice.id,
1249+
currency: mockCart.currency,
1250+
customer: mockCustomer,
1251+
taxAddress: mockCart.taxAddress,
1252+
fromPrice: mockFromPrice,
1253+
});
1254+
});
1255+
11861256
it("has metricsOptedOut set to true if the cart's account has opted out of metrics", async () => {
11871257
const mockUid = faker.string.hexadecimal({
11881258
length: 32,
@@ -1196,6 +1266,7 @@ describe('CartService', () => {
11961266
const mockCart = ResultCartFactory({
11971267
uid: mockUid,
11981268
stripeSubscriptionId: null,
1269+
eligibilityStatus: CartEligibilityStatus.CREATE,
11991270
});
12001271
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
12011272
const mockPrice = StripePriceFactory();
@@ -1212,6 +1283,9 @@ describe('CartService', () => {
12121283
jest
12131284
.spyOn(accountManager, 'getAccounts')
12141285
.mockResolvedValue([mockAccount]);
1286+
jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({
1287+
subscriptionEligibilityResult: EligibilityStatus.CREATE,
1288+
});
12151289

12161290
const result = await cartService.getCart(mockCart.id);
12171291
expect(accountManager.getAccounts).toHaveBeenCalledWith([mockUid]);
@@ -1230,6 +1304,7 @@ describe('CartService', () => {
12301304
const mockCart = ResultCartFactory({
12311305
uid: mockUid,
12321306
stripeSubscriptionId: null,
1307+
eligibilityStatus: CartEligibilityStatus.CREATE,
12331308
});
12341309
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
12351310
const mockPrice = StripePriceFactory();
@@ -1246,14 +1321,20 @@ describe('CartService', () => {
12461321
jest
12471322
.spyOn(accountManager, 'getAccounts')
12481323
.mockResolvedValue([mockAccount]);
1324+
jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({
1325+
subscriptionEligibilityResult: EligibilityStatus.CREATE,
1326+
});
12491327

12501328
const result = await cartService.getCart(mockCart.id);
12511329
expect(accountManager.getAccounts).toHaveBeenCalledWith([mockUid]);
12521330
expect(result.metricsOptedOut).toBeFalsy();
12531331
});
12541332

12551333
it('has metricsOptedOut set to false if the cart has no associated account', async () => {
1256-
const mockCart = ResultCartFactory({ stripeSubscriptionId: null });
1334+
const mockCart = ResultCartFactory({
1335+
stripeSubscriptionId: null,
1336+
eligibilityStatus: CartEligibilityStatus.CREATE,
1337+
});
12571338
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
12581339
const mockPrice = StripePriceFactory();
12591340
const mockInvoicePreview = InvoicePreviewFactory();
@@ -1267,6 +1348,9 @@ describe('CartService', () => {
12671348
.spyOn(invoiceManager, 'previewUpcoming')
12681349
.mockResolvedValue(mockInvoicePreview);
12691350
jest.spyOn(accountManager, 'getAccounts').mockResolvedValue([]);
1351+
jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({
1352+
subscriptionEligibilityResult: EligibilityStatus.CREATE,
1353+
});
12701354

12711355
const result = await cartService.getCart(mockCart.id);
12721356
expect(accountManager.getAccounts).not.toHaveBeenCalled();

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

Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import { Injectable } from '@nestjs/common';
66
import * as Sentry from '@sentry/node';
7+
import assert from 'assert';
78

89
import {
910
CustomerManager,
@@ -28,13 +29,24 @@ import {
2829
} from '@fxa/payments/stripe';
2930
import { ProductConfigurationManager } from '@fxa/shared/cms';
3031
import { CurrencyManager } from '@fxa/payments/currency';
32+
import { AccountManager } from '@fxa/shared/account/account';
3133
import {
3234
CartEligibilityStatus,
3335
CartErrorReasonId,
3436
CartState,
3537
} from '@fxa/shared/db/mysql/account';
38+
import { SanitizeExceptions } from '@fxa/shared/error';
3639
import { GeoDBManager } from '@fxa/shared/geodb';
3740

41+
import {
42+
CartError,
43+
CartInvalidCurrencyError,
44+
CartInvalidPromoCodeError,
45+
CartInvalidStateForActionError,
46+
CartNotUpdatedError,
47+
CartStateProcessingError,
48+
CartSubscriptionNotFoundError,
49+
} from './cart.error';
3850
import { CartManager } from './cart.manager';
3951
import type {
4052
CartDTO,
@@ -48,20 +60,8 @@ import type {
4860
} from './cart.types';
4961
import { NeedsInputType } from './cart.types';
5062
import { handleEligibilityStatusMap } from './cart.utils';
51-
import { CheckoutService } from './checkout.service';
52-
import {
53-
CartError,
54-
CartInvalidCurrencyError,
55-
CartInvalidPromoCodeError,
56-
CartInvalidStateForActionError,
57-
CartNotUpdatedError,
58-
CartStateProcessingError,
59-
CartSubscriptionNotFoundError,
60-
} from './cart.error';
61-
import { AccountManager } from '@fxa/shared/account/account';
62-
import assert from 'assert';
6363
import { CheckoutFailedError } from './checkout.error';
64-
import { SanitizeExceptions } from '@fxa/shared/error';
64+
import { CheckoutService } from './checkout.service';
6565

6666
// TODO - Add flow to handle situations where currency is not found
6767
const DEFAULT_CURRENCY = 'USD';
@@ -256,7 +256,8 @@ export class CartService {
256256
),
257257
]);
258258

259-
const cartEligibilityStatus = handleEligibilityStatusMap[eligibility];
259+
const cartEligibilityStatus =
260+
handleEligibilityStatusMap[eligibility.subscriptionEligibilityResult];
260261

261262
if (args.promoCode) {
262263
try {
@@ -535,13 +536,39 @@ export class CartService {
535536
]);
536537
}
537538

538-
const upcomingInvoicePreview = await this.invoiceManager.previewUpcoming({
539-
priceId: price.id,
540-
currency: cart.currency || DEFAULT_CURRENCY,
541-
customer,
542-
taxAddress: cart.taxAddress || undefined,
543-
couponCode: cart.couponCode || undefined,
544-
});
539+
const eligibility = await this.eligibilityService.checkEligibility(
540+
cart.interval as SubplatInterval,
541+
cart.offeringConfigId,
542+
cart.stripeCustomerId
543+
);
544+
545+
const cartEligibilityStatus =
546+
handleEligibilityStatusMap[eligibility.subscriptionEligibilityResult];
547+
548+
let upcomingInvoicePreview: InvoicePreview | undefined;
549+
if (cartEligibilityStatus === CartEligibilityStatus.UPGRADE) {
550+
assert(
551+
'fromPrice' in eligibility,
552+
'fromPrice not present for upgrade cart'
553+
);
554+
upcomingInvoicePreview =
555+
await this.invoiceManager.previewUpcomingForUpgrade({
556+
priceId: price.id,
557+
currency: cart.currency || DEFAULT_CURRENCY,
558+
customer,
559+
taxAddress: cart.taxAddress || undefined,
560+
couponCode: cart.couponCode || undefined,
561+
fromPrice: eligibility.fromPrice,
562+
});
563+
} else {
564+
upcomingInvoicePreview = await this.invoiceManager.previewUpcoming({
565+
priceId: price.id,
566+
currency: cart.currency || DEFAULT_CURRENCY,
567+
customer,
568+
taxAddress: cart.taxAddress || undefined,
569+
couponCode: cart.couponCode || undefined,
570+
});
571+
}
545572

546573
let paymentInfo: PaymentInfo | undefined;
547574
if (customer?.invoice_settings.default_payment_method) {
@@ -609,6 +636,11 @@ export class CartService {
609636
latestInvoicePreview,
610637
metricsOptedOut,
611638
paymentInfo,
639+
fromOfferingConfigId:
640+
'fromOfferingConfigId' in eligibility
641+
? eligibility.fromOfferingConfigId
642+
: undefined,
643+
fromPrice: 'fromPrice' in eligibility ? eligibility.fromPrice : undefined,
612644
};
613645
}
614646

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

55
import { TaxAddress } from '@fxa/payments/customer';
6+
import { StripePrice } from '@fxa/payments/stripe';
67
import {
78
Cart,
89
CartEligibilityStatus,
@@ -64,6 +65,8 @@ export type BaseCartDTO = Omit<ResultCart, 'state'> & {
6465
upcomingInvoicePreview: Invoice;
6566
latestInvoicePreview?: Invoice;
6667
paymentInfo?: PaymentInfo;
68+
fromOfferingConfigId?: string;
69+
fromPrice?: StripePrice;
6770
};
6871

6972
export type StartCartDTO = BaseCartDTO & {

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -240,9 +240,9 @@ describe('CheckoutService', () => {
240240
.spyOn(accountCustomerManager, 'createAccountCustomer')
241241
.mockResolvedValue(mockAccountCustomer);
242242
jest.spyOn(cartManager, 'updateFreshCart').mockResolvedValue();
243-
jest
244-
.spyOn(eligibilityService, 'checkEligibility')
245-
.mockResolvedValue(EligibilityStatus.CREATE);
243+
jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({
244+
subscriptionEligibilityResult: EligibilityStatus.CREATE,
245+
});
246246
jest
247247
.spyOn(productConfigurationManager, 'retrieveStripePrice')
248248
.mockResolvedValue(mockPrice);

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,8 @@ export class CheckoutService {
143143
stripeCustomerId
144144
);
145145

146-
const cartEligibilityStatus = handleEligibilityStatusMap[eligibility];
146+
const cartEligibilityStatus =
147+
handleEligibilityStatusMap[eligibility.subscriptionEligibilityResult];
147148

148149
if (cartEligibilityStatus !== cart.eligibilityStatus) {
149150
throw new CartEligibilityMismatchError(

0 commit comments

Comments
 (0)