This commit is contained in:
Lisa Chan 2025-01-15 09:46:38 -05:00
Родитель 73d418d869
Коммит a1ae46d0ae
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 9052E177BBC5E764
9 изменённых файлов: 120 добавлений и 333 удалений

Просмотреть файл

@ -38,8 +38,7 @@ export default async function UpgradeLayout({
cms.defaultPurchase.purchaseDetails.localizations.at(0) ||
cms.defaultPurchase.purchaseDetails;
const currentOfferingId = cart.fromOfferingConfigId;
const currentCmsDataPromise = fetchCMSData(currentOfferingId, locale);
const currentCmsDataPromise = fetchCMSData(cart.fromOfferingConfigId, locale);
const currentCms = await currentCmsDataPromise;
const currentPurchaseDetails =
currentCms.defaultPurchase.purchaseDetails.localizations.at(0) ||

Просмотреть файл

@ -163,19 +163,3 @@ export class CartSuccessMissingRequired extends CartError {
});
}
}
export class CartUpgradeMissingRequired extends CartError {
constructor(cartId: string) {
super('Upgrade cart is missing required fields', {
cartId,
});
}
}
export class CartUpgradeNotValid extends CartError {
constructor(cartId: string) {
super('Upgrade cart does not have current plan', {
cartId,
});
}
}

Просмотреть файл

@ -23,6 +23,7 @@ import {
SuccessCart,
TaxAmount,
UpdateCart,
UpgradeCart,
WithContextCart,
} from './cart.types';
@ -133,3 +134,17 @@ export const SuccessCartFactory = (
paymentInfo: PaymentInfoFactory(),
...override,
});
export const UpgradeCartFactory = (
override?: Partial<UpgradeCart>
): UpgradeCart => ({
...WithContextCartFactory(),
eligibilityStatus: CartEligibilityStatus.UPGRADE,
fromOfferingConfigId: faker.string.uuid(),
upgradeFromPrice: {
currency: faker.finance.currencyCode(),
interval: faker.helpers.arrayElement(['day', 'month', 'week', 'year']),
listAmount: faker.number.int({ max: 1000 }),
},
...override,
});

Просмотреть файл

