package tinkoff
import (
"context"
"github.com/pkg/errors"
"github.com/tebeka/selenium"
)
func withSession(ctx context.Context, session *Session) context.Context {
return context.WithValue(ctx, Session{}, session)
}
func getSession(ctx context.Context) *Session {
if session, ok := ctx.Value(Session{}).(*Session); ok {
return session
}
return nil
}
// AuthFlow обозначает способ аутентификации.
type AuthFlow interface {
authorize(ctx context.Context, client *Client, authorizer Authorizer) (*Session, error)
}
// ApiAuthFlow производит аутентификацию с помощью вызовов API.
// В начале июня 2024 Тинькофф что-то поменял, и теперь не срабатывает подтверждение СМС-кодом (не хватает каких-то полей).
// Несмотря на то, что этот способ аутентификации сейчас не работает, он все еще остается дефолтным в клиенте для обратной
// совместимости, и все еще остается в коде на случай потенциальной починки в будущем.
var ApiAuthFlow AuthFlow = &apiAuthFlow{}
type apiAuthFlow struct{}
func (f *apiAuthFlow) authorize(ctx context.Context, c *Client, authorizer Authorizer) (*Session, error) {
var session *Session
if resp, err := executeCommon(ctx, c, sessionIn{}); err != nil {
return nil, errors.Wrap(err, "get new sessionid")
} else {
session = &Session{ID: resp.Payload}
ctx = withSession(ctx, session)
}
if resp, err := executeCommon(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(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(ctx, c, passwordSignUpIn{Password: c.credential.Password}); err != nil {
return nil, errors.Wrap(err, "password sign up")
}
if _, err := executeCommon(ctx, c, levelUpIn{}); err != nil {
return nil, errors.Wrap(err, "level up")
}
return session, nil
}
// SeleniumAuthFlow производит аутентификацию с помощью Selenium.
// Вероятно, самый оптимальный способ с точки зрения поддержки и дальнейших доработок.
type SeleniumAuthFlow struct {
Capabilities selenium.Capabilities
URLPrefix string
}
func (f *SeleniumAuthFlow) authorize(ctx context.Context, c *Client, authorizer Authorizer) (*Session, error) {
driver, err := selenium.NewRemote(f.Capabilities, f.URLPrefix)
if err != nil {
return nil, errors.Wrap(err, "create remote")
}
defer driver.Close()
if err := driver.MaximizeWindow(""); err != nil {
return nil, errors.Wrap(err, "maximize window")
}
if err := driver.Get(baseURL + "/login"); err != nil {
return nil, errors.Wrap(err, "open login page")
}
var complete bool
steps := map[string]func(el selenium.WebElement) error{
"//input[@automation-id='phone-input']": func(el selenium.WebElement) error { return el.SendKeys(c.credential.Phone + selenium.EnterKey) },
"//input[@automation-id='password-input']": func(el selenium.WebElement) error { return el.SendKeys(c.credential.Password + selenium.EnterKey) },
"//input[@automation-id='otp-input']": func(el selenium.WebElement) error {
code, err := authorizer.GetConfirmationCode(ctx, c.credential.Phone)
if err != nil {
return errors.Wrap(err, "get confirmation code")
}
return el.SendKeys(code)
},
"//button[@automation-id='cancel-button']": func(el selenium.WebElement) error { return el.Click() },
"//a[@href='/new-product/']": func(_ selenium.WebElement) error { complete = true; return nil },
}
for !complete {
if err := driver.Wait(func(wd selenium.WebDriver) (bool, error) {
select {
case <-ctx.Done():
return false, ctx.Err()
default:
}
for xpath, handle := range steps {
elements, err := driver.FindElements(selenium.ByXPATH, xpath)
if err != nil {
return false, errors.Wrapf(err, "find xpath '%s'", xpath)
}
for _, element := range elements {
displayed, err := element.IsDisplayed()
if err != nil {
return false, errors.Wrapf(err, "xpath '%s' is displayed", xpath)
}
if displayed {
if err := handle(element); err != nil {
return false, errors.Wrapf(err, "handle xpath '%s'", xpath)
}
delete(steps, xpath)
return true, nil
}
}
}
return false, nil
}); err != nil {
return nil, err
}
}
sessionID, err := driver.GetCookie("psid")
if err != nil {
return nil, errors.Wrap(err, "session cookie not found")
}
return &Session{
ID: sessionID.Value,
}, nil
}
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"
"time"
"github.com/google/go-querystring/query"
"github.com/jfk9w-go/based"
"github.com/pkg/errors"
)
const (
baseURL = "https://www.tbank.ru"
baseApiURL = baseURL + "/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"`
AuthFlow AuthFlow
Transport http.RoundTripper
}
type Client struct {
httpClient *http.Client
authFlow AuthFlow
credential Credential
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
}
authFlow := params.AuthFlow
if authFlow == nil {
authFlow = ApiAuthFlow
}
return &Client{
httpClient: &http.Client{
Transport: params.Transport,
},
authFlow: authFlow,
credential: params.Credential,
session: based.NewWriteThroughCached(
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),
},
},
}, 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(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(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(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(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(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(ctx, c, clientOfferEssencesIn{})
if err != nil {
return nil, err
}
return resp.Payload, nil
}
func (c *Client) InvestOperationTypes(ctx context.Context) (*InvestOperationTypesOut, error) {
return executeInvest(ctx, c, investOperationTypesIn{})
}
func (c *Client) InvestAccounts(ctx context.Context, in *InvestAccountsIn) (*InvestAccountsOut, error) {
return executeInvest(ctx, c, in)
}
func (c *Client) InvestOperations(ctx context.Context, in *InvestOperationsIn) (*InvestOperationsOut, error) {
return executeInvest(ctx, c, in)
}
func (c *Client) InvestCandles(ctx context.Context, in *InvestCandlesIn) (*InvestCandlesOut, error) {
resp, err := executeCommon(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) {
var err error
session := getSession(ctx)
if session == nil {
session, err = c.session.Get(ctx)
if err != nil {
return "", err
}
}
if session == nil {
if session, err = c.authorize(ctx); err != nil {
return "", errors.Wrap(err, "authorize")
}
if err = c.session.Update(ctx, session); err != nil {
return "", errors.Wrap(err, "store new sessionid")
}
}
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")
}
return c.authFlow.authorize(ctx, c, authorizer)
}
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(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, baseApiURL+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" || investErr.ErrorCode == "Forbidden" {
// 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(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")
}
method := http.MethodGet
httpReq, err := http.NewRequestWithContext(ctx, method, baseApiURL+in.path(), nil)
if err != nil {
return nil, errors.Wrap(err, "create request")
}
reqBody.Set("origin", "web,ib5,platform")
if sessionID != "" {
reqBody.Set("sessionid", sessionID)
}
httpReq.URL.RawQuery = reqBody.Encode()
httpReq.Header.Set("Content-Type", "application/json")
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(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 float64 `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"
"net/http"
"net/http/httputil"
"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"
tbank "github.com/jfk9w-go/tbank-api"
)
type jsonSessionStorage struct {
path string
}
func (s jsonSessionStorage) LoadSession(ctx context.Context, phone string) (*tbank.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]tbank.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 *tbank.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]tbank.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
}
type httpTransport struct {
client http.Client
}
func (t *httpTransport) RoundTrip(req *http.Request) (*http.Response, error) {
reqData, err := httputil.DumpRequestOut(req, true)
if err != nil {
return nil, errors.Wrap(err, "dump request")
}
fmt.Println(string(reqData))
resp, err := t.client.Do(req)
if err != nil {
return nil, err
}
respData, err := httputil.DumpResponse(resp, true)
if err != nil {
return nil, errors.Wrap(err, "dump response")
}
fmt.Println(string(respData))
return resp, nil
}
func main() {
var config struct {
Phone string `env:"TBANK_PHONE,required"`
Password string `env:"TBANK_PASSWORD,required"`
SessionsFile string `env:"TBANK_SESSIONS_FILE,required"`
}
if err := env.Parse(&config); err != nil {
panic(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
client, err := tbank.NewClient(tbank.ClientParams{
Clock: based.StandardClock,
Credential: tbank.Credential{
Phone: config.Phone,
Password: config.Password,
},
SessionStorage: jsonSessionStorage{path: config.SessionsFile},
Transport: new(httpTransport),
AuthFlow: new(tbank.SeleniumAuthFlow),
})
if err != nil {
panic(err)
}
ctx = tbank.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, &tbank.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, &tbank.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, &tbank.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, &tbank.ShoppingReceiptIn{
OperationId: operation.Id,
})
switch {
case errors.Is(err, tbank.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
}
}