mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-03 20:13:00 +00:00
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.
This commit is contained in:
@@ -0,0 +1,461 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user