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
+10
View File
@@ -155,6 +155,16 @@ YOOKASSA_ANDROID_TEST=
# receipts back on — the switch a lost НПД regime would need. A Gitea VARIABLE, not a secret.
YOOKASSA_VAT_CODE=
#
# «Мой налог» (НПД) export. MYNALOG_KEY is OUR OWN random 32 bytes — not a tax-service credential:
# it encrypts the tax cabinet's refresh token before it is written to Postgres, so the credential
# does not travel in a database dump or a backup. Generate with `openssl rand -base64 32`; a Gitea
# TEST_/PROD_ secret. Empty leaves the whole rail dormant — no console section and no workers.
# The login and password are entered in the admin console and are never stored.
# MYNALOG_TZ is the taxpayer's own time zone: its offset decides which tax month a near-midnight
# payment is filed under. A Gitea VARIABLE, not a secret. Defaults to Europe/Moscow.
MYNALOG_KEY=
MYNALOG_TZ=
#
# Robokassa (the retired direct rail) is deliberately absent here: no deployment sets its
# credentials, which is what keeps it dormant. The backend still reads BACKEND_ROBOKASSA_* and
# falls the direct rail back to it if they are ever set again — see
+3
View File
@@ -81,6 +81,9 @@ compose binds from this directory.
| `BACKEND_YOOKASSA_{WEB,ANDROID}_TEST` | **variable** | `1` marks the credentials as a **test shop**: the intake then refuses to credit anything but a test payment (and a live shop refuses a test one). A variable, not a secret, so switching a contour between a test and a live shop is a flag flip + redeploy. |
| `BACKEND_YOOKASSA_VAT_CODE` | **variable** | Fiscal receipts (54-ФЗ). **Empty by default = no receipt is sent at all**, which is the intended state: the merchant runs on НПД, outside 54-ФЗ, so nothing is registered through the provider and each operation is reported to «Мой налог» instead. Setting a VAT rate code (tag 1199: `1` = «Без НДС», `4` = 20%, `11` = 22%) switches receipts back on with no code change — the path a lost НПД regime (annual income ceiling) would take. Confirm the rate with the accountant before switching it on: a wrong rate produces wrong receipts, not a failed payment, so it fails silently. |
| `MYNALOG_KEY` | secret | Seals the «Мой налог» tax-cabinet **refresh token** at rest. Our own random 32 bytes, **not** a tax-service credential — generate with `openssl rand -base64 32`. Empty leaves the whole tax-export rail dormant: no `/_gm/mynalog` section, no workers. The cabinet login and password are typed into the console and never stored, so this is the only secret the rail needs. Rotating or losing it is recoverable — the stored token stops decrypting and the console asks the operator to sign in again. |
| `MYNALOG_TZ` | **variable** | The taxpayer's own time zone (IANA name; defaults to `Europe/Moscow`). Its offset is what decides which **tax month** a near-midnight payment is filed under, so it follows the taxpayer's registration rather than the server clock. |
**Plus the bot token**`TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at
boot** when it is empty.
+8
View File
@@ -182,6 +182,14 @@ services:
# Setting a VAT rate code (tag 1199: 1 = «Без НДС», 4 = 20%, 11 = 22%) turns receipts back on
# — the switch a lost НПД regime would need — with no code change.
BACKEND_YOOKASSA_VAT_CODE: ${YOOKASSA_VAT_CODE:-}
# «Мой налог» (НПД) export. The key is ours, not the tax service's: it seals the cabinet's
# refresh token at rest, so the credential survives neither a database dump nor a backup in
# readable form. Empty leaves the whole rail dormant — no console section, no workers. The
# operator signs in from the console; the password is never stored. The time zone decides
# which tax month a near-midnight payment is filed under, so it follows the taxpayer's
# registration rather than the server clock.
BACKEND_MYNALOG_KEY: ${MYNALOG_KEY:-}
BACKEND_MYNALOG_TZ: ${MYNALOG_TZ:-}
# The dictionary lives on a named volume seeded from the image on first boot
# (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume
# inherits). The admin console writes new version subdirectories here, and the
+2
View File
@@ -61,6 +61,8 @@ export YOOKASSA_ANDROID_SHOP_ID='$YOOKASSA_ANDROID_SHOP_ID'
export YOOKASSA_ANDROID_SECRET_KEY='$YOOKASSA_ANDROID_SECRET_KEY'
export YOOKASSA_ANDROID_TEST='$YOOKASSA_ANDROID_TEST'
export YOOKASSA_VAT_CODE='${YOOKASSA_VAT_CODE:-1}'
export MYNALOG_KEY='$MYNALOG_KEY'
export MYNALOG_TZ='${MYNALOG_TZ:-Europe/Moscow}'
export VITE_VK_APP_ID='$VITE_VK_APP_ID'
export VITE_VK_ID_REDIRECT_URL='$VITE_VK_ID_REDIRECT_URL'
export GATEWAY_VK_ID_CLIENT_SECRET='$GATEWAY_VK_ID_CLIENT_SECRET'