# Монетизация 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).
- **D45. Рубильник платежей per-rail + локализованное сообщение.** Оператор выключает покупки на
  рельсе/канале (`direct:web`/`direct:android`/`vk`/`telegram`) живьём из `/_gm` (таблица
  `payments.rail_status`, редактируется на странице каталога). **Fail-open:** нет строки → рельс
  включён (случайно не убить платежи). Выключенный рельс на попытке покупки возвращает
  `payment_unavailable` + сообщение админа на языке юзера (RU/EN; пусто → встроенное «временно
  недоступно»). Сообщение едет клиенту через аддитивное `ExecuteResponse.message` (envelope-слой,
  frozen-contract-safe). Гейт ортогонален security-гейтам.
- **D46. Per-user override покупок (allow/deny/default).** На карточке юзера (`/_gm`) —
  переопределение на аккаунт: `allow` (всегда разрешить), `deny` (всегда запретить), `default` (по
  рельс-рубильнику). Хранится в `payments.account_payment_override` строкой только для не-default
  (нет строки = default; снять = удалить строку). **`allow` обходит ТОЛЬКО ops-рубильник, НЕ
  security-гейты** (D36 email-якорь, VK-iOS-фриз, trusted-платформа, min-client-version) — те
  проверяются отдельно в CreateOrder.
- **D47. Direct-рельс переезжает с Robokassa на ЮKassa; Robokassa консервируется, а не удаляется.**
  Меняется merchant-отношение, не модель кошельков: сегмент `direct`, стенка трат, origin бенефитов,
  мультимагазинность по каналу (D42) и `shop` в заказе (D44) — без изменений. Код Robokassa, её тесты
  и проводка остаются в дереве, а direct-рельс **откатывается на неё**, если ни один магазин ЮKassa не
  настроен, — значит возврат к Robokassa это смена кредов, а не правка кода. Переменные Robokassa
  убраны из CI/compose/деплоя и записаны в `backend/internal/robokassa/README.md` вместе с
  настройками кабинета и порядком возврата. Строки журнала с `provider = 'robokassa'` не трогаем:
  литерал несущий для индекса идемпотентности. Telegram **не трогаем** — там реальными деньгами за
  цифровые товары платить нельзя, рельс остаётся на Stars; префиксы переменных заводим на магазин
  сразу, чтобы будущий магазин (например, android/RuStore) добавлялся аддитивно.
- **D48. Уведомления ЮKassa не подписаны → подтверждающее чтение обязательно.** В отличие от
  Robokassa (подпись Password2) и VK (подпись), ЮKassa **не подписывает** вебхуки. Поэтому тело
  уведомления — только подсказка: оно называет платёж, который сервер перечитывает запросом
  `GET /v3/payments/{id}`, и действует **исключительно** по ответу API. Дополнительно: метаданные
  платежа должны называть тот самый заказ; флаг `test` платежа должен совпадать с флагом магазина
  (платёж тестового магазина не начислит настоящие Фишки, и наоборот). Ответ провайдеру: 200 на всё
  окончательно решённое (включая дубль и неустранимый отказ), 5xx только на временный сбой (ЮKassa
  повторяет 24 часа).
  **Ревизия: адрес отправителя не проверяем.** Изначально сверяли с опубликованными диапазонами
  ЮKassa как эшелонированную защиту. Оказалось лишним и вредным: единственное, что она давала —
  не дать подделывателю превратить фальшивое уведомление в наш исходящий запрос — уже обеспечено
  раньше и жёстче, потому что заказ ищется по метаданным ДО обращения к провайдеру (несуществующий
  order_id стоит одного чтения по индексу; угадать живой — значит угадать uuid). А на тестовом
  контуре, который за туннелем видит только свой внутренний адрес, проверка отбивала настоящие
  уведомления — ровно как IP-баны, сделанные в репозитории prod-only по той же причине.
