Files
scrabble-game/docs/PAYMENTS_DECISIONS_ru.md
T
Ilia Denisov 13be7c3d9a
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
feat(ads): VK post-move interstitial + retire deprecated hint_balance/paid_account
Interstitial video after a confirmed play or a hint, VK-only, offline
banner-only. The gate is client-mirrored: the backend puts the config
cooldowns (global 5m / vs_ai 30m / hint 1m) and a suppressed flag (the
no-ads / no_banner gate, same as the banner) on Profile.ads via adsFor;
the client self-gates on a per-kind last-shown time in localStorage, with
the VITE_ADS_STUB contour "ad fired" toast. Never fires after a pass,
exchange or resign. maybeShowInterstitial + vkShowInterstitial; the codec
and gateway transcode carry the ads block.

Also retires the deprecated accounts.hint_balance / paid_account domain
usage (expand-contract, code only — the columns stay for a later DROP so
image rollback stays DB-safe): drop the Account fields and their scan, the
dead account.SpendHint, account.GrantHints and the admin grant-hints
action; the in-game hint display now comes wholly from the payments hint
benefit. Banner eligibility and account merge no longer read the legacy
flags.

Tests: ads.test.ts (the client-mirrored gate), the codec ads round-trip,
the gateway ads transcode test, and a profile ads-config integration test
(cooldowns + suppressed under no-ads / no_banner). Docs: PAYMENTS §10 (+ru)
interstitial, the decision amends, backend README, the plan.
2026-07-10 02:48:10 +02:00

33 KiB
Raw Blame History

Монетизация scrabble-game — проектирование и внедрение

Context

Игра в проде (erudit-game.ru), мультиплатформенная (VK / Telegram Mini App / web+PWA / native Android+iOS через Capacitor). Владелец (самозанятый, НПД) хочет ввести монетизацию: игровая валюта «Фишка», раздельные кошельки по платформам, покупка бенефитов (без рекламы, подсказки, будущий турнирный взнос), rewarded-реклама как канал пополнения, строгий транзакционный лог и админ-отчёты.

Отправная точка — monetization-prerequisites.md (пожелания владельца). Цель этого процесса: (1) через интервью выстроить чёткую модель, (2) оформить и поддерживать PAYMENTS.md (механики + договорённости), (3) составить PLAN.md (поэтапное внедрение от основ к интеграциям).

Что уже есть в коде (переиспользуем)

  • accounts.hint_balance int (CHECK >= 0) + атомарный SpendHint / GrantHints (backend/internal/account/account.go:551-595) — существующий «кошелёк подсказок», но пополняется только админом, покупки нет.
  • accounts.paid_account bool — пожизненный флаг «без рекламы», уже читается в ads.Eligible(...) (backend/internal/ads/ads.go:107); «no purchase flow yet».
  • Реклама сейчас = внутренний server-driven текст-баннер (ads домен + UI AdBanner.svelte), гасится paid_account / hint_balance>0 / роль no_banner. Rewarded-видео нет нигде.
  • Подсказки — фича готова end-to-end (кнопка, 30-мин gate, vs_ai безлимит).
  • Settings — таб-хаб (ui/src/screens/SettingsHub.svelte), вставка «Кошелёк» между «Друзья» и «Инфо» чистая.
  • Админка /_gm — per-user карточка уже показывает PaidAccount+HintBalance; прецедент денежного действия — POST /_gm/users/:id/grant-hints.
  • Один аккаунт держит VK+TG+email identity разом (kind∈telegram/vk/email/robot); мерж сейчас складывает hint_balance и OR-ит paid_account.
  • Прецедент изолированного сервиса: connector (gRPC), renderer (HTTP-sidecar), platform/telegram. Прецедент public HMAC-подписанного роута (экспорт партии) — образец для приёмника вебхуков.

Greenfield

Валюта «Фишка», реальные рельсы (Stars / Голоса / Robokassa), rewarded-видео, раздельные кошельки, транзакционный леджер, PITR/репликация, серверное знание платформы.

