From 3e2626683a8f30c0254cab5f5a959e1716883f75 Mon Sep 17 00:00:00 2001 From: Lisa Chan Date: Tue, 14 Jan 2025 14:50:45 -0500 Subject: [PATCH] Update based on feedback --- .../[interval]/upgrade/[cartId]/layout.tsx | 2 +- .../cart/src/lib/cart.service.spec.ts | 136 +++++++++++------ libs/payments/cart/src/lib/cart.service.ts | 142 ++++++++++++------ libs/payments/cart/src/lib/cart.types.ts | 8 +- .../payments/cart/src/lib/checkout.service.ts | 7 - .../customer/src/lib/invoice.manager.spec.ts | 93 ++++++++---- .../customer/src/lib/invoice.manager.ts | 129 +++++++++++----- .../stripeInvoiceToInvoicePreviewDTO.spec.ts | 45 +----- .../util/stripeInvoiceToInvoicePreviewDTO.ts | 30 ++-- .../src/lib/eligibility.manager.spec.ts | 14 +- .../src/lib/eligibility.manager.ts | 4 +- .../src/lib/eligibility.service.ts | 2 +- .../client/components/PurchaseDetails/en.ftl | 5 - .../components/PurchaseDetails/index.tsx | 60 ++------ .../UpgradePurchaseDetails/index.tsx | 80 +++------- 15 files changed, 398 insertions(+), 359 deletions(-) diff --git a/apps/payments/next/app/[locale]/[offeringId]/[interval]/upgrade/[cartId]/layout.tsx b/apps/payments/next/app/[locale]/[offeringId]/[interval]/upgrade/[cartId]/layout.tsx index c55eed252ec..feb11e99cfe 100644 --- a/apps/payments/next/app/[locale]/[offeringId]/[interval]/upgrade/[cartId]/layout.tsx +++ b/apps/payments/next/app/[locale]/[offeringId]/[interval]/upgrade/[cartId]/layout.tsx @@ -58,7 +58,7 @@ export default async function UpgradeLayout({ l10n={l10n} interval={cart.interval} invoice={cart.upcomingInvoicePreview} - currentPrice={cart.eligibleSourcePrice} + currentPrice={cart.upgradeFromPrice} currentPurchaseDetails={currentPurchaseDetails} purchaseDetails={purchaseDetails} /> diff --git a/libs/payments/cart/src/lib/cart.service.spec.ts b/libs/payments/cart/src/lib/cart.service.spec.ts index 904e32b33fb..c0db31f3452 100644 --- a/libs/payments/cart/src/lib/cart.service.spec.ts +++ b/libs/payments/cart/src/lib/cart.service.spec.ts @@ -83,6 +83,7 @@ import { CartManager } from './cart.manager'; import { CartService } from './cart.service'; import { CheckoutService } from './checkout.service'; import { + CartEligibilityMismatchError, CartError, CartInvalidCurrencyError, CartInvalidPromoCodeError, @@ -90,6 +91,7 @@ import { CartStateProcessingError, CartSubscriptionNotFoundError, CartSuccessMissingRequired, + CartUpgradeMissingRequired, CartUpgradeNotValid, } from './cart.error'; import { CurrencyManager } from '@fxa/payments/currency'; @@ -687,7 +689,6 @@ describe('CartService', () => { it('returns cart and upcomingInvoicePreview', async () => { const mockCart = ResultCartFactory({ stripeSubscriptionId: null, - eligibilityStatus: CartEligibilityStatus.CREATE, }); const mockCustomer = StripeResponseFactory(StripeCustomerFactory()); const mockPrice = StripePriceFactory(); @@ -698,9 +699,6 @@ describe('CartService', () => { .spyOn(productConfigurationManager, 'retrieveStripePrice') .mockResolvedValue(mockPrice); jest.spyOn(customerManager, 'retrieve').mockResolvedValue(mockCustomer); - jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({ - subscriptionEligibilityResult: EligibilityStatus.CREATE, - }); jest .spyOn(invoiceManager, 'previewUpcoming') .mockResolvedValue(mockInvoicePreview); @@ -724,7 +722,6 @@ describe('CartService', () => { currency: mockCart.currency, customer: mockCustomer, taxAddress: mockCart.taxAddress, - isUpgrade: false, }); }); @@ -734,7 +731,6 @@ describe('CartService', () => { ); const mockCart = ResultCartFactory({ stripeSubscriptionId: mockSubscription.id, - eligibilityStatus: CartEligibilityStatus.CREATE, }); const mockCustomer = StripeResponseFactory(StripeCustomerFactory()); const mockPrice = StripePriceFactory(); @@ -761,9 +757,6 @@ describe('CartService', () => { jest .spyOn(paymentMethodManager, 'retrieve') .mockResolvedValue(mockPaymentMethod); - jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({ - subscriptionEligibilityResult: EligibilityStatus.CREATE, - }); const result = await cartService.getCart(mockCart.id); expect(result).toEqual({ @@ -793,7 +786,6 @@ describe('CartService', () => { currency: mockCart.currency, customer: mockCustomer, taxAddress: mockCart.taxAddress, - isUpgrade: false, }); expect(invoiceManager.preview).toHaveBeenCalledWith( mockSubscription.latest_invoice @@ -803,7 +795,6 @@ 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(); @@ -816,9 +807,6 @@ 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({ @@ -837,7 +825,6 @@ describe('CartService', () => { currency: mockCart.currency, customer: undefined, taxAddress: mockCart.taxAddress, - isUpgrade: false, }); }); @@ -854,7 +841,6 @@ describe('CartService', () => { const mockCart = ResultCartFactory({ uid: mockUid, stripeSubscriptionId: null, - eligibilityStatus: CartEligibilityStatus.CREATE, }); const mockCustomer = StripeResponseFactory(StripeCustomerFactory()); const mockPrice = StripePriceFactory(); @@ -871,9 +857,6 @@ 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]); @@ -892,7 +875,6 @@ describe('CartService', () => { const mockCart = ResultCartFactory({ uid: mockUid, stripeSubscriptionId: null, - eligibilityStatus: CartEligibilityStatus.CREATE, }); const mockCustomer = StripeResponseFactory(StripeCustomerFactory()); const mockPrice = StripePriceFactory(); @@ -909,9 +891,6 @@ 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]); @@ -921,7 +900,6 @@ describe('CartService', () => { it('has metricsOptedOut set to false if the cart has no associated account', async () => { const mockCart = ResultCartFactory({ stripeSubscriptionId: null, - eligibilityStatus: CartEligibilityStatus.CREATE, }); const mockCustomer = StripeResponseFactory(StripeCustomerFactory()); const mockPrice = StripePriceFactory(); @@ -936,15 +914,14 @@ 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(); expect(result.metricsOptedOut).toBeFalsy(); }); + }); + describe('getUpgradeCart', () => { it('returns cart with current plan and offering id', async () => { const mockCart = ResultCartFactory({ stripeSubscriptionId: null, @@ -952,7 +929,10 @@ describe('CartService', () => { }); const mockCustomer = StripeResponseFactory(StripeCustomerFactory()); const mockPrice = StripePriceFactory(); - const mockInvoicePreview = InvoicePreviewFactory(); + const mockInvoicePreview = InvoicePreviewFactory({ + oneTimeCharge: 4500, + proratedAmount: -500, + }); const mockCurrentPrice = StripePriceFactory(); const mockCurrentOffering = PageContentOfferingTransformedFactory(); @@ -961,27 +941,29 @@ describe('CartService', () => { .spyOn(productConfigurationManager, 'retrieveStripePrice') .mockResolvedValue(mockPrice); jest.spyOn(customerManager, 'retrieve').mockResolvedValue(mockCustomer); - jest - .spyOn(invoiceManager, 'previewUpcoming') - .mockResolvedValue(mockInvoicePreview); jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({ subscriptionEligibilityResult: EligibilityStatus.UPGRADE, - eligibleSourcePrice: mockCurrentPrice, fromOfferingConfigId: mockCurrentOffering.apiIdentifier, + upgradeFromPrice: mockCurrentPrice, }); + jest + .spyOn(invoiceManager, 'previewUpcomingForUpgrade') + .mockResolvedValue(mockInvoicePreview); - const result = await cartService.getCart(mockCart.id); + const result = await cartService.getUpgradeCart(mockCart.id); expect(result).toEqual({ ...mockCart, upcomingInvoicePreview: mockInvoicePreview, metricsOptedOut: false, eligibilityStatus: CartEligibilityStatus.UPGRADE, - eligibleSourcePrice: { + fromOfferingConfigId: mockCurrentOffering.apiIdentifier, + oneTimeCharge: 4500, + proratedAmount: -500, + upgradeFromPrice: { currency: mockCurrentPrice.currency, interval: mockCurrentPrice.recurring?.interval, listAmount: mockCurrentPrice.unit_amount, }, - fromOfferingConfigId: mockCurrentOffering.apiIdentifier, }); expect(cartManager.fetchCartById).toHaveBeenCalledWith(mockCart.id); @@ -991,44 +973,104 @@ describe('CartService', () => { expect(customerManager.retrieve).toHaveBeenCalledWith( mockCart.stripeCustomerId ); - expect(invoiceManager.previewUpcoming).toHaveBeenCalledWith({ + expect(invoiceManager.previewUpcomingForUpgrade).toHaveBeenCalledWith({ priceId: mockPrice.id, currency: mockCart.currency, customer: mockCustomer, taxAddress: mockCart.taxAddress, - isUpgrade: true, - sourcePrice: mockCurrentPrice, + upgradeFromPrice: mockCurrentPrice, }); }); - it('throws error if eligibility is upgrade, but no current plan', async () => { + it('throws error if eligibility status is not upgrade for getUpgradeCart', async () => { + const mockCart = ResultCartFactory({ + stripeSubscriptionId: null, + eligibilityStatus: CartEligibilityStatus.CREATE, + }); + const mockCustomer = StripeResponseFactory(StripeCustomerFactory()); + const mockPrice = StripePriceFactory(); + const mockInvoicePreview = InvoicePreviewFactory({ + oneTimeCharge: 4500, + proratedAmount: -500, + }); + + jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart); + jest + .spyOn(productConfigurationManager, 'retrieveStripePrice') + .mockResolvedValue(mockPrice); + jest.spyOn(customerManager, 'retrieve').mockResolvedValue(mockCustomer); + jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({ + subscriptionEligibilityResult: EligibilityStatus.CREATE, + }); + jest + .spyOn(invoiceManager, 'previewUpcomingForUpgrade') + .mockResolvedValue(mockInvoicePreview); + + await expect( + cartService.getUpgradeCart(mockCart.id) + ).rejects.toThrowError(CartEligibilityMismatchError); + }); + + it('throws error if upgrade is missing offering id or price from current plan', async () => { const mockCart = ResultCartFactory({ stripeSubscriptionId: null, eligibilityStatus: CartEligibilityStatus.UPGRADE, }); const mockCustomer = StripeResponseFactory(StripeCustomerFactory()); const mockPrice = StripePriceFactory(); - const mockInvoicePreview = InvoicePreviewFactory(); - const mockCurrentPrice = StripePriceFactory(); - const mockCurrentOffering = PageContentOfferingTransformedFactory(); + const mockInvoicePreview = InvoicePreviewFactory({ + oneTimeCharge: 4500, + proratedAmount: -500, + }); jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart); jest .spyOn(productConfigurationManager, 'retrieveStripePrice') .mockResolvedValue(mockPrice); jest.spyOn(customerManager, 'retrieve').mockResolvedValue(mockCustomer); + jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({ + subscriptionEligibilityResult: EligibilityStatus.UPGRADE, + }); jest - .spyOn(invoiceManager, 'previewUpcoming') + .spyOn(invoiceManager, 'previewUpcomingForUpgrade') .mockResolvedValue(mockInvoicePreview); + + await expect( + cartService.getUpgradeCart(mockCart.id) + ).rejects.toThrowError(CartUpgradeNotValid); + }); + + it('throws error if upgrade is missing oneTimeCharge or proratedAmount from invoice', async () => { + const mockCart = ResultCartFactory({ + stripeSubscriptionId: null, + eligibilityStatus: CartEligibilityStatus.UPGRADE, + }); + const mockCustomer = StripeResponseFactory(StripeCustomerFactory()); + const mockPrice = StripePriceFactory(); + const mockInvoicePreview = InvoicePreviewFactory({ + oneTimeCharge: undefined, + proratedAmount: undefined, + }); + const mockCurrentPrice = StripePriceFactory(); + const mockCurrentOffering = PageContentOfferingTransformedFactory(); + + jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart); + jest + .spyOn(productConfigurationManager, 'retrieveStripePrice') + .mockResolvedValue(mockPrice); + jest.spyOn(customerManager, 'retrieve').mockResolvedValue(mockCustomer); jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({ subscriptionEligibilityResult: EligibilityStatus.UPGRADE, fromOfferingConfigId: mockCurrentOffering.apiIdentifier, - eligibleSourcePrice: { ...mockCurrentPrice, recurring: null }, + upgradeFromPrice: mockCurrentPrice, }); + jest + .spyOn(invoiceManager, 'previewUpcomingForUpgrade') + .mockResolvedValue(mockInvoicePreview); - await expect(cartService.getCart(mockCart.id)).rejects.toThrowError( - CartUpgradeNotValid - ); + await expect( + cartService.getUpgradeCart(mockCart.id) + ).rejects.toThrowError(CartUpgradeMissingRequired); }); }); diff --git a/libs/payments/cart/src/lib/cart.service.ts b/libs/payments/cart/src/lib/cart.service.ts index a59e2a90366..4b56f8ac3f6 100644 --- a/libs/payments/cart/src/lib/cart.service.ts +++ b/libs/payments/cart/src/lib/cart.service.ts @@ -32,7 +32,6 @@ import { GeoDBManager } from '@fxa/shared/geodb'; import { CartManager } from './cart.manager'; import { CheckoutCustomerData, - CurrentPrice, GetNeedsInputResponse, NeedsInputType, NoInputNeededResponse, @@ -47,6 +46,7 @@ import { import { handleEligibilityStatusMap } from './cart.utils'; import { CheckoutService } from './checkout.service'; import { + CartEligibilityMismatchError, CartError, CartInvalidCurrencyError, CartInvalidPromoCodeError, @@ -432,42 +432,12 @@ export class CartService { customer = await this.customerManager.retrieve(cart.stripeCustomerId); } - const eligibility = await this.eligibilityService.checkEligibility( - cart.interval as SubplatInterval, - cart.offeringConfigId, - cart.stripeCustomerId - ); - - const cartEligibilityStatus = - handleEligibilityStatusMap[eligibility.subscriptionEligibilityResult]; - - let currentPrice: CurrentPrice | undefined; - if (cartEligibilityStatus === CartEligibilityStatus.UPGRADE) { - if ( - !eligibility.eligibleSourcePrice || - !eligibility.eligibleSourcePrice.recurring || - !eligibility.eligibleSourcePrice.unit_amount - ) - throw new CartUpgradeNotValid(cartId); - currentPrice = { - currency: eligibility.eligibleSourcePrice.currency, - interval: eligibility.eligibleSourcePrice.recurring?.interval, - listAmount: eligibility.eligibleSourcePrice.unit_amount, - }; - } - const upcomingInvoicePreview = await this.invoiceManager.previewUpcoming({ priceId: price.id, currency: cart.currency || DEFAULT_CURRENCY, customer, taxAddress: cart.taxAddress || undefined, couponCode: cart.couponCode || undefined, - isUpgrade: - cartEligibilityStatus === CartEligibilityStatus.UPGRADE ? true : false, - sourcePrice: - cartEligibilityStatus === CartEligibilityStatus.UPGRADE - ? eligibility.eligibleSourcePrice - : undefined, }); // Cart latest invoice data @@ -511,11 +481,6 @@ export class CartService { metricsOptedOut, latestInvoicePreview, paymentInfo, - eligibilityStatus: cartEligibilityStatus, - eligibleSourcePrice: currentPrice, - fromOfferingConfigId: eligibility.fromOfferingConfigId, - proratedAmount: upcomingInvoicePreview.proratedAmount || undefined, - oneTimeCharge: upcomingInvoicePreview.oneTimeCharge || undefined, }; } @@ -548,26 +513,111 @@ export class CartService { * Fetch a upgrade cart */ async getUpgradeCart(cartId: string): Promise { - const cart = await this.getCart(cartId); + const cart = await this.cartManager.fetchCartById(cartId); if (cart.eligibilityStatus !== CartEligibilityStatus.UPGRADE) - throw new Error(); + throw new CartEligibilityMismatchError( + cartId, + cart.eligibilityStatus, + CartEligibilityStatus.UPGRADE + ); + + const [price, metricsOptedOut] = await Promise.all([ + this.productConfigurationManager.retrieveStripePrice( + cart.offeringConfigId, + cart.interval as SubplatInterval + ), + this.metricsOptedOut(cart.uid), + ]); + + let customer: StripeCustomer | undefined; + if (cart.stripeCustomerId) { + customer = await this.customerManager.retrieve(cart.stripeCustomerId); + } + + const { fromOfferingConfigId, upgradeFromPrice } = + await this.eligibilityService.checkEligibility( + cart.interval as SubplatInterval, + cart.offeringConfigId, + cart.stripeCustomerId + ); + + if ( + !fromOfferingConfigId || + !upgradeFromPrice || + !upgradeFromPrice.recurring || + !upgradeFromPrice.unit_amount + ) + throw new CartUpgradeNotValid(cartId); + + const upcomingInvoicePreview = + await this.invoiceManager.previewUpcomingForUpgrade({ + priceId: price.id, + currency: cart.currency || DEFAULT_CURRENCY, + customer, + taxAddress: cart.taxAddress || undefined, + couponCode: cart.couponCode || undefined, + upgradeFromPrice, + }); if ( - !cart.eligibleSourcePrice || - !cart.fromOfferingConfigId || - !cart.proratedAmount || - !cart.oneTimeCharge + !upcomingInvoicePreview.proratedAmount || + !upcomingInvoicePreview.oneTimeCharge ) { throw new CartUpgradeMissingRequired(cartId); } + // Cart latest invoice data + let latestInvoicePreview: InvoicePreview | undefined; + let paymentInfo: PaymentInfo | undefined; + if (customer && cart.stripeSubscriptionId) { + // fetch latest payment info from subscription + const subscription = await this.subscriptionManager.retrieve( + cart.stripeSubscriptionId + ); + assert(subscription.latest_invoice, 'Subscription not found'); + latestInvoicePreview = await this.invoiceManager.preview( + subscription.latest_invoice + ); + + // fetch payment method info + if (subscription.collection_method === 'send_invoice') { + // PayPal payment method collection + // TODO: render paypal payment info in the UI (FXA-10608) + paymentInfo = { + type: 'external_paypal', + }; + } else { + // Stripe payment method collection + if (customer.invoice_settings.default_payment_method) { + const paymentMethod = await this.paymentMethodManager.retrieve( + customer.invoice_settings.default_payment_method + ); + paymentInfo = { + type: paymentMethod.type, + last4: paymentMethod.card?.last4, + brand: paymentMethod.card?.brand, + }; + } + } + } + + const currentPrice = { + currency: upgradeFromPrice.currency, + interval: upgradeFromPrice.recurring?.interval, + listAmount: upgradeFromPrice.unit_amount, + }; + return { ...cart, - eligibleSourcePrice: cart.eligibleSourcePrice, - fromOfferingConfigId: cart.fromOfferingConfigId, - proratedAmount: cart.proratedAmount, - oneTimeCharge: cart.oneTimeCharge, + upcomingInvoicePreview, + metricsOptedOut, + latestInvoicePreview, + paymentInfo, + fromOfferingConfigId: fromOfferingConfigId, + proratedAmount: upcomingInvoicePreview.proratedAmount, + oneTimeCharge: upcomingInvoicePreview.oneTimeCharge, + upgradeFromPrice: currentPrice, }; } diff --git a/libs/payments/cart/src/lib/cart.types.ts b/libs/payments/cart/src/lib/cart.types.ts index cb05a593d83..0cf4afa65ce 100644 --- a/libs/payments/cart/src/lib/cart.types.ts +++ b/libs/payments/cart/src/lib/cart.types.ts @@ -74,10 +74,10 @@ export type WithContextCart = ResultCart & { upcomingInvoicePreview: Invoice; latestInvoicePreview?: Invoice; paymentInfo?: PaymentInfo; - eligibleSourcePrice?: CurrentPrice; fromOfferingConfigId?: string; - proratedAmount?: number; oneTimeCharge?: number; + proratedAmount?: number; + upgradeFromPrice?: CurrentPrice; }; export type SuccessCart = WithContextCart & { @@ -86,10 +86,10 @@ export type SuccessCart = WithContextCart & { }; export type UpgradeCart = WithContextCart & { - eligibleSourcePrice: CurrentPrice; fromOfferingConfigId: string; - proratedAmount: number; oneTimeCharge: number; + proratedAmount: number; + upgradeFromPrice: CurrentPrice; }; export type SetupCart = { diff --git a/libs/payments/cart/src/lib/checkout.service.ts b/libs/payments/cart/src/lib/checkout.service.ts index 8e237b9cc41..cbf182527a5 100644 --- a/libs/payments/cart/src/lib/checkout.service.ts +++ b/libs/payments/cart/src/lib/checkout.service.ts @@ -29,7 +29,6 @@ import { } from '@fxa/payments/stripe'; import { ProfileClient } from '@fxa/profile/client'; import { ProductConfigurationManager } from '@fxa/shared/cms'; -import { CartEligibilityStatus } from '@fxa/shared/db/mysql/account'; import { StatsDService } from '@fxa/shared/metrics/statsd'; import { NotifierService } from '@fxa/shared/notifier'; import { @@ -167,12 +166,6 @@ export class CheckoutService { currency: cart.currency, customer: customer, taxAddress: taxAddress, - isUpgrade: - cartEligibilityStatus === CartEligibilityStatus.UPGRADE ? true : false, - sourcePrice: - cartEligibilityStatus === CartEligibilityStatus.UPGRADE - ? eligibility.eligibleSourcePrice - : undefined, }); if (upcomingInvoice.subtotal !== cart.amount) { diff --git a/libs/payments/customer/src/lib/invoice.manager.spec.ts b/libs/payments/customer/src/lib/invoice.manager.spec.ts index ca46f070db0..4e2729b44ba 100644 --- a/libs/payments/customer/src/lib/invoice.manager.spec.ts +++ b/libs/payments/customer/src/lib/invoice.manager.spec.ts @@ -12,6 +12,7 @@ import { StripeCouponFactory, StripeCustomerFactory, StripeInvoiceFactory, + StripeInvoiceLineItemFactory, StripePriceFactory, StripePromotionCodeFactory, StripeResponseFactory, @@ -147,6 +148,65 @@ describe('InvoiceManager', () => { }); }); + describe('previewUpcomingForUpgrade', () => { + it('returns upcoming invoice for upgrade', async () => { + const mockCustomer = StripeCustomerFactory(); + const mockPrice = StripePriceFactory(); + const mockUpcomingInvoice = StripeResponseFactory( + StripeUpcomingInvoiceFactory({ + lines: { + object: 'list', + data: [ + StripeInvoiceLineItemFactory({ + amount: -500, + proration: true, + }), + StripeInvoiceLineItemFactory({ + amount: 5000, + proration: false, + }), + ], + has_more: false, + url: faker.internet.url(), + }, + total: 4500, + }) + ); + const mockTaxAddress = TaxAddressFactory(); + const mockInvoicePreview = InvoicePreviewFactory(); + const mockUpgradeFromPrice = StripePriceFactory(); + const mockSubscription = StripeResponseFactory( + StripeSubscriptionFactory() + ); + const mockSubscriptionList = StripeApiListFactory([mockSubscription]); + + jest + .spyOn(stripeClient, 'subscriptionsList') + .mockResolvedValue(mockSubscriptionList); + jest + .spyOn(stripeClient, 'invoicesRetrieveUpcoming') + .mockResolvedValue(mockUpcomingInvoice); + + mockedStripeInvoiceToInvoicePreviewDTO.mockReturnValue( + mockInvoicePreview + ); + + const result = await invoiceManager.previewUpcomingForUpgrade({ + priceId: mockPrice.id, + currency: faker.finance.currencyCode(), + customer: mockCustomer, + taxAddress: mockTaxAddress, + upgradeFromPrice: mockUpgradeFromPrice, + }); + + expect(result).toEqual({ + ...mockInvoicePreview, + oneTimeCharge: 4500, + proratedAmount: -500, + }); + }); + }); + describe('retrieve', () => { it('retrieves an invoice', async () => { const mockInvoice = StripeResponseFactory(StripeInvoiceFactory()); @@ -548,37 +608,4 @@ describe('InvoiceManager', () => { expect(stripeClient.invoicesPay).toHaveBeenCalledWith(mockInvoice.id); }); }); - - describe('getProratedInvoice', () => { - it('returns a prorated invoice', async () => { - const mockCustomer = StripeCustomerFactory(); - const mockPrice = StripePriceFactory(); - const mockRequestObject = { - subscription_items: [{ price: mockPrice.id }], - }; - const mockSubscription = StripeApiListFactory([ - StripeSubscriptionFactory(), - ]); - const mockUpcomingInvoice = StripeResponseFactory( - StripeUpcomingInvoiceFactory() - ); - - jest - .spyOn(stripeClient, 'subscriptionsList') - .mockResolvedValue(mockSubscription); - jest - .spyOn(stripeClient, 'invoicesRetrieveUpcoming') - .mockResolvedValue(mockUpcomingInvoice); - - const result = await invoiceManager.getProratedInvoice( - mockRequestObject, - mockCustomer, - mockPrice - ); - expect(result).toEqual(mockUpcomingInvoice); - expect(stripeClient.subscriptionsList).toHaveBeenCalledWith({ - customer: mockCustomer.id, - }); - }); - }); }); diff --git a/libs/payments/customer/src/lib/invoice.manager.ts b/libs/payments/customer/src/lib/invoice.manager.ts index b23fee705c3..5ea569a54c9 100644 --- a/libs/payments/customer/src/lib/invoice.manager.ts +++ b/libs/payments/customer/src/lib/invoice.manager.ts @@ -52,16 +52,12 @@ export class InvoiceManager { customer, taxAddress, couponCode, - isUpgrade, - sourcePrice, }: { priceId: string; currency: string; customer?: StripeCustomer; taxAddress?: TaxAddress; couponCode?: string; - isUpgrade?: boolean; - sourcePrice?: StripePrice; }): Promise { let promoCode: StripePromotionCode | undefined; if (couponCode) { @@ -105,18 +101,97 @@ export class InvoiceManager { requestObject ); - const invoices = [upcomingInvoice]; + return stripeInvoiceToInvoicePreviewDTO(upcomingInvoice); + } - let proratedInvoice; - if (isUpgrade) { - proratedInvoice = await this.getProratedInvoice( - requestObject, - customer, - sourcePrice - ); - if (proratedInvoice) invoices.push(proratedInvoice); + async previewUpcomingForUpgrade({ + priceId, + currency, + customer, + taxAddress, + couponCode, + upgradeFromPrice, + }: { + priceId: string; + currency: string; + customer?: StripeCustomer; + taxAddress?: TaxAddress; + couponCode?: string; + upgradeFromPrice?: StripePrice; + }): Promise { + let promoCode: StripePromotionCode | undefined; + if (couponCode) { + const promotionCodes = await this.stripeClient.promotionCodesList({ + active: true, + code: couponCode, + }); + promoCode = promotionCodes.data.at(0); } - return stripeInvoiceToInvoicePreviewDTO(invoices); + const automaticTax = !!( + (customer && isCustomerTaxEligible(customer)) || + (!customer && taxAddress) + ); + + const shipping = + !customer && taxAddress + ? { + name: '', + address: { + country: taxAddress.countryCode, + postal_code: taxAddress.postalCode, + }, + } + : undefined; + + const requestObject: Stripe.InvoiceRetrieveUpcomingParams = { + currency, + customer: customer?.id, + automatic_tax: { + enabled: automaticTax, + }, + customer_details: { + tax_exempt: 'none', // Param required when shipping address not present + shipping, + }, + subscription_items: [{ price: priceId }], + discounts: [{ promotion_code: promoCode?.id }], + }; + + const upcomingInvoice = await this.stripeClient.invoicesRetrieveUpcoming( + requestObject + ); + + const invoicePreview = stripeInvoiceToInvoicePreviewDTO(upcomingInvoice); + + if (!requestObject.subscription_items?.length) throw new Error(); + + requestObject.subscription_proration_behavior = 'always_invoice'; + requestObject.subscription_proration_date = Math.floor(Date.now() / 1000); + + const subscriptions = await this.stripeClient.subscriptionsList({ + customer: customer?.id, + }); + + const subscriptionItem = subscriptions.data + .flatMap((subscription) => subscription.items.data) + ?.find((subscription) => subscription.plan.id === upgradeFromPrice?.id); + + requestObject.subscription_items[0].id = subscriptionItem?.id; + requestObject.subscription = subscriptionItem?.subscription; + + const proratedInvoice = await this.stripeClient.invoicesRetrieveUpcoming( + requestObject + ); + + const proration = proratedInvoice.lines.data.find( + (lineItem) => lineItem.proration + ); + + return { + ...invoicePreview, + oneTimeCharge: proratedInvoice.total, + proratedAmount: proration?.amount, + }; } /** @@ -124,7 +199,7 @@ export class InvoiceManager { */ async preview(invoiceId: string): Promise { const invoice = await this.retrieve(invoiceId); - return stripeInvoiceToInvoicePreviewDTO([invoice]); + return stripeInvoiceToInvoicePreviewDTO(invoice); } /** @@ -254,28 +329,4 @@ export class InvoiceManager { return await this.processPayPalNonZeroInvoice(customer, invoice); } - - async getProratedInvoice( - requestObject: Stripe.InvoiceRetrieveUpcomingParams, - customer?: StripeCustomer, - currentPrice?: StripePrice - ) { - requestObject.subscription_proration_behavior = 'always_invoice'; - requestObject.subscription_proration_date = Math.floor(Date.now() / 1000); - - const subscriptions = await this.stripeClient.subscriptionsList({ - customer: customer?.id, - }); - - const subscriptionItem = subscriptions.data - .flatMap((subscription) => subscription.items.data) - ?.find((subscription) => subscription.plan.id === currentPrice?.id); - - const firstSubItem = requestObject.subscription_items?.at(0); - if (!firstSubItem) return; - firstSubItem.id = subscriptionItem?.id; - requestObject.subscription = subscriptionItem?.subscription; - - return await this.stripeClient.invoicesRetrieveUpcoming(requestObject); - } } diff --git a/libs/payments/customer/src/lib/util/stripeInvoiceToInvoicePreviewDTO.spec.ts b/libs/payments/customer/src/lib/util/stripeInvoiceToInvoicePreviewDTO.spec.ts index a703bb5092c..d7d4b2c81b1 100644 --- a/libs/payments/customer/src/lib/util/stripeInvoiceToInvoicePreviewDTO.spec.ts +++ b/libs/payments/customer/src/lib/util/stripeInvoiceToInvoicePreviewDTO.spec.ts @@ -2,11 +2,9 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { faker } from '@faker-js/faker'; import { stripeInvoiceToInvoicePreviewDTO } from './stripeInvoiceToInvoicePreviewDTO'; import { StripeDiscountFactory, - StripeInvoiceLineItemFactory, StripeResponseFactory, StripeTotalDiscountAmountsFactory, StripeTotalTaxAmountsFactory, @@ -24,7 +22,7 @@ describe('stripeInvoiceToInvoicePreviewDTO', () => { }) ); - const result = stripeInvoiceToInvoicePreviewDTO([mockUpcomingInvoice]); + const result = stripeInvoiceToInvoicePreviewDTO(mockUpcomingInvoice); expect(result).toEqual({ currency: mockUpcomingInvoice.currency, listAmount: mockUpcomingInvoice.subtotal, @@ -56,7 +54,7 @@ describe('stripeInvoiceToInvoicePreviewDTO', () => { }) ); - const result = stripeInvoiceToInvoicePreviewDTO([mockUpcomingInvoice]); + const result = stripeInvoiceToInvoicePreviewDTO(mockUpcomingInvoice); expect(result).toEqual({ currency: mockUpcomingInvoice.currency, listAmount: mockUpcomingInvoice.subtotal, @@ -88,7 +86,7 @@ describe('stripeInvoiceToInvoicePreviewDTO', () => { }) ); - const result = stripeInvoiceToInvoicePreviewDTO([mockUpcomingInvoice]); + const result = stripeInvoiceToInvoicePreviewDTO(mockUpcomingInvoice); expect(result).toEqual({ currency: mockUpcomingInvoice.currency, listAmount: mockUpcomingInvoice.subtotal, @@ -121,7 +119,7 @@ describe('stripeInvoiceToInvoicePreviewDTO', () => { }) ); - const result = stripeInvoiceToInvoicePreviewDTO([mockUpcomingInvoice]); + const result = stripeInvoiceToInvoicePreviewDTO(mockUpcomingInvoice); expect(result.taxAmounts).toEqual([]); expect(result.discountAmount).toEqual(0); }); @@ -133,40 +131,7 @@ describe('stripeInvoiceToInvoicePreviewDTO', () => { }) ); - const result = stripeInvoiceToInvoicePreviewDTO([mockUpcomingInvoice]); + const result = stripeInvoiceToInvoicePreviewDTO(mockUpcomingInvoice); expect(result.discountAmount).toBeNull(); }); - - it('formats invoice with prorated amount and one time charge', () => { - const mockUpcomingInvoice = StripeResponseFactory( - StripeUpcomingInvoiceFactory() - ); - const mockProratedInvoice = StripeResponseFactory( - StripeUpcomingInvoiceFactory({ - lines: { - object: 'list', - data: [ - StripeInvoiceLineItemFactory({ - amount: -500, - proration: true, - }), - StripeInvoiceLineItemFactory({ - amount: 5000, - proration: false, - }), - ], - has_more: false, - url: faker.internet.url(), - }, - total: 4500, - }) - ); - - const result = stripeInvoiceToInvoicePreviewDTO([ - mockUpcomingInvoice, - mockProratedInvoice, - ]); - expect(result.proratedAmount).toEqual(-500); - expect(result.oneTimeCharge).toEqual(4500); - }); }); diff --git a/libs/payments/customer/src/lib/util/stripeInvoiceToInvoicePreviewDTO.ts b/libs/payments/customer/src/lib/util/stripeInvoiceToInvoicePreviewDTO.ts index 126802c15a8..5df437dfb1a 100644 --- a/libs/payments/customer/src/lib/util/stripeInvoiceToInvoicePreviewDTO.ts +++ b/libs/payments/customer/src/lib/util/stripeInvoiceToInvoicePreviewDTO.ts @@ -9,38 +9,32 @@ import { InvoicePreview, STRIPE_INVOICE_METADATA } from '../types'; * Formats a Stripe Invoice to the FirstInvoicePreview DTO format. */ export function stripeInvoiceToInvoicePreviewDTO( - invoice: StripeUpcomingInvoice[] | StripeInvoice[] + invoice: StripeUpcomingInvoice | StripeInvoice ): InvoicePreview { - const taxAmounts = invoice[0].total_tax_amounts.map((amount) => ({ + const taxAmounts = invoice.total_tax_amounts.map((amount) => ({ title: amount.tax_rate.display_name, inclusive: amount.inclusive, amount: amount.amount, })); - const discountAmount = invoice[0].total_discount_amounts - ? invoice[0].total_discount_amounts.reduce( + const discountAmount = invoice.total_discount_amounts + ? invoice.total_discount_amounts.reduce( (discount, { amount }) => discount + amount, 0 ) : null; - const proration = invoice[1]?.lines.data.find( - (lineItem) => lineItem.proration - ); - return { - currency: invoice[0].currency, - listAmount: invoice[0].subtotal, - totalAmount: invoice[0].total, + currency: invoice.currency, + listAmount: invoice.subtotal, + totalAmount: invoice.total, taxAmounts, discountAmount, - subtotal: invoice[0].subtotal, - discountEnd: invoice[0].discount?.end, - discountType: invoice[0].discount?.coupon.duration, - number: invoice[0].number, + subtotal: invoice.subtotal, + discountEnd: invoice.discount?.end, + discountType: invoice.discount?.coupon.duration, + number: invoice.number, paypalTransactionId: - invoice[0].metadata?.[STRIPE_INVOICE_METADATA.PaypalTransactionId], - proratedAmount: proration?.amount, - oneTimeCharge: proration ? invoice[1].total : undefined, + invoice.metadata?.[STRIPE_INVOICE_METADATA.PaypalTransactionId], }; } diff --git a/libs/payments/eligibility/src/lib/eligibility.manager.spec.ts b/libs/payments/eligibility/src/lib/eligibility.manager.spec.ts index 098980f8b26..2b81e3385ce 100644 --- a/libs/payments/eligibility/src/lib/eligibility.manager.spec.ts +++ b/libs/payments/eligibility/src/lib/eligibility.manager.spec.ts @@ -429,7 +429,6 @@ describe('EligibilityManager', () => { }), }); const interval = SubplatInterval.Monthly; - const mockCurrentOffering = PageContentOfferingTransformedFactory(); const mockOverlapResult = [ { comparison: OfferingComparison.SAME, @@ -440,9 +439,6 @@ describe('EligibilityManager', () => { jest .spyOn(priceManager, 'retrieveByInterval') .mockResolvedValue(mockPrice2); - jest - .spyOn(productConfigurationManager, 'fetchCMSData') - .mockResolvedValue(mockCurrentOffering); const result = await manager.compareOverlap( mockOverlapResult, @@ -453,7 +449,7 @@ describe('EligibilityManager', () => { expect(result.subscriptionEligibilityResult).toEqual( CartEligibilityStatus.DOWNGRADE ); - expect(result.eligibleSourcePrice).toEqual(mockPrice1); + expect(result.upgradeFromPrice).toEqual(mockPrice1); }); it('returns upgrade when comparison is upgrade', async () => { @@ -494,7 +490,7 @@ describe('EligibilityManager', () => { expect(result.subscriptionEligibilityResult).toEqual( CartEligibilityStatus.UPGRADE ); - expect(result.eligibleSourcePrice).toEqual(mockPrice1); + expect(result.upgradeFromPrice).toEqual(mockPrice1); }); it('returns upgrade when target price interval is longer than the subscribed price', async () => { @@ -510,7 +506,6 @@ describe('EligibilityManager', () => { interval: 'year', }), }); - const mockCurrentOffering = PageContentOfferingTransformedFactory(); const mockOverlapResult = [ { comparison: OfferingComparison.SAME, @@ -521,9 +516,6 @@ describe('EligibilityManager', () => { jest .spyOn(priceManager, 'retrieveByInterval') .mockResolvedValue(mockPrice2); - jest - .spyOn(productConfigurationManager, 'fetchCMSData') - .mockResolvedValue(mockCurrentOffering); const result = await manager.compareOverlap( mockOverlapResult, @@ -534,7 +526,7 @@ describe('EligibilityManager', () => { expect(result.subscriptionEligibilityResult).toEqual( CartEligibilityStatus.UPGRADE ); - expect(result.eligibleSourcePrice).toEqual(mockPrice1); + expect(result.upgradeFromPrice).toEqual(mockPrice1); }); }); }); diff --git a/libs/payments/eligibility/src/lib/eligibility.manager.ts b/libs/payments/eligibility/src/lib/eligibility.manager.ts index 92a6fdb02c4..80af3168b9f 100644 --- a/libs/payments/eligibility/src/lib/eligibility.manager.ts +++ b/libs/payments/eligibility/src/lib/eligibility.manager.ts @@ -130,8 +130,8 @@ export class EligibilityManager { if (intervalComparisonResult === IntervalComparison.SHORTER) { return { subscriptionEligibilityResult: EligibilityStatus.DOWNGRADE, - eligibleSourcePrice: overlappingPrice, fromOfferingConfigId: overlap.fromOfferingId, + upgradeFromPrice: overlappingPrice, }; } @@ -141,8 +141,8 @@ export class EligibilityManager { ) return { subscriptionEligibilityResult: EligibilityStatus.UPGRADE, - eligibleSourcePrice: overlappingPrice, fromOfferingConfigId: overlap.fromOfferingId, + upgradeFromPrice: overlappingPrice, }; } diff --git a/libs/payments/eligibility/src/lib/eligibility.service.ts b/libs/payments/eligibility/src/lib/eligibility.service.ts index cc24cbd0018..7d586618451 100644 --- a/libs/payments/eligibility/src/lib/eligibility.service.ts +++ b/libs/payments/eligibility/src/lib/eligibility.service.ts @@ -27,8 +27,8 @@ export class EligibilityService { if (!stripeCustomerId) { return { subscriptionEligibilityResult: EligibilityStatus.CREATE, - eligibleSourcePrice: undefined, fromOfferingConfigId: undefined, + upgradeFromPrice: undefined, }; } diff --git a/libs/payments/ui/src/lib/client/components/PurchaseDetails/en.ftl b/libs/payments/ui/src/lib/client/components/PurchaseDetails/en.ftl index 5622fc798ee..e2df85901b1 100644 --- a/libs/payments/ui/src/lib/client/components/PurchaseDetails/en.ftl +++ b/libs/payments/ui/src/lib/client/components/PurchaseDetails/en.ftl @@ -1,11 +1,6 @@ ## Component - PurchaseDetails next-plan-details-header = Product details - -## $amount (Number) - The amount billed. It will be formatted as currency. -list-positive-amount = { $amount } -list-negative-amount = - { $amount } - next-plan-details-list-price = List Price next-plan-details-tax = Taxes and Fees next-plan-details-total-label = Total diff --git a/libs/payments/ui/src/lib/client/components/PurchaseDetails/index.tsx b/libs/payments/ui/src/lib/client/components/PurchaseDetails/index.tsx index fea9fc3198c..1f5b4f6165d 100644 --- a/libs/payments/ui/src/lib/client/components/PurchaseDetails/index.tsx +++ b/libs/payments/ui/src/lib/client/components/PurchaseDetails/index.tsx @@ -10,7 +10,6 @@ import { useState } from 'react'; import { Invoice } from '@fxa/payments/cart'; import infoLogo from '@fxa/shared/assets/images/info.svg'; import { - getLocalizedCurrency, getLocalizedCurrencyString, getLocalizedDateString, } from '@fxa/shared/l10n'; @@ -56,9 +55,9 @@ export function PurchaseDetails(props: PurchaseDetailsProps) { />
-

+

{productName} -

+

{priceInterval} @@ -93,15 +92,7 @@ export function PurchaseDetails(props: PurchaseDetailsProps) {

List Price

- - -

{getLocalizedCurrencyString(listAmount, currency)}

-
+

{getLocalizedCurrencyString(listAmount, currency)}

)} @@ -110,15 +101,7 @@ export function PurchaseDetails(props: PurchaseDetailsProps) {

Promo Code

- - -

- {getLocalizedCurrencyString(discountAmount, currency)}`

-
+

{getLocalizedCurrencyString(-1 * discountAmount, currency)}

)} @@ -127,22 +110,12 @@ export function PurchaseDetails(props: PurchaseDetailsProps) {

Taxes and Fees

- -

- {getLocalizedCurrencyString( - exclusiveTaxRates[0].amount, - currency - )} -

-
+

+ {getLocalizedCurrencyString( + exclusiveTaxRates[0].amount, + currency + )} +

)} @@ -155,14 +128,7 @@ export function PurchaseDetails(props: PurchaseDetailsProps) {

{taxRate.title}

- -

{getLocalizedCurrencyString(taxRate.amount, currency)}

-
+

{getLocalizedCurrencyString(taxRate.amount, currency)}

))} @@ -172,12 +138,12 @@ export function PurchaseDetails(props: PurchaseDetailsProps) {

Total

- {totalPrice} - +

diff --git a/libs/payments/ui/src/lib/server/components/UpgradePurchaseDetails/index.tsx b/libs/payments/ui/src/lib/server/components/UpgradePurchaseDetails/index.tsx index da48bef4aea..2700b29cad5 100644 --- a/libs/payments/ui/src/lib/server/components/UpgradePurchaseDetails/index.tsx +++ b/libs/payments/ui/src/lib/server/components/UpgradePurchaseDetails/index.tsx @@ -132,55 +132,31 @@ export function UpgradePurchaseDetails(props: UpgradePurchaseDetailsProps) {
    {!!listAmount && (
  • - {l10n.getString('next-plan-details-list-price', 'List Price')} -
    - {l10n.getString( - `list-positive-amount`, - { - amount: l10n.getLocalizedCurrency(listAmount, currency), - }, - `${l10n.getLocalizedCurrencyString(listAmount, currency)}` - )} -
    +

    + {l10n.getString('next-plan-details-list-price', 'List Price')} +

    +

    {l10n.getLocalizedCurrencyString(listAmount, currency)}

  • )} {!!discountAmount && (
  • - {l10n.getString('next-coupon-promo-code', 'Promo Code')} -
    - {l10n.getString( - `list-negative-amount`, - { - amount: l10n.getLocalizedCurrency(discountAmount, currency), - }, - `- ${l10n.getLocalizedCurrencyString( - discountAmount, - currency - )}` - )} -
    +

    {l10n.getString('next-coupon-promo-code', 'Promo Code')}

    +

    + {l10n.getLocalizedCurrencyString(-1 * discountAmount, currency)} +

  • )} {exclusiveTaxRates.length === 1 && (
  • - {l10n.getString('next-plan-details-tax', 'Taxes and Fees')} -
    - {l10n.getString( - `list-positive-amount`, - { - amount: l10n.getLocalizedCurrency( - exclusiveTaxRates[0].amount, - currency - ), - }, - `${l10n.getLocalizedCurrencyString( - exclusiveTaxRates[0].amount, - currency - )}` +

    {l10n.getString('next-plan-details-tax', 'Taxes and Fees')}

    +

    + {l10n.getLocalizedCurrencyString( + exclusiveTaxRates[0].amount, + currency )} -

    +

  • )} @@ -190,22 +166,10 @@ export function UpgradePurchaseDetails(props: UpgradePurchaseDetailsProps) { key={taxRate.title} className="flex items-center justify-between gap-2 leading-5 text-grey-600 text-sm" > - {l10n.getString('tax', taxRate.title)} -
    - {l10n.getString( - `list-positive-amount`, - { - amount: l10n.getLocalizedCurrency( - taxRate.amount, - currency - ), - }, - `${l10n.getLocalizedCurrencyString( - taxRate.amount, - currency - )}` - )} -
    +

    {l10n.getString('tax', taxRate.title)}

    +

    + {l10n.getLocalizedCurrencyString(taxRate.amount, currency)} +

    ))} @@ -213,7 +177,7 @@ export function UpgradePurchaseDetails(props: UpgradePurchaseDetailsProps) {

    {l10n.getString('next-sub-update-total-label', 'New total')}

    - @@ -224,7 +188,7 @@ export function UpgradePurchaseDetails(props: UpgradePurchaseDetailsProps) { listAmount={listAmount} totalAmount={totalAmount} /> - +

    @@ -236,7 +200,7 @@ export function UpgradePurchaseDetails(props: UpgradePurchaseDetailsProps) { 'Prorated Upgrade' )} - @@ -247,7 +211,7 @@ export function UpgradePurchaseDetails(props: UpgradePurchaseDetailsProps) { listAmount={listAmount} totalAmount={oneTimeCharge} /> - +