добавлен биллинг и тарифы

добавлена интеграция с юкасса
This commit is contained in:
2025-12-24 09:06:19 +03:00
parent b4ce819931
commit 5f35d7a75f
15 changed files with 745 additions and 212 deletions

View File

@@ -0,0 +1,200 @@
// internal/services/billing/service.go
package billing
import (
"context"
"errors"
"fmt"
"time"
"rmser/internal/domain/account"
"rmser/internal/domain/billing"
"rmser/internal/infrastructure/yookassa"
"github.com/google/uuid"
"github.com/shopspring/decimal"
)
// Список доступных тарифов
var AvailableTariffs = []billing.Tariff{
{ID: "pack_10", Name: "Пакет 10", Type: billing.TariffPack, Price: 500, InvoicesCount: 10, DurationDays: 30},
{ID: "pack_50", Name: "Пакет 50", Type: billing.TariffPack, Price: 1000, InvoicesCount: 50, DurationDays: 180},
{ID: "pack_100", Name: "Пакет 100", Type: billing.TariffPack, Price: 1500, InvoicesCount: 100, DurationDays: 365},
{ID: "sub_7", Name: "Безлимит 7 дней", Type: billing.TariffSubscription, Price: 700, InvoicesCount: 1000, DurationDays: 7},
{ID: "sub_14", Name: "Безлимит 14 дней", Type: billing.TariffSubscription, Price: 1300, InvoicesCount: 1000, DurationDays: 14},
{ID: "sub_30", Name: "Безлимит 30 дней", Type: billing.TariffSubscription, Price: 2500, InvoicesCount: 1000, DurationDays: 30},
}
// PaymentNotifier определяет интерфейс для уведомления о успешном платеже
type PaymentNotifier interface {
NotifySuccess(userID uuid.UUID, amount float64, newBalance int, serverName string)
}
type Service struct {
billingRepo billing.Repository
accountRepo account.Repository
ykClient *yookassa.Client
notifier PaymentNotifier
}
func (s *Service) SetNotifier(n PaymentNotifier) {
s.notifier = n
}
func NewService(bRepo billing.Repository, aRepo account.Repository, yk *yookassa.Client) *Service {
return &Service{
billingRepo: bRepo,
accountRepo: aRepo,
ykClient: yk,
}
}
func (s *Service) GetTariffs() []billing.Tariff {
return AvailableTariffs
}
// CreateOrder теперь возвращает (*billing.Order, string, error)
func (s *Service) CreateOrder(ctx context.Context, userID uuid.UUID, tariffID string, targetServerURL string, returnURL string) (*billing.Order, string, error) {
// 1. Ищем тариф
var selectedTariff *billing.Tariff
for _, t := range AvailableTariffs {
if t.ID == tariffID {
selectedTariff = &t
break
}
}
if selectedTariff == nil {
return nil, "", errors.New("тариф не найден")
}
// 2. Определяем целевой сервер
var targetServerID uuid.UUID
if targetServerURL != "" {
srv, err := s.accountRepo.GetServerByURL(targetServerURL)
if err != nil {
return nil, "", fmt.Errorf("сервер не найден: %w", err)
}
targetServerID = srv.ID
} else {
srv, err := s.accountRepo.GetActiveServer(userID)
if err != nil || srv == nil {
return nil, "", errors.New("активный сервер не выбран")
}
targetServerID = srv.ID
}
// 3. Создаем заказ в БД
order := &billing.Order{
ID: uuid.New(),
UserID: userID,
TargetServerID: targetServerID,
TariffID: tariffID,
Amount: selectedTariff.Price,
Status: billing.StatusPending,
}
if err := s.billingRepo.CreateOrder(order); err != nil {
return nil, "", err
}
// 4. Запрос к ЮКассе
description := fmt.Sprintf("Оплата тарифа %s", selectedTariff.Name)
ykResp, err := s.ykClient.CreatePayment(ctx, decimal.NewFromFloat(order.Amount), description, order.ID, returnURL)
if err != nil {
return nil, "", fmt.Errorf("yookassa error: %w", err)
}
// Сохраняем payment_id от ЮКассы в наш заказ
if err := s.billingRepo.UpdateOrderStatus(order.ID, billing.StatusPending, ykResp.ID); err != nil {
return nil, "", err
}
return order, ykResp.Confirmation.URL, nil
}
// ProcessWebhook — вызывается из HTTP хендлера
func (s *Service) ProcessWebhook(ctx context.Context, event yookassa.WebhookEvent) error {
if event.Event != "payment.succeeded" {
return nil
}
orderIDStr := event.Object.Metadata["order_id"]
orderID, err := uuid.Parse(orderIDStr)
if err != nil {
return err
}
order, err := s.billingRepo.GetOrder(orderID)
if err != nil {
return err
}
if order.Status == billing.StatusPaid {
return nil
}
return s.applyOrderToBalance(order, event.Object.ID)
}
// applyOrderToBalance — внутренняя логика начисления (DRY)
func (s *Service) applyOrderToBalance(order *billing.Order, externalID string) error {
var tariff *billing.Tariff
for _, t := range AvailableTariffs {
if t.ID == order.TariffID {
tariff = &t
break
}
}
server, err := s.accountRepo.GetServerByID(order.TargetServerID)
if err != nil {
return err
}
now := time.Now()
var newPaidUntil time.Time
if server.PaidUntil != nil && server.PaidUntil.After(now) {
newPaidUntil = server.PaidUntil.AddDate(0, 0, tariff.DurationDays)
} else {
newPaidUntil = now.AddDate(0, 0, tariff.DurationDays)
}
if err := s.accountRepo.UpdateBalance(server.ID, tariff.InvoicesCount, &newPaidUntil); err != nil {
return err
}
if s.notifier != nil {
// Мы запускаем в горутине, чтобы не тормозить ответ ЮКассе
go s.notifier.NotifySuccess(order.UserID, order.Amount, server.Balance+tariff.InvoicesCount, server.Name)
}
return s.billingRepo.UpdateOrderStatus(order.ID, billing.StatusPaid, externalID)
}
// ConfirmOrder — оставляем для совместимости или ручного подтверждения (если нужно)
func (s *Service) ConfirmOrder(orderID uuid.UUID) error {
order, err := s.billingRepo.GetOrder(orderID)
if err != nil {
return err
}
return s.applyOrderToBalance(order, "manual_confirmation")
}
// CanProcessInvoice проверяет возможность отправки накладной
func (s *Service) CanProcessInvoice(serverID uuid.UUID) (bool, error) {
server, err := s.accountRepo.GetServerByID(serverID)
if err != nil {
return false, err
}
if server.Balance <= 0 {
return false, errors.New("недостаточно накладных на балансе")
}
if server.PaidUntil == nil || server.PaidUntil.Before(time.Now()) {
return false, errors.New("срок действия услуг истек")
}
return true, nil
}