Денежные суммы, округление и НДС
Как система хранит и считает деньги, чтобы суммы, наценки и НДС всегда сходились до копейки.
Главный принцип: деньги — это десятичные числа, а не float
Денежные суммы никогда не считаются в плавающей точке (float64). Причина известна: 0.1 + 0.2 в двоичном float даёт 0.30000000000000004. На одной операции это незаметно, но при суммировании сотен статей затрат, начислении НДС и наценок ошибка накапливается и «итого» перестаёт сходиться.
В системе для денег используется фиксированная десятичная арифметика — пакет internal/money поверх shopspring/decimal.
- В базе данных все денежные колонки имеют тип
NUMERIC(_, 2)— хранятся точно. - В коде они читаются и считаются типом
money.Money, а неfloat64. - Все промежуточные суммы (сумма статей, наценка, НДС, прибыль) считаются в
decimalи округляются до копеек по правилу «половина вверх» (half-up).
Почему это важно для НДС. НДС — это умножение на ставку (
× 22 / 100), а умножение почти всегда даёт «некруглый» результат. Только десятичное округление до копеек гарантирует, чтонетто + НДС = бруттои обратное выделение НДС из брутто сходится копейка-в-копейку.
Тип money.Money
| Возможность | Поведение |
|---|---|
| Точность | 2 знака (копейки), округление half-up |
| Хранение в БД | Scan/Value для NUMERIC — без потери точности |
| JSON на проводе | число (1234.50), а не строка — контракт API остаётся number |
| Приём на входе | принимает и число (10.25), и строку ("10.25") |
| Отрицательные суммы | корректно округляются (возвраты, корректировки) |
Арифметика
sum := money.Sum(a, b, c) // точное суммирование
total := subtotal.Add(markup) // сложение
part := amount.Percent(rate) // p% от суммы (для наценки/НДС)
qtyly := unit.Mul(decimal.NewFromInt(qty)) // умножение на количествоКаждая операция возвращает сумму, уже округлённую до копеек.
НДС
Хелперы НДС живут в internal/money и работают в обе стороны. rate — ставка в процентах (22 = 22%). Основная ставка закреплена константой money.StandardVATRate (22%).
| Функция | Назначение | Пример (ставка 22%) |
|---|---|---|
VATOnNet(net, rate) | НДС сверху на сумму без налога | 100 → 22.00 |
GrossFromNet(net, rate) | сумма с НДС | 100 → 122.00 |
VATInGross(gross, rate) | НДС, уже включённый в сумму | 122 → 22.00 |
NetFromGross(gross, rate) | сумма без НДС из суммы с НДС | 122 → 100.00 |
Выделение НДС из брутто использует формулу gross × rate / (100 + rate), и для любого gross выполняется инвариант:
NetFromGross(gross) + VATInGross(gross) == grossОсновная ставка НДС — 22% (
money.StandardVATRate). Хелперы принимают ставку параметром, поэтому пониженные ставки и освобождения подключаются передачей другогоrate. Главное, что фундамент считает точно при любой ставке.
Налоговый режим: ComputeVAT
Для гибкого НДС (с / без / сверху) есть единый разбор суммы по режиму vat_mode — money.ComputeVAT(amount, mode, rate) возвращает {Net, VAT, Gross}:
Режим (vat_mode) | Трактовка amount | Результат |
|---|---|---|
none | без налога | Net = Gross = amount, VAT = 0 |
included | сумма с НДС | VAT = VATInGross, Net = amount − VAT |
on_top | сумма без НДС | VAT = VATOnNet, Gross = amount + VAT |
Константы режимов — money.VATModes (none/included/on_top); валидатор — money.IsValidVATMode. Где режим хранится и как наследуется проектом — см. Гибкий НДС и «Моя компания».
Где это применяется
Денежный тип используется во всех модулях, где есть суммы:
- Финансы (
internal/modules/finance) — статьи затрат (planned_amount/actual_amount), выручка, P&L (прибыль = выручка − затраты). - Бюджет проекта (
internal/modules/budget) — суммирование статей по категориям, наценка (markup_percent), УСН, НДС и итогgrand_total. Всё вdecimal, округление до копеек на каждом шаге. - Аренда оборудования (
internal/modules/equipmentrequests) — стоимость аренды считается какцена × количество × множитель_по_днямвdecimalс единственным округлением в конце. - Проекты (
internal/modules/projects) —planned_budget,actual_revenue. - Оборудование (
internal/modules/equipment) —cost,unit_cost. - Командировки (
internal/modules/trips) и логистика (internal/modules/logistics) —planned_cost,actual_cost. - Документы (
internal/modules/documents) —total_amount; итог счёта/акта суммируется из позиций (кол-во × цена) вdecimal.
На проводе все эти поля — JSON-числа; контракт OpenAPI/Scalar остаётся
number.
Правила для разработчиков
- Никогда не объявляйте денежное поле как
float64. Используйтеmoney.Money(или*money.Moneyдля NULL-сумм). - Для чтения NULL-колонки сканируйте через
money.NullMoneyи берите.Ptr()— в*money.Moneyнапрямую сканировать нельзя. - При записи
*money.Moneyв БД оборачивайте вmoney.PtrValue(p)— это nil-безопасно. - Любое умножение/деление денег (НДС, наценка, доля, количество) выполняйте через методы
money.Money/decimal, а не черезfloat64. - Округляйте один раз на границе результата, а не на каждом промежуточном множителе, где это возможно.
См. также Систему бюджета и сметы.