Implement yookassa client

This commit is contained in:
2026-03-19 00:09:25 +03:00
parent 075a53f6ef
commit dd2c360cf6
15 changed files with 411 additions and 114 deletions

View 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,
})
}

View File

@@ -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))
}

View File

@@ -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
View File

@@ -0,0 +1,6 @@
package models
type ErrorResp struct {
Status int `json:"status"`
Message string `json:"message"`
}

View File

@@ -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"`
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View 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)
}

View File

@@ -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].