
Типовые инциденты:
Эти баги редко заметны на тестах, но дорого стоят в проде: возвраты, штрафы, недоверие клиентов и ручные разборки в конце месяца.
Есть два надёжных подхода.
Практика: для платёжных систем и кошельков — чаще minor units; для учётных регистров и отчётности — NUMERIC. Можно комбинировать: кошелёк — bigint, акты/счета — NUMERIC.
-- Валюты с экспонентой (сколько знаков после запятой)
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);
Ключевые правила:
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;
$$;
Важно: храните «использованный в операции курс» прямо в записи оплаты, даже если он есть в справочнике. Это ускоряет разборы и позволяет корректно делать возвраты.
Ошибки тут чаще всего. Базовые приёмы:
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
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
Разница способа расчёта может дать расхождение в копейку.
Что сохранять:
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), сумма которых равна нулю, но по разным счетам. Баланс счёта — сумма проводок по нему.
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();
Использование: платеж «депозит клиента» — дебет «Касса», кредит «Обязательства перед клиентом». Возврат — обратный набор проводок. Баланс «Обязательства перед клиентом» — это сальдо кошелька клиента.
// Представление денег: целые минимальные единицы
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 ₽
Деньги — не место для импровизаций. Чёткий выбор представления, фиксированные курсы и ставки, прозрачное округление и элементарная двойная запись снимают 90% болезненных инцидентов: споров о копейках, «плавающих» сумм при возвратах и непонятных расхождений в отчётах. Это не усложнение ради усложнения — это страховка, которая быстро окупается снижением ручных разборов и ростом доверия клиентов и бухгалтерии.