- **D49. Сверка — без постоянного опроса, но с коротким шагом (ревизия).** «Или» в документации
  ЮKassa адресовано тем, кто не хочет вебхуки; у нас вебхуки основные. Но безвозвратно потерянное
  уведомление оставило бы деньги списанными, а Фишки — не выданными, и молча. Поэтому существующий
  жнец спрашивает провайдера о судьбе незакрытых заказов с идентификатором платежа и начисляет реально
  оплаченные; отдельный воркер не заводим.
  **Ревизия порога:** сперва проверка была привязана к возрасту истечения заказа (30 минут), и это
  была ошибка — время жизни заказа отвечает на вопрос «сколько покупателю позволено думать», а не «как
  быстро заметить потерянный колбэк». На практике это дало покупателю 30 минут ожидания Фишек при
  первом же сбое доставки. Порог развязан: проверяем заказы старше **минуты**, на каждом тике жнеца.
  Ограниченность сохраняется — запросов на заказ не больше, чем время его жизни, делённое на шаг
  жнеца.
- **D50. Возвраты на direct-рельсе — через API ЮKassa, одним действием.** Кнопка возврата в `/_gm`
  сперва двигает деньги (`POST /v3/refunds`, `Idempotence-Key` = идентификатор заказа, с чеком
  возврата) и только потом пишет реверс в журнал; неудачный вызов не пишет **ничего** — журнал не
  может заявить о возврате, которого не было. Реверс пишется **только при статусе `succeeded`**:
  возврат в `pending` ещё может отмениться, а журнал только на добавление, поэтому ранняя запись
  отобрала бы у покупателя Фишки за деньги, оставшиеся у нас; выход — нажать ещё раз, ключ
  идемпотентности вернёт тот же возврат. Записывается собственный refund-id провайдера (сверка с
  данными ЮKassa). VK и TG Stars — как раньше: деньги руками, запись фактом.
- **D52. Обрабатываем `refund.succeeded`: кабинет — вторая точка входа для возврата.** В отличие от
  прежних рельсов, у ЮKassa возврат можно оформить прямо в кабинете магазина, минуя наш API, — тогда
  деньги ушли бы назад, а Фишки остались бы начисленными, и молча. Поэтому подписываемся на событие и
  проводим возврат тем же движком: возврат перечитывается из API (тело уведомления — не доказательство,
  как и у платежа), привязывается к заказу через записанный идентификатор платежа, идемпотентность по
  `(провайдер, refund-id)` делает уведомление о возврате, уже записанном консолью, пустой операцией.
  **Частичный возврат не записываем вовсе** — движок по устройству работает только с полной суммой
  заказа, а непроизвольного способа решить, скольких Фишек стоит часть возврата, нет; вместо записи —
  громкий лог для оператора.
- **D51 (ревизия). Чеки не передаём: владелец на НПД, это вне 54-ФЗ.** Уточнено у поддержки ЮKassa:
  при НПД провайдер с чеками не работает. Онлайн-кассы нет, `receipt` в запросах не отправляется, о
  каждой операции владелец сообщает в **«Мой налог»**, он и формирует чек. Побочный выигрыш: исчезает
  целый класс отказов — некорректный `receipt` был ошибкой API прямо при создании платежа, то есть
  ломал покупку.
  **Код чеков при этом консервируем, а не удаляем** (решение владельца): вся сборка `receipt` живёт за
  одним переключателем `BACKEND_YOOKASSA_VAT_CODE` — пусто (дефолт) значит «не отправлять», код ставки
  по 54-ФЗ (тег 1199) возвращает прежнее поведение: одна позиция, признак предмета расчёта `service`
  (тег 1212), способ расчёта `full_payment` (тег 1214), доставка на email-якорь D36,
  `tax_system_code` не шлём. Возврат к чекам предсказуем: у НПД годовой потолок дохода 2,4 млн ₽, и
  его превышение возвращает 54-ФЗ — тогда это правка переменной, а не кода.
  Связка с «Мой налог» — **отдельная задача**, закрыта решениями D53-D60 ниже.

## Выгрузка в «Мой налог» (интервью с владельцем, 2026-07-28)

