«Купил — не потеряешь»: моделирование данных как этика
В живой платной базе доступ к оплаченному выводился из изменяемого состояния — смена темы или удаление ребёнка осиротили бы оплаченное, а сбой записи после списания мог стоить денег конкретной семье. Почему обещание «купил — не потеряешь» проектируется на уровне схемы БД, а не текста на экране.
Момент, когда я понял, что обещание держится на честном слове
В Сказии есть простое обещание: купил серию сказок — она твоя. Я писал его в FAQ, в чекауте, в финале серии. А потом сел смотреть, на чём оно держится в коде. Доступ к оплаченной серии выводился из изменяемого состояния: право на чтение жило, по сути, во флаге `series.unlocked` на строке серии ребёнка плюс CSV-заплатке `paid_situations` — списке тем, за которые когда-либо платили. Это значит, что обещание было ложью — не злонамеренной, а архитектурной. Серия у ребёнка физически одна (`childId` уникален), и стоило пользователю сменить тему на неоплаченную, как `unlocked` мгновенно гас, а единственная серия занималась новой темой. Удаление ребёнка шло каскадом: `children` → `series` → `paid_situations` стирались безвозвратно — при том что правила проекта прямой удаление запрещают. Был сценарий хуже: оплата из Telegram разблокировала серию в оперативной памяти процесса *после* блока записи в БД и от него не зависела. Если запись не долетала — деньги списаны, право живёт только в RAM, рестарт сервера, и человек заплатил в пустоту. Восстановление — руками по логу. Это не баг интерфейса. Это встроенная нечестность, которую не видно на экране, пока она не сработает на конкретной семье.
Владение — это факт, а не вычисленное состояние
Дальше будет про схему БД, но точка не в Postgres. Точка в том, что доверие — это инвариант, и его нельзя оставлять на «обычно срабатывает». Сравни две модели. Первая: доступ — производное от текущего состояния продукта. Есть строка серии, у неё флаг «открыта», читаем флаг — пускаем. Дёшево, и ровно так оно и было написано. Вторая: доступ — неизменяемый факт, записанный один раз в момент оплаты и больше никогда не пересчитываемый из живого состояния. Я выбрал вторую, и причина не эстетическая: только в ней смена темы или удаление ребёнка физически не могут отнять то, за что заплатили. В первой — могут, и вопрос лишь в том, когда совпадут условия. Поэтому в v0.11.0 я завёл таблицу `entitlements`: пользователь + slug серии + ребёнок, источник права (`purchase` / `promo` / `referral` / `grant`), ссылка на платёж, уникальное ограничение против дублей. Право пишется один раз и переживает бамп версии промпта, переименование темы, удаление профиля ребёнка. Рефанд — не удаление строки, а `revoked_at`: право не стирается, оно помечается отозванным. Удалять факт покупки — значит снова делать владение производным от состояния, только с обратным знаком. История остаётся целой: видно, что куплено, когда и почему закрыто. Журнал `payments` при этом перестаёт быть источником права и становится тем, чем и должен быть, — журналом.
Контент тоже можно осиротить — поэтому я отделил право от текста
Неизменяемого права мало. Сама сказка в Сказии генеративная: её сочиняет языковая модель, а движок я переписываю — меняются длина, возрастные диапазоны, склонения имён, версия промпта. Каждый такой бамп меняет `cacheKey` и инвалидирует кэш. Оплаченная серия после переработки физически отдаст другой текст — может, лучше, а может, и нет, но точно не тот, который ребёнок просил вчера. Право при этом не теряется (оно больше не зависит от версии промпта), а вот «та самая» сказка — теряется. Поэтому источником правды о владении стал `entitlements`, а сгенерированная серия (`generated_stories`) превратилась в то, чем и должна быть, — в кэш движка, который можно инвалидировать без последствий для оплаченного. Рядом с правом в v0.11.0 заведена таблица `owned_story_snapshots` — модель под иммутабельную копию выданного текста со склонениями и версией промпта. Это задуманный «пол качества»: после любой будущей переработки движка владелец получает ровно ту сказку, за которую заплатил, не хуже. Сама модель отгружена; заполнение снапшота при выдаче — следующий шаг, и я держу его именно так, а не выдаю за сделанное. Право от текста уже развязано — это и есть та часть, которая закрывает P0.
Главная сложность была не в схеме, а в том, что база живая и платная
Спроектировать таблицы — легко. Я делал миграцию не на чистом стенде, а на живой базе с реальными оплатами Telegram Stars. Здесь любая ошибка стоит не падения теста, а денег конкретного родителя. Так что миграцию я вёл по трём правилам, и порядок здесь не косметика. Схема — только аддитивная: новые таблицы, ни одного `DROP`, старый гейт работает, пока новый не доказан. Бэкфилл прав из текущего состояния — идемпотентный, через `ON CONFLICT DO NOTHING`: повторный прогон не плодит дублей и не переписывает уже выданное, он переживает даже перезапуск миграции на старте сервиса. И самое денежное — запись права, платежа и разблокировки в одной транзакции, с гейтом «оплачено впервые». Telegram умеет доставить уведомление об успешном платеже повторно; без гейта это двойная выдача — второе право, лишний промокод, лишняя реферальная награда. Insert платежа с `ON CONFLICT DO NOTHING` закрывает это на уровне транзакции, а не на уровне надежды, что доставка придёт один раз. Мигратор намеренно падает при ошибке: видимый crash-loop лучше тихой порчи данных.
Маленький радиус взрыва — это тоже продуктовое решение
Можно было переписать всё: тронуть каждое место, где код спрашивает «есть ли доступ». Я этого не сделал и считаю отказ частью работы. Доступ выводится из `entitlements` ровно один раз — при загрузке сессии. Семь call-site, которые потом спрашивают «пускать ли» — в боте и в Mini App, — остались нетронутыми: они читают уже вычисленное право, им всё равно, откуда оно взялось. Контракт `GET /api/series-state` не изменился, поэтому Mini App не трогали вообще. Это прямое следствие первого принципа. Если владение вычисляется один раз и дальше живёт как факт, то и точка вычисления одна — менять надо в одном месте, а не в семи. Архитектура, в которой честность — инвариант, а не размазанная по коду проверка, сама даёт маленький радиус взрыва. Безопасность миграции живой платной базы здесь не отдельная заслуга, а побочный эффект правильной модели. И да, я закрыл это тестами: полный прогон на одноразовом клоне Postgres, 175 тестов, 0 пропущено. Среди них — путь seed entitlement → доступ есть → `revoked_at` → доступа нет, и самый денежный путь: повторная доставка платежа, погашение промокода, реферальная награда только за первый платёж. Это ровно тот код, где ошибка списывает чужие деньги, — он покрыт не «на глаз».
Что из этого забрать
Честность продукта — не строка в FAQ. Это инвариант, который либо есть в схеме данных, либо его нет нигде. «Купил — не потеряешь» нельзя пообещать копирайтом: пока владение выводится из изменяемого состояния, любое обещание держится на том, что условия его поломки случайно не совпали. Поэтому вопрос, который я задаю себе про любую такую гарантию, — не «что написано на экране», а «из чего это вычисляется и что произойдёт, когда я поменяю движок». Я не оптимизировал тут интерфейс. Я спроектировал доверие — и сделал это там, где его нельзя случайно отменить.