e3c2e80a0a
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Failing after 24s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
The direct rail runs on НПД, where the provider neither files with the tax service nor issues a receipt — so nobody was doing it. This registers each rouble purchase, annuls its receipt on a refund, and hands the buyer the receipt by email. Two properties of the (unofficial) lknpd API shape the design. Registering an income takes no idempotency key, so an error does not mean nothing happened: the service name is frozen before the call and carries a marker from the tail of the order id, and after a failure the taxpayer's income list is searched for that exact name. Found means filed; not found halts the queue for a human, because declaring an income twice is as wrong as not declaring it. And faults are classified rather than logged: a token is renewed silently, a throttle backs off, an outage retries, but three unfixable rejections take the rail out of service — a changed format must not become thousands of requests overnight. The console button and the worker share one RunBatch. Automatic mode is armed from the console, not from configuration, so the operator can watch a run go through by hand first. A daily watchdog runs whether or not it is armed, since the case it exists for is the export being off. An idle queue issues no call at all — not even an authentication. No payment path changed: the purchase letter rides the existing payment-event outbox on its own cursor, the receipt and annulment letters ride the export row. Decisions D53-D60.
564 lines
59 KiB
Markdown
564 lines
59 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 / рубли через Ю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).
|