# PAYMENTS — механики монетизации Русское зеркало [`PAYMENTS.md`](PAYMENTS.md) (английская версия — основная). Описывает домен монетизации: игровую валюту, кошельки, ценности, правила комплаенса сторов, приём платежей, рекламу, каталог, журнал операций и отчёты. Каждую правку `PAYMENTS.md` зеркалим сюда в том же PR (как `FUNCTIONAL.md`/`FUNCTIONAL_ru.md`). Читать перед любым изменением платёжного поведения. Технический план внедрения — [`../PLAN.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. Три операции, которые нельзя путать Не смешивать — у них разные ключи: 1. **Пополнить Фишки** — деньги/реклама → сегмент `source` Фишек, по **контексту исполнения**. 2. **Потратить Фишки = купить ценность** — сегмент → бенефит, по контексту с гейтом (§4). Здесь бенефит **рождается** и помечается своим `origin`. 3. **Применить бенефит** во времени (напр. «без рекламы до *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` / Telegram `initData`) на **каждом холодном старте**, поэтому их платформа переподтверждается при каждом запуске; `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)` — быстрый **материализованный** кэш, обновляется **в той же транзакции**, что и запись журнала, и пересчитывается из журнала для сверки. **In-process кэш чтения.** Поверх материализованных таблиц пакет payments держит in-process кэш сегментов и бенефитов по ключу-аккаунту (write-through), чтобы горячие пути чтения — проверка показа рекламы, доступность подсказок, экран кошелька, гейт траты — на устоявшемся пути **не** делали ни одного запроса к схеме `payments`. Кэш инвалидируется на каждой изменяющей операции payments (трата / грант / пополнение / возврат / мерж) и перечитывается из материализованных таблиц при промахе (тот же write-through-паттерн, что и у гейта блокировок аккаунта). Один инстанс — под текущий деплой; многоинстансный backend потребовал бы общего кэша. Присутствие identity (какие сегменты «не спят», §6) передаёт вызывающий, здесь не кэшируется, поэтому отвязка/повторная привязка действует сразу. **Награждение админом.** Админ начисляет **только конкретные ценности** (без рекламы / подсказки) — **никогда не Фишки** (подаренный баланс валюты = обход кассы стора). Админ **выбирает 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).