Files
scrabble-game/backend/internal/mynalog/income.go
T
Ilia Denisov 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
feat(payments): report income to «Мой налог»
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.
2026-07-28 15:40:36 +02:00

195 lines
8.1 KiB
Go

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
}