
Хранение денег — вещь только на первый взгляд простая, а на деле содержит множество подводных камней. Выбрав не тот тип данных, можно получить неточности в расчётах, возможна путаница при переводе суммы из одной валюты в другую. А если ещё и подключать внешние 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/