Files
rmser/internal/services/billing/service.go
SERTY 5f35d7a75f добавлен биллинг и тарифы
добавлена интеграция с юкасса
2025-12-24 09:06:19 +03:00

201 lines
6.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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