Есть популярная схема: база хранит данные, а вся логика живёт в приложении. Для блога это нормально. Для продукта, где три приложения работают с одними и теми же деньгами, — нет. Расскажу, почему в платформе, которую я строю (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, чтобы не поднимать отдельный сервер. Нужна дисциплина с миграциями.
Но я выбираю эту сложность осознанно. Ошибка в вёрстке — это некрасиво. Ошибка в деньгах — это разговор с клиентом, бухгалтерией и, возможно, юристом. Пусть уж лучше база будет строгой.
Коротко
Деньги — в базе. Права — в базе. События — в той же транзакции. Вебхуки — идемпотентно. Приложения — тонкий слой, который показывает и спрашивает. Звучит скучно, и это лучший комплимент для денежной логики.