Split the single Robokassa direct rail into one merchant shop per channel (web / android; ios later), chosen by the trusted X-Platform subtype, while every shop still credits the one `direct` wallet — merchant-account separation for accounting and receipts, not a new wallet. - config: a channel-keyed shops registry (web seeds from the legacy vars or _WEB_*, android from _ANDROID_*); an empty shop leaves the rail dormant; per-shop validation. - intake: the order picks its shop by subtype (unknown falls back to web); the per-shop Result callback is verified by that shop's own Password2 at /pay/robokassa/result/<channel> (the gateway extracts the channel; Caddy's /pay/* glob already forwards it — no Caddyfile change). - persistence: an additive `shop` column on the order (migration 00015), recorded from the payment context, surfaced per entry in the admin report. - standalone apps sign in by email only, so a direct purchase keeps its email anchor. - docs: PAYMENTS (+ru) topology, deploy env vars + compose mapping, the decisions log (D41 revised for the ИП / 54-ФЗ move; D42-D44), the plan. Contour-safe: dormant until shops are configured; the migration is additive (no wipe); no client wire change. Fiscalization (Receipt/Email) and the gateway `direct/android` subtype follow when the ИП / RuStore are live.
37 KiB
PAYMENTS — механики монетизации
Русское зеркало PAYMENTS.md (английская версия — основная). Описывает
домен монетизации: игровую валюту, кошельки, ценности, правила комплаенса сторов, приём
платежей, рекламу, каталог, журнал операций и отчёты. Каждую правку PAYMENTS.md зеркалим
сюда в том же PR (как FUNCTIONAL.md/FUNCTIONAL_ru.md).
Читать перед любым изменением платёжного поведения. Технический план внедрения —
../PLAN.md.
Статус: спецификация утверждена; внедрение поэтапно (см.
PLAN.md). В проде пока ничего из этого нет.
1. Обзор
Игра зарабатывает двумя независимыми каналами:
- Покупки игровой валюты «Фишка» за реальные деньги, затем трата Фишек на ценности (бенефиты).
- Реклама — ролик за награду пополняет Фишки; полноэкранный ролик и наш баннер зарабатывают показами.
Валюта двухуровневая:
деньги (Голоса VK / Stars TG / рубли через Robokassa) ─┐
├─► Фишки ──► ценности
просмотр ролика за награду ─┘ (без рекламы,
подсказки,
турнирный взнос)
Фишки — единая единица витрины. Деньги и просмотры пополняют Фишки; Фишки покупают ценности. Прямой оплаты ценности деньгами нет — всегда через Фишки.
2. Модель валюты
Одна Фишка равна одной Фишке везде — единица единая. Разница между методами оплаты целиком в курсе покупки: пакет Фишек стоит X Голосов / Y Stars / Z рублей, и курс учитывает комиссию каждого стора. Цены ценностей фиксированы в Фишках и одинаковы для всех методов.
Баланс Фишек сегментирован по «источнику» (source) — платформе, где Фишки пополнены:
source |
Чем пополняется |
|---|---|
vk |
Покупка за Голоса VK, ролик за награду в VK |
telegram |
Покупка за Stars TG |
direct |
Покупка через Robokassa (web / native) |
Один аккаунт держит все три сегмента одновременно: баланс = (account_id, source), до трёх
записей — запись материализуется лениво при первом пополнении сегмента, отсутствующий сегмент
читается как ноль. Сегментация не по привязке (identity) — см. §6.
Почему сегментировано, а не один общий баланс: правила сторов запрещают активировать
ценность, оплаченную мимо их кассы. Фишки, пополненные внутри VK (Голоса), тратятся только
в контексте VK; Stars — только внутри Telegram; Фишки от Robokassa (direct) — только вне
сторов. См. §4.
Мультимагазинный direct-рельс (D42). Рельс direct маршрутизирует в отдельный магазин
Robokassa на канал — web и android (RuStore), позже ios — по трастовому подтипу
X-Platform; каждый магазин зачисляет в единый кошелёк direct (отдельных кошельков на канал нет).
Это разделение merchant-аккаунтов только для учёта / чеков: заказ хранит свой shop (виден в
админ-отчёте), а Result-колбэк каждого магазина проверяется своим Password2 по
/pay/robokassa/result/<channel>. В standalone-приложениях (Android/iOS) вход только по email, поэтому
у direct-покупки всегда есть email-якорь D36 (D43). Фискализация (54-ФЗ через кабинет Robokassa под
одним ИП) — один источник независимо от числа магазинов (D41).
3. Три операции, которые нельзя путать
Не смешивать — у них разные ключи:
- Пополнить Фишки — деньги/реклама → сегмент
sourceФишек, по контексту исполнения. - Потратить Фишки = купить ценность — сегмент → бенефит, по контексту с гейтом (§4).
Здесь бенефит рождается и помечается своим
origin. - Применить бенефит во времени (напр. «без рекламы до 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/ TelegraminitData) на каждом холодном старте, поэтому их платформа переподтверждается при каждом запуске;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. Приём платежей
Только серверный колбэк провайдера. Фишки начисляются лишь по проверенному
(подпись/HMAC) серверному колбэку — Robokassa webhook / TG successful_payment / VK
callback. Клиентское «я оплатил» игнорируется.
Единственный писатель. Один платёжный домен — единственный, кто пишет в журнал операций.
Публичные вебхуки (Robokassa/VK) терминируются на краю (Caddy/gateway) и проксируются в
платёжный домен; TG successful_payment приходит боту, тот форвардит в платёжный домен.
Одно место начисляет и защищает от повторов.
Флоу заказа. Сервер заранее создаёт order(pending) с account / платформой / пакетом /
ожидаемой суммой / origin. order_id прокидывается провайдеру (Robokassa InvId / TG
invoice_payload / VK item — точную форму VK уточнить при интеграции). Колбэк матчится по
order_id (никогда по сумме, поэтому коллизии одинаковых сумм невозможны), сверяет сумму,
начисляет, помечает paid. Защита от повторов: дедуп по (провайдер, provider_payment_id).
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): ни один рельс не шлёт непрошеный возврат — Robokassa через refund-API / ЛК
(авто-опрос статуса — отложенный воркер, при низком объёме чарджбеков не оправдан), 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 — балансы сегментов, платежи, траты,
гранты, возвраты, полная история — расширение существующей карточки (UserDetailView,
handlers_admin_console.go). Плюс экспорт журнала.
12. Налоги и комплаенс
Чеки формируются автоматически на стороне провайдера и отличаются по каналу:
- Robokassa (direct) — чек НПД самозанятого при оплате.
- VK — VK сам процессит Голоса через налоговую; делать нечего.
- TG Stars — налоговой стороны нет (для РФ-самозанятого Stars легально невыводимы = не доход НПД; принимаем, чек не формируем).
ОРД (маркировка рекламы) по VK-рекламе — на стороне VK. (Не юридическая консультация — владелец сверяет точную схему НПД с налоговым консультантом.)
13. Дистрибуция (native Android)
- RuStore — Robokassa/внешний гейт разрешён (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) — неизменяемая запись всех операций с деньгами/ценностями.
- платёжный канал / рельса — платёжный провайдер (Robokassa / Голоса VK / Stars TG).