# PAYMENTS — механики монетизации

Русское зеркало [`PAYMENTS.md`](PAYMENTS.md) (английская версия — основная). Описывает
домен монетизации: игровую валюту, кошельки, ценности, правила комплаенса сторов, приём
платежей, рекламу, каталог, журнал операций и отчёты. Каждую правку `PAYMENTS.md` зеркалим
сюда в том же PR (как `FUNCTIONAL.md`/`FUNCTIONAL_ru.md`).

Читать перед любым изменением платёжного поведения. Технический план внедрения —
[`../PLAN.md`](../PLAN.md).

> Статус: спецификация утверждена; внедрение поэтапно (см. `PLAN.md`). В проде пока ничего
> из этого нет.

## 1. Обзор

Игра зарабатывает двумя независимыми каналами:

- **Покупки** игровой валюты **«Фишка»** за реальные деньги, затем трата Фишек на
  **ценности** (бенефиты).
- **Реклама** — ролик за награду пополняет Фишки; полноэкранный ролик и наш баннер
  зарабатывают показами.

Валюта **двухуровневая**:

```
деньги (Голоса VK / Stars TG / рубли через ЮKassa)  ─┐
                                                        ├─►  Фишки  ──►  ценности
просмотр ролика за награду                             ─┘                (без рекламы,
                                                                          подсказки,
                                                                          турнирный взнос)
```

Фишки — единая единица витрины. Деньги и просмотры **пополняют** Фишки; Фишки **покупают**
ценности. Прямой оплаты ценности деньгами нет — всегда через Фишки.

## 2. Модель валюты

**Одна Фишка равна одной Фишке везде** — единица единая. Разница между методами оплаты
целиком в **курсе покупки**: пакет Фишек стоит *X* Голосов / *Y* Stars / *Z* рублей, и курс
учитывает комиссию каждого стора. Цены ценностей фиксированы **в Фишках** и одинаковы для
всех методов.

Баланс Фишек **сегментирован по «источнику» (`source`)** — платформе, где Фишки пополнены:

| `source`   | Чем пополняется                    |
|------------|------------------------------------|
| `vk`       | Покупка за Голоса VK, ролик за награду в VK |
| `telegram` | Покупка за Stars TG                |
| `direct`   | Покупка через ЮKassa (web / native) |

Один аккаунт держит все три сегмента одновременно: `баланс = (account_id, source)`, до трёх
записей — запись материализуется лениво при первом пополнении сегмента, отсутствующий сегмент
читается как ноль. Сегментация **не** по привязке (identity) — см. §6.

Почему сегментировано, а не один общий баланс: правила сторов запрещают активировать
ценность, оплаченную мимо их кассы. Фишки, пополненные внутри VK (Голоса), тратятся только
в контексте VK; Stars — только внутри Telegram; Фишки от ЮKassa (`direct`) — только вне
сторов. См. §4.

**Мультимагазинный direct-рельс (D42).** Рельс `direct` маршрутизирует в отдельный магазин
ЮKassa **на канал** — `web` и `android` (RuStore), позже `ios` — по трастовому подтипу
`X-Platform`; каждый магазин зачисляет в единый кошелёк `direct` (отдельных кошельков на канал нет).
Это разделение merchant-аккаунтов только для учёта / чеков: заказ хранит свой `shop` (виден в
админ-отчёте). Точка приёма уведомлений у всех магазинов одна — `/pay/yookassa/notify`; входящее
уведомление привязывается к магазину по идентификатору магазина, который сообщает сам платёж
(`recipient.account_id`), с откатом на канал, записанный в заказе, и подтверждающее чтение (§9)
выполняется кредами именно этого магазина. В standalone-приложениях (Android/iOS) вход только по
email, поэтому у direct-покупки всегда есть email-якорь D36 (D43) — он же адрес доставки чека.
Фискализация (54-ФЗ под одним ИП) — один источник независимо от числа магазинов (D41).

**Robokassa законсервирована, но не удалена (D47).** До ЮKassa она обслуживала рельс `direct`. Код,
тесты и проводка остаются в дереве, и рельс откатывается на неё, если ни один магазин ЮKassa не
настроен, — поэтому возврат к ней это смена кредов, а не правка кода; сегодня эти креды не задаёт ни
один контур. `backend/internal/robokassa/README.md` хранит список выведенных переменных, настройки
кабинета и порядок возврата рельса. Строки журнала, записанные при её жизни, сохраняют
`provider = 'robokassa'` — этот литерал несущий для индекса идемпотентности, его не переименовывают и
не переиспользуют.

