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
+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)
}
}
}