1507ceb793
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m0s
Let an operator disable purchases live from the admin — a whole rail/channel or one account — and show the user a localized reason on their next attempt, so a provider outage or a misconfig is explained instead of a silent dead button. - rail kill switch (payments.rail_status, per rail direct:web / direct:android / vk / telegram): enabled + a per-language message, edited on the catalog page. Fail-open — a rail with no row stays enabled, so payments are never accidentally killed. The intake gate (CanPurchase in handleWalletOrder, before the order) returns payment_unavailable + the localized message, orthogonal to the security gates. - per-account override (payments.account_payment_override, a row only for non-default): allow / deny / default, edited on the user card. "allow" bypasses ONLY the ops rail switch, never the security gates (trusted platform, the email anchor, the VK-iOS freeze, the min client version). - wire: an additive ExecuteResponse.message envelope field (frozen-contract-safe); the gateway forwards a backend domain-error message; the client shows it on a payment_unavailable buy attempt. - admin: rail toggles on the catalog page, the override control on the user card. - tests: the pure gate (unit, TDD), the store + gate + override end-to-end (integration, migration 00016), the client (svelte-check / vitest). - docs: PAYMENTS (+ru), the decisions log (D45/D46). Fiscalization stays cabinet-side (owner decision) — no itemized-receipt code. Contour-safe: additive migration (two new tables, no wipe), the wire add is additive, and fail-open so nothing is disabled until an operator acts.
407 lines
38 KiB
Markdown
407 lines
38 KiB
Markdown
# 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.
|
||
|
||
**Мультимагазинный 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).
|
||
|
||
**Рубильник платежей (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. Приём платежей
|
||
|
||
**Только серверный колбэк провайдера.** Фишки начисляются лишь по **проверенному**
|
||
(подпись/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).
|