Files
scrabble-game/docs/PAYMENTS_ru.md
T
Ilia Denisov d2d6955cbf
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
feat(admin): admin grant — raw benefits and by-product reward bundles
The /_gm user card gains a Grant panel: grant raw benefit atoms (hints /
no-ads days / forever) or a defined value product (a reward bundle, including
an archived one), origin-picked. Both write an admin_grant ledger row via
payments.Grant / GrantProduct; the by-product grant records the source
product_id + snapshot. Both refuse a chips atom (never grant currency) or a
tournament atom (no credit target yet); chips/tournament products are also
kept out of the by-product picker.

Tests: the console grant end to end (raw, by-product, refuse a chips pack,
CSRF-guarded).
2026-07-10 05:35:55 +02:00

36 KiB
Raw Blame History

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. Три операции, которые нельзя путать

Не смешивать — у них разные ключи:

  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) vk (заморожена покупка)
Внутри Telegram telegram
Web / native (Direct) direct + vk + telegram direct → vk → tg
  • Внутри VK/TG доступен только одноимённый сегмент; всё остальное (в первую очередь direct) там невидимо как тратимое.
  • В вебе у стора нет юрисдикции, поэтому доступны все привязанные сегменты, списываются по приоритету direct → vk → tg.
  • VK iOS — заморозка ПОКУПОК, а не траты. ToS Apple запрещает там только покупать внутриигровые ценности (за любую валюту) — поэтому покупка (деньги → Фишки) отклоняется. А тратить Фишки VK-кошелька (заработанные рекламой или купленные на том же VK-аккаунте, напр. в VK Android) и зарабатывать их — легально и на 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 ≠ отмена — деньги реальны, Фишки должны быть выданы). Пользователь видит только успешные покупки.

TG Stars. До Telegram дотягивается только бот, поэтому весь рельс идёт через обратный mTLS bot-link (бот ↔ gateway; напрямую к бэкенду бот не ходит). Инвойс создаёт бот: на пути заказа gateway шлёт команду CreateInvoice, а бот возвращает createInvoiceLink (XTR) в Ack, который Mini App открывает через WebApp.openInvoice. До списания звёзд бот отвечает на pre_checkout_query через унарный вызов бот→gateway ValidatePreCheckout (за ним — приём): одобрить, только если заказ существует, ещё оплачиваем и не оплачен ранее — ссылка Stars-инвойса переиспользуема, так что этот гейт — единственное место, где повторная оплата отсекается до списания; текст отказа локализован в язык аккаунта заказа.

Outbox TG-бота. successful_payment приходит только боту (Bot API, не Mini App), а хост бота слабый и может терять связь, поэтому бот — durable-звено. Store-and-forward на SQLite на диске бота (internal/outbox): сохранил при получении (идемпотентно по telegram_payment_charge_id) → форвардит по bot-link (унарный ForwardPayment; gateway проксирует в приём) → при durable-ответе пометил forwarded. Дореталивает недоставленное при рестарте и по периодическому тику. Доставка at-least-once + идемпотентный приём (дедуп по telegram_payment_charge_id) = начисление ровно один раз.

События. Платёжный домен пишет payment_events (succeeded / failed / refunded); диспетчер рассылает по каналам — live gRPC-стрим, если пользователь в аппе, иначе существующий пуш botlink / email. «Оплата не прошла» (активный отказ провайдера, не брошенный pending) доводится до пользователя; «оплата прошла» — хук (письмо / сообщение в бота).