Зафиксированные решения (Decisions Log)

  • D1. Модель валюты — двухуровневая. Деньги (Голоса/Stars/руб) и rewarded-реклама пополняют баланс «Фишек»; за Фишки покупаются ценности (без рекламы, подсказки, турнир). Витрина ценностей — в Фишках.
  • D2. Фишка единая (1 = 1 везде). Разница по методам оплаты сидит в курсе покупки Фишек (пакет стоит X Голосов / Y Stars / Z руб, курс учитывает комиссии сторов). Цена ценностей — фиксирована в Фишках, одинакова для всех методов.
  • D3. Изоляция — схема payments в общем инстансе Postgres + доменная граница (свой store/service/интерфейс) + отдельный DB-роль (права только на payments). Атомарность «Фишки↔бенефит» с backend.accounts сохраняется (cross-schema tx в одном инстансе). Отдельный процесс/БД отвергнуты: ломают атомарность выдачи бенефита (бенефиты живут на accounts), цена распределённых транзакций не окупается. Вынос в отдельный сервис оставлен как возможность на потом (граница уже чистая).
  • D4. Сохранность — PITR (непрерывный WAL-архив, pgBackRest/WAL-G на 2-й хост или объектное хранилище). Тёплая реплика — опция на потом. Durability решается этим, а не топологией БД.
  • D5. Сегмент кошелька — по source (vk / telegram / direct) на аккаунте. Баланс = (account_id, source), ровно 3 сегмента. Не per-identity.
  • D6. Комплаенс-гейт — логический, аккаунт единый. email/direct физически привязан (мерж, единый профиль/друзья/статистика), но в VK/TG-контексте сервер активирует только одноимённый сегмент; чужое (в первую очередь direct) внутри VK/TG невидимо и не тратится. Гейт держится на доверенном сигнале платформы (проставляет гейтвей из подписанного контекста, не тело запроса — клиенту не верим).
  • D7. Три операции разведены: (1) пополнение Фишек по контексту; (2) трата Фишек = покупка бенефита, по контексту с гейтом (в VK только vk; в вебе direct+vk+tg по приоритету direct→vk→tg); (3) применение бенефита во времени.
  • D8. origin бенефита = контекст ПОКУПКИ (не «чем оплачено»). Правило послабления: origin∈{vk,tg} → действует везде (в сторе и наружу в web/native); origin=direct → только web/native (внутрь VK/TG нельзя = бан). Срок «без рекламы» хранится per-origin (paid_until на origin, покупки одного origin складываются/ сдвигают дату); «реклама выключена в контексте P» = есть применимый в P origin с paid_until > now (в вебе берём максимум direct/vk/tg, в VK — только vk, в TG — tg).
  • D9. «Без рекламы» гасит верхний баннер + fullscreen-ролик после хода. Добровольный rewarded (за Фишки) не трогаем.
  • D10. UX-требование: при трате tg/vk-Фишек в вебе — предупредить юзера ДО покупки, что купленная механика будет доступна только здесь (web/native), т.к. ограничения VK/TG.
  • D11. Три сущности разведены: Фишки (валюта, сегмент по source = где пополнено), Подсказки (расходник, сегмент по origin = где куплено), «Без рекламы» (срок, сегмент по origin). source и origin — одна ось значений {vk,tg,direct}, но разная семантика; в вебе source и origin расходятся (vk-Фишки → direct-покупка).
  • D12. Подсказки — по origin с обратным послаблением (vk/tg→везде, direct→только web/native), как «без рекламы». Списание в онлайн-игре = из origin, применимого в контексте; переписать SpendHint (game/service.go:1147) на контекст-аварное списание. vs_ai подсказки остаются бесплатными/безлимитными (не в счёт).
  • D13. accounts.hint_balance выводим expand-contract (сначала игнор, потом DROP); legacy-баланс обнуляем (в проде даров не было).
  • D14. Unlink vk/tg — разрешён, сегмент усыпляется. Сегмент доступен ⟺ на аккаунте есть identity этого source (direct ⟺ есть устойчивый identity/email). Отвязка не сжигает баланс/бенефит — усыпляет; re-link будит. Перед отвязкой — предупреждение «N Фишек станут недоступны до повторной привязки». Последнюю identity отвязать нельзя (существующий ErrLastIdentity).
  • D15. Мерж — слияние по origin: одноимённые сегменты складываются (Фишки sum, сроки бенефита продлеваются per-origin), разные сосуществуют. Прямое расширение текущего hint_balance += / paid_account OR=; origin сохраняется, ничего не протекает между платформами.
  • D16. Админ-награждение: админ начисляет только конкретные ценности (без рекламы / подсказки), никогда не Фишки (подаренный баланс валюты = обход кассы стора). Админ выбирает origin при выдаче (ответственность за комплаенс на нём: origin=vk точечно/малый объём = низкий риск, origin=direct = безопасно). Грант = транзакция типа admin_grant в едином леджере, цена 0 Фишек — полный аудит наград.
  • D17. Доверенная платформа = свойство сессии, переподтверждается свежей подписью (VK sign / TG initData) при каждом холодном старте (не однократно при входе — обёртка всё равно шлёт подпись каждое открытие). direct фиксируется фактом создания веб/native-сессии (внешней подписи нет и не нужно — доступ к vk/tg-сегментам в direct всё равно требует реальной привязки, D14). Backend получает platform при резолве сессии. Платформа несёт kind (vk/tg/direct) + подтип (ios/android/web) — подтип обязателен (VK iOS заморожен). TG initData-валидатор уже есть (platform/telegram/internal/initdata). АМЕНД (E6, находка владельца по ToS): VK iOS — заморозка только ПОКУПОК (деньги→Фишки), а не траты. Apple запрещает там лишь покупать внутриигровые ценности за любую валюту; а тратить Фишки VK-кошелька (заработанные rewarded-рекламой или купленные на том же VK-аккаунте в Android) и зарабатывать их — легально и на iOS. Код: vkFrozen() гейтит только CreateOrder (покупку), не spendableSources (трату).
  • D18. Fail-closed: недоверенная/неподтверждённая платформа (VK/TG-сессия без валидной подписи на старте; старая сессия без записанной платформы) → запрет трат/покупок/применения чужого origin, только просмотр.
  • D19. Дистрибуция native Android: RuStore (Robokassa разрешён, чистый direct) + Google Play (direct-покупки скрыты — на «Кошельке» заглушка «установите версию из RuStore для покупок»; rewarded-реклама и трата уже накопленных Фишек работают). Перед GP-релизом свериться с актуальными правилами Google по внутренней валюте.
  • D20. Приём оплаты — только серверный колбэк провайдера. Фишки начисляются лишь по проверенному (подпись/HMAC) серверному уведомлению: Robokassa webhook / TG successful_payment / VK callback. Клиентское «я оплатил» игнорируется.
  • D21. Единый payments-домен — единственный писатель леджера. Публичные вебхуки (Robokassa/VK) терминируются на edge (Caddy/gateway) и проксируются в payments; TG successful_payment → бот → payments. Один источник начисления/идемпотентности.
  • D22. Order-flow с предзаказом. Сервер создаёт order(pending) с account/ платформой/пакетом/ожидаемой суммой/origin; order_id прокидывается провайдеру (Robokassa InvId / TG invoice_payload / VK item — точную форму VK уточнить при интеграции). Колбэк матчится по order_id (не по сумме → коллизия сумм невозможна), сверяет сумму, начисляет, помечает paid. Идемпотентность — дедуп по (провайдер, provider_payment_id).
  • D23. Pending невидим юзеру; авто-expiry по таймауту (~30 мин — гигиена БД). Но валидный колбэк исполняется ВСЕГДА, даже на истёкшем order (expired ≠ отмена — деньги реальны, обязаны выдать). Юзер видит только успешные покупки.
  • D24. Экран «Кошелёк» — минимальный: балансы Фишек (доступные в контексте) + активные бенефиты (без рекламы до даты, счётчик подсказок). Без ленты истории (шумит, отвлекает от игры). Полная история — только в админке.
  • D25. TG-бот outbox — SQLite на диске бота. Store-and-forward: получил successful_payment → сохранил в SQLite → подтвердил апдейт Telegram → форвардит в payments (идемпотентно, дедуп по telegram_payment_charge_id) → ack → forwarded. Ретраи с backoff, дореталивание при рестарте. At-least-once доставка + идемпотентный приём = начисление ровно один раз.
  • D26. Событийный слой payment_events (succeeded/failed/refunded) + диспетчер по каналам: live gRPC-стрим (если юзер в аппе), иначе botlink/email-relay (существующие). «Оплата не прошла» = активный отказ провайдера (не брошенный pending) — доводится до юзера. «Оплата прошла» — хук (письмо/сообщение в бота).
  • D27. Возвраты. ToS «невозвратно» — юзеру возврат не предлагаем. Админ может сделать ручной возврат (исключение: юзер требует вскоре после оплаты / закрывает аккаунт — связать с accountdelete, где уже правило «сообщения не трогаем»). Внешние возвраты (чарджбек / решение стора / TG / VK) — система принимает событие refunded, по возможности отзывает бенефит (в минус НЕ уходим; если Фишки потрачены — фиксируем убыток + флаг защиты от злоупотреблений), пишет в журнал. Журнал операций проектируем экспортопригодным (будущая налоговая отчётность + авто-сверка с Robokassa) — реализацию сверки пока не делаем, схему закладываем совместимой.
  • D28. Стартовый охват видео-рекламы — только VK (рублёвый доход, ОРД автоматом, API готов). web/native/TG — существующий текст-баннер. Рекламный провайдер закладываем абстракцией (будущая крутилка для других платформ встроится без переделки). Крипто-провайдеры (AdsGram/AdMob) отвергнуты — нет легального рублёвого дохода самозанятому (санкции + НПД не учитывает крипту).
  • D29. Rewarded (добровольный ролик за Фишки) начисляет Фишки через payments. Не гасится «без рекламы». Сколько Фишек за просмотр — в блоке экономики. АМЕНД (E6, по факту реализации): у VK Mini App серверного verify-колбэка нетVKWebAppShowNativeAds отдаёт только клиентский data.result. Поэтому начисление client-attested, а защита (и экономический рычаг) — серверный дневной и часовой кап (config reward_daily_cap / reward_hourly_cap, дефолт 50 / 10) + идемпотентность по клиентскому nonce. Сеть с серверным verify встроится за ads-абстракцией позже.
  • D30. Частота навязанного interstitial (конфигурируемые серверные значения): глобальный кулдаун на юзера сквозь все партии, дефолт 5 мин; vs_ai — 30 мин (соосно кулдауну подсказок). Применение подсказки триггерит ролик после хода независимо от основного кулдауна, со своим кулдауном 1 мин. Оффлайн — только баннер. Уважать собственные лимиты частоты VK. АМЕНД (E6, по факту реализации): гейт зеркальный — сервер отдаёт кулдауны и suppressed в профиле (adsFor), клиент сам гейтит по времени последнего показа на каждый вид в localStorage (без раунд-трипа на ход). Ролик показывается только после подтверждённого хода или подсказкине после пропуска, обмена или сдачи (за не-очковое действие рекламой не «награждаем»). Interstitial — только VK (как и rewarded).
  • D31. paid_account тоже deprecated → удаление из схемы (как hint_balance), в пользу per-origin бенефитов «без рекламы». Существующий ads.Eligible (backend/internal/ads/ads.go:107) расширяется: баннер гасится по origin-бенефиту, применимому в текущем контексте, а не по одному глобальному флагу. Legacy paid_account/hint_balance в проде никем не выставлены (потока покупки не было) → обнуляем/игнорируем, после релиза платежей дропаем. АМЕНД (E6, по факту реализации — expand-contract, шаг 1 «contract-код»): доменное использование обеих колонок убрано — поля Account.HintBalance / Account.PaidAccount, их скан, мёртвый account.SpendHint, account.GrantHints и админ-действие «grant-hints» (роут /_gm/users/:id/grant-hints, форма, UserDetailView.HintBalance/ PaidAccount); отображение подсказок в игре теперь всегда из payments (HintsAvailable), профильный баланс — из payments-бенефита. Колонки БД пока оставлены (без миграции — откат образа DB-safe); их DROP — отдельным contract-PR, когда E6 стабилен на проде.
  • D32. Каталог — конфигурируемый (БД + админка). Базовые ценности (атомы начисления): Фишки, подсказки, дни-без-рекламы, участие-в-турнире. Продукт = набор атомов + цена (по одной ценности или комбо). «Пакет Фишек» — цена per-метод (мультивалютная: Голоса/Stars/руб — один продукт, D2). «Ценность за Фишки» — цена в Фишках (единая). Курсы покупки Фишек и Фишки за rewarded — тоже в каталоге.
  • D33. Стекинг «без рекламы»: paid_until[origin] += срок от max(now, текущий конец) (остаток не теряется, «плюсуются» как в документе). «Навсегда» — отдельный вечный флаг (перекрывает сроки). Бонусы «(+50)» — маркетинговая пометка владельца; юзеру показываем финальную цифру, в модели просто количество.
  • D34. Деактивация, не удаление. Продукты soft-delete (деактивация). Покупка хранит снимок проданного (состав атомов + цена на момент) → архив. Для истории, чеков, налоговой — покупка не зависит от дальнейших правок/деактивации каталога.
  • D35. Антифрод rewarded — только серверный verify провайдера на старте (без своего дневного потолка). Провайдер-абстракция позволит добавить лимиты позже.
  • D36. Email/direct. У гостей раздел «Кошелёк» скрыт полностью; баланс и покупки — только у durable-аккаунтов. В direct email обязателен перед первой покупкой (якорь восстановления доступа; в VK/TG якорь — сама vk/tg-привязка). Флоу email уже есть (запрос кода → подтверждение → ClearGuest → durable).
  • D37. Reaper — гостей чистит свободно: у гостя баланса нет by design (Кошелёк скрыт, покупок нет, direct-rewarded нет). Спец-защита не нужна.
  • D38. Гостевые ограничения — ОТДЕЛЬНЫЙ этап монетизации (воронка к регистрации). Набор: макс 1 активная игра со случайным + 1 vs_ai; серверный guest-гейт на friend-request / redeem-code / invitation-create. Находка: сейчас гейт только в UIsocial/friends.go:50 (SendFriendRequest), robotfriends.go:41 (RequestInGame), friendcodes.go:67 (RedeemFriendCode), lobby/invitations.go:208 (CreateInvitation) не проверяют is_guest. Это изменение поведения игры → свой этап со своими тестами.
  • D39. Хранение — неизменяемый журнал + материализованный баланс. Журнал операций append-only (только INSERT, никогда UPDATE/DELETE — полный аудит, №3). Баланс сегмента (account, source) и бенефиты (account, origin) — быстрый материализованный кэш, обновляется в той же транзакции, что и запись журнала; пересчитывается из журнала для сверки.
  • D40. Финансовый отчёт per-user в /_gm — балансы сегментов, платежи, траты, гранты, возвраты, полная история — расширение существующей карточки (UserDetailView, handlers_admin_console.go:343). Плюс экспорт журнала (D27).
  • D41. Налоги/чеки — авто через провайдера. Robokassa (direct) — авточек НПД (режим самозанятого). VK — сам процессит Голоса через налоговую (владельцу делать нечего). TG Stars — налоговой стороны нет (для РФ-самозанятого невыводимы легально = не доход по НПД; принимаем, чек не формируем). ОРД по VK-рекламе — на стороне VK. (Не юридическая консультация — точную схему НПД владелец сверяет с налоговой стороной.)

