diff --git a/cmd/main.go b/cmd/main.go index 0b22268..0c2688f 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -13,12 +13,14 @@ import ( "rmser/config" "rmser/internal/infrastructure/db" "rmser/internal/infrastructure/ocr_client" + "rmser/internal/infrastructure/yookassa" "rmser/internal/transport/http/middleware" tgBot "rmser/internal/transport/telegram" // Repositories accountPkg "rmser/internal/infrastructure/repository/account" + billingPkg "rmser/internal/infrastructure/repository/billing" catalogPkg "rmser/internal/infrastructure/repository/catalog" draftsPkg "rmser/internal/infrastructure/repository/drafts" invoicesPkg "rmser/internal/infrastructure/repository/invoices" @@ -31,6 +33,7 @@ import ( "rmser/internal/infrastructure/rms" // Services + billingServicePkg "rmser/internal/services/billing" draftsServicePkg "rmser/internal/services/drafts" ocrServicePkg "rmser/internal/services/ocr" recServicePkg "rmser/internal/services/recommend" @@ -68,6 +71,7 @@ func main() { // 4. Repositories accountRepo := accountPkg.NewRepository(database) + billingRepo := billingPkg.NewRepository(database) catalogRepo := catalogPkg.NewRepository(database) recipesRepo := recipesPkg.NewRepository(database) invoicesRepo := invoicesPkg.NewRepository(database) @@ -82,25 +86,29 @@ func main() { // 6. Services pyClient := ocr_client.NewClient(cfg.OCR.ServiceURL) + ykClient := yookassa.NewClient(cfg.YooKassa.ShopID, cfg.YooKassa.SecretKey) + billingService := billingServicePkg.NewService(billingRepo, accountRepo, ykClient) syncService := sync.NewService(rmsFactory, accountRepo, catalogRepo, recipesRepo, invoicesRepo, opsRepo, supplierRepo) recService := recServicePkg.NewService(recRepo) ocrService := ocrServicePkg.NewService(ocrRepo, catalogRepo, draftsRepo, accountRepo, pyClient, cfg.App.StoragePath) - draftsService := draftsServicePkg.NewService(draftsRepo, ocrRepo, catalogRepo, accountRepo, supplierRepo, rmsFactory) + draftsService := draftsServicePkg.NewService(draftsRepo, ocrRepo, catalogRepo, accountRepo, supplierRepo, rmsFactory, billingService) // 7. Handlers draftsHandler := handlers.NewDraftsHandler(draftsService) + billingHandler := handlers.NewBillingHandler(billingService) ocrHandler := handlers.NewOCRHandler(ocrService) recommendHandler := handlers.NewRecommendationsHandler(recService) settingsHandler := handlers.NewSettingsHandler(accountRepo, catalogRepo) // 8. Telegram Bot (Передаем syncService) if cfg.Telegram.Token != "" { - bot, err := tgBot.NewBot(cfg.Telegram, ocrService, syncService, accountRepo, rmsFactory, cryptoManager) + bot, err := tgBot.NewBot(cfg.Telegram, ocrService, syncService, billingService, accountRepo, rmsFactory, cryptoManager) if err != nil { logger.Log.Fatal("Ошибка создания Telegram бота", zap.Error(err)) } - settingsHandler.SetNotifier(bot) // Внедряем зависимость + billingService.SetNotifier(bot) + settingsHandler.SetNotifier(bot) go bot.Start() defer bot.Stop() } @@ -111,6 +119,8 @@ func main() { } r := gin.Default() + r.POST("/api/webhooks/yookassa", billingHandler.YooKassaWebhook) + corsConfig := cors.DefaultConfig() corsConfig.AllowAllOrigins = true corsConfig.AllowMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"} diff --git a/config.yaml b/config.yaml index 590df97..fba33e4 100644 --- a/config.yaml +++ b/config.yaml @@ -27,4 +27,8 @@ security: telegram: token: "7858843765:AAE5HBQHbef4fGLoMDV91bhHZQFcnsBDcv4" admin_ids: [665599275] - web_app_url: "https://rmser.serty.top" \ No newline at end of file + web_app_url: "https://rmser.serty.top" + +yookassa: + shop_id: "1236145" + secret_key: "test_HxUkDTirAycj7xooYcu_-gURsHMETbE_onIJYXGkj5Y" \ No newline at end of file diff --git a/config/config.go b/config/config.go index cb1c93f..9267d5b 100644 --- a/config/config.go +++ b/config/config.go @@ -15,6 +15,7 @@ type Config struct { OCR OCRConfig Telegram TelegramConfig Security SecurityConfig + YooKassa YooKassaConfig `mapstructure:"yookassa"` } type AppConfig struct { @@ -54,6 +55,11 @@ type SecurityConfig struct { SecretKey string `mapstructure:"secret_key"` // 32 bytes for AES-256 } +type YooKassaConfig struct { + ShopID string `mapstructure:"shop_id"` + SecretKey string `mapstructure:"secret_key"` +} + // LoadConfig загружает конфигурацию из файла и переменных окружения func LoadConfig(path string) (*Config, error) { viper.AddConfigPath(path) diff --git a/internal/domain/account/entity.go b/internal/domain/account/entity.go index 3b04ae0..6e8c042 100644 --- a/internal/domain/account/entity.go +++ b/internal/domain/account/entity.go @@ -65,7 +65,11 @@ type RMSServer struct { RootGroupGUID *uuid.UUID `gorm:"type:uuid" json:"root_group_guid"` AutoProcess bool `gorm:"default:false" json:"auto_process"` - // Billing / Stats + // Billing + Balance int `gorm:"default:0" json:"balance"` + PaidUntil *time.Time `json:"paid_until"` + + // Stats InvoiceCount int `gorm:"default:0" json:"invoice_count"` CreatedAt time.Time `json:"created_at"` @@ -77,10 +81,12 @@ type Repository interface { // Users GetOrCreateUser(telegramID int64, username, first, last string) (*User, error) GetUserByTelegramID(telegramID int64) (*User, error) + GetUserByID(id uuid.UUID) (*User, error) // ConnectServer - Основной метод подключения. - // Реализует логику: Новый URL -> Owner, Старый URL -> Operator. ConnectServer(userID uuid.UUID, url, login, encryptedPass, name string) (*RMSServer, error) + GetServerByURL(url string) (*RMSServer, error) + GetServerByID(id uuid.UUID) (*RMSServer, error) SaveServerSettings(server *RMSServer) error @@ -106,7 +112,10 @@ type Repository interface { AddUserToServer(serverID, userID uuid.UUID, role Role) error RemoveUserFromServer(serverID, userID uuid.UUID) error + // Billing & Stats IncrementInvoiceCount(serverID uuid.UUID) error + UpdateBalance(serverID uuid.UUID, amountChange int, newPaidUntil *time.Time) error + DecrementBalance(serverID uuid.UUID) error // Super Admin Functions GetAllServersSystemWide() ([]RMSServer, error) diff --git a/internal/domain/billing/entity.go b/internal/domain/billing/entity.go new file mode 100644 index 0000000..f5b04f9 --- /dev/null +++ b/internal/domain/billing/entity.go @@ -0,0 +1,57 @@ +// internal/domain/billing/entity.go + +package billing + +import ( + "time" + + "github.com/google/uuid" +) + +type TariffType string + +const ( + TariffPack TariffType = "PACK" // Пакет накладных + TariffSubscription TariffType = "SUBSCRIPTION" // Подписка (безлимит на время) +) + +type OrderStatus string + +const ( + StatusPending OrderStatus = "PENDING" + StatusPaid OrderStatus = "PAID" + StatusCanceled OrderStatus = "CANCELED" +) + +// Tariff - Описание услуги +type Tariff struct { + ID string `json:"id"` + Name string `json:"name"` + Type TariffType `json:"type"` + Price float64 `json:"price"` + InvoicesCount int `json:"invoices_count"` // Для PACK - сколько штук, для SUBSCRIPTION - 1000 (лимит) + DurationDays int `json:"duration_days"` +} + +// Order - Заказ на покупку тарифа +type Order struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"` // Кто платит + TargetServerID uuid.UUID `gorm:"type:uuid;not null;index" json:"target_server_id"` // Кому начисляем + TariffID string `gorm:"type:varchar(50);not null" json:"tariff_id"` + Amount float64 `gorm:"type:numeric(19,4);not null" json:"amount"` + Status OrderStatus `gorm:"type:varchar(20);default:'PENDING'" json:"status"` + + PaymentID string `gorm:"type:varchar(100)" json:"payment_id"` // ID транзакции в ЮКассе + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Repository для работы с заказами +type Repository interface { + CreateOrder(order *Order) error + GetOrder(id uuid.UUID) (*Order, error) + UpdateOrderStatus(id uuid.UUID, status OrderStatus, paymentID string) error + GetActiveOrdersByUser(userID uuid.UUID) ([]Order, error) +} diff --git a/internal/infrastructure/db/postgres.go b/internal/infrastructure/db/postgres.go index e8fc9be..9adc1ac 100644 --- a/internal/infrastructure/db/postgres.go +++ b/internal/infrastructure/db/postgres.go @@ -7,6 +7,7 @@ import ( "os" "regexp" "rmser/internal/domain/account" + "rmser/internal/domain/billing" "rmser/internal/domain/catalog" "rmser/internal/domain/drafts" "rmser/internal/domain/invoices" @@ -51,6 +52,7 @@ func NewPostgresDB(dsn string) *gorm.DB { &account.User{}, &account.RMSServer{}, &account.ServerUser{}, + &billing.Order{}, &catalog.Product{}, &catalog.MeasureUnit{}, &catalog.ProductContainer{}, diff --git a/internal/infrastructure/repository/account/postgres.go b/internal/infrastructure/repository/account/postgres.go index e495ff6..76f2111 100644 --- a/internal/infrastructure/repository/account/postgres.go +++ b/internal/infrastructure/repository/account/postgres.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "strings" + "time" "rmser/internal/domain/account" @@ -60,6 +61,15 @@ func (r *pgRepository) GetUserByTelegramID(telegramID int64) (*account.User, err return &user, nil } +func (r *pgRepository) GetUserByID(id uuid.UUID) (*account.User, error) { + var user account.User + err := r.db.Where("id = ?", id).First(&user).Error + if err != nil { + return nil, err + } + return &user, nil +} + // ConnectServer - Основная точка входа для добавления сервера func (r *pgRepository) ConnectServer(userID uuid.UUID, rawURL, login, encryptedPass, name string) (*account.RMSServer, error) { // 1. Нормализация URL (удаляем слеш в конце, приводим к нижнему регистру) @@ -401,3 +411,37 @@ func (r *pgRepository) GetConnectionByID(id uuid.UUID) (*account.ServerUser, err } return &link, nil } + +func (r *pgRepository) GetServerByURL(rawURL string) (*account.RMSServer, error) { + cleanURL := strings.TrimRight(strings.ToLower(strings.TrimSpace(rawURL)), "/") + var server account.RMSServer + err := r.db.Where("base_url = ?", cleanURL).First(&server).Error + if err != nil { + return nil, err + } + return &server, nil +} + +func (r *pgRepository) GetServerByID(id uuid.UUID) (*account.RMSServer, error) { + var server account.RMSServer + err := r.db.First(&server, id).Error + if err != nil { + return nil, err + } + return &server, nil +} + +// UpdateBalance начисляет пакет или продлевает подписку +func (r *pgRepository) UpdateBalance(serverID uuid.UUID, amountChange int, newPaidUntil *time.Time) error { + return r.db.Model(&account.RMSServer{}).Where("id = ?", serverID).Updates(map[string]interface{}{ + "balance": gorm.Expr("balance + ?", amountChange), + "paid_until": newPaidUntil, + }).Error +} + +// DecrementBalance списывает 1 единицу при отправке накладной +func (r *pgRepository) DecrementBalance(serverID uuid.UUID) error { + return r.db.Model(&account.RMSServer{}). + Where("id = ? AND balance > 0", serverID). + UpdateColumn("balance", gorm.Expr("balance - ?", 1)).Error +} diff --git a/internal/infrastructure/repository/billing/postgres.go b/internal/infrastructure/repository/billing/postgres.go new file mode 100644 index 0000000..bbf1eb5 --- /dev/null +++ b/internal/infrastructure/repository/billing/postgres.go @@ -0,0 +1,47 @@ +package billing + +import ( + "rmser/internal/domain/billing" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type pgRepository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) billing.Repository { + return &pgRepository{db: db} +} + +func (r *pgRepository) CreateOrder(order *billing.Order) error { + return r.db.Create(order).Error +} + +func (r *pgRepository) GetOrder(id uuid.UUID) (*billing.Order, error) { + var order billing.Order + err := r.db.Where("id = ?", id).First(&order).Error + if err != nil { + return nil, err + } + return &order, nil +} + +func (r *pgRepository) UpdateOrderStatus(id uuid.UUID, status billing.OrderStatus, paymentID string) error { + updates := map[string]interface{}{ + "status": status, + } + if paymentID != "" { + updates["payment_id"] = paymentID + } + return r.db.Model(&billing.Order{}).Where("id = ?", id).Updates(updates).Error +} + +func (r *pgRepository) GetActiveOrdersByUser(userID uuid.UUID) ([]billing.Order, error) { + var orders []billing.Order + err := r.db.Where("user_id = ? AND status = ?", userID, billing.StatusPending). + Order("created_at DESC"). + Find(&orders).Error + return orders, err +} diff --git a/internal/infrastructure/yookassa/client.go b/internal/infrastructure/yookassa/client.go new file mode 100644 index 0000000..3bc3f01 --- /dev/null +++ b/internal/infrastructure/yookassa/client.go @@ -0,0 +1,79 @@ +package yookassa + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/google/uuid" + "github.com/shopspring/decimal" +) + +type Client struct { + shopID string + secretKey string + baseURL string + httpClient *http.Client +} + +func NewClient(shopID, secretKey string) *Client { + return &Client{ + shopID: shopID, + secretKey: secretKey, + baseURL: "https://api.yookassa.ru/v3", + httpClient: &http.Client{Timeout: 15 * time.Second}, + } +} + +func (c *Client) CreatePayment(ctx context.Context, amount decimal.Decimal, description string, orderID uuid.UUID, returnURL string) (*PaymentResponse, error) { + reqBody := PaymentRequest{ + Amount: Amount{ + Value: amount.StringFixed(2), + Currency: "RUB", + }, + Capture: true, // Автоматическое списание + Confirmation: Confirmation{ + Type: "redirect", + ReturnURL: returnURL, + }, + Metadata: map[string]string{ + "order_id": orderID.String(), + }, + Description: description, + } + + body, _ := json.Marshal(reqBody) + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/payments", bytes.NewReader(body)) + if err != nil { + return nil, err + } + + // Basic Auth + auth := base64.StdEncoding.EncodeToString([]byte(c.shopID + ":" + c.secretKey)) + req.Header.Set("Authorization", "Basic "+auth) + req.Header.Set("Content-Type", "application/json") + + // Идемпотентность: используем наш order_id как ключ + req.Header.Set("Idempotence-Key", orderID.String()) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("yookassa error: status %d", resp.StatusCode) + } + + var result PaymentResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + return &result, nil +} diff --git a/internal/infrastructure/yookassa/dto.go b/internal/infrastructure/yookassa/dto.go new file mode 100644 index 0000000..3c7e674 --- /dev/null +++ b/internal/infrastructure/yookassa/dto.go @@ -0,0 +1,34 @@ +package yookassa + +type Amount struct { + Value string `json:"value"` + Currency string `json:"currency"` +} + +type Confirmation struct { + Type string `json:"type"` + ReturnURL string `json:"return_url,omitempty"` + URL string `json:"confirmation_url,omitempty"` +} + +type PaymentRequest struct { + Amount Amount `json:"amount"` + Capture bool `json:"capture"` + Confirmation Confirmation `json:"confirmation"` + Metadata map[string]string `json:"metadata"` + Description string `json:"description"` +} + +type PaymentResponse struct { + ID string `json:"id"` + Status string `json:"status"` + Amount Amount `json:"amount"` + Confirmation Confirmation `json:"confirmation"` + Metadata map[string]string `json:"metadata"` +} + +type WebhookEvent struct { + Event string `json:"event"` + Type string `json:"type"` + Object PaymentResponse `json:"object"` +} diff --git a/internal/services/billing/service.go b/internal/services/billing/service.go new file mode 100644 index 0000000..339f16e --- /dev/null +++ b/internal/services/billing/service.go @@ -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 +} diff --git a/internal/services/drafts/service.go b/internal/services/drafts/service.go index bc262fe..f01e779 100644 --- a/internal/services/drafts/service.go +++ b/internal/services/drafts/service.go @@ -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 diff --git a/internal/transport/http/handlers/billing.go b/internal/transport/http/handlers/billing.go new file mode 100644 index 0000000..7998797 --- /dev/null +++ b/internal/transport/http/handlers/billing.go @@ -0,0 +1,40 @@ +package handlers + +import ( + "net/http" + "rmser/internal/infrastructure/yookassa" + "rmser/internal/services/billing" + "rmser/pkg/logger" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type BillingHandler struct { + service *billing.Service +} + +func NewBillingHandler(s *billing.Service) *BillingHandler { + return &BillingHandler{service: s} +} + +func (h *BillingHandler) YooKassaWebhook(c *gin.Context) { + var event yookassa.WebhookEvent + if err := c.ShouldBindJSON(&event); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"}) + return + } + + logger.Log.Info("YooKassa Webhook received", + zap.String("event", event.Event), + zap.String("payment_id", event.Object.ID), + ) + + if err := h.service.ProcessWebhook(c.Request.Context(), event); err != nil { + logger.Log.Error("Failed to process webhook", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"status": "error"}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} diff --git a/internal/transport/telegram/bot.go b/internal/transport/telegram/bot.go index 3b5f974..21a9680 100644 --- a/internal/transport/telegram/bot.go +++ b/internal/transport/telegram/bot.go @@ -1,3 +1,5 @@ +// internal/transport/telegram/bot.go + package telegram import ( @@ -16,6 +18,7 @@ import ( "rmser/config" "rmser/internal/domain/account" "rmser/internal/infrastructure/rms" + "rmser/internal/services/billing" "rmser/internal/services/ocr" "rmser/internal/services/sync" "rmser/pkg/crypto" @@ -23,19 +26,18 @@ import ( ) type Bot struct { - b *tele.Bot - ocrService *ocr.Service - syncService *sync.Service - accountRepo account.Repository - rmsFactory *rms.Factory - cryptoManager *crypto.CryptoManager + b *tele.Bot + ocrService *ocr.Service + syncService *sync.Service + billingService *billing.Service + accountRepo account.Repository + rmsFactory *rms.Factory + cryptoManager *crypto.CryptoManager fsm *StateManager adminIDs map[int64]struct{} webAppURL string - // UI Elements (Menus) - // menuMain удаляем как статическое поле, так как оно теперь динамическое menuServers *tele.ReplyMarkup menuDicts *tele.ReplyMarkup menuBalance *tele.ReplyMarkup @@ -45,6 +47,7 @@ func NewBot( cfg config.TelegramConfig, ocrService *ocr.Service, syncService *sync.Service, + billingService *billing.Service, accountRepo account.Repository, rmsFactory *rms.Factory, cryptoManager *crypto.CryptoManager, @@ -69,15 +72,16 @@ func NewBot( } bot := &Bot{ - b: b, - ocrService: ocrService, - syncService: syncService, - accountRepo: accountRepo, - rmsFactory: rmsFactory, - cryptoManager: cryptoManager, - fsm: NewStateManager(), - adminIDs: admins, - webAppURL: cfg.WebAppURL, + b: b, + ocrService: ocrService, + syncService: syncService, + billingService: billingService, + accountRepo: accountRepo, + rmsFactory: rmsFactory, + cryptoManager: cryptoManager, + fsm: NewStateManager(), + adminIDs: admins, + webAppURL: cfg.WebAppURL, } if bot.webAppURL == "" { @@ -89,12 +93,9 @@ func NewBot( return bot, nil } -// initMenus инициализирует статические кнопки (кроме Главного меню) func (bot *Bot) initMenus() { - // --- SERVERS MENU (Dynamic part logic is in handler) --- bot.menuServers = &tele.ReplyMarkup{} - // --- DICTIONARIES MENU --- bot.menuDicts = &tele.ReplyMarkup{} btnSync := bot.menuDicts.Data("⚡️ Обновить данные", "act_sync") btnBack := bot.menuDicts.Data("🔙 Назад", "nav_main") @@ -103,49 +104,33 @@ func (bot *Bot) initMenus() { bot.menuDicts.Row(btnBack), ) - // --- BALANCE MENU --- bot.menuBalance = &tele.ReplyMarkup{} - btnDeposit := bot.menuBalance.Data("💳 Пополнить (Demo)", "act_deposit") - bot.menuBalance.Inline( - bot.menuBalance.Row(btnDeposit), - bot.menuBalance.Row(btnBack), - ) + // Кнопки пополнения теперь создаются динамически в renderBalanceMenu } func (bot *Bot) initHandlers() { bot.b.Use(middleware.Logger()) bot.b.Use(bot.registrationMiddleware) - // Commands bot.b.Handle("/start", bot.handleStartCommand) - - // Admin Commands bot.b.Handle("/admin", bot.handleAdminCommand) - // Navigation Callbacks bot.b.Handle(&tele.Btn{Unique: "nav_main"}, bot.renderMainMenu) bot.b.Handle(&tele.Btn{Unique: "nav_servers"}, bot.renderServersMenu) bot.b.Handle(&tele.Btn{Unique: "nav_dicts"}, bot.renderDictsMenu) bot.b.Handle(&tele.Btn{Unique: "nav_balance"}, bot.renderBalanceMenu) - // Actions Callbacks bot.b.Handle(&tele.Btn{Unique: "act_add_server"}, bot.startAddServerFlow) bot.b.Handle(&tele.Btn{Unique: "act_sync"}, bot.triggerSync) bot.b.Handle(&tele.Btn{Unique: "act_del_server_menu"}, bot.renderDeleteServerMenu) bot.b.Handle(&tele.Btn{Unique: "confirm_name_yes"}, bot.handleConfirmNameYes) bot.b.Handle(&tele.Btn{Unique: "confirm_name_no"}, bot.handleConfirmNameNo) - bot.b.Handle(&tele.Btn{Unique: "act_deposit"}, func(c tele.Context) error { - return c.Respond(&tele.CallbackResponse{Text: "Функция пополнения в разработке 🛠"}) - }) - // Dynamic Handler for server selection ("set_server_UUID") bot.b.Handle(&tele.Btn{Unique: "adm_list_servers"}, bot.adminListServers) bot.b.Handle(tele.OnCallback, bot.handleCallback) - // Input Handlers bot.b.Handle(tele.OnText, bot.handleText) bot.b.Handle(tele.OnPhoto, bot.handlePhoto) - } func (bot *Bot) Start() { @@ -166,19 +151,14 @@ func (bot *Bot) registrationMiddleware(next tele.HandlerFunc) tele.HandlerFunc { } } -// handleStartCommand обрабатывает /start и deep linking (приглашения) func (bot *Bot) handleStartCommand(c tele.Context) error { - payload := c.Message().Payload // То, что после /start - - // Если есть payload, пробуем разобрать как приглашение + payload := c.Message().Payload if payload != "" && strings.HasPrefix(payload, "invite_") { return bot.handleInviteLink(c, strings.TrimPrefix(payload, "invite_")) } - return bot.renderMainMenu(c) } -// handleInviteLink обрабатывает приглашение пользователя на сервер func (bot *Bot) handleInviteLink(c tele.Context, serverIDStr string) error { serverID, err := uuid.Parse(serverIDStr) if err != nil { @@ -186,43 +166,33 @@ func (bot *Bot) handleInviteLink(c tele.Context, serverIDStr string) error { } newUser := c.Sender() - // Гарантируем, что юзер есть в БД (хотя middleware это делает, тут для надежности перед логикой) userDB, _ := bot.accountRepo.GetOrCreateUser(newUser.ID, newUser.Username, newUser.FirstName, newUser.LastName) - // Добавляем пользователя (RoleOperator - желаемая, но репозиторий может оставить более высокую) err = bot.accountRepo.AddUserToServer(serverID, userDB.ID, account.RoleOperator) if err != nil { return c.Send(fmt.Sprintf("❌ Не удалось подключиться к серверу: %v", err)) } - // Сбрасываем кэш подключений bot.rmsFactory.ClearCacheForUser(userDB.ID) - // Получаем актуальные данные о роли и сервере ПОСЛЕ добавления activeServer, err := bot.accountRepo.GetActiveServer(userDB.ID) if err != nil || activeServer == nil || activeServer.ID != serverID { - // Крайний случай, если что-то пошло не так с активацией return c.Send("✅ Доступ предоставлен, но сервер не стал активным автоматически. Выберите его в меню.") } role, _ := bot.accountRepo.GetUserRole(userDB.ID, serverID) - // 1. Отправляем сообщение пользователю c.Send(fmt.Sprintf("✅ Вы подключены к серверу %s.\nВаша роль: %s.\nТеперь вы можете загружать чеки.", activeServer.Name, role), tele.ModeHTML) - // 2. Уведомляем Владельца (только если это реально новый человек или роль изменилась, но упростим - шлем всегда при переходе по ссылке) - // Но не шлем уведомление, если Владелец перешел по своей же ссылке if role != account.RoleOwner { go func() { users, err := bot.accountRepo.GetServerUsers(serverID) if err == nil { for _, u := range users { if u.Role == account.RoleOwner { - // Не уведомляем, если это тот же человек (хотя проверка выше role != Owner уже отсекла это, но на всякий случай) if u.UserID == userDB.ID { continue } - name := newUser.FirstName if newUser.LastName != "" { name += " " + newUser.LastName @@ -230,9 +200,7 @@ func (bot *Bot) handleInviteLink(c tele.Context, serverIDStr string) error { if newUser.Username != "" { name += fmt.Sprintf(" (@%s)", newUser.Username) } - msg := fmt.Sprintf("🔔 Обновление команды\n\nПользователь %s активировал приглашение на сервер «%s» (Роль: %s).", name, activeServer.Name, role) - bot.b.Send(&tele.User{ID: u.User.TelegramID}, msg, tele.ModeHTML) break } @@ -244,7 +212,6 @@ func (bot *Bot) handleInviteLink(c tele.Context, serverIDStr string) error { return bot.renderMainMenu(c) } -// Реализация интерфейса handlers.Notifier func (bot *Bot) SendRoleChangeNotification(telegramID int64, serverName string, newRole string) { msg := fmt.Sprintf("ℹ️ Изменение прав доступа\n\nСервер: %s\nВаша новая роль: %s", serverName, newRole) bot.b.Send(&tele.User{ID: telegramID}, msg, tele.ModeHTML) @@ -255,17 +222,14 @@ func (bot *Bot) SendRemovalNotification(telegramID int64, serverName string) { bot.b.Send(&tele.User{ID: telegramID}, msg, tele.ModeHTML) } -// handleAdminCommand - точка входа в админку func (bot *Bot) handleAdminCommand(c tele.Context) error { userID := c.Sender().ID if _, isAdmin := bot.adminIDs[userID]; !isAdmin { - return nil // Игнорируем не админов + return nil } - menu := &tele.ReplyMarkup{} btnServers := menu.Data("🏢 Список серверов", "adm_list_servers") menu.Inline(menu.Row(btnServers)) - return c.Send("🕵️‍♂️ Super Admin Panel\n\nВыберите действие:", menu, tele.ModeHTML) } @@ -274,30 +238,22 @@ func (bot *Bot) adminListServers(c tele.Context) error { if err != nil { return c.Send("Error: " + err.Error()) } - menu := &tele.ReplyMarkup{} var rows []tele.Row - for _, s := range servers { - // adm_srv_ btn := menu.Data(fmt.Sprintf("🖥 %s", s.Name), "adm_srv_"+s.ID.String()) rows = append(rows, menu.Row(btn)) } menu.Inline(rows...) - return c.EditOrSend("Все серверы системы:", menu, tele.ModeHTML) } -// --- RENDERERS (View Layer) --- - -// renderMainMenu строит меню динамически в зависимости от роли func (bot *Bot) renderMainMenu(c tele.Context) error { bot.fsm.Reset(c.Sender().ID) userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID) activeServer, _ := bot.accountRepo.GetActiveServer(userDB.ID) menu := &tele.ReplyMarkup{} - btnServers := menu.Data("🖥 Серверы", "nav_servers") btnDicts := menu.Data("🔄 Справочники", "nav_dicts") btnBalance := menu.Data("💰 Баланс", "nav_balance") @@ -306,7 +262,6 @@ func (bot *Bot) renderMainMenu(c tele.Context) error { rows = append(rows, menu.Row(btnServers, btnDicts)) rows = append(rows, menu.Row(btnBalance)) - // Проверяем роль для отображения кнопки App showApp := false if activeServer != nil { role, _ := bot.accountRepo.GetUserRole(userDB.ID, activeServer.ID) @@ -318,13 +273,9 @@ func (bot *Bot) renderMainMenu(c tele.Context) error { if showApp { btnApp := menu.WebApp("📱 Открыть приложение", &tele.WebApp{URL: bot.webAppURL}) rows = append(rows, menu.Row(btnApp)) - } else { - // Если оператор или нет сервера, можно добавить подсказку или просто ничего - // Для оператора это нормально. Для нового юзера - он пойдет в "Серверы" } menu.Inline(rows...) - txt := "👋 Панель управления RMSER\n\n" + "Здесь вы можете управлять подключенными серверами iiko и следить за актуальностью справочников." @@ -346,17 +297,13 @@ func (bot *Bot) renderServersMenu(c tele.Context) error { menu := &tele.ReplyMarkup{} var rows []tele.Row - for _, s := range servers { icon := "🔴" if activeServer != nil && activeServer.ID == s.ID { icon = "🟢" } - - // Определяем роль для отображения role, _ := bot.accountRepo.GetUserRole(userDB.ID, s.ID) label := fmt.Sprintf("%s %s (%s)", icon, s.Name, role) - btn := menu.Data(label, "set_server_"+s.ID.String()) rows = append(rows, menu.Row(btn)) } @@ -367,7 +314,6 @@ func (bot *Bot) renderServersMenu(c tele.Context) error { rows = append(rows, menu.Row(btnAdd, btnDel)) rows = append(rows, menu.Row(btnBack)) - menu.Inline(rows...) txt := fmt.Sprintf("🖥 Ваши серверы (%d):\n\nНажмите на сервер, чтобы сделать его активным.", len(servers)) @@ -376,9 +322,7 @@ func (bot *Bot) renderServersMenu(c tele.Context) error { func (bot *Bot) renderDictsMenu(c tele.Context) error { userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID) - stats, err := bot.syncService.GetSyncStats(userDB.ID) - var txt string if err != nil { txt = fmt.Sprintf("⚠️ Статус: Ошибка или нет активного сервера (%v)", err) @@ -387,7 +331,6 @@ func (bot *Bot) renderDictsMenu(c tele.Context) error { if stats.LastInvoice != nil { lastUpdate = stats.LastInvoice.Format("02.01.2006") } - txt = fmt.Sprintf("🔄 Состояние справочников\n\n"+ "🏢 Сервер: %s\n"+ "📦 Товары: %d\n"+ @@ -396,27 +339,63 @@ func (bot *Bot) renderDictsMenu(c tele.Context) error { "📄 Накладные (30дн): %d\n"+ "📅 Посл. документ: %s\n\n"+ "Нажмите «Обновить», чтобы синхронизировать данные.", - stats.ServerName, - stats.ProductsCount, - stats.SuppliersCount, - stats.StoresCount, - stats.InvoicesLast30, - lastUpdate) + stats.ServerName, stats.ProductsCount, stats.SuppliersCount, stats.StoresCount, stats.InvoicesLast30, lastUpdate) } - return c.EditOrSend(txt, bot.menuDicts, tele.ModeHTML) } func (bot *Bot) renderBalanceMenu(c tele.Context) error { - txt := "💰 Ваш баланс\n\n" + - "💵 Текущий счет: 0.00 ₽\n" + - "💎 Тариф: Free\n\n" + - "Пока сервис работает в бета-режиме, использование бесплатно." + userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID) + activeServer, _ := bot.accountRepo.GetActiveServer(userDB.ID) - return c.EditOrSend(txt, bot.menuBalance, tele.ModeHTML) + txt := "💰 Баланс и Тарифы\n\n" + if activeServer == nil { + txt += "❌ У вас нет активного сервера. Сначала подключите сервер в меню «Серверы»." + } else { + paidUntil := "не активно" + if activeServer.PaidUntil != nil { + paidUntil = activeServer.PaidUntil.Format("02.01.2006") + } + txt += fmt.Sprintf("🏢 Сервер: %s\n", activeServer.Name) + txt += fmt.Sprintf("📄 Остаток накладных: %d шт.\n", activeServer.Balance) + txt += fmt.Sprintf("📅 Доступен до: %s\n\n", paidUntil) + txt += "Выберите способ пополнения:" + } + + menu := &tele.ReplyMarkup{} + var rows []tele.Row + if activeServer != nil { + btnTopUp := menu.Data("💳 Пополнить баланс", "bill_topup") + btnGift := menu.Data("🎁 Подарок другу", "bill_gift") + rows = append(rows, menu.Row(btnTopUp, btnGift)) + } + btnBack := menu.Data("🔙 Назад", "nav_main") + rows = append(rows, menu.Row(btnBack)) + menu.Inline(rows...) + + return c.EditOrSend(txt, menu, tele.ModeHTML) } -// --- LOGIC HANDLERS --- +func (bot *Bot) renderTariffShowcase(c tele.Context, targetURL string) error { + tariffs := bot.billingService.GetTariffs() + menu := &tele.ReplyMarkup{} + txt := "🛒 Выберите тарифный план\n" + if targetURL != "" { + txt += fmt.Sprintf("🎁 Оформление подарка для сервера: %s\n", targetURL) + } + txt += "\nПакеты (разово):" + + var rows []tele.Row + for _, t := range tariffs { + label := fmt.Sprintf("%s — %.0f₽ (%d шт)", t.Name, t.Price, t.InvoicesCount) + btn := menu.Data(label, "buy_id_"+t.ID) + rows = append(rows, menu.Row(btn)) + } + btnBack := menu.Data("🔙 Отмена", "nav_balance") + rows = append(rows, menu.Row(btnBack)) + menu.Inline(rows...) + return c.EditOrSend(txt, menu, tele.ModeHTML) +} func (bot *Bot) handleCallback(c tele.Context) error { data := c.Callback().Data @@ -426,7 +405,11 @@ func (bot *Bot) handleCallback(c tele.Context) error { userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID) - // --- SELECT SERVER --- + // --- INTEGRATION: Billing Callbacks --- + if strings.HasPrefix(data, "bill_") || strings.HasPrefix(data, "buy_id_") || strings.HasPrefix(data, "pay_") { + return bot.handleBillingCallbacks(c, data, userDB) + } + if strings.HasPrefix(data, "set_server_") { serverIDStr := strings.TrimPrefix(data, "set_server_") serverIDStr = strings.TrimSpace(serverIDStr) @@ -434,22 +417,15 @@ func (bot *Bot) handleCallback(c tele.Context) error { serverIDStr = serverIDStr[:idx] } targetID := parseUUID(serverIDStr) - if err := bot.accountRepo.SetActiveServer(userDB.ID, targetID); err != nil { logger.Log.Error("Failed to set active server", zap.Error(err)) return c.Respond(&tele.CallbackResponse{Text: "Ошибка: доступ запрещен"}) } - - bot.rmsFactory.ClearCacheForUser(userDB.ID) // Сброс кэша + bot.rmsFactory.ClearCacheForUser(userDB.ID) c.Respond(&tele.CallbackResponse{Text: "✅ Сервер выбран"}) - - // Важно: перерисовываем главное меню, чтобы обновилась кнопка App (появилась/пропала) - // Но мы находимся в подменю. Логичнее остаться в ServersMenu, но кнопка App в MainMenu. - // Пользователь нажмет "Назад" и попадет в MainMenu, где сработает renderMainMenu с новой логикой. return bot.renderServersMenu(c) } - // --- DELETE / LEAVE SERVER --- if strings.HasPrefix(data, "do_del_server_") { serverIDStr := strings.TrimPrefix(data, "do_del_server_") serverIDStr = strings.TrimSpace(serverIDStr) @@ -457,12 +433,10 @@ func (bot *Bot) handleCallback(c tele.Context) error { serverIDStr = serverIDStr[:idx] } targetID := parseUUID(serverIDStr) - role, err := bot.accountRepo.GetUserRole(userDB.ID, targetID) if err != nil { return c.Respond(&tele.CallbackResponse{Text: "Ошибка прав доступа"}) } - if role == account.RoleOwner { if err := bot.accountRepo.DeleteServer(targetID); err != nil { return c.Respond(&tele.CallbackResponse{Text: "Ошибка удаления"}) @@ -476,7 +450,6 @@ func (bot *Bot) handleCallback(c tele.Context) error { bot.rmsFactory.ClearCacheForUser(userDB.ID) c.Respond(&tele.CallbackResponse{Text: "Вы покинули сервер"}) } - active, _ := bot.accountRepo.GetActiveServer(userDB.ID) if active == nil { all, _ := bot.accountRepo.GetAllAvailableServers(userDB.ID) @@ -484,11 +457,9 @@ func (bot *Bot) handleCallback(c tele.Context) error { _ = bot.accountRepo.SetActiveServer(userDB.ID, all[0].ID) } } - return bot.renderDeleteServerMenu(c) } - // --- INVITE LINK GENERATION --- if strings.HasPrefix(data, "gen_invite_") { serverIDStr := strings.TrimPrefix(data, "gen_invite_") link := fmt.Sprintf("https://t.me/%s?start=invite_%s", bot.b.Me.Username, serverIDStr) @@ -496,85 +467,138 @@ func (bot *Bot) handleCallback(c tele.Context) error { return c.Send(fmt.Sprintf("🔗 Ссылка для приглашения:\n\n%s\n\nОтправьте её сотруднику.", link), tele.ModeHTML) } - // --- ADMIN: SELECT SERVER -> SHOW USERS --- if strings.HasPrefix(data, "adm_srv_") { serverIDStr := strings.TrimPrefix(data, "adm_srv_") serverID := parseUUID(serverIDStr) - return bot.renderServerUsers(c, serverID) // <--- ВЫЗОВ НОВОГО МЕТОДА + return bot.renderServerUsers(c, serverID) } - // --- ADMIN: SELECT USER -> CONFIRM OWNERSHIP --- if strings.HasPrefix(data, "adm_usr_") { - // Получаем ID связи connIDStr := strings.TrimPrefix(data, "adm_usr_") connID := parseUUID(connIDStr) - - // Загружаем детали связи через новый метод link, err := bot.accountRepo.GetConnectionByID(connID) if err != nil { return c.Respond(&tele.CallbackResponse{Text: "Ошибка: связь не найдена"}) } - - // Проверяем роль if link.Role == account.RoleOwner { return c.Respond(&tele.CallbackResponse{Text: "Этот пользователь уже Владелец"}) } - menu := &tele.ReplyMarkup{} - - // ИСПРАВЛЕНИЕ: Для подтверждения тоже передаем ID связи - // adm_own_yes_ + UUID = 12 + 36 = 48 байт (OK) btnYes := menu.Data("✅ Сделать Владельцем", fmt.Sprintf("adm_own_yes_%s", link.ID.String())) btnNo := menu.Data("Отмена", "adm_srv_"+link.ServerID.String()) - menu.Inline(menu.Row(btnYes), menu.Row(btnNo)) - txt := fmt.Sprintf("⚠️ Внимание!\n\nВы собираетесь передать права Владельца сервера %s пользователю %s.\n\nТекущий владелец станет Администратором.", link.Server.Name, link.User.FirstName) - return c.EditOrSend(txt, menu, tele.ModeHTML) } - // --- ADMIN: EXECUTE TRANSFER --- if strings.HasPrefix(data, "adm_own_yes_") { connIDStr := strings.TrimPrefix(data, "adm_own_yes_") connID := parseUUID(connIDStr) - link, err := bot.accountRepo.GetConnectionByID(connID) if err != nil { return c.Respond(&tele.CallbackResponse{Text: "Ошибка: связь не найдена"}) } - if err := bot.accountRepo.TransferOwnership(link.ServerID, link.UserID); err != nil { logger.Log.Error("Ownership transfer failed", zap.Error(err)) return c.Respond(&tele.CallbackResponse{Text: "Ошибка: " + err.Error()}) } - - // Уведомляем нового владельца go func() { msg := fmt.Sprintf("👑 Поздравляем!\n\nВам переданы права Владельца (OWNER) сервера %s.", link.Server.Name) bot.b.Send(&tele.User{ID: link.User.TelegramID}, msg, tele.ModeHTML) }() - c.Respond(&tele.CallbackResponse{Text: "Успешно!"}) - - // Возвращаемся к списку return bot.renderServerUsers(c, link.ServerID) } return nil } -// --- Вспомогательный метод рендера списка пользователей --- +// Реализация метода интерфейса PaymentNotifier +func (bot *Bot) NotifySuccess(userID uuid.UUID, amount float64, newBalance int, serverName string) { + + user, err := bot.accountRepo.GetUserByID(userID) + if err != nil { + logger.Log.Error("Failed to find user for payment notification", zap.Error(err)) + return + } + + msg := fmt.Sprintf( + "✅ Оплата получена!\n\n"+ + "Сумма: %.2f ₽\n"+ + "Сервер: %s\n"+ + "Текущий баланс: %d накладных\n\n"+ + "Спасибо за использование RMSer!", + amount, serverName, newBalance, + ) + + bot.b.Send(&tele.User{ID: user.TelegramID}, msg, tele.ModeHTML) +} + +func (bot *Bot) handleBillingCallbacks(c tele.Context, data string, userDB *account.User) error { + if data == "bill_topup" { + return bot.renderTariffShowcase(c, "") + } + + if data == "bill_gift" { + bot.fsm.SetState(c.Sender().ID, StateBillingGiftURL) + return c.EditOrSend("🎁 Режим подарка\n\nВведите URL сервера, который хотите пополнить (например: https://myresto.iiko.it).\n\nСервер должен быть уже зарегистрирован в нашей системе.", tele.ModeHTML) + } + + if strings.HasPrefix(data, "pay_confirm_") { + orderIDStr := strings.TrimPrefix(data, "pay_confirm_") + orderID, _ := uuid.Parse(orderIDStr) + if err := bot.billingService.ConfirmOrder(orderID); err != nil { + return c.Respond(&tele.CallbackResponse{Text: "Ошибка: " + err.Error()}) + } + c.Respond(&tele.CallbackResponse{Text: "✨ Оплата успешно имитирована!"}) + bot.fsm.Reset(c.Sender().ID) + return c.EditOrSend("✅ Оплата прошла успешно!\n\nУслуги начислены на баланс сервера. Теперь вы можете продолжить работу с накладными.", bot.menuBalance, tele.ModeHTML) + } + + if strings.HasPrefix(data, "buy_id_") { + tariffID := strings.TrimPrefix(data, "buy_id_") + ctxFSM := bot.fsm.GetContext(c.Sender().ID) + + // 1. Формируем URL возврата (ссылка на бота) + returnURL := fmt.Sprintf("https://t.me/%s", bot.b.Me.Username) + + // 2. Вызываем обновленный метод (теперь возвращает 3 значения) + order, payURL, err := bot.billingService.CreateOrder( + context.Background(), + userDB.ID, + tariffID, + ctxFSM.BillingTargetURL, + returnURL, + ) + if err != nil { + return c.Send("❌ Ошибка при формировании счета: " + err.Error()) + } + + // 3. Создаем кнопку с URL на ЮКассу + menu := &tele.ReplyMarkup{} + btnPay := menu.URL("💳 Оплатить", payURL) // payURL теперь string + btnBack := menu.Data("🔙 Отмена", "nav_balance") + menu.Inline(menu.Row(btnPay), menu.Row(btnBack)) + + txt := fmt.Sprintf( + "📦 Заказ №%s\n\nСумма к оплате: %.2f ₽\n\nНажмите кнопку ниже для перехода к оплате. Баланс будет пополнен автоматически сразу после подтверждения платежа.", + order.ID.String()[:8], // Показываем короткий ID для красоты + order.Amount, + ) + return c.EditOrSend(txt, menu, tele.ModeHTML) + } + + return nil +} + func (bot *Bot) renderServerUsers(c tele.Context, serverID uuid.UUID) error { users, err := bot.accountRepo.GetServerUsers(serverID) if err != nil { return c.Respond(&tele.CallbackResponse{Text: "Ошибка загрузки юзеров"}) } - menu := &tele.ReplyMarkup{} var rows []tele.Row - for _, u := range users { roleIcon := "👤" if u.Role == account.RoleOwner { @@ -583,45 +607,32 @@ func (bot *Bot) renderServerUsers(c tele.Context, serverID uuid.UUID) error { if u.Role == account.RoleAdmin { roleIcon = "⭐️" } - label := fmt.Sprintf("%s %s %s", roleIcon, u.User.FirstName, u.User.LastName) - // Используем ID связи payload := fmt.Sprintf("adm_usr_%s", u.ID.String()) - btn := menu.Data(label, payload) rows = append(rows, menu.Row(btn)) } - btnBack := menu.Data("🔙 К серверам", "adm_list_servers") rows = append(rows, menu.Row(btnBack)) menu.Inline(rows...) - - // Для заголовка нам нужно имя сервера, но в users[0].Server оно есть (Preload), - // либо если юзеров нет (пустой сервер?), то имя не узнаем без доп запроса. - // Но пустой сервер вряд ли будет, там как минимум Owner. serverName := "Unknown" if len(users) > 0 { serverName = users[0].Server.Name } - return c.EditOrSend(fmt.Sprintf("👥 Пользователи сервера %s:", serverName), menu, tele.ModeHTML) } func (bot *Bot) triggerSync(c tele.Context) error { userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID) - server, err := bot.accountRepo.GetActiveServer(userDB.ID) if err != nil || server == nil { return c.Respond(&tele.CallbackResponse{Text: "Нет активного сервера"}) } - role, _ := bot.accountRepo.GetUserRole(userDB.ID, server.ID) if role == account.RoleOperator { return c.Respond(&tele.CallbackResponse{Text: "⚠️ Синхронизация доступна только Админам", ShowAlert: true}) } - c.Respond(&tele.CallbackResponse{Text: "Запускаю синхронизацию..."}) - go func() { if err := bot.syncService.SyncAllData(userDB.ID); err != nil { logger.Log.Error("Manual sync failed", zap.Error(err)) @@ -630,11 +641,9 @@ func (bot *Bot) triggerSync(c tele.Context) error { bot.b.Send(c.Sender(), "✅ Синхронизация успешно завершена!") } }() - return nil } -// --- FSM: ADD SERVER FLOW --- func (bot *Bot) startAddServerFlow(c tele.Context) error { bot.fsm.SetState(c.Sender().ID, StateAddServerURL) return c.EditOrSend("🔗 Введите URL вашего сервера iikoRMS.\nПример: https://resto.iiko.it\n\n(Напишите 'отмена' для выхода)", tele.ModeHTML) @@ -676,26 +685,21 @@ func (bot *Bot) handleText(c tele.Context) error { password := text ctx := bot.fsm.GetContext(userID) msg, _ := bot.b.Send(c.Sender(), "⏳ Проверяю подключение...") - tempClient := bot.rmsFactory.CreateClientFromRawCredentials(ctx.TempURL, ctx.TempLogin, password) if err := tempClient.Auth(); err != nil { bot.b.Delete(msg) return c.Send(fmt.Sprintf("❌ Ошибка авторизации: %v\nПроверьте логин/пароль.", err)) } - var detectedName string info, err := rms.GetServerInfo(ctx.TempURL) if err == nil && info.ServerName != "" { detectedName = info.ServerName } - bot.b.Delete(msg) - bot.fsm.UpdateContext(userID, func(uCtx *UserContext) { uCtx.TempPassword = password uCtx.TempServerName = detectedName }) - if detectedName != "" { bot.fsm.SetState(userID, StateAddServerConfirmName) menu := &tele.ReplyMarkup{} @@ -704,7 +708,6 @@ func (bot *Bot) handleText(c tele.Context) error { menu.Inline(menu.Row(btnYes), menu.Row(btnNo)) return c.Send(fmt.Sprintf("🔎 Обнаружено имя сервера: %s.\nИспользовать его?", detectedName), menu, tele.ModeHTML) } - bot.fsm.SetState(userID, StateAddServerInputName) return c.Send("🏷 Введите название для этого сервера:") @@ -714,6 +717,19 @@ func (bot *Bot) handleText(c tele.Context) error { return c.Send("⚠️ Название слишком короткое.") } return bot.saveServerFinal(c, userID, name) + + case StateBillingGiftURL: + if !strings.HasPrefix(text, "http") { + return c.Send("❌ Некорректный URL. Он должен начинаться с http:// или https://") + } + _, err := bot.accountRepo.GetServerByURL(text) + if err != nil { + return c.Send("🔍 Сервер с таким URL не найден в системе. Попросите владельца сначала подключить его к боту.") + } + bot.fsm.UpdateContext(c.Sender().ID, func(ctx *UserContext) { + ctx.BillingTargetURL = text + }) + return bot.renderTariffShowcase(c, text) } return nil @@ -724,60 +740,48 @@ func (bot *Bot) handlePhoto(c tele.Context) error { if err != nil { return c.Send("Ошибка базы данных пользователей") } - _, err = bot.rmsFactory.GetClientForUser(userDB.ID) if err != nil { return c.Send("⛔ Ошибка доступа к iiko или сервер не выбран.\nПроверьте статус сервера в меню.") } - photo := c.Message().Photo file, err := bot.b.FileByID(photo.FileID) if err != nil { return c.Send("Ошибка доступа к файлу.") } - fileURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", bot.b.Token, file.FilePath) resp, err := http.Get(fileURL) if err != nil { return c.Send("Ошибка скачивания файла.") } defer resp.Body.Close() - imgData, err := io.ReadAll(resp.Body) if err != nil { return c.Send("Ошибка чтения файла.") } - c.Send("⏳ Обрабатываю чек: создаю черновик и распознаю...") - ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() - draft, err := bot.ocrService.ProcessReceiptImage(ctx, userDB.ID, imgData) if err != nil { logger.Log.Error("OCR processing failed", zap.Error(err)) return c.Send("❌ Ошибка обработки: " + err.Error()) } - matchedCount := 0 for _, item := range draft.Items { if item.IsMatched { matchedCount++ } } - baseURL := strings.TrimRight(bot.webAppURL, "/") fullURL := fmt.Sprintf("%s/invoice/%s", baseURL, draft.ID.String()) - var msgText string if matchedCount == len(draft.Items) { msgText = fmt.Sprintf("✅ Успех! Все позиции (%d) распознаны.\n\nПереходите к созданию накладной.", len(draft.Items)) } else { msgText = fmt.Sprintf("⚠️ Внимание! Распознано %d из %d позиций.\n\nНекоторые товары требуют ручного сопоставления.", matchedCount, len(draft.Items)) } - menu := &tele.ReplyMarkup{} - role, _ := bot.accountRepo.GetUserRole(userDB.ID, draft.RMSServerID) if role != account.RoleOperator { btnOpen := menu.WebApp("📝 Открыть накладную", &tele.WebApp{URL: fullURL}) @@ -785,7 +789,6 @@ func (bot *Bot) handlePhoto(c tele.Context) error { } else { msgText += "\n\n(Редактирование доступно Администратору)" } - return c.Send(msgText, menu, tele.ModeHTML) } @@ -807,25 +810,15 @@ func (bot *Bot) handleConfirmNameNo(c tele.Context) error { func (bot *Bot) saveServerFinal(c tele.Context, userID int64, serverName string) error { ctx := bot.fsm.GetContext(userID) userDB, _ := bot.accountRepo.GetOrCreateUser(userID, c.Sender().Username, "", "") - encPass, _ := bot.cryptoManager.Encrypt(ctx.TempPassword) - server, err := bot.accountRepo.ConnectServer(userDB.ID, ctx.TempURL, ctx.TempLogin, encPass, serverName) if err != nil { return c.Send("Ошибка подключения сервера: " + err.Error()) } - bot.fsm.Reset(userID) - role, _ := bot.accountRepo.GetUserRole(userDB.ID, server.ID) c.Send(fmt.Sprintf("✅ Сервер %s подключен!\nВаша роль: %s", server.Name, role), tele.ModeHTML) - - if role == account.RoleOwner { - go bot.syncService.SyncAllData(userDB.ID) - } else { - go bot.syncService.SyncAllData(userDB.ID) - } - + go bot.syncService.SyncAllData(userDB.ID) return bot.renderMainMenu(c) } @@ -835,26 +828,20 @@ func (bot *Bot) renderDeleteServerMenu(c tele.Context) error { if err != nil { return c.Send("Ошибка БД: " + err.Error()) } - if len(servers) == 0 { return c.Respond(&tele.CallbackResponse{Text: "Список серверов пуст"}) } - menu := &tele.ReplyMarkup{} var rows []tele.Row - for _, s := range servers { role, _ := bot.accountRepo.GetUserRole(userDB.ID, s.ID) - var label string if role == account.RoleOwner { label = fmt.Sprintf("❌ Удалить %s (Owner)", s.Name) } else { label = fmt.Sprintf("🚪 Покинуть %s", s.Name) } - btnAction := menu.Data(label, "do_del_server_"+s.ID.String()) - if role == account.RoleOwner || role == account.RoleAdmin { btnInvite := menu.Data(fmt.Sprintf("📩 Invite %s", s.Name), "gen_invite_"+s.ID.String()) rows = append(rows, menu.Row(btnAction, btnInvite)) @@ -862,12 +849,9 @@ func (bot *Bot) renderDeleteServerMenu(c tele.Context) error { rows = append(rows, menu.Row(btnAction)) } } - btnBack := menu.Data("🔙 Назад к списку", "nav_servers") rows = append(rows, menu.Row(btnBack)) - menu.Inline(rows...) - return c.EditOrSend("⚙️ Управление серверами\n\nЗдесь вы можете удалить сервер или пригласить сотрудников.", menu, tele.ModeHTML) } diff --git a/internal/transport/telegram/fsm.go b/internal/transport/telegram/fsm.go index 2c825d6..202945f 100644 --- a/internal/transport/telegram/fsm.go +++ b/internal/transport/telegram/fsm.go @@ -12,15 +12,17 @@ const ( StateAddServerPassword StateAddServerConfirmName StateAddServerInputName + StateBillingGiftURL ) // UserContext хранит временные данные в процессе диалога type UserContext struct { - State State - TempURL string - TempLogin string - TempPassword string - TempServerName string + State State + TempURL string + TempLogin string + TempPassword string + TempServerName string + BillingTargetURL string } // StateManager управляет состояниями