«Купил — не потеряешь»: моделирование данных как этика
В живой платной базе доступ к оплаченному выводился из изменяемого состояния — смена темы или удаление ребёнка осиротили бы оплаченное, а сбой записи после списания мог стоить денег конкретной семье. Почему обещание «купил — не потеряешь» проектируется на уровне схемы БД, а не текста на экране.
Момент, когда я понял, что обещание держится на честном слове
В Сказии есть простое обещание: купил серию сказок — она твоя. Я писал его в 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. Это инвариант, который либо есть в схеме данных, либо его нет нигде. «Купил — не потеряешь» нельзя пообещать копирайтом: пока владение выводится из изменяемого состояния, любое обещание держится на том, что условия его поломки случайно не совпали.
Поэтому вопрос, который я задаю себе про любую такую гарантию, — не «что написано на экране», а «из чего это вычисляется и что произойдёт, когда я поменяю движок». Я не оптимизировал тут интерфейс. Я спроектировал доверие — и сделал это там, где его нельзя случайно отменить.
Часть серии «Сказия: 0→1 в одиночку с AI»
Дальше в серии
Максим Вечкитов · Senior Product Designer · Design Engineer
Открыт к senior/lead-ролям и парт-тайму.