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
}
+85
View File
@@ -0,0 +1,85 @@
package mynalog
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
)
// KeyLength is the AES-256 key size the stored refresh token is sealed with.
const KeyLength = 32
// ErrUndecryptable is returned when a stored secret cannot be opened with the configured key —
// typically because the key was rotated or lost. Callers treat it as "there is no session" and ask
// the operator to sign in again, which is a nuisance rather than a failure.
var ErrUndecryptable = errors.New("mynalog: stored secret cannot be decrypted")
// ParseKey decodes the configured encryption key. It accepts standard base64 with or without
// padding (so the output of `openssl rand -base64 32` works as-is) and plain hex.
func ParseKey(s string) ([]byte, error) {
for _, decode := range []func(string) ([]byte, error){
base64.StdEncoding.DecodeString,
base64.RawStdEncoding.DecodeString,
hex.DecodeString,
} {
if key, err := decode(s); err == nil && len(key) == KeyLength {
return key, nil
}
}
return nil, fmt.Errorf("mynalog: encryption key must decode to %d bytes (base64 or hex)", KeyLength)
}
// Seal encrypts plaintext with key, returning the nonce followed by the AES-GCM ciphertext.
//
// This exists because the refresh token is a long-lived credential to the operator's personal tax
// cabinet, and it is the one part of the rail that has to sit on disk. Encrypting it at the
// application layer keeps it out of database dumps, which travel further than the database does.
func Seal(key []byte, plaintext string) ([]byte, error) {
gcm, err := newGCM(key)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("mynalog: nonce: %w", err)
}
return gcm.Seal(nonce, nonce, []byte(plaintext), nil), nil
}
// Open decrypts a blob produced by Seal. Any failure — a wrong key, a truncated value, a tampered
// one — comes back as ErrUndecryptable, because the caller's response is the same in every case.
func Open(key, blob []byte) (string, error) {
gcm, err := newGCM(key)
if err != nil {
return "", err
}
if len(blob) < gcm.NonceSize() {
return "", ErrUndecryptable
}
nonce, ciphertext := blob[:gcm.NonceSize()], blob[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", ErrUndecryptable
}
return string(plaintext), nil
}
// newGCM builds the AEAD both directions share.
func newGCM(key []byte) (cipher.AEAD, error) {
if len(key) != KeyLength {
return nil, fmt.Errorf("mynalog: encryption key must be %d bytes, got %d", KeyLength, len(key))
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("mynalog: cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("mynalog: gcm: %w", err)
}
return gcm, nil
}
+194
View File
@@ -0,0 +1,194 @@
package mynalog
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"regexp"
"time"
)
// timeLayout is how the service wants a moment: ISO-8601 to the second with an explicit numeric
// offset. The offset is load-bearing — it decides which tax month a near-midnight payment falls
// into — so the layout never renders UTC as "Z".
const timeLayout = "2006-01-02T15:04:05-07:00"
// queryTimeLayout is the same with milliseconds, which is what the income-list filter expects.
const queryTimeLayout = "2006-01-02T15:04:05.000-07:00"
// findLimit is how many recent incomes the probe reads in one page. The probe searches a one-day
// window for a name carrying a unique order marker, so a single page is ample; a larger window
// would need paging.
const findLimit = 50
// cancelReasonRefund is the annulment reason the tax service recognises for money given back.
const cancelReasonRefund = "Возврат средств"
// decimalPattern accepts exactly the shape a JSON number may take here: an integer part with an
// optional fractional part, no exponent and no sign.
var decimalPattern = regexp.MustCompile(`^\d+(\.\d+)?$`)
// decimal is a monetary amount that marshals as a JSON *number* while being carried as an exact
// decimal string. payments.Money.Major() already renders that string, and passing it through
// untouched is what keeps binary floating point out of a tax filing.
type decimal string
// MarshalJSON emits the amount verbatim as a JSON number, refusing anything that is not a plain
// decimal literal rather than silently producing invalid JSON.
func (d decimal) MarshalJSON() ([]byte, error) {
if !decimalPattern.MatchString(string(d)) {
return nil, fmt.Errorf("mynalog: %q is not a decimal amount: %w", string(d), ErrPermanent)
}
return []byte(d), nil
}
// service is one line of a receipt. Quantity is always 1: an income is registered as a single
// service priced at the whole amount, not as a quantity of units.
type service struct {
Name string `json:"name"`
Amount decimal `json:"amount"`
Quantity int `json:"quantity"`
}
// client identifies the payer. For a private individual every field stays null and the type alone
// selects the 4% rate; a company or sole trader would instead need its INN and name here, which is
// why this rail is restricted to individual buyers.
type client struct {
ContactPhone *string `json:"contactPhone"`
DisplayName *string `json:"displayName"`
INN *string `json:"inn"`
IncomeType string `json:"incomeType"`
}
// incomeTypeIndividual marks the payer as a private individual.
const incomeTypeIndividual = "FROM_INDIVIDUAL"
// paymentTypeCash is the settlement type sent for every income. It does not affect the tax owed;
// it is what every known client of this API sends.
const paymentTypeCash = "CASH"
// IncomeRequest is one income to register. Name is the service description shown to the tax service
// and to the buyer, and doubles as the only key by which FindIncome can locate the receipt
// afterwards. Amount is an exact decimal string. OperationTime is when the money was actually
// received — not when we got round to reporting it — and must already be in the taxpayer's time
// zone, because its offset decides the tax period.
type IncomeRequest struct {
Name string
Amount string
OperationTime time.Time
RequestTime time.Time
}
// incomeBody is the wire shape of a registration call.
type incomeBody struct {
OperationTime string `json:"operationTime"`
RequestTime string `json:"requestTime"`
Services []service `json:"services"`
TotalAmount string `json:"totalAmount"`
Client client `json:"client"`
PaymentType string `json:"paymentType"`
// IgnoreMaxTotalIncomeRestriction stays false so that exceeding the annual professional-income
// ceiling is refused loudly instead of being quietly filed.
IgnoreMaxTotalIncomeRestriction bool `json:"ignoreMaxTotalIncomeRestriction"`
}
// incomeResponse carries the receipt identifier the tax service assigns. That uuid is the only
// handle on the receipt afterwards: without it the income can never be annulled.
type incomeResponse struct {
ApprovedReceiptUUID string `json:"approvedReceiptUuid"`
}
// AddIncome registers one income and returns the receipt uuid the tax service assigned.
//
// This call is NOT idempotent — the API takes no idempotency key — so a returned error does not
// mean nothing was recorded. On anything other than a clean success the caller must probe with
// FindIncome before considering a retry, or it risks filing the same income twice.
func (c *Client) AddIncome(ctx context.Context, sess Session, req IncomeRequest) (string, error) {
body := incomeBody{
OperationTime: req.OperationTime.Format(timeLayout),
RequestTime: req.RequestTime.Format(timeLayout),
Services: []service{{Name: req.Name, Amount: decimal(req.Amount), Quantity: 1}},
TotalAmount: req.Amount,
Client: client{IncomeType: incomeTypeIndividual},
PaymentType: paymentTypeCash,
}
var out incomeResponse
if err := c.do(ctx, http.MethodPost, "/api/v1/income", sess.Token, body, &out); err != nil {
return "", err
}
if out.ApprovedReceiptUUID == "" {
// A success with no receipt id leaves us unable to annul the income later, so it counts as
// an ambiguous outcome and the caller must probe rather than assume either way.
return "", fmt.Errorf("mynalog: income accepted without a receipt id: %w", ErrTransient)
}
return out.ApprovedReceiptUUID, nil
}
// cancelBody is the wire shape of an annulment call.
type cancelBody struct {
OperationTime string `json:"operationTime"`
RequestTime string `json:"requestTime"`
Comment string `json:"comment"`
ReceiptUUID string `json:"receiptUuid"`
}
// CancelIncome annuls a previously registered receipt because the money was returned. The tax
// service has no separate refund concept: annulling the receipt is the refund, and it removes the
// income from the tax base. now must already be in the taxpayer's time zone.
func (c *Client) CancelIncome(ctx context.Context, sess Session, receiptUUID string, now time.Time) error {
stamp := now.Format(timeLayout)
body := cancelBody{
OperationTime: stamp,
RequestTime: stamp,
Comment: cancelReasonRefund,
ReceiptUUID: receiptUUID,
}
return c.do(ctx, http.MethodPost, "/api/v1/cancel", sess.Token, body, nil)
}
// incomeListItem is one entry of the taxpayer's income list. A non-empty CancellationInfo marks an
// already-annulled receipt, which must never be matched: annulling it again would fail, and
// treating it as ours would hide a genuinely missing income.
type incomeListItem struct {
Name string `json:"name"`
ApprovedReceiptUUID string `json:"approvedReceiptUuid"`
CancellationInfo json.RawMessage `json:"cancellationInfo"`
}
// incomeListResponse is the paged income list.
type incomeListResponse struct {
Content []incomeListItem `json:"content"`
}
// FindIncome searches the taxpayer's own income list between from and to for a live receipt whose
// service name is exactly name, and reports its uuid.
//
// This is the recovery path for the non-idempotent AddIncome: after a timeout it answers the only
// question that matters — was the receipt created after all? It is exact only because the name
// carries a unique order marker; searching by amount and description alone would confuse two
// identical purchases. from and to must already be in the taxpayer's time zone.
func (c *Client) FindIncome(ctx context.Context, sess Session, name string, from, to time.Time) (string, bool, error) {
q := url.Values{}
q.Set("from", from.Format(queryTimeLayout))
q.Set("to", to.Format(queryTimeLayout))
q.Set("offset", "0")
q.Set("sortBy", "operation_time:desc")
q.Set("limit", fmt.Sprint(findLimit))
var out incomeListResponse
if err := c.do(ctx, http.MethodGet, "/api/v1/incomes?"+q.Encode(), sess.Token, nil, &out); err != nil {
return "", false, err
}
for _, item := range out.Content {
if item.Name != name || item.ApprovedReceiptUUID == "" {
continue
}
if len(item.CancellationInfo) > 0 && string(item.CancellationInfo) != "null" {
continue
}
return item.ApprovedReceiptUUID, true, nil
}
return "", false, nil
}
+239
View File
@@ -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
}
+497
View File
@@ -0,0 +1,497 @@
package mynalog
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/google/uuid"
)
// msk is the taxpayer's time zone in these tests. It matters: the offset a moment is rendered with
// decides which tax month it lands in.
var msk = time.FixedZone("MSK", 3*60*60)
// capture records what the fake cabinet received, so a test can assert on the request the client
// built. raw is kept alongside the decoded body because some assertions are about JSON *shape* —
// notably that an amount is a number and not a string.
type capture struct {
method string
path string
query string
bearer string
raw string
body map[string]any
}
// fakeCabinet serves one canned response and records the request, returning a client already
// pointed at it.
func fakeCabinet(t *testing.T, status int, response string) (*Client, *capture) {
t.Helper()
got := &capture{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got.method = r.Method
got.path = r.URL.Path
got.query = r.URL.RawQuery
got.bearer = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if data, _ := io.ReadAll(r.Body); len(data) > 0 {
got.raw = string(data)
_ = json.Unmarshal(data, &got.body)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = io.WriteString(w, response)
}))
t.Cleanup(srv.Close)
return NewClient(srv.URL, "device-1", srv.Client()), got
}
// liveSession is a session with a token that has not aged out.
func liveSession() Session {
return Session{Token: "access-1", RefreshToken: "refresh-1", INN: "770000000000",
ExpiresAt: time.Now().Add(time.Hour)}
}
func TestAuthByPasswordBuildsTheRequest(t *testing.T) {
c, got := fakeCabinet(t, http.StatusOK, `{
"token":"access-1","refreshToken":"refresh-1",
"tokenExpireIn":"2026-07-28T15:04:05+03:00",
"profile":{"inn":"770000000000"}}`)
now := time.Date(2026, 7, 28, 14, 0, 0, 0, msk)
sess, err := c.AuthByPassword(context.Background(), "770000000000", "hunter2", now)
if err != nil {
t.Fatalf("auth: %v", err)
}
if got.path != "/api/v1/auth/lkfl" {
t.Fatalf("path = %q", got.path)
}
if got.body["username"] != "770000000000" || got.body["password"] != "hunter2" {
t.Fatalf("credentials not sent: %v", got.body)
}
device, _ := got.body["deviceInfo"].(map[string]any)
if device["sourceDeviceId"] != "device-1" || device["sourceType"] != "WEB" {
t.Fatalf("deviceInfo = %v", device)
}
if sess.Token != "access-1" || sess.RefreshToken != "refresh-1" || sess.INN != "770000000000" {
t.Fatalf("session = %+v", sess)
}
// The stated expiry, less the renewal slack.
want := time.Date(2026, 7, 28, 15, 3, 5, 0, msk)
if !sess.ExpiresAt.Equal(want) {
t.Fatalf("expires = %v, want %v", sess.ExpiresAt, want)
}
}
func TestAuthByPasswordFallsBackToTheLoginForTheINN(t *testing.T) {
c, _ := fakeCabinet(t, http.StatusOK, `{"token":"access-1","refreshToken":"refresh-1"}`)
now := time.Date(2026, 7, 28, 14, 0, 0, 0, msk)
sess, err := c.AuthByPassword(context.Background(), "770000000000", "hunter2", now)
if err != nil {
t.Fatalf("auth: %v", err)
}
if sess.INN != "770000000000" {
t.Fatalf("inn = %q", sess.INN)
}
if !sess.Valid(now) {
t.Fatal("session should be valid right after authenticating")
}
}
// A rejected password is a permanent failure, not an aged-out token: reporting it as ErrAuthExpired
// would send the caller into a pointless refresh loop.
func TestAuthByPasswordRejectionIsPermanent(t *testing.T) {
c, _ := fakeCabinet(t, http.StatusUnauthorized, `{"message":"Неверный логин или пароль"}`)
_, err := c.AuthByPassword(context.Background(), "770000000000", "wrong", time.Now())
if !errors.Is(err, ErrPermanent) {
t.Fatalf("err = %v, want ErrPermanent", err)
}
if errors.Is(err, ErrAuthExpired) {
t.Fatal("a bad password must not look like an expired token")
}
}
func TestAuthByRefreshKeepsTheOldTokenWhenNotRotated(t *testing.T) {
c, got := fakeCabinet(t, http.StatusOK, `{"token":"access-2"}`)
sess, err := c.AuthByRefresh(context.Background(), "refresh-1", "770000000000", time.Now())
if err != nil {
t.Fatalf("refresh: %v", err)
}
if got.path != "/api/v1/auth/token" || got.body["refreshToken"] != "refresh-1" {
t.Fatalf("request = %s %s %v", got.method, got.path, got.body)
}
if sess.RefreshToken != "refresh-1" {
t.Fatalf("refresh token = %q, want the supplied one carried forward", sess.RefreshToken)
}
}
// The service sometimes issues a new refresh token; losing it would strand the next renewal.
func TestAuthByRefreshSurfacesARotatedToken(t *testing.T) {
c, _ := fakeCabinet(t, http.StatusOK, `{"token":"access-2","refreshToken":"refresh-2"}`)
sess, err := c.AuthByRefresh(context.Background(), "refresh-1", "770000000000", time.Now())
if err != nil {
t.Fatalf("refresh: %v", err)
}
if sess.RefreshToken != "refresh-2" {
t.Fatalf("refresh token = %q, want the rotated one", sess.RefreshToken)
}
}
func TestAuthByRefreshRejectionIsPermanent(t *testing.T) {
c, _ := fakeCabinet(t, http.StatusUnauthorized, `{"message":"token is invalid"}`)
_, err := c.AuthByRefresh(context.Background(), "stale", "770000000000", time.Now())
if !errors.Is(err, ErrPermanent) {
t.Fatalf("err = %v, want ErrPermanent — only a fresh password login can recover", err)
}
}
func TestAddIncomeBuildsTheRequest(t *testing.T) {
c, got := fakeCabinet(t, http.StatusOK, `{"approvedReceiptUuid":"20abcdef01"}`)
receipt, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{
Name: `Внутриигровая валюта: "Фишка", 100 шт. (ID: 0198c1f2)`,
Amount: "149.50",
OperationTime: time.Date(2026, 7, 28, 23, 30, 0, 0, msk),
RequestTime: time.Date(2026, 7, 29, 0, 5, 0, 0, msk),
})
if err != nil {
t.Fatalf("add income: %v", err)
}
if receipt != "20abcdef01" {
t.Fatalf("receipt = %q", receipt)
}
if got.path != "/api/v1/income" || got.bearer != "access-1" {
t.Fatalf("request = %s %s bearer=%q", got.method, got.path, got.bearer)
}
if got.body["operationTime"] != "2026-07-28T23:30:00+03:00" {
t.Fatalf("operationTime = %v — the offset decides the tax month", got.body["operationTime"])
}
if got.body["requestTime"] != "2026-07-29T00:05:00+03:00" {
t.Fatalf("requestTime = %v", got.body["requestTime"])
}
if got.body["totalAmount"] != "149.50" {
t.Fatalf("totalAmount = %v, want the exact decimal string", got.body["totalAmount"])
}
if got.body["paymentType"] != paymentTypeCash {
t.Fatalf("paymentType = %v", got.body["paymentType"])
}
if got.body["ignoreMaxTotalIncomeRestriction"] != false {
t.Fatal("the annual ceiling must stay enforced")
}
services, _ := got.body["services"].([]any)
if len(services) != 1 {
t.Fatalf("services = %v", got.body["services"])
}
line, _ := services[0].(map[string]any)
if line["quantity"] != float64(1) {
t.Fatalf("quantity = %v", line["quantity"])
}
payer, _ := got.body["client"].(map[string]any)
if payer["incomeType"] != incomeTypeIndividual || payer["inn"] != nil {
t.Fatalf("client = %v — a private buyer carries no INN", payer)
}
}
// The amount must reach the tax service as a JSON number carrying the exact decimal we hold, with
// no float32/float64 round-trip anywhere in between.
func TestAddIncomeSendsTheAmountAsANumber(t *testing.T) {
c, got := fakeCabinet(t, http.StatusOK, `{"approvedReceiptUuid":"20abcdef01"}`)
if _, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{
Name: "x", Amount: "149.50",
OperationTime: time.Now().In(msk), RequestTime: time.Now().In(msk),
}); err != nil {
t.Fatalf("add income: %v", err)
}
if !strings.Contains(got.raw, `"amount":149.50`) {
t.Fatalf("body = %s, want a bare JSON number for amount", got.raw)
}
}
func TestAddIncomeRejectsAnAmountThatIsNotDecimal(t *testing.T) {
c, _ := fakeCabinet(t, http.StatusOK, `{"approvedReceiptUuid":"20abcdef01"}`)
for _, bad := range []string{"", "1,50", "1e2", "-5", "149.50 RUB"} {
_, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{
Name: "x", Amount: bad,
OperationTime: time.Now().In(msk), RequestTime: time.Now().In(msk),
})
if !errors.Is(err, ErrPermanent) {
t.Fatalf("amount %q: err = %v, want ErrPermanent", bad, err)
}
}
}
// A 200 with no receipt id leaves the income unannullable, so it counts as ambiguous and the caller
// must probe rather than assume it failed.
func TestAddIncomeWithoutAReceiptIsTransient(t *testing.T) {
c, _ := fakeCabinet(t, http.StatusOK, `{}`)
_, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{
Name: "x", Amount: "10",
OperationTime: time.Now().In(msk), RequestTime: time.Now().In(msk),
})
if !errors.Is(err, ErrTransient) {
t.Fatalf("err = %v, want ErrTransient", err)
}
}
func TestErrorClassification(t *testing.T) {
cases := []struct {
name string
status int
body string
want error
notWant error
}{
{"expired token", http.StatusUnauthorized, `{"message":"unauthorized"}`, ErrAuthExpired, ErrPermanent},
{"throttled", http.StatusTooManyRequests, `{"message":"slow down"}`, ErrThrottled, ErrPermanent},
{"server fault", http.StatusBadGateway, `{}`, ErrTransient, ErrPermanent},
{"bad request", http.StatusBadRequest, `{"message":"bad operationTime"}`, ErrPermanent, ErrTransient},
{"annual ceiling", http.StatusBadRequest, `{"message":"Превышен лимит дохода"}`, ErrIncomeLimit, ErrTransient},
}
// The ceiling refines a permanent rejection rather than replacing it: a caller that only knows
// about ErrPermanent must still stop on it.
if !errors.Is(ErrIncomeLimit, ErrPermanent) {
t.Fatal("ErrIncomeLimit must subsume ErrPermanent")
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c, _ := fakeCabinet(t, tc.status, tc.body)
_, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{
Name: "x", Amount: "10",
OperationTime: time.Now().In(msk), RequestTime: time.Now().In(msk),
})
if !errors.Is(err, tc.want) {
t.Fatalf("err = %v, want %v", err, tc.want)
}
if errors.Is(err, tc.notWant) {
t.Fatalf("err = %v must not also be %v", err, tc.notWant)
}
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != tc.status {
t.Fatalf("err does not carry the status: %v", err)
}
})
}
}
// A connection that never answers is indistinguishable from one whose request was applied, so it
// has to be transient — the caller probes before retrying.
func TestUnreachableServiceIsTransient(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
url := srv.URL
srv.Close()
c := NewClient(url, "device-1", &http.Client{Timeout: time.Second})
_, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{
Name: "x", Amount: "10",
OperationTime: time.Now().In(msk), RequestTime: time.Now().In(msk),
})
if !errors.Is(err, ErrTransient) {
t.Fatalf("err = %v, want ErrTransient", err)
}
}
func TestCancelIncomeBuildsTheRequest(t *testing.T) {
c, got := fakeCabinet(t, http.StatusOK, `{}`)
now := time.Date(2026, 8, 1, 10, 0, 0, 0, msk)
if err := c.CancelIncome(context.Background(), liveSession(), "20abcdef01", now); err != nil {
t.Fatalf("cancel: %v", err)
}
if got.path != "/api/v1/cancel" {
t.Fatalf("path = %q", got.path)
}
if got.body["receiptUuid"] != "20abcdef01" {
t.Fatalf("receiptUuid = %v", got.body["receiptUuid"])
}
if got.body["comment"] != cancelReasonRefund {
t.Fatalf("comment = %v, want the reason the service recognises", got.body["comment"])
}
if got.body["operationTime"] != "2026-08-01T10:00:00+03:00" {
t.Fatalf("operationTime = %v", got.body["operationTime"])
}
}
func TestFindIncomeMatchesTheExactName(t *testing.T) {
c, got := fakeCabinet(t, http.StatusOK, `{"content":[
{"name":"другая покупка","approvedReceiptUuid":"other"},
{"name":"нужный чек","approvedReceiptUuid":"wanted"}]}`)
from := time.Date(2026, 7, 28, 0, 0, 0, 0, msk)
to := time.Date(2026, 7, 29, 0, 0, 0, 0, msk)
uuid, found, err := c.FindIncome(context.Background(), liveSession(), "нужный чек", from, to)
if err != nil {
t.Fatalf("find: %v", err)
}
if !found || uuid != "wanted" {
t.Fatalf("found = %v, uuid = %q", found, uuid)
}
if got.path != "/api/v1/incomes" {
t.Fatalf("path = %q", got.path)
}
if !strings.Contains(got.query, "from=2026-07-28T00%3A00%3A00.000%2B03%3A00") {
t.Fatalf("query = %q", got.query)
}
}
// An annulled receipt must never satisfy the probe: annulling it again would fail, and treating it
// as ours would hide an income that really is missing.
func TestFindIncomeSkipsAnnulledReceipts(t *testing.T) {
c, _ := fakeCabinet(t, http.StatusOK, `{"content":[
{"name":"нужный чек","approvedReceiptUuid":"dead","cancellationInfo":{"comment":"Возврат средств"}}]}`)
_, found, err := c.FindIncome(context.Background(), liveSession(), "нужный чек",
time.Now().In(msk).Add(-24*time.Hour), time.Now().In(msk))
if err != nil {
t.Fatalf("find: %v", err)
}
if found {
t.Fatal("an annulled receipt must not be reported as ours")
}
}
func TestFindIncomeReportsAbsence(t *testing.T) {
c, _ := fakeCabinet(t, http.StatusOK, `{"content":[]}`)
_, found, err := c.FindIncome(context.Background(), liveSession(), "нужный чек",
time.Now().In(msk).Add(-24*time.Hour), time.Now().In(msk))
if err != nil {
t.Fatalf("find: %v", err)
}
if found {
t.Fatal("found = true on an empty list")
}
}
func TestDeviceIDForLoginIsStable(t *testing.T) {
first := DeviceIDForLogin("770000000000")
if first != DeviceIDForLogin("770000000000") {
t.Fatal("the device id must not change between runs")
}
if len(first) != deviceIDLength {
t.Fatalf("device id = %q, want %d characters", first, deviceIDLength)
}
if first == DeviceIDForLogin("770000000001") {
t.Fatal("different logins must yield different devices")
}
}
func TestReceiptURL(t *testing.T) {
c := NewClient("https://lknpd.example.test", "device-1", nil)
got := c.ReceiptURL("770000000000", "20abcdef01")
want := "https://lknpd.example.test/api/v1/receipt/770000000000/20abcdef01/print"
if got != want {
t.Fatalf("url = %q, want %q", got, want)
}
}
func TestIncomeName(t *testing.T) {
order := uuid.MustParse("0198c1f2-1111-7000-8000-0000cafe0001")
got := IncomeName(100, order, "")
want := `Внутриигровая валюта: "Фишка", 100 шт. (ID: cafe0001)`
if got != want {
t.Fatalf("name = %q, want %q", got, want)
}
}
// The marker must come from the random tail of a UUIDv7, not its clock-derived head: the first
// eight hex digits are the high bits of a millisecond timestamp and are shared by every order placed
// within about a minute. Two such orders would be indistinguishable to the recovery probe, which is
// how an income ends up filed twice.
func TestIncomeNameMarkerIsNotClockDerived(t *testing.T) {
// Two ids one millisecond apart, differing only in their random tail.
first := uuid.MustParse("0198c1f2-0001-7000-8000-000000000001")
second := uuid.MustParse("0198c1f2-0002-7000-8000-000000000002")
if IncomeName(100, first, "") == IncomeName(100, second, "") {
t.Fatal("two orders in the same time bucket share a service name")
}
}
func TestIncomeNameFallsBackToTheProductTitle(t *testing.T) {
order := uuid.MustParse("0198c1f2-1111-7000-8000-0000cafe0001")
if got := IncomeName(0, order, "Подсказки"); got != "Подсказки (ID: cafe0001)" {
t.Fatalf("name = %q", got)
}
if got := IncomeName(0, order, " "); got != defaultTitle+" (ID: cafe0001)" {
t.Fatalf("name = %q, want the default description", got)
}
}
// The marker is what makes the post-timeout probe exact, so the description gives way first and the
// marker is never truncated.
func TestIncomeNameCapsLengthButKeepsTheMarker(t *testing.T) {
order := uuid.MustParse("0198c1f2-1111-7000-8000-0000cafe0001")
got := IncomeName(0, order, strings.Repeat("Ы", 400))
if n := len([]rune(got)); n != maxNameRunes {
t.Fatalf("length = %d runes, want %d", n, maxNameRunes)
}
if !strings.HasSuffix(got, "(ID: cafe0001)") {
t.Fatalf("name = %q, the marker must survive truncation", got)
}
}
func TestCryptoRoundTrip(t *testing.T) {
key, err := ParseKey("YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=")
if err != nil {
t.Fatalf("parse key: %v", err)
}
blob, err := Seal(key, "refresh-1")
if err != nil {
t.Fatalf("seal: %v", err)
}
if strings.Contains(string(blob), "refresh-1") {
t.Fatal("the sealed blob still contains the plaintext")
}
got, err := Open(key, blob)
if err != nil {
t.Fatalf("open: %v", err)
}
if got != "refresh-1" {
t.Fatalf("opened %q", got)
}
}
// Losing or rotating the key must be a nuisance — sign in again — not a crash.
func TestOpenWithAWrongKeyIsUndecryptable(t *testing.T) {
good, _ := ParseKey("YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=")
other, _ := ParseKey("MTIzNDU2NzhhYmNkZWZnaGlqa2xtbm9wcXJzdHV2dzA=")
blob, err := Seal(good, "refresh-1")
if err != nil {
t.Fatalf("seal: %v", err)
}
if _, err := Open(other, blob); !errors.Is(err, ErrUndecryptable) {
t.Fatalf("err = %v, want ErrUndecryptable", err)
}
if _, err := Open(good, blob[:4]); !errors.Is(err, ErrUndecryptable) {
t.Fatalf("truncated blob: err = %v, want ErrUndecryptable", err)
}
}
func TestParseKey(t *testing.T) {
if _, err := ParseKey("6162636465666768696a6b6c6d6e6f707172737475767778797a313233343536"); err != nil {
t.Fatalf("hex key rejected: %v", err)
}
for _, bad := range []string{"", "short", "YWJj"} {
if _, err := ParseKey(bad); err == nil {
t.Fatalf("ParseKey(%q) accepted a key of the wrong size", bad)
}
}
}
+85
View File
@@ -0,0 +1,85 @@
package mynalog
import (
"fmt"
"net/url"
"strings"
"unicode/utf8"
"github.com/google/uuid"
)
// maxNameRunes is the tax service's ceiling on a service name, counted in characters rather than
// bytes — the names here are Cyrillic, so the two differ by a factor of two.
const maxNameRunes = 128
// markerRunes is how much of the order id rides in the name.
//
// It is taken from the END of the identifier, and that is not cosmetic. Our order ids are UUIDv7,
// whose leading hex digits are a millisecond timestamp: the first eight of them are the high 32
// bits of that timestamp, so they only change about once a minute and every order placed within the
// same minute would share them. The trailing digits are random. Since this marker is the sole key
// the recovery probe matches on, a shared marker would make two purchases indistinguishable — which
// is precisely the situation that ends in an income filed twice or not at all.
const markerRunes = 8
// defaultTitle describes the goods when the product carries no usable title of its own. It has to
// read as a plain description of what was sold, because the tax service and the buyer both see it.
const defaultTitle = "Цифровые ценности"
// IncomeName builds the service name for one purchase: a human-readable description of what was
// sold, followed by a marker derived from the order id.
//
// The marker is not decoration. Registering an income is not idempotent, so after a timeout the
// only way to discover whether the receipt exists is to search the tax service's income list by
// this exact string — and without the marker two identical chip packs would be indistinguishable,
// which would either duplicate the income or lose it. The result is capped at maxNameRunes with the
// description giving way first: the marker is never truncated.
//
// chips is the pack size; when it is zero the product is not a chip pack and fallbackTitle
// describes it instead.
func IncomeName(chips int, orderID uuid.UUID, fallbackTitle string) string {
desc := strings.TrimSpace(fallbackTitle)
if chips > 0 {
desc = fmt.Sprintf("Внутриигровая валюта: %q, %d шт.", "Фишка", chips)
}
if desc == "" {
desc = defaultTitle
}
suffix := fmt.Sprintf(" (ID: %s)", orderMarker(orderID))
budget := maxNameRunes - utf8.RuneCountInString(suffix)
if budget < 1 {
return suffix
}
if utf8.RuneCountInString(desc) > budget {
desc = strings.TrimSpace(string([]rune(desc)[:budget-1])) + "…"
}
return desc + suffix
}
// orderMarker renders the part of the order id that rides in a service name: its last markerRunes
// hex digits, which for a UUIDv7 are random rather than clock-derived.
func orderMarker(orderID uuid.UUID) string {
s := orderID.String()
if len(s) < markerRunes {
return s
}
return s[len(s)-markerRunes:]
}
// ReceiptURL is where the printable receipt for a registered income lives. It is what the buyer is
// sent as their proof of purchase, so it is a package function rather than a method: the letter is
// composed far from any authenticated client.
func ReceiptURL(baseURL, inn, receiptUUID string) string {
if baseURL == "" {
baseURL = DefaultBaseURL
}
return fmt.Sprintf("%s/api/v1/receipt/%s/%s/print",
strings.TrimSuffix(baseURL, "/"), url.PathEscape(inn), url.PathEscape(receiptUUID))
}
// ReceiptURL is where the printable receipt for a registered income lives, against this client's
// service root.
func (c *Client) ReceiptURL(inn, receiptUUID string) string {
return ReceiptURL(c.baseURL, inn, receiptUUID)
}