diff --git a/docs/CURRENCY_CONVERSIONS.md b/docs/CURRENCY_CONVERSIONS.md index 45ba1b1a..c31932bd 100644 --- a/docs/CURRENCY_CONVERSIONS.md +++ b/docs/CURRENCY_CONVERSIONS.md @@ -14,6 +14,7 @@ SplitPro can convert expenses and balances between currencies using cached excha - Rates are fetched from the configured provider and cached in the database. - Conversions can be created for an expense and later edited if needed. - Group balances can be converted on demand without changing the original expense currency. +- Currency rate precisions are set to the number of digits obtained from the provider. ## Providers and limitations @@ -22,22 +23,24 @@ Exchange rate APIs are typically rate limited or paywalled. SplitPro supports th - Frankfurter (https://frankfurter.dev/) - Free, but limited currency set. - Check availability at https://api.frankfurter.dev/v1/currencies. + - Responses truncate trailing zeros in rate values, which may limit the precision input. - Open Exchange Rates (https://openexchangerates.org/) - Free tier includes 1000 requests/day and wide currency coverage. - Free tier uses USD as the base currency; SplitPro joins rates internally. - Requires an API key. + - Responses truncate trailing zeros in rate values, which may limit the precision input. - NBP (https://api.nbp.pl/en.html) - Polish National Bank. - No API key required, base currency PLN. - - Table A updates daily, table B (less common currencies) updates weekly. + - Table A updates daily, table B (currencies less common for Poland) updates weekly. ## Provider comparison -| Provider | Base currency | Coverage | API key | Notes | -| ------------------- | ------------- | -------- | ------- | -------------------------- | -| Frankfurter | EUR | Limited | No | Free, limited currency set | -| Open Exchange Rates | USD | Broad | Yes | Free tier 1000 req/day | -| NBP | PLN | Medium | No | Table B updates weekly | +| Provider | Base currency | Coverage | API key | Trailing zeros | Notes | +| ------------------- | ------------- | -------- | ------- | -------------- | -------------------------- | +| Frankfurter | EUR | Limited | No | Truncated | Free, limited currency set | +| Open Exchange Rates | USD | Broad | Yes | Truncated | Free tier 1000 req/day | +| NBP | PLN | Medium | No | Preserved | Table B updates weekly | ## Configuration @@ -47,6 +50,7 @@ Set your provider in `CURRENCY_RATE_PROVIDER` and supply `OPEN_EXCHANGE_RATES_AP - Rates are cached in the database to minimize calls and stay within provider limits. - Provider coverage and rate freshness depend on the selected provider. +- Frankfurter and Open Exchange Rates truncate trailing zeros in rate values, which limits the reported precision for those responses. ## Common use cases diff --git a/prisma/migrations/20260209204650_add_cached_currency_rate_precision/migration.sql b/prisma/migrations/20260209204650_add_cached_currency_rate_precision/migration.sql new file mode 100644 index 00000000..ab8bf090 --- /dev/null +++ b/prisma/migrations/20260209204650_add_cached_currency_rate_precision/migration.sql @@ -0,0 +1,6 @@ +-- Clear cached currency rates to repopulate with precision +TRUNCATE TABLE "CachedCurrencyRate"; + +-- Add precision to cached currency rates +ALTER TABLE "CachedCurrencyRate" +ADD COLUMN "precision" INTEGER NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7e98901f..27b54fd3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -210,6 +210,7 @@ model CachedCurrencyRate { to String date DateTime rate Float + precision Int lastFetched DateTime createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/components/AddExpense/AddExpensePage.tsx b/src/components/AddExpense/AddExpensePage.tsx index 03145441..9f67ca94 100644 --- a/src/components/AddExpense/AddExpensePage.tsx +++ b/src/components/AddExpense/AddExpensePage.tsx @@ -223,7 +223,7 @@ export const AddOrEditExpensePage: React.FC<{ const previousCurrencyRef = React.useRef(null); const onConvertAmount: React.ComponentProps['onSubmit'] = useCallback( - ({ amount: absAmount, rate }) => { + ({ amount: absAmount, rate, ratePrecision }) => { if (!previousCurrencyRef.current) { return; } @@ -233,6 +233,7 @@ export const AddOrEditExpensePage: React.FC<{ currencyConversion({ amount: absAmount, rate, + ratePrecision, from: previousCurrencyRef.current, to: currency, }); diff --git a/src/components/Expense/ConvertibleBalance.tsx b/src/components/Expense/ConvertibleBalance.tsx index 42261e9d..c1bfac0d 100644 --- a/src/components/Expense/ConvertibleBalance.tsx +++ b/src/components/Expense/ConvertibleBalance.tsx @@ -113,7 +113,8 @@ export const ConvertibleBalance: React.FC = ({ from: balance.currency, to: selectedCurrency, amount: balance.amount, - rate, + rate: rate.rate, + ratePrecision: rate.precision, }); total += convertedValue; } diff --git a/src/components/Friend/CurrencyConversion.tsx b/src/components/Friend/CurrencyConversion.tsx index 44e7cf59..d3e87be1 100644 --- a/src/components/Friend/CurrencyConversion.tsx +++ b/src/components/Friend/CurrencyConversion.tsx @@ -1,7 +1,7 @@ import React, { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; import { api } from '~/utils/api'; -import { currencyConversion } from '~/utils/numbers'; +import { currencyConversion, getRatePrecision } from '~/utils/numbers'; import { toast } from 'sonner'; import { env } from '~/env'; @@ -26,6 +26,7 @@ export const CurrencyConversion: React.FC<{ to: CurrencyCode; amount: bigint; rate: number; + ratePrecision: number; }) => Promise | void; }> = ({ amount, editingRate, editingTargetCurrency, currency, children, onSubmit }) => { const { t, getCurrencyHelpersCached } = useTranslationWithUtils(); @@ -34,6 +35,7 @@ export const CurrencyConversion: React.FC<{ const [amountStr, setAmountStr] = useState(''); const [rate, setRate] = useState(''); + const [ratePrecision, setRatePrecision] = useState(0); const [targetAmountStr, setTargetAmountStr] = useState(''); const preferredCurrency = useAddExpenseStore((state) => state.currency); const { setCurrency } = useAddExpenseStore((state) => state.actions); @@ -49,13 +51,21 @@ export const CurrencyConversion: React.FC<{ useEffect(() => { if (getCurrencyRate.isPending) { setRate(''); + setRatePrecision(0); setTargetAmountStr(''); } }, [getCurrencyRate.isPending]); useEffect(() => { setAmountStr(toUIString(amount, false, true)); - setRate(editingRate ? editingRate.toFixed(4) : ''); + if (editingRate) { + const precision = Number.isFinite(editingRate) ? getRatePrecision(editingRate.toString()) : 0; + setRate(editingRate.toFixed(precision)); + setRatePrecision(precision); + } else { + setRate(''); + setRatePrecision(0); + } if (editingTargetCurrency && isCurrencyCode(editingTargetCurrency)) { setTargetCurrency(editingTargetCurrency); } @@ -63,7 +73,8 @@ export const CurrencyConversion: React.FC<{ useEffect(() => { if (getCurrencyRate.data?.rate) { - setRate(getCurrencyRate.data.rate.toFixed(4)); + setRate(getCurrencyRate.data.rate.toFixed(getCurrencyRate.data.precision)); + setRatePrecision(getCurrencyRate.data.precision); } }, [getCurrencyRate.data]); @@ -79,9 +90,10 @@ export const CurrencyConversion: React.FC<{ to: targetCurrency, amount: toSafeBigInt(amountStr), rate: Number(rate), + ratePrecision, }); setTargetAmountStr(toUITargetString(targetAmount, false, true)); - }, [amountStr, rate, toSafeBigInt, toUITargetString, currency, targetCurrency]); + }, [amountStr, rate, ratePrecision, toSafeBigInt, toUITargetString, currency, targetCurrency]); const onUpdateAmount = useCallback( ({ strValue }: { strValue?: string; bigIntValue?: bigint }) => { @@ -97,6 +109,7 @@ export const CurrencyConversion: React.FC<{ // Allow empty while typing if (raw === '') { setRate(''); + setRatePrecision(0); return; } // Only digits and optional dot @@ -104,14 +117,16 @@ export const CurrencyConversion: React.FC<{ return; } const [int = '', dec = ''] = raw.split('.'); - const trimmedDec = dec.slice(0, 4); - const normalized = raw.includes('.') ? `${int}.${trimmedDec}` : int; + const precision = raw.includes('.') ? dec.length : 0; + const normalized = raw.includes('.') ? `${int}.${dec}` : int; setRate(normalized); + setRatePrecision(precision); }, []); const onChangeTargetCurrency = useCallback( (currency: CurrencyCode) => { setRate(''); + setRatePrecision(0); setTargetCurrency(currency); setCurrency(currency); }, @@ -124,13 +139,14 @@ export const CurrencyConversion: React.FC<{ const amount = currencyConversion({ amount: bigIntValue ?? 0n, rate: 1 / Number(rate), + ratePrecision, from: targetCurrency, to: currency, }); setAmountStr(toUIString(amount, false, true)); } }, - [rate, toUIString, targetCurrency, currency], + [rate, ratePrecision, toUIString, targetCurrency, currency], ); const onSave = useCallback(async () => { @@ -143,6 +159,7 @@ export const CurrencyConversion: React.FC<{ await onSubmit({ amount: getCurrencyHelpersCached(currency).toSafeBigInt(amountStr), rate: Number(rate), + ratePrecision, from: currency, to: targetCurrency, }); @@ -151,7 +168,16 @@ export const CurrencyConversion: React.FC<{ console.error(error); toast.error(t('errors.currency_conversion_error')); } - }, [onSubmit, targetCurrency, amountStr, rate, currency, getCurrencyHelpersCached, t]); + }, [ + onSubmit, + targetCurrency, + amountStr, + rate, + ratePrecision, + currency, + getCurrencyHelpersCached, + t, + ]); return ( )} - {!!rate && ( + {Boolean(rate) && ( <> - 1 {currency} = {Number(rate).toFixed(4)} {targetCurrency} + 1 {currency} = {Number(rate).toFixed(ratePrecision)} {targetCurrency} - 1 {targetCurrency} = {(1 / Number(rate)).toFixed(4)} {currency} + 1 {targetCurrency} = {(1 / Number(rate)).toFixed(ratePrecision)} {currency} )} diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index dd9251f7..05a1abd4 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -14,7 +14,7 @@ import { getCurrencyRateSchema, } from '~/types/expense.types'; import { createExpense, deleteExpense, editExpense } from '../services/splitService'; -import { currencyRateProvider } from '../services/currencyRateService'; +import { type ParsedRate, currencyRateProvider } from '../services/currencyRateService'; import { type CurrencyCode, isCurrencyCode } from '~/lib/currency'; import { SplitType } from '@prisma/client'; import { DEFAULT_CATEGORY } from '~/lib/category'; @@ -103,7 +103,7 @@ export const expenseRouter = createTRPCRouter({ if (!acc[friendId]) { acc[friendId] = []; } - acc[friendId]!.push({ currency, amount }); + acc[friendId].push({ currency, amount }); return acc; }, {}); @@ -168,13 +168,14 @@ export const expenseRouter = createTRPCRouter({ addOrEditCurrencyConversion: protectedProcedure .input(createCurrencyConversionSchema) .mutation(async ({ input, ctx }) => { - const { amount, rate, from, to, senderId, receiverId, groupId, expenseId } = input; + const { amount, rate, ratePrecision, from, to, senderId, receiverId, groupId, expenseId } = + input; if (!isCurrencyCode(from) || !isCurrencyCode(to)) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid currency code' }); } - const amountTo = currencyConversion({ from, to, amount, rate }); + const amountTo = currencyConversion({ from, to, amount, rate, ratePrecision }); const name = `${from} → ${to} @ ${rate}`; const conversionFrom = { @@ -583,7 +584,7 @@ export const expenseRouter = createTRPCRouter({ const rate = await currencyRateProvider.getCurrencyRate(from, to, date); - return { rate }; + return rate; }), getBatchCurrencyRates: protectedProcedure @@ -599,7 +600,7 @@ export const expenseRouter = createTRPCRouter({ throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid currency code' }); } - const rates = new Map(); + const rates = new Map(); // Get the first rate to precache rates for the target currency const rate = await currencyRateProvider.getCurrencyRate(from[0] as CurrencyCode, to, date); @@ -609,11 +610,7 @@ export const expenseRouter = createTRPCRouter({ // Fetch rates for remaining currencies and return as map await Promise.all( from.slice(1).map(async (currency) => { - const r = await currencyRateProvider.getCurrencyRate( - currency as CurrencyCode, - to, - date, - ); + const r = await currencyRateProvider.getCurrencyRate(currency, to, date); rates.set(currency, r); }), ); diff --git a/src/server/api/services/currencyRateService.ts b/src/server/api/services/currencyRateService.ts index 09f807d6..86095369 100644 --- a/src/server/api/services/currencyRateService.ts +++ b/src/server/api/services/currencyRateService.ts @@ -5,12 +5,17 @@ import { db } from '~/server/db'; export interface RateResponse { base: string; - rates: { [key: string]: number }; + rates: { [key: string]: string }; +} + +export interface ParsedRate { + rate: number; + precision: number; } class ProviderMissingError extends Error {} -abstract class CurrencyRateProvider { +export abstract class CurrencyRateProvider { intermediateBase: CurrencyCode | null = null; abstract providerName: string; @@ -20,9 +25,9 @@ abstract class CurrencyRateProvider { from: CurrencyCode, to: CurrencyCode, date: Date = new Date(), - ): Promise { + ): Promise { if (from === to) { - return 1; + return { rate: 1, precision: 0 }; } const cachedRate = await this.checkCache(from, to, date); @@ -34,7 +39,12 @@ abstract class CurrencyRateProvider { await Promise.all( Object.entries(data.rates).map(([to, rate]) => - this.upsertCache(data.base as CurrencyCode, to as CurrencyCode, date, rate), + this.upsertCache( + data.base as CurrencyCode, + to as CurrencyCode, + date, + this.toParsedRate(rate), + ), ), ); @@ -50,36 +60,41 @@ abstract class CurrencyRateProvider { from: CurrencyCode, to: CurrencyCode, date: Date = new Date(), - ): Promise { + ): Promise { const cachedRate = await this.getCache(from, to, date); if (cachedRate) { - return cachedRate.rate; + return { rate: cachedRate.rate, precision: cachedRate.precision }; } const reverseCachedRate = await this.getCache(to, from, date); if (reverseCachedRate) { - void this.upsertCache(from, to, date, 1 / reverseCachedRate.rate); - return 1 / reverseCachedRate.rate; + const invertedRate = this.roundRate(1 / reverseCachedRate.rate, reverseCachedRate.precision); + void this.upsertCache(from, to, date, { + rate: invertedRate, + precision: reverseCachedRate.precision, + }); + return { rate: invertedRate, precision: reverseCachedRate.precision }; } if ([null, from, to].includes(this.intermediateBase)) { return undefined; } - // try with intermediate base currency + // Try with intermediate base currency const rateFromIntermediate = await this.checkCache(this.intermediateBase!, from, date); const rateToIntermediate = await this.checkCache(this.intermediateBase!, to, date); if (rateFromIntermediate && rateToIntermediate) { - const rate = rateToIntermediate / rateFromIntermediate; - void this.upsertCache(from, to, date, rate); - return rate; + const precision = Math.max(rateFromIntermediate.precision, rateToIntermediate.precision); + const rate = this.roundRate(rateToIntermediate.rate / rateFromIntermediate.rate, precision); + void this.upsertCache(from, to, date, { rate, precision }); + return { rate, precision }; } } - private upsertCache(from: CurrencyCode, to: CurrencyCode, date: Date, rate: number) { + private upsertCache(from: CurrencyCode, to: CurrencyCode, date: Date, parsedRate: ParsedRate) { return db.cachedCurrencyRate.upsert({ where: { from_to_date: { from, to, date }, @@ -88,11 +103,13 @@ abstract class CurrencyRateProvider { from, to, date, - rate, + rate: parsedRate.rate, + precision: parsedRate.precision, lastFetched: new Date(), }, update: { - rate, + rate: parsedRate.rate, + precision: parsedRate.precision, lastFetched: new Date(), }, }); @@ -116,6 +133,31 @@ abstract class CurrencyRateProvider { } return result; } + + protected getPrecision(rate: string): number { + const match = rate.match(/\.(\d+)/); + if (!match) { + return 0; + } + return match[1]?.length ?? 0; + } + + protected roundRate(rate: number, precision: number): number { + if (0 === precision) { + return Math.round(rate); + } + const factor = 10 ** precision; + return Math.round(rate * factor) / factor; + } + + protected formatRate(rate: number, precision: number): string { + return rate.toFixed(precision); + } + + protected toParsedRate(rate: string): ParsedRate { + const precision = this.getPrecision(rate); + return { rate: Number(rate), precision }; + } } class FrankfurterProvider extends CurrencyRateProvider { @@ -147,7 +189,7 @@ class OpenExchangeRatesProvider extends CurrencyRateProvider { } const key = !date || isToday(date) ? 'latest' : `historical/${format(date, 'yyyy-MM-dd')}`; - // sadly the free tier supports only USD as base currency + // Sadly the free tier supports only USD as base currency const response = await fetch( `https://openexchangerates.org/api/${key}.json?app_id=${process.env.OPEN_EXCHANGE_RATES_APP_ID}`, ); @@ -172,7 +214,13 @@ class NbpProvider extends CurrencyRateProvider { return { base: 'PLN', - rates: Object.fromEntries(response.rates.map((rate) => [rate.code, 1 / rate.mid])), + rates: Object.fromEntries( + response.rates.map((rate) => { + const precision = this.getPrecision(rate.mid); + const invertedRate = this.roundRate(1 / Number(rate.mid), precision); + return [rate.code, this.formatRate(invertedRate, precision)]; + }), + ), }; } @@ -196,7 +244,7 @@ class NbpProvider extends CurrencyRateProvider { table: string; no: string; effectiveDate: string; - rates: { currency: string; code: string; mid: number }[]; + rates: { currency: string; code: string; mid: string }[]; }[] > { const response = await fetch( @@ -206,7 +254,7 @@ class NbpProvider extends CurrencyRateProvider { if (table === 'A') { throw new Error(response.statusText || 'Failed to fetch exchange rates'); } else { - // table B is published weekly on Wednesdays + // Table B is published weekly on Wednesdays const currentIsoDay = getISODay(date); const previousWednesday = subDays( date, @@ -216,7 +264,9 @@ class NbpProvider extends CurrencyRateProvider { } } - return response.json(); + const raw = await response.text(); + const wrapped = raw.replaceAll(/("mid"\s*:\s*)(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g, '$1"$2"'); + return JSON.parse(wrapped); } } diff --git a/src/store/addStore.ts b/src/store/addStore.ts index 9a5d29e5..0a4dcbcd 100644 --- a/src/store/addStore.ts +++ b/src/store/addStore.ts @@ -6,7 +6,7 @@ import { DEFAULT_CATEGORY } from '~/lib/category'; import { type CurrencyCode } from '~/lib/currency'; import type { TransactionAddInputModel } from '~/types'; import { shuffleArray } from '~/utils/array'; -import { BigMath, gcd } from '~/utils/numbers'; +import { BigMath } from '~/utils/numbers'; import { cyrb128, splitmix32 } from '~/utils/random'; export type Participant = User & { amount?: bigint }; diff --git a/src/tests/addStore.test.ts b/src/tests/addStore.test.ts index 6f948a8b..44f6f74f 100644 --- a/src/tests/addStore.test.ts +++ b/src/tests/addStore.test.ts @@ -580,7 +580,7 @@ describe('calculateSplitShareBasedOnAmount', () => { user1, ); - // user1 is payer: paid 10000n but their amount is 8000n, so they owe 2000n (20%) + // User1 is payer: paid 10000n but their amount is 8000n, so they owe 2000n (20%) expect(splitShares[user1.id]![SplitType.PERCENTAGE]).toBe(2000n); // 20% expect(splitShares[user2.id]![SplitType.PERCENTAGE]).toBe(3000n); // 30% expect(splitShares[user3.id]![SplitType.PERCENTAGE]).toBe(5000n); // 50% @@ -627,7 +627,7 @@ describe('calculateSplitShareBasedOnAmount', () => { calculateSplitShareBasedOnAmount(12000n, participants, SplitType.SHARE, splitShares, user1); - // user1 is payer: paid 12000n but their amount is 6000n, so they owe 6000n + // User1 is payer: paid 12000n but their amount is 6000n, so they owe 6000n // Shares: 6000:3000:3000 = 2:1:1 expect(splitShares[user1.id]![SplitType.SHARE]).toBe(200n); expect(splitShares[user2.id]![SplitType.SHARE]).toBe(100n); @@ -673,7 +673,7 @@ describe('calculateSplitShareBasedOnAmount', () => { calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EXACT, splitShares, user1); - // user1 is payer: paid 10000n but their amount is 6000n, so they owe 4000n + // User1 is payer: paid 10000n but their amount is 6000n, so they owe 4000n expect(splitShares[user1.id]![SplitType.EXACT]).toBe(4000n); expect(splitShares[user2.id]![SplitType.EXACT]).toBe(3000n); expect(splitShares[user3.id]![SplitType.EXACT]).toBe(3000n); @@ -725,7 +725,7 @@ describe('calculateSplitShareBasedOnAmount', () => { calculateSplitShareBasedOnAmount(5000n, participants, SplitType.ADJUSTMENT, splitShares); // Only user2 has non-zero amount, so equal share = 5000n / 1 = 5000n - // user2 owes 5000n vs 5000n = 0n adjustment + // User2 owes 5000n vs 5000n = 0n adjustment expect(splitShares[user1.id]![SplitType.ADJUSTMENT]).toBe(-5000n); // 0 - 5000n expect(splitShares[user2.id]![SplitType.ADJUSTMENT]).toBe(0n); // 5000n - 5000n expect(splitShares[user3.id]![SplitType.ADJUSTMENT]).toBe(-5000n); // 0 - 5000n diff --git a/src/tests/currencyRateService.test.ts b/src/tests/currencyRateService.test.ts new file mode 100644 index 00000000..3926b61c --- /dev/null +++ b/src/tests/currencyRateService.test.ts @@ -0,0 +1,130 @@ +const cache = new Map< + string, + { + from: string; + to: string; + date: Date; + rate: number; + precision: number; + lastFetched: Date; + createdAt: Date; + updatedAt: Date; + } +>(); + +const buildCacheKey = (from: string, to: string, date: Date) => + `${from}-${to}-${date.toISOString()}`; + +jest.mock('~/server/db', () => ({ + db: { + cachedCurrencyRate: { + findUnique: jest.fn(({ where }: { where: { from_to_date: any } }) => { + const key = buildCacheKey( + where.from_to_date.from, + where.from_to_date.to, + where.from_to_date.date, + ); + return cache.get(key) ?? null; + }), + upsert: jest.fn(({ where, create, update }: any) => { + const key = buildCacheKey( + where.from_to_date.from, + where.from_to_date.to, + where.from_to_date.date, + ); + const existing = cache.get(key); + const next = { + ...(existing ?? create), + ...update, + }; + cache.set(key, next); + return next; + }), + update: jest.fn(({ where, data }: any) => { + const key = buildCacheKey( + where.from_to_date.from, + where.from_to_date.to, + where.from_to_date.date, + ); + const existing = cache.get(key); + if (!existing) { + return null; + } + const next = { ...existing, ...data }; + cache.set(key, next); + return next; + }), + }, + }, +})); + +jest.mock('~/env', () => ({ + env: { + CURRENCY_RATE_PROVIDER: 'frankfurter', + OPEN_EXCHANGE_RATES_APP_ID: undefined, + }, +})); + +import { CurrencyRateProvider } from '~/server/api/services/currencyRateService'; + +const mockRawResponse = + '{"base":"USD","rates":{"EUR":0.92345,"IDR":0.00005,"PLN":4.0000,"GBP":0.80000,"JPY":150.0}}'; + +class MockProvider extends CurrencyRateProvider { + providerName = 'mock'; + intermediateBase = 'USD' as const; + + async fetchRates(): Promise<{ base: string; rates: { [key: string]: string } }> { + const wrapped = mockRawResponse.replace( + /("rates"\s*:\s*\{)([\s\S]*?)(\})/g, + (_match, start, body, end) => + `${start}${body.replace( + /("([A-Z]{3})"\s*:\s*)(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g, + '$1"$3"', + )}${end}`, + ); + + return JSON.parse(wrapped); + } +} + +describe('CurrencyRateProvider precision handling', () => { + beforeEach(() => { + cache.clear(); + }); + + it('preserves precision from raw JSON numbers', async () => { + const provider = new MockProvider(); + const rate = await provider.getCurrencyRate('USD', 'PLN'); + + expect(rate.rate).toBeCloseTo(4.0, 8); + expect(rate.precision).toBe(4); + }); + + it('rounds inverted rates using the original precision', async () => { + const provider = new MockProvider(); + await provider.getCurrencyRate('USD', 'PLN'); + + const inverted = await provider.getCurrencyRate('PLN', 'USD'); + expect(inverted.precision).toBe(4); + expect(inverted.rate.toFixed(inverted.precision)).toBe('0.2500'); + }); + + it('uses the highest precision when combining via intermediate', async () => { + const provider = new MockProvider(); + await provider.getCurrencyRate('USD', 'GBP'); + await provider.getCurrencyRate('USD', 'EUR'); + + const combined = await provider.getCurrencyRate('GBP', 'EUR'); + expect(combined.precision).toBe(5); + expect(combined.rate).toBeCloseTo(1.15431, 5); + }); + + it('keeps tiny rates from JSON without losing precision', async () => { + const provider = new MockProvider(); + const tiny = await provider.getCurrencyRate('USD', 'IDR'); + + expect(tiny.precision).toBe(5); + expect(tiny.rate).toBe(0.00005); + }); +}); diff --git a/src/types/expense.types.ts b/src/types/expense.types.ts index 8ead7d23..ebb297cd 100644 --- a/src/types/expense.types.ts +++ b/src/types/expense.types.ts @@ -61,6 +61,7 @@ export const createCurrencyConversionSchema = z.object({ from: z.string(), to: z.string(), rate: z.number().positive(), + ratePrecision: z.number().int().nonnegative(), senderId: z.number(), receiverId: z.number(), groupId: z.number().nullable(), diff --git a/src/utils/numbers.ts b/src/utils/numbers.ts index f87bf821..76bb8447 100644 --- a/src/utils/numbers.ts +++ b/src/utils/numbers.ts @@ -85,7 +85,7 @@ export const getCurrencyHelpers = ({ let hasNegativeSign = false; `${input}`.split('').forEach((letter) => { - //allowing only one separator + // Allowing only one separator if (letter === decimalSeparator && !hasDecimalSeparator) { cleaned += letter; hasDecimalSeparator = true; @@ -101,7 +101,7 @@ export const getCurrencyHelpers = ({ hasDecimalSeparator = true; return; } - // when a user presses '-' sign, switch the sign of the number + // When a user presses '-' sign, switch the sign of the number if (letter === '-' && !hasNegativeSign) { hasNegativeSign = true; } @@ -249,22 +249,37 @@ export function currencyConversion({ to, amount, rate, + ratePrecision, }: { from: CurrencyCode; to: CurrencyCode; amount: bigint; rate: number; + ratePrecision: number; }) { const fromDecimalDigits = CURRENCIES[from].decimalDigits; const toDecimalDigits = CURRENCIES[to].decimalDigits; const preMultiplier = BigInt(10 ** Math.max(toDecimalDigits - fromDecimalDigits, 0)); const postMultiplier = BigInt(10 ** Math.max(fromDecimalDigits - toDecimalDigits, 0)); + const ratePrecisionFactor = 10 ** ratePrecision; return BigMath.roundDiv( - amount * preMultiplier * BigInt(Math.round(rate * 10000)), - postMultiplier * 10000n, + amount * preMultiplier * BigInt(Math.round(rate * ratePrecisionFactor)), + postMultiplier * BigInt(ratePrecisionFactor), ); } +export const getRatePrecision = (value: string) => { + const normalized = value.trim(); + if ('' === normalized) { + return 0; + } + const decimalIndex = normalized.indexOf('.'); + if (-1 === decimalIndex) { + return 0; + } + return Math.max(normalized.length - decimalIndex - 1, 0); +}; + export const BigMath = { abs(x: bigint) { return 0n > x ? -x : x; @@ -304,6 +319,12 @@ export const BigMath = { return value; } }, + gcd(a: bigint, b: bigint): bigint { + if (b === 0n) { + return BigMath.abs(a); + } + return BigMath.gcd(b, a % b); + }, roundDiv(x: bigint, y: bigint) { if (0n === y) { throw new Error('Division by zero'); @@ -324,5 +345,3 @@ export const BigMath = { export const bigIntReplacer = (key: string, value: any): any => typeof value === 'bigint' ? value.toString() : value; - -export const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b));