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:
@@ -0,0 +1,239 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user