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.
37 KiB
PAYMENTS — monetization mechanics
Authoritative specification of the monetization domain: the in-game currency, wallets,
benefits, store-compliance rules, payment intake, ads, catalog, ledger, and reporting.
English is authoritative; PAYMENTS_ru.md mirrors it in plain Russian
(mirror every point edit in the same PR, like FUNCTIONAL.md/FUNCTIONAL_ru.md).
Read this before changing any payments behaviour. The technical implementation plan lives
in ../PLAN.md.
Status: specification approved; implementation staged (see
PLAN.md). Nothing here is live yet.
1. Overview
The game earns through two orthogonal channels:
- Purchases of the in-game currency Фишка (chips) with real money, then spending chips on values (benefits).
- Ads — a rewarded video tops chips up; an interstitial and a house banner monetize by impression.
The currency is two-tier:
money (VK Votes / TG Stars / RUB via YooKassa) ─┐
├─► Фишки (chips) ──► values
rewarded ad view ─┘ (no-ads, hints,
tournament fee)
Chips are the single storefront unit. Money and rewarded views fund chips; chips buy values. There is no path that spends money directly on a value — always through chips.
2. Currency model
One chip is one chip everywhere — the unit is uniform. The difference between payment methods lives entirely in the purchase rate: a chip pack costs X Votes / Y Stars / Z RUB, and the rate absorbs each store's commission. Value prices are fixed in chips and identical across methods.
The chip balance is segmented by source — the platform where the chips were funded:
source |
Funded by |
|---|---|
vk |
VK Votes purchase, VK rewarded ad |
telegram |
TG Stars purchase |
direct |
YooKassa purchase (web / native) |
A single account holds all three segments simultaneously: balance = (account_id, source),
up to three rows — a row is materialised lazily on the segment's first funding, and an absent
segment reads as zero. Segmentation is not per-identity — see §6.
Why segmented, not one pooled balance: store rules forbid activating value that was paid
for outside the store's own cash desk. Chips funded inside VK (Votes) may only be spent in
the VK context; Stars only inside Telegram; YooKassa-funded (direct) chips only outside
the stores. See §4.
Multi-shop direct rail (D42). The direct rail routes to one YooKassa merchant shop per
channel — web and android (RuStore), ios later — chosen by the trusted X-Platform subtype;
every shop credits the one direct wallet (no per-channel wallet). This is merchant-account
separation for accounting / receipts only: the order records its shop (shown in the admin report).
All shops share one notification endpoint, /pay/yookassa/notify; an incoming notification is
attributed to a shop by the shop id the payment reports (recipient.account_id), falling back to the
channel recorded on the order, and that shop's credentials perform the confirming read (§9).
Standalone apps (Android/iOS) sign in by email only, so a direct purchase always has the D36 email
anchor (D43), which is also where the fiscal receipt is delivered. Fiscalization (54-ФЗ under one ИП)
is a single source regardless of the number of shops (D41).
Robokassa is retired, not deleted (D47). It settled the direct rail before YooKassa. Its code,
tests and wiring stay in the tree and the rail falls back to it when no YooKassa shop is configured,
so reviving it is a credentials change rather than a code change; no deployment sets those
credentials today. backend/internal/robokassa/README.md records the retired variables, the cabinet
configuration and the revival steps. Ledger rows written while it was live keep
provider = 'robokassa' — that literal is load-bearing for the idempotency index and is never
renamed or reused.
Payment availability kill switch (D45/D46). An operator can disable purchases on a rail/channel
(direct:web / direct:android / vk / telegram) or for one account, live from /_gm, and the
user sees a localized reason on their next attempt (payment_unavailable, carried on the additive
ExecuteResponse.message). Fail-open: a rail with no status row is enabled. A per-account
allow override bypasses only this operational switch, never the security gates (D46).
3. Three operations, kept distinct
Do not conflate these — they use different keys:
- Fund chips — money/ad → chip
sourcesegment, keyed by the execution context. - Spend chips = buy a value — segment → benefit, keyed by context with the gate (§4).
This is where a benefit is born and stamped with its
origin. - Apply a benefit over time (e.g. "no-ads until T") — governed by the origin rule (§5).
source (where chips were funded) and origin (where a value was bought) share the same
value set {vk, telegram, direct} but mean different things. On the web they can diverge:
web spending may draw vk chips (source=vk) into a direct purchase (origin=direct).
4. Store-compliance gate
The compliance wall is one-directional. The dangerous direction — activating externally paid value inside a store wrapper — is blocked; the safe direction (a store benefit leaking out to the open web) is allowed.
Spend context → which segments/benefits are usable:
| Execution context | Spendable chip segments | Spend priority |
|---|---|---|
| Inside VK (Android) | vk |
— |
| Inside VK (iOS) | vk (purchase-frozen) |
— |
| Inside Telegram | telegram |
— |
| Web / native (Direct) | direct + vk + telegram |
direct → vk → tg |
- Inside VK/TG only the same-named segment is usable; everything else (chiefly
direct) is invisible-as-spendable there. - On the web the store has no jurisdiction, so all attached segments are spendable, drained by priority direct → vk → tg.
- VK iOS is a PURCHASE freeze, not a spend freeze. Apple's ToS forbids only buying in-app values there (for any currency) — so a purchase (money → chips) is refused. Spending VK-wallet chips (earned via rewarded ads or bought on the same VK account elsewhere, e.g. VK Android) and earning them are legal and stay allowed on VK iOS; a bought benefit also applies there. Only the money-in step is blocked.
The account is single (identities merge, one profile/friends/stats). The gate is logical: in a VK/TG context the server activates only the same-named segment. It rests on a trusted platform signal (§8) — the client is never believed. When the platform cannot be trusted, the gate is fail-closed: spends/purchases are denied, view only.
One-directional benefit application
A benefit carries origin = the purchase context (not "what it was paid with"). Buying
on the web with vk chips still yields origin=direct.
origin |
Where the benefit applies |
|---|---|
vk / telegram |
Everywhere — inside its store and out on web/native |
direct |
Only web/native — never inside VK/TG (= store ban) |
Before a web spend draws vk/telegram chips, the UI warns the user that the value
will be available here (web/native) only, because of VK/TG restrictions.
5. Benefits
Three distinct entities, do not merge:
- Chips — currency. Segment by
source. - Hints — consumable, bought with chips. Segment by
origin. Spent one-per-hint in online games;vs_aihints stay free/unlimited (30-min idle gate, not counted). A hint spent in a game is drawn from anoriginapplicable to the current context (same one-directional relaxation as above). - No-ads — a duration benefit, bought with chips. Segment by
origin.
No-ads stacking. Buying a term extends paid_until[origin] += term from
max(now, current end) — the remainder is never lost ("terms add up"). Forever is a
separate perpetual flag that overrides terms. "Ads off in context P" ⇔ some origin
applicable in P has paid_until > now (on the web take the max over direct/vk/tg; in VK
only vk; in TG only tg).
What no-ads suppresses: the top banner and the post-move fullscreen interstitial. The voluntary rewarded view (for chips) is never suppressed — it is the user's choice.
Tournament fee — a future value type; the atom is provisioned but the mechanic ships later.
6. Wallet lifecycle
Segment availability rule. A segment is spendable ⇔ the account has an identity of that
source (for direct: a durable identity/email). This makes unlink/merge fall out
naturally.
Unlink (vk/tg). Allowed even with a non-zero balance/active benefit. The segment is not
burned — it sleeps (no identity ⇒ unavailable in VK and, lacking the attachment,
unavailable as an attached web segment); re-linking wakes it. The user is warned before
unlinking ("N chips will be unavailable until you re-link"). The last identity cannot be
unlinked (existing ErrLastIdentity).
Merge. Segments and benefits merge by origin: same-origin segments add (chips sum,
benefit terms extend per origin), different origins coexist. This extends the current
account-merge (hint_balance +=, paid_account OR=) so origin is preserved and nothing
leaks across platforms.
Guest. A guest account has no wallet at all — the Wallet section is hidden, no
purchases, no balance, by design. Balances belong only to durable accounts. The guest
reaper therefore deletes guests freely (no money can exist there). In direct, email is
required before the first purchase as a recovery anchor (in VK/TG the vk/tg identity is
already the anchor); the existing email flow (request code → confirm → clear guest →
durable) is reused.
7. Catalog and pricing
The catalog is configurable — products, prices, purchase rates, and rewarded payout live in the database and are edited in the admin console, no release required.
- Base values (atoms): chips, hints, no-ads days, tournament entry.
- Product = a set of atoms + a price. Sold singly or as a combo (e.g. "250 hints + 30 no-ads days").
- Chip pack (funds chips) — priced per method (multi-currency Votes/Stars/RUB) as a single product.
- Value (bought with chips) — priced in chips (uniform across methods).
Deactivation, not deletion. Products are soft-deleted (deactivated). A completed purchase stores a snapshot of what was sold (atom composition + price at the time) into an archive, so history/receipts/tax are independent of later catalog edits.
Titles reject HTML-special characters. The admin editor refuses a product title containing
& < > " ' (validateProduct). VK HTML-escapes such characters in its order_status_change
payment-callback echo (e.g. " → ") but computes the notification signature over a different
form, so our callback signature check no longer matches and the purchase silently fails to confirm —
while the get_item phase (whose request carries no title) still passes. Use « » guillemets, not
straight quotes.
8. Trusted platform signal
The gate (§4) needs a trusted, unforgeable platform context on the server. The client is never the source of truth.
- The platform is a property of the session, captured when the session is created. VK and
Telegram wrappers re-mint (and so re-validate the launch signature — VK launch-params
sign/ TelegraminitData) on every cold start, so their platform is re-confirmed each launch; adirectsession captures it once, established by the fact of a web/native session's creation (no external signer, and none needed — reaching vk/tg segments in a direct context still requires a real attachment, §6). - The platform carries kind (
vk/telegram/direct) plus subtype (ios/android/web).kindis always trusted — the server derives it from the validated launch, never a client field. The subtype is trusted only for VK: it rides inside the signed launch parameters, which is what makes the VK iOS freeze enforceable; for Telegram and direct the subtype is client-reported and best-effort, so the gate never keys off it. - The gateway resolves the session and passes
platformto the backend (alongside the existingX-User-ID), sourced from the session — never the client request body. - Fail-closed: an untrusted platform — a session with no recorded platform, one predating this feature or one the gateway could not attribute — denies spends/purchases and applying any foreign origin (view only). A VK/TG session recovers on its next cold-start re-mint; a reused direct/email session on re-login.
9. Payment intake
Server callback only. Chips are credited solely on a verified provider callback —
YooKassa notification / TG successful_payment / VK callback. A client "I paid" is ignored. What
"verified" means differs by rail: VK and Telegram sign their callbacks, and YooKassa does not
(D48) — see the confirming read below.
Single writer. One payments domain is the only writer of the ledger. Public webhooks
(YooKassa/VK) terminate at the edge (Caddy/gateway) and proxy into payments; TG
successful_payment reaches the bot, which forwards into payments. One place credits and
dedupes.
Order-flow. The server pre-creates an order(pending) with account / platform / pack /
expected amount / origin. The order_id is threaded to the provider (YooKassa metadata.order_id /
TG invoice_payload / VK item). The callback matches by order_id (never by amount, so
equal-amount collisions cannot happen), verifies the amount, credits, marks paid.
Idempotency: dedup by (provider, provider_payment_id).
Direct rail (YooKassa). Opening a purchase is an outbound API call: the server creates the
payment (POST /v3/payments, capture: true, redirect confirmation, the order id as both the
Idempotence-Key and metadata.order_id, plus the fiscal receipt of §12) and sends the customer to
the returned confirmation_url. The provider's payment id is recorded on the order straight away,
which is what later lets the order be re-checked and refunded. The browser return page
(/pay/yookassa/return) is cosmetic: the credit never rides a redirect.
The notification is a hint, never evidence (D48). YooKassa does not sign notifications, so
nothing in the body may be acted on. It only names a payment, which the server then re-reads with
GET /v3/payments/{id}; only that answer is trusted. Two guards ride on it: the payment's metadata
must name the order being credited, and its test flag must match the shop's, so a test-shop payment
can never credit real chips (nor a live payment be credited against test credentials). The reply
tells the provider whether to redeliver: 200 for anything durably decided, including a duplicate and
a permanent rejection, and 5xx only for a transient failure (YooKassa redelivers for 24 hours).
The sender address is not checked (D48 rev). It would add no security — the confirming read is the whole boundary — and the one thing it bought, keeping a forger from turning each fabricated notification into an outbound call of ours, is already bought earlier and far more tightly: the order is resolved from the notification's metadata before any provider call, so an id matching no order costs one indexed read and stops there, and guessing a live order id means guessing a uuid. An address check is also actively harmful wherever the deployment cannot observe real client addresses — a contour behind a tunnel sees only its own — where it rejects genuine notifications.
Reconciliation on a short cadence (D49 rev). No open-ended polling, but the check is not tied to the order lifetime: that governs when an unpaid order is written off, which answers "how long may a customer take to pay" — a different question from "how soon should we notice a lost callback". Tying them together would make a customer wait a full lifetime for chips whenever the notification path fails. The pending-order reaper instead asks the provider about every pending order older than a minute that carries a payment id, and credits the ones that were in fact paid. That bounds the calls one order can cause to its lifetime divided by the sweep interval — a handful — while a failure of the primary path costs minutes rather than half an hour.
A declined payment is surfaced. A YooKassa payment.canceled records a failed payment event,
so the customer is told the attempt did not go through instead of watching a balance that never
moves. An order simply abandoned records nothing — it just expires, invisibly.
Pending is invisible to the user; it auto-expires on a timeout (~30 min, DB hygiene). A
valid callback is always honoured, even on an expired order (expired ≠ cancellation —
the money is real, the chips are owed). The user sees only successful purchases.
TG Stars. Only the bot reaches Telegram, so the whole rail funnels through the reverse
mTLS bot-link (bot ↔ gateway; the bot cannot dial the backend). The invoice is minted by the
bot: on the order path the gateway sends a CreateInvoice command and the bot returns a
createInvoiceLink (XTR) in its Ack, which the Mini App opens with WebApp.openInvoice. Before any
star moves the bot answers pre_checkout_query via a bot→gateway ValidatePreCheckout unary
(backed by the intake): approve only if the order exists, is still creditable and is not already
paid — a Stars invoice link is reusable, so this gate is the one place a repeat payment is stopped
before the charge; the decline reason is localised to the order account's language.
successful_payment reaches the bot only (Bot API, not the Mini App), and the bot host is weak and
can lose connectivity, so the bot is a durable link. Store-and-forward on SQLite on the bot's
disk (internal/outbox): persist on receipt (idempotent on telegram_payment_charge_id) → forward
over the bot-link (a ForwardPayment unary; the gateway proxies to the intake) → on a durable
response, mark forwarded. Re-drives undelivered on restart and on a periodic tick. At-least-once
delivery + idempotent intake (dedup by telegram_payment_charge_id) = credited exactly once.
Events. The payments domain writes payment_events (succeeded / failed / refunded); a
dispatcher fans out over channels — the live gRPC stream if the user is in-app, else the
existing botlink push / email relay. "Payment failed" (an active provider decline, not
an abandoned pending) is surfaced to the user; "payment succeeded" is a hook (email / bot
message).
Refunds. ToS is non-refundable — we do not offer refunds to the user. Refunds are
admin-triggered (the E7 console). On the direct rail the console does the whole job in one click
(D50): it calls YooKassa's refund API first (POST /v3/refunds, the order id as the
Idempotence-Key, carrying the refund receipt of §12) and records the reversal only once the money
has actually moved — a failed call records nothing, so the ledger can never claim a refund that
did not happen. A refund is recorded only in status succeeded: one still pending can yet be
canceled, and the ledger is append-only, so recording early could revoke a customer's chips for money
that stayed with us. Pressing again is the way out — the idempotency key returns the same refund
rather than paying twice. The recorded refund id is the provider's own, which keeps the ledger
reconcilable against YooKassa's records.
The cabinet is a second entry point (D52). Unlike the earlier rails, YooKassa lets an operator
refund from the merchant cabinet, and such a refund never passes through our API — so the money would
go back while the chips stayed credited. The refund.succeeded notification closes that: the refund
is re-read from the API (the body is no more evidence than a payment notification's), bound back to
the order through the payment id we recorded, and reversed through the same engine — idempotent on
(provider, refund id), so the notification for a refund the console already recorded reverses
nothing twice. The engine is full-refund-only by design (it revokes exactly what the pack funded
and rejects any other amount), so a partial refund is recorded as nothing at all and logged
loudly for an operator: there is no non-arbitrary way to decide how many chips a part-refund costs.
The two recording paths race, benignly: YooKassa fires refund.succeeded the moment the refund is
created, so the notification often writes the reversal before the console's own write lands. Both
name the same refund id, so the second is caught by the idempotency index and nothing is revoked
twice; the console reports the refund as successful rather than as a repeated click.
VK refunds are still handled by support and Telegram Stars refunds issued with refundStarPayment,
both recorded by hand afterwards. All of them converge on one engine —
the Refund method (internal/payments): it matches the paid order, appends a refund ledger
row (idempotent on (provider, provider_refund_id) — the refund id is distinct from the fund's
payment id, so the two rows coexist under the same partial-unique index), and best-effort revokes
the funded chips floored at 0 (never negative — D27, balances_chips_chk). When the chips were
already spent, the unrecoverable remainder is recorded as a per-account loss + abuse flag
(payments.account_risk, read by the E7 report). The refund ledger row's chip delta is what was
actually reclaimed, so the ledger stays reconcilable against the balance; the full reversal
(money, original chips, loss) rides in the row's snapshot. The order stays paid — the refund lives
in the ledger + a refunded payment event, not in the order status. A duplicate refund reverses
nothing. The ledger is export-ready for future tax reporting and reconciliation (the reconciler
itself is not built yet; the schema stays compatible).
10. Ads
Launch scope: VK only for video (rewarded payout in RUB, ОРД automatic, API ready). web/native/TG keep the existing house text banner only; video is deferred until a ruble-paying in-app network exists. The ad provider is behind an abstraction so a future network for other platforms slots in without rework. Crypto-payout networks (AdsGram/AdMob) are rejected — no legal ruble income for a self-employed (НПД) developer.
Rewarded (voluntary video for chips) credits chips through payments. VK Mini App ads expose
only a client-side watch result (VKWebAppShowNativeAds → data.result) — there is no
server-to-server verify — so a rewarded credit is client-attested (D29 amended: server verify is
not possible on VK). The anti-abuse (and economic) guard is a server-side daily and hourly cap
(config reward_daily_cap / reward_hourly_cap, default 50 / 10), which bounds a forger who skips
the ad and calls the credit endpoint directly and, as importantly, limits free rewarded chips so a
player who wants more buys. The credit is idempotent on a client nonce and order-less; the payout
is config (rewarded_payout_chips, default 0 = rewarded off until set). Rewarded is VK-only and is
not suppressed by no-ads (D9). A future network that offers a server verify slots in behind the ads
abstraction. On the test contour a build flag (VITE_ADS_STUB) swaps a toast for the real ad; prod
always shows real ads (a failed real ad must not credit).
Interstitial (fullscreen after a confirmed play), VK-only, configurable server-side
values. The gate is client-mirrored: the profile carries the cooldowns and a suppressed
flag (the same no-ads / no_banner gate as the banner, resolved server-side in adsFor), and
the client self-gates on a single shared last-shown time in localStorage — the kind only
picks the required gap, so a hint ad and a move ad never fire within a cooldown of each other
(one shared timer, not one per kind) — no per-move server round-trip. The contour
VITE_ADS_STUB swaps the same "ad fired" toast for the real ad. Values:
- Global cooldown per user, across all games, default 5 min.
vs_ai— 30 min (aligned with the hint cooldown, so it does not scare casual players).- Applying a hint triggers an interstitial independently of the main cooldown, with its own 1-min cooldown.
- Fires only after a confirmed play or a hint — never after a pass, exchange or resign (an ad for a non-scoring action would only annoy, so those are never "rewarded" with one).
- Offline — banner only.
- Respect VK's own frequency caps.
No ads offline beyond the house banner. The no-ads benefit suppresses the banner via
the existing ads.Eligible (backend/internal/ads/ads.go), which is extended to gate on
the origin benefit applicable in the current context rather than one global flag.
11. Admin, audit, reporting
Append-only ledger + materialized balance. The operations ledger is INSERT-only
(never UPDATE/DELETE — full audit). Segment balances (account, source) and benefits
(account, origin) are a fast materialized cache, updated in the same transaction as
the ledger write, and recomputable from the ledger for reconciliation.
In-process read cache. On top of the materialized tables, the payments package keeps an
in-process, account-keyed, write-through cache of each account's segments and benefits, so the
hot read paths — the ad-eligibility check, hint availability, the wallet view, the spend gate
— issue no query to the payments schema on the steady-state path. It is invalidated on
every payments mutation (spend / grant / fund / refund / merge) and re-read from the
materialized tables on a miss (the same write-through pattern the account-suspension gate
uses). Single-instance, matching the deployment; a multi-instance backend would need a shared
cache. Identity-presence (which segments are awake, §6) is supplied by the caller, not cached
here, so unlink/re-link takes effect immediately.
Admin rewards. An admin grants concrete values only (no-ads / hints) — never
chips (a gifted currency balance = a store cash-desk bypass). It grants either raw atoms or a
defined value product (a reward bundle, which may be archived — hidden from the store but
grantable); both refuse a chips or tournament atom. The admin picks the
origin at grant time (compliance is on them: origin=vk point-wise/low-volume = low risk,
origin=direct = safe). A grant is a ledger transaction of type admin_grant, price 0
chips (the by-product grant records the source product_id + snapshot) — full audit of rewards.
The ledger section /_gm/ledger is the all-accounts view of the money: every operation, newest
first, filtered by date range (defaulting to the last 30 days), by wallet (vk/telegram/
direct — matching the funded segment or the benefit origin), by rail (the settling provider, so
chip spends drop out by construction), by kind and by account. Above the table sit the totals for
everything the filter matches, not merely the page: money in and refunded per currency (the rails
settle in roubles, Votes and Stars, so one sum across them would mean nothing) and chips credited and
spent. The row amounts come from the ledger snapshot, since the ledger's own columns count chips.
Paging, the CSV export and a refund's way back all carry the same filter query, so none of them can
silently show a different slice than the screen.
The refund action lives on the funded rows here — the operator can find a payment by filter without knowing whose it is first — and returns to the same filtered view afterwards.
The user card carries the account's standing rather than its history: segment balances, benefits, the refund-risk flag, and a short lifetime summary (money paid and refunded per currency, chips credited and spent), with a link into the ledger section pre-filtered to that account. One place renders operations, with filters and paging, instead of two.
12. Taxes and compliance
Receipts are automatic through the provider, and differ by rail:
-
YooKassa (direct) — no receipt is sent, by design (D51 rev). The merchant operates on НПД, which is outside 54-ФЗ: there is no online cash register and the provider registers nothing. Each operation is reported by the merchant to «Мой налог», which issues the чек. YooKassa does not serve receipts for this regime at all.
The fiscal code is kept dormant, not deleted: «Чеки от ЮKassa» (YooKassa owning the cash register, the fiscal drive and the OFD contract) registers a receipt only if the request carries it, and that request-building lives behind one switch —
BACKEND_YOOKASSA_VAT_CODE. Unset, noreceiptis built or sent; set to a 54-ФЗ rate code (tag 1199) it goes back to sending one line (pack title, quantity 1, amount) with the settlement subjectservice(tag 1212) and methodfull_payment(tag 1214), delivered by email to the D36 anchor. That switch exists because the return is foreseeable: НПД carries an annual income ceiling, and losing the regime puts 54-ФЗ back in force.Reporting each rouble operation to «Мой налог» is automated — see below.
-
VK — VK processes Votes through the tax authority itself; nothing to do.
-
TG Stars — no tax side (for a RU self-employed, Stars are not legally withdrawable = not НПД income; accepted, no receipt issued).
ОРД (ad marking) for VK ads is handled on VK's side. (Not legal advice — the owner confirms the exact НПД scheme with a tax advisor.)
Reporting to «Мой налог» (D53-D60)
Rouble income taken through the direct rail is registered with the tax service by us, because
nobody else does it: the provider neither files nor issues a receipt under this regime. The rail
covers kind='fund', provider='yookassa', currency RUB — the store rails are out of scope,
since the ledger records their price in the store's own currency and holds no rouble amount at all.
Where it runs. backend/internal/mynalog is the API client (no database), backend/internal/ mynalogsync the orchestration, payments.mynalog_receipt the per-income progress, and
/_gm/mynalog the operator surface. The console button and the background worker call the same
RunBatch; there is deliberately no second implementation of the part that decides what a failed
registration means. The whole rail is dormant without BACKEND_MYNALOG_KEY.
The API is unofficial — lknpd.nalog.ru, no published contract, no sandbox. Two consequences
run through the design:
- Registering an income is not idempotent. There is no idempotency key, so an error does not mean nothing happened. The service name is therefore frozen in the database before the call and carries a marker taken from the tail of the order id (a UUIDv7's leading digits are a millisecond clock and would be shared by every order in the same minute). After a failure the income list is searched for that exact name: found means filed, not found means unresolved — which halts the queue until a human decides, because the alternatives are declaring an income twice or not at all. One run at a time, on a Postgres advisory lock, for the same reason.
- Faults are classified, not just logged. A 401 is renewed silently; a 429 backs off; a 5xx or timeout retries later and only alerts after a day; any other 4xx is unfixable by repetition, and three in a row take the rail out of service — a changed format must not become thousands of requests overnight. Exceeding the annual НПД ceiling is separated out, because it means the regime no longer applies.
Timing. Every moment is sent in the taxpayer's zone (BACKEND_MYNALOG_TZ, default
Europe/Moscow), since the offset decides which tax month a near-midnight payment falls into; the
receipt carries the date the money arrived, not the date it was filed. The automatic mode ticks
every 15 minutes and does nothing — including not authenticating — when the queue is empty. A
separate daily watchdog runs whether or not the automatic mode does, and escalates unfiled
income from a closed month on the 1st, the 5th and the 9th. (ст. 14 ч. 3 ФЗ-422 allows until the
9th only for settlements not involving an electronic means of payment; a card payment is one, so
the receipt is strictly due at settlement. The automatic mode satisfies both readings.)
Manual fallback. /_gm/mynalog.csv lists exactly what would be sent — time, service name,
amount — plus the ledger id, so a receipt filed by hand can have its number entered back on the
page. That number is what keeps a hand-filed income annullable on a refund, which is why it is
asked for rather than a plain "done" tick.
Refunds. The tax service has no refund concept: annulling the receipt is the refund. A refund
that lands before the income was ever filed is ruled out locally as not_required — nothing filed,
nothing annulled, no call made. A partial refund is out of scope on both sides (§ Refunds).
The buyer hears about it three times, in Russian, driven by durable queues rather than by the
payment path: the purchase confirmation (which says plainly that it is not the fiscal receipt and
that one follows), the receipt itself with its printable link, and — after a refund — a notice that
the receipt has been annulled. The first rides the existing payments.payment_events outbox on its
own mailed_at cursor; the other two ride columns on the export row. The address is the D36
confirmed-email anchor a direct purchase already requires.
Credentials. The cabinet login and password are typed into the console, used once and never
stored: only the refresh token they yield is kept, sealed with AES-GCM under
BACKEND_MYNALOG_KEY (our own random 32 bytes, not a tax-service credential) so it does not travel
in a database dump. A lost key is a nuisance, not a loss — the operator signs in again.
13. Distribution (native Android)
- RuStore — an external payment gate is allowed (0%); native = clean
directcontext. - Google Play — direct purchases are hidden; the Wallet shows a stub ("install the RuStore build to make purchases"). Rewarded ads and spending already-earned chips still work. Confirm Google's current in-app-currency rules before the GP release.
14. Data model (schema payments)
The payments domain lives in its own schema payments in the shared Postgres instance,
with its own DB role (rights limited to payments) and a domain package behind a hard
interface. There is no cross-schema foreign key to backend.accounts — an account id is a
plain value here, kept consistent in code and joined to the tombstoned account /
retained-identities dossier by the stable id. That keeps the chip↔benefit spend atomic
within payments and the domain extractable into its own database. Durability is PITR
(continuous WAL archive), independent of DB topology.
Core tables (final names/columns fixed in PLAN.md):
- ledger — append-only operations: fund / spend /
admin_grant/ refund;(provider, provider_payment_id)unique for idempotency; export-ready. - balances — materialized
(account_id, source) → chips. - benefits — materialized
(account_id, origin)→ no-adspaid_until/forever+ hints count. - catalog — atoms + products (soft-deletable), per-method prices, chip-purchase rates, rewarded payout.
- orders — pending purchases,
order_id, expected amount, origin, status (pending/paid/expired). - payment_events — succeeded/failed/refunded for the dispatcher.
Legacy accounts.hint_balance and accounts.paid_account are deprecated in favour of the
segmented model and dropped in a later contract-phase migration (expand-contract, after the
currency core flips reads and Release 2 — image rollback stays DB-safe); neither was ever set in
prod (no purchase flow existed), so legacy values are zeroed.
15. Glossary
- Фишка / chip — the in-game currency; uniform unit, segmented by
source. - source — where chips were funded (
vk/telegram/direct). - origin — where a value was bought; governs where the benefit applies.
- value / benefit — what chips buy (no-ads, hints, tournament fee).
- gate — the one-directional store-compliance rule (§4).
- ledger — the append-only record of all money/value operations.
- rail — a payment provider (YooKassa / VK Votes / TG Stars).