The "watch for chips" rewarded payout and its per-day / per-hour caps live in the shared payments.config row and had no admin surface — they could only be changed by SQL, contrary to D32 (the rewarded rate is meant to be admin-configurable). Add a "Rewarded ads" form on the /_gm/catalog page: RewardConfig / SetRewardConfig on the service (non-negative validated), a setRewardConfig store writer (the singleton config row), the consoleSetReward handler + POST /_gm/catalog/reward route, and the form pre-filled from the current config. No migration — the columns already exist. The reward rate is a config value, not a sellable catalog atom (so a "no-ads forever" style product is still out of scope by D32/D33). Test: an integration test sets the config, checks the page pre-fill, and refuses a negative value.
34 KiB
Монетизация 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домен + UIAdBanner.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 заморожен). TGinitData-валидатор уже есть (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прокидывается провайдеру (RobokassaInvId/ TGinvoice_payload/ VKitem— точную форму 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, а защита (и экономический рычаг) — серверный дневной и часовой кап (configreward_daily_cap/reward_hourly_cap, дефолт 50 / 10) + идемпотентность по клиентскому nonce. Сеть с серверным verify встроится за ads-абстракцией позже. - D30. Частота навязанного interstitial (конфигурируемые серверные значения):
глобальный кулдаун на юзера сквозь все партии, дефолт 5 мин; vs_ai — 30 мин
(соосно кулдауну подсказок). Применение подсказки триггерит ролик после хода
независимо от основного кулдауна, со своим кулдауном 1 мин. Оффлайн — только
баннер. Уважать собственные лимиты частоты VK.
АМЕНД (E6, по факту реализации): гейт зеркальный — сервер отдаёт кулдауны и
suppressedв профиле (adsFor), клиент сам гейтит по единому времени последнего показа вlocalStorage(без раунд-трипа на ход). Таймер общий на все виды — вид (hint/move/vs_ai) лишь выбирает нужный интервал, поэтому «независимость» подсказочного кулдауна значит лишь более короткий интервал от последнего показа, а не отдельный таймер: hint-ролик и move-ролик не встают подряд (баг раздельных таймеров: после hint-ролика move-таймер оставался нулевым → следующий ход сразу крутил рекламу). Ролик показывается только после подтверждённого хода или подсказки — не после пропуска, обмена или сдачи (за не-очковое действие рекламой не «награждаем»). Interstitial — только VK (как и rewarded). Частота — глобальная на все игры (localStorage на устройство). - D31.
paid_accountтоже deprecated → удаление из схемы (какhint_balance), в пользу per-origin бенефитов «без рекламы». Существующийads.Eligible(backend/internal/ads/ads.go:107) расширяется: баннер гасится по origin-бенефиту, применимому в текущем контексте, а не по одному глобальному флагу. Legacypaid_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 — тоже в каталоге. (Ставка
rewarded + дневной/часовой потолки — строка общего конфига
payments.config, правится в секции «Rewarded ads» на странице каталога админки; это не продаваемый продукт-атом.) - 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. Находка: сейчас гейт только в UI
—
social/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). Старт deprecatehint_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; механика позже.
Финальные артефакты
PAYMENTS.md(англ) +PAYMENTS_ru.md(рус) — первыми, из Decisions Log D1-D41; поддерживать далее (мирроринг в том же PR, как FUNCTIONAL).PLAN.md— из раздела «План внедрения» выше, с критериями готовности на этап.- Реализация по релизам, начиная с E0.
Verification
Каждый этап — своим слоем (docs/TESTING.md): unit (гейт по контексту, стекинг
сроков, курсы, идемпотентность-ключи); integration (Postgres-backed атомарные
транзакции «Фишки↔бенефит», идемпотентность колбэков, бот-outbox доставка); UI
(Кошелёк, предупреждения, гость-скрыт, GP-заглушка); контурный прогон на development
перед промоушеном. Локальная полная проверка перед пушем (интеграция + UI + codegen).
Прод: expand-contract миграции (rollback-safe), PITR армирован до первого приёма денег.
Комплаенс-гейт — обязательная регрессия (direct-бенефит не активируется в VK/TG).