package lkdr import "context" type Authorizer interface { GetCaptchaToken(ctx context.Context, userAgent, siteKey, pageURL string) (string, error) GetConfirmationCode(ctx context.Context, phone string) (string, error) } type authorizerKey struct{} func WithAuthorizer(ctx context.Context, authorizer Authorizer) context.Context { return context.WithValue(ctx, authorizerKey{}, authorizer) } func getAuthorizer(ctx context.Context) Authorizer { authorizer, _ := ctx.Value(authorizerKey{}).(Authorizer) return authorizer }
package lkdr import ( "bytes" "context" "encoding/json" "net/http" "time" "github.com/jfk9w-go/based" "github.com/pkg/errors" ) const ( baseURL = "https://mco.nalog.ru/api" expireTokenOffset = 5 * time.Minute captchaSiteKey = "hfU4TD7fJUI7XcP5qRphKWgnIR5t9gXAxTRqdQJk" captchaPageURL = "https://lkdr.nalog.ru/login" ) type TokenStorage interface { LoadTokens(ctx context.Context, phone string) (*Tokens, error) UpdateTokens(ctx context.Context, phone string, tokens *Tokens) error } type ClientParams struct { Phone string `validate:"required"` Clock based.Clock `validate:"required"` DeviceID string `validate:"required"` UserAgent string `validate:"required"` TokenStorage TokenStorage `validate:"required"` Transport http.RoundTripper } func NewClient(params ClientParams) (*Client, error) { if err := based.Validate(params); err != nil { return nil, err } return &Client{ clock: params.Clock, phone: params.Phone, deviceInfo: deviceInfo{ SourceType: "WEB", SourceDeviceId: params.DeviceID, MetaDetails: metaDetails{ UserAgent: params.UserAgent, }, AppVersion: "1.0.0", }, httpClient: &http.Client{ Transport: params.Transport, }, token: based.NewWriteThroughCached[string, *Tokens]( based.WriteThroughCacheStorageFunc[string, *Tokens]{ LoadFn: params.TokenStorage.LoadTokens, UpdateFn: params.TokenStorage.UpdateTokens, }, params.Phone, ), mu: based.Semaphore(params.Clock, 20, time.Minute), }, nil } type Client struct { clock based.Clock phone string deviceInfo deviceInfo httpClient *http.Client token *based.WriteThroughCached[*Tokens] mu based.Locker } func (c *Client) Receipt(ctx context.Context, in *ReceiptIn) (*ReceiptOut, error) { return execute(ctx, c, in) } func (c *Client) FiscalData(ctx context.Context, in *FiscalDataIn) (*FiscalDataOut, error) { return execute(ctx, c, in) } func (c *Client) ensureToken(ctx context.Context) (string, error) { tokens, err := c.token.Get(ctx) if err != nil { return "", errors.Wrap(err, "load token") } now := c.clock.Now() updateToken := true if tokens == nil || tokens.RefreshTokenExpiresIn != nil && tokens.RefreshTokenExpiresIn.Time().Before(now.Add(expireTokenOffset)) { tokens, err = c.authorize(ctx) if err != nil { return "", errors.Wrap(err, "authorize") } } else if tokens.TokenExpireIn.Time().Before(now.Add(expireTokenOffset)) { tokens, err = c.refreshToken(ctx, tokens.RefreshToken) if err != nil { return "", errors.Wrap(err, "refresh token") } } else { updateToken = false } if updateToken { if err := c.token.Update(ctx, tokens); err != nil { return "", errors.Wrap(err, "update token") } } return tokens.Token, nil } func (c *Client) authorize(ctx context.Context) (*Tokens, error) { authorizer := getAuthorizer(ctx) if authorizer == nil { return nil, errors.New("authorizer is required, but not set") } captchaToken, err := authorizer.GetCaptchaToken(ctx, c.deviceInfo.MetaDetails.UserAgent, captchaSiteKey, captchaPageURL) if err != nil { return nil, errors.Wrap(err, "get captcha token") } startIn := &startIn{ DeviceInfo: c.deviceInfo, Phone: c.phone, CaptchaToken: captchaToken, } startOut, err := execute(ctx, c, startIn) if err != nil { var clientErr Error if !errors.As(err, &clientErr) || clientErr.Code != SmsVerificationNotExpired { return nil, errors.Wrap(err, "start sms challenge") } } code, err := authorizer.GetConfirmationCode(ctx, c.phone) if err != nil { return nil, errors.Wrap(err, "get confirmation code") } verifyIn := &verifyIn{ DeviceInfo: c.deviceInfo, Phone: c.phone, ChallengeToken: startOut.ChallengeToken, Code: code, } tokens, err := execute(ctx, c, verifyIn) if err != nil { return nil, errors.Wrap(err, "verify code") } return tokens, nil } func (c *Client) refreshToken(ctx context.Context, refreshToken string) (*Tokens, error) { in := &tokenIn{ DeviceInfo: c.deviceInfo, RefreshToken: refreshToken, } return execute[Tokens](ctx, c, in) } func execute[R any](ctx context.Context, c *Client, in exchange[R]) (*R, error) { var token string if in.auth() { var ( cancel context.CancelFunc err error ) ctx, cancel = c.mu.Lock(ctx) defer cancel() if err := ctx.Err(); err != nil { return nil, err } token, err = c.ensureToken(ctx) if err != nil { return nil, err } } reqBody, err := json.Marshal(in) if err != nil { return nil, errors.Wrap(err, "marshal json body") } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+in.path(), bytes.NewReader(reqBody)) if err != nil { return nil, errors.Wrap(err, "create request") } httpReq.Header.Set("Content-Type", "application/json;charset=UTF-8") if token != "" { httpReq.Header.Set("Authorization", "Bearer "+token) } httpResp, err := c.httpClient.Do(httpReq) if err != nil { return nil, errors.Wrap(err, "execute request") } if httpResp.Body == nil { return nil, errors.New(httpResp.Status) } defer httpResp.Body.Close() if httpResp.StatusCode != http.StatusOK { var clientErr Error if err := json.NewDecoder(httpResp.Body).Decode(&clientErr); err == nil { return nil, clientErr } return nil, errors.New(httpResp.Status) } var out R if err := json.NewDecoder(httpResp.Body).Decode(&out); err != nil { return nil, errors.Wrap(err, "decode response body") } return &out, nil }
package lkdr import ( "context" "encoding/json" "strings" "time" "github.com/jfk9w-go/based" "github.com/pkg/errors" ) var dateTimeLocation = based.LazyFuncRef[*time.Location]( func(ctx context.Context) (*time.Location, error) { return time.LoadLocation("Europe/Moscow") }, ) type DateTime time.Time const dateTimeLayout = "2006-01-02T15:04:05" func (dt DateTime) Time() time.Time { return time.Time(dt) } func (dt DateTime) MarshalJSON() ([]byte, error) { location, err := dateTimeLocation.Get(context.Background()) if err != nil { return nil, errors.Wrap(err, "load location") } str := dt.Time().In(location).Format(dateTimeLayout) return json.Marshal(str) } func (dt *DateTime) UnmarshalJSON(data []byte) error { var str string if err := json.Unmarshal(data, &str); err != nil { return err } location, err := dateTimeLocation.Get(context.Background()) if err != nil { return errors.Wrap(err, "load location") } value, err := time.ParseInLocation(dateTimeLayout, str, location) if err != nil { return err } *dt = DateTime(value) return nil } type Date time.Time const dateLayout = "2006-01-02" func (d Date) Time() time.Time { return time.Time(d) } func (d Date) MarshalJSON() ([]byte, error) { location, err := dateTimeLocation.Get(context.Background()) if err != nil { return nil, errors.Wrap(err, "load location") } str := d.Time().In(location).Format(dateLayout) return json.Marshal(str) } func (d *Date) UnmarshalJSON(data []byte) error { var str string if err := json.Unmarshal(data, &str); err != nil { return err } location, err := dateTimeLocation.Get(context.Background()) if err != nil { return errors.Wrap(err, "load location") } value, err := time.ParseInLocation(dateLayout, str, location) if err != nil { return err } *d = Date(value) return nil } type DateTimeTZ time.Time func (dt DateTimeTZ) Time() time.Time { return time.Time(dt) } const dateTimeTZLayout = "2006-01-02T15:04:05.999Z" func (dt DateTimeTZ) MarshalJSON() ([]byte, error) { str := dt.Time().Format(dateTimeTZLayout) return json.Marshal(str) } func (dt *DateTimeTZ) UnmarshalJSON(data []byte) error { var str string if err := json.Unmarshal(data, &str); err != nil { return err } value, err := time.Parse(dateTimeTZLayout, str) if err != nil { return err } *dt = DateTimeTZ(value) return nil } type DateTimeMilliOffset time.Time const dateTimeMilliOffsetLayout = "2006-01-02T15:04:05.999999-07:00" func (dt DateTimeMilliOffset) Time() time.Time { return time.Time(dt) } func (dt DateTimeMilliOffset) MarshalJSON() ([]byte, error) { location, err := dateTimeLocation.Get(context.Background()) if err != nil { return nil, errors.Wrap(err, "load location") } str := dt.Time().In(location).Format(dateTimeMilliOffsetLayout) return json.Marshal(str) } func (dt *DateTimeMilliOffset) UnmarshalJSON(data []byte) error { var str string if err := json.Unmarshal(data, &str); err != nil { return err } value, err := time.Parse(dateTimeMilliOffsetLayout, str) if err != nil { return err } *dt = DateTimeMilliOffset(value) return nil } type ErrorCode string const ( SmsVerificationNotExpired ErrorCode = "registration.sms.verification.not.expired" BlockedCaptcha ErrorCode = "blocked.captcha" ReceiptFiscalDataNotFound ErrorCode = "receipt.fiscaldata.not.found.dr" ) type Error struct { Code ErrorCode `json:"code"` Message string `json:"message"` } func (e Error) Error() string { var b strings.Builder if e.Code != "" { b.WriteString(string(e.Code)) if e.Message != "" { b.WriteString(" (" + e.Message + ")") } } else if e.Message != "" { b.WriteString(e.Message) } return b.String() } func IsDataNotFound(err error) bool { var e Error if errors.As(err, &e); e.Code == ReceiptFiscalDataNotFound { return true } return false } type metaDetails struct { UserAgent string `json:"userAgent"` } type deviceInfo struct { AppVersion string `json:"appVersion" validate:"required"` MetaDetails metaDetails `json:"metaDetails"` SourceDeviceId string `json:"sourceDeviceId" validate:"required"` SourceType string `json:"sourceType" validate:"required"` } type exchange[R any] interface { auth() bool path() string out() R } type startIn struct { DeviceInfo deviceInfo `json:"deviceInfo" validate:"required"` Phone string `json:"phone" validate:"required"` CaptchaToken string `json:"captchaToken" validate:"required"` } func (in startIn) auth() bool { return false } func (in startIn) path() string { return "/v2/auth/challenge/sms/start" } func (in startIn) out() (_ startOut) { return } type startOut struct { ChallengeToken string `json:"challengeToken"` ChallengeTokenExpiresIn DateTimeMilliOffset `json:"challengeTokenExpiresIn"` ChallengeTokenExpiresInSec int `json:"challengeTokenExpiresInSec"` } type verifyIn struct { DeviceInfo deviceInfo `json:"deviceInfo"` Phone string `json:"phone" validate:"required"` ChallengeToken string `json:"challengeToken" validate:"required"` Code string `json:"code" validate:"required"` } func (in verifyIn) auth() bool { return false } func (in verifyIn) path() string { return "/v1/auth/challenge/sms/verify" } func (in verifyIn) out() (_ Tokens) { return } type tokenIn struct { DeviceInfo deviceInfo `json:"deviceInfo"` RefreshToken string `json:"refreshToken" validate:"required"` } func (in tokenIn) auth() bool { return false } func (in tokenIn) path() string { return "/v1/auth/token" } func (in tokenIn) out() (_ Tokens) { return } type Tokens struct { RefreshToken string `json:"refreshToken"` RefreshTokenExpiresIn *DateTimeTZ `json:"refreshTokenExpiresIn,omitempty"` Token string `json:"token"` TokenExpireIn DateTimeTZ `json:"tokenExpireIn"` } type ReceiptIn struct { DateFrom *Date `json:"dateFrom"` DateTo *Date `json:"dateTo"` Inn *string `json:"inn"` KktOwner string `json:"kktOwner"` Limit int `json:"limit"` Offset int `json:"offset"` OrderBy string `json:"orderBy"` } func (in ReceiptIn) auth() bool { return true } func (in ReceiptIn) path() string { return "/v1/receipt" } func (in ReceiptIn) out() (_ ReceiptOut) { return } type Brand struct { Description string `json:"description"` Id int64 `json:"id"` Image *string `json:"image"` Name string `json:"name"` } type Receipt struct { BrandId *int64 `json:"brandId"` Buyer string `json:"buyer"` BuyerType string `json:"buyerType"` CreatedDate DateTime `json:"createdDate"` FiscalDocumentNumber string `json:"fiscalDocumentNumber"` FiscalDriveNumber string `json:"fiscalDriveNumber"` Key string `json:"key"` KktOwner string `json:"kktOwner"` KktOwnerInn string `json:"kktOwnerInn"` ReceiveDate DateTime `json:"receiveDate"` TotalSum string `json:"totalSum"` } type ReceiptOut struct { Brands []Brand `json:"brands"` Receipts []Receipt `json:"receipts"` HasMore bool `json:"hasMore"` } type FiscalDataIn struct { Key string `json:"key"` } func (in FiscalDataIn) auth() bool { return true } func (in FiscalDataIn) path() string { return "/v1/receipt/fiscal_data" } func (in FiscalDataIn) out() (_ FiscalDataOut) { return } type ProviderData struct { ProviderPhone []string `json:"providerPhone"` ProviderName string `json:"providerName"` } type FiscalDataItem struct { Name string `json:"name"` Nds int `json:"nds"` PaymentType int `json:"paymentType"` Price float64 `json:"price"` ProductType int `json:"productType"` ProviderData *ProviderData `json:"providerData"` ProviderInn *string `json:"providerInn"` Quantity float64 `json:"quantity"` Sum float64 `json:"sum"` } type FiscalDataOut struct { BuyerAddress string `json:"buyerAddress"` CashTotalSum float64 `json:"cashTotalSum"` CreditSum float64 `json:"creditSum"` DateTime DateTime `json:"dateTime"` EcashTotalSum float64 `json:"ecashTotalSum"` FiscalDocumentFormatVer string `json:"fiscalDocumentFormatVer"` FiscalDocumentNumber int64 `json:"fiscalDocumentNumber"` FiscalDriveNumber string `json:"fiscalDriveNumber"` FiscalSign string `json:"fiscalSign"` InternetSign *int `json:"internetSign"` Items []FiscalDataItem `json:"items"` KktRegId string `json:"kktRegId"` MachineNumber *string `json:"machineNumber"` Nds10 *float64 `json:"nds10"` Nds18 *float64 `json:"nds18"` OperationType int `json:"operationType"` Operator *string `json:"operator"` PrepaidSum float64 `json:"prepaidSum"` ProvisionSum float64 `json:"provisionSum"` RequestNumber int64 `json:"requestNumber"` RetailPlace *string `json:"retailPlace"` RetailPlaceAddress *string `json:"retailPlaceAddress"` ShiftNumber int64 `json:"shiftNumber"` TaxationType int `json:"taxationType"` TotalSum float64 `json:"totalSum"` User *string `json:"user"` UserInn string `json:"userInn"` }
package main import ( "bufio" "context" "encoding/json" "fmt" "os" "path/filepath" "strings" "github.com/caarlos0/env" "github.com/jfk9w-go/based" "github.com/jfk9w-go/rucaptcha-api" "github.com/pkg/errors" "github.com/jfk9w-go/lkdr-api" ) type jsonTokenStorage struct { path string } func (s jsonTokenStorage) LoadTokens(ctx context.Context, phone string) (*lkdr.Tokens, error) { file, err := s.open(os.O_RDONLY) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, nil } return nil, err } defer file.Close() contents := make(map[string]lkdr.Tokens) if err := json.NewDecoder(file).Decode(&contents); err != nil { return nil, errors.Wrap(err, "decode json") } if tokens, ok := contents[phone]; ok { return &tokens, nil } return nil, nil } func (s jsonTokenStorage) UpdateTokens(ctx context.Context, phone string, tokens *lkdr.Tokens) error { file, err := s.open(os.O_RDWR | os.O_CREATE) if err != nil { return err } stat, err := file.Stat() if err != nil { return errors.Wrap(err, "stat") } contents := make(map[string]lkdr.Tokens) if stat.Size() > 0 { if err := json.NewDecoder(file).Decode(&contents); err != nil { return errors.Wrap(err, "decode json") } } if tokens != nil { contents[phone] = *tokens } else { delete(contents, phone) } if err := file.Truncate(0); err != nil { return errors.Wrap(err, "truncate file") } if _, err := file.Seek(0, 0); err != nil { return errors.Wrap(err, "seek to the start of file") } if err := json.NewEncoder(file).Encode(&contents); err != nil { return errors.Wrap(err, "encode json") } return nil } func (s jsonTokenStorage) open(flag int) (*os.File, error) { if err := os.MkdirAll(filepath.Dir(s.path), os.ModeDir); err != nil { return nil, errors.Wrap(err, "create parent directory") } file, err := os.OpenFile(s.path, flag, 0644) if err != nil { return nil, errors.Wrap(err, "open file") } return file, nil } type authorizer struct { rucaptchaClient *rucaptcha.Client } func (a *authorizer) GetCaptchaToken(ctx context.Context, userAgent, siteKey, pageURL string) (string, error) { solved, err := a.rucaptchaClient.Solve(ctx, &rucaptcha.YandexSmartCaptchaIn{ UserAgent: userAgent, SiteKey: siteKey, PageURL: pageURL, }) if err != nil { return "", err } return solved.Answer, nil } func (a *authorizer) GetConfirmationCode(ctx context.Context, phone string) (string, error) { reader := bufio.NewReader(os.Stdin) fmt.Printf("Enter confirmation code for %s: ", phone) text, err := reader.ReadString('\n') if err != nil { return "", errors.Wrap(err, "read line from stdin") } return strings.Trim(text, " \n\t\v"), nil } func main() { var config struct { RucaptchaKey string `env:"RUCAPTCHA_KEY,required"` Phone string `env:"LKDR_PHONE,required"` TokensFile string `env:"LKDR_TOKENS_FILE,required"` DeviceID string `env:"LKDR_DEVICE_ID,required"` UserAgent string `env:"LKDR_USER_AGENT,required"` } if err := env.Parse(&config); err != nil { panic(err) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() client, err := lkdr.NewClient(lkdr.ClientParams{ Phone: config.Phone, Clock: based.StandardClock, DeviceID: config.DeviceID, UserAgent: config.UserAgent, TokenStorage: jsonTokenStorage{ path: config.TokensFile, }, }) if err != nil { panic(err) } rucaptchaClient, err := rucaptcha.NewClient(rucaptcha.ClientParams{ Config: rucaptcha.Config{ Key: config.RucaptchaKey, }, }) if err != nil { panic(err) } ctx = lkdr.WithAuthorizer(ctx, &authorizer{rucaptchaClient: rucaptchaClient}) receipts, err := client.Receipt(ctx, &lkdr.ReceiptIn{ Limit: 1, Offset: 0, OrderBy: "RECEIVE_DATE:DESC", }) if err != nil { panic(err) } fmt.Printf("Last receipt key: %s\n", receipts.Receipts[0].Key) fiscalData, err := client.FiscalData(ctx, &lkdr.FiscalDataIn{ Key: receipts.Receipts[0].Key, }) if err != nil { panic(err) } fmt.Printf("First item in last receipt: %s\n", fiscalData.Items[0].Name) }