mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
добавлен биллинг и тарифы
добавлена интеграция с юкасса
This commit is contained in:
200
internal/services/billing/service.go
Normal file
200
internal/services/billing/service.go
Normal 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
|
||||
}
|
||||
@@ -17,16 +17,18 @@ import (
|
||||
"rmser/internal/domain/ocr"
|
||||
"rmser/internal/domain/suppliers"
|
||||
"rmser/internal/infrastructure/rms"
|
||||
"rmser/internal/services/billing"
|
||||
"rmser/pkg/logger"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
draftRepo drafts.Repository
|
||||
ocrRepo ocr.Repository
|
||||
catalogRepo catalog.Repository
|
||||
accountRepo account.Repository
|
||||
supplierRepo suppliers.Repository
|
||||
rmsFactory *rms.Factory
|
||||
draftRepo drafts.Repository
|
||||
ocrRepo ocr.Repository
|
||||
catalogRepo catalog.Repository
|
||||
accountRepo account.Repository
|
||||
supplierRepo suppliers.Repository
|
||||
rmsFactory *rms.Factory
|
||||
billingService *billing.Service
|
||||
}
|
||||
|
||||
func NewService(
|
||||
@@ -36,14 +38,16 @@ func NewService(
|
||||
accountRepo account.Repository,
|
||||
supplierRepo suppliers.Repository,
|
||||
rmsFactory *rms.Factory,
|
||||
billingService *billing.Service,
|
||||
) *Service {
|
||||
return &Service{
|
||||
draftRepo: draftRepo,
|
||||
ocrRepo: ocrRepo,
|
||||
catalogRepo: catalogRepo,
|
||||
accountRepo: accountRepo,
|
||||
supplierRepo: supplierRepo,
|
||||
rmsFactory: rmsFactory,
|
||||
draftRepo: draftRepo,
|
||||
ocrRepo: ocrRepo,
|
||||
catalogRepo: catalogRepo,
|
||||
accountRepo: accountRepo,
|
||||
supplierRepo: supplierRepo,
|
||||
rmsFactory: rmsFactory,
|
||||
billingService: billingService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +232,11 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// --- BILLING CHECK ---
|
||||
if can, err := s.billingService.CanProcessInvoice(server.ID); !can {
|
||||
return "", fmt.Errorf("ошибка биллинга: %w", err)
|
||||
}
|
||||
|
||||
// 2. Черновик
|
||||
draft, err := s.draftRepo.GetByID(draftID)
|
||||
if err != nil {
|
||||
@@ -300,10 +309,16 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
|
||||
draft.Status = drafts.StatusCompleted
|
||||
s.draftRepo.Update(draft)
|
||||
|
||||
// 7. БИЛЛИНГ и Обучение
|
||||
// --- БИЛЛИНГ: Списание баланса и инкремент счетчика ---
|
||||
if err := s.accountRepo.DecrementBalance(server.ID); err != nil {
|
||||
logger.Log.Error("Billing decrement failed", zap.Error(err), zap.String("server_id", server.ID.String()))
|
||||
}
|
||||
|
||||
if err := s.accountRepo.IncrementInvoiceCount(server.ID); err != nil {
|
||||
logger.Log.Error("Billing increment failed", zap.Error(err))
|
||||
}
|
||||
|
||||
// 7. Запуск обучения
|
||||
go s.learnFromDraft(draft, server.ID)
|
||||
|
||||
return docNum, nil
|
||||
|
||||
Reference in New Issue
Block a user