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
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:
@@ -0,0 +1,85 @@
|
||||
package mynalog
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// KeyLength is the AES-256 key size the stored refresh token is sealed with.
|
||||
const KeyLength = 32
|
||||
|
||||
// ErrUndecryptable is returned when a stored secret cannot be opened with the configured key —
|
||||
// typically because the key was rotated or lost. Callers treat it as "there is no session" and ask
|
||||
// the operator to sign in again, which is a nuisance rather than a failure.
|
||||
var ErrUndecryptable = errors.New("mynalog: stored secret cannot be decrypted")
|
||||
|
||||
// ParseKey decodes the configured encryption key. It accepts standard base64 with or without
|
||||
// padding (so the output of `openssl rand -base64 32` works as-is) and plain hex.
|
||||
func ParseKey(s string) ([]byte, error) {
|
||||
for _, decode := range []func(string) ([]byte, error){
|
||||
base64.StdEncoding.DecodeString,
|
||||
base64.RawStdEncoding.DecodeString,
|
||||
hex.DecodeString,
|
||||
} {
|
||||
if key, err := decode(s); err == nil && len(key) == KeyLength {
|
||||
return key, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("mynalog: encryption key must decode to %d bytes (base64 or hex)", KeyLength)
|
||||
}
|
||||
|
||||
// Seal encrypts plaintext with key, returning the nonce followed by the AES-GCM ciphertext.
|
||||
//
|
||||
// This exists because the refresh token is a long-lived credential to the operator's personal tax
|
||||
// cabinet, and it is the one part of the rail that has to sit on disk. Encrypting it at the
|
||||
// application layer keeps it out of database dumps, which travel further than the database does.
|
||||
func Seal(key []byte, plaintext string) ([]byte, error) {
|
||||
gcm, err := newGCM(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, fmt.Errorf("mynalog: nonce: %w", err)
|
||||
}
|
||||
return gcm.Seal(nonce, nonce, []byte(plaintext), nil), nil
|
||||
}
|
||||
|
||||
// Open decrypts a blob produced by Seal. Any failure — a wrong key, a truncated value, a tampered
|
||||
// one — comes back as ErrUndecryptable, because the caller's response is the same in every case.
|
||||
func Open(key, blob []byte) (string, error) {
|
||||
gcm, err := newGCM(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(blob) < gcm.NonceSize() {
|
||||
return "", ErrUndecryptable
|
||||
}
|
||||
nonce, ciphertext := blob[:gcm.NonceSize()], blob[gcm.NonceSize():]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", ErrUndecryptable
|
||||
}
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
// newGCM builds the AEAD both directions share.
|
||||
func newGCM(key []byte) (cipher.AEAD, error) {
|
||||
if len(key) != KeyLength {
|
||||
return nil, fmt.Errorf("mynalog: encryption key must be %d bytes, got %d", KeyLength, len(key))
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mynalog: cipher: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mynalog: gcm: %w", err)
|
||||
}
|
||||
return gcm, nil
|
||||
}
|
||||
Reference in New Issue
Block a user