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.
86 lines
3.5 KiB
Go
86 lines
3.5 KiB
Go
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)
|
|
}
|