diff --git a/config/payouts.properties b/config/payouts.properties index 71c5a46..9d1a687 100644 --- a/config/payouts.properties +++ b/config/payouts.properties @@ -38,8 +38,20 @@ Cache.TTL = 24h # Yookassa related props # Base API Url YooKassa.BaseUrl = https://api.yookassa.ru/v3 -YooKassa.Timeout = 30s +# Timeout for requests +YooKassa.Timeout = 2s + +YooKassa.Retry.Enabled = false +# Set retry count (including initial request) +YooKassa.Retry.Count = 3 +# Set wait time between retries +YooKassa.Retry.WaitTime = 200ms +# Set maximum wait time (for exponential backoff) +YooKassa.Retry.MaxWaitTime = 5s + YooKassa.Test = false + +YooKassa.CheckAllowedCallbackAddress = true YooKassa.AllowedCallbackSubnets = 185.71.76.0/27,185.71.77.0/27,77.75.153.0/25,77.75.156.11/32,77.75.156.35/32,77.75.154.128/25,2a02:5180::/32 # Base API key/secret YooKassa.ApiBaseKey = @@ -47,3 +59,5 @@ YooKassa.ApiBaseSecret = # Payments API key/secret YooKassa.ApiPaymentKey = YooKassa.ApiPaymentSecret = +# Timeout to process yookassa callback +YooKassa.CallbackProcessTimeout = 1s \ No newline at end of file diff --git a/go.mod b/go.mod index 627a0a5..7a0e8c2 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module payouts go 1.25.0 require ( + github.com/go-resty/resty/v2 v2.17.2 github.com/go-viper/encoding/javaproperties v0.1.0 github.com/go-viper/mapstructure/v2 v2.5.0 github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum index 756b63e..e406ec9 100644 --- a/go.sum +++ b/go.sum @@ -23,6 +23,8 @@ github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1 github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= +github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= @@ -232,6 +234,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= diff --git a/internal/api/common/error.go b/internal/api/common/error.go new file mode 100644 index 0000000..4025b21 --- /dev/null +++ b/internal/api/common/error.go @@ -0,0 +1,36 @@ +package common + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strings" + "unicode" + "unicode/utf8" + + "payouts/internal/models" +) + +func Reason(reason string, params ...any) slog.Attr { + return slog.String("reason", fmt.Sprintf(reason, params...)) +} + +func ErrorResponse(w http.ResponseWriter, message string, err error, status int, logOpts ...any) { + r, size := utf8.DecodeRuneInString(message) + errorMsg := string(unicode.ToUpper(r)) + strings.ToLower(message[size:]) + + logFields := logOpts + if err != nil { + logFields = append([]any{slog.String("error", err.Error())}, logOpts...) + } + + slog.Error(errorMsg, logFields...) + w.Header().Set("Content-type", "application/json") + w.WriteHeader(status) + + json.NewEncoder(w).Encode(&models.ErrorResp{ + Message: errorMsg, + Status: status, + }) +} diff --git a/internal/api/payout/payout_handler.go b/internal/api/payout/payout_handler.go index 476822e..86166de 100644 --- a/internal/api/payout/payout_handler.go +++ b/internal/api/payout/payout_handler.go @@ -1,6 +1,7 @@ package payout import ( + "context" "encoding/json" "errors" "fmt" @@ -12,6 +13,7 @@ import ( "github.com/google/uuid" "go.uber.org/fx" + "payouts/internal/api/common" "payouts/internal/config" "payouts/internal/models" "payouts/internal/service/cache" @@ -99,18 +101,36 @@ func (p *payoutHandler) checkAllowedIpCallback(ipStr string) bool { // GetSbpBanks implements [Handler]. func (p *payoutHandler) GetSbpBanks(w http.ResponseWriter, r *http.Request) { - panic("unimplemented") + w.Header().Set("Content-type", "application/json") + + banksResp, err := p.yooKassa.GetSbpBanks(yookassa.WithContext(r.Context())) + + if err != nil { + status := http.StatusBadRequest + var yError *yookassa.Error + if errors.As(err, &yError) { + status = yError.Status + } + common.ErrorResponse(w, "failed to retrieve sbp banks", err, status) + return + } + + encoder := json.NewEncoder(w) + err = encoder.Encode(banksResp) + if err != nil { + common.ErrorResponse(w, "failed to encode response", err, http.StatusInternalServerError) + } } // PaymentCreate implements [Handler]. func (p *payoutHandler) PayoutCreate(w http.ResponseWriter, r *http.Request) { - errResponse := func(message string, err error, status int) { - http.Error(w, errors.Join(errors.New(message), err).Error(), status) - } + defer r.Body.Close() + + w.Header().Set("Content-type", "application/json") userSession, err := p.getSession(r) if err != nil { - errResponse("unauthorized", err, http.StatusUnauthorized) + common.ErrorResponse(w, "unauthorized", err, http.StatusUnauthorized) return } @@ -120,8 +140,7 @@ func (p *payoutHandler) PayoutCreate(w http.ResponseWriter, r *http.Request) { decoder := json.NewDecoder(r.Body) err = decoder.Decode(&payoutReq) if err != nil { - slog.Error("Failed to decode request body", slog.String("error", err.Error())) - errResponse("failed to decode request body", err, http.StatusBadRequest) + common.ErrorResponse(w, "failed to decode request body", err, http.StatusBadRequest) return } @@ -131,34 +150,78 @@ func (p *payoutHandler) PayoutCreate(w http.ResponseWriter, r *http.Request) { IdempotenceKey: idempotenceKey, Type: payoutReq.PayoutType.String(), Amount: payoutReq.Amount, - Status: orm.StatusCreated, + Status: models.StatusCreated.String(), } err = p.dbService.CreatePayout(payoutModel, database.WithContext(r.Context())) + if err != nil { + common.ErrorResponse(w, "failed to create payout data", err, http.StatusInternalServerError) + return + } slog.Debug(fmt.Sprintf("Received create payload request: %v from user %v", payoutReq, userSession)) + payoutResp, err := p.yooKassa.CreatePayout(payoutReq, userSession, idempotenceKey, yookassa.WithContext(r.Context())) if err != nil { - slog.Error("Failed to create payout request", slog.String("error", err.Error())) - errResponse("failed to create payout request", err, http.StatusBadRequest) + status := http.StatusBadRequest + var yError *yookassa.Error + if errors.As(err, &yError) { + status = yError.Status + } + common.ErrorResponse(w, "failed to create payout request", err, status) + return + } + + updatedRows, err := p.dbService.UpdatePayoutById(payoutModel.ID, orm.Payout{ + PayoutID: payoutResp.ID, + Status: payoutResp.Status.String(), + }, database.WithContext(r.Context())) + + if err != nil || updatedRows == 0 { + common.ErrorResponse(w, "failed to update payout data", err, http.StatusInternalServerError, slog.String("id", fmt.Sprintf("%d", payoutModel.ID))) return } encoder := json.NewEncoder(w) - encoder.Encode(payoutResp) + err = encoder.Encode(payoutResp) + if err != nil { + common.ErrorResponse(w, "failed to encode response", err, http.StatusInternalServerError) + } +} + +func (p *payoutHandler) delayedPayoutUpdate(ctx context.Context, payoutData *yookassa.PayoutResponse) { + <-ctx.Done() + slog.Info("Updating payout data received from callback") + + updatedRows, err := p.dbService.UpdatePayoutByPayoutID(payoutData.ID, orm.Payout{ + Status: payoutData.Status.String(), + }, database.WithContext(context.Background())) + + if err != nil || updatedRows == 0 { + slog.Error("Failed to update paylout data", slog.String("error", fmt.Sprintf("%v", err)), slog.Int("rows_updated", updatedRows)) + } else { + slog.Info("Successfully updated payout data", slog.String("payout_id", payoutData.ID), slog.String("new_status", payoutData.Status.String())) + } } // PaymentCallback implements [Handler]. func (p *payoutHandler) PayoutCallback(w http.ResponseWriter, r *http.Request) { - inData := map[string]any{} - decoder := json.NewDecoder(r.Body) - decoder.Decode(&inData) - // todo: check also the X-real-ip and/or X-Forwarded-For - if !p.checkAllowedIpCallback(r.RemoteAddr) { - slog.Error(fmt.Sprintf("Callback came from unallowed ip: %s", r.RemoteAddr)) - http.Error(w, "unallowed", http.StatusForbidden) + if p.yookassaConf.CheckAllowedCallbackAddress && !p.checkAllowedIpCallback(r.RemoteAddr) { + common.ErrorResponse(w, "unallowed", nil, http.StatusForbidden, common.Reason("Callback came from unallowed ip: %s", r.RemoteAddr)) return } - slog.Info(fmt.Sprintf("Received callback from %s with object %v with headers %v", r.RemoteAddr, inData, r.Header)) + payoutData := &yookassa.PayoutResponse{} + decoder := json.NewDecoder(r.Body) + err := decoder.Decode(payoutData) + + if err != nil { + common.ErrorResponse(w, "bad request", nil, http.StatusBadRequest, common.Reason("Failed to decode payload data: %v", err)) + return + } + + ctx, _ := context.WithTimeout(context.Background(), p.yookassaConf.CallbackProcessTimeout) + go p.delayedPayoutUpdate(ctx, payoutData) + + slog.Debug(fmt.Sprintf("Received callback from %s with object %v with headers %v", r.RemoteAddr, payoutData, r.Header)) } diff --git a/internal/api/user/user_handler.go b/internal/api/user/user_handler.go index 4f2815e..4742c88 100644 --- a/internal/api/user/user_handler.go +++ b/internal/api/user/user_handler.go @@ -2,8 +2,6 @@ package user import ( "encoding/json" - "errors" - "log/slog" "net/http" "time" @@ -12,6 +10,7 @@ import ( "go.uber.org/fx" "golang.org/x/crypto/bcrypt" + "payouts/internal/api/common" "payouts/internal/config" "payouts/internal/models" "payouts/internal/service/cache" @@ -52,28 +51,21 @@ func NewUserHandler(p Params) (Handler, error) { func (u *userHandler) UserRegister(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() - errResponse := func(message string, err error, status int) { - http.Error(w, errors.Join(errors.New(message), err).Error(), status) - } - user := models.UserRegister{} err := json.NewDecoder(r.Body).Decode(&user) if err != nil { - slog.Error("Failed to get password hash", slog.String("error", err.Error())) - errResponse("failed to decode request body", err, http.StatusBadRequest) + common.ErrorResponse(w, "failed to decode request body", err, http.StatusBadRequest) return } if user.Passwd != user.PasswdCfm || len(user.Passwd) == 0 || len(user.Phone) == 0 || len(user.TIN) == 0 { - slog.Error("No required parameters passed") - errResponse("invalid parameters", nil, http.StatusBadRequest) + common.ErrorResponse(w, "invalid parameters", nil, http.StatusBadRequest) return } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Passwd), bcrypt.DefaultCost) if err != nil { - slog.Error("Failed to get password hash", slog.String("error", err.Error())) - errResponse("internal error", nil, http.StatusInternalServerError) + common.ErrorResponse(w, "internal error", nil, http.StatusInternalServerError, common.Reason("failed to get password hash: %v", err)) return } user.PasswdHash = string(hashedPassword) @@ -84,8 +76,7 @@ func (u *userHandler) UserRegister(w http.ResponseWriter, r *http.Request) { // todo: add data validation err = u.dbService.CreateUser(&ormUser, database.WithContext(r.Context())) if err != nil { - slog.Error("Failed to create user", slog.String("error", err.Error())) - errResponse("failed to create user", err, http.StatusBadRequest) + common.ErrorResponse(w, "failed to create user", err, http.StatusBadRequest) return } @@ -96,33 +87,27 @@ func (u *userHandler) UserRegister(w http.ResponseWriter, r *http.Request) { func (u *userHandler) UserLogin(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() - errResponse := func(message string, err error, status int) { - w.Header().Set("Content-Type", "text/plain") - http.Error(w, errors.Join(errors.New(message), err).Error(), status) - } - user := models.UserLoginReq{} err := json.NewDecoder(r.Body).Decode(&user) if err != nil { - errResponse("failed to decode request body", err, http.StatusBadRequest) + common.ErrorResponse(w, "failed to decode request body", err, http.StatusBadRequest) return } if len(user.Phone) == 0 || len(user.Passwd) == 0 { - slog.Error("No required parameters passed") - errResponse("invalid parameters", nil, http.StatusBadRequest) + common.ErrorResponse(w, "invalid parameters", nil, http.StatusBadRequest, common.Reason("no user or password passed")) return } ormUser, err := u.dbService.GetUser(&orm.User{Phone: user.Phone}, database.WithContext(r.Context())) if err != nil { - errResponse("invalid credentials", nil, http.StatusUnauthorized) + common.ErrorResponse(w, "invalid credentials", nil, http.StatusUnauthorized, common.Reason("no user found by number %s", user.Phone)) return } err = bcrypt.CompareHashAndPassword([]byte(ormUser.PasswdHash), []byte(user.Passwd)) if err != nil { - errResponse("invalid credentials", nil, http.StatusUnauthorized) + common.ErrorResponse(w, "invalid credentials", nil, http.StatusUnauthorized, common.Reason("password does not match")) return } @@ -138,7 +123,7 @@ func (u *userHandler) UserLogin(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") err = json.NewEncoder(w).Encode(resp) if err != nil { - errResponse("failed to encode response", err, http.StatusInternalServerError) + common.ErrorResponse(w, "failed to encode response", err, http.StatusInternalServerError) return } } diff --git a/internal/models/error.go b/internal/models/error.go new file mode 100644 index 0000000..509f36a --- /dev/null +++ b/internal/models/error.go @@ -0,0 +1,6 @@ +package models + +type ErrorResp struct { + Status int `json:"status"` + Message string `json:"message"` +} diff --git a/internal/models/payout.go b/internal/models/payout.go index 677d469..2873aa9 100644 --- a/internal/models/payout.go +++ b/internal/models/payout.go @@ -39,14 +39,69 @@ func (r *PayoutType) UnmarshalText(text []byte) (err error) { return err } +type PayoutStatus int64 + +const ( + StatusCreated PayoutStatus = iota + StatusCanceled + StatusPending + StatusSucceeded + StatusFailed +) + +func (r PayoutStatus) String() string { + switch r { + case StatusCreated: + return "created" + case StatusCanceled: + return "canceled" + case StatusPending: + return "pending" + case StatusSucceeded: + return "succeeded" + case StatusFailed: + return "failed" + } + return "unknown" +} + +func (r PayoutStatus) MarshalText() (text []byte, err error) { + return []byte(r.String()), nil +} + +func (r *PayoutStatus) UnmarshalText(text []byte) (err error) { + s := strings.ToLower(string(text)) + switch s { + case "canceled": + *r = StatusCanceled + case "created": + *r = StatusCreated + case "pending": + *r = StatusPending + case "succeeded": + *r = StatusSucceeded + case "failed": + *r = StatusFailed + default: + err = fmt.Errorf("invalid payment type: %s", s) + } + return err +} + +type SBPBank struct { + BankID string `json:"bank_id"` + Name string `json:"name"` + Bic string `json:"bic"` +} + type PayoutReq struct { PayoutType PayoutType `json:"payout_type"` AccountNumber string `json:"account_number"` + BankID string `json:"bank_id"` Amount float32 `json:"amount"` } type PayoutResp struct { - Result string `json:"result"` - PayoutID string `json:"payout_id,omitempty"` - ErrorReason string `json:"error_reason,omitempty"` + ID string `json:"payout_id,omitempty"` + Status PayoutStatus `json:"payout_status,omitempty"` } diff --git a/internal/service/database/db_service.go b/internal/service/database/db_service.go index 305f1de..80de256 100644 --- a/internal/service/database/db_service.go +++ b/internal/service/database/db_service.go @@ -98,8 +98,14 @@ func (d *dbService) CreatePayout(payoutModel *orm.Payout, opts ...Optional) erro return gorm.G[orm.Payout](d.db).Create(p.ctx, payoutModel) } -// UpdatePayout implements [Service]. -func (d *dbService) UpdatePayout(payoutModel *orm.Payout, opts ...Optional) error { - // p := d.getParams(opts...) - panic("unimplemented") +// UpdatePayoutById implements [Service]. +func (d *dbService) UpdatePayoutById(id uint, updateModel orm.Payout, opts ...Optional) (int, error) { + p := d.getParams(opts...) + return gorm.G[orm.Payout](d.db).Where("id = ?", id).Updates(p.ctx, updateModel) +} + +// UpdatePayoutByPayoutID implements [Service]. +func (d *dbService) UpdatePayoutByPayoutID(payoutId string, updateModel orm.Payout, opts ...Optional) (int, error) { + p := d.getParams(opts...) + return gorm.G[orm.Payout](d.db).Where("payout_id = ?", payoutId).Updates(p.ctx, updateModel) } diff --git a/internal/service/database/module.go b/internal/service/database/module.go index 03bfaeb..a5eceb2 100644 --- a/internal/service/database/module.go +++ b/internal/service/database/module.go @@ -29,7 +29,8 @@ type Service interface { GetUser(user *orm.User, opts ...Optional) (orm.User, error) GetPayout(payoutModel *orm.Payout, opts ...Optional) (orm.Payout, error) CreatePayout(payoutModel *orm.Payout, opts ...Optional) error - UpdatePayout(payoutModel *orm.Payout, opts ...Optional) error + UpdatePayoutById(id uint, updateModel orm.Payout, opts ...Optional) (int, error) + UpdatePayoutByPayoutID(payoutId string, updateModel orm.Payout, opts ...Optional) (int, error) } // Params represents the module input params diff --git a/internal/service/database/orm/payout.go b/internal/service/database/orm/payout.go index a35a34b..a3a2bc7 100644 --- a/internal/service/database/orm/payout.go +++ b/internal/service/database/orm/payout.go @@ -1,61 +1,9 @@ package orm import ( - "fmt" - "strings" - "gorm.io/gorm" ) -type PayoutStatus int64 - -const ( - StatusCreated PayoutStatus = iota - StatusCanceled - StatusPending - StatusSucceeded - StatusFailed -) - -func (r PayoutStatus) String() string { - switch r { - case StatusCreated: - return "created" - case StatusCanceled: - return "canceled" - case StatusPending: - return "pending" - case StatusSucceeded: - return "succeeded" - case StatusFailed: - return "failed" - } - return "unknown" -} - -func (r PayoutStatus) MarshalText() (text []byte, err error) { - return []byte(r.String()), nil -} - -func (r *PayoutStatus) UnmarshalText(text []byte) (err error) { - s := strings.ToLower(string(text)) - switch s { - case "canceled": - *r = StatusCanceled - case "created": - *r = StatusCreated - case "pending": - *r = StatusPending - case "succeeded": - *r = StatusSucceeded - case "failed": - *r = StatusFailed - default: - err = fmt.Errorf("invalid payment type: %s", s) - } - return err -} - type Payout struct { gorm.Model @@ -69,6 +17,6 @@ type Payout struct { AccountNumber string Amount float32 Currency string - Status PayoutStatus + Status string Test bool } diff --git a/internal/service/yookassa/config/yookassa.go b/internal/service/yookassa/config/yookassa.go index fa40298..1682ff1 100644 --- a/internal/service/yookassa/config/yookassa.go +++ b/internal/service/yookassa/config/yookassa.go @@ -5,12 +5,23 @@ import "time" type YooKassa struct { BaseUrl string Timeout time.Duration + Retry Retry Test bool - AllowedCallbackSubnets []string + CheckAllowedCallbackAddress bool + AllowedCallbackSubnets []string ApiBaseKey string ApiBaseSecret string ApiPaymentKey string ApiPaymentSecret string + + CallbackProcessTimeout time.Duration +} + +type Retry struct { + Enabled bool + Count int + WaitTime time.Duration + MaxWaitTime time.Duration } diff --git a/internal/service/yookassa/module.go b/internal/service/yookassa/module.go index 1b4713c..f590aff 100644 --- a/internal/service/yookassa/module.go +++ b/internal/service/yookassa/module.go @@ -27,7 +27,8 @@ func WithContext(ctx context.Context) Optional { } type Service interface { - CreatePayout(models.PayoutReq, *orm.User, string, ...Optional) (models.PayoutResp, error) + GetSbpBanks(...Optional) ([]models.SBPBank, error) + CreatePayout(models.PayoutReq, *orm.User, string, ...Optional) (*models.PayoutResp, error) GetConfig() yookassaConf.YooKassa } diff --git a/internal/service/yookassa/yookassa_api.go b/internal/service/yookassa/yookassa_api.go new file mode 100644 index 0000000..8c9ff68 --- /dev/null +++ b/internal/service/yookassa/yookassa_api.go @@ -0,0 +1,71 @@ +package yookassa + +import ( + "fmt" + "time" + + "payouts/internal/models" +) + +type SBPBankResponse struct { + Type string `json:"type"` + Items []models.SBPBank `json:"items"` +} + +type Amount struct { + Value string `json:"value"` + Currency string `json:"currency"` +} + +type Card struct { + Number string `json:"number"` + First6 string `json:"first6"` + Last4 string `json:"last4"` + CardType string `json:"card_type"` + IssuerCountry string `json:"issuer_country"` + IssuerName string `json:"issuer_name"` +} + +type PayoutDestination struct { + Type models.PayoutType `json:"type"` + AccountNumber string `json:"account_number"` + Phone string `json:"phone"` + BankID string `json:"bank_id"` + RecipientChecked bool `json:"recipient_checked,omitempty"` + Card Card `json:"card,omitzero"` +} + +type Metadata map[string]any + +type PayoutRequest struct { + Amount Amount `json:"amount"` + PayoutDestinationData PayoutDestination `json:"payout_destination_data"` + Description string `json:"description"` + Metadata Metadata `json:"metadata"` + Test bool `json:"test"` +} + +type PayoutResponse struct { + ID string `json:"id"` + Status models.PayoutStatus `json:"status"` + Amount Amount `json:"amount"` + PayoutDestination PayoutDestination `json:"payout_destination"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` + SucceededAt time.Time `json:"succeeded_at"` + Metadata Metadata `json:"metadata"` + Test bool `json:"test"` +} + +type Error struct { + Type string `json:"type"` + ID string `json:"id"` + Description string `json:"description"` + Parameter string `json:"parameter"` + Code string `json:"code"` + Status int `json:"status"` +} + +func (e *Error) Error() string { + return fmt.Sprintf("yookassa error. status %d (%s). %s %s", e.Status, e.Code, e.Description, e.Parameter) +} diff --git a/internal/service/yookassa/yookassa_service.go b/internal/service/yookassa/yookassa_service.go index d68a200..e9abeaf 100644 --- a/internal/service/yookassa/yookassa_service.go +++ b/internal/service/yookassa/yookassa_service.go @@ -3,21 +3,54 @@ package yookassa import ( "context" "errors" + "fmt" + "log/slog" + "net/http" + + resty "github.com/go-resty/resty/v2" + "payouts/internal/models" "payouts/internal/service/database/orm" "payouts/internal/service/yookassa/config" ) +const IdempotenceHeader = "Idempotence-Key" + type yookassaService struct { conf config.YooKassa ctx context.Context + + client *resty.Client } func NewYookassaService(conf config.YooKassa) (Service, error) { + client := resty.New() + client.SetBaseURL(conf.BaseUrl) + client.SetBasicAuth(conf.ApiPaymentKey, conf.ApiPaymentSecret) + client.SetTimeout(conf.Timeout) + + if conf.Retry.Enabled { + client. + SetRetryCount(conf.Retry.Count). + SetRetryWaitTime(conf.Retry.WaitTime). + SetRetryMaxWaitTime(conf.Retry.MaxWaitTime). + AddRetryCondition( + func(r *resty.Response, err error) bool { + // Retry on network errors + if err != nil { + return true + } + // Retry on specific status codes + return r.StatusCode() == 429 || // Too Many Requests + r.StatusCode() >= 500 // Server errors + }, + ) + } svc := &yookassaService{ - conf: conf, - ctx: context.Background(), + conf: conf, + client: client, + ctx: context.Background(), } return svc, nil } @@ -32,17 +65,79 @@ func (y *yookassaService) getParams(options ...Optional) *params { return params } +// GetSbpBanks implements [Service]. +func (y *yookassaService) GetSbpBanks(opts ...Optional) ([]models.SBPBank, error) { + params := y.getParams(opts...) + + yResp := &SBPBankResponse{} + yError := &Error{} + + restyResp, err := y.client.R(). + SetContext(params.ctx). + SetResult(yResp). + SetError(yError). + Get("/sbp_banks") + + slog.Debug(fmt.Sprintf("Got response from yookassa: %v", restyResp)) + + if err != nil { + return nil, errors.Join(errors.New("failed to perform yookassa api post"), err) + } + + if restyResp.StatusCode() != http.StatusOK { + yError.Status = restyResp.StatusCode() + return nil, yError + } + + return yResp.Items, nil +} + // CreatePayout implements [Service]. -func (y *yookassaService) CreatePayout(req models.PayoutReq, userSession *orm.User, idempotenceKey string, opts ...Optional) (models.PayoutResp, error) { - // params := y.getParams(opts...) +func (y *yookassaService) CreatePayout(req models.PayoutReq, userSession *orm.User, idempotenceKey string, opts ...Optional) (*models.PayoutResp, error) { + params := y.getParams(opts...) + + yReq := &PayoutRequest{ + Amount: Amount{ + Value: fmt.Sprintf("%.2f", req.Amount), + Currency: "RUB", + }, + PayoutDestinationData: PayoutDestination{ + Type: req.PayoutType, + }, + } + yResp := &PayoutResponse{} + yError := &Error{} switch req.PayoutType { case models.TypeSBP: + yReq.PayoutDestinationData.Phone = userSession.Phone + yReq.PayoutDestinationData.BankID = req.BankID case models.TypeYooMoney: + yReq.PayoutDestinationData.AccountNumber = req.AccountNumber default: - return models.PayoutResp{Result: "failed", ErrorReason: "unsupported payout type"}, errors.New("unsupported payout type") + return nil, errors.New("unsupported payout type") } - return models.PayoutResp{}, nil + + restyResp, err := y.client.R(). + SetContext(params.ctx). + SetHeader(IdempotenceHeader, idempotenceKey). + SetBody(yReq). + SetResult(yResp). + SetError(yError). + Post("/payouts") + + slog.Debug(fmt.Sprintf("Got response from yookassa: %v", restyResp)) + + if err != nil { + return nil, errors.Join(errors.New("failed to perform yookassa api post"), err) + } + + if restyResp.StatusCode() != http.StatusOK { + yError.Status = restyResp.StatusCode() + return nil, yError + } + + return &models.PayoutResp{ID: yResp.ID, Status: yResp.Status}, nil } // GetConfig implements [Service].