TGViewer
Сергей Озеранский Сергей Озеранский @sergeiozeranskii · 2.35K subscribers
Post #598 873
Баланс: как хранить деньги клиента и не ошибиться

В прошлом посте разделили биллинг и леджер: баланс разрешает операции, леджер их объясняет. Сегодня про первую половину — таблицу баланса.

Одна строка на клиента

Баланс — это отдельная таблица с одной строкой на аккаунт. В ней текущий остаток и два накопительных счётчика: сколько всего зачислено и сколько всего потрачено. Счётчики не участвуют в решениях, они для дашборда и аналитики. Решает только остаток.

В чём хранить

Не во 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 применяется целиком, даже если клиент уже потратил часть пополнения; баланс уходит в минус, аккаунт приостанавливается. Счётчик потраченного не трогается: возврат — это не расход.

Отдельно стоит знаковая корректировка, и она баланс не трогает. Её пишет только фоновая сверка с леджером при расхождении: одна строка, объясняющая разницу. Ручных правок остатка нет: бонус можно начислить, но он пройдёт тем же путём, что и пополнение.
  • 👍 6
  • 🔥 2
  • ❤ 1
  • 👏 1
More from @sergeiozeranskii
  1. Sep 25, 2026Контейнеры в одном инструменте, виртуалки — в другом, частное облако — вообще отдельная ис…
  2. Sep 24, 2026Еще немного про ускорения. На этот раз про тесты, а точнее про coverage (Python). Библиоте…
  3. Sep 24, 2026Обнаружил, что агенты Claude Code в разных сессиях умеют общаться между собой. У меня сейч…
  4. Sep 23, 2026Возьмем Python-сервис в Kubernetes. Как правило на старте контейнера может проявляться спа…
  5. Sep 23, 2026Сегодня случилось страшное. То, чего я никак не ожидал. За все годы на MacBook со мной так…
  6. Sep 17, 2026Я вот не понимаю, зачем люди идут в OSS и контрибьютят на отвали. Ценности в этом ноль для…
Threads Profile ViewerView any public Threads profile without an account.Open ThreadLook →Writing with AI? Make it sound human.Metric37 rewrites AI drafts so they read naturally. Free AI detector, 1,500 words free.Try Metric37 →