20 июл. 2025 г.·5 мин. чтения

Храните суммы в целых центах — чтобы устранить ошибки биллинга в прототипе

Храните суммы в целых центах, чтобы избежать ошибок округления, задать правила валют и предотвратить споры по платежам при переходе прототипа в продукт.

Храните суммы в целых центах — чтобы устранить ошибки биллинга в прототипе

Почему прототипы ошибаются с деньгами

Прототип может выглядеть идеально на экране и при этом списать неправильную сумму. На ценнике написано $19.99, в корзине отображается $39.98, а подтверждение платежа приходит как $39.97 или $39.99. Никто не замечает этого в тестах — замечают реальные пользователи.

Обычно это происходит потому, что в прототипе деньги обрабатывают как обычные числа. Складывают, делят и применяют проценты с помощью десятичных дробей, которые не всегда точно представлены. Интерфейс округляет одним способом, бэкенд — другим, платёжный провайдер может округлять ещё иначе. Эти маленькие расхождения превращаются в «Почему с меня взяли больше?».

Один цент кажется безобидным, но он накапливается:

  • тикеты в поддержку и возвраты отнимают время
  • чарджбеки приносят комиссии и риск для аккаунта
  • бухгалтерия путается, когда суммы не сходятся с отчётами
  • доверие падает, если квитанции и экраны не совпадают

Цель — скучная предсказуемость: те же входные данные должны давать те же итоги каждый раз, во всех местах — корзина, оформление заказа, чек, счёт и возврат.

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

Скрытая проблема с десятичными и числом с плавающей точкой

Цены выглядят просто на экране: $9.99, $19.00, $0.50. Проблема в том, что многие языки программирования хранят такие значения как числа с плавающей точкой. Числа с плавающей точкой созданы для скорости, а не для точных денежных вычислений. Некоторые десятичные значения нельзя представить точно в двоичной форме, поэтому в памяти хранится чуть другой номер.

Вот как $9.99 может внутри приложения превратиться в 9.9899999997 или 9.9900000003. Обычно вы этого не замечаете при печати одного числа, потому что форматирование округляет его для показа. Но маленькая погрешность остаётся.

Эти ошибки проявляются в обычной работе с биллингом:

  • при суммировании множества позиций
  • при расчёте налогов (умножить, затем округлить)
  • при применении процентных скидок
  • при делении или пропорциональном распределении сборов

Распространённый сценарий: в корзине итог $49.95 (пять позиций по $9.99). UI показывает $49.95. Затем вы добавляете налог 8.25%. Если исходные значения чуть-чуть «пошли вбок», налог может округлиться по-разному в зависимости от способа расчёта (по каждой позиции или в конце). Клиент видит одну сумму, платёжный процессор получает другую — иногда разница в $0.01.

Именно из-за такого несоответствия начинаются споры. Пользователям не важно, что разница — «просто округление». Им важно, что экран оформления, чек и списание по карте не совпадают.

Решите вопросы валюты и правил округления до того, как писать код

Многие баги в биллинге вызваны не «плохой математикой», а тем, что никто не согласовал правила.

Начните с выбора валюты ценообразования. Для раннего тестирования обычно достаточно одной валюты. Вы всё ещё можете показывать символ в интерфейсе, но продукт должен иметь одну официальную валюту для сохранённых цен и списаний.

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

Небольшой набор правил предотвратит большинство споров:

  • одна валюта ценообразования для хранимых цен
  • один момент округления (по строке или на итоговой сумме)
  • фиксированный порядок операций (например: скидки, затем налог, затем сборы)
  • понятное обещание про отображение и списание (то, что видит пользователь, должно совпадать с тем, что отправляется процессору)
  • один источник правды при расхождениях (ваши сохранённые итоги или итог процессора)

Если потом вы перейдёте на хранение целых центов, эти правила укажут, что сохранять, что вычислять и с чем сравнивать результаты процессора.

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

Если вы хотите, чтобы цены вели себя одинаково во всех средах, самое простое и безопасное — хранить деньги в целых минорных единицах (центах для USD) и хранить код валюты отдельно.

Когда вы сохраняете сумму как десятичную 9.99, многие системы не могут представить её точно. Даже если UI выводит «$9.99», сохранённое значение может быть чуть выше или ниже. Эта разница может изменить итоги после налогов, скидок или повторных вычислений. Если вы сохраняете 999, математика остаётся точной.

Чистая модель хранения состоит из двух частей:

  • сумма в минорных единицах (целое число)
  • код валюты (например “USD” или “EUR”)

Используйте имена полей, которые затрудняют ошибку:

  • amount_cents (integer)
  • currency (string)
  • description (по желанию, для чеков и логов)
  • created_at (для аудита)
  • metadata (по желанию, отдельно от денежных полей)

Не храните форматированные строки. «$9.99» — это визуальное представление, а не значение. Форматируйте на границе UI из amount + currency.

Запланируйте отрицательные значения заранее. Возвраты, кредиты и чарджбеки — нормальная часть работы. Знаковый целый делает возврат явно — например, -999 в той же валюте.