**Рубильник платежей (D45/D46).** Оператор выключает покупки на рельсе/канале (`direct:web` /
`direct:android` / `vk` / `telegram`) или для одного аккаунта, живьём из `/_gm`, и юзер на следующей
попытке видит локализованную причину (`payment_unavailable`, едет на аддитивном
`ExecuteResponse.message`). **Fail-open:** рельс без строки статуса — включён. Per-account `allow`
обходит только этот ops-рубильник, не security-гейты (D46).

## 3. Три операции, которые нельзя путать

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

1. **Пополнить Фишки** — деньги/реклама → сегмент `source` Фишек, по **контексту
   исполнения**.
2. **Потратить Фишки = купить ценность** — сегмент → бенефит, по контексту с гейтом (§4).
   Здесь бенефит **рождается** и помечается своим `origin`.
3. **Применить бенефит** во времени (напр. «без рекламы до *T*») — по правилу `origin`
   (§5).

`source` (где пополнены Фишки) и `origin` (где куплена ценность) используют одно множество
значений `{vk, telegram, direct}`, но значат разное. В вебе они расходятся: покупка в вебе
может списать `vk`-Фишки (`source=vk`) в `direct`-покупку (`origin=direct`).

## 4. Гейт комплаенса сторов

Стена комплаенса **односторонняя**. Опасное направление — активация оплаченной снаружи
ценности *внутри* обёртки стора — заблокировано; безопасное (бенефит стора действует
наружу, в открытом вебе) — разрешено.

**Контекст траты → какие сегменты/бенефиты доступны:**

| Контекст исполнения  | Тратимые сегменты Фишек   | Приоритет траты    |
|----------------------|---------------------------|--------------------|
| Внутри VK (Android)  | `vk`                      | —                  |
| Внутри VK (iOS)      | `vk` (заморожена покупка) | —                  |
| Внутри Telegram      | `telegram`                | —                  |
| Web / native (Direct)| `direct` + `vk` + `telegram` | direct → vk → tg |

- Внутри VK/TG доступен только одноимённый сегмент; всё остальное (в первую очередь
  `direct`) там невидимо как тратимое.
- В вебе у стора нет юрисдикции, поэтому доступны все привязанные сегменты, списываются по
  приоритету direct → vk → tg.
- **VK iOS — заморозка ПОКУПОК, а не траты.** ToS Apple запрещает там только **покупать**
  внутриигровые ценности (за любую валюту) — поэтому покупка (деньги → Фишки) отклоняется. А
  **тратить** Фишки VK-кошелька (заработанные рекламой или купленные на том же VK-аккаунте, напр. в
  VK Android) и зарабатывать их — легально и на VK iOS разрешено; купленный бенефит там тоже
  *действует*. Блокируется только шаг «деньги внутрь».

Аккаунт **единый** (привязки сливаются, один профиль/друзья/статистика). Гейт
**логический**: в контексте VK/TG сервер активирует только одноимённый сегмент. Держится на
**доверенном сигнале платформы** (§8) — клиенту не верим. Когда платформу нельзя доверенно
установить, гейт **fail-closed**: траты/покупки запрещены, только просмотр.

### Одностороннее применение бенефита

Бенефит несёт `origin` = **контекст покупки** (не «чем оплачено»). Покупка в вебе за
`vk`-Фишки всё равно даёт `origin=direct`.

| `origin`         | Где действует бенефит                              |
|------------------|----------------------------------------------------|
| `vk` / `telegram`| **Везде** — внутри своего стора *и* наружу в web/native |
| `direct`         | **Только** web/native — никогда внутри VK/TG (= бан) |

Перед тратой в вебе `vk`/`telegram`-Фишек интерфейс **предупреждает**, что ценность будет
доступна только здесь (web/native) из-за ограничений VK/TG.

## 5. Ценности

Три разные сущности, не смешивать:

