Kravchenko

Web Lab

АудитБлогКонтакты

Kravchenko

Web Lab

Разрабатываем сайты и автоматизацию на современных фреймворках под ключ

Услуги
ЛендингМногостраничныйВизитка
E-commerceБронированиеПортфолио
Навигация
БлогКонтактыАудит
Обратная связь
+7 921 567-11-16
info@kravlab.ru
с 09:00 до 18:00

© 2026 Все права защищены

•

ИП Кравченко Никита Владимирович

•

ОГРНИП: 324784700339743

Политика конфиденциальности

Деньги в коде без сюрпризов: хранение сумм, валют, курсов и налогов — меньше инцидентов и споров с бухгалтерией

Разработка и технологии24 февраля 2026 г.
Денежные ошибки почти всегда обходятся дороже, чем кажется: спор с клиентом из-за 1 копейки, округление не туда, рекалькуляция курсов при возвратах. Разбираем практики, которые избавляют от плавающих неточностей: как хранить суммы, как округлять, как работать с валютами и налогами, и как сделать проводки проверяемыми. С примерами на PostgreSQL, Python и TypeScript.
Деньги в коде без сюрпризов: хранение сумм, валют, курсов и налогов — меньше инцидентов и споров с бухгалтерией

Оглавление

  • Почему деньги часто «плывут» и чем это бьёт по бизнесу
  • Базовые принципы: точность, неизменяемость, аудит
  • Как хранить суммы: целые минимальные единицы vs NUMERIC
    • Пример схемы для minor units (PostgreSQL)
  • Валюты и курсы: экспоненты, фиксация курсов, конвертация без ошибок
    • Таблица курсов и конвертация
  • Округление: где, когда и по каким правилам
    • Python: точная арифметика с Decimal
    • Округление к шагу 0.05
  • Налоги: считать по строкам или по итогу, и что сохранять
    • Пример расчёта НДС по строкам (Python)
  • Возвраты и сторно: отрицательные суммы без хаоса
  • Мини-бухучёт для разработчика: двойная запись в базе
    • Схема проводок (PostgreSQL)
  • Границы API и фронтенда: формат, сериализация, валидация
    • Типы и функции на TypeScript (BigInt)
  • Чек-лист внедрения
  • Итог

Почему деньги часто «плывут» и чем это бьёт по бизнесу

Типовые инциденты:

  • Микрорасхождение в 1–2 копейки между счётом и фактическим списанием — клиент поддержке, лишние возвраты.
  • Повторные перерасчёты на возвратах по «текущему» курсу — финансовая дыра и споры с бухгалтерией.
  • «Красивое» представление суммы на фронте с типом number — потерянные копейки из-за двоичной дроби.
  • Округление в неверном месте — сумма по строкам не сходится с итогом.

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

Базовые принципы: точность, неизменяемость, аудит

  • Храним сумму так, чтобы арифметика была точной. Избегаем двоичной плавающей точки там, где требуются деньги.
  • Любая финансовая операция — неизменяема. Исправления — отдельными обратными операциями (сторно), а не «перезаписью» суммы.
  • Все значимые параметры (валюта, курс, ставка налога, правило округления) фиксируются на момент операции и сохраняются вместе с ней.
  • Балансы считаем из проводок, а не «храним готовые» без основания. Иначе рано или поздно сойдёмся на минус одну копейку.

Как хранить суммы: целые минимальные единицы vs NUMERIC

Есть два надёжных подхода.

  1. Целые минимальные единицы (minor units):
  • Храним 10.50 RUB как 1050 в bigint, вместе с кодом валюты RUB.
  • Преимущества: простая точная арифметика, высокая скорость.
  • Недостатки: нужна таблица валют с «экспонентой» (сколько знаков после запятой) и аккуратное форматирование.
  1. NUMERIC/DECIMAL с фиксированной точностью:
  • В PostgreSQL — NUMERIC(20, 4) или по экспоненте валюты.
  • Преимущества: проще конвертировать и читать в админке.
  • Недостатки: следить за масштабом и округлениями в коде.

Практика: для платёжных систем и кошельков — чаще minor units; для учётных регистров и отчётности — NUMERIC. Можно комбинировать: кошелёк — bigint, акты/счета — NUMERIC.

