Files
payouts/internal/api/payout/payout_handler.go

165 lines
4.3 KiB
Go

package payout
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"regexp"
"github.com/google/uuid"
"go.uber.org/fx"
"payouts/internal/config"
"payouts/internal/models"
"payouts/internal/service/cache"
"payouts/internal/service/database"
"payouts/internal/service/database/orm"
"payouts/internal/service/yookassa"
yookassaConf "payouts/internal/service/yookassa/config"
)
const (
BaseRoute = "/payout"
CreateRoute = "/create"
CallbackRoute = "/callback"
BanksRoute = "/sbp/banks"
)
var authHeaderRe = regexp.MustCompile(`^Bearer\s+(.*)$`)
type payoutHandler struct {
dbService database.Service
cacheService cache.Service
yooKassa yookassa.Service
yookassaConf yookassaConf.YooKassa
}
// Params represents the module input params
type Params struct {
fx.In
AppConfig *config.App
DbService database.Service
YooKassa yookassa.Service
CacheService cache.Service
}
func NewPayoutHandler(p Params) (Handler, error) {
return &payoutHandler{
dbService: p.DbService,
cacheService: p.CacheService,
yooKassa: p.YooKassa,
yookassaConf: p.YooKassa.GetConfig(),
}, nil
}
func (p *payoutHandler) getSession(r *http.Request) (*orm.User, error) {
authHeaderValue := r.Header.Get("Authorization")
if len(authHeaderValue) == 0 {
return nil, errors.New("no valid auth header")
}
matches := authHeaderRe.FindStringSubmatch(authHeaderValue)
if matches == nil {
return nil, errors.New("no valid auth header")
}
sessionId := matches[1]
userSession, err := p.cacheService.GetSession(sessionId)
if err != nil {
return nil, errors.New("session not found")
}
return &userSession, nil
}
func (p *payoutHandler) checkAllowedIpCallback(ipStr string) bool {
ipWithoutPort, _, _ := net.SplitHostPort(ipStr)
ip := net.ParseIP(ipWithoutPort)
if ip == nil {
slog.Error(fmt.Sprintf("Invalid IP: %s", ipStr))
return false
}
for _, subnetStr := range p.yookassaConf.AllowedCallbackSubnets {
_, ipNet, err := net.ParseCIDR(subnetStr)
if err != nil {
slog.Error(fmt.Sprintf("Invalid subnet CIDR: %v", err))
continue
}
if ipNet.Contains(ip) {
return true
}
}
return false
}
// GetSbpBanks implements [Handler].
func (p *payoutHandler) GetSbpBanks(w http.ResponseWriter, r *http.Request) {
panic("unimplemented")
}
// 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)
}
userSession, err := p.getSession(r)
if err != nil {
errResponse("unauthorized", err, http.StatusUnauthorized)
return
}
payoutReq := models.PayoutReq{
PayoutType: models.TypeSBP,
}
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)
return
}
idempotenceKey := uuid.New().String()
payoutModel := &orm.Payout{
User: *userSession,
IdempotenceKey: idempotenceKey,
Type: payoutReq.PayoutType.String(),
Amount: payoutReq.Amount,
Status: orm.StatusCreated,
}
err = p.dbService.CreatePayout(payoutModel, database.WithContext(r.Context()))
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)
return
}
encoder := json.NewEncoder(w)
encoder.Encode(payoutResp)
}
// 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)
return
}
slog.Info(fmt.Sprintf("Received callback from %s with object %v with headers %v", r.RemoteAddr, inData, r.Header))
}