Как хранить деньги в базах данных и почему это не так просто, как кажется

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

Требования к хранению

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

  1. Основное правило: хранить суммы в валюте вместе со ссылкой на спецификацию валюты (например, внешний ключ в базе данных или специальный класс в коде проекта), чтобы корректно их интерпретировать и обрабатывать.

  2. Если хранить спецификацию валюты, то нужно включить в неё:

    • Минимальную учитываемую единицу (minimum accountable unit), вместо или вдобавок к числу десятичных знаков (точности).

    • Наименьшую физическую купюру или монету, если есть работа с наличными.

  3. Стоит убедиться, что точность хранения сумм валют соответствует максимальной точности среди всех поддерживаемых валют.

  4. Также стоит помнить о добавлении дополнительной точности для внутренних операций: промежуточных расчётов или хранения цен на мелкие товары.

  5. При удержании комиссий с мелких сумм (например, 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 цента.

Проблемы и ограничения:

  1. Желательно заранее определить необходимую точность, чтобы избежать пересчётов в будущем.

  2. Каждое преобразование из микроединиц в центы, а потом в доллары — это место для потенциальной ошибки. Особенно, если нужно отобразить количество денег пользователю или отправить в другую систему.

  3. Minor unit валюты может со временем измениться, что потребует масштабирования (scale) всех значений в будущем.

  4. Внешние системы, с которыми есть взаимодействие, могут неправильно интерпретировать значения при изменении масштабирования. Например:

    • Сторонние сервисы или клиенты, не осведомлённые о масштабировании

    • Микросервисы, которые не успели обновиться синхронно

    • Message queues или платформы event streaming, в которых невозможно изменить старые сообщения

Эта проблема может быть решена, если всегда явно передавать масштаб числа вместе с самим числом.

Какие типы можно использовать при таком подходе

BigInt

BigInt — это не встроенный тип данных, а категория типов, которые позволяют работать с целыми числами произвольной (или достаточно большой) длины с поддержкой математических операций. В контексте денежных расчетов это наиболее подходящий способ хранения младших единиц или микроединиц.

Не стоит путать его с SQL-типом bigint, который на самом деле представляет собой 64-битное целое (Int64).

Когда мы храним деньги как целые числа в младших единицах ($5.99 → 599 центов), обычных типов данных может не хватить:

  • Криптовалюты: до 18 десятичных знаков

  • Микроединицы: дополнительная точность для комиссий и промежуточных расчетов

  • Большие суммы: Int64 может переполниться при работе с крупными транзакциями

Язык

Реализация

Пример

JavaScript

Встроенный BigInt

const amount = 999999999999999999n;

Python

Встроенный int

amount = 999999999999999999

Java

BigInteger

BigInteger amount = new BigInteger("999999999999999999");

C#

System.Numerics.BigInteger

var amount = BigInteger.Parse("999999999999999999");

Go

big.Int

amount := big.NewInt(0).SetString("999999999999999999", 10)

PHP

BCMath или GMP

$amount = "999999999999999999";

Для хранения 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:

  1. Decimal128 — с ограниченным количеством значащих цифр

  2. реализуемый по-разному во внутренних механизмах (например, 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 при необходимости вычислений.

Существует два подхода к обработке строковых сумм:

  1. Прямая обработка — строки парсятся в Decimal при каждой операции (используется в PayPal API).

  2. Гибридный подход — строки для передачи, 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/

Опубликовано в категории: Статьи