Все посты
PaymentsBillingPaymeNestJS

Моделирование денег как журнала (и почему мне пришлось переписывать биллинг дважды)

Опубликовано 18 сентября 2025 г. · 5 мин чтения

Как выглядела первая версия

Честно — было стыдно. Булево поле subscription_active в записи тенанта. Крон-задание, которое переключало его в false в конце месяца, если платёж не поступил.

Первые две недели всё работало. Потом начались обращения в поддержку: "Я пополнил, но аккаунт всё ещё заблокирован." "Почему списали дважды?"

Корневая проблема была проста: я относился к деньгам как к состоянию (активный/неактивный), тогда как деньги — это история событий. Из булева нельзя воссоздать, что произошло.

Деньги — это журнал

Решение: перестать хранить балансы, начать хранить движения.

Каждый раз, когда деньги касаются системы, это строка в wallet_transactions. Строка имеет тип, знаковую сумму (положительная = кредит, отрицательная = дебет), ID ссылки и временную метку.

Типы, которые я использую:

  • top_up — платёж Payme, зачисленный на кошелёк
  • subscription_fee — ежемесячное автоматическое списание
  • refund — ручной кредит от поддержки
  • adjustment — редкая ручная корректировка

Баланс в любой момент — просто SUM-запрос:

Один запрос, всегда точный, не может рассинхронизироваться — синхронизировать нечего.

Интеграция Payme

Payme работает на колбэках: вы открываете JSON-RPC-эндпоинт, Payme вызывает его при изменении состояния платежа.

Единственное, что нужно сделать правильно: идемпотентность. Payme будет повторять колбэки.

Мой обработчик PerformTransaction:

  1. Проверяю, существует ли строка top_up с payme_transaction_id = X
  2. Если да → возвращаю успех, ничего не пишу
  3. Если нет → вставляю строку в DB-транзакции, возвращаю успех

Автоматическое списание подписки

Первого числа каждого месяца NestJS cron пытается списать у каждого активного тенанта:

  1. Читаю текущий баланс (SUM)
  2. Если баланс ≥ комиссии: вставляю отрицательную транзакцию subscription_fee
  3. Если баланс < комиссии: ставлю payment_overdue = true, отправляю уведомление в очередь

Граничные случаи, усвоенные на своих ошибках

Конкурентная запись. Если пополнение и списание одновременно читают баланс 0, оба могут создать отрицательный баланс. Решение: SELECT FOR UPDATE при атомарной проверке и дебете.

Часовой пояс. "Первое число месяца" означает первое в Ташкенте (UTC+5). Однажды ошибся. Биллинг запустился в 8 вечера 31 января для некоторых пользователей.

Частичный сбой задания. Если cron зарядит 400 из 500 тенантов и упадёт, наивный перезапуск снова зарядит часть. Обрабатывайте идемпотентными пакетами.

Биллинг скучный, пока не перестаёт быть. Моделируйте деньги как события, пишите проверки идемпотентности до счастливого пути.