Files
scrabble-game/docs/PAYMENTS_DECISIONS_ru.md
T
Ilia Denisov 395a307eca
CI / changes (pull_request) Successful in 11s
CI / unit (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 29s
CI / ui (pull_request) Successful in 1m27s
CI / conformance (pull_request) Successful in 19s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m52s
feat(payments): reverse refunds issued outside the console
Two holes on the refund path, both found by asking what happens when a refund
does not come from our own `/_gm` button.

The merchant cabinet is a second entry point. An operator can refund there, and
such a refund never passes through our API — so the money went back while the
chips stayed credited, silently. Handle `refund.succeeded`: the refund is
re-read from the API (the notification body is no more evidence here than it is
for a payment), bound back to its order through the payment id recorded when
the payment was minted, and reversed through the same engine. It is idempotent
on (provider, refund id), so the event for a refund the console already
recorded reverses nothing twice.

The reversal engine is full-refund-only by design — it revokes exactly what the
pack funded and rejects any other amount — so a partial refund is recorded as
nothing at all and logged loudly for an operator. There is no non-arbitrary way
to decide how many chips a part-refund costs, and guessing would be worse than
asking a human.

Second hole: a refund can still be canceled while pending, and the ledger is
append-only. Recording on any non-empty refund id therefore risked revoking a
customer's chips for money that stayed with us, with no way to take the row
back. The console now records only a `succeeded` refund and tells the operator
to press again otherwise — the idempotency key returns the same refund rather
than paying twice.

Tests: unit (GetRefund, the refund notification envelope, a non-final status
surfaced to the caller); integration (a cabinet refund is reversed once and a
redelivery is a no-op, the event after a console refund changes nothing, a
partial refund records nothing, an unconfirmed refund reverses nothing, a
pending refund records nothing until it settles and then does).

The suite shares one database and the ledger dedupes refunds globally, so the
fake provider now mints a refund id per payment — a constant id made one test's
refund look like another's duplicate.

Decisions D50 (amended) and D52; the notification subscription list in the
deploy docs gains refund.succeeded.
2026-07-28 09:18:19 +02:00

50 KiB
Raw Blame History

Монетизация scrabble-game — проектирование и внедрение

Context

Игра в проде (erudit-game.ru), мультиплатформенная (VK / Telegram Mini App / web+PWA / native Android+iOS через Capacitor). Владелец (самозанятый, НПД) хочет ввести монетизацию: игровая валюта «Фишка», раздельные кошельки по платформам, покупка бенефитов (без рекламы, подсказки, будущий турнирный взнос), rewarded-реклама как канал пополнения, строгий транзакционный лог и админ-отчёты.

Отправная точка — monetization-prerequisites.md (пожелания владельца). Цель этого процесса: (1) через интервью выстроить чёткую модель, (2) оформить и поддерживать PAYMENTS.md (механики + договорённости), (3) составить PLAN.md (поэтапное внедрение от основ к интеграциям).

Что уже есть в коде (переиспользуем)

  • accounts.hint_balance int (CHECK >= 0) + атомарный SpendHint / GrantHints (backend/internal/account/account.go:551-595) — существующий «кошелёк подсказок», но пополняется только админом, покупки нет.
  • accounts.paid_account bool — пожизненный флаг «без рекламы», уже читается в ads.Eligible(...) (backend/internal/ads/ads.go:107); «no purchase flow yet».
  • Реклама сейчас = внутренний server-driven текст-баннер (ads домен + UI AdBanner.svelte), гасится paid_account / hint_balance>0 / роль no_banner. Rewarded-видео нет нигде.
  • Подсказки — фича готова end-to-end (кнопка, 30-мин gate, vs_ai безлимит).
  • Settings — таб-хаб (ui/src/screens/SettingsHub.svelte), вставка «Кошелёк» между «Друзья» и «Инфо» чистая.
  • Админка /_gm — per-user карточка уже показывает PaidAccount+HintBalance; прецедент денежного действия — POST /_gm/users/:id/grant-hints.
  • Один аккаунт держит VK+TG+email identity разом (kind∈telegram/vk/email/robot); мерж сейчас складывает hint_balance и OR-ит paid_account.
  • Прецедент изолированного сервиса: connector (gRPC), renderer (HTTP-sidecar), platform/telegram. Прецедент public HMAC-подписанного роута (экспорт партии) — образец для приёмника вебхуков.

Greenfield

Валюта «Фишка», реальные рельсы (Stars / Голоса / Robokassa), rewarded-видео, раздельные кошельки, транзакционный леджер, PITR/репликация, серверное знание платформы.

Зафиксированные решения (Decisions Log)

  • D1. Модель валюты — двухуровневая. Деньги (Голоса/Stars/руб) и rewarded-реклама пополняют баланс «Фишек»; за Фишки покупаются ценности (без рекламы, подсказки, турнир). Витрина ценностей — в Фишках.
  • D2. Фишка единая (1 = 1 везде). Разница по методам оплаты сидит в курсе покупки Фишек (пакет стоит X Голосов / Y Stars / Z руб, курс учитывает комиссии сторов). Цена ценностей — фиксирована в Фишках, одинакова для всех методов.
  • D3. Изоляция — схема payments в общем инстансе Postgres + доменная граница (свой store/service/интерфейс) + отдельный DB-роль (права только на payments). Атомарность «Фишки↔бенефит» с backend.accounts сохраняется (cross-schema tx в одном инстансе). Отдельный процесс/БД отвергнуты: ломают атомарность выдачи бенефита (бенефиты живут на accounts), цена распределённых транзакций не окупается. Вынос в отдельный сервис оставлен как возможность на потом (граница уже чистая).
  • D4. Сохранность — PITR (непрерывный WAL-архив, pgBackRest/WAL-G на 2-й хост или объектное хранилище). Тёплая реплика — опция на потом. Durability решается этим, а не топологией БД.
  • D5. Сегмент кошелька — по source (vk / telegram / direct) на аккаунте. Баланс = (account_id, source), ровно 3 сегмента. Не per-identity.
  • D6. Комплаенс-гейт — логический, аккаунт единый. email/direct физически привязан (мерж, единый профиль/друзья/статистика), но в VK/TG-контексте сервер активирует только одноимённый сегмент; чужое (в первую очередь direct) внутри VK/TG невидимо и не тратится. Гейт держится на доверенном сигнале платформы (проставляет гейтвей из подписанного контекста, не тело запроса — клиенту не верим).
  • D7. Три операции разведены: (1) пополнение Фишек по контексту; (2) трата Фишек = покупка бенефита, по контексту с гейтом (в VK только vk; в вебе direct+vk+tg по приоритету direct→vk→tg); (3) применение бенефита во времени.
  • D8. origin бенефита = контекст ПОКУПКИ (не «чем оплачено»). Правило послабления: origin∈{vk,tg} → действует везде (в сторе и наружу в web/native); origin=direct → только web/native (внутрь VK/TG нельзя = бан). Срок «без рекламы» хранится per-origin (paid_until на origin, покупки одного origin складываются/ сдвигают дату); «реклама выключена в контексте P» = есть применимый в P origin с paid_until > now (в вебе берём максимум direct/vk/tg, в VK — только vk, в TG — tg).
  • D9. «Без рекламы» гасит верхний баннер + fullscreen-ролик после хода. Добровольный rewarded (за Фишки) не трогаем.
  • D10. UX-требование: при трате tg/vk-Фишек в вебе — предупредить юзера ДО покупки, что купленная механика будет доступна только здесь (web/native), т.к. ограничения VK/TG.
  • D11. Три сущности разведены: Фишки (валюта, сегмент по source = где пополнено), Подсказки (расходник, сегмент по origin = где куплено), «Без рекламы» (срок, сегмент по origin). source и origin — одна ось значений {vk,tg,direct}, но разная семантика; в вебе source и origin расходятся (vk-Фишки → direct-покупка).
  • D12. Подсказки — по origin с обратным послаблением (vk/tg→везде, direct→только web/native), как «без рекламы». Списание в онлайн-игре = из origin, применимого в контексте; переписать SpendHint (game/service.go:1147) на контекст-аварное списание. vs_ai подсказки остаются бесплатными/безлимитными (не в счёт).
  • D13. accounts.hint_balance выводим expand-contract (сначала игнор, потом DROP); legacy-баланс обнуляем (в проде даров не было).
  • D14. Unlink vk/tg — разрешён, сегмент усыпляется. Сегмент доступен ⟺ на аккаунте есть identity этого source (direct ⟺ есть устойчивый identity/email). Отвязка не сжигает баланс/бенефит — усыпляет; re-link будит. Перед отвязкой — предупреждение «N Фишек станут недоступны до повторной привязки». Последнюю identity отвязать нельзя (существующий ErrLastIdentity).
  • D15. Мерж — слияние по origin: одноимённые сегменты складываются (Фишки sum, сроки бенефита продлеваются per-origin), разные сосуществуют. Прямое расширение текущего hint_balance += / paid_account OR=; origin сохраняется, ничего не протекает между платформами.
  • D16. Админ-награждение: админ начисляет только конкретные ценности (без рекламы / подсказки), никогда не Фишки (подаренный баланс валюты = обход кассы стора). Админ выбирает origin при выдаче (ответственность за комплаенс на нём: origin=vk точечно/малый объём = низкий риск, origin=direct = безопасно). Грант = транзакция типа admin_grant в едином леджере, цена 0 Фишек — полный аудит наград.
  • D17. Доверенная платформа = свойство сессии, переподтверждается свежей подписью (VK sign / TG initData) при каждом холодном старте (не однократно при входе — обёртка всё равно шлёт подпись каждое открытие). direct фиксируется фактом создания веб/native-сессии (внешней подписи нет и не нужно — доступ к vk/tg-сегментам в direct всё равно требует реальной привязки, D14). Backend получает platform при резолве сессии. Платформа несёт kind (vk/tg/direct) + подтип (ios/android/web) — подтип обязателен (VK iOS заморожен). TG initData-валидатор уже есть (platform/telegram/internal/initdata). АМЕНД (E6, находка владельца по ToS): VK iOS — заморозка только ПОКУПОК (деньги→Фишки), а не траты. Apple запрещает там лишь покупать внутриигровые ценности за любую валюту; а тратить Фишки VK-кошелька (заработанные rewarded-рекламой или купленные на том же VK-аккаунте в Android) и зарабатывать их — легально и на iOS. Код: vkFrozen() гейтит только CreateOrder (покупку), не spendableSources (трату).
  • D18. Fail-closed: недоверенная/неподтверждённая платформа (VK/TG-сессия без валидной подписи на старте; старая сессия без записанной платформы) → запрет трат/покупок/применения чужого origin, только просмотр.
  • D19. Дистрибуция native Android: RuStore (Robokassa разрешён, чистый direct) + Google Play (direct-покупки скрыты — на «Кошельке» заглушка «установите версию из RuStore для покупок»; rewarded-реклама и трата уже накопленных Фишек работают). Перед GP-релизом свериться с актуальными правилами Google по внутренней валюте.
  • D20. Приём оплаты — только серверный колбэк провайдера. Фишки начисляются лишь по проверенному (подпись/HMAC) серверному уведомлению: Robokassa webhook / TG successful_payment / VK callback. Клиентское «я оплатил» игнорируется.
  • D21. Единый payments-домен — единственный писатель леджера. Публичные вебхуки (Robokassa/VK) терминируются на edge (Caddy/gateway) и проксируются в payments; TG successful_payment → бот → payments. Один источник начисления/идемпотентности.
  • D22. Order-flow с предзаказом. Сервер создаёт order(pending) с account/ платформой/пакетом/ожидаемой суммой/origin; order_id прокидывается провайдеру (Robokassa InvId / TG invoice_payload / VK item — точную форму VK уточнить при интеграции). Колбэк матчится по order_id (не по сумме → коллизия сумм невозможна), сверяет сумму, начисляет, помечает paid. Идемпотентность — дедуп по (провайдер, provider_payment_id).
  • D23. Pending невидим юзеру; авто-expiry по таймауту (~30 мин — гигиена БД). Но валидный колбэк исполняется ВСЕГДА, даже на истёкшем order (expired ≠ отмена — деньги реальны, обязаны выдать). Юзер видит только успешные покупки.
  • D24. Экран «Кошелёк» — минимальный: балансы Фишек (доступные в контексте) + активные бенефиты (без рекламы до даты, счётчик подсказок). Без ленты истории (шумит, отвлекает от игры). Полная история — только в админке.
  • D25. TG-бот outbox — SQLite на диске бота. Store-and-forward: получил successful_payment → сохранил в SQLite → подтвердил апдейт Telegram → форвардит в payments (идемпотентно, дедуп по telegram_payment_charge_id) → ack → forwarded. Ретраи с backoff, дореталивание при рестарте. At-least-once доставка + идемпотентный приём = начисление ровно один раз.
  • D26. Событийный слой payment_events (succeeded/failed/refunded) + диспетчер по каналам: live gRPC-стрим (если юзер в аппе), иначе botlink/email-relay (существующие). «Оплата не прошла» = активный отказ провайдера (не брошенный pending) — доводится до юзера. «Оплата прошла» — хук (письмо/сообщение в бота).
  • D27. Возвраты. ToS «невозвратно» — юзеру возврат не предлагаем. Админ может сделать ручной возврат (исключение: юзер требует вскоре после оплаты / закрывает аккаунт — связать с accountdelete, где уже правило «сообщения не трогаем»). Внешние возвраты (чарджбек / решение стора / TG / VK) — система принимает событие refunded, по возможности отзывает бенефит (в минус НЕ уходим; если Фишки потрачены — фиксируем убыток + флаг защиты от злоупотреблений), пишет в журнал. Журнал операций проектируем экспортопригодным (будущая налоговая отчётность + авто-сверка с Robokassa) — реализацию сверки пока не делаем, схему закладываем совместимой.
  • D28. Стартовый охват видео-рекламы — только VK (рублёвый доход, ОРД автоматом, API готов). web/native/TG — существующий текст-баннер. Рекламный провайдер закладываем абстракцией (будущая крутилка для других платформ встроится без переделки). Крипто-провайдеры (AdsGram/AdMob) отвергнуты — нет легального рублёвого дохода самозанятому (санкции + НПД не учитывает крипту).
  • D29. Rewarded (добровольный ролик за Фишки) начисляет Фишки через payments. Не гасится «без рекламы». Сколько Фишек за просмотр — в блоке экономики. АМЕНД (E6, по факту реализации): у VK Mini App серверного verify-колбэка нетVKWebAppShowNativeAds отдаёт только клиентский data.result. Поэтому начисление client-attested, а защита (и экономический рычаг) — серверный дневной и часовой кап (config reward_daily_cap / reward_hourly_cap, дефолт 50 / 10) + идемпотентность по клиентскому nonce. Сеть с серверным verify встроится за ads-абстракцией позже.
  • D30. Частота навязанного interstitial (конфигурируемые серверные значения): глобальный кулдаун на юзера сквозь все партии, дефолт 5 мин; vs_ai — 30 мин (соосно кулдауну подсказок). Применение подсказки триггерит ролик после хода независимо от основного кулдауна, со своим кулдауном 1 мин. Оффлайн — только баннер. Уважать собственные лимиты частоты VK. АМЕНД (E6, по факту реализации): гейт зеркальный — сервер отдаёт кулдауны и suppressed в профиле (adsFor), клиент сам гейтит по единому времени последнего показа в localStorage (без раунд-трипа на ход). Таймер общий на все виды — вид (hint/move/vs_ai) лишь выбирает нужный интервал, поэтому «независимость» подсказочного кулдауна значит лишь более короткий интервал от последнего показа, а не отдельный таймер: hint-ролик и move-ролик не встают подряд (баг раздельных таймеров: после hint-ролика move-таймер оставался нулевым → следующий ход сразу крутил рекламу). Ролик показывается только после подтверждённого хода или подсказкине после пропуска, обмена или сдачи (за не-очковое действие рекламой не «награждаем»). Interstitial — только VK (как и rewarded). Частота — глобальная на все игры (localStorage на устройство).
  • D31. paid_account тоже deprecated → удаление из схемы (как hint_balance), в пользу per-origin бенефитов «без рекламы». Существующий ads.Eligible (backend/internal/ads/ads.go:107) расширяется: баннер гасится по origin-бенефиту, применимому в текущем контексте, а не по одному глобальному флагу. Legacy paid_account/hint_balance в проде никем не выставлены (потока покупки не было) → обнуляем/игнорируем, после релиза платежей дропаем. АМЕНД (E6, по факту реализации — expand-contract, шаг 1 «contract-код»): доменное использование обеих колонок убрано — поля Account.HintBalance / Account.PaidAccount, их скан, мёртвый account.SpendHint, account.GrantHints и админ-действие «grant-hints» (роут /_gm/users/:id/grant-hints, форма, UserDetailView.HintBalance/ PaidAccount); отображение подсказок в игре теперь всегда из payments (HintsAvailable), профильный баланс — из payments-бенефита. Колонки БД пока оставлены (без миграции — откат образа DB-safe); их DROP — отдельным contract-PR, когда E6 стабилен на проде.
  • D32. Каталог — конфигурируемый (БД + админка). Базовые ценности (атомы начисления): Фишки, подсказки, дни-без-рекламы, участие-в-турнире. Продукт = набор атомов + цена (по одной ценности или комбо). «Пакет Фишек» — цена per-метод (мультивалютная: Голоса/Stars/руб — один продукт, D2). «Ценность за Фишки» — цена в Фишках (единая). Курсы покупки Фишек и Фишки за rewarded — тоже в каталоге. (Ставка rewarded + дневной/часовой потолки — строка общего конфига payments.config, правится в секции «Rewarded ads» на странице каталога админки; это не продаваемый продукт-атом.)
  • D33. Стекинг «без рекламы»: paid_until[origin] += срок от max(now, текущий конец) (остаток не теряется, «плюсуются» как в документе). «Навсегда» — отдельный вечный флаг (перекрывает сроки). Бонусы «(+50)» — маркетинговая пометка владельца; юзеру показываем финальную цифру, в модели просто количество.
  • D34. Деактивация, не удаление. Продукты soft-delete (деактивация). Покупка хранит снимок проданного (состав атомов + цена на момент) → архив. Для истории, чеков, налоговой — покупка не зависит от дальнейших правок/деактивации каталога.
  • D35. Антифрод rewarded — только серверный verify провайдера на старте (без своего дневного потолка). Провайдер-абстракция позволит добавить лимиты позже.
  • D36. Email/direct. У гостей раздел «Кошелёк» скрыт полностью; баланс и покупки — только у durable-аккаунтов. В direct email обязателен перед первой покупкой (якорь восстановления доступа; в VK/TG якорь — сама vk/tg-привязка). Флоу email уже есть (запрос кода → подтверждение → ClearGuest → durable).
  • D37. Reaper — гостей чистит свободно: у гостя баланса нет by design (Кошелёк скрыт, покупок нет, direct-rewarded нет). Спец-защита не нужна.
  • D38. Гостевые ограничения — ОТДЕЛЬНЫЙ этап монетизации (воронка к регистрации). Набор: макс 1 активная игра со случайным + 1 vs_ai; серверный guest-гейт на friend-request / redeem-code / invitation-create. Находка: сейчас гейт только в UIsocial/friends.go:50 (SendFriendRequest), robotfriends.go:41 (RequestInGame), friendcodes.go:67 (RedeemFriendCode), lobby/invitations.go:208 (CreateInvitation) не проверяют is_guest. Это изменение поведения игры → свой этап со своими тестами.
  • D39. Хранение — неизменяемый журнал + материализованный баланс. Журнал операций append-only (только INSERT, никогда UPDATE/DELETE — полный аудит, №3). Баланс сегмента (account, source) и бенефиты (account, origin) — быстрый материализованный кэш, обновляется в той же транзакции, что и запись журнала; пересчитывается из журнала для сверки.
  • D40. Финансовый отчёт per-user в /_gm — балансы сегментов, платежи, траты, гранты, возвраты, полная история — расширение существующей карточки (UserDetailView, handlers_admin_console.go:343). Плюс экспорт журнала (D27).
  • D41. Налоги/чеки — авто через провайдера (ревизия: владелец переходит на ИП). Robokassa (direct) — фискальный чек по 54-ФЗ через облачную кассу Robokassa (провайдер как фискальный агент): чек формирует касса, не банк (банковский слип об оплате — отдельный документ, не фискальный чек). Настраивается владельцем в ЛКК Robokassa (касса + ОФД + СНО). Код — опционально: слать детализированный Receipt + Email покупателя (берём подтверждённый email-якорь D36) → чек с точным названием пакета и ставкой по СНО; иначе — обобщённый дефолт-чек из кабинета. Receipt входит в подпись (MerchantLogin:OutSum:InvId:Receipt:Пароль#1, URL-кодируется), одна позиция влезает в текущий GET-redirect (POST-форма — фолбэк на длину URL). Источник чеков один — ИП/касса, независимо от числа магазинов Robokassa. VK — процессит Голоса через налоговую сам. TG Stars — вне рублёвого фискального контура (принимаем, чек не формируем). ОРД по VK-рекламе — на стороне VK. (Не юрконсультация — схему по 54-ФЗ/СНО владелец сверяет с бухгалтером.)
  • D42. Direct-рельс — несколько магазинов Robokassa = маршрутизация merchant-аккаунтов по каналу при едином кошельке. Кошелёк direct остаётся один. Отдельные магазины Robokassa (web, android; ios — позже) различаются только кредами / Return-URL / учётом и все зачисляют в сегмент direct. Модель кошельков, стенка трат и origin бенефитов не меняются. Магазин выбирается по трастовому сигналу канала (X-Platform вида <kind>/<subtype>: direct/web, direct/android), а не по подделываемому клиентскому полю; неизвестный подтип → магазин web (безопасный дефолт). Зачисление от выбора магазина не зависит (всегда direct), поэтому ошибка маршрутизации влияет максимум на учёт, не на деньги.
  • D43. Standalone-приложения (Android, iOS) — вход только по email; покупка требует email-якорь. В нативной сборке доступны только guest + email: VK ID-логин (full-page redirect на id.vk.com) не возвращается в Capacitor, TG Login Widget в WebView ненадёжен — это уже действующая реальность сборки, не новое ограничение. Direct-покупка требует подтверждённого email-якоря (D36) — он же адрес фискального чека (D41); гость не покупает. Отдельный сегмент apple НЕ заводим: iOS-standalone — тот же direct-контекст, внешний гейт (Robokassa) фондирует единый direct. Сегмент apple со стенкой (по образцу vk/tg) понадобился бы только при Apple IAP (StoreKit) — отложено до решения по iOS; и identity-kind apple→direct, и отдельный сегмент — аддитивны, без риска, делаются на месте.
  • D44. Канал платежа хранится на заказе для раздельного учёта/отчёта. Заказ получает поле shop (web/android/…) — аддитивная колонка. Используется в финансовом отчёте (D40) для разбивки «из какого магазина/канала платёж». На зачисление и на сегмент кошелька не влияет (всегда direct, D42).
  • D45. Рубильник платежей per-rail + локализованное сообщение. Оператор выключает покупки на рельсе/канале (direct:web/direct:android/vk/telegram) живьём из /_gm (таблица payments.rail_status, редактируется на странице каталога). Fail-open: нет строки → рельс включён (случайно не убить платежи). Выключенный рельс на попытке покупки возвращает payment_unavailable + сообщение админа на языке юзера (RU/EN; пусто → встроенное «временно недоступно»). Сообщение едет клиенту через аддитивное ExecuteResponse.message (envelope-слой, frozen-contract-safe). Гейт ортогонален security-гейтам.
  • D46. Per-user override покупок (allow/deny/default). На карточке юзера (/_gm) — переопределение на аккаунт: allow (всегда разрешить), deny (всегда запретить), default (по рельс-рубильнику). Хранится в payments.account_payment_override строкой только для не-default (нет строки = default; снять = удалить строку). allow обходит ТОЛЬКО ops-рубильник, НЕ security-гейты (D36 email-якорь, VK-iOS-фриз, trusted-платформа, min-client-version) — те проверяются отдельно в CreateOrder.
  • D47. Direct-рельс переезжает с Robokassa на ЮKassa; Robokassa консервируется, а не удаляется. Меняется merchant-отношение, не модель кошельков: сегмент direct, стенка трат, origin бенефитов, мультимагазинность по каналу (D42) и shop в заказе (D44) — без изменений. Код Robokassa, её тесты и проводка остаются в дереве, а direct-рельс откатывается на неё, если ни один магазин ЮKassa не настроен, — значит возврат к Robokassa это смена кредов, а не правка кода. Переменные Robokassa убраны из CI/compose/деплоя и записаны в backend/internal/robokassa/README.md вместе с настройками кабинета и порядком возврата. Строки журнала с provider = 'robokassa' не трогаем: литерал несущий для индекса идемпотентности. Telegram не трогаем — там реальными деньгами за цифровые товары платить нельзя, рельс остаётся на Stars; префиксы переменных заводим на магазин сразу, чтобы будущий магазин (например, android/RuStore) добавлялся аддитивно.
  • D48. Уведомления ЮKassa не подписаны → подтверждающее чтение обязательно. В отличие от Robokassa (подпись Password2) и VK (подпись), ЮKassa не подписывает вебхуки. Поэтому тело уведомления — только подсказка: оно называет платёж, который сервер перечитывает запросом GET /v3/payments/{id}, и действует исключительно по ответу API. Дополнительно: метаданные платежа должны называть тот самый заказ; флаг test платежа должен совпадать с флагом магазина (платёж тестового магазина не начислит настоящие Фишки, и наоборот); адрес отправителя сверяется с опубликованными диапазонами ЮKassa — эшелонированная защита, которая не даёт превратить каждое фальшивое уведомление в наш исходящий запрос. Ответ провайдеру: 200 на всё окончательно решённое (включая дубль и неустранимый отказ), 5xx только на временный сбой (ЮKassa повторяет 24 часа).
  • D49. Сверка — одна проверка на истечении заказа, без постоянного опроса. «Или» в документации ЮKassa адресовано тем, кто не хочет вебхуки; у нас вебхуки основные. Но безвозвратно потерянное уведомление оставило бы деньги списанными, а Фишки — не выданными, и молча. Поэтому существующий жнец просроченных заказов перед списанием в expired спрашивает провайдера о судьбе каждого заказа, дожившего до срока с идентификатором платежа, и начисляет реально оплаченные. Один запрос на заказ за всю его жизнь; отдельный воркер не заводим.
  • D50. Возвраты на direct-рельсе — через API ЮKassa, одним действием. Кнопка возврата в /_gm сперва двигает деньги (POST /v3/refunds, Idempotence-Key = идентификатор заказа, с чеком возврата) и только потом пишет реверс в журнал; неудачный вызов не пишет ничего — журнал не может заявить о возврате, которого не было. Реверс пишется только при статусе succeeded: возврат в pending ещё может отмениться, а журнал только на добавление, поэтому ранняя запись отобрала бы у покупателя Фишки за деньги, оставшиеся у нас; выход — нажать ещё раз, ключ идемпотентности вернёт тот же возврат. Записывается собственный refund-id провайдера (сверка с данными ЮKassa). VK и TG Stars — как раньше: деньги руками, запись фактом.
  • D52. Обрабатываем refund.succeeded: кабинет — вторая точка входа для возврата. В отличие от прежних рельсов, у ЮKassa возврат можно оформить прямо в кабинете магазина, минуя наш API, — тогда деньги ушли бы назад, а Фишки остались бы начисленными, и молча. Поэтому подписываемся на событие и проводим возврат тем же движком: возврат перечитывается из API (тело уведомления — не доказательство, как и у платежа), привязывается к заказу через записанный идентификатор платежа, идемпотентность по (провайдер, refund-id) делает уведомление о возврате, уже записанном консолью, пустой операцией. Частичный возврат не записываем вовсе — движок по устройству работает только с полной суммой заказа, а непроизвольного способа решить, скольких Фишек стоит часть возврата, нет; вместо записи — громкий лог для оператора.
  • D51. Фискализация — «Чеки от ЮKassa», чек отправляем из кода. Ревизия D41: у ЮKassa нет кабинетного «обобщённого чека», как у Robokassa, — чек регистрируется только если запрос его несёт, поэтому itemized-код, от которого отказались в D41, возвращается в объём. Каждый платёж и возврат шлют receipt: одна позиция (название пакета, количество 1, сумма), код ставки НДС (тег 1199), признак предмета расчёта service (тег 1212), признак способа расчёта full_payment (тег 1214); доставка только на email — на подтверждённый якорь D36. Код ставки НДС — переменная деплоя (дефолт 1 = «Без НДС» для УСН/ПСН): это единственный реквизит, который реально меняется (с 1 января 2026 ставки выросли, появились коды 11/12), и менять его без релиза нужно; признаки предмета и способа расчёта — константы в коде. tax_system_code не шлём: для «Чеков от ЮKassa» провайдер его игнорирует.

Заметки к оформлению документов

  • Язык документов (решено). PAYMENTS.mdна английском, терминами как в коде и общепринятыми (ledger, refund, idempotency, order, …). Рядом PAYMENTS_ru.md — перевод на простой русский владельца. Паттерн FUNCTIONAL.md + FUNCTIONAL_ru.md, уже принятый в репозитории; мирроринг правок — в том же PR.
  • Язык диалога с владельцем — по-русски, без калек-англицизмов («леджер»→«журнал операций»). Сохранить как feedback-память после выхода из plan mode.
  • Формат интервью (владелец флагнул дважды). ВСЕ вопросы владельцу — только через интерактивное интервью (AskUserQuestion). НИКОГДА не выносить вопросы в текст ответа, даже «мелкие» или «да/нет»: смешение текстовых и интерактивных вопросов раздражает и ведёт к пропускам. Текст — только для фиксации решённого и пояснений. Усилить feedback-память prefer-interview-mode после plan mode.

Все развилки закрыты (D1-D52)

Интервью завершено. Дальше — оформление документов и реализация по релизам.

Дополнение 2026-07-14 (интервью с владельцем). D41 ревизована (владелец переходит на ИП: 54-ФЗ через облачную кассу Robokassa вместо авточека НПД); добавлены D42-D44 — сплит direct-рельса Robokassa на магазины по каналу (web/android; ios позже) при едином кошельке direct и входе email-only в standalone-приложениях (этап E10 в PLAN.md). Плюс D45-D46 — рубильник платежей per-rail + локализованное сообщение + per-user override (этап E11). Фискализация (B4) — кабинетная на стороне Robokassa, itemized-код не делаем (решение владельца).

Дополнение 2026-07-28 (интервью с владельцем). Direct-рельс переезжает на ЮKassa; добавлены D47-D51 — консервация Robokassa с откатом по кредам, подтверждающее чтение вместо подписи уведомления, сверка на истечении заказа, возвраты через API и фискализация через «Чеки от ЮKassa» с отправкой receipt из кода (ревизия D41, этап E12 в PLAN.md). Telegram из объёма исключён: реальными деньгами за цифровые товары в Mini App платить нельзя, рельс остаётся на Stars. Отдельно уточнено: у ЮKassa есть тестовый режим (отдельный тестовый магазин со своими креды и тестовыми картами), поэтому весь путь проверяется на тестовом контуре без реальных денег.

Уточнение по возвратам (владелец, 2026-07-28). D50 дополнена требованием статуса succeeded; добавлена D52 — обработка refund.succeeded, потому что кабинет ЮKassa позволяет вернуть деньги мимо нашего API. Обе правки вошли в тот же PR, что и переход на рельс.

План внедрения (черновик PLAN.md — «слоями»)

Владелец выбрал слоёную стратегию: сначала вся механика без реальных денег (обкатка через admin_grant), затем монетизация (все рельсы + реклама вместе).

Релиз 1 — механика без денег (обкатка через admin_grant)

  • E0. Фундамент данных payments. Схема + DB-роль + jetgen-таргет + миграции. Таблицы: журнал операций (append-only, D39), балансы сегментов (account, source), бенефиты (account, origin) (paid_until/forever + подсказки count), каталог продуктов (D32, soft-delete D34), заказы orders (D22), payment_events (D26). Доменный пакет с жёсткой границей (D3). Старт deprecate hint_balance/paid_account (expand).
  • E1. Доверенный сигнал платформы. Platform в сессии (kind+подтип), переподтверждение на холодном старте (VK sign / TG initData), fail-closed (D17-D18); gateway прокидывает platform в backend.
  • E2. Ядро валюты + бенефитов. Баланс Фишек; трата Фишек → бенефит атомарно (D7); гейт по контексту (D6-D8); применение per-origin (без рекламы стекинг D33, подсказки D12); переписать SpendHint на контекст-аварное списание; миграция/обнуление legacy (D13, D31). Обкатка начислений через admin_grant (D16).
  • E3. Кошелёк UI. Раздел в SettingsHub (между Друзья/Инфо), балансы + бенефиты (минимальный, D24), витрина каталога, скрыт у гостей (D36), GP-заглушка (D19), предупреждение при трате vk/tg в вебе (D10).

Релиз 2 — монетизация (рельсы + реклама вместе)

  • E4. Durability (PITR). WAL-архив (pgBackRest/WAL-G, D4) — ДО первого реального приёма денег.
  • E5. Приём платежей. Order-flow (D22); единый payments-домен принимает колбэки (D21); Robokassa + VK + TG (бот-outbox на SQLite, D25); идемпотентность; payment_events
    • диспетчер уведомлений (D26); чеки НПД (D41); внешние + ручные возвраты (D27).
  • E6. Реклама. VK interstitial (частота-конфиг D30) + VK rewarded (verify→Фишки D29, D35); ads.Eligible расширение per-origin (D31). Провайдер-абстракция (D28).
  • E7. Админка / отчёты. Финансовый отчёт per-user в /_gm (D40); UI ручного возврата; экспорт журнала (D27).

Отдельный этап (изменение поведения игры)

  • E8. Гостевые ограничения. Серверный guest-гейт друзей/приглашений + лимиты игр (1 случайная + 1 vs_ai, D38). Свои тесты; можно вести параллельно.

Будущее

  • E9. Турнирный взнос. Тип-ценность заложен в E0; механика позже.

Финальные артефакты

  1. PAYMENTS.md (англ) + PAYMENTS_ru.md (рус) — первыми, из Decisions Log D1-D41; поддерживать далее (мирроринг в том же PR, как FUNCTIONAL).
  2. PLAN.md — из раздела «План внедрения» выше, с критериями готовности на этап.
  3. Реализация по релизам, начиная с E0.

Verification

Каждый этап — своим слоем (docs/TESTING.md): unit (гейт по контексту, стекинг сроков, курсы, идемпотентность-ключи); integration (Postgres-backed атомарные транзакции «Фишки↔бенефит», идемпотентность колбэков, бот-outbox доставка); UI (Кошелёк, предупреждения, гость-скрыт, GP-заглушка); контурный прогон на development перед промоушеном. Локальная полная проверка перед пушем (интеграция + UI + codegen). Прод: expand-contract миграции (rollback-safe), PITR армирован до первого приёма денег. Комплаенс-гейт — обязательная регрессия (direct-бенефит не активируется в VK/TG).