Files
scrabble-game/docs/PAYMENTS_DECISIONS_ru.md
T
Ilia Denisov eef90a152e
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m0s
feat(payments): per-channel Robokassa shops on the direct rail
Split the single Robokassa direct rail into one merchant shop per channel
(web / android; ios later), chosen by the trusted X-Platform subtype, while
every shop still credits the one `direct` wallet — merchant-account separation
for accounting and receipts, not a new wallet.

- config: a channel-keyed shops registry (web seeds from the legacy vars or
  _WEB_*, android from _ANDROID_*); an empty shop leaves the rail dormant;
  per-shop validation.
- intake: the order picks its shop by subtype (unknown falls back to web); the
  per-shop Result callback is verified by that shop's own Password2 at
  /pay/robokassa/result/<channel> (the gateway extracts the channel; Caddy's
  /pay/* glob already forwards it — no Caddyfile change).
- persistence: an additive `shop` column on the order (migration 00015),
  recorded from the payment context, surfaced per entry in the admin report.
- standalone apps sign in by email only, so a direct purchase keeps its email
  anchor.
- docs: PAYMENTS (+ru) topology, deploy env vars + compose mapping, the
  decisions log (D41 revised for the ИП / 54-ФЗ move; D42-D44), the plan.

Contour-safe: dormant until shops are configured; the migration is additive
(no wipe); no client wire change. Fiscalization (Receipt/Email) and the gateway
`direct/android` subtype follow when the ИП / RuStore are live.
2026-07-14 14:57:34 +02:00

355 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Монетизация 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`
(без раунд-трипа на ход). Таймер **общий на все виды** — вид (`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-бенефиту,
**применимому в текущем контексте**, а не по одному глобальному флагу. 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 — тоже в каталоге. (Ставка
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) — фискальный чек по **54-ФЗ** через облачную кассу Robokassa
(провайдер как фискальный агент): чек формирует касса, не банк (банковский слип об оплате —
отдельный документ, не фискальный чек). Настраивается владельцем в ЛКК Robokassa
(касса + ОФД + СНО). Код — опционально: слать детализированный `Receipt` + `Email`
покупателя (берём подтверждённый email-якорь D36) → чек с точным названием пакета и ставкой
по СНО; иначе — обобщённый дефолт-чек из кабинета. `Receipt` входит в подпись
(`MerchantLogin:OutSum:InvId:Receipt:Пароль#1`, URL-кодируется), одна позиция влезает в
текущий GET-redirect (POST-форма — фолбэк на длину URL). Источник чеков один — ИП/касса,
независимо от числа магазинов Robokassa. **VK** — процессит Голоса через налоговую сам.
**TG Stars** — вне рублёвого фискального контура (принимаем, чек не формируем). ОРД по
VK-рекламе — на стороне VK. *(Не юрконсультация — схему по 54-ФЗ/СНО владелец сверяет с
бухгалтером.)*
- **D42. Direct-рельс — несколько магазинов Robokassa = маршрутизация merchant-аккаунтов по
каналу при едином кошельке.** Кошелёк `direct` остаётся один. Отдельные магазины Robokassa
(web, android; ios — позже) различаются только кредами / Return-URL / учётом и **все
зачисляют в сегмент `direct`**. Модель кошельков, стенка трат и origin бенефитов не меняются.
Магазин выбирается по трастовому сигналу канала (`X-Platform` вида `<kind>/<subtype>`:
`direct/web`, `direct/android`), а не по подделываемому клиентскому полю; неизвестный подтип
→ магазин `web` (безопасный дефолт). Зачисление от выбора магазина не зависит (всегда
`direct`), поэтому ошибка маршрутизации влияет максимум на учёт, не на деньги.
- **D43. Standalone-приложения (Android, iOS) — вход только по email; покупка требует
email-якорь.** В нативной сборке доступны только guest + email: VK ID-логин (full-page
redirect на `id.vk.com`) не возвращается в Capacitor, TG Login Widget в WebView ненадёжен —
это уже действующая реальность сборки, не новое ограничение. Direct-покупка требует
подтверждённого email-якоря (D36) — он же адрес фискального чека (D41); гость не покупает.
Отдельный сегмент `apple` НЕ заводим: iOS-standalone — тот же `direct`-контекст, внешний гейт
(Robokassa) фондирует единый `direct`. Сегмент `apple` со стенкой (по образцу vk/tg)
понадобился бы только при Apple IAP (StoreKit) — отложено до решения по iOS; и identity-kind
`apple→direct`, и отдельный сегмент — аддитивны, без риска, делаются на месте.
- **D44. Канал платежа хранится на заказе для раздельного учёта/отчёта.** Заказ получает поле
`shop` (web/android/…) — аддитивная колонка. Используется в финансовом отчёте (D40) для
разбивки «из какого магазина/канала платёж». На зачисление и на сегмент кошелька не влияет
(всегда `direct`, D42).
## Заметки к оформлению документов
- **Язык документов (решено).** `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-D44)
Интервью завершено. Дальше — оформление документов и реализация по релизам.
**Дополнение 2026-07-14 (интервью с владельцем).** D41 ревизована (владелец переходит на
ИП: 54-ФЗ через облачную кассу Robokassa вместо авточека НПД); добавлены D42-D44 — сплит
`direct`-рельса Robokassa на магазины по каналу (web/android; ios позже) при едином кошельке
`direct` и входе email-only в standalone-приложениях. Реализация — новый этап E10 в `PLAN.md`.
## План внедрения (черновик 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).