- **Фишки** — валюта. Сегмент по `source`.
- **Подсказки** — расходник, покупается за Фишки. Сегмент по `origin`. Тратятся по одной в
  онлайн-играх; в `vs_ai` подсказки бесплатны/безлимитны (30-мин кулдаун, не в счёт).
  Подсказка в игре списывается из `origin`, применимого в текущем контексте (то же
  одностороннее послабление).
- **Без рекламы** — срок-бенефит, покупается за Фишки. Сегмент по `origin`.

**Складывание «без рекламы».** Покупка срока продлевает `paid_until[origin] += срок` от
`max(сейчас, текущий конец)` — остаток не теряется («сроки плюсуются»). **Навсегда** —
отдельный вечный флаг, перекрывает сроки. «Реклама выключена в контексте *P*» ⇔ есть
применимый в *P* `origin` с `paid_until > сейчас` (в вебе берём максимум по direct/vk/tg; в
VK только vk; в TG только tg).

**Что гасит «без рекламы»:** верхний баннер **и** полноэкранный ролик после хода.
Добровольный **ролик за награду** (за Фишки) не гасится — это выбор пользователя.

**Турнирный взнос** — будущий тип ценности; атом заложен, механика позже.

## 6. Жизненный цикл кошелька

**Правило доступности сегмента.** Сегмент тратим ⇔ на аккаунте есть привязка этого
`source` (для `direct` — устойчивая привязка/email). Отсюда unlink/мерж выводятся
естественно.

**Отвязка (vk/tg).** Разрешена даже при ненулевом балансе/активном бенефите. Сегмент не
сжигается — он **засыпает** (нет привязки ⇒ недоступен в VK *и*, без привязки, недоступен
как подтянутый веб-сегмент); повторная привязка будит. Перед отвязкой предупреждение
(«N Фишек станут недоступны до повторной привязки»). Последнюю привязку отвязать нельзя
(существующий `ErrLastIdentity`).

**Мерж.** Сегменты и бенефиты сливаются **по origin**: одноимённые складываются (Фишки
суммируются, сроки бенефита продлеваются по origin), разные сосуществуют. Это расширяет
текущий мерж аккаунтов (`hint_balance +=`, `paid_account OR=`), так что origin сохраняется и
ничего не протекает между платформами.

**Гость.** У гостевого аккаунта **вообще нет кошелька** — раздел «Кошелёк» скрыт, покупок
нет, баланса нет, by design. Балансы — только у durable-аккаунтов. Поэтому чистильщик
гостей удаляет их свободно (денег там быть не может). В `direct` email обязателен **перед
первой покупкой** как якорь восстановления (в VK/TG якорь — сама vk/tg-привязка);
переиспользуем существующий флоу email (запрос кода → подтверждение → снятие флага гостя →
durable).

## 7. Каталог и цены

Каталог **конфигурируемый** — продукты, цены, курсы покупки и награда за ролик живут в базе
и правятся в админке, без релиза.

- **Базовые ценности (атомы):** Фишки, подсказки, дни без рекламы, участие в турнире.
- **Продукт = набор атомов + цена.** Продаётся по одной или комбо (напр. «250 подсказок +
  30 дней без рекламы»).
- **Пакет Фишек** (пополняет Фишки) — цена **per-метод** (мультивалютная Голоса/Stars/руб)
  одним продуктом.
- **Ценность** (за Фишки) — цена **в Фишках** (единая для всех методов).

**Деактивация, не удаление.** Продукты деактивируются (soft-delete). Состоявшаяся покупка
хранит **снимок** проданного (состав атомов + цена на момент) в архиве, чтобы
история/чеки/налоги не зависели от последующих правок каталога.

## 8. Доверенный сигнал платформы

Гейту (§4) нужен **доверенный, неподделываемый** контекст платформы на сервере. Клиент —
никогда не источник правды.

- Платформа — **свойство сессии**, фиксируется при создании сессии. Обёртки VK и Telegram
  пересоздают сессию (и потому заново проверяют подпись запуска — VK launch-params `sign` /
  Telegram `initData`) на **каждом холодном старте**, поэтому их платформа переподтверждается
  при каждом запуске; `direct`-сессия фиксирует платформу один раз, самим фактом создания
  веб/native-сессии (внешней подписи нет и не нужно — доступ к vk/tg-сегментам в direct-контексте
  всё равно требует реальной привязки, §6).
