Implement yookassa client
This commit is contained in:
@@ -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
|
||||
1
go.mod
1
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
|
||||
|
||||
4
go.sum
4
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=
|
||||
|
||||
36
internal/api/common/error.go
Normal file
36
internal/api/common/error.go
Normal file
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
6
internal/models/error.go
Normal file
6
internal/models/error.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package models
|
||||
|
||||
type ErrorResp struct {
|
||||
Status int `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
71
internal/service/yookassa/yookassa_api.go
Normal file
71
internal/service/yookassa/yookassa_api.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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].
|
||||
|
||||
Reference in New Issue
Block a user