Перейти к основному содержимому

Правила Финансового Кода

Назначение

Документ фиксирует обязательные правила для кода, работающего с деньгами, балансами, транзакциями, начислениями и расчётами. Правила направлены на предотвращение потерь из-за ошибок округления, потери 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 для cryptoUX требования
// Явное округление — не 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 entryAppend новой записи
Финансовая мутация без idempotency keyIdempotency key + DB constraint
Баланс в double в DTOdecimal в DTO и API
Distributed transaction для cross-service балансаSaga + outbox + reconciliation
Расчёт суммы в UI или frontendВсе расчёты в backend domain

Обратные ссылки

Автоссылки: где используется этот документ.