A real test payment on the contour exposed both problems at once. YooKassa
delivered the notification five times; all five were rejected because the
backend saw the sender as 10.77.0.1 — the contour sits behind a tunnel and
cannot observe real client addresses, the same reason the IP bans in this
repository are prod-only. The chips were not lost (the reconcile sweep would
have credited them), but the primary path was dead and the customer was left
watching an unchanged balance.
The address check is removed rather than made conditional. It never was the
security boundary — the confirming GET /v3/payments/{id} is — and the one thing
it bought is already bought earlier and far more tightly: the order is resolved
from the notification's metadata *before* any provider call, so a notification
naming no known order costs a single indexed read and stops there. Guessing a
live order id means guessing a uuid. Against that, an address check adds nothing
and breaks every deployment that cannot see real client addresses, while turning
any future change to YooKassa's published ranges into a silent degradation.
The second problem was mine. The reconcile threshold was keyed off the order
lifetime, so a lost notification cost the customer the full 30-minute TTL before
the chips landed. Those are different questions: the lifetime governs how long a
customer may take to pay, the re-check governs how soon we notice a lost
callback. Split apart — `payments.ReconcileAfter`, one minute, swept on every
reaper tick. The bound D49 was chosen for survives: the calls one order can
cause are still its lifetime divided by the sweep interval, a handful, not an
open-ended poll. Worst case for a failed notification drops from ~30 minutes to
~5; an order the customer is still paying for is left alone.
Tests: the foreign-sender test is replaced by the two properties that now carry
the load — a notification naming an unknown order makes no provider call at all,
and a genuine notification is honoured whatever address it appears to come from.
Plus one pinning that a seconds-old order is not polled.
The shared bundle budget goes 31 -> 32 KB, with the reason recorded in the
script header: every user-visible string lands in that chunk and it had been
sitting 40 bytes under the cap.
Decisions D48 and D49 revised.
53 KiB
Монетизация 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домен + UIAdBanner.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 заморожен). TGinitData-валидатор уже есть (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прокидывается провайдеру (RobokassaInvId/ TGinvoice_payload/ VKitem— точную форму 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, а защита (и экономический рычаг) — серверный дневной и часовой кап (configreward_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-бенефиту, применимому в текущем контексте, а не по одному глобальному флагу. Legacypaid_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. Находка: сейчас гейт только в UI
—
social/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-kindapple→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платежа должен совпадать с флагом магазина (платёж тестового магазина не начислит настоящие Фишки, и наоборот). Ответ провайдеру: 200 на всё окончательно решённое (включая дубль и неустранимый отказ), 5xx только на временный сбой (ЮKassa повторяет 24 часа). Ревизия: адрес отправителя не проверяем. Изначально сверяли с опубликованными диапазонами ЮKassa как эшелонированную защиту. Оказалось лишним и вредным: единственное, что она давала — не дать подделывателю превратить фальшивое уведомление в наш исходящий запрос — уже обеспечено раньше и жёстче, потому что заказ ищется по метаданным ДО обращения к провайдеру (несуществующий order_id стоит одного чтения по индексу; угадать живой — значит угадать uuid). А на тестовом контуре, который за туннелем видит только свой внутренний адрес, проверка отбивала настоящие уведомления — ровно как IP-баны, сделанные в репозитории prod-only по той же причине. - D49. Сверка — без постоянного опроса, но с коротким шагом (ревизия). «Или» в документации ЮKassa адресовано тем, кто не хочет вебхуки; у нас вебхуки основные. Но безвозвратно потерянное уведомление оставило бы деньги списанными, а Фишки — не выданными, и молча. Поэтому существующий жнец спрашивает провайдера о судьбе незакрытых заказов с идентификатором платежа и начисляет реально оплаченные; отдельный воркер не заводим. Ревизия порога: сперва проверка была привязана к возрасту истечения заказа (30 минут), и это была ошибка — время жизни заказа отвечает на вопрос «сколько покупателю позволено думать», а не «как быстро заметить потерянный колбэк». На практике это дало покупателю 30 минут ожидания Фишек при первом же сбое доставки. Порог развязан: проверяем заказы старше минуты, на каждом тике жнеца. Ограниченность сохраняется — запросов на заказ не больше, чем время его жизни, делённое на шаг жнеца.
- 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 (ревизия). Чеки не передаём: владелец на НПД, это вне 54-ФЗ. Уточнено у поддержки ЮKassa:
при НПД провайдер с чеками не работает. Онлайн-кассы нет,
receiptв запросах не отправляется, о каждой операции владелец сообщает в «Мой налог», он и формирует чек. Побочный выигрыш: исчезает целый класс отказов — некорректныйreceiptбыл ошибкой API прямо при создании платежа, то есть ломал покупку. Код чеков при этом консервируем, а не удаляем (решение владельца): вся сборкаreceiptживёт за одним переключателемBACKEND_YOOKASSA_VAT_CODE— пусто (дефолт) значит «не отправлять», код ставки по 54-ФЗ (тег 1199) возвращает прежнее поведение: одна позиция, признак предмета расчётаservice(тег 1212), способ расчётаfull_payment(тег 1214), доставка на email-якорь D36,tax_system_codeне шлём. Возврат к чекам предсказуем: у НПД годовой потолок дохода 2,4 млн ₽, и его превышение возвращает 54-ФЗ — тогда это правка переменной, а не кода. Связка с «Мой налог» — отдельная задача. Всё нужное для неё уже хранится (идентификатор заказа и платежа провайдера, сумма, валюта, время зачисления, снимок пакета, возвраты); не хватает только отметки «операция уже отправлена» для идемпотентности выгрузки — её заводит та задача.
Заметки к оформлению документов
- Язык документов (решено).
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, что и переход на рельс.
Уточнение по фискализации (владелец, 2026-07-28). D51 ревизована: владелец принимает платежи как
ИП на НПД, при котором ЮKassa с чеками не работает (подтверждено поддержкой), поэтому receipt
не передаётся вовсе, а отчётность идёт через «Мой налог». Код чеков законсервирован за переменной
BACKEND_YOOKASSA_VAT_CODE на случай потери режима. Правка вошла в тот же 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). Старт deprecatehint_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; механика позже.
Финальные артефакты
PAYMENTS.md(англ) +PAYMENTS_ru.md(рус) — первыми, из Decisions Log D1-D41; поддерживать далее (мирроринг в том же PR, как FUNCTIONAL).PLAN.md— из раздела «План внедрения» выше, с критериями готовности на этап.- Реализация по релизам, начиная с E0.
Verification
Каждый этап — своим слоем (docs/TESTING.md): unit (гейт по контексту, стекинг
сроков, курсы, идемпотентность-ключи); integration (Postgres-backed атомарные
транзакции «Фишки↔бенефит», идемпотентность колбэков, бот-outbox доставка); UI
(Кошелёк, предупреждения, гость-скрыт, GP-заглушка); контурный прогон на development
перед промоушеном. Локальная полная проверка перед пушем (интеграция + UI + codegen).
Прод: expand-contract миграции (rollback-safe), PITR армирован до первого приёма денег.
Комплаенс-гейт — обязательная регрессия (direct-бенефит не активируется в VK/TG).