Implement yookassa client
This commit is contained in:
@@ -38,8 +38,20 @@ Cache.TTL = 24h
|
|||||||
# Yookassa related props
|
# Yookassa related props
|
||||||
# Base API Url
|
# Base API Url
|
||||||
YooKassa.BaseUrl = https://api.yookassa.ru/v3
|
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.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
|
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
|
# Base API key/secret
|
||||||
YooKassa.ApiBaseKey =
|
YooKassa.ApiBaseKey =
|
||||||
@@ -47,3 +59,5 @@ YooKassa.ApiBaseSecret =
|
|||||||
# Payments API key/secret
|
# Payments API key/secret
|
||||||
YooKassa.ApiPaymentKey =
|
YooKassa.ApiPaymentKey =
|
||||||
YooKassa.ApiPaymentSecret =
|
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
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/go-resty/resty/v2 v2.17.2
|
||||||
github.com/go-viper/encoding/javaproperties v0.1.0
|
github.com/go-viper/encoding/javaproperties v0.1.0
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0
|
github.com/go-viper/mapstructure/v2 v2.5.0
|
||||||
github.com/google/uuid v1.6.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/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 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
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-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 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
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.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 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
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-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-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
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
|
package payout
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
|
"payouts/internal/api/common"
|
||||||
"payouts/internal/config"
|
"payouts/internal/config"
|
||||||
"payouts/internal/models"
|
"payouts/internal/models"
|
||||||
"payouts/internal/service/cache"
|
"payouts/internal/service/cache"
|
||||||
@@ -99,18 +101,36 @@ func (p *payoutHandler) checkAllowedIpCallback(ipStr string) bool {
|
|||||||
|
|
||||||
// GetSbpBanks implements [Handler].
|
// GetSbpBanks implements [Handler].
|
||||||
func (p *payoutHandler) GetSbpBanks(w http.ResponseWriter, r *http.Request) {
|
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].
|
// PaymentCreate implements [Handler].
|
||||||
func (p *payoutHandler) PayoutCreate(w http.ResponseWriter, r *http.Request) {
|
func (p *payoutHandler) PayoutCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
errResponse := func(message string, err error, status int) {
|
defer r.Body.Close()
|
||||||
http.Error(w, errors.Join(errors.New(message), err).Error(), status)
|
|
||||||
}
|
w.Header().Set("Content-type", "application/json")
|
||||||
|
|
||||||
userSession, err := p.getSession(r)
|
userSession, err := p.getSession(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errResponse("unauthorized", err, http.StatusUnauthorized)
|
common.ErrorResponse(w, "unauthorized", err, http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,8 +140,7 @@ func (p *payoutHandler) PayoutCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
decoder := json.NewDecoder(r.Body)
|
decoder := json.NewDecoder(r.Body)
|
||||||
err = decoder.Decode(&payoutReq)
|
err = decoder.Decode(&payoutReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to decode request body", slog.String("error", err.Error()))
|
common.ErrorResponse(w, "failed to decode request body", err, http.StatusBadRequest)
|
||||||
errResponse("failed to decode request body", err, http.StatusBadRequest)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,34 +150,78 @@ func (p *payoutHandler) PayoutCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
IdempotenceKey: idempotenceKey,
|
IdempotenceKey: idempotenceKey,
|
||||||
Type: payoutReq.PayoutType.String(),
|
Type: payoutReq.PayoutType.String(),
|
||||||
Amount: payoutReq.Amount,
|
Amount: payoutReq.Amount,
|
||||||
Status: orm.StatusCreated,
|
Status: models.StatusCreated.String(),
|
||||||
}
|
}
|
||||||
err = p.dbService.CreatePayout(payoutModel, database.WithContext(r.Context()))
|
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))
|
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()))
|
payoutResp, err := p.yooKassa.CreatePayout(payoutReq, userSession, idempotenceKey, yookassa.WithContext(r.Context()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to create payout request", slog.String("error", err.Error()))
|
status := http.StatusBadRequest
|
||||||
errResponse("failed to create payout request", err, 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
encoder := json.NewEncoder(w)
|
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].
|
// PaymentCallback implements [Handler].
|
||||||
func (p *payoutHandler) PayoutCallback(w http.ResponseWriter, r *http.Request) {
|
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
|
// todo: check also the X-real-ip and/or X-Forwarded-For
|
||||||
if !p.checkAllowedIpCallback(r.RemoteAddr) {
|
if p.yookassaConf.CheckAllowedCallbackAddress && !p.checkAllowedIpCallback(r.RemoteAddr) {
|
||||||
slog.Error(fmt.Sprintf("Callback came from unallowed ip: %s", r.RemoteAddr))
|
common.ErrorResponse(w, "unallowed", nil, http.StatusForbidden, common.Reason("Callback came from unallowed ip: %s", r.RemoteAddr))
|
||||||
http.Error(w, "unallowed", http.StatusForbidden)
|
|
||||||
return
|
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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -12,6 +10,7 @@ import (
|
|||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"payouts/internal/api/common"
|
||||||
"payouts/internal/config"
|
"payouts/internal/config"
|
||||||
"payouts/internal/models"
|
"payouts/internal/models"
|
||||||
"payouts/internal/service/cache"
|
"payouts/internal/service/cache"
|
||||||
@@ -52,28 +51,21 @@ func NewUserHandler(p Params) (Handler, error) {
|
|||||||
func (u *userHandler) UserRegister(w http.ResponseWriter, r *http.Request) {
|
func (u *userHandler) UserRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
defer r.Body.Close()
|
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{}
|
user := models.UserRegister{}
|
||||||
err := json.NewDecoder(r.Body).Decode(&user)
|
err := json.NewDecoder(r.Body).Decode(&user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to get password hash", slog.String("error", err.Error()))
|
common.ErrorResponse(w, "failed to decode request body", err, http.StatusBadRequest)
|
||||||
errResponse("failed to decode request body", err, http.StatusBadRequest)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.Passwd != user.PasswdCfm || len(user.Passwd) == 0 || len(user.Phone) == 0 || len(user.TIN) == 0 {
|
if user.Passwd != user.PasswdCfm || len(user.Passwd) == 0 || len(user.Phone) == 0 || len(user.TIN) == 0 {
|
||||||
slog.Error("No required parameters passed")
|
common.ErrorResponse(w, "invalid parameters", nil, http.StatusBadRequest)
|
||||||
errResponse("invalid parameters", nil, http.StatusBadRequest)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Passwd), bcrypt.DefaultCost)
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Passwd), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to get password hash", slog.String("error", err.Error()))
|
common.ErrorResponse(w, "internal error", nil, http.StatusInternalServerError, common.Reason("failed to get password hash: %v", err))
|
||||||
errResponse("internal error", nil, http.StatusInternalServerError)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user.PasswdHash = string(hashedPassword)
|
user.PasswdHash = string(hashedPassword)
|
||||||
@@ -84,8 +76,7 @@ func (u *userHandler) UserRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
// todo: add data validation
|
// todo: add data validation
|
||||||
err = u.dbService.CreateUser(&ormUser, database.WithContext(r.Context()))
|
err = u.dbService.CreateUser(&ormUser, database.WithContext(r.Context()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to create user", slog.String("error", err.Error()))
|
common.ErrorResponse(w, "failed to create user", err, http.StatusBadRequest)
|
||||||
errResponse("failed to create user", err, http.StatusBadRequest)
|
|
||||||
return
|
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) {
|
func (u *userHandler) UserLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
defer r.Body.Close()
|
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{}
|
user := models.UserLoginReq{}
|
||||||
err := json.NewDecoder(r.Body).Decode(&user)
|
err := json.NewDecoder(r.Body).Decode(&user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errResponse("failed to decode request body", err, http.StatusBadRequest)
|
common.ErrorResponse(w, "failed to decode request body", err, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(user.Phone) == 0 || len(user.Passwd) == 0 {
|
if len(user.Phone) == 0 || len(user.Passwd) == 0 {
|
||||||
slog.Error("No required parameters passed")
|
common.ErrorResponse(w, "invalid parameters", nil, http.StatusBadRequest, common.Reason("no user or password passed"))
|
||||||
errResponse("invalid parameters", nil, http.StatusBadRequest)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ormUser, err := u.dbService.GetUser(&orm.User{Phone: user.Phone}, database.WithContext(r.Context()))
|
ormUser, err := u.dbService.GetUser(&orm.User{Phone: user.Phone}, database.WithContext(r.Context()))
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = bcrypt.CompareHashAndPassword([]byte(ormUser.PasswdHash), []byte(user.Passwd))
|
err = bcrypt.CompareHashAndPassword([]byte(ormUser.PasswdHash), []byte(user.Passwd))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errResponse("invalid credentials", nil, http.StatusUnauthorized)
|
common.ErrorResponse(w, "invalid credentials", nil, http.StatusUnauthorized, common.Reason("password does not match"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +123,7 @@ func (u *userHandler) UserLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
err = json.NewEncoder(w).Encode(resp)
|
err = json.NewEncoder(w).Encode(resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errResponse("failed to encode response", err, http.StatusInternalServerError)
|
common.ErrorResponse(w, "failed to encode response", err, http.StatusInternalServerError)
|
||||||
return
|
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
|
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 {
|
type PayoutReq struct {
|
||||||
PayoutType PayoutType `json:"payout_type"`
|
PayoutType PayoutType `json:"payout_type"`
|
||||||
AccountNumber string `json:"account_number"`
|
AccountNumber string `json:"account_number"`
|
||||||
|
BankID string `json:"bank_id"`
|
||||||
Amount float32 `json:"amount"`
|
Amount float32 `json:"amount"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PayoutResp struct {
|
type PayoutResp struct {
|
||||||
Result string `json:"result"`
|
ID string `json:"payout_id,omitempty"`
|
||||||
PayoutID string `json:"payout_id,omitempty"`
|
Status PayoutStatus `json:"payout_status,omitempty"`
|
||||||
ErrorReason string `json:"error_reason,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)
|
return gorm.G[orm.Payout](d.db).Create(p.ctx, payoutModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdatePayout implements [Service].
|
// UpdatePayoutById implements [Service].
|
||||||
func (d *dbService) UpdatePayout(payoutModel *orm.Payout, opts ...Optional) error {
|
func (d *dbService) UpdatePayoutById(id uint, updateModel orm.Payout, opts ...Optional) (int, error) {
|
||||||
// p := d.getParams(opts...)
|
p := d.getParams(opts...)
|
||||||
panic("unimplemented")
|
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)
|
GetUser(user *orm.User, opts ...Optional) (orm.User, error)
|
||||||
GetPayout(payoutModel *orm.Payout, opts ...Optional) (orm.Payout, error)
|
GetPayout(payoutModel *orm.Payout, opts ...Optional) (orm.Payout, error)
|
||||||
CreatePayout(payoutModel *orm.Payout, opts ...Optional) 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
|
// Params represents the module input params
|
||||||
|
|||||||
@@ -1,61 +1,9 @@
|
|||||||
package orm
|
package orm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"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 {
|
type Payout struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
|
|
||||||
@@ -69,6 +17,6 @@ type Payout struct {
|
|||||||
AccountNumber string
|
AccountNumber string
|
||||||
Amount float32
|
Amount float32
|
||||||
Currency string
|
Currency string
|
||||||
Status PayoutStatus
|
Status string
|
||||||
Test bool
|
Test bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,23 @@ import "time"
|
|||||||
type YooKassa struct {
|
type YooKassa struct {
|
||||||
BaseUrl string
|
BaseUrl string
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
|
Retry Retry
|
||||||
Test bool
|
Test bool
|
||||||
|
|
||||||
AllowedCallbackSubnets []string
|
CheckAllowedCallbackAddress bool
|
||||||
|
AllowedCallbackSubnets []string
|
||||||
|
|
||||||
ApiBaseKey string
|
ApiBaseKey string
|
||||||
ApiBaseSecret string
|
ApiBaseSecret string
|
||||||
ApiPaymentKey string
|
ApiPaymentKey string
|
||||||
ApiPaymentSecret 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 {
|
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
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
resty "github.com/go-resty/resty/v2"
|
||||||
|
|
||||||
"payouts/internal/models"
|
"payouts/internal/models"
|
||||||
"payouts/internal/service/database/orm"
|
"payouts/internal/service/database/orm"
|
||||||
"payouts/internal/service/yookassa/config"
|
"payouts/internal/service/yookassa/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const IdempotenceHeader = "Idempotence-Key"
|
||||||
|
|
||||||
type yookassaService struct {
|
type yookassaService struct {
|
||||||
conf config.YooKassa
|
conf config.YooKassa
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
|
|
||||||
|
client *resty.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewYookassaService(conf config.YooKassa) (Service, error) {
|
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{
|
svc := &yookassaService{
|
||||||
conf: conf,
|
conf: conf,
|
||||||
ctx: context.Background(),
|
client: client,
|
||||||
|
ctx: context.Background(),
|
||||||
}
|
}
|
||||||
return svc, nil
|
return svc, nil
|
||||||
}
|
}
|
||||||
@@ -32,17 +65,79 @@ func (y *yookassaService) getParams(options ...Optional) *params {
|
|||||||
return 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].
|
// CreatePayout implements [Service].
|
||||||
func (y *yookassaService) CreatePayout(req models.PayoutReq, userSession *orm.User, idempotenceKey string, opts ...Optional) (models.PayoutResp, error) {
|
func (y *yookassaService) CreatePayout(req models.PayoutReq, userSession *orm.User, idempotenceKey string, opts ...Optional) (*models.PayoutResp, error) {
|
||||||
// params := y.getParams(opts...)
|
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 {
|
switch req.PayoutType {
|
||||||
case models.TypeSBP:
|
case models.TypeSBP:
|
||||||
|
yReq.PayoutDestinationData.Phone = userSession.Phone
|
||||||
|
yReq.PayoutDestinationData.BankID = req.BankID
|
||||||
case models.TypeYooMoney:
|
case models.TypeYooMoney:
|
||||||
|
yReq.PayoutDestinationData.AccountNumber = req.AccountNumber
|
||||||
default:
|
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].
|
// GetConfig implements [Service].
|
||||||
|
|||||||
Reference in New Issue
Block a user