- Платформа несёт **kind** (`vk`/`telegram`/`direct`) **плюс подтип** (`ios`/`android`/`web`).
  `kind` доверенный всегда — сервер выводит его из проверенного запуска, не из клиентского поля.
  **Подтип доверенный только у VK**: он лежит внутри подписанных параметров запуска, что и делает
  **заморозку VK iOS** выполнимой; у Telegram и direct подтип сообщает клиент, он best-effort, и
  гейт на него не опирается.
- Гейтвей резолвит сессию и передаёт `platform` в бэкенд (рядом с существующим `X-User-ID`),
  беря его из сессии — не из тела клиентского запроса.
- **Fail-closed:** недоверенная платформа — сессия без записанной платформы, созданная до этой
  функции или которую гейтвей не смог атрибутировать — запрещает траты/покупки и применение
  любого чужого origin (только просмотр). VK/TG-сессия восстанавливается на следующем холодном
  старте (пересоздание), переиспользуемая direct/email-сессия — при повторном входе.

## 9. Приём платежей

**Только серверный колбэк провайдера.** Фишки начисляются лишь по **проверенному** серверному
колбэку — уведомление ЮKassa / TG `successful_payment` / VK callback. Клиентское «я оплатил»
игнорируется. Что значит «проверенный», зависит от рельса: VK и Telegram подписывают свои колбэки,
а **ЮKassa — нет** (D48), см. подтверждающее чтение ниже.

**Единственный писатель.** Один платёжный домен — единственный, кто пишет в журнал операций.
Публичные вебхуки (ЮKassa/VK) терминируются на краю (Caddy/gateway) и проксируются в
платёжный домен; TG `successful_payment` приходит боту, тот форвардит в платёжный домен.
Одно место начисляет и защищает от повторов.

**Флоу заказа.** Сервер заранее создаёт `order(pending)` с account / платформой / пакетом /
ожидаемой суммой / origin. `order_id` прокидывается провайдеру (ЮKassa `metadata.order_id` / TG
`invoice_payload` / VK `item`). Колбэк матчится по `order_id` (никогда по сумме, поэтому коллизии
одинаковых сумм невозможны), сверяет сумму, начисляет, помечает `paid`. **Защита от повторов:**
дедуп по `(провайдер, provider_payment_id)`.

**Direct-рельс (ЮKassa).** Открытие покупки — исходящий вызов API: сервер создаёт платёж
(`POST /v3/payments`, `capture: true`, подтверждение через redirect, идентификатор заказа и как
`Idempotence-Key`, и как `metadata.order_id`, плюс фискальный чек из §12) и отправляет покупателя на
полученный `confirmation_url`. Идентификатор платежа сразу записывается в заказ — именно он позволяет
потом перепроверить заказ и оформить возврат. Страница возврата браузера (`/pay/yookassa/return`)
косметическая: начисление никогда не едет на редиректе.

**Уведомление — подсказка, а не доказательство (D48).** ЮKassa не подписывает уведомления, поэтому
действовать по содержимому тела нельзя. Оно лишь называет платёж, который сервер затем перечитывает
запросом `GET /v3/payments/{id}`; доверяем только этому ответу. На нём же держатся две проверки:
метаданные платежа должны называть тот самый заказ, а его флаг `test` — совпадать с флагом магазина,
поэтому платёж тестового магазина никогда не начислит настоящие Фишки (и наоборот). Ответ сообщает
провайдеру, повторять ли доставку: 200 на всё, что решено окончательно, включая дубль и неустранимый
отказ, и 5xx только на временный сбой (ЮKassa повторяет доставку 24 часа).

Адрес отправителя **не проверяем** (ревизия D48). Безопасности это не добавляет — вся граница доверия
в подтверждающем чтении, — а единственное, что такая проверка давала (чтобы подделыватель не
превращал каждое фальшивое уведомление в наш исходящий запрос), уже обеспечено раньше и жёстче: заказ
находится по метаданным **до** любого обращения к провайдеру, поэтому идентификатор, которому не
соответствует ни один заказ, стоит одного чтения по индексу и на этом всё, а угадать живой order_id —
значит угадать uuid. Вдобавок проверка адреса вредна везде, где развёртывание не видит настоящих
адресов клиентов (контур за туннелем видит только свой), — там она отбивает настоящие уведомления.

