feat(payments): report direct-rail income to «Мой налог» #295

Merged
developer merged 4 commits from feature/mynalog-export into development 2026-07-28 14:50:14 +00:00
Owner

Registers the direct rail's rouble income with the professional-income tax service, annuls the receipt on a refund, and hands the buyer the receipt by email. Decisions D53-D60 (docs/PAYMENTS_DECISIONS_ru.md).

Why

On НПД the provider neither files with the tax service nor issues a receipt, so nobody was doing it. Doing it by hand stops scaling at the first hundred purchases a week, and the buyer currently gets nothing at all — neither a confirmation nor the receipt ст. 14 ФЗ-422 requires.

What shapes the design

The lknpd API is unofficial: no contract, no sandbox, and POST /income takes no idempotency key. So:

  • The service name is frozen in the DB before the call and carries a marker from the tail of the order id (a UUIDv7's leading digits are a millisecond clock — the first eight are shared by every order in the same ~65 s, which would make two purchases indistinguishable to the recovery probe).
  • After a failure the taxpayer's income list is searched for that exact name. Found ⇒ filed. Not found ⇒ unresolved, and the queue halts for a human: declaring an income twice is as wrong as not declaring it. One run at a time, on an advisory lock.
  • Faults are classified into five classes, not just logged: 401 renews silently, 429 backs off, 5xx/timeout retries and only alerts after a day, any other 4xx is unfixable and three in a row take the rail out of service. Exceeding the annual НПД ceiling is separated out — that is a lost tax regime, not a bug.

Shape

  • backend/internal/mynalog — API client, no database. backend/internal/mynalogsync — orchestration. One RunBatch serves both the console button (100 s budget) and the worker (13 min); there is no second implementation of the part that decides what a failed registration means.
  • Automatic mode is armed from the console, not from configuration, so the operator can watch a run go through by hand first. It ticks every 15 min and issues no call at all — not even an authentication — while the queue is empty.
  • A daily fiscal-deadline watchdog runs whether or not automatic mode is armed, escalating on the 1st / 5th / 9th.
  • Manual fallback: /_gm/mynalog.csv lists exactly what would be sent, and a receipt filed by hand can have its number entered back — which keeps it annullable on a refund.
  • No payment path changed. The purchase letter rides the existing payments.payment_events outbox on its own mailed_at cursor; the receipt and annulment letters ride columns on the export row.
  • Credentials: login+password are typed into the console, used once, never stored. Only the refresh token is kept, AES-GCM-sealed under BACKEND_MYNALOG_KEY (our own 32 bytes, not a tax-service secret), so it does not travel in a database dump.

Migration

00019_mynalog.sql — two new tables plus one nullable column. Strictly additive: applies forward with no data rewrite (no contour wipe) and an image rollback ignores it.

Owner-side steps before deploy

  1. Generate a key (openssl rand -base64 32) and add Gitea secrets PROD_MYNALOG_KEY and TEST_MYNALOG_KEY. Without them the rail stays dormant — no console section, no workers — so the PR is safe to merge before they exist.
  2. Optionally set the PROD_MYNALOG_TZ / TEST_MYNALOG_TZ variables; they default to Europe/Moscow.
  3. Confirm the lknpd cabinet accepts a password login (an account created only via Госуслуги has none).

Verification

  • gofmt -l . clean; go build + go vet across backend, gateway, telegram, loadtest.
  • Unit: the client against an httptest stub — auth by password/refresh incl. token rotation, the amount as a JSON number (regression against float), all five error classes, the receipt-name cap at 128 preserving the marker, a regression pinning the marker to the random tail, AES-GCM round-trip.
  • Integration (Postgres + a stubbed cabinet), 10 tests: the happy path incl. annulment on refund; probe recovery after an ambiguous call; an unresolved outcome halting the queue and clearing after a requeue; self-pause after three rejections and resume; refund-before-filing ruled out with zero calls; an idle run contacting nobody; hand-filed receipt still annulled; the CSV shape; same-origin on every action; all three buyer letters plus repeat protection.
  • Full backend + gateway + telegram suites green; the ui job is untouched (no file under ui/).

Not covered, deliberately

VK Votes and Telegram Stars: the ledger holds no rouble amount for them at all, and VK pays as a legal entity (6%, its INN in the receipt). Partial refunds stay manual on both sides, as they already were.

Cannot be verified before prod

«Мой налог» has no test environment — every call lands on a real tax account. Whether /api/v1/receipt/<ИНН>/<uuid>/print is publicly readable is genuinely unknown (sources contradict), so the owner's own first real purchase decides whether the emailed link stands or we render the receipt ourselves. There are zero real direct payments on prod today, which is why that window is free.

Registers the direct rail's rouble income with the professional-income tax service, annuls the receipt on a refund, and hands the buyer the receipt by email. Decisions **D53-D60** (`docs/PAYMENTS_DECISIONS_ru.md`). ## Why On НПД the provider neither files with the tax service nor issues a receipt, so nobody was doing it. Doing it by hand stops scaling at the first hundred purchases a week, and the buyer currently gets nothing at all — neither a confirmation nor the receipt ст. 14 ФЗ-422 requires. ## What shapes the design The lknpd API is unofficial: no contract, **no sandbox**, and `POST /income` takes **no idempotency key**. So: - The service name is frozen in the DB *before* the call and carries a marker from the **tail** of the order id (a UUIDv7's leading digits are a millisecond clock — the first eight are shared by every order in the same ~65 s, which would make two purchases indistinguishable to the recovery probe). - After a failure the taxpayer's income list is searched for that exact name. Found ⇒ filed. Not found ⇒ **unresolved**, and the queue halts for a human: declaring an income twice is as wrong as not declaring it. One run at a time, on an advisory lock. - Faults are classified into five classes, not just logged: 401 renews silently, 429 backs off, 5xx/timeout retries and only alerts after a day, any other 4xx is unfixable and three in a row take the rail out of service. Exceeding the annual НПД ceiling is separated out — that is a lost tax regime, not a bug. ## Shape - `backend/internal/mynalog` — API client, no database. `backend/internal/mynalogsync` — orchestration. One `RunBatch` serves both the console button (100 s budget) and the worker (13 min); there is no second implementation of the part that decides what a failed registration means. - Automatic mode is armed **from the console**, not from configuration, so the operator can watch a run go through by hand first. It ticks every 15 min and issues **no call at all** — not even an authentication — while the queue is empty. - A daily fiscal-deadline watchdog runs **whether or not** automatic mode is armed, escalating on the 1st / 5th / 9th. - Manual fallback: `/_gm/mynalog.csv` lists exactly what would be sent, and a receipt filed by hand can have its number entered back — which keeps it annullable on a refund. - **No payment path changed.** The purchase letter rides the existing `payments.payment_events` outbox on its own `mailed_at` cursor; the receipt and annulment letters ride columns on the export row. - Credentials: login+password are typed into the console, used once, never stored. Only the refresh token is kept, AES-GCM-sealed under `BACKEND_MYNALOG_KEY` (our own 32 bytes, not a tax-service secret), so it does not travel in a database dump. ## Migration `00019_mynalog.sql` — two new tables plus one nullable column. Strictly additive: applies forward with no data rewrite (**no contour wipe**) and an image rollback ignores it. ## Owner-side steps before deploy 1. Generate a key (`openssl rand -base64 32`) and add Gitea secrets **`PROD_MYNALOG_KEY`** and **`TEST_MYNALOG_KEY`**. Without them the rail stays dormant — no console section, no workers — so the PR is safe to merge before they exist. 2. Optionally set the `PROD_MYNALOG_TZ` / `TEST_MYNALOG_TZ` variables; they default to `Europe/Moscow`. 3. Confirm the lknpd cabinet accepts a **password** login (an account created only via Госуслуги has none). ## Verification - `gofmt -l .` clean; `go build` + `go vet` across backend, gateway, telegram, loadtest. - Unit: the client against an httptest stub — auth by password/refresh incl. token rotation, the amount as a JSON **number** (regression against float), all five error classes, the receipt-name cap at 128 preserving the marker, a regression pinning the marker to the random tail, AES-GCM round-trip. - Integration (Postgres + a stubbed cabinet), 10 tests: the happy path incl. annulment on refund; **probe recovery** after an ambiguous call; an unresolved outcome halting the queue and clearing after a requeue; self-pause after three rejections and resume; refund-before-filing ruled out with **zero** calls; an idle run contacting nobody; hand-filed receipt still annulled; the CSV shape; same-origin on every action; all three buyer letters plus repeat protection. - Full backend + gateway + telegram suites green; the `ui` job is untouched (no file under `ui/`). ## Not covered, deliberately VK Votes and Telegram Stars: the ledger holds no rouble amount for them at all, and VK pays as a legal entity (6%, its INN in the receipt). Partial refunds stay manual on both sides, as they already were. ## Cannot be verified before prod «Мой налог» has **no test environment** — every call lands on a real tax account. Whether `/api/v1/receipt/<ИНН>/<uuid>/print` is publicly readable is genuinely unknown (sources contradict), so the owner's own first real purchase decides whether the emailed link stands or we render the receipt ourselves. There are zero real direct payments on prod today, which is why that window is free.
developer added 1 commit 2026-07-28 13:41:12 +00:00
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
e3c2e80a0a
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.
developer added 1 commit 2026-07-28 13:47:24 +00:00
test(inttest): de-flake the no-repeat pin guard
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m51s
f8efa21158
The bag seed comes from crypto/rand, so an opening rack occasionally has no
playable word — a legitimate engine answer that says nothing about the pin the
test is guarding. Reproduced at roughly one run in three hundred locally, and
it turned CI red once. Retry with fresh games instead: an unlucky draw no
longer decides the result, while a genuinely broken path still fails every
attempt.
developer added 1 commit 2026-07-28 14:14:56 +00:00
fix(payments): stop doubling the pack size in buyer mail
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
4d4452596d
Catalog titles already name the pack ("50 фишек"), so appending the chip count
produced "50 фишек — 50 фишек, 79.00 ₽". The letter now carries the title
exactly as the storefront shows it, then the price.

The tax receipt is unaffected: it states the quantity in the wording the tax
service wants, which is a separate surface with separate requirements.
developer added 1 commit 2026-07-28 14:44:46 +00:00
fix(admin): shorten the receipt letter, show the account id
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
69bddaeb9a
The receipt letter repeated what the receipt itself states. It now carries the
amount, the moment of payment with its offset, and the link — the receipt is
the document, and it opens without authentication, so there is nothing for the
two to disagree about. Subject reworded to name what it is.

The ledger and tax-export tables labelled the account link "card", meaning the
user card. On pages about payments that reads as a bank card, which is exactly
how it was misread. Both now show the first eight characters of the account id,
so the column carries data — two rows from the same buyer are visible at a
glance — while the link still holds the whole id.
owner approved these changes 2026-07-28 14:47:45 +00:00
developer merged commit 1f17e26d9b into development 2026-07-28 14:50:14 +00:00
developer deleted branch feature/mynalog-export 2026-07-28 14:50:14 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: developer/scrabble-game#295