Kravchenko

Web Lab

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

Kravchenko

Web Lab

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

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

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

•

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

•

ОГРНИП: 324784700339743

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

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

Разработка и технологии25 марта 2026 г.
Большинство ошибок с деньгами не из-за платёжек, а из-за кода: неверный тип данных, неправильное округление, хаос с валютами. Разбираем, как хранить суммы, считать комиссии и налоги, делать переводы без отрицательных остатков и вести реестр проводок так, чтобы бухгалтерии было что сверять. В статье — готовые схемы БД, примеры кода и чек‑лист внедрения.
Деньги в коде: точные расчёты без спорных списаний — мультивалюта, округление и проводки

  • Зачем это бизнесу
  • Базовые принципы работы с деньгами в коде
    • Денежный тип как объект предметной области
    • Запрет на числа с плавающей точкой
    • Единицы измерения и валюты
  • Хранение в базе данных
  • Округление: где, как и почему
  • Мультивалюта и курсы
  • Проводки и двойная запись
  • Конкурентность и защита от отрицательных остатков
  • Интеграции с платёжными системами: формат сумм и сверка
  • Тестирование денежных расчётов
  • Чек-лист внедрения
  • Вывод

Зачем это бизнесу

Деньги — не место для «примерно». Пара лишних копеек в расчёте комиссии и возвраты потекут рекой; копеечная недостача в сторнировании — и платёжный провайдер заблокирует мерчант‑аккаунт. Неверное округление в одной стране и другое — в соседней, и отчёты перестают сходиться. Все эти вещи устранимы инженерно: правильные типы данных, чёткие правила округления, мультивалютные курсы с источником и временем, реестр проводок с двойной записью и транзакции с блокировками.

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

Базовые принципы работы с деньгами в коде

Денежный тип как объект предметной области

Вводим в код явный тип «Деньги» с валютой, масштабом и операциями. Складывать можно только суммы в одной валюте; умножение — только с явным правилом округления; распределение по долям — с контролем остатка.

Запрет на числа с плавающей точкой

Числа с плавающей точкой дают двоичные погрешности. 0.1 + 0.2 в двоичном представлении не равно точно 0.3. Для денег используйте:

  • фиксированный десятичный тип (Decimal/BigDecimal) в приложении;
  • целые «минимальные единицы» (minor units: копейки/центы) или DECIMAL/NUMERIC в базе.

Единицы измерения и валюты

Каждая валюта имеет «разрядность» — число знаков после запятой. Примеры: RUB 2, USD 2, JPY 0, KWD 3. Храните этот параметр в справочнике валют и используйте для валидации и округления. Никогда не «угадывайте» разрядность на лету.

Ниже — пример минимального, но практичного денежного типа на Python.

from decimal import Decimal, getcontext, ROUND_HALF_UP
from dataclasses import dataclass
from typing import List, Tuple

# Достаточная точность для денег и курсов
getcontext().prec = 28

CURRENCY_EXPONENT = {
    'RUB': 2,
    'USD': 2,
    'EUR': 2,
    'JPY': 0,
    'KWD': 3,
}


def quant_step(curr: str) -> Decimal:
    exp = CURRENCY_EXPONENT[curr]
    return Decimal('1').scaleb(-exp)  # 10^(-exp)