**Сверка с коротким шагом (ревизия D49).** Бесконечного опроса нет, но проверка **не привязана** к
времени жизни заказа: оно отвечает на вопрос «сколько покупателю позволено думать», а не «как быстро
мы должны заметить потерянный колбэк». Связав их, мы заставили бы покупателя ждать Фишки всё время
жизни заказа всякий раз, когда ломается доставка уведомлений. Вместо этого жнец спрашивает провайдера
о каждом незакрытом заказе старше минуты, у которого есть идентификатор платежа, и начисляет реально
оплаченные. Число запросов на один заказ при этом ограничено его временем жизни, делённым на шаг
жнеца, — единицы, — а сбой основного пути стоит минут, а не получаса.

**Об отклонённой оплате сообщаем.** `payment.canceled` от ЮKassa пишет событие `failed`, поэтому
покупатель узнаёт, что попытка не прошла, вместо разглядывания неменяющегося баланса. Просто
брошенный заказ не пишет ничего — он молча истекает.

**Pending невидим** пользователю; авто-истекает по таймауту (~30 мин, гигиена базы).
Валидный колбэк исполняется **всегда**, даже на истёкшем заказе (`expired` ≠ отмена —
деньги реальны, Фишки должны быть выданы). Пользователь видит только успешные покупки.

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

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

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

**Возвраты.** ToS — **невозвратно**, пользователю возврат не предлагаем. Возвраты **инициирует
админ** (консоль E7). На direct-рельсе консоль делает всю работу одним нажатием (D50): сперва
вызывает refund-API ЮKassa (`POST /v3/refunds`, `Idempotence-Key` — идентификатор заказа, с чеком
возврата из §12) и записывает реверс только после того, как деньги действительно ушли, — неудачный
вызов не пишет **ничего**, поэтому журнал не может заявить о возврате, которого не было. Возврат
записывается **только в статусе `succeeded`**: ещё не завершённый (`pending`) может отмениться, а
журнал только на добавление, поэтому ранняя запись отобрала бы у покупателя Фишки за деньги, оставшиеся у
нас. Выход — нажать ещё раз: ключ идемпотентности вернёт тот же возврат, а не заплатит дважды.
Записывается собственный refund-id провайдера: по нему журнал сверяется с данными ЮKassa.

**Кабинет — вторая точка входа (D52).** В отличие от прежних рельсов, ЮKassa позволяет оформить
возврат прямо в кабинете магазина, и такой возврат не проходит через наш API — деньги ушли бы назад,
а Фишки остались бы начисленными. Это закрывает уведомление `refund.succeeded`: возврат
перечитывается из API (тело уведомления — такое же не-доказательство, как и у платежа), привязывается
к заказу через идентификатор платежа, который мы записали, и проводится тем же движком —
идемпотентно по `(провайдер, refund-id)`, поэтому уведомление о возврате, уже записанном консолью,
ничего не отзывает повторно. Движок по устройству работает **только с полным возвратом** (отзывает
ровно то, что профондировал пакет, и отвергает любую другую сумму), поэтому **частичный** возврат не
записывается вовсе и громко логируется для оператора: непроизвольного способа решить, скольких Фишек
стоит часть возврата, нет.

Два пути записи при этом безобидно гоняются: ЮKassa шлёт `refund.succeeded` сразу после создания
возврата, поэтому уведомление часто записывает реверс раньше, чем это успевает сделать сама консоль.
Оба называют один и тот же refund-id, поэтому вторая запись отсекается индексом идемпотентности и
ничего не отзывается дважды; консоль при этом сообщает об успешном возврате, а не о повторном нажатии.

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

## 10. Реклама

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

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

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

- Глобальный кулдаун **на пользователя, сквозь все партии**, дефолт **5 мин**.
- **`vs_ai` — 30 мин** (соосно кулдауну подсказок, чтобы не отпугивать казуалов).
- Применение **подсказки** триггерит ролик **независимо** от основного кулдауна, со своим
  кулдауном **1 мин**.
- Показывается **только после подтверждённого хода или подсказки** — никогда после пропуска,
  обмена или сдачи (ролик за не-очковое действие только раздражает, за них не «награждаем»).
