Клиент в Токио оформляет подписку за 19,99 $. Ваш код умножает на курс USD/JPY, получает 2942,82785, записывает это в базу и отправляет платёжному провайдеру. Провайдер отклоняет платёж — или, что хуже, принимает его и списывает в 100 раз больше. У иены нет десятичных знаков, а ваш код об этом так и не спросил.
Округление валют — из тех задач, которые кажутся тривиальными, пока не доходят до продакшена. Это вопрос не форматирования, а корректности. У каждой валюты своё число десятичных знаков, арифметика с плавающей точкой тихо портит суммы, и в момент конвертации между валютами возникает решение об округлении, которое нужно принимать осознанно. В этом руководстве — правила, которые действительно важны: сколько знаков у каждой валюты, почему хранить суммы нужно целыми числами, какой режим округления выбрать и как округлять валютную конвертацию, чтобы книга по-прежнему сходилась в конце месяца.
Разменные единицы: сколько десятичных знаков у каждой валюты?
Разменная единица валюты — это её наименьшее подразделение, участвующее в расчётах. Для доллара США это цент, поэтому у USD два десятичных знака, а 19,99 $ — это 1999 центов. Именно такого представления ждут платёжные провайдеры, и именно его должна использовать ваша база данных.
ISO 4217 — тот же стандарт, который даёт трёхбуквенные коды вроде USD и JPY, — также назначает каждой валюте показатель разменной единицы. Большинство разработчиков считает, что этот показатель всегда равен 2. Это не так, и это допущение — самый дорогой баг в данной категории.
Валюты без десятичных знаков
У этих валют нет разменной единицы в обращении, поэтому отправляемая сумма и есть целое число единиц:
- JPY — японская иена
- KRW — южнокорейская вона
- VND — вьетнамский донг
- CLP — чилийское песо
- ISK — исландская крона
- XAF / XOF / XPF — франки КФА и КФП
- UGX — угандийский шиллинг
- PYG — парагвайский гуарани
- RWF, GNF, KMF, DJF, VUV — и ещё несколько валют малого номинала
Если вы считаете JPY двузначной валютой и умножаете на 100 перед отправкой провайдеру, вы только что списали с клиента в 100 раз больше задуманного.
Валюты с тремя десятичными знаками
Семь валют делятся на тысячные, а не на сотые:
- KWD — кувейтский динар (1000 филсов)
- BHD — бахрейнский динар (1000 филсов)
- OMR — оманский риал (1000 байз)
- JOD — иорданский динар (1000 филсов)
- TND — тунисский динар (1000 миллимов)
- IQD — иракский динар (1000 филсов)
- LYD — ливийский динар (1000 дирхамов)
Здесь ошибка идёт в обратную сторону: если считать KWD двузначной, вы спишете одну десятую от задуманного. Счёт на KWD 12,500 превращается в KWD 1,250.
В ISO 4217 есть даже позиции с четырьмя знаками — чилийская unidad de fomento (CLF) и уругвайская unidad previsional (UYW). Это индексируемые учётные единицы, а не наличные, но если ваша система принимает произвольные коды ISO, она должна их пережить.
Когда стандарт и ваш провайдер расходятся
Это ловушка, в которую попадают команды, сделавшие всё остальное правильно. Платёжные провайдеры иногда отступают от ISO 4217 по операционным причинам. Adyen, например, документирует, что CLP, CVE, IDR и ISK имеют в его API иное число знаков, чем в стандарте: у ISK по ISO 4217 ноль знаков, но в Adyen её нужно отправлять с двумя.
Правило: ваша таблица округления — это свойство системы, с которой вы говорите, а не универсальная константа. Держите по таблице на интеграцию, заполняйте её из ISO 4217 и переопределяйте для конкретного провайдера там, где этого требует его документация. Никогда не зашивайте 100.
Никогда не храните деньги во float
Прежде чем говорить об округлении — фундамент. Двоичная плавающая точка не может точно представить большинство десятичных дробей:
0.1 + 0.2 // 0.30000000000000004
1.005 * 100 // 100.49999999999999
19.99 * 147.2150 // 2942.8278499999997Эти хвостовые цифры — не косметика. Пропустите их через Math.round() в неподходящий момент, и вы получите сумму, отличающуюся на одну разменную единицу, а этого достаточно, чтобы сверка не сошлась.
Два правила закрывают почти все случаи:
- Храните суммы целыми числами в разменных единицах. Колонка
amount_minor BIGINTплюс колонкаcurrency CHAR(3).{ amount_minor: 1999, currency: "USD" }однозначно и совпадает с тем, чего уже ждут Stripe, Adyen и большинство провайдеров. - Считайте целыми числами или десятичным типом.
decimal.Decimalв Python,BigDecimalв Java,NUMERICв PostgreSQL или JavaScript-библиотека для денег поверх целочисленной арифметики. Оставьте float самому обменному курсу — и то лишь до момента умножения.
Если вы проектируете этот слой с нуля, наше руководство по проектированию мультивалютной книги подробнее разбирает решения по схеме.
Конвейер конвертации: разменные единицы на входе, разменные единицы на выходе
Конвертация валют состоит ровно из четырёх шагов, и округление относится к шагу три — один раз, в самом конце.
- Переведите исходную сумму из разменных единиц в десятичное значение.
- Умножьте на обменный курс полной точности.
- Округлите до показателя разменной единицы целевой валюты.
- Переведите обратно в целые разменные единицы.
Вот это на JavaScript, где всю работу делает таблица по валютам:
// Minor unit exponents. Seed from ISO 4217, override per payment provider.
const MINOR_UNITS = {
USD: 2, EUR: 2, GBP: 2, CHF: 2, CAD: 2, AUD: 2, CNY: 2, INR: 2,
JPY: 0, KRW: 0, VND: 0, CLP: 0, ISK: 0, XAF: 0, XOF: 0, XPF: 0,
KWD: 3, BHD: 3, OMR: 3, JOD: 3, TND: 3, IQD: 3, LYD: 3,
};
function exponentFor(currency) {
const e = MINOR_UNITS[currency];
if (e === undefined) throw new Error(`Unknown minor unit for ${currency}`);
return e;
}
/**
* Convert an integer minor-unit amount from one currency to another.
* Returns an integer in the target currency's minor units.
*/
function convertMinor(amountMinor, from, to, rate) {
const fromExp = exponentFor(from);
const toExp = exponentFor(to);
const decimalAmount = amountMinor / 10 ** fromExp; // 1999 -> 19.99
const converted = decimalAmount * rate; // full precision, no rounding yet
return Math.round(converted * 10 ** toExp); // single rounding step
}
convertMinor(1999, "USD", "JPY", 147.2150); // 2943 (¥2,943)
convertMinor(1999, "USD", "KWD", 0.30590); // 6115 (KWD 6.115)
convertMinor(1999, "USD", "EUR", 0.9241); // 1847 (€18.47)Обратите внимание, чего функция не делает: она никогда не округляет курс, никогда не округляет промежуточное значение и никогда не предполагает два знака. Math.round здесь — округление половины вверх для положительных чисел: для чекаута нормально, но прочитайте следующий раздел, прежде чем применять это к чему-то регулируемому.
Выбор режима округления
«Округлить до двух знаков» — это не спецификация. Есть как минимум пять обоснованных способов разрешить ничью, и финансовым системам не всё равно, какой вы выберете.
| Режим | 2,5 → | 3,5 → | −2,5 → | Типичное применение |
|---|---|---|---|---|
| Половина вверх (half up) | 3 | 4 | −3 | Розничные цены, итоги чекаута |
| Половина к чётному (банковское) | 2 | 4 | −2 | Бухгалтерия, проценты, налоги, отчётность |
| Половина вниз (half down) | 2 | 3 | −2 | Редко; изредка в устаревшем финансовом коде |
| Вверх (ceiling) | 3 | 4 | −2 | Комиссии, которые нельзя недобрать |
| Вниз (floor / усечение) | 2 | 3 | −3 | Выплаты, которые нельзя переплатить |
Math.round() для положительных чисел. Интуитивно понятно и подходит для цены, которую клиент вот-вот увидит.Half even, оно же банковское округление, отправляет точные половины к ближайшей чётной цифре. На больших объёмах транзакций оно компенсирует систематическое смещение вверх, которое вносит half up, — поэтому оно является умолчанием в бухгалтерских системах, в модуле decimal в Python и в самом IEEE 754. Если вы агрегируете тысячи конвертированных сумм в отчёт о выручке, half up тихо завысит итог; half even — нет.
Ceiling и floor существуют для асимметричного риска. Маркетплейс, выплачивающий продавцам, может округлять каждую выплату вниз, чтобы никогда не распределить больше, чем имеет; разница уходит на счёт округлений.
Python делает выбор явным — это правильная эргономика:
from decimal import Decimal, ROUND_HALF_EVEN, ROUND_HALF_UP
MINOR_UNITS = {"USD": 2, "EUR": 2, "JPY": 0, "KWD": 3}
def convert_minor(amount_minor: int, src: str, dst: str,
rate: str, mode=ROUND_HALF_EVEN) -> int:
"""Convert integer minor units to integer minor units, exactly once."""
src_exp, dst_exp = MINOR_UNITS[src], MINOR_UNITS[dst]
amount = Decimal(amount_minor) / (Decimal(10) ** src_exp)
converted = amount * Decimal(rate) # rate passed as a string, not a float
quantum = Decimal(1).scaleb(-dst_exp) # 0.01, 1, or 0.001
rounded = converted.quantize(quantum, rounding=mode)
return int(rounded.scaleb(dst_exp))
convert_minor(1999, "USD", "JPY", "147.2150") # 2943
convert_minor(1999, "USD", "KWD", "0.30590") # 6115Передавать курс в Decimal строкой важно. Decimal(0.9241) наследует ошибку float; Decimal("0.9241") — нет.
Три ошибки округления, которые стоят реальных денег
1. Округление курса до умножения
Обменные курсы обычно несут от четырёх до шести значащих знаков после запятой, и обрезать их небезобидно. Возьмём USD/JPY по 147,2150 и перевод на 10 000 $:
- Полный курс:
10000 × 147.2150 = ¥1 472 150 - Курс, округлённый до двух знаков (147,21):
10000 × 147.21 = ¥1 472 100
Расхождение в ¥50 на одной транзакции — исключительно из-за форматирования курса перед использованием. Храните курс с той точностью, которую возвращает провайдер, округляйте только итоговую сумму и сохраняйте точный использованный курс рядом с транзакцией для аудита. Наше руководство о том, откуда API обменных курсов берут данные, объясняет, почему эта точность вообще имеет значение.
2. Двойное округление при многоэтапной конвертации
Если вы маршрутизируете USD → EUR → JPY и округляете на этапе EUR, вы выбрасываете точность, которую второе умножение затем усиливает. Конвертация 12,34 $ при USD/EUR 0,9241 и EUR/JPY 159,3063:
- Напрямую:
12.34 × 147.2150 = 1816.63→ ¥1 817 - Через округлённое плечо EUR:
12.34 × 0.9241 = 11.4034→ округлено до 11,40 € →11.40 × 159.3063 = 1816.09→ ¥1 816
Одна иена — из-за одного лишнего шага округления. На прогоне выплат в пятьдесят тысяч транзакций это уже тикет по сверке. Там, где доступна прямая пара, используйте её; когда приходится триангулировать, держите промежуточное значение в полной точности. Механику см. в объяснении кросс-курсов.
3. Строки, которые не складываются в итог
Округлите каждую строку счёта по отдельности — и части не всегда дадут округлённое целое. Классический случай — деление:
$10.00 split three ways
10.00 / 3 = 3.3333...
→ 3.33 + 3.33 + 3.33 = 9.99 ✗ one cent missingРешение — распределение, а не округление. Округлите итог один раз, а затем распределите его по частям, раздавая остаток по одной разменной единице:
/**
* Split an integer minor-unit total into `n` parts whose sum is exactly the total.
* Remainder units are distributed to the earliest parts (largest-remainder method).
*/
function allocate(totalMinor, ratios) {
const sum = ratios.reduce((a, b) => a + b, 0);
const shares = ratios.map(r => Math.floor((totalMinor * r) / sum));
let remainder = totalMinor - shares.reduce((a, b) => a + b, 0);
for (let i = 0; remainder > 0; i = (i + 1) % shares.length, remainder--) {
shares[i] += 1;
}
return shares;
}
allocate(1000, [1, 1, 1]); // [334, 333, 333] → sums to exactly 1000
allocate(9247, [3, 2, 1]); // [4624, 3082, 1541] → sums to exactly 9247Применяйте тот же приём после валютной конвертации: сконвертируйте и округлите итог счёта, а затем распределите этот итог по строкам. Строки всегда сойдутся, потому что выведены из итога, а не посчитаны независимо. Особенно это важно в мультивалютном выставлении счетов и в SaaS-биллинге с пропорциональным расчётом, где расхождение в один цент попадает в PDF, который видит клиент.
Округление наличных — отдельное правило
Разменная единица валюты говорит о наименьшей сумме, которую можно учесть. Она не всегда говорит о наименьшей сумме, которую можно оплатить наличными. Несколько стран вывели из обращения самые мелкие монеты и округляют наличные платежи на кассе:
- Швейцария — наличные округляются до ближайших 0,05 CHF
- Канада — монета в один цент выведена в 2013 году; наличные округляются до ближайших 5 центов
- Швеция — наличные округляются до ближайшей целой кроны
- Нидерланды — наличные округляются до ближайших 5 центов
Важно: это относится к наличной оплате, а не к счёту. Швейцарский счёт на CHF 12,32 по-прежнему учитывается как 12,32; только наличный расчёт округляется до 12,30, а разница в 0,02 проводится как корректировка округления. Если вы делаете POS-софт, моделируйте округление наличных как отдельный, более поздний шаг, применяемый к платежу, — никогда не встраивайте его в сохраняемую сумму, иначе электронные и наличные операции разойдутся.
Форматирование — последний шаг, а не расчёт
Когда арифметика закончена, передайте отображение локале-зависимому форматтеру. Intl.NumberFormat уже знает число знаков, позицию символа и разделители для каждой валюты:
function formatMinor(amountMinor, currency, locale = "en-US") {
const exp = exponentFor(currency);
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
}).format(amountMinor / 10 ** exp);
}
formatMinor(1999, "USD"); // "$19.99"
formatMinor(2943, "JPY", "ja-JP"); // "¥2,943"
formatMinor(6115, "KWD"); // "KWD 6.115"
formatMinor(1847, "EUR", "de-DE"); // "18,47 €"Две практические заметки. Во-первых, экземпляры Intl.NumberFormat дороги в создании — кэшируйте по одному на пару «локаль-валюта», а не создавайте на каждую строку. Во-вторых, деление на 10 ** exp в последней строке — единственное место, где float должен касаться денежной величины, и только потому, что результат сразу становится строкой.
Собираем всё вместе с API Finexly
Получите курс в полной точности, сконвертируйте один раз, округлите один раз и сохраните использованный курс:
curl "https://api.finexly.com/v1/latest?base=USD&symbols=JPY,KWD,EUR" \
-H "Authorization: Bearer YOUR_API_KEY"{
"success": true,
"base": "USD",
"timestamp": 1755244800,
"rates": {
"JPY": 147.2150,
"KWD": 0.30590,
"EUR": 0.9241
}
}async function quote(amountMinor, from, to) {
const res = await fetch(
`https://api.finexly.com/v1/latest?base=${from}&symbols=${to}`,
{ headers: { Authorization: `Bearer ${process.env.FINEXLY_API_KEY}` } }
);
const data = await res.json();
const rate = data.rates[to];
return {
amount_minor: convertMinor(amountMinor, from, to, rate),
currency: to,
rate, // persist the exact rate used
rate_timestamp: data.timestamp, // and when it was captured
};
}
await quote(1999, "USD", "JPY");
// { amount_minor: 2943, currency: "JPY", rate: 147.215, rate_timestamp: 1755244800 }Хранение rate и rate_timestamp в строке транзакции — это то, что позволяет ответить на спор через полгода. Полные детали эндпоинтов и параметров — в документации API Finexly, а если вы кэшируете курсы между запросами, наши заметки о кэшировании и обработке ошибок разбирают компромиссы по свежести данных.
Чек-лист тестирования
Денежные баги прячутся в случаях, для которых никто не пишет тесты. Как минимум покройте:
- Цель без десятичных знаков — сконвертируйте в JPY или KRW и проверьте, что у результата нет дробной части.
- Цель с тремя знаками — сконвертируйте в KWD или BHD и проверьте, что три знака сохраняются.
- Точные половины — проверьте выбранный режим округления в обе стороны, включая отрицательные значения.
- Дрейф при возврате — сконвертируйте USD → EUR → USD и проверьте, что результат в пределах одной разменной единицы, а не равен исходному.
- Инвариант распределения — проверьте, что части всегда дают в сумме ровно итог, для 1–100 частей.
- Неизвестные коды валют — проверьте, что код выбрасывает ошибку, а не молча подставляет два знака.
- Очень большие суммы — проверьте отсутствие потери точности за пределами
Number.MAX_SAFE_INTEGERв JavaScript; используйтеBigInt, если работаете с IDR или VND в объёме.
Часто задаваемые вопросы
Сколько десятичных знаков у каждой валюты?
У большинства — два. Примерно у двух десятков их нет вовсе — включая JPY, KRW, VND, CLP и ISK, — а у семи их три: KWD, BHD, OMR, JOD, TND, IQD и LYD. ISO 4217 — авторитетный источник, но проверьте и таблицу вашего платёжного провайдера: некоторые отступают от стандарта по операционным причинам.
Округлять обменные курсы или конвертированные суммы?
Только конвертированные суммы. Держите курс в полной точности, которую возвращает провайдер, умножайте, а затем округляйте результат ровно один раз до разменной единицы целевой валюты. Округление курса перед умножением вносит ошибку, пропорциональную размеру транзакции.
Чем half-up отличается от банковского округления?
Half-up всегда отправляет точную половину от нуля (2,5 → 3). Банковское округление — половина к чётному — отправляет её к ближайшей чётной цифре (2,5 → 2, 3,5 → 4), что убирает систематическое смещение вверх при агрегации множества сумм. Используйте half-up для цен, показываемых клиентам, и half-even для бухгалтерии и отчётности.
Почему мои конвертированные строки не складываются в конвертированный итог?
Потому что каждая строка округлялась независимо, и ошибки накапливаются. Округлите итог один раз, а затем распределите его по строкам методом наибольшего остатка. Тогда части сложатся в целое по построению.
Можно ли просто хранить деньги во float с двумя знаками?
Нет. Двоичная плавающая точка не представляет точно значения вроде 0,1, поэтому ошибки накапливаются при сложениях и умножениях и в итоге переворачивают решение об округлении. Храните целые числа в разменных единицах или используйте точный десятичный тип. Это не теоретическая проблема — это самая частая первопричина сбоев сверки на один цент.
Получите курсы в полной точности
Правильное округление начинается с курса, которому можно доверять, и с точности, которую вы не выбросили. Получите бесплатный API-ключ Finexly — без банковской карты. Начните с 1 000 бесплатных запросов в месяц по 170+ валютам, проверьте свои расчёты в конвертере валют и посмотрите тарифные планы, когда объёмы вырастут.
Explore More
Vlado Grigirov
Senior Currency Markets Analyst & Financial Strategist
Vlado Grigirov is a senior currency markets analyst and financial strategist with over 14 years of experience in foreign exchange markets, cross-border finance, and currency risk management. He has wo...
View full profile →