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

добавлена интеграция с юкасса
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
}

View File

@@ -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