Files
scrabble-game/docs/PAYMENTS_ru.md
T
Ilia Denisov 1c06d1d0d1
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
feat(payments): chip wallet, store-compliance gate and benefit application
Stand up the internal chip/benefit mechanic behind the narrow payments interface:
context-aware balances and benefits, an atomic chip spend, admin grants as
zero-price value sales, the one-directional store-compliance gate (VK/TG same-
origin only, web draws direct→vk→tg, VK-iOS frozen, untrusted fail-closed), and
per-origin hint and no-ads application with term stacking. Reads are served from
an in-process, account-keyed write-through cache (mirroring the suspension gate),
so hot paths issue no query to the payments schema.

Flip the online-game hint wallet and the ad-banner suppression from the deprecated
accounts.hint_balance / paid_account columns to the payments benefit (a hint
balance no longer suppresses the banner — only a no-ads benefit does), and fold
chip segments and benefits by origin on account merge, inside the merge tx. Add
the GET/POST /api/v1/user/wallet edge chain (REST → Connect → FlatBuffers) plus
its codec unit test; no wallet UI yet.

Bring the frozen owner decisions log into the repo at
docs/PAYMENTS_DECISIONS_ru.md (it was untracked under .vscode) and reference it
from PLAN.md; record the read-cache design and the present-sources interface in
PLAN.md and docs/PAYMENTS.md (+ RU mirror).
2026-07-08 06:06:40 +02:00

357 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PAYMENTS — механики монетизации
Русское зеркало [`PAYMENTS.md`](PAYMENTS.md) (английская версия — основная). Описывает
домен монетизации: игровую валюту, кошельки, ценности, правила комплаенса сторов, приём
платежей, рекламу, каталог, журнал операций и отчёты. Каждую правку `PAYMENTS.md` зеркалим
сюда в том же PR (как `FUNCTIONAL.md`/`FUNCTIONAL_ru.md`).
Читать перед любым изменением платёжного поведения. Технический план внедрения —
[`../PLAN.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.
## 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) | нет — заморожено (только просмотр) | — |
| Внутри Telegram | `telegram` | — |
| Web / native (Direct)| `direct` + `vk` + `telegram` | direct → vk → tg |
- Внутри VK/TG доступен только одноимённый сегмент; всё остальное (в первую очередь
`direct`) там невидимо как тратимое.
- В вебе у стора нет юрисдикции, поэтому доступны все привязанные сегменты, списываются по
приоритету direct → vk → tg.
- **VK iOS** заморожен для траты (Apple запрещает тратить виртуальную валюту на цифровые
товары мимо IAP внутри 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. Приём платежей
**Только серверный колбэк провайдера.** Фишки начисляются лишь по **проверенному**
(подпись/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` ≠ отмена —
деньги реальны, Фишки должны быть выданы). Пользователь видит только успешные покупки.
**Outbox TG-бота.** `successful_payment` приходит только боту (Bot API, не Mini App), а
хост бота слабый и может терять связь, поэтому бот — durable-звено. Store-and-forward на
**SQLite** на диске бота: получил → сохранил → подтвердил апдейт Telegram → форвардит в
платёжный домен (идемпотентно, дедуп по `telegram_payment_charge_id`) → ack → пометил
`forwarded`. Ретраи с backoff; дореталивает недоставленное при рестарте. Доставка
at-least-once + идемпотентный приём = начисление ровно один раз.
**События.** Платёжный домен пишет `payment_events` (succeeded / failed / refunded);
диспетчер рассылает по каналам — live gRPC-стрим, если пользователь в аппе, иначе
существующий пуш `botlink` / email. «Оплата не прошла» (**активный** отказ провайдера, не
брошенный pending) доводится до пользователя; «оплата прошла» — хук (письмо / сообщение в
бота).
**Возвраты.** ToS — **невозвратно**, пользователю возврат не предлагаем. Админ может сделать
**ручной** возврат (крайний случай: пользователь требует вскоре после оплаты / закрывает
аккаунт — связано с `accountdelete`, где уже сохраняются сообщения). **Внешние** возвраты
(чарджбек / решение стора / TG / VK) обрабатываются: система принимает событие `refunded`,
**по возможности** отзывает бенефит (в минус не уходим; если Фишки уже потрачены —
фиксирует убыток + флаг защиты от злоупотреблений), пишет в журнал. Журнал операций
**спроектирован экспортопригодным** для будущей налоговой отчётности и сверки с Robokassa
(саму сверку пока не строим; схема остаётся совместимой).
## 10. Реклама
**Охват на старте: только VK** для видео (награда в рублях, ОРД автоматом, API готов).
web/native/TG держат только существующий наш **текст-баннер**; видео отложено до появления
рублёвой in-app сети. Рекламный провайдер за **абстракцией**, чтобы будущая сеть для других
платформ встроилась без переделки. Крипто-сети (AdsGram/AdMob) отвергнуты — нет легального
рублёвого дохода самозанятому (НПД).
**Ролик за награду** (добровольное видео за Фишки) начисляет Фишки через платёжный домен по
**серверному verify-колбэку** сети (клиенту не верим, как платежу). Антифрод на старте — только
verify провайдера (своего дневного потолка пока нет; абстракция позволит добавить лимиты
позже).
**Полноэкранный ролик** (после хода), конфигурируемые серверные значения:
- Глобальный кулдаун **на пользователя, сквозь все партии**, дефолт **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) передаёт
вызывающий, здесь не кэшируется, поэтому отвязка/повторная привязка действует сразу.
**Награждение админом.** Админ начисляет **только конкретные ценности** (без рекламы /
подсказки) — **никогда не Фишки** (подаренный баланс валюты = обход кассы стора). Админ
**выбирает origin** при выдаче (ответственность за комплаенс на нём: `origin=vk`
точечно/малый объём = низкий риск, `origin=direct` = безопасно). Грант — транзакция журнала
типа `admin_grant`, цена 0 Фишек — полный аудит наград.
**Финансовый отчёт по пользователю** в админке `/_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).