- Оффлайн — только баннер.
- Уважать собственные лимиты частоты VK.

**Оффлайн без рекламы**, кроме нашего баннера. Бенефит `без рекламы` гасит баннер через
существующий `ads.Eligible` (`backend/internal/ads/ads.go`), который расширяется до гейта по
**origin-бенефиту, применимому в текущем контексте**, а не по одному глобальному флагу.

## 11. Админ, аудит, отчётность

**Неизменяемый журнал операций + материализованный баланс.** Журнал операций **только на
INSERT** (никогда UPDATE/DELETE — полный аудит). Балансы сегментов `(account, source)` и
бенефиты `(account, origin)` — быстрый **материализованный** кэш, обновляется **в той же
транзакции**, что и запись журнала, и пересчитывается из журнала для сверки.

**In-process кэш чтения.** Поверх материализованных таблиц пакет payments держит
in-process кэш сегментов и бенефитов по ключу-аккаунту (write-through), чтобы горячие пути
чтения — проверка показа рекламы, доступность подсказок, экран кошелька, гейт траты — на
устоявшемся пути **не** делали ни одного запроса к схеме `payments`. Кэш инвалидируется на
каждой изменяющей операции payments (трата / грант / пополнение / возврат / мерж) и
перечитывается из материализованных таблиц при промахе (тот же write-through-паттерн, что и у
гейта блокировок аккаунта). Один инстанс — под текущий деплой; многоинстансный backend
потребовал бы общего кэша. Присутствие identity (какие сегменты «не спят», §6) передаёт
вызывающий, здесь не кэшируется, поэтому отвязка/повторная привязка действует сразу.

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

**Раздел журнала** `/_gm/ledger` — общий вид на деньги по всем аккаунтам: все операции, новые
сверху, с фильтрами по диапазону дат (по умолчанию последние 30 дней), по **кошельку**
(`vk`/`telegram`/`direct` — совпадение по пополненному сегменту или по origin бенефита), по
**рельсу** (провайдер, который провёл платёж, поэтому траты Фишек в такой фильтр не попадают вовсе),
по виду операции и по аккаунту. Над таблицей — итоги по **всему, что попало под фильтр**, а не по
странице: пришло и возвращено денег по каждой валюте отдельно (рельсы считают в рублях, Голосах и
Stars, общая сумма по ним не значила бы ничего) и начислено/списано Фишек. Суммы в строках берутся из
снимка операции, потому что собственные колонки журнала считают Фишки, а не деньги. Пагинация,
выгрузка в CSV и возврат оператора после refund несут одну и ту же строку фильтров, поэтому ни один
из них не может незаметно показать срез, отличный от экрана.

**Кнопка возврата** живёт на строках пополнения здесь же — оператор может найти платёж фильтрами, не
зная заранее, чей он, — и после возврата возвращает на тот же отфильтрованный вид.

**Карточка пользователя** показывает положение дел, а не историю: балансы сегментов, бенефиты, флаг
риска и краткую сводку за всё время (сколько заплачено и возвращено по валютам, сколько Фишек
начислено и потрачено), плюс ссылку в раздел журнала, уже отфильтрованный по этому аккаунту.
Операции рисуются в одном месте, с фильтрами и страницами, а не в двух.

## 12. Налоги и комплаенс

Чеки формируются автоматически **на стороне провайдера** и отличаются по каналу:

- **ЮKassa** (direct) — **чек не передаём, так задумано (ревизия D51)**. Владелец работает на
  **НПД**, а это вне 54-ФЗ: онлайн-кассы нет, провайдер ничего не регистрирует. О каждой операции
  владелец сообщает в **«Мой налог»**, он и формирует чек. ЮKassa при таком режиме налогообложения с
  чеками не работает вовсе.

  Фискальный код **законсервирован, а не удалён**: «Чеки от ЮKassa» (касса, фискальный накопитель и
  договор с ОФД на стороне ЮKassa) регистрируют чек **только если запрос его несёт**, и вся сборка
  такого запроса спрятана за одним переключателем — `BACKEND_YOOKASSA_VAT_CODE`. Пусто — `receipt` не
  собирается и не отправляется; задан код ставки по 54-ФЗ (тег 1199) — снова уходит одна позиция
  (название пакета, количество 1, сумма) с признаком предмета расчёта `service` (тег 1212) и способа
  расчёта `full_payment` (тег 1214), доставка на email-якорь D36. Переключатель нужен потому, что
  возврат к чекам предсказуем: у НПД есть годовой потолок дохода, и потеря режима возвращает 54-ФЗ.

  Отправка каждой рублёвой операции в «Мой налог» автоматизирована — см. ниже.
