Skip to content

Commit

Permalink
Merge pull request #18304 from mozilla/FXA-11023
Browse files Browse the repository at this point in the history
feat(libs): Update libs for subscription upgrades
  • Loading branch information
xlisachan authored Feb 10, 2025
2 parents fd10a8f + 5889005 commit 67558a8
Show file tree
Hide file tree
Showing 14 changed files with 440 additions and 78 deletions.
104 changes: 94 additions & 10 deletions libs/payments/cart/src/lib/cart.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,9 +479,9 @@ describe('CartService', () => {
jest
.spyOn(invoiceManager, 'previewUpcoming')
.mockResolvedValue(mockInvoicePreview);
jest
.spyOn(eligibilityService, 'checkEligibility')
.mockResolvedValue(EligibilityStatus.CREATE);
jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({
subscriptionEligibilityResult: EligibilityStatus.CREATE,
});
});

it('calls createCart with expected parameters', async () => {
Expand Down Expand Up @@ -569,9 +569,9 @@ describe('CartService', () => {
.mockReturnValue(mockResolvedCurrency);
jest.spyOn(cartManager, 'createCart').mockResolvedValue(mockResultCart);
jest.spyOn(accountManager, 'getAccounts').mockResolvedValue([]);
jest
.spyOn(eligibilityService, 'checkEligibility')
.mockResolvedValue(EligibilityStatus.DOWNGRADE);
jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({
subscriptionEligibilityResult: EligibilityStatus.DOWNGRADE,
});
jest.spyOn(cartService, 'finalizeCartWithError').mockResolvedValue();

const result = await cartService.setupCart(args);
Expand Down Expand Up @@ -603,9 +603,9 @@ describe('CartService', () => {
.mockReturnValue(mockResolvedCurrency);
jest.spyOn(cartManager, 'createCart').mockResolvedValue(mockResultCart);
jest.spyOn(accountManager, 'getAccounts').mockResolvedValue([]);
jest
.spyOn(eligibilityService, 'checkEligibility')
.mockResolvedValue(EligibilityStatus.INVALID);
jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({
subscriptionEligibilityResult: EligibilityStatus.INVALID,
});
jest.spyOn(cartService, 'finalizeCartWithError').mockResolvedValue();

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

it('returns cart and upcomingInvoicePreview', async () => {
const mockCart = ResultCartFactory({
state: CartState.START,
stripeSubscriptionId: null,
eligibilityStatus: CartEligibilityStatus.CREATE,
});
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
const mockPrice = StripePriceFactory();
Expand All @@ -1056,6 +1058,9 @@ describe('CartService', () => {
jest
.spyOn(invoiceManager, 'previewUpcoming')
.mockResolvedValue(mockInvoicePreview);
jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({
subscriptionEligibilityResult: EligibilityStatus.CREATE,
});

const result = await cartService.getCart(mockCart.id);
expect(result).toEqual({
Expand Down Expand Up @@ -1088,6 +1093,7 @@ describe('CartService', () => {
it('returns cart and upcomingInvoicePreview and latestInvoicePreview', async () => {
const mockCart = ResultCartFactory({
stripeSubscriptionId: mockSubscription.id,
eligibilityStatus: CartEligibilityStatus.CREATE,
});
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
const mockPrice = StripePriceFactory();
Expand All @@ -1096,6 +1102,9 @@ describe('CartService', () => {
const mockPaymentMethod = StripeResponseFactory(
StripePaymentMethodFactory({})
);
jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({
subscriptionEligibilityResult: EligibilityStatus.CREATE,
});

jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
jest
Expand Down Expand Up @@ -1150,6 +1159,7 @@ describe('CartService', () => {
it('returns cart and upcomingInvoicePreview if customer is undefined', async () => {
const mockCart = ResultCartFactory({
stripeCustomerId: null,
eligibilityStatus: CartEligibilityStatus.CREATE,
});
const mockPrice = StripePriceFactory();
const mockInvoicePreview = InvoicePreviewFactory();
Expand All @@ -1162,6 +1172,9 @@ describe('CartService', () => {
jest
.spyOn(invoiceManager, 'previewUpcoming')
.mockResolvedValue(mockInvoicePreview);
jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({
subscriptionEligibilityResult: EligibilityStatus.CREATE,
});

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

it('returns cart with upgrade eligibility status', async () => {
const mockCart = ResultCartFactory({
state: CartState.START,
stripeSubscriptionId: null,
eligibilityStatus: CartEligibilityStatus.UPGRADE,
});
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
const mockPrice = StripePriceFactory();
const mockInvoicePreview = InvoicePreviewFactory();
const mockFromOfferingId = faker.string.uuid();
const mockFromPrice = StripePriceFactory();

jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
jest
.spyOn(productConfigurationManager, 'retrieveStripePrice')
.mockResolvedValue(mockPrice);
jest.spyOn(customerManager, 'retrieve').mockResolvedValue(mockCustomer);
jest
.spyOn(invoiceManager, 'previewUpcomingForUpgrade')
.mockResolvedValue(mockInvoicePreview);
jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({
subscriptionEligibilityResult: EligibilityStatus.UPGRADE,
fromOfferingConfigId: mockFromOfferingId,
fromPrice: mockFromPrice,
});

const result = await cartService.getCart(mockCart.id);
expect(result).toEqual({
...mockCart,
upcomingInvoicePreview: mockInvoicePreview,
paymentInfo: {
type: mockPaymentMethod.type,
last4: mockPaymentMethod.card?.last4,
brand: mockPaymentMethod.card?.brand,
customerSessionClientSecret: mockCustomerSession.client_secret,
},
metricsOptedOut: false,
fromOfferingConfigId: mockFromOfferingId,
fromPrice: mockFromPrice,
});

expect(cartManager.fetchCartById).toHaveBeenCalledWith(mockCart.id);
expect(
productConfigurationManager.retrieveStripePrice
).toHaveBeenCalledWith(mockCart.offeringConfigId, mockCart.interval);
expect(customerManager.retrieve).toHaveBeenCalledWith(
mockCart.stripeCustomerId
);
expect(invoiceManager.previewUpcomingForUpgrade).toHaveBeenCalledWith({
priceId: mockPrice.id,
currency: mockCart.currency,
customer: mockCustomer,
taxAddress: mockCart.taxAddress,
fromPrice: mockFromPrice,
});
});

it("has metricsOptedOut set to true if the cart's account has opted out of metrics", async () => {
const mockUid = faker.string.hexadecimal({
length: 32,
Expand All @@ -1196,6 +1266,7 @@ describe('CartService', () => {
const mockCart = ResultCartFactory({
uid: mockUid,
stripeSubscriptionId: null,
eligibilityStatus: CartEligibilityStatus.CREATE,
});
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
const mockPrice = StripePriceFactory();
Expand All @@ -1212,6 +1283,9 @@ describe('CartService', () => {
jest
.spyOn(accountManager, 'getAccounts')
.mockResolvedValue([mockAccount]);
jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({
subscriptionEligibilityResult: EligibilityStatus.CREATE,
});

const result = await cartService.getCart(mockCart.id);
expect(accountManager.getAccounts).toHaveBeenCalledWith([mockUid]);
Expand All @@ -1230,6 +1304,7 @@ describe('CartService', () => {
const mockCart = ResultCartFactory({
uid: mockUid,
stripeSubscriptionId: null,
eligibilityStatus: CartEligibilityStatus.CREATE,
});
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
const mockPrice = StripePriceFactory();
Expand All @@ -1246,14 +1321,20 @@ describe('CartService', () => {
jest
.spyOn(accountManager, 'getAccounts')
.mockResolvedValue([mockAccount]);
jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({
subscriptionEligibilityResult: EligibilityStatus.CREATE,
});

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

it('has metricsOptedOut set to false if the cart has no associated account', async () => {
const mockCart = ResultCartFactory({ stripeSubscriptionId: null });
const mockCart = ResultCartFactory({
stripeSubscriptionId: null,
eligibilityStatus: CartEligibilityStatus.CREATE,
});
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
const mockPrice = StripePriceFactory();
const mockInvoicePreview = InvoicePreviewFactory();
Expand All @@ -1267,6 +1348,9 @@ describe('CartService', () => {
.spyOn(invoiceManager, 'previewUpcoming')
.mockResolvedValue(mockInvoicePreview);
jest.spyOn(accountManager, 'getAccounts').mockResolvedValue([]);
jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({
subscriptionEligibilityResult: EligibilityStatus.CREATE,
});

const result = await cartService.getCart(mockCart.id);
expect(accountManager.getAccounts).not.toHaveBeenCalled();
Expand Down
74 changes: 53 additions & 21 deletions libs/payments/cart/src/lib/cart.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import { Injectable } from '@nestjs/common';
import * as Sentry from '@sentry/node';
import assert from 'assert';

import {
CustomerManager,
Expand All @@ -28,13 +29,24 @@ import {
} from '@fxa/payments/stripe';
import { ProductConfigurationManager } from '@fxa/shared/cms';
import { CurrencyManager } from '@fxa/payments/currency';
import { AccountManager } from '@fxa/shared/account/account';
import {
CartEligibilityStatus,
CartErrorReasonId,
CartState,
} from '@fxa/shared/db/mysql/account';
import { SanitizeExceptions } from '@fxa/shared/error';
import { GeoDBManager } from '@fxa/shared/geodb';

import {
CartError,
CartInvalidCurrencyError,
CartInvalidPromoCodeError,
CartInvalidStateForActionError,
CartNotUpdatedError,
CartStateProcessingError,
CartSubscriptionNotFoundError,
} from './cart.error';
import { CartManager } from './cart.manager';
import type {
CartDTO,
Expand All @@ -48,20 +60,8 @@ import type {
} from './cart.types';
import { NeedsInputType } from './cart.types';
import { handleEligibilityStatusMap } from './cart.utils';
import { CheckoutService } from './checkout.service';
import {
CartError,
CartInvalidCurrencyError,
CartInvalidPromoCodeError,
CartInvalidStateForActionError,
CartNotUpdatedError,
CartStateProcessingError,
CartSubscriptionNotFoundError,
} from './cart.error';
import { AccountManager } from '@fxa/shared/account/account';
import assert from 'assert';
import { CheckoutFailedError } from './checkout.error';
import { SanitizeExceptions } from '@fxa/shared/error';
import { CheckoutService } from './checkout.service';

// TODO - Add flow to handle situations where currency is not found
const DEFAULT_CURRENCY = 'USD';
Expand Down Expand Up @@ -256,7 +256,8 @@ export class CartService {
),
]);

const cartEligibilityStatus = handleEligibilityStatusMap[eligibility];
const cartEligibilityStatus =
handleEligibilityStatusMap[eligibility.subscriptionEligibilityResult];

if (args.promoCode) {
try {
Expand Down Expand Up @@ -535,13 +536,39 @@ export class CartService {
]);
}

const upcomingInvoicePreview = await this.invoiceManager.previewUpcoming({
priceId: price.id,
currency: cart.currency || DEFAULT_CURRENCY,
customer,
taxAddress: cart.taxAddress || undefined,
couponCode: cart.couponCode || undefined,
});
const eligibility = await this.eligibilityService.checkEligibility(
cart.interval as SubplatInterval,
cart.offeringConfigId,
cart.stripeCustomerId
);

const cartEligibilityStatus =
handleEligibilityStatusMap[eligibility.subscriptionEligibilityResult];

let upcomingInvoicePreview: InvoicePreview | undefined;
if (cartEligibilityStatus === CartEligibilityStatus.UPGRADE) {
assert(
'fromPrice' in eligibility,
'fromPrice not present for upgrade cart'
);
upcomingInvoicePreview =
await this.invoiceManager.previewUpcomingForUpgrade({
priceId: price.id,
currency: cart.currency || DEFAULT_CURRENCY,
customer,
taxAddress: cart.taxAddress || undefined,
couponCode: cart.couponCode || undefined,
fromPrice: eligibility.fromPrice,
});
} else {
upcomingInvoicePreview = await this.invoiceManager.previewUpcoming({
priceId: price.id,
currency: cart.currency || DEFAULT_CURRENCY,
customer,
taxAddress: cart.taxAddress || undefined,
couponCode: cart.couponCode || undefined,
});
}

let paymentInfo: PaymentInfo | undefined;
if (customer?.invoice_settings.default_payment_method) {
Expand Down Expand Up @@ -609,6 +636,11 @@ export class CartService {
latestInvoicePreview,
metricsOptedOut,
paymentInfo,
fromOfferingConfigId:
'fromOfferingConfigId' in eligibility
? eligibility.fromOfferingConfigId
: undefined,
fromPrice: 'fromPrice' in eligibility ? eligibility.fromPrice : undefined,
};
}

Expand Down
3 changes: 3 additions & 0 deletions libs/payments/cart/src/lib/cart.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { TaxAddress } from '@fxa/payments/customer';
import { StripePrice } from '@fxa/payments/stripe';
import {
Cart,
CartEligibilityStatus,
Expand Down Expand Up @@ -64,6 +65,8 @@ export type BaseCartDTO = Omit<ResultCart, 'state'> & {
upcomingInvoicePreview: Invoice;
latestInvoicePreview?: Invoice;
paymentInfo?: PaymentInfo;
fromOfferingConfigId?: string;
fromPrice?: StripePrice;
};

export type StartCartDTO = BaseCartDTO & {
Expand Down
6 changes: 3 additions & 3 deletions libs/payments/cart/src/lib/checkout.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,9 +240,9 @@ describe('CheckoutService', () => {
.spyOn(accountCustomerManager, 'createAccountCustomer')
.mockResolvedValue(mockAccountCustomer);
jest.spyOn(cartManager, 'updateFreshCart').mockResolvedValue();
jest
.spyOn(eligibilityService, 'checkEligibility')
.mockResolvedValue(EligibilityStatus.CREATE);
jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({
subscriptionEligibilityResult: EligibilityStatus.CREATE,
});
jest
.spyOn(productConfigurationManager, 'retrieveStripePrice')
.mockResolvedValue(mockPrice);
Expand Down
3 changes: 2 additions & 1 deletion libs/payments/cart/src/lib/checkout.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,8 @@ export class CheckoutService {
stripeCustomerId
);

const cartEligibilityStatus = handleEligibilityStatusMap[eligibility];
const cartEligibilityStatus =
handleEligibilityStatusMap[eligibility.subscriptionEligibilityResult];

if (cartEligibilityStatus !== cart.eligibilityStatus) {
throw new CartEligibilityMismatchError(
Expand Down
Loading

0 comments on commit 67558a8

Please sign in to comment.