Пошагово: реализуем ценообразование в центах в прототипе

Make billing math predictable
Пришлите ваш код — мы заменим вычисления денег на числах с плавающей точкой на безопасную логику с целыми центами.

Выберите одну базовую валюту и зафиксируйте её: в чём вы берёте плату, какой символ показываете и как округляете. Это одно решение уберёт много сюрпризов типа «мы думали, что это USD».

Затем храните деньги в целых центах везде, где это важно. В базе данных держите amount_cents как integer (999 означает $9.99) и currency как короткий код, например USD. В коде тоже передавайте суммы как целые.

Простой предсказуемый поток:

  • храните прайс-листы в центах (plan_price_cents = 999)
  • умножайте и складывайте, используя целые (количество, доп. опции, единицы использования)
  • применяйте скидки целочисленной арифметикой, с одним правилом округления
  • добавляйте налог и сборы по тому же правилу
  • сохраняйте результаты как subtotal_cents, tax_cents, total_cents и код валюты

Округляйте только там, где это разрешено вашими правилами. Частый подход: вычислить подитог в центах, затем посчитать налог от этого подитога и округлить налог один раз до центов.

Для аудита логируйте входные данные (цены, количества, скидки, ставка налога, валюта) и выходы (subtotal_cents, tax_cents, total_cents). Если итог кажется неправильным, эти логи делают проблему очевидной, а не загадочной.

Налоги, скидки и сборы без сюрпризов округления

Если вы храните базовые цены в целых центах, большинство проблем всплывёт позже: налоги, чаевые, сборы и процентные скидки. Эти шаги создают доли цента, поэтому нужны последовательные правила.

Выберите одно правило округления и используйте его везде

Выберите метод округления и придерживайтесь его. Два распространённых варианта — округление «по арифметическому правилу» (half-up, 0.5 вверх) и «банковское» (half-even). Любой из них может подойти. Проблема возникает при смешивании разных методов — тогда появляются споры «ваша квитанция отличается от нашей».

Также решите, когда округлять. Практическое правило: делайте вычисления в минорных единицах и округляйте только тогда, когда нужно вернуть результат в центы после процентного расчёта.

Процентные скидки: избегайте ловушки в один цент

10% скидка от 999 центов = 99.9 цента. Эта десятая доля цента где-то должна оказаться, и это должно происходить одинаково каждый раз.

Надёжная последовательность:

  • вычислите discount_cents от исходного подитога
  • округлите discount_cents один раз выбранным методом
  • вычтите discount_cents из subtotal_cents
  • при необходимости считайте налог с уменьшенного подитога

Это избегает двойного округления, когда вы округляете по ходу, а затем снова в конце.

Делайте сборы явными

Сборы проще понимать и отлаживать, когда они отдельными полями, а не спрятаны в итогах. Используйте понятные имена вроде shipping_cents, service_fee_cents, platform_fee_cents и tip_cents. Тогда чек будет соответствовать базе.

Если вы можете объяснить каждый цент в счёте, обычно удаётся избежать споров.

Мультивалютность: что делать сейчас и что отложить

Большинству прототипов не нужна настоящая мультивалютность с первого дня. Если ваши пользователи платят в одной стране и вы рассчитываетесь в одной валюте, держите всё в одной валюте и доведите базу до ума. Вы всё ещё можете показывать конвертированную оценку, но считайте её только отображением, а не суммой списания.

Если вы всё же поддерживаете несколько валют, каждая сумма должна идти в паре с кодом валюты (USD, EUR, GBP). Паттерн с целыми минорными единицами остаётся в силе, но «центы» не универсальны. У некоторых валют нет минорных единиц (JPY), у некоторых их три (KWD). Поэтому храните:

  • целую сумму в минорных единицах
  • код валюты
  • точность минорной единицы валюты (выводимую из кода)

Учтите, что обменные курсы не обратимы. Конвертация USD→EUR→USD не вернёт исходную целую сумму. Это нормально. Ошибка — притворяться, что конверсии без потерь, а потом спорить из‑за отсутствующего цента.

Если вы поддерживаете мультивалюту, зафиксируйте:

  • откуда берутся курсы
  • сколько действует курс
  • что вы сохраняете (списанная сумма, использованный курс, ссылка на источник конверсии)
  • когда вы конвертируете (при оформлении, в счёте, при расчёте)
  • как вы округляете

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

Частые ошибки, ведущие к спорам по платежам

Fix the risky parts fast
Мы укрепляем приложения, созданные ИИ, против проблем вроде утечек секретов и уязвимостей SQL-injection.

Большинство споров начинается с мелочи: один экран показывает $19.99, чек — $20.00, а списание по карте — $19.98. Пользователям не важно почему. Им важно, что кажется неаккуратно или нечестно.

Частая причина — хранение «красивых» значений вместо сырой суммы. Если вы сохраняете «$10.00» или «10.00» (уже форматированное), разные части приложения будут пересобирать, заново округлять или догадываться о валюте. Безобидный выбор для показа превращается в неправильные итоги.

