Skip to content

Денежные суммы, округление и НДС ​

Как система хранит и считает деньги, чтобы суммы, наценки и НДС всегда сходились до копейки.


Главный принцип: деньги — это десятичные числа, а не 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")
Отрицательные суммыкорректно округляются (возвраты, корректировки)

Арифметика ​

go
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.


Правила для разработчиков ​

  1. Никогда не объявляйте денежное поле как float64. Используйте money.Money (или *money.Money для NULL-сумм).
  2. Для чтения NULL-колонки сканируйте через money.NullMoney и берите .Ptr() — в *money.Money напрямую сканировать нельзя.
  3. При записи *money.Money в БД оборачивайте в money.PtrValue(p) — это nil-безопасно.
  4. Любое умножение/деление денег (НДС, наценка, доля, количество) выполняйте через методы money.Money/decimal, а не через float64.
  5. Округляйте один раз на границе результата, а не на каждом промежуточном множителе, где это возможно.

См. также Систему бюджета и сметы.

Загружаем документацию…