
Хранение денег — вещь только на первый взгляд простая, а на деле содержит множество подводных камней. Выбрав не тот тип данных, можно получить неточности в расчётах, возможна путаница при переводе суммы из одной валюты в другую. А если ещё и подключать внешние API, у каждого из которых своя точность для одних и тех же валют, уследить за совместимостью еще труднее.
В свободное время я решила разработать телеграм-бота, который хранит и учитывает вклады, накопительные и брокерские счета. На этапе проектирования я столкнулась с проблемой разных валют и с тем, как именно хранить деньги в базе данных и на бэкенде.
У меня появилось желание разобраться, как проблемы хранения денежных сумм и разных валют решаются в мире. Будем рассматривать полные подходы к работе с деньгами — от API до хранения в базе данных.
В основу статьи лег материал, который был переведен и дополнен примерами.
Международный стандарт для валют
Начнем с международного стандарта валют. Существует стандарт ISO 4217, описывающий коды валют и их minor units. При переводах, большинство платежных систем опираются именно на этот стандарт.
Минорная/младшая единица (minor unit) — это наименьшая возможная часть валюты, например: 1 доллар = 100 центов (2 десятичных знака).
В стандарте каждая валюта имеет название, трёхбуквенный код, числовой код и количество минорных единиц. Некоторые валюты могут использоваться в нескольких странах или регионах (например, на момент написания статьи евро использовалось в 33 странах).
Например, вот так представлен рубль по этому стандарту:
Alphabetical Code | Numeric Code | Minor unit | Currency | Countries |
RUB | 643 | 2 | Russian Ruble | Russian Federation |
Подробнее можно посмотреть в XML и CSV на официальном сайте стандарта.
Валютное разнообразие в цифрах и фактах
Теперь хочется поговорить об интересных особенностях у разных валют, которые усложняют попытки унифицировать подход к хранению денег.
Некоторые валюты имеют фиксированный курс к другой валюте. Например, гонконгский доллар (HKD) с 1983 года привязан к доллару США в диапазоне 7,75-7,85 HKD за 1 USD.
Большинство валют ведут себя предсказуемо: 2 знака после запятой (точность, precision) — доллары, евро, рубли с их центами и копейками. Но есть и «особенные» — японская йена вообще без дробных частей, а иорданский динар делится на 1000 филсов и может довести до тысячных (0.001 JOD). Приятно, когда есть стандарты, но реальный мир любит отклонения.
Мавритания и Мадагаскар пошли своим путем: их валюты не используют десятичную систему: 1 угия = 5 хумов, 1 ариари = 5 ираймбиланджа.
У криптовалют может быть до 18 десятичных знаков (например, у ETH).
Количество знаков после запятой может изменяться со временем из-за инфляции. Деноминация решает проблему кардинально, но требует введения нового валютного кода. Пример из российской практики: до 29 февраля 2004 года использовался код валюты RUR (810), а после деноминации был введен RUB (643). Интересно, что в некоторых legacy-системах до сих пор можно встретить старый код.
У некоторых валют младшие единицы существуют только на бумаге — физически 0.1 японской йены или 0.1 южнокорейской воны не существует, хотя технически такие суммы возможны в расчетах.
При хранении цен на недорогие товары может потребоваться дополнительная точность. Например, после конвертации товар за $0.01 может стоить 0.009 евро — такую цену нужно где-то хранить, даже если евро формально имеет только 2 знака после запятой.
Требования к хранению
Теперь, зная все эти особенности валют, можно сформулировать основные требования к хранению денег.
Основное правило: хранить суммы в валюте вместе со ссылкой на спецификацию валюты (например, внешний ключ в базе данных или специальный класс в коде проекта), чтобы корректно их интерпретировать и обрабатывать.
Если хранить спецификацию валюты, то нужно включить в неё:
Минимальную учитываемую единицу (minimum accountable unit), вместо или вдобавок к числу десятичных знаков (точности).
Наименьшую физическую купюру или монету, если есть работа с наличными.
Стоит убедиться, что точность хранения сумм валют соответствует максимальной точности среди всех поддерживаемых валют.
Также стоит помнить о добавлении дополнительной точности для внутренних операций: промежуточных расчётов или хранения цен на мелкие товары.
При удержании комиссий с мелких сумм (например, 10% с операции в 1 цент) стоит накапливать дробные части до достижения минимальной единицы учёта и только затем списывать у клиента.
Подходы к хранению у разных платежных систем, типы данных

