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

Почему этот баг возвращается\n\nКоманды часто начинают с одного сервиса заказов и складывают туда всё. Это кажется практичным. Тот же код создаёт корзину, проверяет запас, выбирает доставку, списывает карту, отправляет чек и считает итоги.\n\nИменно последняя задача вызывает проблемы.\n\nПоток заказа и финансовые правила — это разная работа. Поток заказа отвечает на вопросы вроде:\n\n- Нажал ли клиент "купить"?\n- Есть ли товар на складе?\n- Нужно ли создавать отправление?\n- Отправлять ли подтверждение по почте?\n\nФинансовые правила отвечают на совсем другие вопросы. Как округлять налог в этом регионе? Уменьшает ли store credit налогооблагаемую базу или применяется после налога? При частичном возврате, какие позиции отменять в первую очередь и как обработать последний цент?\n\nКогда этим решением управляет код заказов, итоги со временем расходятся. Один разработчик добавляет правило скидки на этапе оплаты. Другой месяц спустя правит математику возврата. Третий меняет обработку налогов для одного штата или страны. Каждое изменение кажется небольшим, но логика растекается по хендлерам, хелперам и единичным фиксам.\n\nПотом появляется баг с возвратом, и кто‑то срочно правит общую функцию. Возврат работает. Checkout ломается. Или корзина выглядит правильно, но счёт и возврат расходятся на один цент. Клиенты это замечают быстро.\n\nЭтот баг возвращается, потому что финансовая логика имеет длинную память. Небольшое правило сегодня влияет на checkout, счета, кредиты, отмены, частичные возвраты и экспорт в учёт позже. Если эти правила живут внутри общего кода заказов, никто не может сказать, какое изменение сломает следующий шаг.\n\nМодуль денег исправляет это, давая каждому финансовому решению одно место. Заказы по‑прежнему решают, когда списать или вернуть. Модуль денег решает, сколько, как округлять и какое правило побеждает при конфликте правил.\n\nЭто разделение — не просто более чистый код. Оно даёт команде одно место, где читать, тестировать и менять итоги, прежде чем финансовые баги распространится по системе.\n\n## Что gehört в модуль денег\n\nМодуль денег должен владеть каждым правилом, которое может изменить сумму. Если число может измениться на один цент из‑за математики, закона или политики магазина — держите это правило там и нигде больше.\n\nНачните с округления. Решите, когда округлять, как округлять и на каком точном шаге это происходит. Округляйте цены за единицу, суммы строк, налог, доставку и итоговую сумму только в тех местах, которые вы определили. Если одна часть приложения округляет по позиции, а другая — в конце, итоги разойдутся.\n\nНалоги, кредиты, скидки и возвраты должны быть в одном месте по той же причине: они влияют друг на друга. Скидка может снизить налогооблагаемую базу. Кредит может применяться до налога в одном бизнесе и после — в другом. При возврате нужно в фиксированном порядке вернуть цену товара, налог, доставку и долю скидки.\n\nНа практике модуль обычно владеет правилами типа:\n\n- как хранятся и считаются цены\n- когда применяются налоги и от какой суммы\n- как скидки и кредиты уменьшают баланс\n- как частичные и полные возвраты распределяются по товарам, налогу и доставке\n- где происходит округление на каждом шаге\n\nДержите отображаемые цены отдельно от хранимых сумм. Приложение может показывать «$19.99» клиенту, но система должна хранить точную сумму в формате, предназначенном для вычислений, например в центах. Форматирование для отображения — для людей. Хранимые суммы — для математики. Смешение этих задач создаёт странные баги, особенно при поддержке нескольких валют или налоговых правил.\n\nКод заказов не должен решать финансовые правила. Он не должен угадывать, идёт ли налог до кредита, округляется ли возврат вверх или вниз, или применяется ли скидка к доставке. Он должен спрашивать модуль денег и сохранять результат.\n\nЭта граница значительно упрощает поиск багов. Если финансы сообщают неверный итог, команда проверяет одно место вместо того, чтобы гнаться за математикой по коду корзины, платежным обработчикам, админским инструментам и задачам возврата. Для команд, которые хотят чище логику биллинга, такая единственная граница экономит много времени и избавляет от неприятных одноцентовых ошибок.\n\n## Запишите правила в текст прежде чем кодить\n\nФинансовые баги часто начинаются ещё до того, как кто‑то потрогает код. Один разработчик округляет каждую позицию, другой — итоговый счёт, и оба уверены, что правы. Модуль денег работает лучше, когда команда сначала записывает правила понятным языком.\n\nНачните с хранения. Выберите наименьшую единицу, которую будете держать в базе данных, и никогда не смешивайте её. Для большинства валют это означает хранение целых чисел, например центов, а не чисел с плавающей точкой вроде 19.99. Если вы поддерживаете валюты с разными дробными единицами, тоже зафиксируйте это.\n\nЗатем определите момент налогообложения. Считаете ли вы налог при обновлении корзины, когда клиент нажимает оплатить или при создании счёта? Эти выборы могут дать разные ответы, как только появятся скидки, доставка и изменения адреса. Одно записанное правило лучше, чем пять скрытых допущений.\n\nКороткая страница с правилами должна ответить на пять вопросов:\n\n- Какая единица хранит каждую сумму и как вы округляете?\n- Когда рассчитывается налог и округляете ли вы по строке или по заказу?\n- Истекают ли кредиты аккаунта и что происходит с остатком?\n- Как работают частичные возвраты, включая распределение налога и скидки?\n- Какая запись даёт финальную сумму, если экраны расходятся?\n\nПоследний пункт экономит много боли. Выберите одну финальную запись для итогов, например финализованный счёт или запись в биллинге. Не позволяйте корзине, callback платежа, админ панели и шаблону письма каждый по‑своему пересчитывать сумму.\n\nПростой пример показывает, почему это важно. Клиент покупает два товара, использует кредит $10, затем просит возврат одного товара. Если правило возврата не записано, люди будут гадать. Один вернёт половину оплаченной суммы. Другой вернёт половину до налога. Третий вернёт часть store credit.\n\nЗапишите правило один раз, обсудите с продуктом и финансами и держите рядом с кодом. Скучные документы предотвращают дорогие баги.\n\n## Как отделить код денег от кода заказа\n\nСначала найдите все места, где приложение меняет итог. Обычно это checkout, обработка купонов, правки счетов, частичные возвраты, обновления налогов и админские инструменты. Если пять частей кода могут «поправить» цену, одна из них обязательно уйдёт в сторону.\n\nПоместите математику в одно место. Модуль денег может быть пакетом, сервисом или небольшой библиотекой внутри приложения. Форма менее важна, чем правило: код заказов запрашивает числа, но не считает их.\n\nЧистое разделение часто выглядит так:\n\n- Код заказов отправляет входные данные, такие как позиции, цены, налоговые правила, кредиты и причина возврата.\n- Модуль вычисляет итоги за один проход.\n- Модуль возвращает именованные результаты: subtotal, tax, grand total, credited amount и refund amount.\n- Код заказов сохраняет результат и переводит заказ на следующий шаг.\n- Логи и тесты сравнивают возвращённые числа, а не ручные формулы в контроллерах.\n\nФорма возвращаемых данных важнее, чем ожидают многие. Если модуль отдаёт только одно финальное число, разработчики будут делать побочную математику вне него. Возвращайте составные части. Когда возврат кажется неверным, вы должны увидеть, изменился ли налог, округление или кредит применился дважды.\n\nДелайте слой заказов «скучным». Он должен решать такие вещи, как «клиент отменил до отправки» или «платёж не прошёл». Он не должен решать, округляется ли налог по строке или по итогу — это дело биллинга.\n\nВот разделение простыми словами. Поток заказа говорит: «посчитай checkout для этих трёх позиций в этом состоянии с этим store credit». Модуль возвращает subtotal 42.50, tax 3.19, credit used 10.00 и total due 35.69. Позже поток возврата просит тот же модуль посчитать частичный возврат для одного товара. Код заказов ничего не пересчитывает — он отправляет факты и сохраняет ответ.\n\nПроводите изменения малыми шагами. Заменяйте один путь, сохраняйте старый результат для сравнения и логируйте любые расхождения. Команды без избытка ресурсов часто делают это в рамках обычных релизов, по одному endpoint за раз. Это медленнее на неделю или две, но гораздо дешевле, чем гоняться за финансовыми багами по всей кодовой базе заказов.\n\n## Простой пример checkout и возврата\n\nПредположим, в корзине один облагаемый товар за $19.99 и доставка $4.00. У клиента есть $5.00 store credit. Модуль денег должен владеть этой математикой с самого начала.\n\nОн решает, как кредит влияет на налог, как долго держится дробная точность и что реально платит клиент.\n\n### Checkout\n\nВ этом примере кредит уменьшает цену товара до оплаты. Доставка остаётся вне этого правила, и налог применяется только к уценённой цене товара.\n\n- Товар: $19.99\n- Кредит, применённый к товару: -$5.00\n- Налогооблагаемая сумма товара: $14.99\n- Налог 8.25%: $1.236675\n- Доставка: $4.00\n\nМодуль хранит полную точность до конца. Затем суммирует всё и округляет итог единожды. Mатематика: $14.99 + $1.236675 + $4.00 = $20.226675, что даёт $20.23.\n\nЭто выглядит просто, но именно здесь код заказов начинает расходиться. Одна часть рано округляет налог, другая — сумму строки, третья снова вычитает кредит. Отсюда и начинается одноцентовая ошибка, которая при возвратах превращается в большую проблему.\n\n### Возврат\n\nКлиент возвращает товар, но оставляет доставку. Тот же модуль может обратить только часть товара, потому что он знает, как был построен исходный платёж.\n\nОн рассчитывает два отдельных возврата:\n\n- Наличными возврат за товар и его налог: $16.23\n- Кредит, возвращённый на баланс клиента: $5.00\n\nДоставка остаётся $4.00. Итоговое состояние совпадает с расчётом при покупке, поэтому клиент платит только за доставку.\n\nЭта согласованность важнее, чем сам пример. Checkout, возвраты, поддержка, отчёты и экспорт в бухгалтерию используют одни и те же правила и одни и те же числа. Когда финансовая логика спрятана в общем коде заказов, каждый путь склонен править итоги по‑своему. Когда один модуль владеет налогами, кредитами, округлением и возвратами, числа остаются скучными — а деньги должны быть скучными.
Часто задаваемые вопросы
Почему логика денег должна жить отдельно от сервиса заказов?
Потому что поток заказа и правила работы с деньгами решают разные задачи. Заказы определяют, когда списать, отправить или отменить, а модуль денег решает, сколько списать, как округлять, как учитывать налоги, кредиты и возвраты.
Когда один сервис делает и то, и другое, небольшие правки распространяются по checkout, счетам, админским инструментам и коду возвратов. Так появляются одноценточные баги, которые возвращаются снова и снова.
Что должно принадлежать модулю денег?
Хорошее правило — помещать в модуль все правила, которые могут изменить сумму. Это включает округление, налоги, скидки, кредит магазина, расчёт доставки, частичные и полные возвраты.
Если число может измениться на цент из‑за политики или математики, держите это правило в модуле и нигде больше.
Стоит ли хранить цены как float или форматированные строки?
Нет. Храните числовые суммы в наименьшей единице, которую использует ваша система, например в центах, и держите форматирование для отображения отдельно.
Строки и числа с плавающей точкой вызывают проблемы с разбором и точностью. Приложение может показывать «$19.99» человеку, но база данных должна хранить строгий формат для вычислений.
Когда стоит округлять цены и налог?
Выберите одно правило округления и используйте его везде. Решите, когда происходит округление, как оно выполняется и округляете ли вы по строке или только по итоговой сумме.
Запишите это правило и реализуйте в коде. Если в checkout и в возвратах используются разные правила, суммы быстро разойдутся.
Как кредит магазина должен влиять на налог?
Напишите правило до того, как кодировать. Решите, уменьшает ли кредит облагаемую сумму, применяется ли он до или после налога и остаётся ли доставка вне этого правила.
Выберите правило и используйте его во всех местах: checkout, счётах и возвратах. Поздние догадки создают расхождения в суммах.
Как обеспечить точность частичных возвратов?
Используйте тот же модуль денег, который собрал исходный платёж. Передайте ему исходные факты, попросите результат возврата и сохраните возвращённые суммы вместо того, чтобы пересчитывать в слое заказов.
Так синхронизируются цена товара, налог, доставка, доля скидки и восстановленный кредит.
Какой первый шаг при разделении кода?
Начните с возвратов. Они затрагивают налоги, кредиты, скидки и округление, но обычно их путь короче, чем полный путь checkout.
Переместите один путь возврата в модуль, сравните старые и новые результаты и логируйте расхождения. Это даст команде безопасный шаблон для следующих шагов.
Какая запись должна считаться финальной суммой?
Выберите одну запись как источник истины для итогов. Во многих системах это финализованный счёт или запись в бухгалтерском журнале.
Не позволяйте cart, callback платежа, админке и шаблону письма каждый по‑своему пересчитывать итог. Одна финальная запись сохраняет единое число для поддержки, финансов и инженеров.
Как обрабатывать мультивалютные заказы и изменения налогов?
Для заказов с несколькими валютами сохраняйте валюту заказа, использованный курс обмена и списанные суммы. Для изменений налогов сохраните снимок налоговой ставки на момент покупки, если вам нужна согласованность позже.
Без этих записей возвраты и отчёты начнут расходиться по мере смены ставок.
Когда стоит просить внешнюю помощь по архитектуре биллинга?
Если команда постоянно патчит баги с возвратами, служба поддержки вручную исправляет суммы или финансы теряют доверие к цифрам, стоит привлечь внешнее мнение. Короткий архитектурный аудит может сэкономить недели проб и ошибок.
Oleg Sotnikov помогает командам выстраивать границы биллинга и перемещать правила денег в одно место без остановки обычной работы продукта.