From 9dc5416f1ed05af18adfe22bbc2ec8c91b900dd4 Mon Sep 17 00:00:00 2001 From: Javi Date: Tue, 22 Sep 2026 11:55:23 +0200 Subject: [PATCH] feat: new currencies available --- .../services/checkout.service.test.ts | 177 ++++++++++++++++++ .../Checkout/services/checkout.service.ts | 60 +++++- .../services/currency.service.test.ts | 56 ++++++ .../Checkout/services/currency.service.ts | 3 + src/views/Checkout/types/index.ts | 6 + 5 files changed, 298 insertions(+), 4 deletions(-) diff --git a/src/views/Checkout/services/checkout.service.test.ts b/src/views/Checkout/services/checkout.service.test.ts index a4c70fce81..3812df8556 100644 --- a/src/views/Checkout/services/checkout.service.test.ts +++ b/src/views/Checkout/services/checkout.service.test.ts @@ -91,6 +91,45 @@ vi.mock('utils/userLocation', () => ({ }), })); +const THEME = { + backgroundColor: '#000', + textColor: '#fff', + borderColor: '#ccc', + borderInputColor: '#aaa', + labelTextColor: '#eee', +}; + +const buildPlan = ({ + currency, + interval, + decimalAmountWithTax, + decimalAmount, +}: { + currency: string; + interval: string; + decimalAmountWithTax?: number; + decimalAmount?: number; +}) => + ({ + price: { + interval, + currency, + ...(decimalAmount !== undefined && { decimalAmount }), + }, + ...(decimalAmountWithTax !== undefined && { + taxes: { + amountWithTax: 1500, + decimalAmountWithTax, + }, + }), + }) as any; + +const getPaymentMethodsFor = async (plan: ReturnType) => { + const options = await checkoutService.loadStripeElements(THEME, plan); + + return options.payment_method_types; +}; + describe('Checkout Service tests', () => { beforeEach(() => { vi.clearAllMocks(); @@ -332,6 +371,144 @@ describe('Checkout Service tests', () => { payment_method_types: ['card', 'paypal'], }); }); + + describe('Payment methods per currency and interval', () => { + test('When the currency is EUR and the interval is monthly, then PayPal is allowed alongside card', async () => { + const result = await getPaymentMethodsFor(buildPlan({ currency: 'eur', interval: 'month' })); + + expect(result).toStrictEqual(['card', 'paypal']); + }); + + test('When the currency is EUR and the interval is lifetime, then Klarna is allowed as well', async () => { + const result = await getPaymentMethodsFor(buildPlan({ currency: 'eur', interval: 'lifetime' })); + + expect(result).toStrictEqual(['card', 'paypal', 'klarna']); + }); + + test('When the currency is USD and the interval is monthly, then PayPal is allowed alongside card', async () => { + const result = await getPaymentMethodsFor(buildPlan({ currency: 'usd', interval: 'month' })); + + expect(result).toStrictEqual(['card', 'paypal']); + }); + + test('When the currency is USD and the interval is lifetime, then Klarna is not allowed', async () => { + const result = await getPaymentMethodsFor(buildPlan({ currency: 'usd', interval: 'lifetime' })); + + expect(result).toStrictEqual(['card', 'paypal']); + }); + + test('When the currency is INR and the interval is monthly, then UPI is allowed alongside card', async () => { + const result = await getPaymentMethodsFor(buildPlan({ currency: 'inr', interval: 'month' })); + + expect(result).toStrictEqual(['card', 'upi']); + }); + + test('When the currency is BRL and the interval is monthly, then PIX is allowed alongside card', async () => { + const result = await getPaymentMethodsFor(buildPlan({ currency: 'brl', interval: 'month' })); + + expect(result).toStrictEqual(['card', 'pix']); + }); + + test.each([ + ['eur', ['card', 'paypal']], + ['usd', ['card', 'paypal']], + ['inr', ['card', 'upi']], + ['brl', ['card', 'pix']], + ])( + 'When the currency is %s and the interval is yearly, then its alternative method is allowed alongside card', + async (currency, expected) => { + const result = await getPaymentMethodsFor(buildPlan({ currency: currency as string, interval: 'year' })); + + expect(result).toStrictEqual(expected); + }, + ); + + test('When the interval is yearly, then the amount caps do not apply', async () => { + const result = await getPaymentMethodsFor( + buildPlan({ currency: 'inr', interval: 'year', decimalAmountWithTax: 150000 }), + ); + + expect(result).toStrictEqual(['card', 'upi']); + }); + + test('When the currency is not supported, then it falls back to card only', async () => { + const result = await getPaymentMethodsFor(buildPlan({ currency: 'gbp', interval: 'month' })); + + expect(result).toStrictEqual(['card']); + }); + }); + + describe('Payment methods capped by the charged amount', () => { + test('When an INR lifetime amount is below the UPI cap, then UPI is kept', async () => { + const result = await getPaymentMethodsFor( + buildPlan({ currency: 'inr', interval: 'lifetime', decimalAmountWithTax: 50000 }), + ); + + expect(result).toStrictEqual(['card', 'upi']); + }); + + test('When an INR lifetime amount is exactly the UPI cap, then UPI is dropped', async () => { + const result = await getPaymentMethodsFor( + buildPlan({ currency: 'inr', interval: 'lifetime', decimalAmountWithTax: 99000 }), + ); + + expect(result).toStrictEqual(['card']); + }); + + test('When an INR lifetime amount exceeds the UPI cap, then UPI is dropped', async () => { + const result = await getPaymentMethodsFor( + buildPlan({ currency: 'inr', interval: 'lifetime', decimalAmountWithTax: 150000 }), + ); + + expect(result).toStrictEqual(['card']); + }); + + test('When a BRL lifetime amount is below the PIX cap, then PIX is kept', async () => { + const result = await getPaymentMethodsFor( + buildPlan({ currency: 'brl', interval: 'lifetime', decimalAmountWithTax: 5000 }), + ); + + expect(result).toStrictEqual(['card', 'pix']); + }); + + test('When a BRL lifetime amount is exactly the PIX cap, then PIX is dropped', async () => { + const result = await getPaymentMethodsFor( + buildPlan({ currency: 'brl', interval: 'lifetime', decimalAmountWithTax: 14600 }), + ); + + expect(result).toStrictEqual(['card']); + }); + + test('When a BRL lifetime amount exceeds the PIX cap, then PIX is dropped', async () => { + const result = await getPaymentMethodsFor( + buildPlan({ currency: 'brl', interval: 'lifetime', decimalAmountWithTax: 20000 }), + ); + + expect(result).toStrictEqual(['card']); + }); + + test('When the amount is large but the currency has no caps, then every method is kept', async () => { + const result = await getPaymentMethodsFor( + buildPlan({ currency: 'eur', interval: 'lifetime', decimalAmountWithTax: 150000 }), + ); + + expect(result).toStrictEqual(['card', 'paypal', 'klarna']); + }); + + test('When there are no taxes, then the cap is applied to the price decimal amount', async () => { + const result = await getPaymentMethodsFor( + buildPlan({ currency: 'inr', interval: 'lifetime', decimalAmount: 150000 }), + ); + + expect(result).toStrictEqual(['card']); + }); + + test('When no amount is available, then the capped method is kept', async () => { + const result = await getPaymentMethodsFor(buildPlan({ currency: 'inr', interval: 'lifetime' })); + + expect(result).toStrictEqual(['card', 'upi']); + }); + }); }); describe('Track Incomplete Checkout', () => { diff --git a/src/views/Checkout/services/checkout.service.ts b/src/views/Checkout/services/checkout.service.ts index 4f909f3e1b..b1f751343a 100644 --- a/src/views/Checkout/services/checkout.service.ts +++ b/src/views/Checkout/services/checkout.service.ts @@ -16,8 +16,63 @@ import errorService from 'services/error.service'; import { bytesToString } from 'app/drive/services/size.service'; import userService from 'services/user.service'; import encryptedStorageService from 'services/encrypted-storage.service'; +import { CurrencyCode, PlanInterval, PaymentMethod } from '../types'; const BORDER_SHADOW = 'rgb(0 102 255)'; +const UPI_MAX_DECIMAL_AMOUNT = 99000; +const PIX_MAX_DECIMAL_AMOUNT = 14600; + +const PAYMENT_METHOD_LIMITS: Partial< + Record>>>> +> = { + inr: { + [PlanInterval.LIFETIME]: { upi: UPI_MAX_DECIMAL_AMOUNT }, + }, + brl: { + [PlanInterval.LIFETIME]: { pix: PIX_MAX_DECIMAL_AMOUNT }, + }, +}; + +const PAYMENT_METHODS: Record> = { + eur: { + [PlanInterval.MONTH]: ['card', 'paypal'], + [PlanInterval.LIFETIME]: ['card', 'paypal', 'klarna'], + [PlanInterval.YEAR]: ['card', 'paypal'], + }, + usd: { + [PlanInterval.MONTH]: ['card', 'paypal'], + [PlanInterval.LIFETIME]: ['card', 'paypal'], + [PlanInterval.YEAR]: ['card', 'paypal'], + }, + inr: { + [PlanInterval.MONTH]: ['card', 'upi'], + [PlanInterval.LIFETIME]: ['card', 'upi'], + [PlanInterval.YEAR]: ['card', 'upi'], + }, + brl: { + [PlanInterval.MONTH]: ['card', 'pix'], + [PlanInterval.LIFETIME]: ['card', 'pix'], + [PlanInterval.YEAR]: ['card', 'pix'], + }, +}; + +const getPaymentMethods = (plan: PriceWithTax): PaymentMethod[] => { + const currency = plan.price?.currency as CurrencyCode; + const interval = plan.price?.interval as PlanInterval; + + const methods = PAYMENT_METHODS[currency]?.[interval] ?? ['card']; + const limits = PAYMENT_METHOD_LIMITS[currency]?.[interval]; + + if (!limits) return methods; + + const amount = plan.taxes?.decimalAmountWithTax ?? plan.price?.decimalAmount ?? 0; + const allowed = methods.filter((method) => { + const max = limits[method]; + return max === undefined || amount < max; + }); + + return allowed.length > 0 ? allowed : ['card']; +}; const fetchPromotionCodeByName = async (priceId: string, promotionCodeName: string): Promise => { const paymentClient = await SdkFactory.getNewApiInstance().createPaymentsClient(); @@ -216,10 +271,7 @@ const loadStripeElements = async ( mode: plan.price?.interval === 'lifetime' ? 'payment' : 'subscription', amount: plan.taxes?.amountWithTax, currency: plan.price?.currency, - payment_method_types: - plan?.price?.interval === 'lifetime' && plan.price?.currency === 'eur' - ? ['card', 'paypal', 'klarna'] - : ['card', 'paypal'], + payment_method_types: getPaymentMethods(plan), }; }; diff --git a/src/views/Checkout/services/currency.service.test.ts b/src/views/Checkout/services/currency.service.test.ts index 65eb244d13..0eface5ca9 100644 --- a/src/views/Checkout/services/currency.service.test.ts +++ b/src/views/Checkout/services/currency.service.test.ts @@ -61,6 +61,30 @@ describe('Currency Service', () => { expect(result).toStrictEqual('usd'); }); + test('When location is mapped to usd but is not the US, then usd is returned', () => { + const result = currencyService.getCurrencyForLocation('CA'); + + expect(result).toStrictEqual('usd'); + }); + + test('When location is India, then inr is returned', () => { + const result = currencyService.getCurrencyForLocation('IN'); + + expect(result).toStrictEqual('inr'); + }); + + test('When location is Brazil, then brl is returned', () => { + const result = currencyService.getCurrencyForLocation('BR'); + + expect(result).toStrictEqual('brl'); + }); + + test('When location is mapped and a fallback currency is provided, then the mapped currency wins', () => { + const result = currencyService.getCurrencyForLocation('IN', 'eur'); + + expect(result).toStrictEqual('inr'); + }); + test('When location is not mapped and fallback currency is provided, then fallback currency is returned', () => { const fallbackCurrency = 'gbp'; const result = currencyService.getCurrencyForLocation('ES', fallbackCurrency); @@ -80,4 +104,36 @@ describe('Currency Service', () => { expect(result).toStrictEqual('eur'); }); }); + + describe('Get currency symbol', () => { + test('When the currency is brl, then the Brazilian real symbol is returned', () => { + const result = currencyService.getCurrencySymbol('brl'); + + expect(result).toStrictEqual('R$'); + }); + + test('When the currency is inr, then the Indian rupee symbol is returned', () => { + const result = currencyService.getCurrencySymbol('inr'); + + expect(result).toStrictEqual('₹'); + }); + + test('When the currency is eur, then the euro symbol is returned', () => { + const result = currencyService.getCurrencySymbol('eur'); + + expect(result).toStrictEqual('€'); + }); + + test('When the currency is uppercase, then the symbol is still returned', () => { + const result = currencyService.getCurrencySymbol('BRL'); + + expect(result).toStrictEqual('R$'); + }); + + test('When the currency is an empty string, then the euro symbol is returned', () => { + const result = currencyService.getCurrencySymbol(''); + + expect(result).toStrictEqual('€'); + }); + }); }); diff --git a/src/views/Checkout/services/currency.service.ts b/src/views/Checkout/services/currency.service.ts index 272b1773e7..cbfc028915 100644 --- a/src/views/Checkout/services/currency.service.ts +++ b/src/views/Checkout/services/currency.service.ts @@ -4,6 +4,7 @@ import { SdkFactory } from 'app/core/factory/sdk'; enum CurrencySymbol { USD = '$', // US Dollar EUR = '€', // Euro + BRL = 'R$', // Brazilian Real CRC = '₡', // Costa Rican Colón GBP = '£', // British Pound Sterling ILS = '₪', // Israeli New Sheqel @@ -28,6 +29,8 @@ const CURRENCY_BY_COUNTRY: Record = { BH: 'usd', // Bahrain OM: 'usd', // Oman IR: 'usd', // Iran + IN: 'inr', // India + BR: 'brl', // Brazil }; const DEFAULT_CURRENCY = 'eur'; diff --git a/src/views/Checkout/types/index.ts b/src/views/Checkout/types/index.ts index e738368e9a..acd61ed05c 100644 --- a/src/views/Checkout/types/index.ts +++ b/src/views/Checkout/types/index.ts @@ -7,6 +7,8 @@ import { Translate } from 'app/i18n/types'; export enum Currency { 'eur' = '€', 'usd' = '$', + 'inr' = '₹', + 'brl' = 'R$', } export interface ProductMetadata { @@ -79,6 +81,10 @@ export type ErrorType = 'auth' | 'stripe' | 'coupon'; export type PartialErrorState = Partial>; +export type CurrencyCode = keyof typeof Currency; + +export type PaymentMethod = 'card' | 'paypal' | 'klarna' | 'upi' | 'pix'; + export interface CreatePaymentIntentPayload { customerId: string; priceId: string;