В прошлом посте разделили биллинг и леджер: баланс разрешает операции, леджер их объясняет. Сегодня про первую половину — таблицу баланса.
Одна строка на клиента
Баланс — это отдельная таблица с одной строкой на аккаунт. В ней текущий остаток и два накопительных счётчика: сколько всего зачислено и сколько всего потрачено. Счётчики не участвуют в решениях, они для дашборда и аналитики. Решает только остаток.
В чём хранить
Не во float. Это база: 0.1 + 0.2 в двоичной арифметике не равно 0.3. Баланс меняется каждые несколько секунд работы каждой джобы, это тысячи мелких списаний, и ошибка округления в нём накапливается.
Не в центах. Ставка раннера — доли цента за минуту, а списываем мы за секунды. В центах такая операция либо округляется в ноль, либо требует накопителя дробных остатков рядом с балансом.
Мы храним целое число микро-долларов: 1 USD = 1 000 000 единиц, BIGINT в Postgres. Целочисленная арифметика точна, а шести знаков после запятой хватает, чтобы за всю джобу накопить погрешность меньше одной единицы. Decimal живёт только на границах: ввод суммы пополнения и вывод пользователю.
Побочный эффект: ошибка в значении это значит ошибиться не на проценты, а в миллион раз. Поэтому число 1 000 000 в коде встречается ровно один раз, в модуле с прайсингом, а конвертация в доллары и обратно идёт только через две функции рядом с ним. Всё остальное работает в единицах и о долларах не знает.
Одна точка изменения
Баланс меняется только в одном классе — репозитории биллинга. Ни один сервис, роутер или воркер не пишет UPDATE в эту таблицу напрямую. Это гарантирует, что каждое изменение остатка сопровождается записью в леджер, проверкой лимитов и корректным ключом идемпотентности.
Списание одним запросом
Классическая ошибка: прочитать баланс, проверить в коде, что хватает, и записать новое значение. Между чтением и записью успевает другая операция, и клиент уходит в минус, которого никто не разрешал.
У нас проверка и изменение — одно выражение:
UPDATE tenant_billing
SET credits_balance = credits_balance - :amount
WHERE tenant_id = :id
AND credits_balance - :amount >= -:buffer
RETURNING credits_balance
Postgres берёт блокировку на строку, проверяет условие уже под ней и возвращает новый остаток. Два параллельных списания выстраиваются в очередь, второе видит результат первого.
Если запрос затронул ноль строк, это одно из двух: аккаунта нет или денег не хватает. Различаем отдельным запросом.
Зачем буфер
Обратите внимание на
-:buffer в условии. Баланс может уйти в минус, но не ниже $1.Причина в природе продукта. Проверки денег на старте джобы нет: пока баланс в порядке, раннеры клиента доступны, и джоба стартует. Дальше она работает минуты, а списание идёт по ходу. Если ровно на нуле отклонить очередное списание, джоба упадёт на середине, клиент потеряет и время, и уже потраченные деньги. Буфер даёт ей доработать.
При уходе ниже нуля аккаунт получает отметку блокировки, и его раннеры ставятся на паузу: новые джобы не получают исполнителя, работающие платформа не прерывает. Отметка ставится один раз и не перетирается повторными списаниями, так что видно, когда именно клиент ушёл в минус. Пополнение, выводящее баланс в плюс, отметку снимает.
Кто решает блокировать, а кто выполняет — разные слои. Репозиторий умеет только списать и вернуть остаток. Решение "остаток отрицательный, значит блокируем" принимает сервис. Так политику можно менять, не трогая механику.
Три способа уменьшить баланс
Обычное списание с буфером — для джоб. Увеличивает счётчик потраченного.
Принудительное списание без буфера — для возвратов. Refund применяется целиком, даже если клиент уже потратил часть пополнения; баланс уходит в минус, аккаунт приостанавливается. Счётчик потраченного не трогается: возврат — это не расход.
Отдельно стоит знаковая корректировка, и она баланс не трогает. Её пишет только фоновая сверка с леджером при расхождении: одна строка, объясняющая разницу. Ручных правок остатка нет: бонус можно начислить, но он пройдёт тем же путём, что и пополнение.