Моделирование денег как журнала (и почему мне пришлось переписывать биллинг дважды)
Опубликовано 18 сентября 2025 г. · 5 мин чтения
Как выглядела первая версия
Честно — было стыдно. Булево поле subscription_active в записи тенанта. Крон-задание, которое переключало его в false в конце месяца, если платёж не поступил.
Первые две недели всё работало. Потом начались обращения в поддержку: "Я пополнил, но аккаунт всё ещё заблокирован." "Почему списали дважды?"
Корневая проблема была проста: я относился к деньгам как к состоянию (активный/неактивный), тогда как деньги — это история событий. Из булева нельзя воссоздать, что произошло.
Деньги — это журнал
Решение: перестать хранить балансы, начать хранить движения.
Каждый раз, когда деньги касаются системы, это строка в wallet_transactions. Строка имеет тип, знаковую сумму (положительная = кредит, отрицательная = дебет), ID ссылки и временную метку.
Типы, которые я использую:
top_up— платёж Payme, зачисленный на кошелёкsubscription_fee— ежемесячное автоматическое списаниеrefund— ручной кредит от поддержкиadjustment— редкая ручная корректировка
Баланс в любой момент — просто SUM-запрос:
SELECT SUM(amount) AS balance FROM wallet_transactions WHERE tenant_id = $1;
Один запрос, всегда точный, не может рассинхронизироваться — синхронизировать нечего.
Интеграция Payme
Payme работает на колбэках: вы открываете JSON-RPC-эндпоинт, Payme вызывает его при изменении состояния платежа.
Единственное, что нужно сделать правильно: идемпотентность. Payme будет повторять колбэки.
Мой обработчик PerformTransaction:
- Проверяю, существует ли строка
top_upсpayme_transaction_id = X - Если да → возвращаю успех, ничего не пишу
- Если нет → вставляю строку в DB-транзакции, возвращаю успех
async performTransaction(params: PaymeParams) {
return this.db.$transaction(async (tx) => {
const existing = await tx.walletTransaction.findFirst({
where: { reference: params.id, type: 'top_up' }
});
if (existing) return { result: { transaction: existing.id } };
const txn = await tx.walletTransaction.create({
data: {
tenant_id: params.account.tenant_id,
type: 'top_up',
amount: params.amount / 100,
reference: params.id,
}
});
return { result: { transaction: txn.id } };
});
}Автоматическое списание подписки
Первого числа каждого месяца NestJS cron пытается списать у каждого активного тенанта:
- Читаю текущий баланс (SUM)
- Если баланс ≥ комиссии: вставляю отрицательную транзакцию
subscription_fee - Если баланс < комиссии: ставлю
payment_overdue = true, отправляю уведомление в очередь
Граничные случаи, усвоенные на своих ошибках
Конкурентная запись. Если пополнение и списание одновременно читают баланс 0, оба могут создать отрицательный баланс. Решение: SELECT FOR UPDATE при атомарной проверке и дебете.
Часовой пояс. "Первое число месяца" означает первое в Ташкенте (UTC+5). Однажды ошибся. Биллинг запустился в 8 вечера 31 января для некоторых пользователей.
Частичный сбой задания. Если cron зарядит 400 из 500 тенантов и упадёт, наивный перезапуск снова зарядит часть. Обрабатывайте идемпотентными пакетами.
Биллинг скучный, пока не перестаёт быть. Моделируйте деньги как события, пишите проверки идемпотентности до счастливого пути.