Сначала кажется, что главное — поделить платёж. Потом оказывается, что платёж вообще не главное.
Совместные подписки: что мы думали до начала разработки, что оказалось на самом деле и почему список того, чего мы не стали делать, важнее списка того, что сделали.
Когда начинаешь делать сервис совместных подписок, кажется, что главная задача — правильно разделить платёж. Пять человек скидываются на Netflix, система списывает с каждого по 200 рублей, организатор получает тысячу. Что тут сложного.
Потом оказывается, что платёж делить не надо. Что деньги от участника к организатору не должны идти напрямую, потому что они незнакомы. Что процесс вступления в группу — это не добавить запись в таблицу, а admission control с двумя разными проверками вместимости и тремя путями входа. Что float для денег — ошибка, которую совершаешь ровно один раз. И что фоновая задача про списание не может жить в Redis, потому что она про деньги, а деньги лежат в PostgreSQL.
Это не баги. Это вещи, которые становятся очевидны, только когда начинаешь писать код и архитектура показывает свои настоящие ограничения. В prvms.hat мы прошли через несколько таких открытий.
Деньги не должны идти от участника к организатору
Первое, что мы сделали — и первое, что пришлось переделать.
На старте модель была простая: участник платит свою долю, организатор получает деньги, оплачивает подписку провайдеру. Выглядит логично. Но как только начинаешь продумывать отказы, схема рассыпается. Организатор получил деньги и не оплатил подписку — что делаем? Участник перевёл, а его не добавили — что делаем? Организатор пропал — деньги висят где?
Оказалось, что модель «участник → организатор» работает только между знакомыми людьми. Между незнакомыми она не работает вообще. Потому что нет доверия. И продукт не может опираться на доверие, которого нет.
Пришлось перевернуть денежный поток. Теперь участник не платит организатору. Он фиксирует своё намерение платить, а деньги замораживаются на платформе. Организатор тем временем оплачивает подписку провайдеру из своего кармана, загружает подтверждение в систему, и только после проверки получает деньги участников. Не подтвердил вовремя — деньги возвращаются.
Это заняло время и вылилось в отдельную подсистему: escrow, циклы, подтверждения, выплаты, возвраты. Но альтернативы не было. Продукт для незнакомых людей не может работать по модели «я тебе доверяю». Он должен работать по модели «я не обязан тебе доверять, потому что деньги держит платформа».
Вступление в группу — это не INSERT
Второе открытие касалось групп. Казалось бы: пользователь нажимает «вступить», делаем INSERT в таблицу участников, готово. Но когда начинаешь выписывать сценарии, INSERT превращается в admission control.
Первое: есть три разных способа вступления. Открытая группа — нажал и вошёл. Модерируемая — подал заявку, организатор принял или отклонил. По приглашению — перешёл по ссылке и вошёл сразу. Три пути, три набора проверок, три разных ответа на клиенте.
Второе: в модерируемой группе заявка и решение разнесены во времени. Пока заявка висела, группа могла заполниться другими людьми. То есть принять заявку — не гарантирует место. Вместимость надо проверять повторно в момент решения. И если группа полна, заявка остаётся висеть — это не ошибка, это честный ответ.
Третье: заявка не может висеть вечно. Мы добавили авто-протухание: 72 часа, и заявка переходит в статус «истекла». Заявитель может подать снова. Организатор видит только актуальные.
В сухом остатке: «вступить в группу» — это не одна операция. Это подсистема с собственным состоянием, таймерами, тремя точками входа и повторной проверкой ограничений в момент, отложенный от запроса. Кода там больше, чем во всей остальной работе с группами вместе взятой.
Float для денег — это ошибка, которую совершаешь один раз
Деньги в системе изначально ходили как float64. Естественно же: рубли с копейками.
Проблема проявилась не сразу. Она накапливалась. Цена группы — допустим, 500 рублей на пятерых. Система делит на участников, добавляет комиссию, считает выплату организатору. На одном цикле расхождение в копейку незаметно. На десяти циклах — расхождение. Сто циклов — и организатор систематически получает чуть меньше или чуть больше, чем должен.
Перевели всё на целые копейки. Храним как BIGINT, считаем в целых числах, показываем в рублях. Убрали float из всех денежных операций.
Но и этого оказалось мало. Самая опасная точка — ввод. Если дать пользователю поле «цена», он введёт что угодно. 499.99, 500.50, 500.01. И тогда целые копейки в базе не спасают — ошибка приходит с фронта, дробится при делении на участников и размазывается по циклам.
Закрыли поле ввода: только целые рубли. Цена всегда кратна 100 копейкам. Да, организатор не может выставить 499.99. Но он и не должен — в совместной подписке копейки не имеют смысла. А класс ошибок, связанных с округлением, исчез целиком.
Фоновая задача про деньги не может жить в Redis
Биллинг — это не списание по кнопке. Это цепочка фоновых операций: создать цикл за пять дней до даты оплаты, в дату оплаты заморозить средства, дождаться подтверждения от организатора, выпустить выплату, проверить просрочку и при необходимости заморозить цикл с возвратом.
Очевидный кандидат для фоновых задач — очередь на Redis. Быстро, просто, все так делают. Но в биллинге это не работает.
Redis-очередь и PostgreSQL-база — два разных хранилища. Когда ты создаёшь задачу «заморозить средства» и одновременно обновляешь статус цикла, эти две операции неатомарны. Одна может пройти, вторая — нет. Или задача выполнилась, а состояние цикла тем временем изменил другой процесс. Для рассылки уведомлений это приемлемо. Для денег — нет.
Мы взяли River — очередь задач на самом PostgreSQL. Создание задачи и запись в денежный ledger происходят в одной транзакции. Откатилась транзакция — задача не появилась. Отработала задача — состояние цикла гарантированно согласовано с тем, что было на момент создания. Никаких «почти одновременно».
Это не вопрос вкуса. Это вопрос цены ошибки. В деньгах «почти» не бывает.
Что мы в итоге поняли
Главное открытие этого проекта не техническое. Оно про то, как устроена разработка продукта, в котором участвуют чужие деньги.
Оказалось, что список того, чего мы не стали делать, не менее важен, чем список сделанного. Мы не стали делать очередь на Redis — потому что для денег это неправильно. Не стали делать Centrifugo для чата — потому что native WebSocket решает задачу без дополнительной инфраструктуры. Не стали выкатывать реальный платёжный шлюз — сначала убедились, что модель escrow работает на симулированных деньгах. Не стали давать пользователю поле с копейками — потому что свобода ввода, которая плодит ошибки, хуже, чем ограничение, которое их предотвращает.
Это дисциплина, а не минимализм. Когда работаешь с деньгами, каждое лишнее звено — потенциальная точка отказа. И лучше не добавлять технологию, пока не стало понятно, какую проблему она решает и какую создаёт.
prvms.hat — это Go, PostgreSQL, SvelteKit, WebSocket и очередь задач, которая живёт в той же базе, что и деньги. Но стек здесь не про что. Про что — это модель, в которой незнакомые люди могут делить подписки без риска. И эта модель стала понятна не на старте, а где-то между третьим переделанным компонентом и четвёртым отказом добавить технологию.