- **D53. Охват выгрузки — только рубли ЮKassa.** Выгружаем `kind='fund'`, `provider='yookassa'`,
  валюта `RUB`. VK-Голоса и Telegram Stars вне охвата **не по решению, а по факту**: журнал операций
  хранит их цену в `VOTE`/`XTR` и рублёвой суммы не знает вообще — она приходит выплатой платформы за
  вычетом её комиссии. Плюс VK — юрлицо, то есть ставка 6% и ИНН плательщика в чеке, а это другой тип
  чека. Stars владелец не выводит, поэтому спор о моменте признания дохода по ним не открываем.
  Доход от rewarded-рекламы (`vk_ads`) денег в журнале не имеет и тоже вне охвата.
- **D54. Пароль вводится, хранится только refresh-токен, зашифрованный.** Логин (ИНН) и пароль
  вводятся в форме `/_gm/mynalog`, используются один раз и **никуда не пишутся**; на диск ложится
  только выданный refresh-токен, запечатанный AES-GCM на `BACKEND_MYNALOG_KEY`. Ключ — наши
  собственные случайные 32 байта, к учётным данным налоговой отношения не имеющие; он нужен потому,
  что refresh-токен — долгоживущий доступ к личному кабинету ФНС, и в дампе базы (а бэкапы уезжают
  в S3) ему открытым текстом не место. Потеря ключа не катастрофа: токен перестаёт расшифровываться,
  и консоль просит войти заново. Вариант «логин/пароль в секретах Gitea» отклонён: это пароль от
  налоговой, а не от магазина, и радиус поражения несопоставим.
- **D55. Автоматический режим есть, но включается галочкой в консоли, а не конфигом.** Кнопка и
  воркер зовут один и тот же `RunBatch` — второй реализации нет. Автомат по умолчанию выключен:
  владелец сначала прогоняет рельс руками и убеждается, что чек выглядит правильно, и только потом
  включает. Тик — 15 минут; при пустой очереди прогон **не авторизуется** и наружу не ходит.
  Ограничение прогона — **бюджет времени** (13 минут у воркера, 100 секунд у кнопки), а не счётчик
  чеков: счётчик ничего не защищает, защищают пауза 2 секунды между вызовами и предохранители.
- **D56. Неустановленный исход останавливает очередь.** Регистрация дохода не идемпотентна: ключа
  идемпотентности у API нет, поэтому ошибка не означает, что чек не создан. Название услуги
  замораживается в базе **до** запроса и несёт маркер из **хвоста** идентификатора заказа (старшие
  разряды UUIDv7 — миллисекундные часы, одинаковые у всех заказов одной минуты, и такой маркер сделал
  бы две покупки неразличимыми). После сбоя ищем чек по этому точному названию: нашли — записываем,
  не нашли — статус «неизвестно», и **новые чеки не отправляются**, пока человек не разберёт.
  Аннулирования при этом продолжают идти: они только убирают доход. Один прогон за раз — advisory-лок
  Postgres; строка, застрявшая в `sending` после падения процесса, под локом заведомо осиротевшая и
  переводится в «неизвестно», а не переотправляется.
- **D57. Сбои классифицируются на пять классов.** `401` — молча переавторизуемся (запрос отклонён до
  обработки, поэтому единственный сбой, который можно повторить без зонда); `429` — отступаем; `5xx`
  и таймаут — мягко, письмо только если сервис не поднялся за сутки; прочие `4xx` — жёстко, и три
  подряд снимают рельс с эксплуатации с письмом, потому что смена формата API отклоняет всё подряд и
  без этого превратилась бы в тысячи запросов за ночь; превышение годового лимита НПД выделено
  отдельным классом (оно подчиняет предыдущий, чтобы проверка «это неисправимо?» его не пропустила) —
  это уже не техническая ошибка, а потеря режима.
- **D58. Сумма — полная, дата — фактическая, пояс — налогоплательщика.** В чек идёт то, что заплатил
  покупатель: на НПД расходы не вычитаются, комиссия ЮKassa — расход владельца. Время операции — это
  момент платежа, а не момент выгрузки, и отправляется в поясе `BACKEND_MYNALOG_TZ` (по умолчанию
  `Europe/Moscow`), потому что смещение решает, в какой налоговый месяц попадёт околополуночный
  платёж. Название услуги: `Внутриигровая валюта: "Фишка", NN шт. (ID: xxxxxxxx)`, не длиннее 128
  символов — при усечении жертвуем описанием, маркер неприкосновенен.
