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
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:
+60
-5
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user