package tinkoff import "context" type Authorizer interface { 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 tinkoff import ( "context" "encoding/json" "io" "net/http" "net/url" "strings" "time" "github.com/google/go-querystring/query" "github.com/jfk9w-go/based" "github.com/pkg/errors" ) const ( baseURL = "https://www.tinkoff.ru/api" pingInterval = time.Minute ) var ( ErrNoDataFound = errors.New("no data found") errMaxRetriesExceeded = errors.New("max retries exceeded") errUnauthorized = errors.New("no sessionid") ) type Session struct { ID string } type SessionStorage interface { LoadSession(ctx context.Context, phone string) (*Session, error) UpdateSession(ctx context.Context, phone string, session *Session) error } type Credential struct { Phone string Password string } type ClientParams struct { Clock based.Clock `validate:"required"` Credential Credential `validate:"required"` SessionStorage SessionStorage `validate:"required"` Transport http.RoundTripper } type Client struct { credential Credential httpClient *http.Client session *based.WriteThroughCached[*Session] rateLimiters map[string]based.Locker mu based.RWMutex } func NewClient(params ClientParams) (*Client, error) { if err := based.Validate(params); err != nil { return nil, err } c := &Client{ credential: params.Credential, httpClient: &http.Client{ Transport: params.Transport, }, session: based.NewWriteThroughCached[string, *Session]( based.WriteThroughCacheStorageFunc[string, *Session]{ LoadFn: params.SessionStorage.LoadSession, UpdateFn: params.SessionStorage.UpdateSession, }, params.Credential.Phone, ), rateLimiters: map[string]based.Locker{ shoppingReceiptPath: based.Lockers{ based.Semaphore(params.Clock, 25, 75*time.Second), based.Semaphore(params.Clock, 75, 11*time.Minute), }, }, } return c, nil } func (c *Client) Ping(ctx context.Context) { ticker := time.NewTicker(pingInterval) defer ticker.Stop() for { _ = c.ping(ctx) select { case <-ctx.Done(): return case <-ticker.C: } } } func (c *Client) AccountsLightIb(ctx context.Context) (AccountsLightIbOut, error) { resp, err := executeCommon[AccountsLightIbOut](ctx, c, accountsLightIbIn{}) if err != nil { return nil, err } return resp.Payload, nil } func (c *Client) Statements(ctx context.Context, in *StatementsIn) (StatementsOut, error) { resp, err := executeCommon[StatementsOut](ctx, c, in) if err != nil { return nil, err } return resp.Payload, nil } func (c *Client) AccountRequisites(ctx context.Context, in *AccountRequisitesIn) (*AccountRequisitesOut, error) { resp, err := executeCommon[*AccountRequisitesOut](ctx, c, in) if err != nil { return nil, err } return resp.Payload, nil } func (c *Client) Operations(ctx context.Context, in *OperationsIn) (OperationsOut, error) { resp, err := executeCommon[OperationsOut](ctx, c, in) if err != nil { return nil, err } return resp.Payload, nil } func (c *Client) ShoppingReceipt(ctx context.Context, in *ShoppingReceiptIn) (*ShoppingReceiptOut, error) { resp, err := executeCommon[ShoppingReceiptOut](ctx, c, in) if err != nil { return nil, err } return &resp.Payload, nil } func (c *Client) ClientOfferEssences(ctx context.Context) (ClientOfferEssencesOut, error) { resp, err := executeCommon[ClientOfferEssencesOut](ctx, c, clientOfferEssencesIn{}) if err != nil { return nil, err } return resp.Payload, nil } func (c *Client) InvestOperationTypes(ctx context.Context) (*InvestOperationTypesOut, error) { return executeInvest[InvestOperationTypesOut](ctx, c, investOperationTypesIn{}) } func (c *Client) InvestAccounts(ctx context.Context, in *InvestAccountsIn) (*InvestAccountsOut, error) { return executeInvest[InvestAccountsOut](ctx, c, in) } func (c *Client) InvestOperations(ctx context.Context, in *InvestOperationsIn) (*InvestOperationsOut, error) { return executeInvest[InvestOperationsOut](ctx, c, in) } func (c *Client) InvestCandles(ctx context.Context, in *InvestCandlesIn) (*InvestCandlesOut, error) { resp, err := executeCommon[InvestCandlesOut](ctx, c, in) if err != nil { return nil, err } return &resp.Payload, nil } func (c *Client) rateLimiter(path string) based.Locker { if rateLimiter, ok := c.rateLimiters[path]; ok { return rateLimiter } return based.Unlocker } func (c *Client) getSessionID(ctx context.Context) (string, error) { session, err := c.session.Get(ctx) if err != nil { return "", errors.Wrap(err, "get sessionid") } if session == nil { return "", errUnauthorized } return session.ID, nil } func (c *Client) ensureSessionID(ctx context.Context) (string, error) { session, err := c.session.Get(ctx) if err != nil { return "", err } if session == nil { if session, err = c.authorize(ctx); err != nil { _ = c.resetSessionID(ctx) return "", err } } return session.ID, nil } func (c *Client) resetSessionID(ctx context.Context) error { return c.session.Update(ctx, nil) } func (c *Client) authorize(ctx context.Context) (*Session, error) { authorizer := getAuthorizer(ctx) if authorizer == nil { return nil, errors.New("authorizer is required, but not set") } var session *Session if resp, err := executeCommon[sessionOut](ctx, c, sessionIn{}); err != nil { return nil, errors.Wrap(err, "get new sessionid") } else { session = &Session{ID: resp.Payload} if err := c.session.Update(ctx, session); err != nil { return nil, errors.Wrap(err, "store new sessionid") } } if resp, err := executeCommon[signUpOut](ctx, c, phoneSignUpIn{Phone: c.credential.Phone}); err != nil { return nil, errors.Wrap(err, "phone sign up") } else { code, err := authorizer.GetConfirmationCode(ctx, c.credential.Phone) if err != nil { return nil, errors.Wrap(err, "get confirmation code") } if _, err := executeCommon[confirmOut](ctx, c, confirmIn{ InitialOperation: "sign_up", InitialOperationTicket: resp.OperationTicket, ConfirmationData: confirmationData{SMSBYID: code}, }); err != nil { return nil, errors.Wrap(err, "submit confirmation code") } } if _, err := executeCommon[signUpOut](ctx, c, passwordSignUpIn{Password: c.credential.Password}); err != nil { return nil, errors.Wrap(err, "password sign up") } if _, err := executeCommon[levelUpOut](ctx, c, levelUpIn{}); err != nil { return nil, errors.Wrap(err, "level up") } return session, nil } func (c *Client) ping(ctx context.Context) error { ctx, cancel := c.mu.Lock(ctx) defer cancel() if err := ctx.Err(); err != nil { return err } out, err := executeCommon[pingOut](ctx, c, pingIn{}) if err != nil { return errors.Wrap(err, "ping") } if out.Payload.AccessLevel != "CLIENT" { if err := c.resetSessionID(ctx); err != nil { return errors.Wrap(err, "reset sessionid") } return errUnauthorized } return nil } func executeInvest[R any](ctx context.Context, c *Client, in investExchange[R]) (*R, error) { ctx, cancel := c.rateLimiter(in.path()).Lock(ctx) defer cancel() if err := ctx.Err(); err != nil { return nil, err } var sessionID string if in.auth() { ctx, cancel = c.mu.Lock(ctx) defer cancel() if err := ctx.Err(); err != nil { return nil, err } var err error sessionID, err = c.ensureSessionID(ctx) if err != nil { return nil, errors.Wrap(err, "ensure sessionid") } } urlQuery, err := query.Values(in) if err != nil { return nil, errors.Wrap(err, "encode url query") } if sessionID != "" { urlQuery.Set("sessionId", sessionID) } httpReq, err := http.NewRequest(http.MethodGet, baseURL+in.path(), nil) if err != nil { return nil, errors.Wrap(err, "create http request") } httpReq.URL.RawQuery = urlQuery.Encode() httpReq.Header.Set("X-App-Name", "invest") httpReq.Header.Set("X-App-Version", "1.328.0") 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() switch { case httpResp.StatusCode == http.StatusOK: var resp R if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { return nil, errors.Wrap(err, "unmarshal response body") } return &resp, nil case httpResp.StatusCode >= 400 && httpResp.StatusCode < 600: var investErr investError if body, err := io.ReadAll(httpResp.Body); err != nil { return nil, errors.New(httpResp.Status) } else if err := json.Unmarshal(body, &investErr); err != nil { return nil, errors.New(ellipsis(body)) } else { if investErr.ErrorCode == "404" { // this may be due to expired sessionid, try to check it if err := c.ping(ctx); errors.Is(err, errUnauthorized) { retry := &retryStrategy{ timeout: constantRetryTimeout(0), maxRetries: 1, } ctx, err := retry.do(ctx) if err != nil { return nil, investErr } if _, err := c.authorize(ctx); err != nil { return nil, errors.Wrap(err, "authorize") } return executeInvest[R](ctx, c, in) } } return nil, investErr } default: _, _ = io.Copy(io.Discard, httpResp.Body) return nil, errors.New(httpResp.Status) } } func ellipsis(data []byte) string { str := string(data) if len(str) > 200 { return str + "..." } return str } func executeCommon[R any](ctx context.Context, c *Client, in commonExchange[R]) (*commonResponse[R], error) { ctx, cancel := c.rateLimiter(in.path()).Lock(ctx) defer cancel() if err := ctx.Err(); err != nil { return nil, err } var sessionID string if in.auth() != none { var ( cancel context.CancelFunc err error ) ctx, cancel = c.mu.Lock(ctx) defer cancel() if err := ctx.Err(); err != nil { return nil, err } switch in.auth() { case force: sessionID, err = c.ensureSessionID(ctx) case check: sessionID, err = c.getSessionID(ctx) default: return nil, errors.Errorf("unsupported auth %v", in.auth()) } if err != nil { return nil, errors.Wrap(err, "get sessionid") } } reqBody, err := query.Values(in) if err != nil { return nil, errors.Wrap(err, "encode form values") } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+in.path(), strings.NewReader(reqBody.Encode())) if err != nil { return nil, errors.Wrap(err, "create request") } urlQuery := make(url.Values) urlQuery.Set("origin", "web,ib5,platform") if sessionID != "" { urlQuery.Set("sessionid", sessionID) } httpReq.URL.RawQuery = urlQuery.Encode() httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") 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() var ( respErr error retry *retryStrategy ) if httpResp.StatusCode != http.StatusOK { if body, err := io.ReadAll(httpResp.Body); err != nil { respErr = errors.New(httpResp.Status) } else { respErr = errors.New(ellipsis(body)) } retry = &retryStrategy{ timeout: exponentialRetryTimeout(time.Second, 2, 0.5), maxRetries: -1, } } else { var resp commonResponse[R] if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { return nil, errors.Wrap(err, "decode response body") } if in.exprc() == resp.ResultCode { return &resp, nil } respErr = resultCodeError{ actual: resp.ResultCode, expected: in.exprc(), message: resp.ErrorMessage, } switch resp.ResultCode { case "NO_DATA_FOUND": return nil, ErrNoDataFound case "REQUEST_RATE_LIMIT_EXCEEDED": retry = &retryStrategy{ timeout: exponentialRetryTimeout(time.Minute, 2, 0.2), maxRetries: 5, } case "INSUFFICIENT_PRIVILEGES": if _, err := c.authorize(ctx); err != nil { return nil, errors.Wrap(err, "authorize") } retry = &retryStrategy{ timeout: constantRetryTimeout(0), maxRetries: 1, } } } if retry != nil { ctx, retryErr := retry.do(ctx) switch { case errors.Is(retryErr, errMaxRetriesExceeded): // fallthrough case retryErr != nil: return nil, retryErr default: return executeCommon[R](ctx, c, in) } } return nil, respErr }
package tinkoff import ( "context" "encoding/json" "net/url" "strings" "time" "github.com/jfk9w-go/based" "github.com/pkg/errors" ) const ( shoppingReceiptPath = "/common/v1/shopping_receipt" ) type auth int const ( none auth = iota check force ) type commonExchange[R any] interface { auth() auth path() string out() R exprc() string } type commonResponse[R any] struct { ResultCode string `json:"resultCode"` ErrorMessage string `json:"errorMessage"` Payload R `json:"payload"` OperationTicket string `json:"operationTicket"` } type resultCodeError struct { expected, actual string message string } func (e resultCodeError) Error() string { var b strings.Builder b.WriteString(e.actual) b.WriteString(" != ") b.WriteString(e.expected) if e.message != "" { b.WriteString(" (") b.WriteString(e.message) b.WriteString(")") } return b.String() } type Milliseconds time.Time func (ms Milliseconds) Time() time.Time { return time.Time(ms) } func (ms Milliseconds) MarshalJSON() ([]byte, error) { var value struct { Milliseconds int64 `json:"milliseconds"` } value.Milliseconds = ms.Time().UnixMilli() return json.Marshal(value) } func (ms *Milliseconds) UnmarshalJSON(data []byte) error { var value struct { Milliseconds int64 `json:"milliseconds"` } if err := json.Unmarshal(data, &value); err != nil { return err } *ms = Milliseconds(time.UnixMilli(value.Milliseconds)) return nil } var receiptDateTimeLocation = based.LazyFuncRef[*time.Location]( func(ctx context.Context) (*time.Location, error) { return time.LoadLocation("Europe/Moscow") }, ) type ReceiptDateTime time.Time func (dt ReceiptDateTime) Time() time.Time { return time.Time(dt) } func (dt ReceiptDateTime) MarshalJSON() ([]byte, error) { location, err := receiptDateTimeLocation.Get(context.Background()) if err != nil { return nil, errors.Wrap(err, "load location") } value := dt.Time().In(location) value = time.Date(value.Year(), value.Month(), value.Day(), value.Hour(), value.Minute(), value.Second(), value.Nanosecond(), time.UTC) return json.Marshal(value.Unix()) } func (dt *ReceiptDateTime) UnmarshalJSON(data []byte) error { location, err := receiptDateTimeLocation.Get(context.Background()) if err != nil { return errors.Wrap(err, "load location") } var secs int64 if err := json.Unmarshal(data, &secs); err != nil { return err } value := time.Unix(secs, 0).In(time.UTC) value = time.Date(value.Year(), value.Month(), value.Day(), value.Hour(), value.Minute(), value.Second(), value.Nanosecond(), location) *dt = ReceiptDateTime(value) return nil } type sessionIn struct{} func (in sessionIn) auth() auth { return none } func (in sessionIn) path() string { return "/common/v1/session" } func (in sessionIn) out() (_ sessionOut) { return } func (in sessionIn) exprc() string { return "OK" } type sessionOut = string type pingIn struct{} func (in pingIn) auth() auth { return check } func (in pingIn) path() string { return "/common/v1/ping" } func (in pingIn) out() (_ pingOut) { return } func (in pingIn) exprc() string { return "OK" } type pingOut struct { AccessLevel string `json:"accessLevel"` } type signUpIn struct{} func (in signUpIn) auth() auth { return check } func (in signUpIn) path() string { return "/common/v1/sign_up" } func (in signUpIn) out() (_ signUpOut) { return } type signUpOut = json.RawMessage type phoneSignUpIn struct { signUpIn Phone string `url:"phone"` } func (in phoneSignUpIn) exprc() string { return "WAITING_CONFIRMATION" } type passwordSignUpIn struct { signUpIn Password string `url:"password"` } func (in passwordSignUpIn) exprc() string { return "OK" } type confirmationData struct { SMSBYID string `json:"SMSBYID"` } func (cd confirmationData) EncodeValues(key string, v *url.Values) error { data, err := json.Marshal(cd) if err != nil { return err } v.Set(key, string(data)) return nil } type confirmIn struct { InitialOperation string `url:"initialOperation"` InitialOperationTicket string `url:"initialOperationTicket"` ConfirmationData confirmationData `url:"confirmationData"` } func (in confirmIn) auth() auth { return check } func (in confirmIn) path() string { return "/common/v1/confirm" } func (in confirmIn) out() (_ confirmOut) { return } func (in confirmIn) exprc() string { return "OK" } type confirmOut = json.RawMessage type levelUpIn struct{} func (in levelUpIn) auth() auth { return check } func (in levelUpIn) path() string { return "/common/v1/level_up" } func (in levelUpIn) out() (_ levelUpOut) { return } func (in levelUpIn) exprc() string { return "OK" } type levelUpOut = json.RawMessage type Currency struct { Code uint `json:"code"` Name string `json:"name"` StrCode string `json:"strCode"` } type MoneyAmount struct { Currency Currency `json:"currency"` Value float64 `json:"value"` } type accountsLightIbIn struct{} func (in accountsLightIbIn) auth() auth { return force } func (in accountsLightIbIn) path() string { return "/common/v1/accounts_light_ib" } func (in accountsLightIbIn) out() (_ AccountsLightIbOut) { return } func (in accountsLightIbIn) exprc() string { return "OK" } type MultiCardCluster struct { Id string `json:"id"` } type Card struct { Id string `json:"id"` StatusCode string `json:"statusCode"` Status string `json:"status"` PinSet bool `json:"pinSet"` Expiration Milliseconds `json:"expiration"` CardDesign string `json:"cardDesign"` Ucid string `json:"ucid"` PaymentSystem string `json:"paymentSystem"` FrozenCard bool `json:"frozenCard"` HasWrongPins bool `json:"hasWrongPins"` Value string `json:"value"` IsEmbossed bool `json:"isEmbossed"` IsVirtual bool `json:"isVirtual"` CreationDate Milliseconds `json:"creationDate"` MultiCardCluster *MultiCardCluster `json:"multiCardCluster,omitempty"` Name string `json:"name"` IsPaymentDevice bool `json:"isPaymentDevice"` Primary bool `json:"primary"` CardIssueType string `json:"cardIssueType"` SharedResourceId *string `json:"sharedResourceId,omitempty"` } type Loyalty struct { ProgramName string `json:"programName"` ProgramCode string `json:"programCode"` AccountBackgroundColor string `json:"accountBackgroundColor"` CashbackProgram bool `json:"cashbackProgram"` CoreGroup string `json:"coreGroup"` LoyaltyPointsId uint8 `json:"loyaltyPointsId"` AccrualBonuses *float64 `json:"accrualBonuses,omitempty"` LinkedBonuses *string `json:"linkedBonuses,omitempty"` TotalAvailableBonuses *float64 `json:"totalAvailableBonuses,omitempty"` AvailableBonuses *float64 `json:"availableBonuses,omitempty"` } type AccountShared struct { Scopes []string `json:"scopes"` StartDate Milliseconds `json:"startDate"` OwnerName string `json:"ownerName"` SharStatus string `json:"sharStatus"` } type Account struct { Id string `json:"id"` Currency *Currency `json:"currency,omitempty"` CreditLimit *MoneyAmount `json:"creditLimit,omitempty"` MoneyAmount *MoneyAmount `json:"moneyAmount,omitempty"` DebtBalance *MoneyAmount `json:"debtBalance,omitempty"` CurrentMinimalPayment *MoneyAmount `json:"currentMinimalPayment,omitempty"` ClientUnverifiedFlag *string `json:"clientUnverifiedFlag,omitempty"` IdentificationState *string `json:"identificationState,omitempty"` Status *string `json:"status,omitempty"` EmoneyFlag *bool `json:"emoneyFlag,omitempty"` NextStatementDate *Milliseconds `json:"nextStatementDate,omitempty"` DueDate *Milliseconds `json:"dueDate,omitempty"` Cards []Card `json:"cards,omitempty"` MultiCardCluster *MultiCardCluster `json:"multiCardCluster,omitempty"` LoyaltyId *string `json:"loyaltyId,omitempty"` MoneyPotFlag *bool `json:"moneyPotFlag,omitempty"` PartNumber *string `json:"partNumber,omitempty"` PastDueDebt *MoneyAmount `json:"pastDueDebt,omitempty"` Name string `json:"name"` AccountType string `json:"accountType"` Hidden bool `json:"hidden"` SharedByMeFlag *bool `json:"sharedByMeFlag,omitempty"` Loyalty *Loyalty `json:"loyalty,omitempty"` CreationDate *Milliseconds `json:"creationDate,omitempty"` DebtAmount *MoneyAmount `json:"debtAmount,omitempty"` LastStatementDate *Milliseconds `json:"lastStatementDate,omitempty"` DueColor *int `json:"dueColor,omitempty"` LinkedAccountNumber *string `json:"linkedAccountNumber,omitempty"` IsKidsSaving *bool `json:"isKidsSaving,omitempty"` IsCrowdfunding *bool `json:"isCrowdfunding,omitempty"` Shared *AccountShared `json:"shared,omitempty"` } type AccountsLightIbOut = []Account type AccountRequisitesIn struct { Account string `url:"account" validate:"required"` } func (AccountRequisitesIn) auth() auth { return force } func (AccountRequisitesIn) path() string { return "/common/v1/account_requisites" } func (AccountRequisitesIn) out() (_ *AccountRequisitesOut) { return } func (AccountRequisitesIn) exprc() string { return "OK" } type AccountRequisitesOut struct { CardImage string `json:"cardImage"` CardLine1 string `json:"cardLine1"` CardLine2 string `json:"cardLine2"` Recipient string `json:"recipient"` BeneficiaryInfo string `json:"beneficiaryInfo"` BeneficiaryBank string `json:"beneficiaryBank"` RecipientExternalAccount string `json:"recipientExternalAccount"` CorrespondentAccountNumber string `json:"correspondentAccountNumber"` BankBik string `json:"bankBik"` Name string `json:"name"` Inn string `json:"inn"` Kpp string `json:"kpp"` } type StatementsIn struct { Account string `url:"account" validate:"required"` ItemsOrder string `url:"itemsOrder,omitempty"` } func (StatementsIn) auth() auth { return force } func (StatementsIn) path() string { return "/common/v1/statements" } func (StatementsIn) out() (_ StatementsOut) { return } func (StatementsIn) exprc() string { return "OK" } type StatementPeriod struct { Start Milliseconds `json:"start"` End Milliseconds `json:"end"` } type Statement struct { OverdraftFee *MoneyAmount `json:"overdraftFee,omitempty"` Expense MoneyAmount `json:"expense"` OverLimitDebt *MoneyAmount `json:"overLimitDebt,omitempty"` PeriodEndBalance MoneyAmount `json:"periodEndBalance"` ArrestAmount *MoneyAmount `json:"arrestAmount,omitempty"` OtherBonus *MoneyAmount `json:"otherBonus,omitempty"` CreditLimit *MoneyAmount `json:"creditLimit,omitempty"` TranchesMonthlyPayment *MoneyAmount `json:"tranchesMonthlyPayment,omitempty"` BilledDebt *MoneyAmount `json:"billedDebt,omitempty"` Cashback MoneyAmount `json:"cashback"` Balance MoneyAmount `json:"balance"` HighCashback *MoneyAmount `json:"highCashback,omitempty"` PeriodStartBalance MoneyAmount `json:"periodStartBalance"` LowCashback *MoneyAmount `json:"lowCashback,omitempty"` AvailableLimit *MoneyAmount `json:"availableLimit,omitempty"` Id string `json:"id"` InterestBonus *MoneyAmount `json:"interestBonus,omitempty"` Interest MoneyAmount `json:"interest"` Date Milliseconds `json:"date"` Income MoneyAmount `json:"income"` CreditBonus *MoneyAmount `json:"creditBonus,omitempty"` LastPaymentDate *Milliseconds `json:"lastPaymentDate,omitempty"` OtherCashback *MoneyAmount `json:"otherCashback,omitempty"` MinimalPaymentAmount *MoneyAmount `json:"minimalPaymentAmount,omitempty"` PastDueDebt *MoneyAmount `json:"pastDueDebt,omitempty"` Period StatementPeriod `json:"period"` NoOverdue *bool `json:"noOverdue,omitempty"` Repaid *string `json:"repaid,omitempty"` } type StatementsOut = []Statement type OperationsIn struct { Account string `url:"account" validate:"required"` Start time.Time `url:"start,unixmilli" validate:"required"` End *time.Time `url:"end,unixmilli,omitempty"` OperationId *string `url:"operationId,omitempty"` TrancheCreationAllowed *bool `url:"trancheCreationAllowed,omitempty"` LoyaltyPaymentProgram *string `url:"loyaltyPaymentProgram,omitempty"` LoyaltyPaymentStatus *string `url:"loyaltyPaymentStatus,omitempty"` } func (in OperationsIn) auth() auth { return force } func (in OperationsIn) path() string { return "/common/v1/operations" } func (in OperationsIn) out() (_ OperationsOut) { return } func (in OperationsIn) exprc() string { return "OK" } type Category struct { Id string `json:"id"` Name string `json:"name"` } type Location struct { Latitude float64 `json:"latitude"` Longitude float64 `json:"longitude"` } type LoyaltyAmount struct { Value float64 `json:"value"` LoyaltyProgramId string `json:"loyaltyProgramId"` Loyalty string `json:"loyalty"` Name string `json:"name"` LoyaltySteps uint8 `json:"loyaltySteps"` LoyaltyPointsId uint8 `json:"loyaltyPointsId"` LoyaltyPointsName string `json:"loyaltyPointsName"` LoyaltyImagine bool `json:"loyaltyImagine"` PartialCompensation bool `json:"partialCompensation"` } type LoyaltyBonus struct { Description string `json:"description"` Icon string `json:"icon"` LoyaltyType string `json:"loyaltyType"` Amount LoyaltyAmount `json:"amount"` CompensationType string `json:"compensationType"` } type Region struct { Country *string `json:"country,omitempty"` City *string `json:"city,omitempty"` Address *string `json:"address,omitempty"` Zip *string `json:"zip,omitempty"` AddressRus *string `json:"addressRus,omitempty"` } type Merchant struct { Name string `json:"name"` Region *Region `json:"region,omitempty"` } type SpendingCategory struct { Id string `json:"id"` Name string `json:"name"` } type Brand struct { Name string `json:"name"` BaseTextColor *string `json:"baseTextColor,omitempty"` Logo *string `json:"logo,omitempty"` Id string `json:"id"` RoundedLogo bool `json:"roundedLogo"` BaseColor *string `json:"baseColor,omitempty"` LogoFile *string `json:"logoFile,omitempty"` Link *string `json:"link,omitempty"` SvgLogoFile *string `json:"svgLogoFile"` } type AdditionalInfo struct { FieldName string `json:"fieldName"` FieldValue string `json:"fieldValue"` } type LoyaltyPaymentAmount struct { LoyaltyAmount Price float64 `json:"price"` } type LoyaltyPayment struct { Amount LoyaltyPaymentAmount `json:"amount"` Status string `json:"status"` SoldTime *Milliseconds `json:"soldTime"` } type LoyaltyBonusSummary struct { Amount float64 `json:"amount"` } type Payment struct { SourceIsQr bool `json:"sourceIsQr"` BankAccountId string `json:"bankAccountId"` PaymentId string `json:"paymentId"` ProviderGroupId *string `json:"providerGroupId,omitempty"` PaymentType string `json:"paymentType"` FeeAmount *MoneyAmount `json:"feeAmount,omitempty"` ProviderId string `json:"providerId"` HasPaymentOrder bool `json:"hasPaymentOrder"` Comment string `json:"comment"` IsQrPayment bool `json:"isQrPayment"` FieldsValues map[string]any `json:"fieldsValues"` Repeatable bool `json:"repeatable"` CardNumber string `json:"cardNumber"` TemplateId *string `json:"templateId,omitempty"` TemplateIsFavorite *bool `json:"templateIsFavorite,omitempty"` } type Subgroup struct { Id string `json:"id"` Name string `json:"name"` } type Operation struct { IsDispute bool `json:"isDispute"` IsOffline bool `json:"isOffline"` HasStatement bool `json:"hasStatement"` IsSuspicious bool `json:"isSuspicious"` AuthorizationId *string `json:"authorizationId,omitempty"` IsInner bool `json:"isInner"` Id string `json:"id"` Status string `json:"status"` OperationTransferred bool `json:"operationTransferred"` IdSourceType string `json:"idSourceType"` HasShoppingReceipt *bool `json:"hasShoppingReceipt,omitempty"` Type string `json:"type"` Locations []Location `json:"locations,omitempty"` LoyaltyBonus []LoyaltyBonus `json:"loyaltyBonus,omitempty"` CashbackAmount MoneyAmount `json:"cashbackAmount"` AuthMessage *string `json:"authMessage,omitempty"` Description string `json:"description"` IsTemplatable bool `json:"isTemplatable"` Cashback float64 `json:"cashback"` Brand *Brand `json:"brand,omitempty"` Amount MoneyAmount `json:"amount"` OperationTime Milliseconds `json:"operationTime"` SpendingCategory SpendingCategory `json:"spendingCategory"` IsHce bool `json:"isHce"` Mcc uint `json:"mcc"` Category Category `json:"category"` AdditionalInfo []AdditionalInfo `json:"additionalInfo,omitempty"` VirtualPaymentType uint8 `json:"virtualPaymentType"` Account string `json:"account"` Ucid *string `json:"ucid,omitempty"` Merchant *Merchant `json:"merchant,omitempty"` Card *string `json:"card,omitempty"` LoyaltyPayment []LoyaltyPayment `json:"loyaltyPayment,omitempty"` TrancheCreationAllowed bool `json:"trancheCreationAllowed"` Group *string `json:"group,omitempty"` MccString string `json:"mccString"` CardPresent bool `json:"cardPresent"` IsExternalCard bool `json:"isExternalCard"` CardNumber *string `json:"cardNumber,omitempty"` AccountAmount MoneyAmount `json:"accountAmount"` LoyaltyBonusSummary *LoyaltyBonusSummary `json:"loyaltyBonusSummary,omitempty"` TypeSerno *uint `json:"typeSerno"` Payment *Payment `json:"payment,omitempty"` OperationPaymentType *string `json:"operationPaymentType,omitempty"` Subgroup *Subgroup `json:"subgroup,omitempty"` DebitingTime *Milliseconds `json:"debitingTime,omitempty"` PosId *string `json:"posId,omitempty"` Subcategory *string `json:"subcategory,omitempty"` SenderAgreement *string `json:"senderAgreement,omitempty"` PointOfSaleId *uint64 `json:"pointOfSaleId,omitempty"` Compensation *string `json:"compensation,omitempty"` InstallmentStatus *string `json:"installmentStatus,omitempty"` SenderDetails *string `json:"senderDetails,omitempty"` PartnerType *string `json:"partnerType,omitempty"` Nomination *string `json:"nomination,omitempty"` Message *string `json:"message,omitempty"` TrancheId *string `json:"trancheId,omitempty"` } type OperationsOut = []Operation type ShoppingReceiptIn struct { OperationId string `url:"operationId" validate:"required"` OperationTime *time.Time `url:"operationTime,unixmilli,omitempty"` IdSourceType *string `url:"idSourceType,omitempty"` Account *string `url:"account,omitempty"` } func (in ShoppingReceiptIn) auth() auth { return force } func (in ShoppingReceiptIn) path() string { return shoppingReceiptPath } func (in ShoppingReceiptIn) out() (_ ShoppingReceiptOut) { return } func (in ShoppingReceiptIn) exprc() string { return "OK" } type ReceiptItem struct { Name string `json:"name"` Price float64 `json:"price"` Sum float64 `json:"sum"` Quantity float64 `json:"quantity"` NdsRate *uint8 `json:"ndsRate,omitempty"` Nds *uint8 `json:"nds,omitempty"` Nds10 *float64 `json:"nds10,omitempty"` Nds18 *float64 `json:"nds18,omitempty"` BrandId uint64 `json:"brand_id,omitempty"` GoodId uint64 `json:"good_id,omitempty"` } type Receipt struct { RetailPlace *string `json:"retailPlace,omitempty"` RetailPlaceAddress *string `json:"retailPlaceAddress,omitempty"` CreditSum *float64 `json:"creditSum,omitempty"` ProvisionSum *float64 `json:"provisionSum,omitempty"` FiscalDriveNumber *uint64 `json:"fiscalDriveNumber,omitempty"` OperationType uint8 `json:"operationType"` CashTotalSum float64 `json:"cashTotalSum"` ShiftNumber uint `json:"shiftNumber"` KktRegId string `json:"kktRegId"` Items []ReceiptItem `json:"items"` TotalSum float64 `json:"totalSum"` EcashTotalSum float64 `json:"ecashTotalSum"` Nds10 *float64 `json:"nds10,omitempty"` Nds18 *float64 `json:"nds18,omitempty"` UserInn string `json:"userInn"` DateTime ReceiptDateTime `json:"dateTime"` TaxationType uint8 `json:"taxationType"` PrepaidSum *float64 `json:"prepaidSum,omitempty"` FiscalSign uint64 `json:"fiscalSign"` RequestNumber uint `json:"requestNumber"` Operator *string `json:"operator,omitempty"` AppliedTaxationType uint8 `json:"appliedTaxationType"` FiscalDocumentNumber uint64 `json:"fiscalDocumentNumber"` User *string `json:"user,omitempty"` FiscalDriveNumberString string `json:"fiscalDriveNumberString"` } type ShoppingReceiptOut struct { OperationDateTime Milliseconds `json:"operationDateTime"` OperationId string `json:"operationId"` Receipt Receipt `json:"receipt"` } type clientOfferEssencesIn struct{} func (clientOfferEssencesIn) auth() auth { return force } func (clientOfferEssencesIn) path() string { return "/common/v1/client_offer_essences" } func (clientOfferEssencesIn) out() (_ ClientOfferEssencesOut) { return } func (clientOfferEssencesIn) exprc() string { return "OK" } type ClientOfferEssence struct { Name string `json:"name"` Description string `json:"description"` BusinessType uint `json:"businessType"` IsActive bool `json:"isActive"` BaseColor string `json:"baseColor"` MccCodes []string `json:"mccCodes,omitempty"` Logo string `json:"logo"` ExternalCode string `json:"externalCode"` ExternalId string `json:"externalId"` Id string `json:"id"` Percent uint `json:"percent"` } type ClientOfferEssencesAttributes struct { NotificationFlag bool `json:"notificationFlag"` } type ClientOfferEssences struct { TypeCode string `json:"typeCode"` AvailableEssenceCount uint `json:"availableEssenceCount"` ActiveTo Milliseconds `json:"activeTo"` Attributes ClientOfferEssencesAttributes `json:"attributes"` ActiveFrom Milliseconds `json:"activeFrom"` Essences []ClientOfferEssence `json:"essences"` DisplayTo Milliseconds `json:"displayTo"` AccountIds []string `json:"accountIds"` DisplayFrom Milliseconds `json:"displayFrom"` Id string `json:"id"` } type ClientOfferEssencesOut = []ClientOfferEssences
package main import ( "bufio" "context" "encoding/json" "fmt" "os" "path/filepath" "strings" "time" "github.com/AlekSi/pointer" "github.com/caarlos0/env" "github.com/davecgh/go-spew/spew" "github.com/jfk9w-go/based" "github.com/pkg/errors" "github.com/jfk9w-go/tinkoff-api" ) type jsonSessionStorage struct { path string } func (s jsonSessionStorage) LoadSession(ctx context.Context, phone string) (*tinkoff.Session, 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]tinkoff.Session) if err := json.NewDecoder(file).Decode(&contents); err != nil { return nil, errors.Wrap(err, "decode json") } if session, ok := contents[phone]; ok { return &session, nil } return nil, nil } func (s jsonSessionStorage) UpdateSession(ctx context.Context, phone string, session *tinkoff.Session) 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]tinkoff.Session) if stat.Size() > 0 { if err := json.NewDecoder(file).Decode(&contents); err != nil { return errors.Wrap(err, "decode json") } } if session != nil { contents[phone] = *session } 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 jsonSessionStorage) 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{} 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 { Phone string `env:"TINKOFF_PHONE,required"` Password string `env:"TINKOFF_PASSWORD,required"` SessionsFile string `env:"TINKOFF_SESSIONS_FILE,required"` } if err := env.Parse(&config); err != nil { panic(err) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() client, err := tinkoff.NewClient(tinkoff.ClientParams{ Clock: based.StandardClock, Credential: tinkoff.Credential{ Phone: config.Phone, Password: config.Password, }, SessionStorage: jsonSessionStorage{path: config.SessionsFile}, }) if err != nil { panic(err) } ctx = tinkoff.WithAuthorizer(ctx, authorizer{}) investOperationTypes, err := client.InvestOperationTypes(ctx) if err != nil { panic(err) } fmt.Printf("found %d invest operation types\n", len(investOperationTypes.OperationsTypes)) for _, operationType := range investOperationTypes.OperationsTypes { spew.Dump(operationType) break } investAccounts, err := client.InvestAccounts(ctx, &tinkoff.InvestAccountsIn{ Currency: "RUB", }) if err != nil { panic(err) } fmt.Printf("found %d invest accounts\n", investAccounts.Accounts.Count) for _, account := range investAccounts.Accounts.List { spew.Dump(account) investOperations, err := client.InvestOperations(ctx, &tinkoff.InvestOperationsIn{ From: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), To: time.Now(), Limit: 10, BrokerAccountId: account.BrokerAccountId, }) if err != nil { panic(err) } fmt.Printf("found %d invest operations in invest account '%s'\n", len(investOperations.Items), account.Name) for _, operation := range investOperations.Items { spew.Dump(operation) break } break //nolint:staticcheck } accounts, err := client.AccountsLightIb(ctx) if err != nil { panic(err) } fmt.Printf("found %d accounts\n", len(accounts)) if len(accounts) == 0 { return } for _, account := range accounts { spew.Dump(account) if account.AccountType == "Telecom" || account.AccountType == "ExternalAccount" { continue } operations, err := client.Operations(ctx, &tinkoff.OperationsIn{ Account: account.Id, Start: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), //End: pointer.To(time.Now()), }) if err != nil { panic(err) } fmt.Printf("found %d operations in account '%s'\n", len(operations), account.Name) if len(operations) == 0 { return } for _, operation := range operations { if pointer.Get(operation.HasShoppingReceipt) { spew.Dump(operation) receipt, err := client.ShoppingReceipt(ctx, &tinkoff.ShoppingReceiptIn{ OperationId: operation.Id, }) switch { case errors.Is(err, tinkoff.ErrNoDataFound): continue case err != nil: panic(err) } for _, item := range receipt.Receipt.Items { fmt.Println(item.Name) } spew.Dump(receipt) break } } } clientOfferEssences, err := client.ClientOfferEssences(ctx) if err != nil { panic(err) } fmt.Printf("found %d client offer essences\n", len(clientOfferEssences)) spew.Dump(clientOfferEssences) }
package tinkoff import ( "context" "encoding/json" "time" "github.com/jfk9w-go/based" "github.com/pkg/errors" ) type investError struct { ErrorMessage string `json:"errorMessage"` ErrorCode string `json:"errorCode"` } func (e investError) Error() string { return e.ErrorMessage + " (" + e.ErrorCode + ")" } type investExchange[R any] interface { auth() bool path() string out() R } type DateTimeMilliOffset time.Time func (dt DateTimeMilliOffset) Time() time.Time { return time.Time(dt) } const dateTimeMilliOffsetLayout = "2006-01-02T15:04:05.999-07:00" func (dt DateTimeMilliOffset) MarshalJSON() ([]byte, error) { value := dt.Time().Format(dateTimeMilliOffsetLayout) return json.Marshal(value) } 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 DateTime time.Time func (dt DateTime) Time() time.Time { return time.Time(dt) } const dateTimeLayout = "2006-01-02T15:04:05Z" func (dt DateTime) MarshalJSON() ([]byte, error) { value := dt.Time().Format(dateTimeLayout) return json.Marshal(value) } func (dt *DateTime) UnmarshalJSON(data []byte) error { var str string if err := json.Unmarshal(data, &str); err != nil { return err } value, err := time.Parse(dateTimeLayout, str) if err != nil { return err } *dt = DateTime(value) return nil } var dateLocation = based.LazyFuncRef[*time.Location]( func(ctx context.Context) (*time.Location, error) { return time.LoadLocation("Europe/Moscow") }, ) type Date time.Time func (d Date) Time() time.Time { return time.Time(d) } const dateLayout = "2006-01-02" func (d Date) MarshalJSON() ([]byte, error) { location, err := dateLocation.Get(context.Background()) if err != nil { return nil, errors.Wrap(err, "load location") } value := d.Time().In(location).Format(dateLayout) return json.Marshal(value) } func (d *Date) UnmarshalJSON(data []byte) error { location, err := dateLocation.Get(context.Background()) if err != nil { return errors.Wrap(err, "load location") } var str string if err := json.Unmarshal(data, &str); err != nil { return err } value, err := time.ParseInLocation("2006-01-02", str, location) if err != nil { return err } *d = Date(value) return nil } type InvestCandleDate time.Time func (d InvestCandleDate) Time() time.Time { return time.Time(d) } func (d InvestCandleDate) MarshalJSON() ([]byte, error) { return json.Marshal(d.Time().Unix()) } func (d *InvestCandleDate) UnmarshalJSON(data []byte) error { var secs int64 if err := json.Unmarshal(data, &secs); err != nil { return err } value := time.Unix(secs, 0) *d = InvestCandleDate(value) return nil } type investOperationTypesIn struct{} func (in investOperationTypesIn) auth() bool { return false } func (in investOperationTypesIn) path() string { return "/invest-gw/ca-operations/api/v1/operations/types" } func (in investOperationTypesIn) out() (_ InvestOperationTypesOut) { return } type InvestOperationType struct { Category string `json:"category"` OperationName string `json:"operationName"` OperationType string `json:"operationType"` } type InvestOperationTypesOut struct { OperationsTypes []InvestOperationType `json:"operationsTypes"` } type InvestAmount struct { Currency string `json:"currency"` Value float64 `json:"value"` } type InvestAccountsIn struct { Currency string `url:"currency" validate:"required"` } func (in InvestAccountsIn) auth() bool { return true } func (in InvestAccountsIn) path() string { return "/invest-gw/invest-portfolio/portfolios/accounts" } func (in InvestAccountsIn) out() (_ InvestAccountsOut) { return } type InvestTotals struct { ExpectedYield InvestAmount `json:"expectedYield"` ExpectedYieldRelative float64 `json:"expectedYieldRelative"` ExpectedYieldPerDay InvestAmount `json:"expectedYieldPerDay"` ExpectedYieldPerDayRelative float64 `json:"expectedYieldPerDayRelative"` ExpectedAverageYield InvestAmount `json:"expectedAverageYield"` ExpectedAverageYieldRelative float64 `json:"expectedAverageYieldRelative"` TotalAmount InvestAmount `json:"totalAmount"` } type InvestAccount struct { BrokerAccountId string `json:"brokerAccountId"` BrokerAccountType string `json:"brokerAccountType"` Name string `json:"name"` OpenedDate Date `json:"openedDate"` Order int `json:"order"` Status string `json:"status"` IsVisible bool `json:"isVisible"` Organization string `json:"organization"` BuyByDefault bool `json:"buyByDefault"` MarginEnabled bool `json:"marginEnabled"` AutoApp bool `json:"autoApp"` InvestTotals } type InvestAccounts struct { Count int `json:"count"` List []InvestAccount `json:"list"` } type InvestAccountsOut struct { Accounts InvestAccounts `json:"accounts"` Totals InvestTotals `json:"totals"` } type InvestOperationsIn struct { From time.Time `url:"from,omitempty" layout:"2006-01-02T15:04:05.999Z"` To time.Time `url:"to,omitempty" layout:"2006-01-02T15:04:05.999Z"` BrokerAccountId string `url:"brokerAccountId,omitempty"` OvernightsDisabled *bool `url:"overnightsDisabled,omitempty"` Limit int `url:"limit,omitempty"` Cursor string `url:"cursor,omitempty"` } func (in InvestOperationsIn) auth() bool { return true } func (in InvestOperationsIn) path() string { return "/invest-gw/ca-operations/api/v1/user/operations" } func (in InvestOperationsIn) out() (_ InvestOperationsOut) { return } type Trade struct { Date DateTimeMilliOffset `json:"date"` Num string `json:"num"` Price InvestAmount `json:"price"` Quantity int `json:"quantity"` Yield *InvestAmount `json:"yield,omitempty"` YieldRelative *float64 `json:"yieldRelative,omitempty"` } type TradesInfo struct { Trades []Trade `json:"trades"` TradesSize int `json:"tradesSize"` } type InvestChildOperation struct { Currency string `json:"currency"` Id string `json:"id"` InstrumentType string `json:"instrumentType"` InstrumentUid string `json:"instrumentUid"` LogoName string `json:"logoName"` Payment InvestAmount `json:"payment"` ShowName string `json:"showName"` Ticker string `json:"ticker"` Type string `json:"type"` Value float64 `json:"value"` } type InvestOperation struct { AccountName string `json:"accountName"` AssetUid *string `json:"assetUid,omitempty"` BestExecuted bool `json:"bestExecuted"` BrokerAccountId string `json:"brokerAccountId"` ClassCode *string `json:"classCode,omitempty"` Cursor string `json:"cursor"` Date DateTimeMilliOffset `json:"date"` Description string `json:"description"` Id *string `json:"id,omitempty"` InstrumentType *string `json:"instrumentType,omitempty"` InstrumentUid *string `json:"instrumentUid,omitempty"` InternalId string `json:"internalId"` IsBlockedTradeClearingAccount *bool `json:"isBlockedTradeClearingAccount,omitempty"` Isin *string `json:"isin,omitempty"` LogoName *string `json:"logoName,omitempty"` Name *string `json:"name,omitempty"` Payment InvestAmount `json:"payment"` PaymentEur InvestAmount `json:"paymentEur"` PaymentRub InvestAmount `json:"paymentRub"` PaymentUsd InvestAmount `json:"paymentUsd"` PositionUid *string `json:"positionUid,omitempty"` ShortDescription *string `json:"shortDescription,omitempty"` ShowName *string `json:"showName,omitempty"` Status string `json:"status"` TextColor *string `json:"textColor,omitempty"` Ticker *string `json:"ticker,omitempty"` Type string `json:"type"` AccountId *string `json:"accountId,omitempty"` DoneRest *int `json:"doneRest,omitempty"` Price *InvestAmount `json:"price,omitempty"` Quantity *int `json:"quantity,omitempty"` TradesInfo *TradesInfo `json:"tradesInfo,omitempty"` ParentOperationId *string `json:"parentOperationId,omitempty"` ChildOperations []InvestChildOperation `json:"childOperations,omitempty"` Commission *InvestAmount `json:"commission,omitempty"` Yield *InvestAmount `json:"yield,omitempty"` YieldRelative *float64 `json:"yieldRelative,omitempty"` CancelReason *string `json:"cancelReason,omitempty"` QuantityRest *int `json:"quantityRest,omitempty"` WithdrawDateTime *DateTime `json:"withdrawDateTime,omitempty"` } type InvestOperationsOut struct { HasNext bool `json:"hasNext"` Items []InvestOperation `json:"items"` NextCursor string `json:"nextCursor"` } type InvestCandlesIn struct { From time.Time `url:"from" layout:"2006-01-02T15:04:05+00:00" validate:"required"` To time.Time `url:"to" layout:"2006-01-02T15:04:05+00:00" validate:"required"` Resolution any `url:"resolution" validate:"required"` Ticker string `url:"ticker" validate:"required"` } func (InvestCandlesIn) auth() auth { return force } func (InvestCandlesIn) path() string { return "/api/trading/symbols/candles" } func (InvestCandlesIn) out() (_ InvestCandlesOut) { return } func (InvestCandlesIn) exprc() string { return "OK" } type InvestCandle struct { O float64 `json:"o"` C float64 `json:"c"` H float64 `json:"h"` L float64 `json:"l"` V float64 `json:"v"` Date InvestCandleDate `json:"date"` } type InvestCandlesOut struct { Candles []InvestCandle `json:"candles"` }
package tinkoff import ( "context" "math" "math/rand" "time" ) type retryKey struct{} type retryStrategy struct { timeout retryTimeoutFunc maxRetries int } func (rs *retryStrategy) do(ctx context.Context) (context.Context, error) { retry, _ := ctx.Value(retryKey{}).(int) if rs.maxRetries > 0 && retry >= rs.maxRetries { return ctx, errMaxRetriesExceeded } select { case <-time.After(rs.timeout(retry)): return context.WithValue(ctx, retryKey{}, retry+1), nil case <-ctx.Done(): return ctx, ctx.Err() } } type retryTimeoutFunc func(retry int) time.Duration func exponentialRetryTimeout(base time.Duration, factor, jitter float64) retryTimeoutFunc { return func(retry int) time.Duration { timeout := base * time.Duration(math.Pow(factor, float64(retry))) return timeout * time.Duration(1+jitter*(0.5-rand.Float64())) } } func constantRetryTimeout(timeout time.Duration) retryTimeoutFunc { return func(retry int) time.Duration { return timeout } }