- **VK** — VK сам процессит Голоса через налоговую; делать нечего.
- **TG Stars** — налоговой стороны нет (для РФ-самозанятого Stars легально невыводимы = не
  доход НПД; принимаем, чек не формируем).

**ОРД** (маркировка рекламы) по VK-рекламе — на стороне VK. (Не юридическая консультация —
владелец сверяет точную схему НПД с налоговым консультантом.)

### Выгрузка в «Мой налог» (D53-D60)

Рублёвый доход прямого рельса регистрируем в налоговой мы, потому что больше некому: провайдер при
этом режиме ни отчёта не подаёт, ни чека не формирует. Охват — `kind='fund'`,
`provider='yookassa'`, валюта `RUB`; магазинные рельсы вне охвата, так как журнал операций хранит их
цену в валюте магазина и рублёвой суммы там нет вообще.

**Где живёт.** `backend/internal/mynalog` — клиент API (без базы), `backend/internal/mynalogsync` —
оркестрация, `payments.mynalog_receipt` — состояние по каждому приходу, `/_gm/mynalog` — экран
оператора. Кнопка в консоли и фоновый воркер зовут один и тот же `RunBatch`; второй реализации той
части, которая решает, что значит неудавшаяся регистрация, сознательно не существует. Без
`BACKEND_MYNALOG_KEY` рельс спит целиком.

**API неофициальный** — `lknpd.nalog.ru`, опубликованного контракта нет, тестовой среды нет. Отсюда
два следствия, пронизывающих всю конструкцию:

- **Регистрация дохода не идемпотентна.** Ключа идемпотентности нет, поэтому ошибка не означает, что
  ничего не произошло. Название услуги замораживается в базе **до** запроса и несёт маркер из
  **хвоста** идентификатора заказа (старшие разряды UUIDv7 — миллисекундные часы, они одинаковы у
  всех заказов одной минуты). После сбоя список доходов ищется по этому точному названию: нашли —
  значит зарегистрировано, не нашли — **исход неизвестен**, и очередь встаёт до решения человека,
  потому что альтернативы — задвоить доход или потерять его. По той же причине один прогон за раз,
  на advisory-локе Postgres.
- **Сбои классифицируются, а не просто логируются.** 401 обновляем молча; 429 — отступаем; 5xx и
  таймаут — повторим позже, письмо только через сутки; любой другой 4xx повтором не лечится, и три
  подряд снимают рельс с эксплуатации — смена формата не должна превращаться в тысячи запросов за
  ночь. Превышение годового лимита НПД выделено отдельно: это уже не техническая ошибка, а потеря
  режима.

**Время.** Каждый момент отправляется в часовом поясе налогоплательщика (`BACKEND_MYNALOG_TZ`, по
умолчанию `Europe/Moscow`): смещение решает, в какой налоговый месяц попадёт околополуночный платёж.
В чеке стоит дата поступления денег, а не дата выгрузки. Автоматический режим просыпается раз в 15
минут и при пустой очереди не делает ничего — в том числе не авторизуется. Отдельный суточный
надзиратель работает **независимо** от автоматического режима и эскалирует незакрытый прошлый месяц
1-го, 5-го и 9-го числа. (Ст. 14 ч. 3 ФЗ-422 даёт отсрочку до 9-го только для расчётов, **не**
связанных с электронными средствами платежа; оплата картой — ЭСП, поэтому чек строго в момент
расчёта. Автоматический режим удовлетворяет обеим трактовкам.)

**Ручной фолбэк.** `/_gm/mynalog.csv` отдаёт ровно то, что было бы отправлено — время, название
услуги, сумму — плюс идентификатор строки журнала, чтобы номер выданного вручную чека можно было
внести обратно на странице. Именно этот номер оставляет ручной приход аннулируемым при возврате,
поэтому спрашивается он, а не галочка «сделано».