Заметки к оформлению документов

  • Язык документов (решено). PAYMENTS.mdна английском, терминами как в коде и общепринятыми (ledger, refund, idempotency, order, …). Рядом PAYMENTS_ru.md — перевод на простой русский владельца. Паттерн FUNCTIONAL.md + FUNCTIONAL_ru.md, уже принятый в репозитории; мирроринг правок — в том же PR.
  • Язык диалога с владельцем — по-русски, без калек-англицизмов («леджер»→«журнал операций»). Сохранить как feedback-память после выхода из plan mode.
  • Формат интервью (владелец флагнул дважды). ВСЕ вопросы владельцу — только через интерактивное интервью (AskUserQuestion). НИКОГДА не выносить вопросы в текст ответа, даже «мелкие» или «да/нет»: смешение текстовых и интерактивных вопросов раздражает и ведёт к пропускам. Текст — только для фиксации решённого и пояснений. Усилить feedback-память prefer-interview-mode после plan mode.

Все развилки закрыты (D1-D41)

Интервью завершено. Дальше — оформление документов и реализация по релизам.

План внедрения (черновик PLAN.md — «слоями»)

Владелец выбрал слоёную стратегию: сначала вся механика без реальных денег (обкатка через admin_grant), затем монетизация (все рельсы + реклама вместе).