Пример схемы для minor units (PostgreSQL)

-- Валюты с экспонентой (сколько знаков после запятой)
CREATE TABLE currency (
  code CHAR(3) PRIMARY KEY,
  name TEXT NOT NULL,
  exponent SMALLINT NOT NULL CHECK (exponent BETWEEN 0 AND 6)
);

INSERT INTO currency(code, name, exponent) VALUES
('RUB', 'Российский рубль', 2),
('USD', 'Доллар США', 2),
('JPY', 'Японская иена', 0);

-- Деньги как «целые минимальные единицы»
CREATE TABLE payment (
  id BIGSERIAL PRIMARY KEY,
  amount_minor BIGINT NOT NULL,
  currency CHAR(3) NOT NULL REFERENCES currency(code),
  -- фиксация ставки НДС и курса на момент операции
  vat_rate NUMERIC(5,2) NOT NULL DEFAULT 0,
  fx_rate_numerator BIGINT,        -- числитель курса (например, 123456 для 1.23456)
  fx_rate_denominator BIGINT,      -- знаменатель курса (например, 100000)
  fx_pair CHAR(7),                 -- "USD/RUB"
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX ON payment (created_at);

Валюты и курсы: экспоненты, фиксация курсов, конвертация без ошибок

Ключевые правила:

  • У каждой валюты — своя экспонента. JPY не имеет копеек, а в некоторых валютах есть 3 знака после запятой.
  • Курс фиксируется на момент операции и хранится вместе с ней. Никаких «пересчитаем завтра по новому курсу».
  • Курс хранить как рациональное число (числитель/знаменатель) или NUMERIC с понятной шкалой, а не float.

Таблица курсов и конвертация

CREATE TABLE fx_rate (
  base CHAR(3) NOT NULL REFERENCES currency(code),
  quote CHAR(3) NOT NULL REFERENCES currency(code),
  valid_from TIMESTAMPTZ NOT NULL,
  numerator BIGINT NOT NULL,
  denominator BIGINT NOT NULL CHECK (denominator > 0),
  PRIMARY KEY (base, quote, valid_from)
);

-- Функция конвертации минимальных единиц через рациональный курс
CREATE OR REPLACE FUNCTION convert_minor(
  amount_minor BIGINT,
  base CHAR(3),
  quote CHAR(3),
  ts TIMESTAMPTZ
) RETURNS BIGINT LANGUAGE plpgsql AS $$
DECLARE
  r fx_rate;
  q_exp SMALLINT;
BEGIN
  IF base = quote THEN RETURN amount_minor; END IF;
  SELECT * INTO r FROM fx_rate
    WHERE base = base AND quote = quote AND valid_from <= ts
    ORDER BY valid_from DESC LIMIT 1;
  IF NOT FOUND THEN RAISE EXCEPTION 'FX rate not found for %/% at %', base, quote, ts; END IF;

  SELECT exponent INTO q_exp FROM currency WHERE code = quote;

  -- Рассчитываем в минимальных единицах quote с коммерческим округлением
  RETURN ROUND((amount_minor::NUMERIC * r.numerator / r.denominator))::BIGINT;
END;
$$;

Важно: храните «использованный в операции курс» прямо в записи оплаты, даже если он есть в справочнике. Это ускоряет разборы и позволяет корректно делать возвраты.

Округление: где, когда и по каким правилам

Ошибки тут чаще всего. Базовые приёмы:

  • Округлять — только на «границах» бизнес-операций: цена позиции, скидка, налог по позиции, итог счета. Не округляйте на каждом шаге.
  • Явно задавайте режим: обычное 0.5 вверх (ROUND_HALF_UP) или банковское (ROUND_HALF_EVEN). Для большинства бизнес-кейсов — HALF_UP.
  • Учитывайте шаг округления: бывают тарифы с шагом 0.05 (например, кассовые правила). Тогда нужно округлять к кратному 0.05, а не к ближайшей копейке.

Python: точная арифметика с Decimal

from decimal import Decimal, getcontext, ROUND_HALF_UP

# Глобальный контекст: достаточно 28 знаков, округление по правилам торговли
getcontext().prec = 28

def quantize_money(value: Decimal, exponent: int) -> Decimal:
    # exponent=2 => шаг 0.01; exponent=0 => шаг 1; exponent=3 => 0.001
    step = Decimal(10) ** (-exponent)
    return value.quantize(step, rounding=ROUND_HALF_UP)

def line_total(unit_price: Decimal, qty: int, currency_exp: int) -> Decimal:
    raw = unit_price * Decimal(qty)
    return quantize_money(raw, currency_exp)

# Пример: 19.995 при экспоненте 2 -> 20.00
print(line_total(Decimal('19.995'), 1, 2))  # 20.00

Округление к шагу 0.05

def round_to_step(value: Decimal, step: Decimal) -> Decimal:
    # шагом может быть 0.05; умножаем, округляем до целого, делим
    return (value / step).to_integral_value(rounding=ROUND_HALF_UP) * step

print(round_to_step(Decimal('10.023'), Decimal('0.05')))  # 10.00
print(round_to_step(Decimal('10.026'), Decimal('0.05')))  # 10.05

Налоги: считать по строкам или по итогу, и что сохранять

Разница способа расчёта может дать расхождение в копейку.

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

Что сохранять:

  • Ставку налога, базу налогообложения (с учётом скидок), рассчитанную сумму налога и правило округления.
  • Итоговые суммы и суммы по строкам — обе величины. Тогда можно доказать «как получилось».

Пример расчёта НДС по строкам (Python)

from decimal import Decimal

def vat_line_amount(net: Decimal, vat_rate: Decimal, exp: int) -> Decimal:
    vat = net * vat_rate / Decimal('100')
    return quantize_money(vat, exp)

items = [
    {'net': Decimal('100.00'), 'qty': 1},
    {'net': Decimal('33.335'), 'qty': 1},
]
rate = Decimal('20')

total_vat = sum(vat_line_amount(i['net'] * i['qty'], rate, 2) for i in items)
print(total_vat)  # 26.67, а «по итогу» может быть 26.67 или 26.66

Выберите правило, согласуйте с бухгалтерией и зафиксируйте его в коде и документации. Не переключайте его «по настроению».

Возвраты и сторно: отрицательные суммы без хаоса

  • Возврат — это новая операция с отрицательной суммой в той же валюте и с теми же параметрами (ставка налога, курс), что и исходная.
  • Часть возврата — пропорционально «разрезать» исходную операцию и фиксировать использованный курс/налог для этой части.
  • Никаких «перезаписей» исходных сумм. Только проводки.

Мини-бухучёт для разработчика: двойная запись в базе

Даже простая реализация «двойной записи» резко снижает класс ошибок «сальдо не сошлось».

Идея: любая операция — набор проводок (postings), сумма которых равна нулю, но по разным счетам. Баланс счёта — сумма проводок по нему.

Схема проводок (PostgreSQL)

CREATE TABLE ledger_account (
  id BIGSERIAL PRIMARY KEY,
  code TEXT UNIQUE NOT NULL,
  currency CHAR(3) NOT NULL REFERENCES currency(code)
);

CREATE TABLE ledger_entry (
  id BIGSERIAL PRIMARY KEY,
  business_ref TEXT NOT NULL,   -- ссылка на бизнес-сущность (платёж, заказ)
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE ledger_posting (
  id BIGSERIAL PRIMARY KEY,
  entry_id BIGINT NOT NULL REFERENCES ledger_entry(id) ON DELETE CASCADE,
  account_id BIGINT NOT NULL REFERENCES ledger_account(id),
  amount_minor BIGINT NOT NULL,     -- положительное или отрицательное
  CHECK (amount_minor <> 0)
);

-- Гарантия «сумма проводок по записи равна нулю»
CREATE OR REPLACE FUNCTION check_balanced() RETURNS TRIGGER LANGUAGE plpgsql AS $$
DECLARE
  s BIGINT;
BEGIN
  SELECT COALESCE(SUM(amount_minor),0) INTO s FROM ledger_posting WHERE entry_id = NEW.entry_id;
  IF TG_OP <> 'DELETE' THEN s := s + NEW.amount_minor; END IF;
  IF s <> 0 THEN
    RAISE EXCEPTION 'Unbalanced entry %, delta=%', NEW.entry_id, s;
  END IF;
  RETURN NEW;
END;
$$;

CREATE TRIGGER trg_balanced_ins
BEFORE INSERT ON ledger_posting
FOR EACH ROW EXECUTE FUNCTION check_balanced();

Использование: платеж «депозит клиента» — дебет «Касса», кредит «Обязательства перед клиентом». Возврат — обратный набор проводок. Баланс «Обязательства перед клиентом» — это сальдо кошелька клиента.

Границы API и фронтенда: формат, сериализация, валидация

  • В API не передавайте деньги как float. Варианты: целые минимальные единицы (число) + код валюты, либо строка с фиксированным форматом ("123.45").
  • На фронте форматируйте через локаль, но арифметику делайте в целых или в десятичных.
  • Всегда валидируйте «валюта + экспонента + шаг округления».

Типы и функции на TypeScript (BigInt)

// Представление денег: целые минимальные единицы
export type CurrencyCode = 'RUB' | 'USD' | 'JPY' | string;

export interface MoneyMinor {
  amountMinor: bigint; // например, 1050 = 10.50 RUB
  currency: CurrencyCode;
  exponent: number; // 2 для RUB/USD, 0 для JPY
}

export function add(a: MoneyMinor, b: MoneyMinor): MoneyMinor {
  if (a.currency !== b.currency || a.exponent !== b.exponent) {
    throw new Error('currency or exponent mismatch');
  }
  return { amountMinor: a.amountMinor + b.amountMinor, currency: a.currency, exponent: a.exponent };
}

export function allocate(amount: MoneyMinor, parts: number): MoneyMinor[] {
  if (parts <= 0) throw new Error('parts must be > 0');
  const base = amount.amountMinor / BigInt(parts);
  let remainder = amount.amountMinor % BigInt(parts);
  const res: MoneyMinor[] = [];
  for (let i = 0; i < parts; i++) {
    let extra = remainder > 0n ? 1n : 0n;
    if (remainder > 0n) remainder -= 1n;
    res.push({ amountMinor: base + extra, currency: amount.currency, exponent: amount.exponent });
  }
  return res;
}

export function formatMoney(m: MoneyMinor, locale = 'ru-RU'): string {
  const factor = 10 ** m.exponent;
  const integer = Number(m.amountMinor / BigInt(factor));
  const frac = Number(m.amountMinor % BigInt(factor));
  const value = integer + frac / factor;
  return new Intl.NumberFormat(locale, { style: 'currency', currency: m.currency }).format(value);
}

// Пример
const price: MoneyMinor = { amountMinor: 1050n, currency: 'RUB', exponent: 2 };
console.log(formatMoney(price)); // 10,50 ₽

Чек-лист внедрения

  • Выбран формат хранения сумм: minor units (bigint) или NUMERIC. Документирован и единообразен по сервисам.
  • Таблица валют с экспонентой. Валидация соответствия суммы и валюты.
  • Курс фиксируется и сохраняется в операции. Конвертация — рациональными числами или Decimal.
  • Округление: единое правило (ROUND_HALF_UP, шаги 0.01/0.05 и т.п.) и места применения. Тесты примеров из бухгалтерии.
  • Налоги: определено правило («по строкам» или «по итогу»). В операции сохраняется ставка, база, рассчитанный налог.
  • Возвраты: только отдельные операции с отрицательными суммами, без перезаписи исходных.
  • Проводки: двойная запись, инвариант «сумма по записи = 0» проверяется триггером/кодом.
  • API: сериализация без float. Фронт форматирует, но не «считает» в number.
  • Набор юнит-тестов на граничные случаи округлений и конвертаций.

Итог

Деньги — не место для импровизаций. Чёткий выбор представления, фиксированные курсы и ставки, прозрачное округление и элементарная двойная запись снимают 90% болезненных инцидентов: споров о копейках, «плавающих» сумм при возвратах и непонятных расхождений в отчётах. Это не усложнение ради усложнения — это страховка, которая быстро окупается снижением ручных разборов и ростом доверия клиентов и бухгалтерии.


деньгивалютаокругление