# 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).