e3c2e80a0a
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.
131 lines
4.7 KiB
Go
131 lines
4.7 KiB
Go
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
|
|
}
|