Правила Финансового Кода
Назначение
Документ фиксирует обязательные правила для кода, работающего с деньгами, балансами, транзакциями, начислениями и расчётами. Правила направлены на предотвращение потерь из-за ошибок округления, потери precision, неатомарных операций, race conditions и некорректного аудита.
Ошибка в финансовом коде — это реальная потеря денег, нарушение лицензии или предписание регулятора.
Decimal Арифметика
- Деньги хранятся и вычисляются только в
decimal. Использованиеdoubleилиfloatдля финансовых расчётов — жёсткий запрет. decimalне теряет точность при сложении, вычитании и умножении в пределах финансовых сумм.- Деление
decimalтребует явного указания правила округления.
// ХОРОШО
decimal incentive = commissionAmount * 0.1m; // m-суффикс — это decimal literal
decimal tax = Math.Round(amount * 0.2m, 2, MidpointRounding.AwayFromZero);
// ПЛОХО
double incentive = (double)commissionAmount * 0.1; // потеря precision
float fee = (float)amount * 0.015f; // недопустимо для денег
MoneyAmount — Деньги Неотделимы От Валюты
// Деньги без валюты — бессмысленны и опасны
// ПЛОХО
decimal amount = 100.00m; // какая валюта?
// ХОРОШО — MoneyAmount всегда несёт валюту
var amount = new MoneyAmount(100.00m, currencyId);
Сложение, вычитание и сравнение MoneyAmount проверяют совпадение валюты. Операция над суммами разных валют → DomainException.
public MoneyAmount Add(MoneyAmount other)
{
Guard.Equal(Currency, other.Currency, "Cannot add amounts in different currencies.");
return new MoneyAmount(Value + other.Value, Currency);
}
Округление
Правила округления явно задаются для каждого типа расчёта и документируются:
| Операция | Правило | Причина |
|---|---|---|
| Комиссия игрока | AwayFromZero | В пользу платформы |
| Партнёрское вознаграждение | AwayFromZero | Минимальная выплата |
| Бонусный вейджер (1x) | AwayFromZero | Контрольный расчёт |
| Кэшбэк | ToEven (banker's rounding) | Нейтральное |
| Отображение игроку | 2 знака для фиата, до 8 для crypto | UX требования |
// Явное округление — не implicit truncation
var commission = Math.Round(
amount.Value * rate,
currencyPrecision,
MidpointRounding.AwayFromZero);
Никогда не использовать Math.Round(value, 2) без указания MidpointRounding — поведение по умолчанию (ToEven) неочевидно.
Precision Валюты
Precision берётся из справочника валют handbook (ADR-0063), а не хардкодится:
// Precision из справочника валют handbook
public int GetPrecision(short currencyId)
{
var currency = _handbook.GetCurrency(currencyId);
return currency.DecimalPrecision; // EUR=2, BTC=8, JPY=0
}
В PostgreSQL финансовые поля хранятся как numeric(18, 8) — запас для crypto и будущих валют. Precision при отображении задаётся отдельно от хранения.
Атомарность Финансовых Операций
Любое изменение баланса или финансового статуса должно быть атомарным:
// Внутри одной транзакции:
// 1. Загрузить aggregate с lock или optimistic version
// 2. Применить domain method (изменить state)
// 3. Сохранить aggregate changes
// 4. Сохранить ledger entry (append-only)
// 5. Добавить event в MassTransit outbox
// 6. Commit — все или ничего
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
try
{
var payoutOrder = await _repository.Find(id, ct);
var approveResult = payoutOrder.Approve(actorId, command.ExpectedVersion);
if (approveResult.IsFailed) return approveResult;
_repository.Update(payoutOrder);
_ledger.Append(LedgerEntry.ForPayoutApproval(payoutOrder));
await _eventPublisher.Publish<PayoutOrderApprovedEvent>(..., ct);
await _dbContext.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
return Result.Ok(...);
}
catch
{
await transaction.RollbackAsync(ct);
throw;
}
Idempotency Для Финансовых Операций
Каждая финансовая мутация имеет idempotency key:
- Внешние операции (booking payment, commission payout, refund) — провайдерский transaction ID как idempotency key.
- Внутренние операции (commission accrual, adjustment, reversal) — deterministic key из (userId, operationType, sourceEventId) или UI-generated UUID.
- Upsert idempotency: повторный запрос с тем же ключом возвращает сохранённый результат без повторного выполнения.
- Уникальный DB constraint на idempotency key — защита от race condition при concurrent retry.
Optimistic Concurrency Для Балансов И Агрегатов
Все mutable финансовые агрегаты используют ExpectedVersion:
// Command содержит ожидаемую версию
public record ApproveWithdrawalCommand
{
public required Guid WithdrawalId { get; init; }
public required int ExpectedVersion { get; init; }
}
// Handler проверяет перед изменением
if (payoutOrder.Version != command.ExpectedVersion)
return Result.Fail(new ConcurrencyConflictError(
payoutOrder.Id, payoutOrder.Version, command.ExpectedVersion));
Append-Only Ledger
Accounting ledger — append-only. Записи никогда не удаляются и не обновляются.
// ХОРОШО — append новой записи
public static LedgerEntry ForDeposit(UserId userId, MoneyAmount amount, Guid depositId) =>
new() { Type = LedgerEntryType.Deposit, Amount = amount, UserId = userId, ... };
// ПЛОХО — update существующей записи
existingLedgerEntry.Amount = newAmount; // никогда
Текущий баланс — это SUM(amount) по всем записям ledger для user, либо materialized в отдельной таблице user_balance с собственным version для optimistic concurrency.
Reconciliation
Любая операция с внешним провайдером (payment, payout, provider) имеет reconciliation:
- Scheduled reconciliation job сравнивает internal state с provider report.
- Несовпадение создаёт reconciliation alert и escalation event.
- Документация операции описывает: источник правды, ключ сопоставления, периодичность job, допустимые статусы для автокоррекции, audit trail исправления.
Финансовые Тесты
Для каждой финансовой операции обязательны тесты:
- happy path с конкретными decimal значениями и explicit currency;
- rounding проверяется на граничных значениях (0.005, 0.015, midpoint);
- idempotent replay — повторный запрос с тем же ключом не создаёт двойную запись;
- concurrent conflict — два concurrent approve одного payout order;
- partial failure — provider call успешен, но DB commit failed (reconciliation scenario).
Тесты денежных расчётов не используют AutoFixture для amount — значения задаются явно.
Запрещённые Паттерны
| Запрещено | Правильно |
|---|---|
double/float для денег | decimal |
decimal без валюты | MoneyAmount(Value, CurrencyId) |
Math.Round(x, 2) без MidpointRounding | Явный MidpointRounding.* |
| Update ledger entry | Append новой записи |
| Финансовая мутация без idempotency key | Idempotency key + DB constraint |
Баланс в double в DTO | decimal в DTO и API |
| Distributed transaction для cross-service баланса | Saga + outbox + reconciliation |
| Расчёт суммы в UI или frontend | Все расчёты в backend domain |
Обратные ссылки
Автоссылки: где используется этот документ.
- adr (3): ADR-0005: Financial Ledger И Double-Entry Accounting, ADR-0048: Financial Arithmetic И Money Safety, ADR-0065 — контекст wallet (внутренние балансы и ledger)
- backend-shared-standards-index.md (1): Backend Shared Standards Index