feat(payments): report income to «Мой налог»
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Failing after 24s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped

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.
This commit is contained in:
Ilia Denisov
2026-07-28 15:40:36 +02:00
parent c13f5cdb1e
commit e3c2e80a0a
35 changed files with 4926 additions and 13 deletions
+60 -5
View File
@@ -454,11 +454,7 @@ Receipts are automatic **through the provider**, and differ by rail:
return is foreseeable: НПД carries an annual income ceiling, and losing the regime puts 54-ФЗ back
in force.
Everything an automated «Мой налог» submission needs is already recorded — the order id, the
provider payment id, the amount and currency, the credit time, the sold-pack snapshot, and refunds
as their own ledger rows, all exportable as CSV. The one thing it will additionally need is a
per-operation "already reported" marker for its own idempotency; that belongs to the submission
work, not here.
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).
@@ -466,6 +462,65 @@ Receipts are automatic **through the provider**, and differ by rail:
**ОРД** (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 `direct` context.