Релиз 1 — механика без денег (обкатка через admin_grant)

  • E0. Фундамент данных payments. Схема + DB-роль + jetgen-таргет + миграции. Таблицы: журнал операций (append-only, D39), балансы сегментов (account, source), бенефиты (account, origin) (paid_until/forever + подсказки count), каталог продуктов (D32, soft-delete D34), заказы orders (D22), payment_events (D26). Доменный пакет с жёсткой границей (D3). Старт deprecate hint_balance/paid_account (expand).
  • E1. Доверенный сигнал платформы. Platform в сессии (kind+подтип), переподтверждение на холодном старте (VK sign / TG initData), fail-closed (D17-D18); gateway прокидывает platform в backend.
  • E2. Ядро валюты + бенефитов. Баланс Фишек; трата Фишек → бенефит атомарно (D7); гейт по контексту (D6-D8); применение per-origin (без рекламы стекинг D33, подсказки D12); переписать SpendHint на контекст-аварное списание; миграция/обнуление legacy (D13, D31). Обкатка начислений через admin_grant (D16).
  • E3. Кошелёк UI. Раздел в SettingsHub (между Друзья/Инфо), балансы + бенефиты (минимальный, D24), витрина каталога, скрыт у гостей (D36), GP-заглушка (D19), предупреждение при трате vk/tg в вебе (D10).

