Files
Bookra/apps/backend/internal/sms/service.go
T
Tomas Dvorak 7d3e3448cf
CI / Frontend (push) Successful in 9m50s
CI / Go - apps/auth-service (push) Failing after 4s
CI / Go - apps/backend (push) Successful in 10m18s
CI / Docker publish - auth-service (push) Has been skipped
CI / Docker publish - backend (push) Has been skipped
feat(sms): implement SMS messaging and metered billing
Implement a complete SMS messaging system including:
- Integration with SMS Manager.cz API for sending messages.
- Metered billing via Stripe using monthly aggregate invoice items.
- Backend services for managing SMS settings, usage logging, and monthly reporting.
- Database migrations for tenant settings, usage logs, and monthly reports.
- Frontend dashboard components for SMS configuration, usage tracking, and history.
- Support for customer phone numbers in the booking flow.

Includes new migrations, backend services, and frontend UI components.
2026-05-10 11:40:53 +02:00

462 lines
12 KiB
Go

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
}