**Возвраты.** У налоговой нет понятия возврата: аннулирование чека **и есть** возврат. Возврат,
пришедший раньше, чем приход был выгружен, закрывается локально как `not_required` — ничего не
регистрируем, ничего не аннулируем, наружу не ходим. Частичный возврат вне охвата с обеих сторон
(см. раздел про возвраты).

**Покупатель узнаёт трижды**, по-русски, из долговечных очередей, а не из платёжного пути:
подтверждение покупки (в котором прямо сказано, что это **не** фискальный чек и что чек придёт
отдельно), сам чек со ссылкой на печатную форму и — после возврата — уведомление об аннулировании.
Первое едет по существующему outbox'у `payments.payment_events` на собственном курсоре `mailed_at`,
два других — по колонкам строки выгрузки. Адрес — тот самый подтверждённый email-якорь D36, который
прямая покупка требует и так.

**Учётные данные.** Логин и пароль от кабинета вводятся в консоли, используются один раз и нигде не
хранятся: на диск кладётся только полученный refresh-токен, запечатанный AES-GCM на
`BACKEND_MYNALOG_KEY` (наши собственные случайные 32 байта, а не секрет налоговой) — чтобы он не
уезжал в дампе базы. Потеря ключа — неудобство, а не потеря: оператор входит заново.

## 13. Дистрибуция (native Android)

- **RuStore** — внешний платёжный гейт разрешён (0%); native = чистый контекст `direct`.
- **Google Play** — direct-покупки **скрыты**; «Кошелёк» показывает заглушку («установите
  версию из RuStore для покупок»). Ролик за награду и трата уже накопленных Фишек работают.
  Перед GP-релизом свериться с актуальными правилами Google по внутренней валюте.

## 14. Модель данных (схема `payments`)

Платёжный домен живёт в **своей схеме `payments`** в общем инстансе Postgres, со своим
DB-ролём (права только на `payments`) и доменным пакетом за жёстким интерфейсом.
**Cross-schema внешнего ключа** к `backend.accounts` **нет** — идентификатор аккаунта здесь
обычное значение, согласуемое в коде и связываемое с tombstone-аккаунтом / досье
retained-identities по стабильному id. Это держит трату «Фишки↔бенефит» атомарной **внутри
`payments`** и делает домен извлекаемым в свою базу. Сохранность — **PITR** (непрерывный
WAL-архив), независимо от топологии базы.

Основные таблицы (финальные имена/колонки — в `PLAN.md`):

- **журнал операций (ledger)** — append-only операции: пополнение / трата / `admin_grant` /
  возврат; `(провайдер, provider_payment_id)` уникален для защиты от повторов;
  экспортопригоден.
- **балансы** — материализованные `(account_id, source) → Фишки`.
- **бенефиты** — материализованные `(account_id, origin)` → «без рекламы»
  `paid_until`/`forever` + счётчик подсказок.
- **каталог** — атомы + продукты (деактивируемые), цены per-метод, курсы покупки Фишек,
  награда за ролик.
- **заказы (orders)** — pending-покупки, `order_id`, ожидаемая сумма, origin, статус
  (pending/paid/expired).
- **payment_events** — succeeded/failed/refunded для диспетчера.

Legacy `accounts.hint_balance` и `accounts.paid_account` **устаревают** в пользу
сегментированной модели и удаляются в отдельной **contract-миграции** (expand-contract, после
того как валютное ядро переключит чтения, и после Release 2 — откат образа остаётся безопасным
для БД); ни то, ни другое в проде никогда не выставлялось (потока покупки не было), поэтому
legacy-значения обнуляются.

## 15. Словарь

- **Фишка** — игровая валюта; единая единица, сегментирована по `source`.
- **source (источник)** — где пополнены Фишки (`vk`/`telegram`/`direct`).
- **origin (происхождение)** — где куплена ценность; определяет, где действует бенефит.
- **ценность / бенефит** — то, что покупается за Фишки (без рекламы, подсказки, турнирный
  взнос).
- **гейт** — одностороннее правило комплаенса сторов (§4).
- **журнал операций (ledger)** — неизменяемая запись всех операций с деньгами/ценностями.
- **платёжный канал / рельса** — платёжный провайдер (ЮKassa / Голоса VK / Stars TG).