Возвраты. ToS — невозвратно, пользователю возврат не предлагаем. Возвраты инициирует админ (консоль E7): ни один рельс не шлёт непрошеный возврат — Robokassa через refund-API / ЛК (авто-опрос статуса — отложенный воркер, при низком объёме чарджбеков не оправдан), VK — через поддержку, TG Stars — вызовом refundStarPayment. Все сходятся на одном движке — метод Refund (internal/payments): матчит оплаченный заказ, пишет refund-строку журнала (идемпотентно по (provider, provider_refund_id) — refund-id отличается от payment-id fund'а, поэтому строки сосуществуют под тем же partial-unique индексом) и по возможности отзывает начисленные Фишки с полом 0 (в минус не уходим — D27, balances_chips_chk). Если Фишки уже потрачены, невозвратный остаток фиксируется как убыток + флаг злоупотребления per-account (payments.account_risk, читает отчёт E7). Дельта Фишек в refund-строке — то, что реально отозвано, поэтому журнал остаётся сверяемым с балансом; полный реверс (деньги, исходные Фишки, убыток) лежит в snapshot строки. Заказ остаётся paid — возврат живёт в журнале + событии refunded, не в статусе заказа. Повторный возврат не отзывает ничего. Журнал операций спроектирован экспортопригодным для будущей налоговой отчётности и сверки (саму сверку пока не строим; схема остаётся совместимой).

10. Реклама

Охват на старте: только VK для видео (награда в рублях, ОРД автоматом, API готов). web/native/TG держат только существующий наш текст-баннер; видео отложено до появления рублёвой in-app сети. Рекламный провайдер за абстракцией, чтобы будущая сеть для других платформ встроилась без переделки. Крипто-сети (AdsGram/AdMob) отвергнуты — нет легального рублёвого дохода самозанятому (НПД).

Ролик за награду (добровольное видео за Фишки) начисляет Фишки через платёжный домен. VK Mini App отдаёт только клиентский результат просмотра (VKWebAppShowNativeAdsdata.result) — серверной проверки нет — поэтому начисление client-attested (D29 амендим: server-verify у VK невозможен). Защита — серверный дневной и часовой кап (config reward_daily_cap / reward_hourly_cap, дефолт 50 / 10): он ограничивает читера, который пропускает ролик и дёргает эндпоинт напрямую, и — не менее важно — лимитирует бесплатные Фишки, чтобы желающий больше покупал. Начисление идемпотентно по клиентскому nonce и order-less; выплата — config (rewarded_payout_chips, дефолт 0 = ролик выключен, пока не задан). Rewarded только в VK и не гасится «без рекламы» (D9). Сеть с серверным verify встроится за ads-абстракцией. На тест-контуре build-флаг (VITE_ADS_STUB) подменяет ролик тостом; прод всегда крутит настоящую рекламу.

Полноэкранный ролик (после подтверждённого хода), только VK, конфигурируемые серверные значения. Гейт зеркалится на клиенте: профиль несёт кулдауны и флаг suppressed (тот же гейт «без рекламы» / no_banner, что и у баннера, считается на сервере в adsFor), а клиент сам гейтит по единому времени последнего показа в localStorage — вид лишь выбирает нужный интервал, поэтому hint-ролик и move-ролик не встают подряд в пределах кулдауна (один общий таймер, не по одному на вид) — без серверного раунд-трипа на каждый ход. Контурный VITE_ADS_STUB подменяет ролик тем же тостом «ad fired». Значения:

  • Глобальный кулдаун на пользователя, сквозь все партии, дефолт 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) передаёт вызывающий, здесь не кэшируется, поэтому отвязка/повторная привязка действует сразу.

Награждение админом. Админ начисляет только конкретные ценности (без рекламы / подсказки) — никогда не Фишки (подаренный баланс валюты = обход кассы стора). Выдаёт либо сырыми атомами, либо готовым продуктом-ценностью (набор-награда, возможно архивный — скрыт из магазина, но выдаётся); оба отказывают на атоме chips или tournament. Админ выбирает origin при выдаче (ответственность за комплаенс на нём: origin=vk точечно/малый объём = низкий риск, origin=direct = безопасно). Грант — транзакция журнала типа admin_grant, цена 0 Фишек (грант по продукту пишет исходный product_id + снапшот) — полный аудит наград.

Финансовый отчёт по пользователю в админке /_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).