treasurer/internal/payment_provider/yoomoney/provider.go

332 lines
12 KiB
Go

package yoomoney
import (
"context"
"encoding/json"
"fmt"
"gitea.pena/PenaSide/treasurer/internal/errors"
"gitea.pena/PenaSide/treasurer/internal/models"
"gitea.pena/PenaSide/treasurer/internal/models/yandex"
"gitea.pena/PenaSide/treasurer/internal/repository"
"gitea.pena/PenaSide/treasurer/internal/service/callback"
"gitea.pena/PenaSide/treasurer/internal/utils"
"github.com/go-resty/resty/v2"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"go.uber.org/zap"
"net/http"
)
const (
ProviderName = "yoomoney"
)
type Config struct {
StoreID string `env:"YOOMONEY_STORE_ID"`
SecretKey string `env:"YOOMONEY_SECRET_KEY"`
WebhooksURL string `env:"YOOMONEY_WEBHOOKS_URL"`
PaymentsURL string `env:"YOOMONEY_PAYMENTS_URL"`
}
type Provider struct {
logger *zap.Logger
config *Config
httpClient *resty.Client
repository *repository.PaymentRepository
callbackService *callback.Service
paymentMethodRepository *repository.PaymentMethodRepository
}
type Deps struct {
Logger *zap.Logger
Config *Config
Repository *repository.PaymentRepository
CallbackService *callback.Service
PaymentMethodRepository *repository.PaymentMethodRepository
}
func New(deps Deps) (*Provider, errors.Error) {
return &Provider{
logger: deps.Logger,
config: deps.Config,
httpClient: resty.New(),
repository: deps.Repository,
callbackService: deps.CallbackService,
paymentMethodRepository: deps.PaymentMethodRepository,
}, nil
}
func (p *Provider) GetName() string {
return ProviderName
}
func (p *Provider) GetSupportedPaymentMethods() []models.PaymentType {
return []models.PaymentType{
models.PaymentTypeBankCard,
models.PaymentTypeTinkoff,
models.PaymentTypeSberPay,
models.PaymentTypeYoomoney,
models.PaymentTypeMobile,
models.PaymentTypeSBP,
models.PaymentTypeSberB2B,
}
}
// *models.CreatePayment[yandex.Receipt]
func (p *Provider) CreateInvoice(ctx context.Context, req map[string]string) (string, errors.Error) {
request, err := utils.MapToCreatePaymentYandexReceipt(req)
if err != nil {
p.logger.Error("failed to create payment yandex receipt by parse map", zap.Error(err))
return "", errors.NewWithMessage("failed to parse input request by parse map", errors.ErrInvalidArgs)
}
if request.Recurrent {
return p.CreateRecurrentPayment(ctx, request)
}
idempotenceKey := uuid.New().String()
yandexPayment, err := p.httpClient.R().
SetContext(ctx).
SetHeader("Content-Type", "application/json").
SetHeader("Idempotence-Key", idempotenceKey).
SetHeader("Authorization", utils.ConvertYoomoneySercetsToAuth("Basic", p.config.StoreID, p.config.SecretKey)).
SetBody(&yandex.CreatePaymentRequest[yandex.PaymentMethodType]{
Amount: yandex.Amount{
Value: utils.ConvertAmountToStringFloat(request.Amount),
Currency: request.Currency,
},
Receipt: yandex.Receipt{
TaxSystemCode: 2, //https://yookassa.ru/developers/payment-acceptance/receipts/54fz/other-services/parameters-values#tax-systems
Customer: request.Requisites.Customer,
Items: request.Requisites.Items,
},
PaymentMethodData: &yandex.PaymentMethodType{Type: models.YandexPaymentTypeMap[request.Type]},
Confirmation: &yandex.CreateConfirmationRedirect{
Type: yandex.ConfirmationTypeRedirect,
Locale: "ru_RU",
ReturnURL: request.ReturnURL,
Enforce: true,
},
Capture: true,
ClientIP: request.ClientIP,
SavePaymentMethod: request.Auto,
}).
Post(p.config.PaymentsURL)
if err != nil {
p.logger.Error("failed to create payment", zap.Error(err), zap.String("payment_method", string(request.Type)))
return "", errors.NewWithError(fmt.Errorf("failed to create payment: %w", err), errors.ErrInternalError)
}
if yandexPayment.StatusCode() != http.StatusOK {
p.logger.Error("failed to create payment", zap.Int("status_code", yandexPayment.StatusCode()), zap.String("response", yandexPayment.String()))
return "", errors.NewWithMessage("failed to create payment", errors.ErrInternalError)
}
var payment yandex.Payment
if err := json.Unmarshal(yandexPayment.Body(), &payment); err != nil {
p.logger.Error("failed to unmarshal payment response", zap.Error(err))
return "", errors.NewWithError(fmt.Errorf("failed to unmarshal payment response: %w", err), errors.ErrInternalError)
}
_, err = p.repository.Insert(ctx, &models.Payment{
UserID: request.UserID,
PaymentID: payment.ID,
IdempotencePaymentID: idempotenceKey,
ClientIP: request.ClientIP,
Currency: request.Currency,
Amount: request.Amount,
Type: request.Type,
Status: models.PaymentStatusMap[string(payment.Status)],
Completed: false,
RawPaymentBody: payment,
CallbackHostGRPC: request.CallbackHostGRPC,
})
if err != nil {
p.logger.Error("failed to save payment to database", zap.Error(err))
return "", errors.NewWithError(fmt.Errorf("failed to save payment to database: %w", err), errors.ErrInternalError)
}
return payment.Confirmation.ConfirmationURL, nil
}
func (p *Provider) RegisterWebhookHandlers(router fiber.Router) {
router.Post(p.config.WebhooksURL, p.handleWebhook)
}
func (p *Provider) handleWebhook(ctx *fiber.Ctx) error {
var notification yandex.WebhookNotification[yandex.Payment]
if err := ctx.BodyParser(&notification); err != nil {
p.logger.Error("failed to parse webhook notification", zap.Error(err))
return errors.HTTP(ctx, errors.NewWithError(fmt.Errorf("failed to parse webhook notification: %w", err), errors.ErrInternalError))
}
var payment *models.Payment
var err errors.Error
switch notification.Event {
case yandex.WebhookEventPaymentSucceeded:
payment, err = p.repository.SetPaymentStatus(ctx.Context(), notification.Object.ID, models.PaymentStatusSuccessfully)
if err != nil {
p.logger.Error("failed to set payment complete", zap.Error(err))
return errors.HTTP(ctx, err)
}
if notification.Object.PaymentMethod != nil && notification.Object.PaymentMethod.Saved {
method := &yandex.PaymentMethod{
UserID: payment.UserID,
MethodID: notification.Object.PaymentMethod.ID,
Type: notification.Object.PaymentMethod.Type,
Title: notification.Object.PaymentMethod.Title,
Saved: notification.Object.PaymentMethod.Saved,
}
if notification.Object.PaymentMethod.Card != nil {
method.Card = &yandex.Card{
First6: notification.Object.PaymentMethod.Card.First6,
Last4: notification.Object.PaymentMethod.Card.Last4,
ExpiryMonth: notification.Object.PaymentMethod.Card.ExpiryMonth,
ExpiryYear: notification.Object.PaymentMethod.Card.ExpiryYear,
CardType: notification.Object.PaymentMethod.Card.CardType,
IssuerName: notification.Object.PaymentMethod.Card.IssuerName,
IssuerCountry: notification.Object.PaymentMethod.Card.IssuerCountry,
}
}
if notification.Object.PaymentMethod.Card.CardProduct != nil {
method.Card.CardProduct = &yandex.CardProduct{
Code: notification.Object.PaymentMethod.Card.CardProduct.Code,
Name: notification.Object.PaymentMethod.Card.CardProduct.Name,
}
}
if err := p.paymentMethodRepository.Save(ctx.Context(), method); err != nil {
p.logger.Error("failed to save payment method", zap.Error(err),
zap.String("userId", payment.UserID), zap.String("methodId", method.MethodID))
// todo стоит ли возвращать ошибку?
}
}
case yandex.WebhookEventPaymentCanceled:
payment, err = p.repository.SetPaymentStatus(ctx.Context(), notification.Object.ID, models.PaymentStatusCanceled)
if err != nil {
p.logger.Error("failed to set payment status canceled", zap.Error(err))
return errors.HTTP(ctx, err)
}
case yandex.WebhookEventPaymentWaiting:
payment, err = p.repository.SetPaymentStatus(ctx.Context(), notification.Object.ID, models.PaymentStatusWaiting)
if err != nil {
p.logger.Error("failed to set payment status waiting", zap.Error(err))
return errors.HTTP(ctx, err)
}
case yandex.WebhookEventRefundSucceeded:
payment, err = p.repository.SetPaymentStatus(ctx.Context(), notification.Object.ID, models.PaymentStatusRefund)
if err != nil {
p.logger.Error("failed to set payment status refund", zap.Error(err))
return errors.HTTP(ctx, err)
}
default:
p.logger.Warn("unknown webhook event type", zap.String("event", string(notification.Event)))
return errors.HTTP(ctx, errors.NewWithMessage(fmt.Sprintf("unknown webhook event type: %s", notification.Event), errors.ErrInvalidArgs))
}
if payment != nil {
event := &models.Event{
Key: string(notification.Event),
Message: fmt.Sprintf("yoomoney send event: %s", notification.Event),
Payment: payment,
}
if notification.Event == yandex.WebhookEventPaymentSucceeded {
if err := p.callbackService.OnSuccess(ctx.Context(), event); err != nil {
p.logger.Error("failed to send success callback", zap.Error(err))
return errors.HTTP(ctx, err)
}
}
if notification.Event == yandex.WebhookEventPaymentCanceled {
if err := p.callbackService.OnFailure(ctx.Context(), event); err != nil {
p.logger.Error("failed to send failure callback", zap.Error(err))
return errors.HTTP(ctx, err)
}
}
}
return ctx.SendStatus(http.StatusOK)
}
func (p *Provider) CreateRecurrentPayment(ctx context.Context, request *models.CreatePayment[yandex.Receipt]) (string, errors.Error) {
methods, err := p.paymentMethodRepository.GetByUserID(ctx, request.UserID)
if err != nil {
p.logger.Error("failed to get payment methods", zap.Error(err), zap.String("userId", request.UserID))
return "", errors.NewWithError(err, errors.ErrInternalError)
}
if len(methods) == 0 {
p.logger.Warn("no saved payment methods found", zap.String("userId", request.UserID))
return "", errors.NewWithMessage("no saved payment methods found", errors.ErrInvalidArgs)
}
for _, method := range methods {
idempotenceKey := uuid.New().String()
yandexPayment, err := p.httpClient.R().
SetContext(ctx).
SetHeader("Content-Type", "application/json").
SetHeader("Idempotence-Key", idempotenceKey).
SetHeader("Authorization", utils.ConvertYoomoneySercetsToAuth("Basic", p.config.StoreID, p.config.SecretKey)).
SetBody(&yandex.CreateRecurrentPayment{
Amount: yandex.Amount{
Value: utils.ConvertAmountToStringFloat(request.Amount),
Currency: request.Currency,
},
PaymentMethodID: method.MethodID,
Capture: true,
}).
Post(p.config.PaymentsURL)
if err != nil {
p.logger.Error("failed to create recurrent payment", zap.Error(err), zap.String("userId", request.UserID))
return "", errors.NewWithError(fmt.Errorf("failed to create recurrent payment"), errors.ErrInternalError)
}
if yandexPayment.StatusCode() != http.StatusOK {
p.logger.Error("unexpected status code from yandex", zap.Int("statusCode", yandexPayment.StatusCode()), zap.String("userId", request.UserID))
return "", errors.NewWithError(fmt.Errorf("unexpected status code: %d", yandexPayment.StatusCode()), errors.ErrInternalError)
}
var payment yandex.Payment
if err := json.Unmarshal(yandexPayment.Body(), &payment); err != nil {
p.logger.Error("failed to unmarshal payment response", zap.Error(err), zap.String("userId", request.UserID), zap.String("methodId", method.MethodID))
return "", errors.NewWithError(err, errors.ErrInternalError)
}
if payment.Status != yandex.PaymentStatusSuccessfully {
p.logger.Error("payment not succeeded", zap.String("userId", request.UserID), zap.String("status", string(payment.Status)))
continue
}
_, err = p.repository.Insert(ctx, &models.Payment{
UserID: request.UserID,
PaymentID: payment.ID,
IdempotencePaymentID: idempotenceKey,
ClientIP: request.ClientIP,
Currency: request.Currency,
Amount: request.Amount,
Type: request.Type,
Status: models.PaymentStatusMap[string(payment.Status)],
Completed: false,
RawPaymentBody: payment,
CallbackHostGRPC: request.CallbackHostGRPC,
})
if err != nil {
p.logger.Error("failed to save payment to database", zap.Error(err))
return "", errors.NewWithError(fmt.Errorf("failed to save payment to database: %w", err), errors.ErrInternalError)
}
return payment.ID, nil
}
return "", errors.NewWithMessage("failed to create recurrent payment with any saved method", errors.ErrInternalError)
}