Посмотрим, какими способами можно технически хранить деньги в базах данных и как каждый подход справляется с валютными особенностями, которые мы обсудили выше.
Но сначала — о том, чего точно стоит избегать.
Float, Double
Первое правило здесь — «никогда не используй типы с плавающей запятой для хранения денежных сумм». Люди ожидают, что денежные расчёты будут производиться в десятичной системе счисления, а арифметика с плавающей точкой использует двоичное представление, что может приводить к неожиданным результатам с финансовой точки зрения. Классический пример: 0.1 + 0.2 != 0.3
. Это правило актуально как для языков программирования, так и для баз данных.
Хотя десятичное представление тоже не может точно представить все числа, оно соответствует ожиданиям людей, требованиям финансовых учреждений и нормативных стандартов.
Money
Тип MONEY проблематичен, потому что привязан к локали базы данных. Он имеет фиксированную точность (обычно 2-4 знака), что не подходит для валют с разным количеством десятичных знаков (йена без дробей, иорданский динар с 3 знаками).
Какие варианты есть еще
Я отталкивалась от API больших платежных систем, чтобы посмотреть, как они обрабатывают платежные транзакции.
1. Integer with minor units
Это один из популярных подходов (например, используется в Klarna, Stripe, Adyen) — хранить целое число minor units. Minor units мы можем подсмотреть опять-таки в международном стандарте, о котором говорится выше. Для доллара — 2 знака после запятой.
Принцип: превращаем $10.95 в 1095 центов — никаких дробей, только целые числа. Это позволяет выполнять точные вычисления и сравнения внутри системы, а затем отображать результат в нужном формате — как сумму в долларах.
Если учитывать требование дополнительной точности (например, ещё 3 десятичных знака), то 5$ можно представить как 500 000 «micro units», где 1 micro unit = 1/1000 цента.
Проблемы и ограничения:
Желательно заранее определить необходимую точность, чтобы избежать пересчётов в будущем.
Каждое преобразование из микроединиц в центы, а потом в доллары — это место для потенциальной ошибки. Особенно, если нужно отобразить количество денег пользователю или отправить в другую систему.
Minor unit валюты может со временем измениться, что потребует масштабирования (scale) всех значений в будущем.
Внешние системы, с которыми есть взаимодействие, могут неправильно интерпретировать значения при изменении масштабирования. Например:
Сторонние сервисы или клиенты, не осведомлённые о масштабировании
Микросервисы, которые не успели обновиться синхронно
Message queues или платформы event streaming, в которых невозможно изменить старые сообщения
Эта проблема может быть решена, если всегда явно передавать масштаб числа вместе с самим числом.
Какие типы можно использовать при таком подходе
BigInt
BigInt — это не встроенный тип данных, а категория типов, которые позволяют работать с целыми числами произвольной (или достаточно большой) длины с поддержкой математических операций. В контексте денежных расчетов это наиболее подходящий способ хранения младших единиц или микроединиц.
Не стоит путать его с SQL-типом bigint
, который на самом деле представляет собой 64-битное целое (Int64
).
Когда мы храним деньги как целые числа в младших единицах ($5.99 → 599 центов), обычных типов данных может не хватить:
Криптовалюты: до 18 десятичных знаков
Микроединицы: дополнительная точность для комиссий и промежуточных расчетов
Большие суммы: Int64 может переполниться при работе с крупными транзакциями
Язык | Реализация | Пример |
JavaScript | Встроенный |
|
Python | Встроенный |
|
Java |
|
|
C# |
|
|
Go |
|
|
PHP |
|
|
Для хранения BigInt в базах данных используется тип DECIMAL с нулевой точностью (так как SQL BIGINT ограничен 64 битами):
-- SQL (PostgreSQL, MySQL, SQL Server)
CREATE TABLE transactions (
id SERIAL PRIMARY KEY,
amount DECIMAL(38,0) NOT NULL, -- BigInt как Decimal с precision=0
currency_code CHAR(3) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Пример данных: $5.99 = 599 центов
INSERT INTO transactions (amount, currency_code)
VALUES (599, 'USD');
Особенности:
При передаче данных между системами
BigInt
часто сериализуется как строка. Не все системы поддерживают большие числа, поэтому за счет сериализации достигается универсальная совместимость.Тип
BigInt
обычно требует больше памяти, чем обычные целые числа. Размер зависит от величины числа. Вычисления выполняются медленнее, так как процессоры не поддерживают аппаратно операции с такими типами и приходится использовать программную реализацию.Производительность
BigInt
сильно зависит от внутренней реализации. Оптимальные реализации используют основание 2^32 или 2^64. Неоптимальные реализации хранят числа, как строки в десятичном виде => больше потребление памяти.В базах данных тип
DECIMAL
тоже может быть неоптимальным — многие СУБД используют основание 100 или 10000 вместо степеней двойки.
Int64
Некоторые разработчики выбирают знаковый или беззнаковый Int64
(также называемый BigInt
в SQL, что может ввести в заблуждение) для хранения значений в копейках или более мелких единицах валюты. У этого подхода есть серьезные ограничения:
Этого может быть недостаточно для хранения минимальных единиц криптовалют или значений с расширенной точностью (5$ → 500,000).
Некоторые языки (например, Go) требуют приведения к типу
Float64
для выполнения арифметических операций. НоFloat64
имеет всего 52 бита мантиссы, что недостаточно для хранения произвольного значенияInt64
без потери точности.Не все языки программирования поддерживают
Int64
:PHP, работающий на 32-битной архитектуре, не может корректно обрабатывать
Int64
.В некоторых языках (например, Java, PHP) отсутствует поддержка беззнаковых целых чисел.
В JavaScript числа представлены в виде знаковых
Float64
, то есть: даже если бэкенд может сериализоватьInt64
в JSON, на стороне фронтенда при десериализации произойдёт переполнение, и значение будет искажено.
2. Decimal base units
Этот подход предполагает использование специального числового типа Decimal
, который позволяет точно хранить десятичные дробные числа с заданной точностью (максимальная точность зависит от конкретной базы данных).
Принцип: $10.95 хранится именно как 10.95, а не как приближение в двоичной системе счисления. Такой подход используют, например, Visa и PayPal.
Большинство языков программирования (JavaScript, PHP, Go, Python, Java, C#, C++) поддерживают Decimal
либо встроено, либо через сторонние библиотеки. SQL-базы данных предоставляют собственный стандартный тип Decimal
.
Существует два разных типа реализаций Decimal
:
Decimal128
— с ограниченным количеством значащих цифрреализуемый по-разному во внутренних механизмах (например, SQL
Decimal
,BigDecimal
в Java).
Как и в случае с BigInt, бинарное представление Decimal может различаться:
2 десятичных цифры в одном байте (основание 100 — как это реализовано во многих базах данных)
BigInt
+ экспонента, по аналогии с представлением чисел с плавающей запятой в основании 2массив или строка отдельных десятичных цифр — используется в некоторых простых реализациях, но является неэффективным способом
Описанные выше проблемы с производительностью для BigInt
актуальны и для Decimal
-типов.
Основные преимущества использования Decimal
по сравнению с BigInt
:
Нет существенных накладных расходов, если значения уже хранятся в базе данных в виде
Decimal
(что всё равно будет, даже если работаем сBigInt
). Кроме того, форматированиеBigInt
требует таких же вычислений, как и при работе сDecimal
.Более интуитивное представление значений упрощает бизнес-логику и форматирование чисел для отображения в человеко-читаемом виде.
Изменение младшей единицы можно реализовать просто через изменение точности (precision) у столбца
Decimal
в базе данных — пересчитывать сами значения не нужно, как это пришлось бы делать сBigInt
.Значение, сериализованное в строку, естественным образом включает в себя информацию о точности. Если изменить точность, внешние системы не интерпретируют значение ошибочно, в отличие от случая с
BigInt
, где масштаб можно не понять без дополнительной информации.Как и с
BigInt
, все сериализации обычно идут через строку с десятичным числом, чтобы обеспечить совместимость между разными библиотеками. Впрочем, сериализация в виде мантиссы + экспоненты может быть более эффективной для систем с высокими требованиями к производительности.
Однако, даже с Decimal
необходимо учитывать минимальную учитываемую единицу. Кроме того, при ограничении точности столбца Decimal
в базе данных, нужно заранее закладывать запас точности (precision reserve) для промежуточных расчётов и накоплений.
3. String Base Units
String Base Units — это передача денежных сумм в виде строк с десятичными числами в основных единицах валюты. $10.95 передается именно как «10.95», а не как 1095 центов или число 10.95.
Принцип: максимальная совместимость и отсутствие потери точности при парсинге между системами.
Такой подход используют, например, Google Wallet, Amazon Payments.
Большинство языков программирования работают со строками нативно, а для вычислений конвертируют их в Decimal
или BigDecimal
типы. Для хранения в базах данных используется тип VARCHAR
с валидацией формата или конвертация в DECIMAL
при необходимости вычислений.
Существует два подхода к обработке строковых сумм:
Прямая обработка — строки парсятся в
Decimal
при каждой операции (используется в PayPal API).Гибридный подход — строки для передачи,
Decimal
для хранения и вычислений (используется в Google Wallet).
Основные преимущества по сравнению с Integer minor units:
Универсальная совместимость — работает во всех языках программирования без потери точности, включая JavaScript
Интуитивное представление — разработчики с больше вероятностью поймут «10.95» долларов, в отличие от 1095 центов
Автоматическое масштабирование — изменение точности валюты не требует пересчета данных
Самодокументированность API — внешние системы понимают формат без дополнительной документации
Отсутствие проблем с переполнением — строки могут представлять числа любого размера
Недостатки:
Производительность — парсинг строк медленнее целочисленной арифметики
Валидация — нужна проверка формата на входе
Память — строки занимают больше места, чем числа
При больших объемах данных стоит кэшировать преобразованные в Decimal
значения для повторных вычислений.
Какие подходы используют большие платежные системы

Minor Units (Integer)
Stripe: integer + minor units
Adyen: integer + minor units
Klarna: integer + minor units
MasterCard: integer + minor units
Base Units (String)
Braintree: string + base units
Google Wallet: string + base units
PayPal: string + base units
Amazon Payments: string + base units
The Currency Cloud: string + base units
2checkout: string + base units
GoCardless: string + base units
Paynova: string + base units
Rogers Catalyst: string + base units
WePay: string + base units
Base Units (Decimal)
Dwolla: decimal + base units
Venmo: decimal + base units
Intuit: decimal в запросах, string в ответах
Выводы и основные принципы работы с деньгами
Деньги — это не просто числа. Они имеют контекст (валюту), ограничения (точность) и особенности (физические vs расчетные единицы). При проектировании системы следует учитывать:
Точность валюты — от 0 знаков (йена) до 18 (криптовалюты)
Изменчивость требований — инфляция, деноминация, новые валюты
Интеграцию с внешними системами — API могут использовать разные форматы
Производительность vs читаемость — что важнее для вашего проекта
Когда какой подход использовать
Если вы только начинаете или делаете MVP — стоит рассмотреть String Base Units или Decimal. Эти варианты имеют меньше подводных камней. Строки хорошо работают в большинстве случаев, Decimal дает больше возможностей при развитии проекта.
Для обычной коммерции (интернет-магазины, простые платежи) часто выбирают String Base Units. Доллары, евро, рубли удобно передавать как «10.95». Это помогает избежать проблем с JavaScript на фронтенде.
Банки и финансовые учреждения обычно предпочитают Decimal. Этот подход соответствует ожиданиям регуляторов, упрощает создание SQL-отчетов и помогает соблюдать требования ЦБ.
Для систем с высокими требованиями к производительности (трейдинг, системы с большим объемом операций) может подойти Integer minor units.
Криптовалютные проекты практически всегда используют BigInt minor units. Для 18 знаков после запятой (1 ETH = 1,000,000,000,000,000,000 wei) других вариантов мало.
Для публичных API многие выбирают String Base Units. Разработчики быстрее понимают «amount»: «19.99», чем разбираются, что означает 1999 без документации.
В микросервисной архитектуре можно рассмотреть BigInt minor units для внутренних вычислений с конвертацией в строки для внешних интерфейсов.
Источник: https://habr.com/ru/articles/924838/