332 lines
12 KiB
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(¬ification); 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)
|
|
}
|