@ -51,7 +51,6 @@ import {
} from '@fxa/profile/client';
import {
MockStrapiClientConfigProvider,
PageContentOfferingTransformedFactory,
ProductConfigurationManager,
StrapiClient,
} from '@fxa/shared/cms';
@ -81,12 +80,12 @@ import {
ResultCartFactory,
SuccessCartFactory,
UpdateCartFactory,
WithContextCartFactory,
} from './cart.factories';
import { CartManager } from './cart.manager';
import { CartService } from './cart.service';
import { CheckoutService } from './checkout.service';
import {
CartEligibilityMismatchError,
CartError,
CartInvalidCurrencyError,
CartInvalidPromoCodeError,
@ -94,8 +93,6 @@ import {
CartStateProcessingError,
CartSubscriptionNotFoundError,
CartSuccessMissingRequired,
CartUpgradeMissingRequired,
CartUpgradeNotValid,
} from './cart.error';
import { CurrencyManager } from '@fxa/payments/currency';
import { MockCurrencyConfigProvider } from 'libs/payments/currency/src/lib/currency.config';
@ -728,6 +725,7 @@ describe('CartService', () => {
it('returns cart and upcomingInvoicePreview', async () => {
const mockCart = ResultCartFactory({
stripeSubscriptionId: null,
eligibilityStatus: CartEligibilityStatus.CREATE,
});
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
const mockPrice = StripePriceFactory();
@ -773,6 +771,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();
@ -835,6 +834,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();
@ -881,6 +881,7 @@ describe('CartService', () => {
const mockCart = ResultCartFactory({
uid: mockUid,
stripeSubscriptionId: null,
eligibilityStatus: CartEligibilityStatus.CREATE,
});
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
const mockPrice = StripePriceFactory();
@ -915,6 +916,7 @@ describe('CartService', () => {
const mockCart = ResultCartFactory({
uid: mockUid,
stripeSubscriptionId: null,
eligibilityStatus: CartEligibilityStatus.CREATE,
});
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
const mockPrice = StripePriceFactory();
@ -961,154 +963,6 @@ describe('CartService', () => {
});
});
describe('getUpgradeCart', () => {
it('returns cart with current plan and offering id', async () => {
const mockCart = ResultCartFactory({
stripeSubscriptionId: null,
eligibilityStatus: CartEligibilityStatus.UPGRADE,
});
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
const mockPrice = StripePriceFactory();
const mockInvoicePreview = InvoicePreviewFactory({
oneTimeCharge: 4500,
});
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,
upgradeFromPrice: mockCurrentPrice,
});
jest
.spyOn(invoiceManager, 'previewUpcomingForUpgrade')
.mockResolvedValue(mockInvoicePreview);
const result = await cartService.getUpgradeCart(mockCart.id);
expect(result).toEqual({
...mockCart,
upcomingInvoicePreview: mockInvoicePreview,
metricsOptedOut: false,
eligibilityStatus: CartEligibilityStatus.UPGRADE,
fromOfferingConfigId: mockCurrentOffering.apiIdentifier,
oneTimeCharge: 4500,
upgradeFromPrice: {
currency: mockCurrentPrice.currency,
interval: mockCurrentPrice.recurring?.interval,
listAmount: mockCurrentPrice.unit_amount,
},
});
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,
upgradeFromPrice: mockCurrentPrice,
});
});
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,
});
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({
oneTimeCharge: 4500,
});
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, 'previewUpcomingForUpgrade')
.mockResolvedValue(mockInvoicePreview);
await expect(
cartService.getUpgradeCart(mockCart.id)
).rejects.toThrowError(CartUpgradeNotValid);
});
it('throws error if upgrade is missing oneTimeCharge from invoice', async () => {
const mockCart = ResultCartFactory({
stripeSubscriptionId: null,
eligibilityStatus: CartEligibilityStatus.UPGRADE,
});
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
const mockPrice = StripePriceFactory();
const mockInvoicePreview = InvoicePreviewFactory({
oneTimeCharge: 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,
upgradeFromPrice: mockCurrentPrice,
});
jest
.spyOn(invoiceManager, 'previewUpcomingForUpgrade')
.mockResolvedValue(mockInvoicePreview);
await expect(
cartService.getUpgradeCart(mockCart.id)
).rejects.toThrowError(CartUpgradeMissingRequired);
});
});
describe('getSuccessCart', () => {
const mockSuccessCart = SuccessCartFactory();
it('should return success cart', async () => {
@ -1118,11 +972,10 @@ describe('CartService', () => {
});
it('should throw error if cart state is not success', async () => {
jest
.spyOn(cartService, 'getCart')
.mockResolvedValue(SuccessCartFactory({ state: CartState.FAIL }));
const mockCart = WithContextCartFactory({ state: CartState.FAIL });
jest.spyOn(cartService, 'getCart').mockResolvedValue(mockCart);
await expect(
cartService.getSuccessCart(mockSuccessCart.id)
cartService.getSuccessCart(mockCart.id)
).rejects.toThrowError(CartInvalidStateForActionError);
});

Просмотреть файл

@ -23,6 +23,7 @@ import {
AccountCustomerManager,
AccountCustomerNotFoundError,
StripeCustomer,
StripePrice,
StripeSubscription,
} from '@fxa/payments/stripe';
import { ProductConfigurationManager } from '@fxa/shared/cms';
@ -37,6 +38,7 @@ import { GeoDBManager } from '@fxa/shared/geodb';
import { CartManager } from './cart.manager';
import type {
CheckoutCustomerData,
GetCartResult,
GetNeedsInputResponse,
NoInputNeededResponse,
PaymentInfo,
@ -44,14 +46,11 @@ import type {
StripeHandleNextActionResponse,
SuccessCart,
UpdateCart,
UpgradeCart,
WithContextCart,
} from './cart.types';
import { NeedsInputType } from './cart.types';
import { CurrentPrice, NeedsInputType } from './cart.types';
import { handleEligibilityStatusMap } from './cart.utils';
import { CheckoutService } from './checkout.service';
import {
CartEligibilityMismatchError,
CartError,
CartInvalidCurrencyError,
CartInvalidPromoCodeError,
@ -60,8 +59,6 @@ import {
CartStateProcessingError,
CartSubscriptionNotFoundError,
CartSuccessMissingRequired,
CartUpgradeMissingRequired,
CartUpgradeNotValid,
} from './cart.error';
import { AccountManager } from '@fxa/shared/account/account';
import assert from 'assert';
@ -437,7 +434,7 @@ export class CartService {
* Fetch a cart from the database by ID
*/
@SanitizeExceptions()
async getCart(cartId: string): Promise<WithContextCart> {
async getCart(cartId: string): Promise<GetCartResult> {
const cart = await this.cartManager.fetchCartById(cartId);
const [price, metricsOptedOut] = await Promise.all([
@ -457,13 +454,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,
});
let fromOfferingConfigId: string | undefined;
let upgradeFromPrice: StripePrice | undefined;
let currentPrice: CurrentPrice | undefined;
if (cart.eligibilityStatus === CartEligibilityStatus.UPGRADE) {
const eligibility = await this.eligibilityService.checkEligibility(
cart.interval as SubplatInterval,
cart.offeringConfigId,
cart.stripeCustomerId
);
fromOfferingConfigId = eligibility.fromOfferingConfigId;
upgradeFromPrice = eligibility.upgradeFromPrice;
}
let upcomingInvoicePreview: InvoicePreview | undefined;
if (cart.eligibilityStatus === CartEligibilityStatus.UPGRADE) {
upcomingInvoicePreview =
await this.invoiceManager.previewUpcomingForUpgrade({
priceId: price.id,
currency: cart.currency || DEFAULT_CURRENCY,
customer,
taxAddress: cart.taxAddress || undefined,
couponCode: cart.couponCode || undefined,
upgradeFromPrice,
});
} 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) {
@ -513,6 +536,8 @@ export class CartService {
metricsOptedOut,
latestInvoicePreview,
paymentInfo,
fromOfferingConfigId,
upgradeFromPrice: currentPrice,
};
}
@ -531,123 +556,21 @@ export class CartService {
);
}
if (!cart.latestInvoicePreview || !cart.paymentInfo?.type) {
throw new CartSuccessMissingRequired(cartId);
}
return {
...cart,
latestInvoicePreview: cart.latestInvoicePreview,
paymentInfo: cart.paymentInfo,
};
}
/**
* Fetch a upgrade cart
*/
async getUpgradeCart(cartId: string): Promise<UpgradeCart> {
const cart = await this.cartManager.fetchCartById(cartId);
if (cart.eligibilityStatus !== CartEligibilityStatus.UPGRADE)
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 (!upcomingInvoicePreview.oneTimeCharge) {
throw new CartUpgradeMissingRequired(cartId);
'latestInvoicePreview' in cart &&
cart.latestInvoicePreview !== undefined &&
'paymentInfo' in cart &&
cart.paymentInfo !== undefined &&
'type' in cart.paymentInfo
) {
return {
...cart,
state: CartState.SUCCESS,
latestInvoicePreview: cart.latestInvoicePreview,
paymentInfo: cart.paymentInfo,
};
}
// 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,
upcomingInvoicePreview,
metricsOptedOut,
latestInvoicePreview,
paymentInfo,
fromOfferingConfigId: fromOfferingConfigId,
oneTimeCharge: upcomingInvoicePreview.oneTimeCharge,
upgradeFromPrice: currentPrice,
};
throw new CartSuccessMissingRequired(cartId);
}
async metricsOptedOut(accountId?: string): Promise<boolean> {

Просмотреть файл

@ -2,7 +2,7 @@
* 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 { TaxAddress } from '@fxa/payments/customer';
import { InvoicePreview, TaxAddress } from '@fxa/payments/customer';
import {
Cart,
CartEligibilityStatus,
@ -29,20 +29,6 @@ export type FinishErrorCart = {
stripeCustomerId?: string;
};
export interface Invoice {
currency: string;
listAmount: number;
totalAmount: number;
taxAmounts: TaxAmount[];
discountAmount: number | null;
subtotal: number;
discountEnd?: number | null;
discountType?: string;
number: string | null; // customer-facing invoice identifier
paypalTransactionId?: string;
oneTimeCharge?: number;
}
export type PaymentProvidersType =
| Stripe.PaymentMethod.Type
| 'google_iap'
@ -69,24 +55,30 @@ export interface CurrentPrice {
listAmount: number;
}
export type GetCartResult = WithContextCart | SuccessCart | UpgradeCart;
export type WithContextCart = ResultCart & {
metricsOptedOut: boolean;
upcomingInvoicePreview: Invoice;
latestInvoicePreview?: Invoice;
upcomingInvoicePreview: InvoicePreview;
latestInvoicePreview?: InvoicePreview;
paymentInfo?: PaymentInfo;
fromOfferingConfigId?: string;
oneTimeCharge?: number;
upgradeFromPrice?: CurrentPrice;
};
export type SuccessCart = WithContextCart & {
latestInvoicePreview: Invoice;
export type SuccessCart = ResultCart & {
state: CartState.SUCCESS;
metricsOptedOut: boolean;
upcomingInvoicePreview: InvoicePreview;
latestInvoicePreview: InvoicePreview;
paymentInfo: PaymentInfo;
};
export type UpgradeCart = WithContextCart & {
export type UpgradeCart = ResultCart & {
eligibilityStatus: CartEligibilityStatus.UPGRADE;
metricsOptedOut: boolean;
upcomingInvoicePreview: InvoicePreview;
fromOfferingConfigId: string;
oneTimeCharge: number;
upgradeFromPrice: CurrentPrice;
};

Просмотреть файл

@ -7,7 +7,7 @@
import { Localized } from '@fluent/react';
import Image from 'next/image';
import { useState } from 'react';
import { Invoice } from '@fxa/payments/cart';
import { InvoicePreview } from '@fxa/payments/customer';
import infoLogo from '@fxa/shared/assets/images/info.svg';
import {
getLocalizedCurrencyString,
@ -16,7 +16,7 @@ import {
import chevron from './images/chevron.svg';
type PurchaseDetailsProps = {
invoice: Invoice;
invoice: InvoicePreview;
priceInterval: React.ReactNode;
purchaseDetails: {
details: string[];

Просмотреть файл

@ -6,10 +6,11 @@ import { Injectable } from '@nestjs/common';
import { Validator } from 'class-validator';
import { GoogleManager } from '@fxa/google';
import { CartService } from '@fxa/payments/cart';
import { CartService, UpgradeCart } from '@fxa/payments/cart';
import { ContentServerManager } from '@fxa/payments/content-server';
import { CheckoutTokenManager } from '@fxa/payments/paypal';
import { ProductConfigurationManager } from '@fxa/shared/cms';
import { CartEligibilityStatus } from '@fxa/shared/db/mysql/account';
import { CheckoutCartWithPaypalActionArgs } from './validators/CheckoutCartWithPaypalActionArgs';
import { CheckoutCartWithStripeActionArgs } from './validators/CheckoutCartWithStripeActionArgs';
@ -60,12 +61,31 @@ export class NextJSActionsService {
return cart;
}
async getUpgradeCart(args: GetCartActionArgs) {
async getUpgradeCart(args: GetCartActionArgs): Promise<UpgradeCart> {
await new Validator().validateOrReject(args);
const cart = await this.cartService.getUpgradeCart(args.cartId);
const cart = await this.cartService.getCart(args.cartId);
return cart;
if (cart.eligibilityStatus !== CartEligibilityStatus.UPGRADE)
throw new Error('Cart eligibility is not upgrade');
if (
'fromOfferingConfigId' in cart &&
cart.fromOfferingConfigId !== undefined &&
'upgradeFromPrice' in cart &&
cart.upgradeFromPrice !== undefined
) {
return {
...cart,
eligibilityStatus: CartEligibilityStatus.UPGRADE,
fromOfferingConfigId: cart.fromOfferingConfigId,
upgradeFromPrice: cart.upgradeFromPrice,
};
} else {
throw new Error(
'ActionsService - cart is missing required fields for upgrade'
);
}
}
async updateCart(args: UpdateCartActionArgs) {

Просмотреть файл

@ -3,7 +3,8 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Image from 'next/image';
import { CurrentPrice, Invoice } from '@fxa/payments/cart';
import { CurrentPrice } from '@fxa/payments/cart';
import { InvoicePreview } from '@fxa/payments/customer';
import { PriceInterval } from '@fxa/payments/ui/server';
import { LocalizerRsc } from '@fxa/shared/l10n/server';
@ -15,7 +16,7 @@ type UpgradePurchaseDetailsProps = {
webIcon: string;
};
interval: string;
invoice: Invoice;
invoice: InvoicePreview;
l10n: LocalizerRsc;
purchaseDetails: {
subtitle: string | null;