Мультивалютная выставка счетов по подписке: практические правила округления и минимальная модель таблиц, чтобы итоги совпадали на вебе, в мобильном и в бухгалтерских экспортax.

Обычная боль: на вебе итог один, в мобильном приложении — чуть другой, а экспорт в бухучёт даёт третье число. Каждая система делает «разумные» вычисления, но не одинаковые.
Подписки усугубляют ситуацию, потому что расчёт повторяется снова и снова. Маленькие отличия накапливаются при продлениях, при пропорциональном начислении при апгрейде в середине периода, кредитах и возвратах, повторных списаниях после ошибок и частичных периодах в начале или конце тарифа.
Дрейф обычно начинается с мелких решений, которые незаметны до определённого момента: когда округлять (на строке или в конце), какая база для налога используется (net vs gross), как обращаться с валютами с 0 или 3 десятичными знаками, и какой FX-курс применяется (какая временная метка, какой источник, какая точность). Если веб округляет до 2 знаков на строке, а мобильное приложение округляет только окончательный итог, разница в 0.01 возможна даже при тех же входных данных.
Цель скучная, но важная: один и тот же счёт должен давать одинаковые итоги везде и всегда. Это успокаивает клиентов, сокращает тикеты в поддержку и выдерживает проверку аудитом.
«Согласованность» означает, что для конкретного invoice ID и версии:
Пример: клиент апгрейдится с EUR 19.99 до EUR 29.99 в середине месяца, получает пропорциональный платёж, затем небольшой кредит за простой. Если одна система округляет каждую пропорциональную строку, а другая — только итог, экспортированный счёт может не совпадать с тем, что видел клиент, хотя каждое число выглядит «достаточно близким».
Прежде чем спорить о курсах FX или правилах округления налогов, зафиксируйте основы. Если они размыты, счета будут расходиться между вебом, мобильным и экспортами.
Каждая строка счёта и итог должны явно хранить три суммы: net (до налога), tax и gross (net + tax). Выберите одно в качестве источника правды для хранения и вычислений, а остальные выводите одинаково везде. Многие команды хранят net и tax, а gross вычисляют как net + tax — это упрощает аудит и возвраты.
Ясно указывайте валюту каждой суммы. Часто путают три понятия:
Они могут совпадать, но не обязаны. Если счёт в EUR, а карта списывает в USD, счёт всё равно должен быть согласован в EUR, даже если банковский депозит отличается.
Далее — работайте с деньгами как с целыми числами в минорных единицах (например, центы). Хранение 9.99 как числа с плавающей точкой часто создаёт проблемы вроде 9.989999, особенно при добавлении налогов, скидок, пропорций или множества позиций. Храните 999 (центов) с кодом валюты и форматируйте только для отображения.
Наконец, выберите режим налогового ценообразования:
Конкретная проверка: план, указанный как 10.00 (tax-inclusive, 20% VAT), должен приводить к одному и тому же сохранённому gross в минорных единицах на вебе и мобильном, а затем net и tax должны выводиться по одному общему правилу.
Отличия по курсам часто начинаются раньше, чем правила налогообложения или округления. Две системы могут быть «правы» и всё равно не совпадать, потому что использовали разные источники, разные временные метки или разную точность.
Поставщики курсов редко полностью совпадают. Некоторые публикуют межбанковский курс, другие включают спред. Частота обновления разная: раз в минуту, час или день. Даже при одном провайдере одна система может округлять курс до 4 знаков, а другая хранить 8+, что влияет на итоги при умножении сумм подписки и налогов.
Самое важное — решить, что означает временная метка курса. Если вы выставляете счёт в EUR, а клиент платит в USD, фиксируете ли вы курс при выпуске счёта или при подтверждении платежа? Оба подхода распространены, но их смешение между вебом, мобильным и экспортами гарантированно создаёт рассогласования.
Как только правило выбрано, сохраните точный курс, который использовали, в счёте. Не пересчитывайте позже по «текущим» курсам, даже если можете посмотреть исторические значения. Исправления провайдера, разница в часовых поясах и небольшие изменения точности заставят старые счета дрейфовать при экспортах или повторной генерации PDF.
Простой пример: счет выписан в 23:59, а платёж прошёл в 00:02. Эти метки часто попадают на разные «дни» у провайдера, и дневная таблица курсов даст разные числа.
Решите и документируйте детали FX:
Особые случаи: валюты без минорных единиц (например, JPY), высокоточные курсы и возвраты. Возвраты обычно должны использовать исходный курс, сохранённый в счёте. Иначе сумма возврата может отличаться от ожиданий клиента и экспортов в бухгалтерию.
Чтобы счета совпадали на вебе, в мобильном и в экспортируемых файлах, модель данных должна хранить результаты, а не только входные параметры. Цель проста: один и тот же счёт должен рендериться с одинаковыми минорными единицами везде, даже через месяцы.
Небольшой набор сущностей обычно достаточен:
Ключевое правило: поля денег должны быть целыми в минорных единицах. Храните и цену за единицу, и вычисленные итоговые значения по строкам. Это предотвращает пересчёт с другим правилом округления или по другому источнику FX.
FX нужно фиксировать в самом счёте, а не выводить из таблицы. Даже если у вас есть общая таблица курсов, счёт должен хранить точное fx_rate_value, использованное при финализации (и откуда оно взято), чтобы экспорты могли воспроизвести те же числа.
Отдельная таблица детализации налогов нужна только когда один счёт содержит несколько налоговых ставок или юрисдикций (например, смешанные товары, EU VAT + местный сбор, или смена ставки в зависимости от адреса). Тогда храните по одной записи на ставку с taxable_base_minor и tax_amount_minor.
Наконец, относитесь к финализированному счёту как к неизменяемому. Снимайте снимок вычисленных значений в момент финализации и никогда не пересчитывайте итоги из подписки позже. Это одно решение убирает большинство вопросов «почему центы поменялись?».
Округление — это не математическая мелочь, это правило продукта. Если веб округляет одним образом, мобильное — другим, а экспорт — третьим, вы получите разные итоги даже при одинаковых входных данных.
Есть три распространённых стратегии, они различаются тем, где вы «фиксируете» минорные единицы:
Для подписок разумным дефолтом обычно бывает округление по строке. Это предсказуемо для клиентов (каждая строка выглядит корректно), просто объяснить в аудите (можно распечатать, как получилась каждая строка) и стабильно при продлениях. Округление по единице может дрейфовать при изменении количества или при отображении цен за единицу в UI. Округление только по итогу часто вызывает тикеты «почему суммы строк не сходятся с итогом?», потому что видимые суммы строк могут не давать показанного итога.
Классическая «проблема пенни» появляется при многих мелких позициях или дробных налогах. Пример: 20 строк дают по 0.004 остатка округления каждая. Округление по строке может дать разницу в 0.08 по сравнению с округлением только в конце. При FX-конверсиях эти крошечные остатки появляются чаще и накапливаются в экспортируемых отчётах и отчётах по доходам.
Что бы вы ни выбрали, сделайте это детерминированным. Одна и та же входная комбинация должна всегда давать одинаковый результат на вебе, в мобильном и в экспортах:
Если вы разрабатываете и веб, и мобильный биллинг, опишите правило округления в виде тестируемой спецификации, а не только как поведение UI.
Чтобы обеспечить одинаковые числа на вебе, в мобильном и в экспортируемых файлах, относитесь к вычислению как к рецепту. Ключевая идея: вычисляйте с высокой точностью, но сохраняйте и суммируйте только целые минорные единицы в валюте счёта.
Начинайте с каждой строки с net в высокой точности. Сохраняйте дополнительные знаки, когда умножаете на количество, применяете скидки и (при необходимости) конвертируете валюту. Затем выполните одно округление в минорные единицы валюты счёта по выбранному правилу и сохраните это целое как line net.
Вычислите налог от сохранённого line net (или от подитога группы налогов, если правила позволяют группировать по ставке). Примените то же правило округления и сохраните налог как целое в минорных единицах. Именно здесь системы часто расходятся: одна сторона округляет до налога, другая — после.
Вычислите gross по строке как (сохранённый net + сохранённый tax). Итоги счёта — это суммы сохранённых миноров. Не пересчитывайте итоги из чисел с плавающей точкой для отображения. Клиенты и экспорты должны читать сохранённые целые и форматировать их.
Если локальные правила требуют итоговых налогов на уровне счёта, может понадобиться распределить остаток. Пример: три строки по 0.01 налога дают 0.03, но округление на уровне счёта требует 0.02. Решите детерминированный критерий (например, добавлять или вычитать 1 минорную единицу, начиная с наибольшей налогооблагаемой строки, затем стабильная сортировка по id строки). Сохраните корректировку как небольшую налоговую правку по затронутым строкам, чтобы каждая система могла воспроизвести её.
Зафиксируйте счёт. После окончательного округления и распределения остатков счёт считается неизменяемым. Если цена подписки изменится позже, создавайте новый счёт или кредит-ноту, но никогда не переписывайте старые числа.
Конкретная проверка: если тариф EUR 9.99 с 19% VAT, ваш сохранённый net может быть 999 центов, tax 190 центов, gross 1189 центов. Каждый клиент должен рендерить 11.89 EUR из этих сохранённых целых, а не пересчитывать НДС «на лету».
Округление налога — это место, где корректная математика превращается в рассогласованные счета. Суть проста: более раннее округление меняет итоговую сумму.
Если округлять налог по каждой строке (или по единице), а затем суммировать, вы получите другой итог, чем при суммировании неокруглённых налогов по счёту с последующим округлением в конце. При большом количестве строк разница накапливается, особенно если минорные единицы и FX-конверсии уже создают дроби.
Пример (2 знака): две строки с налогооблагаемой суммой 0.05 и налогом 10%. Неокруглённый налог по строке — 0.005. При округлении по строке каждая становится 0.01, итог налога 0.02. При округлении на уровне счёта общий налогооблагаемый итог 0.10, налог 0.01. Оба подхода имеют право на существование, но расходятся.
Когда нужно показывать налог по строке и при этом итог счёта должен точно совпадать, распределяйте остаток детерминированно:
Экспорты всё ещё могут уходить вразнобой, если бухгалтерия группирует строки (по продукту, ставке налога или юрисдикции). Чтобы экспортируемые итоги совпадали с итогами счёта, сначала распределяйте остатки внутри каждой требуемой группы, затем убедитесь, что групповые итоги складываются в тот же налог и gross на уровне счёта.
Если учёт требует разбивки налогов по ставке или юрисдикции, а UI показывает одно число, всё равно сохраняйте детализацию (подитоги по ставкам/юрисдикциям и понятное правило аллокации). UI может показывать единый итог, а экспорты будут нести подробные корзины без изменения общего итогового счёта.
Большинство несоответствий счетов происходят в углах. Решите правила заранее — и это перестанет быть сюрпризом.
Валюты без минорных единиц требуют особого подхода. JPY и KRW не имеют дробных единиц, поэтому любой шаг, который предполагает «центовую» логику, может тихо породить расхождения. Решите, округляете ли вы по строке, на уровне налога или только в конце, и убедитесь, что все клиенты используют одинаковые настройки валюты.
Трансграничный VAT/GST может менять ставку в зависимости от местоположения клиента и доказательств (адрес, IP, налоговый номер). Сложность не столько в самой ставке, сколько в моменте её фиксации. Выберите момент (чекаут, дата выписки счета или начало периода обслуживания) и придерживайтесь его.
Прорейтизация — место, где дроби множатся. Апгрейд в середине периода может дать значения вроде 9.3333... за день. Решите, прайсуете ли вы net сначала, gross сначала или по периоду, а затем вычисляйте остальные величины. Порядок операций меняет последнюю минорную единицу.
Запишите правила, чтобы они не менялись со временем:
Возвраты — последний капкан. Если исходный счёт имел распределённый остаток в 0.01 на одной строке, возврат должен инвертировать именно эту аллокацию. Иначе клиент видит один итог, а ваши бухгалтерские экспорты — другой.
Большинство рассогласований не из-за «сложной математики». Они появляются из-за маленьких, несогласованных решений в разных частях стека.
Одна большая ошибка — хранение денег как чисел с плавающей точкой. Значение вроде 19.99 нельзя представить точно во многих системах, поэтому при суммировании строк, применении скидок или расчёте налогов появляются мелкие ошибки. Храните суммы как целые в минорных единицах и указывайте код валюты и масштаб минорных единиц.
Ещё частая проблема — пересчёт FX при экспорте. Клиент платил по конкретному курсу и в конкретное время. Если экспорт берёт «сегодняшний» курс, итог может отличаться, даже если все шаги корректны. Рассматривайте счёт как снимок: храните использованный FX, конвертированные суммы и результаты округления.
Округление расходится также, когда UI и бэкенд округляют на разных шагах. Например, бэкенд округляет налог по строке, а веб UI — только итог. Оба подхода выглядят разумно, но они не совпадут.
Пять частых причин большинства расхождений:
Быстрая практическая проверка: мобильное приложение показывает три позиции по EUR 9.99 с налогом 20%. Если приложение округляет налог в конце, а бекенд — по строкам, вы можете получить расхождение в EUR 0.01. Эта единственная центная разница достаточна, чтобы сломать сверку и вызвать тикет в поддержку.
Самое простое исправление скучно, но эффективно: вычислять один раз на бекенде, сохранять снимок счёта и разрешать веб/мобильным отображать именно эти сохранённые числа.
Если числа различаются между вебом, мобильным и экспортом, обычно это не математическая проблема, а проблема хранения и округления.
Исходный принцип: клиенты должны показывать то, что хранит счёт, а не пересчитывать его. Бэкенд — единый источник правды; все каналы должны читать одни и те же сохранённые значения.
Возвраты и кредит-ноты должны зеркалировать исходные результаты округления. Если исходный счёт округлял налог по строке, возврат должен делать то же самое, используя ту же точность валюты и сохранённый FX-курс. Иначе появятся мелкие остатки, которые со временем накопятся.
Практический способ обеспечить это — хранить явный снимок расчёта в каждом счёте: валюта, точность минорных единиц, режим округления, FX-курс и временная метка, и финализированные миноры строк.
Вот пример счёта, который остаётся согласованным везде.
Предположим: счёт выписан в EUR (2 знака), VAT 20%, а клиент списывается в USD. Бекенд сохраняет FX-снимок: 1 EUR = 1.0857 USD.
| Item | Net (EUR) |
|---|---|
| Pro plan (monthly) | 19.99 |
| Extra seats | 10.00 |
| Discount (10% of 29.99, rounded) | -3.00 |
Net total (EUR) = 26.99
VAT 20% (EUR) = 5.40 (потому что 26.99 x 0.20 = 5.398, округлено до 5.40)
Gross total (EUR) = 32.39
Далее бекенд выводит суммы в валюте списания из сохранённых EUR-итогов и FX-снимка:
Если вы также храните USD по строкам, чаще всего появится разница в 0.01 при округлении каждой конвертированной строки и их суммировании. Именно здесь счета обычно расходятся.
Сделайте поведение детерминированным: конвертируйте и округляйте каждую строку, затем распределяйте оставшиеся центы (положительные или отрицательные) в фиксированном порядке (например, по возрастанию line_id), пока сумма строк не совпадёт с заранее зафиксированным gross в USD.
Веб и мобильное должны отображать сохранённые на бекенде итоги по строкам, налогам, FX-курсу и gross, а не пересчитывать их. Экспорт в бухгалтерию должен отдавать те же сохранённые числа плюс FX-снимок (курс, временная метка или источник), чтобы журналы совпадали с тем, что видел клиент.
Практический следующий шаг — реализовать расчёт как один общий сервис, который возвращает единый снимок счёта (строки, налоги, итоги, FX, корректировки округления) и заставить все каналы рендерить из этого снимка. Если вы строите такие потоки на Koder.ai (koder.ai), модель снимков поможет вебу, мобильным и экспортам оставаться согласованными, потому что все они читают одни и те же сохранённые значения.
Потому что разные системы часто принимают чуть разные решения о том, когда округлять, что округлять (нетто vs брутто) и какую точность сохранять для налогов и FX. Эти небольшие отличия дают разрывы в 0.01–0.02, особенно при правах, кредитах и повторных попытках списания.
Храните суммы как целые числа в минорных единицах (например, центы) и дополнительно сохраняйте код валюты; форматируйте только для отображения. С плавающей точкой многие десятичные дроби невозможно представить точно, поэтому при суммировании налогов, скидок или нескольких строк появляются ошибки.
Выберите одно значение как источник правды и выводите остальные по одному и тому же правилу. Обычная практика — хранить net и tax в минорных единицах, а gross = net + tax вычислять явно, потому что так проще работать с возвратами и аудитом.
Invoice currency — валюта, в которой юридически выражены итоги счёта и с которой вы сверяете отчёты. Display currency — то, что показывают пользователям при выборе тарифов. Settlement currency — валюта, в которой платёжный провайдер делает зачисление. Они могут различаться, но логика счета должна оставаться согласованной в его валюте.
Не подтягивайте курсы заново при экспорте или регенерации PDF. Сохраняйте точный FX-курс, использованный в момент выставления (значение, точность, провайдер и время действия), и всегда используйте его, чтобы старые счета воспроизводились одинаково спустя месяцы.
Выберите одно правило и применяйте его везде: либо «курс на момент выставления счёта», либо «курс на момент списания». Смешивание временных правил между системами часто вызывает расхождения, особенно в зоне перехода дней и часовых поясов.
По умолчанию для подписок безопаснее округлять по строке: округляете итог каждой строки, а затем суммируете сохранённые миноры. Это проще объяснить пользователю, уменьшает тикеты «строки не сходятся» и даёт стабильный результат при продлениях, если все каналы используют одно правило.
Явно выберите либо округление налога по строкам, либо на уровне счёта, затем сделайте алгоритм детерминированным. Если нужно договориться с итогом на счёте, распределите остаток предсказуемо (например, по строкам с наибольшими дробными частями) и сохраните итоговые налоговые миноры для каждой строки, чтобы все системы показывали одинаковый результат.
Прорейтизация даёт дробные значения (например, 9.3333... за день), поэтому порядок операций имеет значение. Выберите метод (например, сначала пропорция по net, затем вычисление налога от сохранённого net), округляйте в согласованной точке и сохраняйте финальные миноры строк, чтобы апгрейды, даунгрейды, кредиты и возвраты точно зеркалировали исходную арифметику.
Проще всего: бекенд должен выпускать финализованный снимок счёта (строки, налоги, итоги, правила минорных единиц, FX-снимок, режим округления) и считать его неизменяемым. Веб, мобильные, PDF и экспорты отображают сохранённые целые значения, а не пересчитывают — этот паттерн также удобен при построении биллинга на Koder.ai.