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
16 changes: 10 additions & 6 deletions docs/CURRENCY_CONVERSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ model CachedCurrencyRate {
to String
date DateTime
rate Float
precision Int
lastFetched DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Expand Down
3 changes: 2 additions & 1 deletion src/components/AddExpense/AddExpensePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ export const AddOrEditExpensePage: React.FC<{
const previousCurrencyRef = React.useRef<CurrencyCode | null>(null);

const onConvertAmount: React.ComponentProps<typeof CurrencyConversion>['onSubmit'] = useCallback(
({ amount: absAmount, rate }) => {
({ amount: absAmount, rate, ratePrecision }) => {
if (!previousCurrencyRef.current) {
return;
}
Expand All @@ -233,6 +233,7 @@ export const AddOrEditExpensePage: React.FC<{
currencyConversion({
amount: absAmount,
rate,
ratePrecision,
from: previousCurrencyRef.current,
to: currency,
});
Expand Down
3 changes: 2 additions & 1 deletion src/components/Expense/ConvertibleBalance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ export const ConvertibleBalance: React.FC<ConvertibleBalanceProps> = ({
from: balance.currency,
to: selectedCurrency,
amount: balance.amount,
rate,
rate: rate.rate,
ratePrecision: rate.precision,
});
total += convertedValue;
}
Expand Down
50 changes: 38 additions & 12 deletions src/components/Friend/CurrencyConversion.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -26,6 +26,7 @@ export const CurrencyConversion: React.FC<{
to: CurrencyCode;
amount: bigint;
rate: number;
ratePrecision: number;
}) => Promise<void> | void;
}> = ({ amount, editingRate, editingTargetCurrency, currency, children, onSubmit }) => {
const { t, getCurrencyHelpersCached } = useTranslationWithUtils();
Expand All @@ -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);
Expand All @@ -49,21 +51,30 @@ 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);
}
}, [amount, editingRate, editingTargetCurrency, toUIString]);

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]);

Expand All @@ -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 }) => {
Expand All @@ -97,21 +109,24 @@ export const CurrencyConversion: React.FC<{
// Allow empty while typing
if (raw === '') {
setRate('');
setRatePrecision(0);
return;
}
// Only digits and optional dot
if (!/^[0-9]*\.?[0-9]*$/.test(raw)) {
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);
},
Expand All @@ -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 () => {
Expand All @@ -143,6 +159,7 @@ export const CurrencyConversion: React.FC<{
await onSubmit({
amount: getCurrencyHelpersCached(currency).toSafeBigInt(amountStr),
rate: Number(rate),
ratePrecision,
from: currency,
to: targetCurrency,
});
Expand All @@ -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 (
<AppDrawer
Expand Down Expand Up @@ -226,7 +252,7 @@ export const CurrencyConversion: React.FC<{
<Input
aria-label="Rate"
type="number"
step="0.0001"
step={0 === ratePrecision ? '1' : `0.${'0'.repeat(ratePrecision - 1)}1`}
min={0}
value={rate}
inputMode="numeric"
Expand All @@ -238,13 +264,13 @@ export const CurrencyConversion: React.FC<{
{t('currency_conversion.fetching_rate')}
</span>
)}
{!!rate && (
{Boolean(rate) && (
<>
<span className="pointer-events-none text-xs text-gray-500">
1 {currency} = {Number(rate).toFixed(4)} {targetCurrency}
1 {currency} = {Number(rate).toFixed(ratePrecision)} {targetCurrency}
</span>
<span className="pointer-events-none text-xs text-gray-500">
1 {targetCurrency} = {(1 / Number(rate)).toFixed(4)} {currency}
1 {targetCurrency} = {(1 / Number(rate)).toFixed(ratePrecision)} {currency}
</span>
</>
)}
Expand Down
19 changes: 8 additions & 11 deletions src/server/api/routers/expense.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}, {});

Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -583,7 +584,7 @@ export const expenseRouter = createTRPCRouter({

const rate = await currencyRateProvider.getCurrencyRate(from, to, date);

return { rate };
return rate;
}),

getBatchCurrencyRates: protectedProcedure
Expand All @@ -599,7 +600,7 @@ export const expenseRouter = createTRPCRouter({
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid currency code' });
}

const rates = new Map<string, number>();
const rates = new Map<string, ParsedRate>();

// Get the first rate to precache rates for the target currency
const rate = await currencyRateProvider.getCurrencyRate(from[0] as CurrencyCode, to, date);
Expand All @@ -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);
}),
);
Expand Down
Loading