Совместный депозит

RLS
защита на уровне строк
SSA
Server Actions
ADR
4 зафиксированных решения
Vitest
юнит-тесты
Next.js 16React 19TypeScriptSupabasePostgreSQLRLSServer ActionsVitestVercel
Роль
Идея, дизайн, разработка
Стек
Next.js 16 · Supabase · TypeScript
Платформа
Веб, mobile-friendly
Статус
Живой прод

Контекст

Совместный вклад — это таблица в WhatsApp

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

Проблема

Три боли одного вклада

01

Видимость

Участники не видят реальных данных по вкладу — только то, что скинул владелец. Актуальность под вопросом.

02

Доходность

Никто не считает вручную проценты. «Сколько уже накапало?» — вопрос без ответа до закрытия.

03

Контроль

Владелец принимает решение о закрытии в одностороннем порядке. Участники узнают постфактум.

Решение

Один экран — вся информация по вкладу

Владелец создаёт вклад (сумма, ставка, срок), приглашает участников по email. Каждый видит те же данные: накопленная доходность считается в реальном времени, история участников — прозрачна. Владелец может закрыть вклад — все получают уведомление. Данные защищены на уровне БД через Row Level Security: чужой вклад недоступен даже по прямой ссылке.

Ключевые решения

Четыре нетривиальных выбора

Решение #1

Разрыв рекурсии RLS через SECURITY DEFINER

Было

При создании вклада Supabase возвращал «infinite recursion detected in policy for relation deposits». Политика SELECT на deposits обращалась к deposit_members, а политика на deposit_members — обратно к deposits. Классический взаимный цикл.

Стало

Функция is_deposit_owner(did) с атрибутом SECURITY DEFINER читает deposits в обход RLS — цикл разрывается в одной точке. Политики перестроены поверх этой функции.

Почему именно так: Сохраняет полную модель доступа, не ослабляет RLS. Стандартный паттерн Supabase для иерархических политик. Варианты «убрать подзапросы» или «отключить RLS» отвергнуты: первый ломает проверку владельца, второй небезопасен для финансовых данных.

Решение #2

Приём приглашения только адресатом — сверка email

Было

Bearer-подход: любой авторизованный, открывший ссылку /auth/invite/<token>, может вступить в вклад. Просто реализовать.

Стало

В функции accept_invitation email авторизованного пользователя сверяется с invitation.email (case-insensitive). При несовпадении — ошибка EMAIL_MISMATCH. Регистрация/вход прокидывают ?next=, чтобы участник возвращался на страницу инвайта под нужным email.

Почему именно так: Финансовое приложение: утёкшая или пересланная ссылка не должна открывать доступ к чужому вкладу. Принцип «наименьших привилегий» должен быть зашит в логику, а не в договорённости.

Решение #3

Модель доходности: простой процент, год = 365 дней

Было

Несколько вариантов: простой процент, сложный (с капитализацией), банковский год 360 дней. Каждый даёт разные цифры.

Стало

Простой процент без капитализации, год строго 365 дней. Формула: accrued = amount · (rate / 100 / 365) · daysElapsed, где daysElapsed ограничен сроком вклада. Реализована в чистой функции computeYield, покрытой юнит-тестами.

Почему именно так: Прозрачно и легко проверяется пользователем вручную. Вынесено в отдельную функцию — при смене модели меняется одна точка и обновляются тесты. MVP не требует точной банковской математики, требует доверия.

Решение #4

middleware.ts → proxy.ts: обход конфликта Next.js + Supabase

Было

Стандартный middleware.ts для обновления Supabase-сессии. В Next.js 16 (App Router) middleware конфликтует с некоторыми Server Actions — сессия не пробрасывается.

Стало

Supabase-клиент вынесен в proxy.ts, который оборачивает cookie-чтение. Middleware остался минимальным (только редиректы). Сессия обновляется через server-side хелпер, а не через middleware.

Почему именно так: Next.js 16 — не привычный Next.js 14. Многие паттерны из документации написаны для старой версии и ломаются. Единственный рабочий путь — изолировать Supabase-клиент от middleware и тестировать каждый слой отдельно.

Архитектура

Почему Next.js + Supabase, а не проще

Финансовое приложение с инвайтами требует авторизации, хранения данных и разграничения доступа. Supabase даёт PostgreSQL с RLS, Auth и realtime из коробки — без написания API-слоя. Next.js 16 с Server Actions позволяет держать логику на сервере, не выставляя эндпоинты наружу. Альтернативы (Firebase, PocketBase) рассматривались, но отвергнуты: Firebase — vendor lock-in без SQL, PocketBase — нет проверенного RLS для финансовых сценариев.

PostgreSQL + RLS

Доступ к строкам проверяется на уровне БД — не в коде приложения. Даже прямой SQL-запрос вернёт только свои данные.

Server Actions

Мутации (создать вклад, принять инвайт, закрыть) — серверные функции без REST-эндпоинтов. Меньше поверхности атаки.

computeYield — чистая функция

Расчёт доходности изолирован и покрыт тестами. Изменить модель (капитализация, 360 дней) — 1 файл + 1 тест-сюит.

Email-сверка на инвайт

Принять приглашение может только тот, чей email совпадает с invitation.email. Утёкшая ссылка не даёт доступ.

Главные ошибки — и что они дали

Доверие к документации Supabase без проверки версии Next.js

RLS-рекурсия обнаружена только в проде

Рефлексия

Что вынесла

Первый проект с полноценным бэкендом после MedLog и razdelit-schet (оба без сервера). Главный урок: RLS — это не «настроить один раз», а архитектурное решение, которое нужно проектировать с самого начала и тестировать с несколькими ролями, а не одним пользователем. Второй урок: документация экосистемы (Supabase, Next.js) часто пишется для предыдущей версии — перед тем как следовать примеру, проверяй версию в примере и версию в проекте.

← Все кейсы