@dataclass(frozen=True)
class Money:
    amount: Decimal
    currency: str

    def __post_init__(self):
        if self.currency not in CURRENCY_EXPONENT:
            raise ValueError(f'Неизвестная валюта: {self.currency}')
        step = quant_step(self.currency)
        # Квантование до нужного количества знаков с правилом round half up (банковское правило может отличаться, см. ниже)
        object.__setattr__(self, 'amount', self.amount.quantize(step, rounding=ROUND_HALF_UP))

    @property
    def minor_units(self) -> int:
        # Преобразование к целым минимальным единицам (копейки/центы)
        exp = CURRENCY_EXPONENT[self.currency]
        return int((self.amount * (Decimal(10) ** exp)).to_integral_value(rounding=ROUND_HALF_UP))

    @staticmethod
    def from_minor(minor: int, currency: str) -> 'Money':
        exp = CURRENCY_EXPONENT[currency]
        factor = Decimal(10) ** (-exp)
        return Money(Decimal(minor) * factor, currency)

    def _ensure_same_currency(self, other: 'Money'):
        if self.currency != other.currency:
            raise ValueError('Операции допустимы только в одной валюте')

    def add(self, other: 'Money') -> 'Money':
        self._ensure_same_currency(other)
        return Money(self.amount + other.amount, self.currency)

    def sub(self, other: 'Money') -> 'Money':
        self._ensure_same_currency(other)
        return Money(self.amount - other.amount, self.currency)

    def mul(self, factor: Decimal) -> 'Money':
        # Явное округление после умножения
        return Money(self.amount * factor, self.currency)

    def allocate(self, ratios: List[int]) -> List['Money']:
        # Справедливое распределение по целым долям с учётом остатка
        total = sum(ratios)
        if total <= 0:
            raise ValueError('Сумма долей должна быть положительной')
        minor = self.minor_units
        base = [minor * r // total for r in ratios]
        distributed = sum(base)
        remainder = minor - distributed
        # Раздаём остаток по одному центу начиная с самых «тяжёлых» долей
        for i in range(remainder):
            base[i % len(base)] += 1
        return [Money.from_minor(x, self.currency) for x in base]

    def convert(self, to_currency: str, rate: Decimal) -> 'Money':
        if to_currency not in CURRENCY_EXPONENT:
            raise ValueError('Неизвестная целевая валюта')
        # Конвертация: amount * rate, затем квантование к разрядности целевой валюты
        raw = (self.amount * rate)
        step = quant_step(to_currency)
        return Money(raw.quantize(step, rounding=ROUND_HALF_UP), to_currency)


# Пример использования
if __name__ == '__main__':
    price = Money(Decimal('12.99'), 'USD')
    tax = price.mul(Decimal('0.075'))  # 7.5%
    total = price.add(tax)
    parts = total.allocate([1, 1, 1])
    assert sum(p.minor_units for p in parts) == total.minor_units

Хранение в базе данных

Два надёжных подхода:

  • хранить целые «минимальные единицы» (bigint) и валюту отдельно; или
  • хранить NUMERIC/DECIMAL с ограниченной разрядностью и валюту.

Целые значения проще для арифметики и инвариантов. NUMERIC удобен, если часто нужны дробные операции на стороне БД и отчёты. Важно: запрещайте «голые» суммы без валюты и жёстко валидируйте разрядность.

Ниже — минимальная схема с аккаунтами и реестром проводок на PostgreSQL.

-- Справочник валют и их разрядность
create table if not exists currencies (
  code char(3) primary key,
  exponent smallint not null check (exponent between 0 and 4)
);
insert into currencies(code, exponent) values
  ('RUB',2) on conflict do nothing,
  ('USD',2) on conflict do nothing,
  ('EUR',2) on conflict do nothing,
  ('JPY',0) on conflict do nothing,
  ('KWD',3) on conflict do nothing;

-- Аккаунты с балансом в минимальных единицах
create table if not exists accounts (
  id uuid primary key,
  currency char(3) not null references currencies(code),
  balance_minor bigint not null default 0,
  unique(id, currency)
);

-- Реестр проводок (движений по счетам)
create table if not exists ledger_entries (
  id bigserial primary key,
  account_id uuid not null,
  currency char(3) not null,
  amount_minor bigint not null check (amount_minor <> 0), -- знак важен: минус/плюс
  batch_id uuid not null, -- идентификатор операции (пара проводок)
  description text,
  created_at timestamptz not null default now(),
  foreign key (account_id, currency) references accounts(id, currency)
);

create index if not exists idx_ledger_batch on ledger_entries(batch_id);
create index if not exists idx_ledger_account_created on ledger_entries(account_id, created_at);

Проверка «валюта совпадает» достигается составным внешним ключом. Баланс храним в аккаунте для быстрых запросов, но «истина» — в реестре проводок. Баланс должен сходиться с суммой проводок по счёту — периодически проверяйте и восстанавливайте.

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

Четыре правила:

  1. Округляйте только на границах бизнес‑процессов: цена позиции, налог на строку счёта, итог счета. Избегайте множественного округления одной и той же суммы.
  2. Правило округления зафиксируйте в одном месте и используйте везде (например, половина вверх — round half up). Банковское округление (half to even) иногда требуется регулятором — не смешивайте разные правила в одной системе.
  3. При распределении суммы на несколько получателей сначала работайте в минимальных единицах, затем раздавайте остаток по одному центу; порядок распределения должен быть детерминированным и задокументированным.
  4. Округление зависит от валюты: JPY без копеек, KWD с тремя знаками — валидируйте ввод и результаты.

Мультивалюта и курсы

Если работаете с несколькими валютами:

  • храните курсы в таблице с источником, временем и направлением (base/quote);
  • для отчётности выбирайте «базовую валюту» и конвертируйте к ней с фиксированным правилом округления;
  • избегайте цепочек конверсий (USD→EUR→RUB) — используйте прямой курс или кросс‑курс с явным расчётом и фиксацией операции;
  • разницу от пересчёта (курсовые разницы) храните отдельной проводкой, иначе отчёты не сойдутся.

Пример таблицы курсов:

create table if not exists fx_rates (
  id bigserial primary key,
  base char(3) not null references currencies(code),
  quote char(3) not null references currencies(code),
  rate numeric(20,10) not null check (rate > 0),
  source text not null,
  as_of timestamptz not null,
  unique(base, quote, as_of, source)
);

Проводки и двойная запись

Двойная запись (две проводки на одну операцию: дебет и кредит) делает систему самопроверяемой: сумма по batch_id всегда равна нулю. Исправления — через сторно (обратную проводку), а не «редактирование задним числом».

Реализуем безопасный перевод между счетами одной валюты:

-- Требуются функции генерации UUID
create extension if not exists pgcrypto;

-- Безопасный перевод: блокируем оба счёта, проверяем остаток, пишем двухстороннюю проводку
create or replace function transfer(
  p_from uuid,
  p_to uuid,
  p_currency char(3),
  p_amount_minor bigint,
  p_description text
) returns uuid language plpgsql as $$
declare
  v_batch uuid := gen_random_uuid();
  v_from_balance bigint;
  v_dummy bigint;
begin
  if p_amount_minor <= 0 then
    raise exception 'Сумма должна быть положительной';
  end if;

  -- Блокируем оба счёта в предсказуемом порядке, чтобы избежать взаимоблокировок
  if p_from = p_to then
    raise exception 'Нельзя переводить сам себе';
  end if;

  -- Блокировка исходного счёта
  select balance_minor into v_from_balance
  from accounts
  where id = p_from and currency = p_currency
  for update;

  if not found then
    raise exception 'Счёт отправителя не найден или валюта не совпадает';
  end if;

  if v_from_balance < p_amount_minor then
    raise exception 'Недостаточно средств';
  end if;

  -- Блокировка счёта получателя
  select 1 into v_dummy
  from accounts
  where id = p_to and currency = p_currency
  for update;

  if not found then
    raise exception 'Счёт получателя не найден или валюта не совпадает';
  end if;

  -- Обновляем балансы
  update accounts set balance_minor = balance_minor - p_amount_minor
  where id = p_from and currency = p_currency;

  update accounts set balance_minor = balance_minor + p_amount_minor
  where id = p_to and currency = p_currency;

  -- Пишем проводки
  insert into ledger_entries(account_id, currency, amount_minor, batch_id, description)
  values
    (p_from, p_currency, -p_amount_minor, v_batch, coalesce(p_description, 'transfer')),
    (p_to,   p_currency,  p_amount_minor, v_batch, coalesce(p_description, 'transfer'));

  return v_batch;
end;
$$;

Этот подход гарантирует:

  • баланс отправителя не уйдёт в минус;
  • проводки симметричны и связаны batch_id;
  • блокировки на уровне строк исключают «гонки» между параллельными переводами.

Конкурентность и защита от отрицательных остатков

Ключевые приёмы:

  • блокировка строк счётов SELECT ... FOR UPDATE перед изменением;
  • инвариант «баланс не отрицательный» через проверку в транзакции до обновления;
  • для высоких нагрузок — оптимистичная конкуренция с версионным полем (version) и попыткой повторить операцию при конфликте;
  • идемпотентность с ключом операции в приложении и уникальным ограничением batch_id + тип операции в БД (чтобы не записать дубль проводок при повторе запроса).

Пример оптимистичного обновления баланса:

-- Добавим версионное поле
alter table accounts add column if not exists version bigint not null default 0;

-- Пример обновления с проверкой версии
-- (псевдо‑шаг в рамках транзакции приложения)
-- update accounts set balance_minor = balance_minor + :delta, version = version + 1
-- where id = :id and currency = :cur and version = :prev_version;
-- если affected_rows = 0, значит баланс изменился — повторяем расчёт.

Интеграции с платёжными системами: формат сумм и сверка

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

Тестирование денежных расчётов

  • Наборы «золотых» тестов для округления: конкретные суммы, налоги, комиссии, разные валюты.
  • Инварианты: сумма распределений равна исходной; сумма проводок по batch_id равна нулю; баланс аккаунта = сумма его проводок.
  • Генеративные тесты: случайные суммы и доли для функции allocate; разные разрядности валют.

Минимальные примеры на Python:

import unittest
from decimal import Decimal

class MoneyTests(unittest.TestCase):
    def test_add_same_currency(self):
        from main import Money  # если код выше сохранён как main.py
        a = Money(Decimal('1.10'), 'USD')
        b = Money(Decimal('2.20'), 'USD')
        self.assertEqual(a.add(b).amount, Decimal('3.30'))

    def test_allocate_preserves_sum(self):
        from main import Money
        total = Money(Decimal('10.00'), 'USD')
        parts = total.allocate([3, 2, 1])
        self.assertEqual(sum(p.minor_units for p in parts), total.minor_units)

    def test_convert_rounded(self):
        from main import Money
        usd = Money(Decimal('1.00'), 'USD')
        eur = usd.convert('EUR', Decimal('0.915'))
        self.assertEqual(eur.currency, 'EUR')
        self.assertIsInstance(eur.amount, Decimal)

if __name__ == '__main__':
    unittest.main()

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

  • Введите явный тип «Деньги» в коде. Запретите float для денежных значений линтерами и ревью.
  • Создайте справочник валют с разрядностью. Валидируйте её при вводе/выводе.
  • Определите единое правило округления и зафиксируйте его в одном модуле/библиотеке.
  • Решите, как храните суммы в БД: minor units (bigint) или NUMERIC. Не смешивайте подходы в одной таблице.
  • Включите реестр проводок с двойной записью. Любая операция — пара проводок с общим batch_id.
  • Обновляйте баланс счетов только внутри транзакций с блокировкой строк. Не допускайте отрицательных остатков.
  • Добавьте таблицу курсов с источником и временем. Фиксируйте разницу от пересчёта отдельной проводкой.
  • Реализуйте ежедневную сверку с провайдером платежей. Несовпадения — через корректирующие проводки.
  • Напишите «золотые» тесты по критичным кейсам, добавьте генеративные на распределения и округления.
  • Документируйте допущения: базовая валюта отчётности, момент округления налогов и комиссий, правило распределения остатка.

Вывод

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


качество данныхбаза данныхфинтех