// 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 }