
Деньги — не место для «примерно». Пара лишних копеек в расчёте комиссии и возвраты потекут рекой; копеечная недостача в сторнировании — и платёжный провайдер заблокирует мерчант‑аккаунт. Неверное округление в одной стране и другое — в соседней, и отчёты перестают сходиться. Все эти вещи устранимы инженерно: правильные типы данных, чёткие правила округления, мультивалютные курсы с источником и временем, реестр проводок с двойной записью и транзакции с блокировками.
В результате бизнес получает точные отчёты «копейка в копейку», меньше спорных списаний, предсказуемую маржу и понятный аудит любой операции без походов в лог‑файлы.
Вводим в код явный тип «Деньги» с валютой, масштабом и операциями. Складывать можно только суммы в одной валюте; умножение — только с явным правилом округления; распределение по долям — с контролем остатка.
Числа с плавающей точкой дают двоичные погрешности. 0.1 + 0.2 в двоичном представлении не равно точно 0.3. Для денег используйте:
Каждая валюта имеет «разрядность» — число знаков после запятой. Примеры: 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
Два надёжных подхода:
Целые значения проще для арифметики и инвариантов. 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);
Проверка «валюта совпадает» достигается составным внешним ключом. Баланс храним в аккаунте для быстрых запросов, но «истина» — в реестре проводок. Баланс должен сходиться с суммой проводок по счёту — периодически проверяйте и восстанавливайте.
Четыре правила:
Если работаете с несколькими валютами:
Пример таблицы курсов:
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;
$$;
Этот подход гарантирует:
Ключевые приёмы:
Пример оптимистичного обновления баланса:
-- Добавим версионное поле
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, значит баланс изменился — повторяем расчёт.
Минимальные примеры на 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()
Деньги требуют дисциплины, но технологии здесь простые: целые минимальные единицы или десятичные типы, фиксированные правила округления, аккуратная мультивалюта и реестр проводок с двойной записью. Это убирает спорные списания, ускоряет сверки и делает отчёты предсказуемыми. Начните с явного денежного типа, исправьте хранение и округление — и большинство «магических копеек» исчезнет само собой.