Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions src/views/Checkout/services/checkout.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof buildPlan>) => {
const options = await checkoutService.loadStripeElements(THEME, plan);

return options.payment_method_types;
};

describe('Checkout Service tests', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -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', () => {
Expand Down
60 changes: 56 additions & 4 deletions src/views/Checkout/services/checkout.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CurrencyCode, Partial<Record<PlanInterval, Partial<Record<PaymentMethod, number>>>>>
> = {
inr: {
[PlanInterval.LIFETIME]: { upi: UPI_MAX_DECIMAL_AMOUNT },
},
brl: {
[PlanInterval.LIFETIME]: { pix: PIX_MAX_DECIMAL_AMOUNT },
},
};

const PAYMENT_METHODS: Record<CurrencyCode, Record<PlanInterval, PaymentMethod[]>> = {
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<CouponCodeData> => {
const paymentClient = await SdkFactory.getNewApiInstance().createPaymentsClient();
Expand Down Expand Up @@ -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),
};
};

Expand Down
56 changes: 56 additions & 0 deletions src/views/Checkout/services/currency.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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('€');
});
});
});
3 changes: 3 additions & 0 deletions src/views/Checkout/services/currency.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,6 +29,8 @@ const CURRENCY_BY_COUNTRY: Record<string, string> = {
BH: 'usd', // Bahrain
OM: 'usd', // Oman
IR: 'usd', // Iran
IN: 'inr', // India
BR: 'brl', // Brazil
};

const DEFAULT_CURRENCY = 'eur';
Expand Down
6 changes: 6 additions & 0 deletions src/views/Checkout/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { Translate } from 'app/i18n/types';
export enum Currency {
'eur' = '€',
'usd' = '$',
'inr' = '₹',
'brl' = 'R$',
}

export interface ProductMetadata {
Expand Down Expand Up @@ -79,6 +81,10 @@ export type ErrorType = 'auth' | 'stripe' | 'coupon';

export type PartialErrorState = Partial<Record<ErrorType, string>>;

export type CurrencyCode = keyof typeof Currency;

export type PaymentMethod = 'card' | 'paypal' | 'klarna' | 'upi' | 'pix';

export interface CreatePaymentIntentPayload {
customerId: string;
priceId: string;
Expand Down
Loading