Релиз 2 — монетизация (рельсы + реклама вместе)

  • E4. Durability (PITR). WAL-архив (pgBackRest/WAL-G, D4) — ДО первого реального приёма денег.
  • E5. Приём платежей. Order-flow (D22); единый payments-домен принимает колбэки (D21); Robokassa + VK + TG (бот-outbox на SQLite, D25); идемпотентность; payment_events
    • диспетчер уведомлений (D26); чеки НПД (D41); внешние + ручные возвраты (D27).
  • E6. Реклама. VK interstitial (частота-конфиг D30) + VK rewarded (verify→Фишки D29, D35); ads.Eligible расширение per-origin (D31). Провайдер-абстракция (D28).
  • E7. Админка / отчёты. Финансовый отчёт per-user в /_gm (D40); UI ручного возврата; экспорт журнала (D27).

Отдельный этап (изменение поведения игры)

  • E8. Гостевые ограничения. Серверный guest-гейт друзей/приглашений + лимиты игр (1 случайная + 1 vs_ai, D38). Свои тесты; можно вести параллельно.

Будущее

  • E9. Турнирный взнос. Тип-ценность заложен в E0; механика позже.

Финальные артефакты

  1. PAYMENTS.md (англ) + PAYMENTS_ru.md (рус) — первыми, из Decisions Log D1-D41; поддерживать далее (мирроринг в том же PR, как FUNCTIONAL).
  2. PLAN.md — из раздела «План внедрения» выше, с критериями готовности на этап.
  3. Реализация по релизам, начиная с E0.

Verification

Каждый этап — своим слоем (docs/TESTING.md): unit (гейт по контексту, стекинг сроков, курсы, идемпотентность-ключи); integration (Postgres-backed атомарные транзакции «Фишки↔бенефит», идемпотентность колбэков, бот-outbox доставка); UI (Кошелёк, предупреждения, гость-скрыт, GP-заглушка); контурный прогон на development перед промоушеном. Локальная полная проверка перед пушем (интеграция + UI + codegen). Прод: expand-contract миграции (rollback-safe), PITR армирован до первого приёма денег. Комплаенс-гейт — обязательная регрессия (direct-бенефит не активируется в VK/TG).