Merge pull request #17668 from mozilla/FXA-7584

feat(payments-cart): add currency to cart
This commit is contained in:
Julian Poyourow 2024-09-24 11:59:54 -07:00 коммит произвёл GitHub
Родитель b23cd152e4 c90fbeb815
Коммит 025f8caee2
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
22 изменённых файлов: 211 добавлений и 14 удалений

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

@ -68,6 +68,10 @@ FIRESTORE_CONFIG__CREDENTIALS__PRIVATE_KEY=
FIRESTORE_CONFIG__KEY_FILENAME=
FIRESTORE_CONFIG__PROJECT_ID=
# Currency Config
CURRENCY_CONFIG__TAX_IDS={ "EUR": "EU1234", "CHF": "CH1234" }
CURRENCY_CONFIG__CURRENCIES_TO_COUNTRIES={ "USD": ["US", "GB", "NZ", "MY", "SG", "CA", "AS", "GU", "MP", "PR", "VI"], "EUR": ["FR", "DE"] }
# StatsD Config
STATS_D_CONFIG__SAMPLE_RATE=
STATS_D_CONFIG__MAX_BUFFER_SIZE=

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

@ -64,6 +64,10 @@ FIRESTORE_CONFIG__CREDENTIALS__PRIVATE_KEY=
FIRESTORE_CONFIG__KEY_FILENAME=
FIRESTORE_CONFIG__PROJECT_ID=
# Currency Config
CURRENCY_CONFIG__TAX_IDS={}
CURRENCY_CONFIG__CURRENCIES_TO_COUNTRIES={}
# StatsD Config
STATS_D_CONFIG__SAMPLE_RATE=
STATS_D_CONFIG__MAX_BUFFER_SIZE=

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

