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 }