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.
62 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-ФЗ — тогда это правка переменной, а не кода. Связка с «Мой налог» — отдельная задача, закрыта решениями D53-D60 ниже.
Выгрузка в «Мой налог» (интервью с владельцем, 2026-07-28)
- D53. Охват выгрузки — только рубли ЮKassa. Выгружаем
kind='fund',provider='yookassa', валютаRUB. VK-Голоса и Telegram Stars вне охвата не по решению, а по факту: журнал операций хранит их цену вVOTE/XTRи рублёвой суммы не знает вообще — она приходит выплатой платформы за вычетом её комиссии. Плюс VK — юрлицо, то есть ставка 6% и ИНН плательщика в чеке, а это другой тип чека. Stars владелец не выводит, поэтому спор о моменте признания дохода по ним не открываем. Доход от rewarded-рекламы (vk_ads) денег в журнале не имеет и тоже вне охвата. - D54. Пароль вводится, хранится только refresh-токен, зашифрованный. Логин (ИНН) и пароль
вводятся в форме
/_gm/mynalog, используются один раз и никуда не пишутся; на диск ложится только выданный refresh-токен, запечатанный AES-GCM наBACKEND_MYNALOG_KEY. Ключ — наши собственные случайные 32 байта, к учётным данным налоговой отношения не имеющие; он нужен потому, что refresh-токен — долгоживущий доступ к личному кабинету ФНС, и в дампе базы (а бэкапы уезжают в S3) ему открытым текстом не место. Потеря ключа не катастрофа: токен перестаёт расшифровываться, и консоль просит войти заново. Вариант «логин/пароль в секретах Gitea» отклонён: это пароль от налоговой, а не от магазина, и радиус поражения несопоставим. - D55. Автоматический режим есть, но включается галочкой в консоли, а не конфигом. Кнопка и
воркер зовут один и тот же
RunBatch— второй реализации нет. Автомат по умолчанию выключен: владелец сначала прогоняет рельс руками и убеждается, что чек выглядит правильно, и только потом включает. Тик — 15 минут; при пустой очереди прогон не авторизуется и наружу не ходит. Ограничение прогона — бюджет времени (13 минут у воркера, 100 секунд у кнопки), а не счётчик чеков: счётчик ничего не защищает, защищают пауза 2 секунды между вызовами и предохранители. - D56. Неустановленный исход останавливает очередь. Регистрация дохода не идемпотентна: ключа
идемпотентности у API нет, поэтому ошибка не означает, что чек не создан. Название услуги
замораживается в базе до запроса и несёт маркер из хвоста идентификатора заказа (старшие
разряды UUIDv7 — миллисекундные часы, одинаковые у всех заказов одной минуты, и такой маркер сделал
бы две покупки неразличимыми). После сбоя ищем чек по этому точному названию: нашли — записываем,
не нашли — статус «неизвестно», и новые чеки не отправляются, пока человек не разберёт.
Аннулирования при этом продолжают идти: они только убирают доход. Один прогон за раз — advisory-лок
Postgres; строка, застрявшая в
sendingпосле падения процесса, под локом заведомо осиротевшая и переводится в «неизвестно», а не переотправляется. - D57. Сбои классифицируются на пять классов.
401— молча переавторизуемся (запрос отклонён до обработки, поэтому единственный сбой, который можно повторить без зонда);429— отступаем;5xxи таймаут — мягко, письмо только если сервис не поднялся за сутки; прочие4xx— жёстко, и три подряд снимают рельс с эксплуатации с письмом, потому что смена формата API отклоняет всё подряд и без этого превратилась бы в тысячи запросов за ночь; превышение годового лимита НПД выделено отдельным классом (оно подчиняет предыдущий, чтобы проверка «это неисправимо?» его не пропустила) — это уже не техническая ошибка, а потеря режима. - D58. Сумма — полная, дата — фактическая, пояс — налогоплательщика. В чек идёт то, что заплатил
покупатель: на НПД расходы не вычитаются, комиссия ЮKassa — расход владельца. Время операции — это
момент платежа, а не момент выгрузки, и отправляется в поясе
BACKEND_MYNALOG_TZ(по умолчаниюEurope/Moscow), потому что смещение решает, в какой налоговый месяц попадёт околополуночный платёж. Название услуги:Внутриигровая валюта: "Фишка", NN шт. (ID: xxxxxxxx), не длиннее 128 символов — при усечении жертвуем описанием, маркер неприкосновенен. - D59. Надзиратель за фискальным периодом работает всегда. Отдельный суточный цикл, независимый от автоматического режима — именно потому, что случай, ради которого он существует, это выключенный или сломавшийся автомат. Эскалация по незакрытому прошлому месяцу: 1-е число — предупреждение, с 5-го — тревога ежедневно, с 9-го — «срок вышел». Правовая рамка: ст. 14 ч. 3 ФЗ-422 даёт отсрочку до 9-го числа только для расчётов, не связанных с электронными средствами платежа; оплата картой — ЭСП, поэтому строгое прочтение требует чек в момент расчёта. Автоматический режим удовлетворяет обеим трактовкам, поэтому спор решать не потребовалось.
- D60. Покупателю уходят три письма, и ни одно не трогает платёжный путь. Подтверждение покупки
(сразу, с прямой оговоркой, что это не чек и что чек придёт отдельно), фискальный чек ссылкой
после регистрации и уведомление об аннулировании после возврата — иначе у покупателя осталась бы
ссылка на мёртвый чек. Только по-русски, локализация не нужна. Первое письмо едет по уже
существующему outbox'у
payments.payment_eventsна собственном курсореmailed_at, два других — по колонкам строки выгрузки; поэтому в коде зачисления и возврата не потребовалось менять ни строки, а сбой релея откладывает письмо, а не теряет его. Адрес — подтверждённый email-якорь D36, который прямая покупка требует и так. Возврат, случившийся раньше выгрузки, закрывается какnot_required: ни чека, ни аннулирования, ни обращения к сервису.
Заметки к оформлению документов
- Язык документов (решено).
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-D60)
Интервью завершено. Дальше — оформление документов и реализация по релизам. D53-D60 добавлены отдельным интервью 2026-07-28 по выгрузке в «Мой налог».
Дополнение 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).