@ -54,7 +54,6 @@ export default async function Checkout({
getApp()
.getGleanEmitter()
.emit('fxaPaySetupView', {
currency: 'USD',
checkoutType: 'without-accounts',
params: { ...params },
searchParams,

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

@ -119,3 +119,17 @@ export class CartInvalidPromoCodeError extends CartError {
});
}
}
export class CartInvalidCurrencyError extends CartError {
constructor(
currency: string | undefined,
country: string | undefined,
cartId?: string
) {
super('Cart specified currency is not supported', {
cartId,
currency,
country,
});
}
}

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

@ -84,6 +84,7 @@ export const ResultCartFactory = (
interval: faker.string.numeric(),
experiment: null,
taxAddress: TaxAddressFactory(),
currency: faker.finance.currencyCode(),
createdAt: faker.date.past().getTime(),
updatedAt: faker.date.past().getTime(),
couponCode: null,

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

@ -89,6 +89,7 @@ export class CartManager {
taxAddress: input.taxAddress
? JSON.stringify(input.taxAddress)
: undefined,
currency: input.currency,
id: uuidv4({}, Buffer.alloc(16)),
uid: input.uid ? Buffer.from(input.uid, 'hex') : undefined,
state: CartState.START,
@ -136,6 +137,7 @@ export class CartManager {
taxAddress: items.taxAddress
? JSON.stringify(items.taxAddress)
: undefined,
currency: items.currency,
uid: items.uid ? Buffer.from(items.uid, 'hex') : undefined,
});
} catch (error) {

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

@ -67,7 +67,12 @@ import {
import { CartManager } from './cart.manager';
import { CartService } from './cart.service';
import { CheckoutService } from './checkout.service';
import { CartInvalidPromoCodeError } from './cart.error';
import {
CartInvalidCurrencyError,
CartInvalidPromoCodeError,
} from './cart.error';
import { CurrencyManager } from '@fxa/payments/currency';
import { MockCurrencyConfigProvider } from 'libs/payments/currency/src/lib/currency.config';
describe('CartService', () => {
let accountCustomerManager: AccountCustomerManager;
@ -75,6 +80,7 @@ describe('CartService', () => {
let cartManager: CartManager;
let checkoutService: CheckoutService;
let customerManager: CustomerManager;
let currencyManager: CurrencyManager;
let promotionCodeManager: PromotionCodeManager;
let eligibilityService: EligibilityService;
let geodbManager: GeoDBManager;
@ -114,6 +120,8 @@ describe('CartService', () => {
StrapiClient,
StripeClient,
SubscriptionManager,
CurrencyManager,
MockCurrencyConfigProvider,
],
}).compile();
@ -122,6 +130,7 @@ describe('CartService', () => {
cartService = moduleRef.get(CartService);
checkoutService = moduleRef.get(CheckoutService);
customerManager = moduleRef.get(CustomerManager);
currencyManager = moduleRef.get(CurrencyManager);
promotionCodeManager = moduleRef.get(PromotionCodeManager);
eligibilityService = moduleRef.get(EligibilityService);
geodbManager = moduleRef.get(GeoDBManager);
@ -147,6 +156,7 @@ describe('CartService', () => {
const taxAddress = TaxAddressFactory();
const mockPrice = StripePriceFactory();
const mockInvoicePreview = InvoicePreviewFactory();
const mockResolvedCurrency = faker.finance.currencyCode();
jest
.spyOn(eligibilityService, 'checkEligibility')
@ -165,6 +175,9 @@ describe('CartService', () => {
jest
.spyOn(promotionCodeManager, 'assertValidPromotionCodeNameForPrice')
.mockResolvedValue(undefined);
jest
.spyOn(currencyManager, 'getCurrencyForCountry')
.mockReturnValue(mockResolvedCurrency);
jest.spyOn(cartManager, 'createCart').mockResolvedValue(mockResultCart);
const result = await cartService.setupCart(args);
@ -177,6 +190,7 @@ describe('CartService', () => {
stripeCustomerId: mockAccountCustomer.stripeCustomerId,
experiment: args.experiment,
taxAddress,
currency: mockResolvedCurrency,
eligibilityStatus: CartEligibilityStatus.CREATE,
});
expect(result).toEqual(mockResultCart);
@ -225,6 +239,56 @@ describe('CartService', () => {
expect(cartManager.createCart).not.toHaveBeenCalled();
});
it('throws an error when country to currency result is invalid', async () => {
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
const mockAccountCustomer = ResultAccountCustomerFactory({
stripeCustomerId: mockCustomer.id,
});
const mockResultCart = ResultCartFactory();
const args = {
interval: SubplatInterval.Monthly,
offeringConfigId: faker.string.uuid(),
experiment: faker.string.uuid(),
promoCode: faker.word.noun(),
uid: faker.string.uuid(),
ip: faker.internet.ipv4(),
};
const taxAddress = TaxAddressFactory();
const mockPrice = StripePriceFactory();
const mockInvoicePreview = InvoicePreviewFactory();
jest
.spyOn(promotionCodeManager, 'assertValidPromotionCodeNameForPrice')
.mockRejectedValue(undefined);
jest
.spyOn(eligibilityService, 'checkEligibility')
.mockResolvedValue(EligibilityStatus.CREATE);
jest.spyOn(geodbManager, 'getTaxAddress').mockReturnValue(taxAddress);
jest
.spyOn(accountCustomerManager, 'getAccountCustomerByUid')
.mockResolvedValue(mockAccountCustomer);
jest
.spyOn(productConfigurationManager, 'retrieveStripePrice')
.mockResolvedValue(mockPrice);
jest.spyOn(customerManager, 'retrieve').mockResolvedValue(mockCustomer);
jest
.spyOn(invoiceManager, 'preview')
.mockResolvedValue(mockInvoicePreview);
jest
.spyOn(promotionCodeManager, 'assertValidPromotionCodeNameForPrice')
.mockResolvedValue(undefined);
jest
.spyOn(currencyManager, 'getCurrencyForCountry')
.mockReturnValue(undefined);
jest.spyOn(cartManager, 'createCart').mockResolvedValue(mockResultCart);
await expect(() => cartService.setupCart(args)).rejects.toThrowError(
CartInvalidCurrencyError
);
expect(cartManager.createCart).not.toHaveBeenCalled();
});
});
describe('restartCart', () => {
@ -253,6 +317,7 @@ describe('CartService', () => {
offeringConfigId: mockOldCart.offeringConfigId,
couponCode: mockOldCart.couponCode,
taxAddress: mockOldCart.taxAddress,
currency: mockOldCart.currency,
stripeCustomerId: mockOldCart.stripeCustomerId,
email: mockOldCart.email,
amount: mockOldCart.amount,
@ -485,6 +550,10 @@ describe('CartService', () => {
const mockPrice = StripePriceFactory();
const mockUpdateCart = UpdateCartFactory({
couponCode: faker.word.noun(),
taxAddress: {
postalCode: faker.location.zipCode(),
countryCode: faker.location.countryCode(),
},
});
beforeEach(async () => {
@ -492,15 +561,16 @@ describe('CartService', () => {
jest
.spyOn(productConfigurationManager, 'retrieveStripePrice')
.mockResolvedValue(mockPrice);
});
it('success if coupon is valid', async () => {
jest
.spyOn(currencyManager, 'getCurrencyForCountry')
.mockReturnValue(faker.finance.currencyCode());
jest
.spyOn(promotionCodeManager, 'assertValidPromotionCodeNameForPrice')
.mockResolvedValue(undefined);
jest.spyOn(cartManager, 'updateFreshCart').mockResolvedValue();
});
it('success if coupon is valid', async () => {
await cartService.updateCart(
mockCart.id,
mockCart.version,
@ -520,7 +590,6 @@ describe('CartService', () => {
.mockImplementation(() => {
throw new CouponErrorExpired();
});
jest.spyOn(cartManager, 'updateFreshCart').mockRejectedValue(undefined);
await expect(
cartService.updateCart(mockCart.id, mockCart.version, mockUpdateCart)
@ -528,6 +597,18 @@ describe('CartService', () => {
expect(cartManager.updateFreshCart).not.toHaveBeenCalledWith();
});
it('throws if country to currency result is not valid', async () => {
jest
.spyOn(currencyManager, 'getCurrencyForCountry')
.mockReturnValue(undefined);
await expect(
cartService.updateCart(mockCart.id, mockCart.version, mockUpdateCart)
).rejects.toBeInstanceOf(CartInvalidCurrencyError);
expect(cartManager.updateFreshCart).not.toHaveBeenCalledWith();
});
});
});

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

@ -17,6 +17,7 @@ import {
StripeCustomer,
} from '@fxa/payments/stripe';
import { ProductConfigurationManager } from '@fxa/shared/cms';
import { CurrencyManager } from '@fxa/payments/currency';
import { CartErrorReasonId, CartState } from '@fxa/shared/db/mysql/account';
import { GeoDBManager } from '@fxa/shared/geodb';
@ -29,7 +30,10 @@ import {
} from './cart.types';
import { handleEligibilityStatusMap } from './cart.utils';
import { CheckoutService } from './checkout.service';
import { CartInvalidPromoCodeError } from './cart.error';
import {
CartInvalidCurrencyError,
CartInvalidPromoCodeError,
} from './cart.error';
@Injectable()
export class CartService {
@ -37,6 +41,7 @@ export class CartService {
private accountCustomerManager: AccountCustomerManager,
private cartManager: CartManager,
private checkoutService: CheckoutService,
private currencyManager: CurrencyManager,
private customerManager: CustomerManager,
private promotionCodeManager: PromotionCodeManager,
private eligibilityService: EligibilityService,
@ -113,6 +118,16 @@ export class CartService {
}
}
let currency: string | undefined;
if (taxAddress?.countryCode) {
currency = this.currencyManager.getCurrencyForCountry(
taxAddress?.countryCode
);
if (!currency) {
throw new CartInvalidCurrencyError(currency, taxAddress.countryCode);
}
}
const cart = await this.cartManager.createCart({
interval: args.interval,
offeringConfigId: args.offeringConfigId,
@ -121,6 +136,7 @@ export class CartService {
stripeCustomerId: accountCustomer?.stripeCustomerId || undefined,
experiment: args.experiment,
taxAddress,
currency,
eligibilityStatus: cartEligibilityStatus,
});
@ -156,6 +172,7 @@ export class CartService {
offeringConfigId: oldCart.offeringConfigId,
experiment: oldCart.experiment || undefined,
taxAddress: oldCart.taxAddress || undefined,
currency: oldCart.currency || undefined,
couponCode: oldCart.couponCode || undefined,
stripeCustomerId: oldCart.stripeCustomerId || undefined,
email: oldCart.email || undefined,
@ -249,6 +266,19 @@ export class CartService {
price
);
}
if (cartDetails.taxAddress?.countryCode) {
cartDetails.currency = this.currencyManager.getCurrencyForCountry(
cartDetails.taxAddress?.countryCode
);
if (!cartDetails.currency) {
throw new CartInvalidCurrencyError(
cartDetails.currency,
cartDetails.taxAddress.countryCode
);
}
}
await this.cartManager.updateFreshCart(cartId, version, cartDetails);
}

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

@ -54,6 +54,7 @@ export type SetupCart = {
offeringConfigId: string;
experiment?: string;
taxAddress?: TaxAddress;
currency?: string;
couponCode?: string;
stripeCustomerId?: string;
email?: string;
@ -70,6 +71,7 @@ export interface TaxAmount {
export type UpdateCart = {
uid?: string;
taxAddress?: TaxAddress;
currency?: string;
couponCode?: string;
email?: string;
stripeCustomerId?: string;

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

@ -68,6 +68,7 @@ import {
CartTotalMismatchError,
CartEmailNotFoundError,
CartInvalidPromoCodeError,
CartInvalidCurrencyError,
} from './cart.error';
import { CheckoutService } from './checkout.service';
@ -347,6 +348,18 @@ describe('CheckoutService', () => {
).rejects.toBeInstanceOf(CartEmailNotFoundError);
});
it('throws cart currency invalid error', async () => {
const mockCart = StripeResponseFactory(
ResultCartFactory({
currency: null,
})
);
await expect(
checkoutService.prePaySteps(mockCart, mockCustomerData)
).rejects.toBeInstanceOf(CartInvalidCurrencyError);
});
it('throws cart eligibility mismatch error', async () => {
const mockCart = StripeResponseFactory(
ResultCartFactory({

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

@ -34,6 +34,7 @@ import {
CartEligibilityMismatchError,
CartEmailNotFoundError,
CartInvalidPromoCodeError,
CartInvalidCurrencyError,
} from './cart.error';
import { CartManager } from './cart.manager';
import { CheckoutCustomerData, ResultCart } from './cart.types';
@ -65,6 +66,13 @@ export class CheckoutService {
throw new CartEmailNotFoundError(cart.id);
}
if (!cart.currency) {
throw new CartInvalidCurrencyError(
cart.currency || undefined,
taxAddress.countryCode
);
}
// if uid not found, create stub account customer
// TODO: update hardcoded verifierVersion
// https://mozilla-hub.atlassian.net/browse/FXA-9693

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

@ -13,10 +13,18 @@ export class CurrencyConfig {
)
@IsObject()
public readonly taxIds!: { [key: string]: string };
@Transform(
({ value }) => (value instanceof Object ? value : JSON.parse(value)),
{ toClassOnly: true }
)
@IsObject()
public readonly currenciesToCountries!: { [key: string]: string[] };
}
export const MockCurrencyConfig = {
taxIds: { EUR: 'EU1234' },
currenciesToCountries: { USD: ['US'] },
} satisfies CurrencyConfig;
export const MockCurrencyConfigProvider = {

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

@ -60,4 +60,16 @@ export class CurrencyManager {
getTaxId(currency: string) {
return this.taxIds[currency.toUpperCase()];
}
getCurrencyForCountry(country: string) {
for (const [currency, countries] of Object.entries(
this.config.currenciesToCountries
)) {
if (countries.includes(country)) {
return currency;
}
}
return undefined;
}
}

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

@ -14,11 +14,8 @@ export type CommonMetrics = {
};
export type CartMetrics = Partial<
Pick<ResultCart, 'uid' | 'errorReasonId' | 'couponCode'>
> & {
//TODO - Replace on completion of FXA-7584 and pick from ResultCart
currency: string;
};
Pick<ResultCart, 'uid' | 'errorReasonId' | 'couponCode' | 'currency'>
>;
export type FxaPaySetupMetrics = CommonMetrics & CartMetrics;

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

@ -42,6 +42,7 @@ import {
import { RootConfig } from './config';
import { NextJSActionsService } from './nextjs-actions.service';
import { validate } from '../config.utils';
import { CurrencyManager } from '@fxa/payments/currency';
@Module({
imports: [
@ -71,6 +72,7 @@ import { validate } from '../config.utils';
CartService,
CheckoutTokenManager,
CustomerManager,
CurrencyManager,
CheckoutService,
EligibilityManager,
EligibilityService,

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

@ -13,6 +13,7 @@ import { StrapiClientConfig } from '@fxa/shared/cms';
import { FirestoreConfig } from 'libs/shared/db/firestore/src/lib/firestore.config';
import { StatsDConfig } from 'libs/shared/metrics/statsd/src/lib/statsd.config';
import { PaymentsGleanConfig } from '@fxa/payments/metrics';
import { CurrencyConfig } from 'libs/payments/currency/src/lib/currency.config';
export class RootConfig {
@Type(() => MySQLConfig)
@ -39,6 +40,11 @@ export class RootConfig {
@IsDefined()
public readonly paypalClientConfig!: Partial<PaypalClientConfig>;
@Type(() => CurrencyConfig)
@ValidateNested()
@IsDefined()
public readonly currencyConfig!: Partial<CurrencyConfig>;
@Type(() => StrapiClientConfig)
@ValidateNested()
@IsDefined()

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

@ -30,6 +30,7 @@ export const CartFactory = (override?: Partial<NewCart>): NewCart => ({
'semiannually',
'annually',
]),
currency: faker.finance.currencyCode(),
createdAt: faker.date.recent().getTime(),
updatedAt: faker.date.recent().getTime(),
amount: faker.number.int(10000),

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

@ -95,6 +95,7 @@ export interface Carts {
countryCode: string;
postalCode: string;
}> | null;
currency: string | null;
createdAt: number;
updatedAt: number;
couponCode: string | null;

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

@ -7,6 +7,7 @@ CREATE TABLE `carts` (
`interval` varchar(255) COLLATE utf8mb4_bin NOT NULL,
`experiment` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL,
`taxAddress` json DEFAULT NULL,
`currency` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL,
`createdAt` bigint unsigned NOT NULL,
`updatedAt` bigint unsigned NOT NULL,
`couponCode` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL,

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

@ -0,0 +1,6 @@
-- Add `currency` column to the `carts` table.
ALTER TABLE carts
ADD COLUMN currency VARCHAR(255) AFTER taxAddress,
ALGORITHM = INPLACE, LOCK = NONE;
UPDATE dbMetadata SET value = '155' WHERE name = 'schema-patch-level';

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

@ -0,0 +1,5 @@
-- Drop `currency` column from the `carts` table.
-- ALTER TABLE carts DROP COLUMN currency,
-- ALGORITHM = INPLACE, LOCK = NONE;
--
-- UPDATE dbMetadata SET value = '154' WHERE name = 'schema-patch-level';

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

@ -1,3 +1,3 @@
{
"level": 153
"level": 155
}