Почему деньги считаются в базе, а не в приложении

@zalosko начал обсуждение · · 3 мин чтения

Есть популярная схема: база хранит данные, а вся логика живёт в приложении. Для блога это нормально. Для продукта, где три приложения работают с одними и теми же деньгами, — нет. Расскажу, почему в платформе, которую я строю (B2C-сервис бытовых услуг, название под NDA), цена, скидки, статусы заказа и деньги считаются в PostgreSQL.

Три приложения — одна правда

В монорепо три приложения на Next.js: публичный сайт с калькулятором и кабинетом клиента, приложение исполнителя и админка для операторов с пятью ролями. Все трое так или иначе трогают заказ и его стоимость.

Если правила цены живут в коде приложения, их рано или поздно станет три. Клиент увидит в калькуляторе одну сумму, оператор в админке — другую, а исполнитель — третью. Каждая будет «правильной» с точки зрения своего кода. Два калькулятора — это две правды, а три — уже повод для претензии.

Поэтому правила живут в одном месте — в базе. Приложения не считают деньги, они спрашивают у базы.

RPC-функции и RLS

На практике это Supabase: PostgreSQL с RPC-функциями и политиками RLS (row-level security). Бизнес-операция — создать заказ, применить скидку, сменить статус — это функция в базе. Она проверяет инварианты и меняет данные в одной транзакции.

Например, правила цены: +10 % в пиковое время, суммарная скидка не больше 20 %, бонусами можно оплатить до 15 % заказа, и скидки идут из доли сервиса, а не исполнителя. Эти ограничения проверяет база. Если фронтенд пришлёт что-то странное, база просто откажет.

RLS отвечает за то, кто что видит. Даже если в интерфейсе ошибка, база не отдаст клиенту чужой заказ, а оператору без нужной роли — чужие данные. Права не «договорённость между фронтендом и бэкендом», а правило, которое нельзя обойти запросом.

За всё время в основной базе накопилось больше 1 000 миграций. Правило одно: применённую миграцию не правим, пишем новую. История изменений базы — такой же журнал решений, как git.

Outbox в той же транзакции

Классическая ловушка: заказ сохранили, а уведомление не ушло. Или наоборот — уведомление ушло, а транзакция откатилась, и клиенту пришло сообщение о заказе, которого нет.

Решение — outbox. Событие «нужно отправить уведомление» записывается в отдельную таблицу в той же транзакции, что и сам заказ. Отдельный процесс читает outbox и отправляет сообщения — например, в Telegram. Либо сохранилось и то и другое, либо ничего.

Платежи: блокировка, возвраты, вебхуки

Онлайн-оплата — через bePaid. Есть два режима: оплата сразу и блокировка средств со списанием позже. Какой использовать, решает настройка, а не правка кода. Плюс возвраты, chargeback и сверка со шлюзом.

Платёжный шлюз сообщает о событиях вебхуками. И тут главное правило: вебхук может прийти дважды. Это нормально. Ненормально, если из-за этого дважды изменится баланс. Поэтому обработка идемпотентна: статус платежа хранится в базе, и повторное событие просто ничего не меняет.

Действия оператора с деньгами и персональными данными требуют 2FA и записываются в аудит. Когда что-то пойдёт не так — а оно пойдёт, — будет видно, кто, что и когда сделал.

А минусы?

Конечно есть. SQL-функции сложнее отлаживать, чем TypeScript. Нужны тесты базы — я гоняю их на PGlite, чтобы не поднимать отдельный сервер. Нужна дисциплина с миграциями.

Но я выбираю эту сложность осознанно. Ошибка в вёрстке — это некрасиво. Ошибка в деньгах — это разговор с клиентом, бухгалтерией и, возможно, юристом. Пусть уж лучше база будет строгой.

Коротко

Деньги — в базе. Права — в базе. События — в той же транзакции. Вебхуки — идемпотентно. Приложения — тонкий слой, который показывает и спрашивает. Звучит скучно, и это лучший комплимент для денежной логики.

0 комментариев · напишите мне — будет 1