mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-04 04:22:59 +00:00
7d3e3448cf
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.
462 lines
12 KiB
Go
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
|
|
}
|