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
+130
View File
@@ -0,0 +1,130 @@
package mynalog
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/http"
"time"
)
// tokenLifetime is the assumed access-token lifetime when the service does not say. The real one is
// about an hour; a short assumption only costs an extra refresh.
const tokenLifetime = time.Hour
// tokenSlack is subtracted from the expiry so a token is renewed slightly early rather than failing
// mid-run and forcing a probe.
const tokenSlack = time.Minute
// deviceIDLength is how many hex characters of the login digest form the device id. It matches what
// the web cabinet issues, and the length is what the service accepts.
const deviceIDLength = 21
// Session is one authenticated cabinet session. Token is the short-lived bearer credential;
// RefreshToken is the long-lived one that renews it and is the only part worth persisting. INN
// identifies the taxpayer and is needed to build a receipt link.
type Session struct {
Token string
RefreshToken string
INN string
ExpiresAt time.Time
}
// Valid reports whether the session still has a usable access token at now.
func (s Session) Valid(now time.Time) bool {
return s.Token != "" && now.Before(s.ExpiresAt)
}
// DeviceIDForLogin derives a stable device identifier from the taxpayer's login. It is
// deterministic so that re-authenticating after a restart presents the same "device" instead of
// looking like a new one every time.
func DeviceIDForLogin(login string) string {
sum := sha256.Sum256([]byte(login))
return hex.EncodeToString(sum[:])[:deviceIDLength]
}
// authResponse is the shape both authentication endpoints answer with.
type authResponse struct {
Token string `json:"token"`
RefreshToken string `json:"refreshToken"`
TokenExpireIn string `json:"tokenExpireIn"`
Profile struct {
INN string `json:"inn"`
} `json:"profile"`
}
// session converts an authentication answer into a Session, defaulting the expiry when the service
// did not state one and falling back to the login for the taxpayer id.
func (a authResponse) session(login string, now time.Time) (Session, error) {
if a.Token == "" {
return Session{}, fmt.Errorf("mynalog: authentication returned no token: %w", ErrPermanent)
}
expires := now.Add(tokenLifetime)
if a.TokenExpireIn != "" {
if t, err := time.Parse(time.RFC3339, a.TokenExpireIn); err == nil {
expires = t
}
}
inn := a.Profile.INN
if inn == "" {
inn = login
}
return Session{
Token: a.Token,
RefreshToken: a.RefreshToken,
INN: inn,
ExpiresAt: expires.Add(-tokenSlack),
}, nil
}
// AuthByPassword exchanges the taxpayer's cabinet login (their INN) and password for a session.
// Callers persist only the returned RefreshToken: the password is never stored, so this runs once,
// interactively, and the session renews itself from then on.
func (c *Client) AuthByPassword(ctx context.Context, login, password string, now time.Time) (Session, error) {
body := struct {
Username string `json:"username"`
Password string `json:"password"`
DeviceInfo deviceInfo `json:"deviceInfo"`
}{Username: login, Password: password, DeviceInfo: c.device()}
var out authResponse
if err := c.do(ctx, http.MethodPost, "/api/v1/auth/lkfl", "", body, &out); err != nil {
// A password login rejected with 401 is bad credentials, not an aged-out token: reporting it
// as ErrAuthExpired would send the caller into a pointless refresh loop.
if errors.Is(err, ErrAuthExpired) {
return Session{}, fmt.Errorf("mynalog: login rejected: %w", ErrPermanent)
}
return Session{}, err
}
return out.session(login, now)
}
// AuthByRefresh renews a session from a stored refresh token. The service sometimes rotates the
// token, so the caller must persist the returned RefreshToken whenever it differs from the one
// supplied — otherwise the next renewal presents a token the service has already retired.
func (c *Client) AuthByRefresh(ctx context.Context, refreshToken, login string, now time.Time) (Session, error) {
body := struct {
DeviceInfo deviceInfo `json:"deviceInfo"`
RefreshToken string `json:"refreshToken"`
}{DeviceInfo: c.device(), RefreshToken: refreshToken}
var out authResponse
if err := c.do(ctx, http.MethodPost, "/api/v1/auth/token", "", body, &out); err != nil {
// The stored token is dead; only a fresh password login can recover, so this is permanent
// for the caller rather than something to retry.
if errors.Is(err, ErrAuthExpired) {
return Session{}, fmt.Errorf("mynalog: refresh token rejected: %w", ErrPermanent)
}
return Session{}, err
}
sess, err := out.session(login, now)
if err != nil {
return Session{}, err
}
if sess.RefreshToken == "" {
sess.RefreshToken = refreshToken
}
return sess, nil
}