// Package mynalog talks to the personal cabinet of the Russian professional-income tax regime // (НПД) at lknpd.nalog.ru: it registers an income and receives the receipt uuid the tax service // assigns, annuls that receipt when the money is returned, and searches the taxpayer's own income // list. It is pure provider glue — no database, no payments-domain coupling — so the payments // domain stays unaware of it and this layer is unit-testable in isolation. // // Three properties of this API shape everything here. // // First, it is NOT official. There is no published contract and no sandbox: the endpoints below // were established by observing the web cabinet, and every call lands on the operator's real tax // account. The format can change without warning, so errors are classified rather than merely // logged, and the caller is expected to have a manual fallback. // // Second, registering an income is NOT idempotent — the API accepts no idempotency key. A timeout // therefore does not mean "nothing happened": the receipt may well exist. The only recovery is // FindIncome, which searches the taxpayer's own income list for the exact service name we sent, // which is why that name must carry a unique marker (see IncomeName). // // Third, the access token lives about an hour while the refresh token is long-lived, so callers // persist only the refresh token and mint an access token per session. package mynalog import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "time" ) // DefaultBaseURL is the production cabinet root. NewClient's baseURL overrides it (tests point it // at an httptest server). const DefaultBaseURL = "https://lknpd.nalog.ru" // RequestTimeout bounds a single API call. It is deliberately generous: the tax service is slow // under load, and a premature client-side timeout is expensive here — an ambiguous outcome halts // the export queue until a human resolves it, so waiting is far cheaper than guessing. It is // exported so a caller sharing one HTTP client across calls applies the same ceiling. const RequestTimeout = 60 * time.Second // maxResponseBytes caps how much of a response body is read, so a misbehaving endpoint cannot // exhaust memory. const maxResponseBytes = 1 << 20 // Error classes every failure is sorted into. They exist so the caller can act differently on each // without parsing messages: a silent re-authentication, a backoff, a retry next run, or a hard stop // with an operator alert. var ( // ErrAuthExpired is a rejected token. It is routine — the access token simply aged out — and // the caller re-authenticates instead of alerting. ErrAuthExpired = errors.New("mynalog: authentication expired") // ErrThrottled is the service asking us to slow down. The run stops and resumes later; it is // worth an alert only if it persists. ErrThrottled = errors.New("mynalog: throttled") // ErrTransient is a server-side fault, a timeout or a broken connection: the request may or may // not have been applied, and repeating it later is reasonable. ErrTransient = errors.New("mynalog: service unavailable") // ErrPermanent is a rejection that repeating cannot fix — a malformed request, or the API // having changed shape underneath us. It stops the run and alerts the operator. ErrPermanent = errors.New("mynalog: request rejected") // ErrIncomeLimit is the annual professional-income ceiling being exceeded. It means the tax // regime itself no longer applies and the operator must act rather than debug, so it is // separated for the sake of the alert wording — but it deliberately WRAPS ErrPermanent, so a // caller that only asks errors.Is(err, ErrPermanent) still stops. Losing that subsumption would // let the one fault the operator most needs to hear about slip through as a retryable one. ErrIncomeLimit = fmt.Errorf("%w: annual income limit exceeded", ErrPermanent) ) // APIError is a non-2xx answer. Message is the service's own explanation when the body carried // one; Code is its error code where present. Class is the sentinel this fault was sorted into and // is what errors.Is matches on. type APIError struct { StatusCode int Code string Message string class error } // Error renders the status together with the service's own message. It never includes credentials: // the admin console prints err.Error() verbatim to the operator. func (e *APIError) Error() string { msg := e.Message if msg == "" { msg = e.Code } if msg == "" { return fmt.Sprintf("mynalog: http %d", e.StatusCode) } return fmt.Sprintf("mynalog: http %d: %s", e.StatusCode, msg) } // Unwrap exposes the error class so errors.Is(err, ErrPermanent) and friends work. func (e *APIError) Unwrap() error { return e.class } // classify sorts an HTTP status and the service's own message into one of the error classes. // // The income-limit test is a heuristic over the code and message, because the undocumented API // gives no stable machine-readable marker for it. Missing it is harmless: the fault stays an // ErrPermanent, so the run still stops and the operator is still alerted — only the wording of the // alert is less specific. func classify(status int, code, message string) error { switch { case status == http.StatusUnauthorized: return ErrAuthExpired case status == http.StatusTooManyRequests: return ErrThrottled case status >= 500: return ErrTransient case status >= 400: if mentionsIncomeLimit(code) || mentionsIncomeLimit(message) { return ErrIncomeLimit } return ErrPermanent } return nil } // mentionsIncomeLimit reports whether s reads like the annual-ceiling rejection, in either of the // two languages the service answers in. func mentionsIncomeLimit(s string) bool { s = strings.ToLower(s) return strings.Contains(s, "лимит") || strings.Contains(s, "limit") } // Client calls one taxpayer's cabinet. deviceID pins every call to a single synthetic "device", so // repeated logins look like the same browser rather than a new one each time. type Client struct { baseURL string deviceID string http *http.Client } // NewClient builds a client against baseURL (empty means DefaultBaseURL) identifying itself as // deviceID. hc may be nil, in which case a client carrying only the ceiling timeout is used; // per-request cancellation always rides the context. func NewClient(baseURL, deviceID string, hc *http.Client) *Client { if baseURL == "" { baseURL = DefaultBaseURL } if hc == nil { hc = &http.Client{Timeout: RequestTimeout} } return &Client{baseURL: strings.TrimSuffix(baseURL, "/"), deviceID: deviceID, http: hc} } // DeviceID returns the device identifier this client presents, so callers can persist it alongside // the refresh token the service issued for it. func (c *Client) DeviceID() string { return c.deviceID } // deviceInfo is the device descriptor every authentication call carries. type deviceInfo struct { SourceDeviceID string `json:"sourceDeviceId"` SourceType string `json:"sourceType"` AppVersion string `json:"appVersion"` MetaDetails map[string]string `json:"metaDetails"` } // userAgent is sent both as the HTTP header and inside deviceInfo, which is what the web cabinet // does; the service correlates the two. const userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/142.0.0.0 Safari/537.36" // device describes this client to the authentication endpoints. func (c *Client) device() deviceInfo { return deviceInfo{ SourceDeviceID: c.deviceID, SourceType: "WEB", AppVersion: "1.0.0", MetaDetails: map[string]string{"userAgent": userAgent}, } } // errorBody is the shape of the service's error responses. Not every fault produces one. type errorBody struct { Code string `json:"code"` Message string `json:"message"` } // do performs one call and decodes the result into out, which may be nil when the body is not // needed. token, when non-empty, is sent as the bearer credential. Every failure comes back // wrapping one of the error classes. func (c *Client) do(ctx context.Context, method, path, token string, body, out any) error { var payload io.Reader if body != nil { buf, err := json.Marshal(body) if err != nil { return fmt.Errorf("mynalog: encode %s %s: %w", method, path, err) } payload = bytes.NewReader(buf) } req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, payload) if err != nil { return fmt.Errorf("mynalog: build %s %s: %w", method, path, err) } req.Header.Set("Accept", "application/json, text/plain, */*") req.Header.Set("Referer", c.baseURL+"/") req.Header.Set("User-Agent", userAgent) if body != nil { req.Header.Set("Content-Type", "application/json") } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } resp, err := c.http.Do(req) if err != nil { // A refused connection, a reset or a timeout: we cannot tell whether the request was // applied, so it is transient by definition and the caller must probe before retrying. return fmt.Errorf("mynalog: %s %s: %w: %v", method, path, ErrTransient, err) } defer resp.Body.Close() raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) if err != nil { return fmt.Errorf("mynalog: read %s %s: %w: %v", method, path, ErrTransient, err) } if resp.StatusCode < 200 || resp.StatusCode > 299 { var eb errorBody // The body is the service's error object on a handled fault and something else entirely on // an edge failure; either way the status alone is enough to classify. _ = json.Unmarshal(raw, &eb) return &APIError{ StatusCode: resp.StatusCode, Code: eb.Code, Message: eb.Message, class: classify(resp.StatusCode, eb.Code, eb.Message), } } if out == nil { return nil } if err := json.Unmarshal(raw, out); err != nil { // A 200 we cannot parse means the contract moved under us: repeating will not help. return fmt.Errorf("mynalog: decode %s %s: %w: %v", method, path, ErrPermanent, err) } return nil }