Назад к блогу

Округление валют для разработчиков: десятичные знаки, разменные единицы и безопасная конвертация

V
Vlado Grigirov
August 21, 2026
Currency API Exchange Rates Currency Rounding Minor Units ISO 4217 Finexly Developer Guide

Клиент в Токио оформляет подписку за 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() в неподходящий момент, и вы получите сумму, отличающуюся на одну разменную единицу, а этого достаточно, чтобы сверка не сошлась.

Два правила закрывают почти все случаи:

  1. Храните суммы целыми числами в разменных единицах. Колонка amount_minor BIGINT плюс колонка currency CHAR(3). { amount_minor: 1999, currency: "USD" } однозначно и совпадает с тем, чего уже ждут Stripe, Adyen и большинство провайдеров.
  2. Считайте целыми числами или десятичным типом. decimal.Decimal в Python, BigDecimal в Java, NUMERIC в PostgreSQL или JavaScript-библиотека для денег поверх целочисленной арифметики. Оставьте float самому обменному курсу — и то лишь до момента умножения.

Если вы проектируете этот слой с нуля, наше руководство по проектированию мультивалютной книги подробнее разбирает решения по схеме.

Конвейер конвертации: разменные единицы на входе, разменные единицы на выходе

Конвертация валют состоит ровно из четырёх шагов, и округление относится к шагу три — один раз, в самом конце.

  1. Переведите исходную сумму из разменных единиц в десятичное значение.
  2. Умножьте на обменный курс полной точности.
  3. Округлите до показателя разменной единицы целевой валюты.
  4. Переведите обратно в целые разменные единицы.

Вот это на 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)34−3Розничные цены, итоги чекаута
Половина к чётному (банковское)24−2Бухгалтерия, проценты, налоги, отчётность
Половина вниз (half down)23−2Редко; изредка в устаревшем финансовом коде
Вверх (ceiling)34−2Комиссии, которые нельзя недобрать
Вниз (floor / усечение)23−3Выплаты, которые нельзя переплатить
Half up — это то, что большинство называет «округлением», и то, что даёт 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, а если вы кэшируете курсы между запросами, наши заметки о кэшировании и обработке ошибок разбирают компромиссы по свежести данных.

Чек-лист тестирования

Денежные баги прячутся в случаях, для которых никто не пишет тесты. Как минимум покройте:

  1. Цель без десятичных знаков — сконвертируйте в JPY или KRW и проверьте, что у результата нет дробной части.
  2. Цель с тремя знаками — сконвертируйте в KWD или BHD и проверьте, что три знака сохраняются.
  3. Точные половины — проверьте выбранный режим округления в обе стороны, включая отрицательные значения.
  4. Дрейф при возврате — сконвертируйте USD → EUR → USD и проверьте, что результат в пределах одной разменной единицы, а не равен исходному.
  5. Инвариант распределения — проверьте, что части всегда дают в сумме ровно итог, для 1–100 частей.
  6. Неизвестные коды валют — проверьте, что код выбрасывает ошибку, а не молча подставляет два знака.
  7. Очень большие суммы — проверьте отсутствие потери точности за пределами 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+ валютам, проверьте свои расчёты в конвертере валют и посмотрите тарифные планы, когда объёмы вырастут.

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 →