Record the execution platform (kind vk|telegram|direct + device subtype ios|android|web) on each session, captured at creation and carried gateway->backend as a trusted X-Platform header, so the upcoming store-compliance gate has an unforgeable execution context. - backend.sessions gains nullable platform_kind/platform_subtype columns (migration 00011, CHECK-constrained, jet regenerated); session.Platform captures them at mint, resolve returns them, middleware exposes platform(c). kind is derived from the establish endpoint, never a client field; the account-merge session mint inherits the caller's platform. - gateway derives the platform (VK subtype from the signed vk_platform via vkauth, Telegram/direct best-effort from the client) and injects X-Platform on every authenticated backend call through the request context. - ui submits a best-effort device subtype on the telegram/guest/email login requests (new FBS subtype field); VK is server-derived from the signed params. - an unattributed session is untrusted (view-only); VK/TG self-heal on the next cold-start re-mint, direct/email on re-login. Signal plumbing only, no user-visible change; X-Platform is inert until the gate consumes it.
30 KiB
PAYMENTS — механики монетизации
Русское зеркало PAYMENTS.md (английская версия — основная). Описывает
домен монетизации: игровую валюту, кошельки, ценности, правила комплаенса сторов, приём
платежей, рекламу, каталог, журнал операций и отчёты. Каждую правку PAYMENTS.md зеркалим
сюда в том же PR (как FUNCTIONAL.md/FUNCTIONAL_ru.md).
Читать перед любым изменением платёжного поведения. Технический план внедрения —
../PLAN.md.
Статус: спецификация утверждена; внедрение поэтапно (см.
PLAN.md). В проде пока ничего из этого нет.
1. Обзор
Игра зарабатывает двумя независимыми каналами:
- Покупки игровой валюты «Фишка» за реальные деньги, затем трата Фишек на ценности (бенефиты).
- Реклама — ролик за награду пополняет Фишки; полноэкранный ролик и наш баннер зарабатывают показами.
Валюта двухуровневая:
деньги (Голоса VK / Stars TG / рубли через Robokassa) ─┐
├─► Фишки ──► ценности
просмотр ролика за награду ─┘ (без рекламы,
подсказки,
турнирный взнос)
Фишки — единая единица витрины. Деньги и просмотры пополняют Фишки; Фишки покупают ценности. Прямой оплаты ценности деньгами нет — всегда через Фишки.
2. Модель валюты
Одна Фишка равна одной Фишке везде — единица единая. Разница между методами оплаты целиком в курсе покупки: пакет Фишек стоит X Голосов / Y Stars / Z рублей, и курс учитывает комиссию каждого стора. Цены ценностей фиксированы в Фишках и одинаковы для всех методов.
Баланс Фишек сегментирован по «источнику» (source) — платформе, где Фишки пополнены:
source |
Чем пополняется |
|---|---|
vk |
Покупка за Голоса VK, ролик за награду в VK |
telegram |
Покупка за Stars TG |
direct |
Покупка через Robokassa (web / native) |
Один аккаунт держит все три сегмента одновременно: баланс = (account_id, source), до трёх
записей — запись материализуется лениво при первом пополнении сегмента, отсутствующий сегмент
читается как ноль. Сегментация не по привязке (identity) — см. §6.
Почему сегментировано, а не один общий баланс: правила сторов запрещают активировать
ценность, оплаченную мимо их кассы. Фишки, пополненные внутри VK (Голоса), тратятся только
в контексте VK; Stars — только внутри Telegram; Фишки от Robokassa (direct) — только вне
сторов. См. §4.
3. Три операции, которые нельзя путать
Не смешивать — у них разные ключи:
- Пополнить Фишки — деньги/реклама → сегмент
sourceФишек, по контексту исполнения. - Потратить Фишки = купить ценность — сегмент → бенефит, по контексту с гейтом (§4).
Здесь бенефит рождается и помечается своим
origin. - Применить бенефит во времени (напр. «без рекламы до T») — по правилу
origin(§5).
source (где пополнены Фишки) и origin (где куплена ценность) используют одно множество
значений {vk, telegram, direct}, но значат разное. В вебе они расходятся: покупка в вебе
может списать vk-Фишки (source=vk) в direct-покупку (origin=direct).
4. Гейт комплаенса сторов
Стена комплаенса односторонняя. Опасное направление — активация оплаченной снаружи ценности внутри обёртки стора — заблокировано; безопасное (бенефит стора действует наружу, в открытом вебе) — разрешено.
Контекст траты → какие сегменты/бенефиты доступны:
| Контекст исполнения | Тратимые сегменты Фишек | Приоритет траты |
|---|---|---|
| Внутри VK (Android) | vk |
— |
| Внутри VK (iOS) | нет — заморожено (только просмотр) | — |
| Внутри Telegram | telegram |
— |
| Web / native (Direct) | direct + vk + telegram |
direct → vk → tg |
- Внутри VK/TG доступен только одноимённый сегмент; всё остальное (в первую очередь
direct) там невидимо как тратимое. - В вебе у стора нет юрисдикции, поэтому доступны все привязанные сегменты, списываются по приоритету direct → vk → tg.
- VK iOS заморожен для траты (Apple запрещает тратить виртуальную валюту на цифровые товары мимо IAP внутри VK на iOS): баланс показывается числом, но покупка/трата невозможны. Ранее купленный бенефит там всё равно действует.
Аккаунт единый (привязки сливаются, один профиль/друзья/статистика). Гейт логический: в контексте VK/TG сервер активирует только одноимённый сегмент. Держится на доверенном сигнале платформы (§8) — клиенту не верим. Когда платформу нельзя доверенно установить, гейт fail-closed: траты/покупки запрещены, только просмотр.
Одностороннее применение бенефита
Бенефит несёт origin = контекст покупки (не «чем оплачено»). Покупка в вебе за
vk-Фишки всё равно даёт origin=direct.
origin |
Где действует бенефит |
|---|---|
vk / telegram |
Везде — внутри своего стора и наружу в web/native |
direct |
Только web/native — никогда внутри VK/TG (= бан) |
Перед тратой в вебе vk/telegram-Фишек интерфейс предупреждает, что ценность будет
доступна только здесь (web/native) из-за ограничений VK/TG.
5. Ценности
Три разные сущности, не смешивать:
- Фишки — валюта. Сегмент по
source. - Подсказки — расходник, покупается за Фишки. Сегмент по
origin. Тратятся по одной в онлайн-играх; вvs_aiподсказки бесплатны/безлимитны (30-мин кулдаун, не в счёт). Подсказка в игре списывается изorigin, применимого в текущем контексте (то же одностороннее послабление). - Без рекламы — срок-бенефит, покупается за Фишки. Сегмент по
origin.
Складывание «без рекламы». Покупка срока продлевает paid_until[origin] += срок от
max(сейчас, текущий конец) — остаток не теряется («сроки плюсуются»). Навсегда —
отдельный вечный флаг, перекрывает сроки. «Реклама выключена в контексте P» ⇔ есть
применимый в P origin с paid_until > сейчас (в вебе берём максимум по direct/vk/tg; в
VK только vk; в TG только tg).
Что гасит «без рекламы»: верхний баннер и полноэкранный ролик после хода. Добровольный ролик за награду (за Фишки) не гасится — это выбор пользователя.
Турнирный взнос — будущий тип ценности; атом заложен, механика позже.
6. Жизненный цикл кошелька
Правило доступности сегмента. Сегмент тратим ⇔ на аккаунте есть привязка этого
source (для direct — устойчивая привязка/email). Отсюда unlink/мерж выводятся
естественно.
Отвязка (vk/tg). Разрешена даже при ненулевом балансе/активном бенефите. Сегмент не
сжигается — он засыпает (нет привязки ⇒ недоступен в VK и, без привязки, недоступен
как подтянутый веб-сегмент); повторная привязка будит. Перед отвязкой предупреждение
(«N Фишек станут недоступны до повторной привязки»). Последнюю привязку отвязать нельзя
(существующий ErrLastIdentity).
Мерж. Сегменты и бенефиты сливаются по origin: одноимённые складываются (Фишки
суммируются, сроки бенефита продлеваются по origin), разные сосуществуют. Это расширяет
текущий мерж аккаунтов (hint_balance +=, paid_account OR=), так что origin сохраняется и
ничего не протекает между платформами.
Гость. У гостевого аккаунта вообще нет кошелька — раздел «Кошелёк» скрыт, покупок
нет, баланса нет, by design. Балансы — только у durable-аккаунтов. Поэтому чистильщик
гостей удаляет их свободно (денег там быть не может). В direct email обязателен перед
первой покупкой как якорь восстановления (в VK/TG якорь — сама vk/tg-привязка);
переиспользуем существующий флоу email (запрос кода → подтверждение → снятие флага гостя →
durable).
7. Каталог и цены
Каталог конфигурируемый — продукты, цены, курсы покупки и награда за ролик живут в базе и правятся в админке, без релиза.
- Базовые ценности (атомы): Фишки, подсказки, дни без рекламы, участие в турнире.
- Продукт = набор атомов + цена. Продаётся по одной или комбо (напр. «250 подсказок + 30 дней без рекламы»).
- Пакет Фишек (пополняет Фишки) — цена per-метод (мультивалютная Голоса/Stars/руб) одним продуктом.
- Ценность (за Фишки) — цена в Фишках (единая для всех методов).
Деактивация, не удаление. Продукты деактивируются (soft-delete). Состоявшаяся покупка хранит снимок проданного (состав атомов + цена на момент) в архиве, чтобы история/чеки/налоги не зависели от последующих правок каталога.
8. Доверенный сигнал платформы
Гейту (§4) нужен доверенный, неподделываемый контекст платформы на сервере. Клиент — никогда не источник правды.
- Платформа — свойство сессии, фиксируется при создании сессии. Обёртки VK и Telegram
пересоздают сессию (и потому заново проверяют подпись запуска — VK launch-params
sign/ TelegraminitData) на каждом холодном старте, поэтому их платформа переподтверждается при каждом запуске;direct-сессия фиксирует платформу один раз, самим фактом создания веб/native-сессии (внешней подписи нет и не нужно — доступ к vk/tg-сегментам в direct-контексте всё равно требует реальной привязки, §6). - Платформа несёт kind (
vk/telegram/direct) плюс подтип (ios/android/web).kindдоверенный всегда — сервер выводит его из проверенного запуска, не из клиентского поля. Подтип доверенный только у VK: он лежит внутри подписанных параметров запуска, что и делает заморозку VK iOS выполнимой; у Telegram и direct подтип сообщает клиент, он best-effort, и гейт на него не опирается. - Гейтвей резолвит сессию и передаёт
platformв бэкенд (рядом с существующимX-User-ID), беря его из сессии — не из тела клиентского запроса. - Fail-closed: недоверенная платформа — сессия без записанной платформы, созданная до этой функции или которую гейтвей не смог атрибутировать — запрещает траты/покупки и применение любого чужого origin (только просмотр). VK/TG-сессия восстанавливается на следующем холодном старте (пересоздание), переиспользуемая direct/email-сессия — при повторном входе.
9. Приём платежей
Только серверный колбэк провайдера. Фишки начисляются лишь по проверенному
(подпись/HMAC) серверному колбэку — Robokassa webhook / TG successful_payment / VK
callback. Клиентское «я оплатил» игнорируется.
Единственный писатель. Один платёжный домен — единственный, кто пишет в журнал операций.
Публичные вебхуки (Robokassa/VK) терминируются на краю (Caddy/gateway) и проксируются в
платёжный домен; TG successful_payment приходит боту, тот форвардит в платёжный домен.
Одно место начисляет и защищает от повторов.
Флоу заказа. Сервер заранее создаёт order(pending) с account / платформой / пакетом /
ожидаемой суммой / origin. order_id прокидывается провайдеру (Robokassa InvId / TG
invoice_payload / VK item — точную форму VK уточнить при интеграции). Колбэк матчится по
order_id (никогда по сумме, поэтому коллизии одинаковых сумм невозможны), сверяет сумму,
начисляет, помечает paid. Защита от повторов: дедуп по (провайдер, provider_payment_id).
Pending невидим пользователю; авто-истекает по таймауту (~30 мин, гигиена базы).
Валидный колбэк исполняется всегда, даже на истёкшем заказе (expired ≠ отмена —
деньги реальны, Фишки должны быть выданы). Пользователь видит только успешные покупки.
Outbox TG-бота. successful_payment приходит только боту (Bot API, не Mini App), а
хост бота слабый и может терять связь, поэтому бот — durable-звено. Store-and-forward на
SQLite на диске бота: получил → сохранил → подтвердил апдейт Telegram → форвардит в
платёжный домен (идемпотентно, дедуп по telegram_payment_charge_id) → ack → пометил
forwarded. Ретраи с backoff; дореталивает недоставленное при рестарте. Доставка
at-least-once + идемпотентный приём = начисление ровно один раз.
События. Платёжный домен пишет payment_events (succeeded / failed / refunded);
диспетчер рассылает по каналам — live gRPC-стрим, если пользователь в аппе, иначе
существующий пуш botlink / email. «Оплата не прошла» (активный отказ провайдера, не
брошенный pending) доводится до пользователя; «оплата прошла» — хук (письмо / сообщение в
бота).
Возвраты. ToS — невозвратно, пользователю возврат не предлагаем. Админ может сделать
ручной возврат (крайний случай: пользователь требует вскоре после оплаты / закрывает
аккаунт — связано с accountdelete, где уже сохраняются сообщения). Внешние возвраты
(чарджбек / решение стора / TG / VK) обрабатываются: система принимает событие refunded,
по возможности отзывает бенефит (в минус не уходим; если Фишки уже потрачены —
фиксирует убыток + флаг защиты от злоупотреблений), пишет в журнал. Журнал операций
спроектирован экспортопригодным для будущей налоговой отчётности и сверки с Robokassa
(саму сверку пока не строим; схема остаётся совместимой).
10. Реклама
Охват на старте: только VK для видео (награда в рублях, ОРД автоматом, API готов). web/native/TG держат только существующий наш текст-баннер; видео отложено до появления рублёвой in-app сети. Рекламный провайдер за абстракцией, чтобы будущая сеть для других платформ встроилась без переделки. Крипто-сети (AdsGram/AdMob) отвергнуты — нет легального рублёвого дохода самозанятому (НПД).
Ролик за награду (добровольное видео за Фишки) начисляет Фишки через платёжный домен по серверному verify-колбэку сети (клиенту не верим, как платежу). Антифрод на старте — только verify провайдера (своего дневного потолка пока нет; абстракция позволит добавить лимиты позже).
Полноэкранный ролик (после хода), конфигурируемые серверные значения:
- Глобальный кулдаун на пользователя, сквозь все партии, дефолт 5 мин.
vs_ai— 30 мин (соосно кулдауну подсказок, чтобы не отпугивать казуалов).- Применение подсказки триггерит ролик после хода независимо от основного кулдауна, со своим кулдауном 1 мин.
- Оффлайн — только баннер.
- Уважать собственные лимиты частоты VK.
Оффлайн без рекламы, кроме нашего баннера. Бенефит без рекламы гасит баннер через
существующий ads.Eligible (backend/internal/ads/ads.go), который расширяется до гейта по
origin-бенефиту, применимому в текущем контексте, а не по одному глобальному флагу.
11. Админ, аудит, отчётность
Неизменяемый журнал операций + материализованный баланс. Журнал операций только на
INSERT (никогда UPDATE/DELETE — полный аудит). Балансы сегментов (account, source) и
бенефиты (account, origin) — быстрый материализованный кэш, обновляется в той же
транзакции, что и запись журнала, и пересчитывается из журнала для сверки.
Награждение админом. Админ начисляет только конкретные ценности (без рекламы /
подсказки) — никогда не Фишки (подаренный баланс валюты = обход кассы стора). Админ
выбирает origin при выдаче (ответственность за комплаенс на нём: origin=vk
точечно/малый объём = низкий риск, origin=direct = безопасно). Грант — транзакция журнала
типа admin_grant, цена 0 Фишек — полный аудит наград.
Финансовый отчёт по пользователю в админке /_gm — балансы сегментов, платежи, траты,
гранты, возвраты, полная история — расширение существующей карточки (UserDetailView,
handlers_admin_console.go). Плюс экспорт журнала.
12. Налоги и комплаенс
Чеки формируются автоматически на стороне провайдера и отличаются по каналу:
- Robokassa (direct) — чек НПД самозанятого при оплате.
- VK — VK сам процессит Голоса через налоговую; делать нечего.
- TG Stars — налоговой стороны нет (для РФ-самозанятого Stars легально невыводимы = не доход НПД; принимаем, чек не формируем).
ОРД (маркировка рекламы) по VK-рекламе — на стороне VK. (Не юридическая консультация — владелец сверяет точную схему НПД с налоговым консультантом.)
13. Дистрибуция (native Android)
- RuStore — Robokassa/внешний гейт разрешён (0%); native = чистый контекст
direct. - Google Play — direct-покупки скрыты; «Кошелёк» показывает заглушку («установите версию из RuStore для покупок»). Ролик за награду и трата уже накопленных Фишек работают. Перед GP-релизом свериться с актуальными правилами Google по внутренней валюте.
14. Модель данных (схема payments)
Платёжный домен живёт в своей схеме payments в общем инстансе Postgres, со своим
DB-ролём (права только на payments) и доменным пакетом за жёстким интерфейсом.
Cross-schema внешнего ключа к backend.accounts нет — идентификатор аккаунта здесь
обычное значение, согласуемое в коде и связываемое с tombstone-аккаунтом / досье
retained-identities по стабильному id. Это держит трату «Фишки↔бенефит» атомарной внутри
payments и делает домен извлекаемым в свою базу. Сохранность — PITR (непрерывный
WAL-архив), независимо от топологии базы.
Основные таблицы (финальные имена/колонки — в PLAN.md):
- журнал операций (ledger) — append-only операции: пополнение / трата /
admin_grant/ возврат;(провайдер, provider_payment_id)уникален для защиты от повторов; экспортопригоден. - балансы — материализованные
(account_id, source) → Фишки. - бенефиты — материализованные
(account_id, origin)→ «без рекламы»paid_until/forever+ счётчик подсказок. - каталог — атомы + продукты (деактивируемые), цены per-метод, курсы покупки Фишек, награда за ролик.
- заказы (orders) — pending-покупки,
order_id, ожидаемая сумма, origin, статус (pending/paid/expired). - payment_events — succeeded/failed/refunded для диспетчера.
Legacy accounts.hint_balance и accounts.paid_account устаревают в пользу
сегментированной модели и удаляются в отдельной contract-миграции (expand-contract, после
того как валютное ядро переключит чтения, и после Release 2 — откат образа остаётся безопасным
для БД); ни то, ни другое в проде никогда не выставлялось (потока покупки не было), поэтому
legacy-значения обнуляются.
15. Словарь
- Фишка — игровая валюта; единая единица, сегментирована по
source. - source (источник) — где пополнены Фишки (
vk/telegram/direct). - origin (происхождение) — где куплена ценность; определяет, где действует бенефит.
- ценность / бенефит — то, что покупается за Фишки (без рекламы, подсказки, турнирный взнос).
- гейт — одностороннее правило комплаенса сторов (§4).
- журнал операций (ledger) — неизменяемая запись всех операций с деньгами/ценностями.
- платёжный канал / рельса — платёжный провайдер (Robokassa / Голоса VK / Stars TG).