Ещё одна причина — несколько мест, где считаются итоги. Если корзина округляет каждую позицию, счёт округляет подитог, а запрос на платёж округляет окончательную сумму, у вас может получиться три разных ответа.

Шаблоны, которые часто создают расхождения:

  • вычисление итогов в браузере и доверие им без серверной проверки
  • генерация писем или счётов другим путём, чем в оформлении заказа
  • захардкоженный символ валюты и предположение, что все валюты ведут себя как USD
  • применение сначала скидок, потом налогов в одном месте и наоборот в другом
  • пропуск краевых тестов (мелкие суммы, много позиций, повторные добавления/удаления)

Быстрые проверки перед запуском платежей

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

Начните с хранения: суммы должны быть целыми в наименьшей единице для этой валюты, включая возвраты и кредиты. Если вы видите десятичные значения в столбцах базы данных для сумм — это красный флаг.

Проверьте обработку валют: каждая сумма должна идти с кодом валюты. Если API возвращает amount: 1999 без currency: "USD", позже кто‑то будет гадать.

Наконец, назначьте одного владельца итогов. Одна функция или сервис вычисляет подитог, налоги, скидки, сборы и общий итог. Все остальные читают сохранённые результаты. Если страница оформления и обработчик webhook оба пересчитывают, они рано или поздно разойдутся.

Короткий чеклист, который ловит большинство проблем:

  • целые суммы везде, где это важно (включая возвраты)
  • код валюты присутствует для каждой денежной величины
  • итоги вычисляются в одном месте, сохраняются и повторно используются
  • правила округления задокументированы и покрыты тестами
  • номера счётов/чеков соответствуют сумме списания в центах

Тестируйте реальные краевые случаи, а не только примеры вроде «$1.00»: процентные скидки плюс налог, частичные возвраты после скидок, сборы до и после налога, корзины с множеством мелких позиций.

Реалистичный пример: куда исчезает один цент

Stop one-cent charge errors
Мы найдём, где ваши суммы расходятся, и покажем точные исправления до запуска платежей.

Предположим, вы продаёте план за $9.99, даёте 20% скидку и начисляете 8.25% налог. План выставлен на 3 посадочных места в одном счёте.

При хранении в целых центах каждая позиция — 999 центов. Разногласие возникает из‑за того, когда вы округляете.

Два разумных подхода к округлению

Метод A: округлять каждое место после скидки

Скидка на одно место: 20% от 999 = 199.8 цента, округляется до 200 центов. Чистая цена за место: 999 - 200 = 799 центов. Для 3 мест: 799 × 3 = 2397 центов ($23.97). Налог: 2397 × 0.0825 = 197.7525 цента, округляется до 198 центов. Итого: 2397 + 198 = 2595 центов ($25.95).

Метод B: округлять один раз по подитогу

Подитог: 999 × 3 = 2997 центов ($29.97). Скидка: 20% от 2997 = 599.4 цента, округляется до 599 центов. Чистая сумма: 2997 - 599 = 2398 центов ($23.98). Налог: 2398 × 0.0825 = 197.835 цента, округляется до 198 центов. Итого: 2398 + 198 = 2596 центов ($25.96).

Оба метода имеют право на существование. Разница — один цент. Если ваш UI показывает один метод, а бэкенд списывает по другому, вы получаете спор.

В счёте подробно опишите расчёт, чтобы математика была понятна. Для возвратов не пересчитывайте заново — возвращайте точно списанные центы, иначе возникнет несоответствие.

Логируйте достаточно данных, чтобы воспроизвести расчёт:

  • код валюты, ставка налога, ставка скидки
  • правило округления и место, где округление происходит
  • позиции, количества и итоговые списанные центы
  • ключевые промежуточные значения (до и после округления)
  • ID платёжного процессора для списания и возврата

Следующие шаги: сделайте обработку денег скучной и надёжной

Цель — не изящный биллинг-код. Цель — итоги, которые всегда совпадают: корзина, счёт, чек, возвраты и отчёты.

Запишите ваши правила денег в коротком общем документе: поддерживаемые валюты, метод округления, где допускается округление и порядок операций. Затем добавьте небольшой набор тестов, которые зафиксируют эти поведения, особенно вокруг мелких скидок, множества позиций и частичных возвратов.

Если вы унаследовали прототип, сгенерированный ИИ (особенно из инструментов вроде Lovable, Bolt, v0, Cursor или Replit), быстрая зачистка стоит того перед запуском для реальных клиентов. Математика с плавающей точкой часто прячется в хелперах, функциях форматирования UI или в колонках базы данных с разной точностью.

FixMyMess (fixmymess.ai) помогает командам превратить «платежи работают в основном» прототипы в готовые к продакшен биллинговые потоки: мы диагностируем математику, нормализуем хранение денег и ужесточаем правила округления, чтобы списанная сумма оставалась одинаковой на каждом шаге.