- **D59. Надзиратель за фискальным периодом работает всегда.** Отдельный суточный цикл, независимый
  от автоматического режима — именно потому, что случай, ради которого он существует, это выключенный
  или сломавшийся автомат. Эскалация по незакрытому прошлому месяцу: 1-е число — предупреждение, с
  5-го — тревога ежедневно, с 9-го — «срок вышел». Правовая рамка: ст. 14 ч. 3 ФЗ-422 даёт отсрочку
  до 9-го числа только для расчётов, **не связанных** с электронными средствами платежа; оплата
  картой — ЭСП, поэтому строгое прочтение требует чек в момент расчёта. Автоматический режим
  удовлетворяет обеим трактовкам, поэтому спор решать не потребовалось.
- **D60. Покупателю уходят три письма, и ни одно не трогает платёжный путь.** Подтверждение покупки
  (сразу, с прямой оговоркой, что это **не** чек и что чек придёт отдельно), фискальный чек ссылкой
  после регистрации и уведомление об аннулировании после возврата — иначе у покупателя осталась бы
  ссылка на мёртвый чек. Только по-русски, локализация не нужна. Первое письмо едет по уже
  существующему outbox'у `payments.payment_events` на собственном курсоре `mailed_at`, два других — по
  колонкам строки выгрузки; поэтому в коде зачисления и возврата не потребовалось менять ни строки, а
  сбой релея откладывает письмо, а не теряет его. Адрес — подтверждённый email-якорь D36, который
  прямая покупка требует и так. Возврат, случившийся раньше выгрузки, закрывается как `not_required`:
  ни чека, ни аннулирования, ни обращения к сервису.

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

- **Язык документов (решено).** `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-D60)

Интервью завершено. Дальше — оформление документов и реализация по релизам.
D53-D60 добавлены отдельным интервью 2026-07-28 по выгрузке в «Мой налог».

**Дополнение 2026-07-14 (интервью с владельцем).** D41 ревизована (владелец переходит на
ИП: 54-ФЗ через облачную кассу Robokassa вместо авточека НПД); добавлены D42-D44 — сплит
`direct`-рельса Robokassa на магазины по каналу (web/android; ios позже) при едином кошельке
`direct` и входе email-only в standalone-приложениях (этап E10 в `PLAN.md`). Плюс D45-D46 —
рубильник платежей per-rail + локализованное сообщение + per-user override (этап E11). Фискализация
(B4) — кабинетная на стороне Robokassa, itemized-код не делаем (решение владельца).

**Дополнение 2026-07-28 (интервью с владельцем).** Direct-рельс переезжает на **ЮKassa**; добавлены
D47-D51 — консервация Robokassa с откатом по кредам, подтверждающее чтение вместо подписи уведомления,
сверка на истечении заказа, возвраты через API и фискализация через «Чеки от ЮKassa» с отправкой
`receipt` из кода (ревизия D41, этап E12 в `PLAN.md`). Telegram из объёма исключён: реальными
деньгами за цифровые товары в Mini App платить нельзя, рельс остаётся на Stars. Отдельно уточнено:
у ЮKassa **есть** тестовый режим (отдельный тестовый магазин со своими креды и тестовыми картами),
поэтому весь путь проверяется на тестовом контуре без реальных денег.

**Уточнение по возвратам (владелец, 2026-07-28).** D50 дополнена требованием статуса `succeeded`;
добавлена D52 — обработка `refund.succeeded`, потому что кабинет ЮKassa позволяет вернуть деньги
мимо нашего API. Обе правки вошли в тот же PR, что и переход на рельс.

**Уточнение по фискализации (владелец, 2026-07-28).** D51 ревизована: владелец принимает платежи как
ИП на **НПД**, при котором ЮKassa с чеками не работает (подтверждено поддержкой), поэтому `receipt`
не передаётся вовсе, а отчётность идёт через «Мой налог». Код чеков законсервирован за переменной
`BACKEND_YOOKASSA_VAT_CODE` на случай потери режима. Правка вошла в тот же PR.

## План внедрения (черновик 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).
