The merchant accepts payments as a sole proprietor on НПД, which is outside 54-ФЗ: there is no online cash register, YooKassa does not serve receipts for that regime at all (checked with their support), and each operation is reported by the merchant to «Мой налог», which issues the чек. So no `receipt` is sent with a payment or a refund. This also removes a failure class rather than just code: a malformed receipt was an API error at payment creation, which broke the purchase outright. The fiscal code is kept dormant rather than deleted. All of it now sits behind one switch, `BACKEND_YOOKASSA_VAT_CODE`: unset — the default — builds and sends nothing; a 54-ФЗ rate code turns «Чеки от ЮKassa» back on unchanged. The return is foreseeable, which is why the switch exists: НПД carries an annual income ceiling, and losing the regime puts 54-ФЗ back in force, at which point this is a deploy-variable edit instead of writing the integration again. The D36 email anchor still gates a direct purchase. It had two justifications — a recovery anchor and the receipt address — and only the second is gone; without an email a paying customer who loses the account loses the chips with it. What was missing is that the rule was enforced but never communicated: the wallet showed the packs to a player signed in through VK or Telegram in a browser, and tapping Buy produced a bare "something went wrong". It now says "add an email in your profile" in the buy tab instead, linking to the profile; spending already-earned chips is untouched. The predicate is a pure function so it is covered by the node-env unit tests rather than needing a browser. Tests: the receipt-off default is pinned by an integration test asserting a purchase carries no receipt, and the dormant path by one that switches a VAT code on and checks the receipt reappears with the right fiscal attributes; plus unit coverage for the enable predicate and the wallet's email rule. A note for whoever runs the numbers next: the shared (svelte + i18n) chunk is now 40 bytes under its 31 KB gzip budget. Decision D51 revised.
48 KiB
PAYMENTS — механики монетизации
Русское зеркало PAYMENTS.md (английская версия — основная). Описывает
домен монетизации: игровую валюту, кошельки, ценности, правила комплаенса сторов, приём
платежей, рекламу, каталог, журнал операций и отчёты. Каждую правку PAYMENTS.md зеркалим
сюда в том же PR (как FUNCTIONAL.md/FUNCTIONAL_ru.md).
Читать перед любым изменением платёжного поведения. Технический план внедрения —
../PLAN.md.
Статус: спецификация утверждена; внедрение поэтапно (см.
PLAN.md). В проде пока ничего из этого нет.
1. Обзор
Игра зарабатывает двумя независимыми каналами:
- Покупки игровой валюты «Фишка» за реальные деньги, затем трата Фишек на ценности (бенефиты).
- Реклама — ролик за награду пополняет Фишки; полноэкранный ролик и наш баннер зарабатывают показами.
Валюта двухуровневая:
деньги (Голоса VK / Stars TG / рубли через ЮKassa) ─┐
├─► Фишки ──► ценности
просмотр ролика за награду ─┘ (без рекламы,
подсказки,
турнирный взнос)
Фишки — единая единица витрины. Деньги и просмотры пополняют Фишки; Фишки покупают ценности. Прямой оплаты ценности деньгами нет — всегда через Фишки.
2. Модель валюты
Одна Фишка равна одной Фишке везде — единица единая. Разница между методами оплаты целиком в курсе покупки: пакет Фишек стоит X Голосов / Y Stars / Z рублей, и курс учитывает комиссию каждого стора. Цены ценностей фиксированы в Фишках и одинаковы для всех методов.
Баланс Фишек сегментирован по «источнику» (source) — платформе, где Фишки пополнены:
source |
Чем пополняется |
|---|---|
vk |
Покупка за Голоса VK, ролик за награду в VK |
telegram |
Покупка за Stars TG |
direct |
Покупка через ЮKassa (web / native) |
Один аккаунт держит все три сегмента одновременно: баланс = (account_id, source), до трёх
записей — запись материализуется лениво при первом пополнении сегмента, отсутствующий сегмент
читается как ноль. Сегментация не по привязке (identity) — см. §6.
Почему сегментировано, а не один общий баланс: правила сторов запрещают активировать
ценность, оплаченную мимо их кассы. Фишки, пополненные внутри VK (Голоса), тратятся только
в контексте VK; Stars — только внутри Telegram; Фишки от ЮKassa (direct) — только вне
сторов. См. §4.
Мультимагазинный direct-рельс (D42). Рельс direct маршрутизирует в отдельный магазин
ЮKassa на канал — web и android (RuStore), позже ios — по трастовому подтипу
X-Platform; каждый магазин зачисляет в единый кошелёк direct (отдельных кошельков на канал нет).
Это разделение merchant-аккаунтов только для учёта / чеков: заказ хранит свой shop (виден в
админ-отчёте). Точка приёма уведомлений у всех магазинов одна — /pay/yookassa/notify; входящее
уведомление привязывается к магазину по идентификатору магазина, который сообщает сам платёж
(recipient.account_id), с откатом на канал, записанный в заказе, и подтверждающее чтение (§9)
выполняется кредами именно этого магазина. В standalone-приложениях (Android/iOS) вход только по
email, поэтому у direct-покупки всегда есть email-якорь D36 (D43) — он же адрес доставки чека.
Фискализация (54-ФЗ под одним ИП) — один источник независимо от числа магазинов (D41).
Robokassa законсервирована, но не удалена (D47). До ЮKassa она обслуживала рельс direct. Код,
тесты и проводка остаются в дереве, и рельс откатывается на неё, если ни один магазин ЮKassa не
настроен, — поэтому возврат к ней это смена кредов, а не правка кода; сегодня эти креды не задаёт ни
один контур. backend/internal/robokassa/README.md хранит список выведенных переменных, настройки
кабинета и порядок возврата рельса. Строки журнала, записанные при её жизни, сохраняют
provider = 'robokassa' — этот литерал несущий для индекса идемпотентности, его не переименовывают и
не переиспользуют.
Рубильник платежей (D45/D46). Оператор выключает покупки на рельсе/канале (direct:web /
direct:android / vk / telegram) или для одного аккаунта, живьём из /_gm, и юзер на следующей
попытке видит локализованную причину (payment_unavailable, едет на аддитивном
ExecuteResponse.message). Fail-open: рельс без строки статуса — включён. Per-account allow
обходит только этот ops-рубильник, не security-гейты (D46).
3. Три операции, которые нельзя путать
Не смешивать — у них разные ключи:
- Пополнить Фишки — деньги/реклама → сегмент
sourceФишек, по контексту исполнения. - Потратить Фишки = купить ценность — сегмент → бенефит, по контексту с гейтом (§4).
Здесь бенефит рождается и помечается своим
origin. - Применить бенефит во времени (напр. «без рекламы до T») — по правилу
origin(§5).
source (где пополнены Фишки) и origin (где куплена ценность) используют одно множество
значений {vk, telegram, direct}, но значат разное. В вебе они расходятся: покупка в вебе
может списать vk-Фишки (source=vk) в direct-покупку (origin=direct).
4. Гейт комплаенса сторов
Стена комплаенса односторонняя. Опасное направление — активация оплаченной снаружи ценности внутри обёртки стора — заблокировано; безопасное (бенефит стора действует наружу, в открытом вебе) — разрешено.
Контекст траты → какие сегменты/бенефиты доступны:
| Контекст исполнения | Тратимые сегменты Фишек | Приоритет траты |
|---|---|---|
| Внутри VK (Android) | vk |
— |
| Внутри VK (iOS) | vk (заморожена покупка) |
— |
| Внутри Telegram | telegram |
— |
| Web / native (Direct) | direct + vk + telegram |
direct → vk → tg |
- Внутри VK/TG доступен только одноимённый сегмент; всё остальное (в первую очередь
direct) там невидимо как тратимое. - В вебе у стора нет юрисдикции, поэтому доступны все привязанные сегменты, списываются по приоритету direct → vk → tg.
- VK iOS — заморозка ПОКУПОК, а не траты. ToS Apple запрещает там только покупать внутриигровые ценности (за любую валюту) — поэтому покупка (деньги → Фишки) отклоняется. А тратить Фишки VK-кошелька (заработанные рекламой или купленные на том же VK-аккаунте, напр. в VK Android) и зарабатывать их — легально и на VK iOS разрешено; купленный бенефит там тоже действует. Блокируется только шаг «деньги внутрь».
Аккаунт единый (привязки сливаются, один профиль/друзья/статистика). Гейт логический: в контексте VK/TG сервер активирует только одноимённый сегмент. Держится на доверенном сигнале платформы (§8) — клиенту не верим. Когда платформу нельзя доверенно установить, гейт fail-closed: траты/покупки запрещены, только просмотр.
Одностороннее применение бенефита
Бенефит несёт origin = контекст покупки (не «чем оплачено»). Покупка в вебе за
vk-Фишки всё равно даёт origin=direct.
origin |
Где действует бенефит |
|---|---|
vk / telegram |
Везде — внутри своего стора и наружу в web/native |
direct |
Только web/native — никогда внутри VK/TG (= бан) |
Перед тратой в вебе vk/telegram-Фишек интерфейс предупреждает, что ценность будет
доступна только здесь (web/native) из-за ограничений VK/TG.
5. Ценности
Три разные сущности, не смешивать:
- Фишки — валюта. Сегмент по
source. - Подсказки — расходник, покупается за Фишки. Сегмент по
origin. Тратятся по одной в онлайн-играх; вvs_aiподсказки бесплатны/безлимитны (30-мин кулдаун, не в счёт). Подсказка в игре списывается изorigin, применимого в текущем контексте (то же одностороннее послабление). - Без рекламы — срок-бенефит, покупается за Фишки. Сегмент по
origin.
Складывание «без рекламы». Покупка срока продлевает paid_until[origin] += срок от
max(сейчас, текущий конец) — остаток не теряется («сроки плюсуются»). Навсегда —
отдельный вечный флаг, перекрывает сроки. «Реклама выключена в контексте P» ⇔ есть
применимый в P origin с paid_until > сейчас (в вебе берём максимум по direct/vk/tg; в
VK только vk; в TG только tg).
Что гасит «без рекламы»: верхний баннер и полноэкранный ролик после хода. Добровольный ролик за награду (за Фишки) не гасится — это выбор пользователя.
Турнирный взнос — будущий тип ценности; атом заложен, механика позже.
6. Жизненный цикл кошелька
Правило доступности сегмента. Сегмент тратим ⇔ на аккаунте есть привязка этого
source (для direct — устойчивая привязка/email). Отсюда unlink/мерж выводятся
естественно.
Отвязка (vk/tg). Разрешена даже при ненулевом балансе/активном бенефите. Сегмент не
сжигается — он засыпает (нет привязки ⇒ недоступен в VK и, без привязки, недоступен
как подтянутый веб-сегмент); повторная привязка будит. Перед отвязкой предупреждение
(«N Фишек станут недоступны до повторной привязки»). Последнюю привязку отвязать нельзя
(существующий ErrLastIdentity).
Мерж. Сегменты и бенефиты сливаются по origin: одноимённые складываются (Фишки
суммируются, сроки бенефита продлеваются по origin), разные сосуществуют. Это расширяет
текущий мерж аккаунтов (hint_balance +=, paid_account OR=), так что origin сохраняется и
ничего не протекает между платформами.
Гость. У гостевого аккаунта вообще нет кошелька — раздел «Кошелёк» скрыт, покупок
нет, баланса нет, by design. Балансы — только у durable-аккаунтов. Поэтому чистильщик
гостей удаляет их свободно (денег там быть не может). В direct email обязателен перед
первой покупкой как якорь восстановления (в VK/TG якорь — сама vk/tg-привязка);
переиспользуем существующий флоу email (запрос кода → подтверждение → снятие флага гостя →
durable).
7. Каталог и цены
Каталог конфигурируемый — продукты, цены, курсы покупки и награда за ролик живут в базе и правятся в админке, без релиза.
- Базовые ценности (атомы): Фишки, подсказки, дни без рекламы, участие в турнире.
- Продукт = набор атомов + цена. Продаётся по одной или комбо (напр. «250 подсказок + 30 дней без рекламы»).
- Пакет Фишек (пополняет Фишки) — цена per-метод (мультивалютная Голоса/Stars/руб) одним продуктом.
- Ценность (за Фишки) — цена в Фишках (единая для всех методов).
Деактивация, не удаление. Продукты деактивируются (soft-delete). Состоявшаяся покупка хранит снимок проданного (состав атомов + цена на момент) в архиве, чтобы история/чеки/налоги не зависели от последующих правок каталога.
8. Доверенный сигнал платформы
Гейту (§4) нужен доверенный, неподделываемый контекст платформы на сервере. Клиент — никогда не источник правды.
- Платформа — свойство сессии, фиксируется при создании сессии. Обёртки VK и Telegram
пересоздают сессию (и потому заново проверяют подпись запуска — VK launch-params
sign/ TelegraminitData) на каждом холодном старте, поэтому их платформа переподтверждается при каждом запуске;direct-сессия фиксирует платформу один раз, самим фактом создания веб/native-сессии (внешней подписи нет и не нужно — доступ к vk/tg-сегментам в direct-контексте всё равно требует реальной привязки, §6). - Платформа несёт kind (
vk/telegram/direct) плюс подтип (ios/android/web).kindдоверенный всегда — сервер выводит его из проверенного запуска, не из клиентского поля. Подтип доверенный только у VK: он лежит внутри подписанных параметров запуска, что и делает заморозку VK iOS выполнимой; у Telegram и direct подтип сообщает клиент, он best-effort, и гейт на него не опирается. - Гейтвей резолвит сессию и передаёт
platformв бэкенд (рядом с существующимX-User-ID), беря его из сессии — не из тела клиентского запроса. - Fail-closed: недоверенная платформа — сессия без записанной платформы, созданная до этой функции или которую гейтвей не смог атрибутировать — запрещает траты/покупки и применение любого чужого origin (только просмотр). VK/TG-сессия восстанавливается на следующем холодном старте (пересоздание), переиспользуемая direct/email-сессия — при повторном входе.
9. Приём платежей
Только серверный колбэк провайдера. Фишки начисляются лишь по проверенному серверному
колбэку — уведомление ЮKassa / TG successful_payment / VK callback. Клиентское «я оплатил»
игнорируется. Что значит «проверенный», зависит от рельса: VK и Telegram подписывают свои колбэки,
а ЮKassa — нет (D48), см. подтверждающее чтение ниже.
Единственный писатель. Один платёжный домен — единственный, кто пишет в журнал операций.
Публичные вебхуки (ЮKassa/VK) терминируются на краю (Caddy/gateway) и проксируются в
платёжный домен; TG successful_payment приходит боту, тот форвардит в платёжный домен.
Одно место начисляет и защищает от повторов.
Флоу заказа. Сервер заранее создаёт order(pending) с account / платформой / пакетом /
ожидаемой суммой / origin. order_id прокидывается провайдеру (ЮKassa metadata.order_id / TG
invoice_payload / VK item). Колбэк матчится по order_id (никогда по сумме, поэтому коллизии
одинаковых сумм невозможны), сверяет сумму, начисляет, помечает paid. Защита от повторов:
дедуп по (провайдер, provider_payment_id).
Direct-рельс (ЮKassa). Открытие покупки — исходящий вызов API: сервер создаёт платёж
(POST /v3/payments, capture: true, подтверждение через redirect, идентификатор заказа и как
Idempotence-Key, и как metadata.order_id, плюс фискальный чек из §12) и отправляет покупателя на
полученный confirmation_url. Идентификатор платежа сразу записывается в заказ — именно он позволяет
потом перепроверить заказ и оформить возврат. Страница возврата браузера (/pay/yookassa/return)
косметическая: начисление никогда не едет на редиректе.
Уведомление — подсказка, а не доказательство (D48). ЮKassa не подписывает уведомления, поэтому
действовать по содержимому тела нельзя. Оно лишь называет платёж, который сервер затем перечитывает
запросом GET /v3/payments/{id}; доверяем только этому ответу. На нём же держатся две проверки:
метаданные платежа должны называть тот самый заказ, а его флаг test — совпадать с флагом магазина,
поэтому платёж тестового магазина никогда не начислит настоящие Фишки (и наоборот). В качестве
эшелонированной защиты — и чтобы подделыватель не превращал каждое фальшивое уведомление в наш
исходящий запрос — адрес отправителя сперва сверяется с опубликованными диапазонами ЮKassa. Ответ
сообщает провайдеру, повторять ли доставку: 200 на всё, что решено окончательно, включая дубль и
неустранимый отказ, и 5xx только на временный сбой (ЮKassa повторяет доставку 24 часа).
Сверка на истечении заказа (D49). Постоянного опроса нет. Но безвозвратно потерянное уведомление оставило бы деньги списанными, а Фишки — не выданными, и молча. Поэтому уже существующий жнец просроченных заказов спрашивает у провайдера судьбу каждого заказа, который дожил до своего срока с идентификатором платежа, и начисляет те, что на самом деле оплачены. Один запрос на заказ за всю его жизнь.
Об отклонённой оплате сообщаем. payment.canceled от ЮKassa пишет событие failed, поэтому
покупатель узнаёт, что попытка не прошла, вместо разглядывания неменяющегося баланса. Просто
брошенный заказ не пишет ничего — он молча истекает.
Pending невидим пользователю; авто-истекает по таймауту (~30 мин, гигиена базы).
Валидный колбэк исполняется всегда, даже на истёкшем заказе (expired ≠ отмена —
деньги реальны, Фишки должны быть выданы). Пользователь видит только успешные покупки.
TG Stars. До Telegram дотягивается только бот, поэтому весь рельс идёт через обратный
mTLS bot-link (бот ↔ gateway; напрямую к бэкенду бот не ходит). Инвойс создаёт бот: на пути
заказа gateway шлёт команду CreateInvoice, а бот возвращает createInvoiceLink (XTR) в Ack,
который Mini App открывает через WebApp.openInvoice. До списания звёзд бот отвечает на
pre_checkout_query через унарный вызов бот→gateway ValidatePreCheckout (за ним — приём): одобрить,
только если заказ существует, ещё оплачиваем и не оплачен ранее — ссылка Stars-инвойса
переиспользуема, так что этот гейт — единственное место, где повторная оплата отсекается до
списания; текст отказа локализован в язык аккаунта заказа.
Outbox TG-бота. successful_payment приходит только боту (Bot API, не Mini App), а хост бота
слабый и может терять связь, поэтому бот — durable-звено. Store-and-forward на SQLite на диске
бота (internal/outbox): сохранил при получении (идемпотентно по telegram_payment_charge_id) →
форвардит по bot-link (унарный ForwardPayment; gateway проксирует в приём) → при durable-ответе
пометил forwarded. Дореталивает недоставленное при рестарте и по периодическому тику. Доставка
at-least-once + идемпотентный приём (дедуп по telegram_payment_charge_id) = начисление ровно один
раз.
События. Платёжный домен пишет payment_events (succeeded / failed / refunded);
диспетчер рассылает по каналам — live gRPC-стрим, если пользователь в аппе, иначе
существующий пуш botlink / email. «Оплата не прошла» (активный отказ провайдера, не
брошенный pending) доводится до пользователя; «оплата прошла» — хук (письмо / сообщение в
бота).
Возвраты. ToS — невозвратно, пользователю возврат не предлагаем. Возвраты инициирует
админ (консоль E7). На direct-рельсе консоль делает всю работу одним нажатием (D50): сперва
вызывает refund-API ЮKassa (POST /v3/refunds, Idempotence-Key — идентификатор заказа, с чеком
возврата из §12) и записывает реверс только после того, как деньги действительно ушли, — неудачный
вызов не пишет ничего, поэтому журнал не может заявить о возврате, которого не было. Возврат
записывается только в статусе succeeded: ещё не завершённый (pending) может отмениться, а
журнал только на добавление, поэтому ранняя запись отобрала бы у покупателя Фишки за деньги, оставшиеся у
нас. Выход — нажать ещё раз: ключ идемпотентности вернёт тот же возврат, а не заплатит дважды.
Записывается собственный refund-id провайдера: по нему журнал сверяется с данными ЮKassa.
Кабинет — вторая точка входа (D52). В отличие от прежних рельсов, ЮKassa позволяет оформить
возврат прямо в кабинете магазина, и такой возврат не проходит через наш API — деньги ушли бы назад,
а Фишки остались бы начисленными. Это закрывает уведомление refund.succeeded: возврат
перечитывается из API (тело уведомления — такое же не-доказательство, как и у платежа), привязывается
к заказу через идентификатор платежа, который мы записали, и проводится тем же движком —
идемпотентно по (провайдер, refund-id), поэтому уведомление о возврате, уже записанном консолью,
ничего не отзывает повторно. Движок по устройству работает только с полным возвратом (отзывает
ровно то, что профондировал пакет, и отвергает любую другую сумму), поэтому частичный возврат не
записывается вовсе и громко логируется для оператора: непроизвольного способа решить, скольких Фишек
стоит часть возврата, нет.
Возвраты VK по-прежнему через поддержку, TG Stars — вызовом refundStarPayment, оба фиксируются
руками после факта. Все сходятся на одном движке — метод Refund
(internal/payments): матчит оплаченный заказ, пишет refund-строку журнала (идемпотентно по
(provider, provider_refund_id) — refund-id отличается от payment-id fund'а, поэтому строки
сосуществуют под тем же partial-unique индексом) и по возможности отзывает начисленные Фишки с
полом 0 (в минус не уходим — D27, balances_chips_chk). Если Фишки уже потрачены, невозвратный
остаток фиксируется как убыток + флаг злоупотребления per-account (payments.account_risk, читает
отчёт E7). Дельта Фишек в refund-строке — то, что реально отозвано, поэтому журнал остаётся сверяемым
с балансом; полный реверс (деньги, исходные Фишки, убыток) лежит в snapshot строки. Заказ остаётся
paid — возврат живёт в журнале + событии refunded, не в статусе заказа. Повторный возврат не
отзывает ничего. Журнал операций спроектирован экспортопригодным для будущей налоговой отчётности
и сверки (саму сверку пока не строим; схема остаётся совместимой).
10. Реклама
Охват на старте: только VK для видео (награда в рублях, ОРД автоматом, API готов). web/native/TG держат только существующий наш текст-баннер; видео отложено до появления рублёвой in-app сети. Рекламный провайдер за абстракцией, чтобы будущая сеть для других платформ встроилась без переделки. Крипто-сети (AdsGram/AdMob) отвергнуты — нет легального рублёвого дохода самозанятому (НПД).
Ролик за награду (добровольное видео за Фишки) начисляет Фишки через платёжный домен. VK Mini
App отдаёт только клиентский результат просмотра (VKWebAppShowNativeAds → data.result) —
серверной проверки нет — поэтому начисление client-attested (D29 амендим: server-verify у VK
невозможен). Защита — серверный дневной и часовой кап (config reward_daily_cap /
reward_hourly_cap, дефолт 50 / 10): он ограничивает читера, который пропускает ролик и дёргает
эндпоинт напрямую, и — не менее важно — лимитирует бесплатные Фишки, чтобы желающий больше покупал.
Начисление идемпотентно по клиентскому nonce и order-less; выплата — config
(rewarded_payout_chips, дефолт 0 = ролик выключен, пока не задан). Rewarded только в VK и не
гасится «без рекламы» (D9). Сеть с серверным verify встроится за ads-абстракцией. На тест-контуре
build-флаг (VITE_ADS_STUB) подменяет ролик тостом; прод всегда крутит настоящую рекламу.
Полноэкранный ролик (после подтверждённого хода), только VK, конфигурируемые серверные
значения. Гейт зеркалится на клиенте: профиль несёт кулдауны и флаг suppressed (тот же гейт
«без рекламы» / no_banner, что и у баннера, считается на сервере в adsFor), а клиент сам
гейтит по единому времени последнего показа в localStorage — вид лишь выбирает нужный
интервал, поэтому hint-ролик и move-ролик не встают подряд в пределах кулдауна (один общий таймер,
не по одному на вид) — без серверного раунд-трипа на каждый ход. Контурный VITE_ADS_STUB подменяет
ролик тем же тостом «ad fired». Значения:
- Глобальный кулдаун на пользователя, сквозь все партии, дефолт 5 мин.
vs_ai— 30 мин (соосно кулдауну подсказок, чтобы не отпугивать казуалов).- Применение подсказки триггерит ролик независимо от основного кулдауна, со своим кулдауном 1 мин.
- Показывается только после подтверждённого хода или подсказки — никогда после пропуска, обмена или сдачи (ролик за не-очковое действие только раздражает, за них не «награждаем»).
- Оффлайн — только баннер.
- Уважать собственные лимиты частоты VK.
Оффлайн без рекламы, кроме нашего баннера. Бенефит без рекламы гасит баннер через
существующий ads.Eligible (backend/internal/ads/ads.go), который расширяется до гейта по
origin-бенефиту, применимому в текущем контексте, а не по одному глобальному флагу.
11. Админ, аудит, отчётность
Неизменяемый журнал операций + материализованный баланс. Журнал операций только на
INSERT (никогда UPDATE/DELETE — полный аудит). Балансы сегментов (account, source) и
бенефиты (account, origin) — быстрый материализованный кэш, обновляется в той же
транзакции, что и запись журнала, и пересчитывается из журнала для сверки.
In-process кэш чтения. Поверх материализованных таблиц пакет payments держит
in-process кэш сегментов и бенефитов по ключу-аккаунту (write-through), чтобы горячие пути
чтения — проверка показа рекламы, доступность подсказок, экран кошелька, гейт траты — на
устоявшемся пути не делали ни одного запроса к схеме payments. Кэш инвалидируется на
каждой изменяющей операции payments (трата / грант / пополнение / возврат / мерж) и
перечитывается из материализованных таблиц при промахе (тот же write-through-паттерн, что и у
гейта блокировок аккаунта). Один инстанс — под текущий деплой; многоинстансный backend
потребовал бы общего кэша. Присутствие identity (какие сегменты «не спят», §6) передаёт
вызывающий, здесь не кэшируется, поэтому отвязка/повторная привязка действует сразу.
Награждение админом. Админ начисляет только конкретные ценности (без рекламы /
подсказки) — никогда не Фишки (подаренный баланс валюты = обход кассы стора). Выдаёт либо
сырыми атомами, либо готовым продуктом-ценностью (набор-награда, возможно архивный — скрыт
из магазина, но выдаётся); оба отказывают на атоме chips или tournament. Админ
выбирает origin при выдаче (ответственность за комплаенс на нём: origin=vk
точечно/малый объём = низкий риск, origin=direct = безопасно). Грант — транзакция журнала
типа admin_grant, цена 0 Фишек (грант по продукту пишет исходный product_id + снапшот) —
полный аудит наград.
Финансовый отчёт по пользователю в админке /_gm — балансы сегментов, платежи, траты,
гранты, возвраты, полная история — расширение существующей карточки (UserDetailView,
handlers_admin_console.go). Плюс экспорт журнала.
12. Налоги и комплаенс
Чеки формируются автоматически на стороне провайдера и отличаются по каналу:
-
ЮKassa (direct) — чек не передаём, так задумано (ревизия D51). Владелец работает на НПД, а это вне 54-ФЗ: онлайн-кассы нет, провайдер ничего не регистрирует. О каждой операции владелец сообщает в «Мой налог», он и формирует чек. ЮKassa при таком режиме налогообложения с чеками не работает вовсе.
Фискальный код законсервирован, а не удалён: «Чеки от ЮKassa» (касса, фискальный накопитель и договор с ОФД на стороне ЮKassa) регистрируют чек только если запрос его несёт, и вся сборка такого запроса спрятана за одним переключателем —
BACKEND_YOOKASSA_VAT_CODE. Пусто —receiptне собирается и не отправляется; задан код ставки по 54-ФЗ (тег 1199) — снова уходит одна позиция (название пакета, количество 1, сумма) с признаком предмета расчётаservice(тег 1212) и способа расчётаfull_payment(тег 1214), доставка на email-якорь D36. Переключатель нужен потому, что возврат к чекам предсказуем: у НПД есть годовой потолок дохода, и потеря режима возвращает 54-ФЗ.Всё, что потребуется автоматической отправке в «Мой налог», уже записано — идентификатор заказа, идентификатор платежа провайдера, сумма и валюта, время зачисления, снимок проданного пакета и возвраты отдельными строками журнала, всё выгружается в CSV. Не хватать будет только отметки «эта операция уже отправлена» для идемпотентности самой выгрузки; ей место в той задаче, а не здесь.
-
VK — VK сам процессит Голоса через налоговую; делать нечего.
-
TG Stars — налоговой стороны нет (для РФ-самозанятого Stars легально невыводимы = не доход НПД; принимаем, чек не формируем).
ОРД (маркировка рекламы) по VK-рекламе — на стороне VK. (Не юридическая консультация — владелец сверяет точную схему НПД с налоговым консультантом.)
13. Дистрибуция (native Android)
- RuStore — внешний платёжный гейт разрешён (0%); native = чистый контекст
direct. - Google Play — direct-покупки скрыты; «Кошелёк» показывает заглушку («установите версию из RuStore для покупок»). Ролик за награду и трата уже накопленных Фишек работают. Перед GP-релизом свериться с актуальными правилами Google по внутренней валюте.
14. Модель данных (схема payments)
Платёжный домен живёт в своей схеме payments в общем инстансе Postgres, со своим
DB-ролём (права только на payments) и доменным пакетом за жёстким интерфейсом.
Cross-schema внешнего ключа к backend.accounts нет — идентификатор аккаунта здесь
обычное значение, согласуемое в коде и связываемое с tombstone-аккаунтом / досье
retained-identities по стабильному id. Это держит трату «Фишки↔бенефит» атомарной внутри
payments и делает домен извлекаемым в свою базу. Сохранность — PITR (непрерывный
WAL-архив), независимо от топологии базы.
Основные таблицы (финальные имена/колонки — в PLAN.md):
- журнал операций (ledger) — append-only операции: пополнение / трата /
admin_grant/ возврат;(провайдер, provider_payment_id)уникален для защиты от повторов; экспортопригоден. - балансы — материализованные
(account_id, source) → Фишки. - бенефиты — материализованные
(account_id, origin)→ «без рекламы»paid_until/forever+ счётчик подсказок. - каталог — атомы + продукты (деактивируемые), цены per-метод, курсы покупки Фишек, награда за ролик.
- заказы (orders) — pending-покупки,
order_id, ожидаемая сумма, origin, статус (pending/paid/expired). - payment_events — succeeded/failed/refunded для диспетчера.
Legacy accounts.hint_balance и accounts.paid_account устаревают в пользу
сегментированной модели и удаляются в отдельной contract-миграции (expand-contract, после
того как валютное ядро переключит чтения, и после Release 2 — откат образа остаётся безопасным
для БД); ни то, ни другое в проде никогда не выставлялось (потока покупки не было), поэтому
legacy-значения обнуляются.
15. Словарь
- Фишка — игровая валюта; единая единица, сегментирована по
source. - source (источник) — где пополнены Фишки (
vk/telegram/direct). - origin (происхождение) — где куплена ценность; определяет, где действует бенефит.
- ценность / бенефит — то, что покупается за Фишки (без рекламы, подсказки, турнирный взнос).
- гейт — одностороннее правило комплаенса сторов (§4).
- журнал операций (ledger) — неизменяемая запись всех операций с деньгами/ценностями.
- платёжный канал / рельса — платёжный провайдер (ЮKassa / Голоса VK / Stars TG).