package sms import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "time" "bookra/apps/backend/internal/config" "bookra/apps/backend/internal/db" "bookra/apps/backend/internal/domain" "bookra/apps/backend/internal/notifications" "bookra/apps/backend/internal/shared" ) var ( ErrSMSNotConfigured = errors.New("sms is not configured") ErrSMSNotEnabled = errors.New("sms is not enabled for this tenant") ErrSMSPlanNotAllowed = errors.New("sms is only available on pro and business plans") ErrSMSLimitReached = errors.New("monthly sms limit reached") ErrSMSInvalidPhone = errors.New("invalid phone number") ErrSMSMissingAPIKey = errors.New("sms manager api key is missing") ErrSMSSendFailed = errors.New("sms send failed") ErrStripeNotConfigured = errors.New("stripe is not configured for sms billing") ErrNoActiveSubscription = errors.New("no active subscription for sms billing") ) const smsCostCents = 150 // 1.50 CZK type Service struct { cfg config.Config repo db.Repository billing *BillingService client *http.Client baseURL string apiKey string } func NewService(cfg config.Config, repo db.Repository) *Service { s := &Service{ cfg: cfg, repo: repo, client: &http.Client{Timeout: 15 * time.Second}, baseURL: strings.TrimRight(cfg.SMSManagerBaseURL, "/"), apiKey: cfg.SMSManagerAPIKey, } if cfg.StripeConfigured() && cfg.StripeSMSConfigured() { s.billing = NewBillingService(cfg, repo) } return s } func (s *Service) Enabled() bool { return s.cfg.SMSConfigured() } func (s *Service) IsAvailable(ctx context.Context, tenantID string) (bool, error) { if !s.Enabled() { return false, nil } settings, err := s.repo.GetTenantSMSSettings(ctx, tenantID) if err != nil { return false, err } return settings.Enabled, nil } func (s *Service) canUseSMS(ctx context.Context, tenantID string) error { if !s.Enabled() { return ErrSMSNotConfigured } tenant, err := s.repo.GetTenantByID(ctx, tenantID) if err != nil { return err } plan := shared.NormalizePlanCode(tenant.PlanCode) if plan != "pro" && plan != "business" { return ErrSMSPlanNotAllowed } if tenant.SubscriptionStatus != "active" && tenant.SubscriptionStatus != "trialing" { return ErrNoActiveSubscription } settings, err := s.repo.GetTenantSMSSettings(ctx, tenantID) if err != nil { return err } if !settings.Enabled { return ErrSMSNotEnabled } return nil } func (s *Service) GetSettings(ctx context.Context, tenantID string) (domain.SMSSettings, error) { settings, err := s.repo.GetTenantSMSSettings(ctx, tenantID) if err != nil { return domain.SMSSettings{}, err } usage, err := s.repo.GetSMSUsageThisMonth(ctx, tenantID) if err != nil { return domain.SMSSettings{}, err } return domain.SMSSettings{ Enabled: settings.Enabled, SenderName: settings.SenderName, MonthlyLimit: settings.MonthlyLimit, MessagesSent: usage.MessageCount, TotalCostCents: usage.TotalCostCents, Available: s.Enabled(), }, nil } func (s *Service) UpdateSettings(ctx context.Context, tenantID string, req domain.UpdateSMSSettingsRequest) (domain.SMSSettings, error) { tenant, err := s.repo.GetTenantByID(ctx, tenantID) if err != nil { return domain.SMSSettings{}, err } plan := shared.NormalizePlanCode(tenant.PlanCode) if plan != "pro" && plan != "business" { return domain.SMSSettings{}, ErrSMSPlanNotAllowed } if tenant.SubscriptionStatus != "active" && tenant.SubscriptionStatus != "trialing" { return domain.SMSSettings{}, ErrNoActiveSubscription } if err := s.repo.UpsertTenantSMSSettings(ctx, db.TenantSMSSettingsRecord{ TenantID: tenantID, Enabled: req.Enabled, SenderName: strings.TrimSpace(req.SenderName), MonthlyLimit: req.MonthlyLimit, }); err != nil { return domain.SMSSettings{}, err } return s.GetSettings(ctx, tenantID) } func (s *Service) SendMessage(ctx context.Context, tenantID string, req domain.SendSMSRequest) (domain.SendSMSResponse, error) { if err := s.canUseSMS(ctx, tenantID); err != nil { return domain.SendSMSResponse{}, err } phone := normalizePhone(req.To) if phone == "" { return domain.SendSMSResponse{}, ErrSMSInvalidPhone } // Check monthly limit settings, err := s.repo.GetTenantSMSSettings(ctx, tenantID) if err != nil { return domain.SendSMSResponse{}, err } if settings.MonthlyLimit > 0 { usage, err := s.repo.GetSMSUsageThisMonth(ctx, tenantID) if err != nil { return domain.SendSMSResponse{}, err } if usage.MessageCount >= settings.MonthlyLimit { return domain.SendSMSResponse{}, ErrSMSLimitReached } } // Send via SMS Manager resp, err := s.sendToSMSManager(ctx, phone, req.Body, settings.SenderName) if err != nil { return domain.SendSMSResponse{}, err } // Extract message ID from accepted recipients messageID := "" if len(resp.Accepted) > 0 { messageID = resp.Accepted[0].MessageID } // Log usage locally (Stripe billing happens once at month-end) logID, err := s.repo.CreateSMSUsageLog(ctx, db.SMSUsageLogRecord{ TenantID: tenantID, RecipientPhone: phone, MessageBody: req.Body, ExternalMessageID: messageID, ExternalRequestID: resp.RequestID, Status: "sent", CostCents: smsCostCents, SentAt: time.Now().UTC(), }) if err != nil { return domain.SendSMSResponse{}, fmt.Errorf("failed to log sms usage: %w", err) } return domain.SendSMSResponse{ LogID: logID, MessageID: messageID, RequestID: resp.RequestID, Status: "sent", CostCents: smsCostCents, }, nil } func (s *Service) GetUsage(ctx context.Context, tenantID string, yearMonth string) (domain.SMSUsageReport, error) { report, err := s.repo.GetSMSUsageForMonth(ctx, tenantID, yearMonth) if err != nil { return domain.SMSUsageReport{}, err } return domain.SMSUsageReport{ YearMonth: report.YearMonth, MessageCount: report.MessageCount, TotalCostCents: report.TotalCostCents, }, nil } func (s *Service) GetUsageHistory(ctx context.Context, tenantID string, limit int) ([]domain.SMSUsageLog, error) { if limit <= 0 { limit = 50 } records, err := s.repo.ListSMSUsageLogs(ctx, tenantID, limit) if err != nil { return nil, err } logs := make([]domain.SMSUsageLog, len(records)) for i, r := range records { logs[i] = domain.SMSUsageLog{ ID: r.ID, RecipientPhone: r.RecipientPhone, MessageBody: r.MessageBody, Status: r.Status, CostCents: r.CostCents, CreatedAt: r.CreatedAt, } } return logs, nil } func (s *Service) GetMonthlyReports(ctx context.Context, tenantID string, limit int) ([]domain.SMSUsageReport, error) { if limit <= 0 { limit = 12 } records, err := s.repo.ListSMSMonthlyReports(ctx, tenantID, limit) if err != nil { return nil, err } reports := make([]domain.SMSUsageReport, len(records)) for i, r := range records { reports[i] = domain.SMSUsageReport{ YearMonth: r.YearMonth, MessageCount: r.MessageCount, TotalCostCents: r.TotalCostCents, StripeInvoiceID: r.StripeInvoiceID, InvoiceSentAt: r.InvoiceSentAt, } } return reports, nil } // GenerateMonthlyInvoices creates/finalizes monthly invoices for SMS usage func (s *Service) GenerateMonthlyInvoices(ctx context.Context, yearMonth string, notificationSvc *notifications.Service) (domain.SMSInvoiceBatchResponse, error) { if yearMonth == "" { now := time.Now().UTC() yearMonth = fmt.Sprintf("%04d-%02d", now.Year(), now.Month()) } response := domain.SMSInvoiceBatchResponse{YearMonth: yearMonth} // Get all tenants with SMS enabled that have usage this month tenants, err := s.repo.ListTenantsWithSMSUsage(ctx, yearMonth) if err != nil { return response, err } for _, t := range tenants { report, err := s.repo.GetSMSUsageForMonth(ctx, t.ID, yearMonth) if err != nil { response.FailedCount++ continue } if report.MessageCount == 0 { continue } // Create Stripe InvoiceItem for total monthly SMS usage // This adds one line to the customer's next invoice: all messages together stripeInvoiceItemID := "" if s.billing != nil { customerID := "" if t.BillingCustomerID != nil { customerID = *t.BillingCustomerID } if customerID != "" { currency := "czk" if t.BillingProvider != "" { // Try to infer currency from billing snapshot if available snap, _ := s.repo.GetSubscriptionSnapshot(ctx, t.ID) if snap.Currency != "" { currency = snap.Currency } } itemID, err := s.billing.CreateMonthlyInvoiceItem(ctx, customerID, currency, yearMonth, report.MessageCount, report.TotalCostCents) if err == nil { stripeInvoiceItemID = itemID } } } if err := s.repo.UpsertSMSMonthlyReport(ctx, db.SMSMonthlyReportRecord{ TenantID: t.ID, YearMonth: yearMonth, MessageCount: report.MessageCount, TotalCostCents: report.TotalCostCents, StripeInvoiceID: stripeInvoiceItemID, }); err != nil { response.FailedCount++ continue } // Send usage summary email if notificationSvc != nil { _ = s.sendUsageSummaryEmail(ctx, t, report, notificationSvc) } response.ProcessedCount++ } return response, nil } func (s *Service) sendUsageSummaryEmail(ctx context.Context, tenant db.TenantRecord, report db.SMSMonthlyReportRecord, svc *notifications.Service) error { // Get brand profile for email styling brand, _ := s.repo.GetBrandProfile(ctx, tenant.ID) data := notifications.SMSUsageEmailData{ TenantName: brand.Name, TenantSlug: tenant.Slug, BusinessEmail: "", YearMonth: report.YearMonth, MessageCount: report.MessageCount, TotalCostCents: report.TotalCostCents, Locale: tenant.Locale, } // Find owner email membership, err := s.repo.GetTenantMembershipByUserID(ctx, tenant.ID) if err == nil { user, err := s.repo.GetUserByID(ctx, membership.UserID) if err == nil { data.BusinessEmail = user.Email } } if data.BusinessEmail == "" { return errors.New("no business email found") } msg := notifications.RenderSMSUsageEmail(data) _, err = svc.SendRawEmail(ctx, msg) if err == nil { _ = s.repo.MarkSMSReportInvoiceSent(ctx, report.TenantID, report.YearMonth) } return err } // --- SMS Manager API client --- type smsManagerMessage struct { Body string `json:"body"` To []struct { PhoneNumber string `json:"phone_number"` } `json:"to"` Tag string `json:"tag,omitempty"` } type smsManagerResponse struct { RequestID string `json:"request_id"` Accepted []struct { Key string `json:"key"` MessageID string `json:"message_id"` } `json:"accepted"` Rejected []struct { Key string `json:"key"` Message string `json:"message,omitempty"` } `json:"rejected"` } func (s *Service) sendToSMSManager(ctx context.Context, phone, body, senderName string) (smsManagerResponse, error) { if s.apiKey == "" { return smsManagerResponse{}, ErrSMSMissingAPIKey } payload := smsManagerMessage{ Body: body, To: []struct { PhoneNumber string `json:"phone_number"` }{{PhoneNumber: phone}}, Tag: "transactional", } jsonBody, err := json.Marshal(payload) if err != nil { return smsManagerResponse{}, err } req, err := http.NewRequestWithContext(ctx, "POST", s.baseURL+"/message", bytes.NewReader(jsonBody)) if err != nil { return smsManagerResponse{}, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("x-api-key", s.apiKey) resp, err := s.client.Do(req) if err != nil { return smsManagerResponse{}, err } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return smsManagerResponse{}, err } if resp.StatusCode != http.StatusOK { return smsManagerResponse{}, fmt.Errorf("%w: status=%d body=%s", ErrSMSSendFailed, resp.StatusCode, string(respBody)) } var result smsManagerResponse if err := json.Unmarshal(respBody, &result); err != nil { return smsManagerResponse{}, err } if len(result.Rejected) > 0 && len(result.Accepted) == 0 { return result, fmt.Errorf("%w: %v", ErrSMSSendFailed, result.Rejected) } return result, nil } func normalizePhone(phone string) string { p := strings.TrimSpace(phone) p = strings.ReplaceAll(p, " ", "") p = strings.ReplaceAll(p, "-", "") p = strings.TrimPrefix(p, "+") p = strings.TrimPrefix(p, "00") // Czech default if no country code and 9 digits if len(p) == 9 { p = "420" + p } return p }