feat(sms): implement SMS messaging and metered billing
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

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:
Tomas Dvorak
2026-05-10 11:40:53 +02:00
parent 164a37e997
commit 7d3e3448cf
28 changed files with 3633 additions and 3190 deletions
+141
View File
@@ -3,6 +3,7 @@ package api
import (
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
@@ -19,6 +20,7 @@ import (
"bookra/apps/backend/internal/domain"
"bookra/apps/backend/internal/httpx"
"bookra/apps/backend/internal/notifications"
"bookra/apps/backend/internal/sms"
"bookra/apps/backend/internal/tenancy"
"github.com/getsentry/sentry-go"
@@ -36,6 +38,7 @@ type Server struct {
adminService *admin.Service
billingService *billing.Service
notificationService *notifications.Service
smsService *sms.Service
}
func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
@@ -53,6 +56,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
catalogService := catalog.NewService(repository, billingService, notificationService)
authService := auth.NewService(repository, cfg.AuthJWTSecret)
adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey)
smsService := sms.NewService(cfg, repository)
publicRateLimiter := httpx.NewRateLimiter(rate.Every(time.Second), 5)
server := &Server{
@@ -64,6 +68,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
adminService: adminService,
billingService: billingService,
notificationService: notificationService,
smsService: smsService,
}
server.router.Use(gin.Logger(), gin.Recovery(), cors.New(cors.Config{
@@ -339,6 +344,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
LocationID *string `json:"locationId"`
CustomerName string `json:"customerName" binding:"required"`
CustomerEmail string `json:"customerEmail" binding:"required,email"`
CustomerPhone string `json:"customerPhone"`
Notes string `json:"notes"`
StartsAt string `json:"startsAt" binding:"required"`
EndsAt string `json:"endsAt" binding:"required"`
@@ -801,6 +807,141 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
c.JSON(http.StatusOK, response)
})
// ============================================
// SMS API
// ============================================
protected.GET("/sms/settings", func(c *gin.Context) {
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
settings, err := smsService.GetSettings(c.Request.Context(), membership.Tenant.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, settings)
})
protected.POST("/sms/settings", func(c *gin.Context) {
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
var request domain.UpdateSMSSettingsRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
settings, err := smsService.UpdateSettings(c.Request.Context(), membership.Tenant.ID, request)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, sms.ErrSMSPlanNotAllowed) {
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, settings)
})
protected.POST("/sms/send", func(c *gin.Context) {
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
var request domain.SendSMSRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := smsService.SendMessage(c.Request.Context(), membership.Tenant.ID, request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, sms.ErrSMSNotConfigured), errors.Is(err, sms.ErrSMSNotEnabled):
status = http.StatusServiceUnavailable
case errors.Is(err, sms.ErrSMSPlanNotAllowed):
status = http.StatusForbidden
case errors.Is(err, sms.ErrSMSLimitReached):
status = http.StatusTooManyRequests
case errors.Is(err, sms.ErrSMSInvalidPhone):
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.GET("/sms/usage", func(c *gin.Context) {
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
yearMonth := c.Query("month")
if yearMonth == "" {
now := time.Now().UTC()
yearMonth = fmt.Sprintf("%04d-%02d", now.Year(), now.Month())
}
report, err := smsService.GetUsage(c.Request.Context(), membership.Tenant.ID, yearMonth)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, report)
})
protected.GET("/sms/history", func(c *gin.Context) {
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
limit := 50
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 && l <= 100 {
limit = l
}
logs, err := smsService.GetUsageHistory(c.Request.Context(), membership.Tenant.ID, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"logs": logs})
})
protected.GET("/sms/invoices", func(c *gin.Context) {
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
limit := 12
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 && l <= 24 {
limit = l
}
reports, err := smsService.GetMonthlyReports(c.Request.Context(), membership.Tenant.ID, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"reports": reports})
})
// Internal job endpoint for SMS monthly invoicing
server.router.POST("/v1/internal/jobs/sms/invoices", func(c *gin.Context) {
if !authorizeJobRunner(c, cfg) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
yearMonth := c.Query("month")
response, err := smsService.GenerateMonthlyInvoices(c.Request.Context(), yearMonth, notificationService)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
server.router.POST("/v1/webhooks/paddle", func(c *gin.Context) {
if err := billingService.HandleWebhook(c.Request.Context(), c.Request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+3
View File
@@ -780,6 +780,7 @@ func entitlementsForPlan(planCode string) domain.PlanEntitlements {
WidgetEmbedding: true,
UmamiTracking: false,
APIAccess: false,
SMSAvailable: false,
}
case "business":
// Business: Unlimited everything, API access, dedicated manager
@@ -793,6 +794,7 @@ func entitlementsForPlan(planCode string) domain.PlanEntitlements {
UmamiTracking: true,
APIAccess: true,
DedicatedManager: true,
SMSAvailable: true,
}
default:
// Pro: 3 locations, 10 staff, unlimited bookings, email reminders, analytics
@@ -805,6 +807,7 @@ func entitlementsForPlan(planCode string) domain.PlanEntitlements {
WidgetEmbedding: true,
UmamiTracking: true,
APIAccess: false,
SMSAvailable: true,
}
}
}
@@ -52,7 +52,7 @@ func TestCreateCheckoutRequiresPaddleConfig(t *testing.T) {
response, err := service.CreateCheckoutSession(context.Background(), domain.Principal{
Subject: "demo-owner",
Email: "owner@bookra.dev",
}, "pro", "czk")
}, "pro", "czk", "monthly")
if err != ErrPaddleNotConfigured {
t.Fatalf("expected ErrPaddleNotConfigured, got response=%v err=%v", response, err)
}
@@ -64,7 +64,7 @@ func TestCreateCheckoutReturnsLaunchPayload(t *testing.T) {
response, err := service.CreateCheckoutSession(context.Background(), domain.Principal{
Subject: "demo-owner",
Email: "owner@bookra.dev",
}, "pro", "czk")
}, "pro", "czk", "monthly")
if err != nil {
t.Fatalf("create checkout: %v", err)
}
@@ -122,6 +122,7 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
customerName := strings.TrimSpace(request.CustomerName)
customerEmail := strings.TrimSpace(request.CustomerEmail)
customerPhone := strings.TrimSpace(request.CustomerPhone)
notes := strings.TrimSpace(request.Notes)
if len(customerName) < 2 || len(customerName) > 120 {
return domain.CreateBookingResponse{}, fmt.Errorf("%w: customerName must be between 2 and 120 characters", ErrInvalidBooking)
@@ -129,6 +130,9 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
if _, err := mail.ParseAddress(customerEmail); err != nil {
return domain.CreateBookingResponse{}, fmt.Errorf("%w: customerEmail must be valid", ErrInvalidBooking)
}
if customerPhone != "" && len(customerPhone) > 30 {
return domain.CreateBookingResponse{}, fmt.Errorf("%w: customerPhone must be at most 30 characters", ErrInvalidBooking)
}
if len(notes) > 1000 {
return domain.CreateBookingResponse{}, fmt.Errorf("%w: notes must be at most 1000 characters", ErrInvalidBooking)
}
@@ -194,6 +198,7 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
BookingMode: request.BookingMode,
CustomerName: customerName,
CustomerEmail: customerEmail,
CustomerPhone: customerPhone,
StartsAt: startsAt.UTC(),
EndsAt: endsAt.UTC(),
Status: status,
+88 -54
View File
@@ -10,64 +10,70 @@ import (
)
type Config struct {
Environment string
Port string
APIURL string
FrontendURL string
DatabaseURL string
DatabaseDirectURL string
NeonAuthURL string
AuthJWTSecret string
JobRunnerKey string
EmailFrom string
SMTPHost string
SMTPPort string
SMTPUsername string
SMTPPassword string
PaddleEnvironment string
PaddleAPIKey string
PaddleWebhookKey string
PaddlePriceMatrix map[string]map[string]string
StripeAPIKey string
StripeWebhookKey string
StripePriceMatrix map[string]map[string]string
AdminEmail string
AdminKey string
UmamiAPIURL string
UmamiAPIKey string
SentryDSN string
DemoMode bool
Environment string
Port string
APIURL string
FrontendURL string
DatabaseURL string
DatabaseDirectURL string
NeonAuthURL string
AuthJWTSecret string
JobRunnerKey string
EmailFrom string
SMTPHost string
SMTPPort string
SMTPUsername string
SMTPPassword string
PaddleEnvironment string
PaddleAPIKey string
PaddleWebhookKey string
PaddlePriceMatrix map[string]map[string]string
StripeAPIKey string
StripeWebhookKey string
StripePriceMatrix map[string]map[string]string
AdminEmail string
AdminKey string
UmamiAPIURL string
UmamiAPIKey string
SentryDSN string
DemoMode bool
SMSManagerAPIKey string
SMSManagerBaseURL string
StripeSMSPriceMatrix map[string]string // currency -> price ID (czk, usd, eur, gbp, ...)
}
func Load() (Config, error) {
cfg := Config{
Environment: valueOrDefault("BOOKRA_APP_ENV", "development"),
Port: valueOrDefault("BOOKRA_API_PORT", "8080"),
APIURL: valueOrDefault("BOOKRA_API_URL", "http://localhost:8080"),
FrontendURL: valueOrDefault("BOOKRA_FRONTEND_URL", "http://localhost:3000"),
DatabaseURL: strings.TrimSpace(os.Getenv("BOOKRA_DATABASE_URL")),
DatabaseDirectURL: strings.TrimSpace(os.Getenv("BOOKRA_DATABASE_DIRECT_URL")),
NeonAuthURL: strings.TrimSpace(os.Getenv("BOOKRA_NEON_AUTH_URL")),
AuthJWTSecret: strings.TrimSpace(os.Getenv("BOOKRA_AUTH_JWT_SECRET")),
JobRunnerKey: strings.TrimSpace(os.Getenv("BOOKRA_JOB_RUNNER_KEY")),
EmailFrom: valueOrDefault("BOOKRA_EMAIL_FROM", "noreply@bookra.dev"),
SMTPHost: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_HOST")),
SMTPPort: valueOrDefault("BOOKRA_SMTP_PORT", "587"),
SMTPUsername: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_USERNAME")),
SMTPPassword: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_PASSWORD")),
PaddleEnvironment: normalizePaddleEnvironment(os.Getenv("BOOKRA_PADDLE_ENV")),
PaddleAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_API_KEY")),
PaddleWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_WEBHOOK_SECRET")),
PaddlePriceMatrix: paddlePriceMatrixFromEnv(),
StripeAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_API_KEY")),
StripeWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_WEBHOOK_SECRET")),
StripePriceMatrix: stripePriceMatrixFromEnv(),
AdminEmail: strings.TrimSpace(os.Getenv("BOOKRA_ADMIN_EMAIL")),
AdminKey: strings.TrimSpace(os.Getenv("BOOKRA_ADMIN_KEY")),
UmamiAPIURL: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_URL")),
UmamiAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_KEY")),
SentryDSN: strings.TrimSpace(os.Getenv("BOOKRA_SENTRY_DSN")),
DemoMode: boolFromEnv("BOOKRA_DEMO_MODE", false),
Environment: valueOrDefault("BOOKRA_APP_ENV", "development"),
Port: valueOrDefault("BOOKRA_API_PORT", "8080"),
APIURL: valueOrDefault("BOOKRA_API_URL", "http://localhost:8080"),
FrontendURL: valueOrDefault("BOOKRA_FRONTEND_URL", "http://localhost:3000"),
DatabaseURL: strings.TrimSpace(os.Getenv("BOOKRA_DATABASE_URL")),
DatabaseDirectURL: strings.TrimSpace(os.Getenv("BOOKRA_DATABASE_DIRECT_URL")),
NeonAuthURL: strings.TrimSpace(os.Getenv("BOOKRA_NEON_AUTH_URL")),
AuthJWTSecret: strings.TrimSpace(os.Getenv("BOOKRA_AUTH_JWT_SECRET")),
JobRunnerKey: strings.TrimSpace(os.Getenv("BOOKRA_JOB_RUNNER_KEY")),
EmailFrom: valueOrDefault("BOOKRA_EMAIL_FROM", "noreply@bookra.dev"),
SMTPHost: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_HOST")),
SMTPPort: valueOrDefault("BOOKRA_SMTP_PORT", "587"),
SMTPUsername: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_USERNAME")),
SMTPPassword: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_PASSWORD")),
PaddleEnvironment: normalizePaddleEnvironment(os.Getenv("BOOKRA_PADDLE_ENV")),
PaddleAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_API_KEY")),
PaddleWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_WEBHOOK_SECRET")),
PaddlePriceMatrix: paddlePriceMatrixFromEnv(),
StripeAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_API_KEY")),
StripeWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_WEBHOOK_SECRET")),
StripePriceMatrix: stripePriceMatrixFromEnv(),
AdminEmail: strings.TrimSpace(os.Getenv("BOOKRA_ADMIN_EMAIL")),
AdminKey: strings.TrimSpace(os.Getenv("BOOKRA_ADMIN_KEY")),
UmamiAPIURL: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_URL")),
UmamiAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_KEY")),
SentryDSN: strings.TrimSpace(os.Getenv("BOOKRA_SENTRY_DSN")),
DemoMode: boolFromEnv("BOOKRA_DEMO_MODE", false),
SMSManagerAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_SMSMANAGER_API_KEY")),
SMSManagerBaseURL: valueOrDefault("BOOKRA_SMSMANAGER_BASE_URL", "https://api.smsmngr.com/v2"),
StripeSMSPriceMatrix: smsPriceMatrixFromEnv(),
}
if cfg.FrontendURL == "" {
@@ -158,6 +164,25 @@ func (cfg Config) BillingWebhookConfigured() bool {
return cfg.StripeWebhookConfigured() || cfg.PaddleWebhookConfigured()
}
func (cfg Config) SMSConfigured() bool {
return strings.TrimSpace(cfg.SMSManagerAPIKey) != ""
}
func (cfg Config) StripeSMSConfigured() bool {
return cfg.StripeConfigured() && cfg.StripeSMSPriceMatrix["czk"] != ""
}
func (cfg Config) StripeSMSPriceID(currency string) string {
c := strings.ToLower(strings.TrimSpace(currency))
if c == "" {
c = "czk"
}
if id := cfg.StripeSMSPriceMatrix[c]; id != "" {
return id
}
return cfg.StripeSMSPriceMatrix["czk"]
}
func paddlePriceMatrixFromEnv() map[string]map[string]string {
matrix := map[string]map[string]string{
"starter": {},
@@ -223,6 +248,15 @@ func boolFromEnv(key string, fallback bool) bool {
return value == "true" || value == "1" || value == "yes" || value == "on"
}
func smsPriceMatrixFromEnv() map[string]string {
matrix := map[string]string{}
for _, currency := range []string{"czk", "usd", "eur", "gbp", "pln"} {
upper := strings.ToUpper(currency)
matrix[currency] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_SMS_" + upper + "_PRICE_ID"))
}
return matrix
}
func uniqueStrings(values []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(values))
+7 -5
View File
@@ -1,14 +1,14 @@
package db
import (
"context"
"time"
"context"
"time"
)
func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
customer_name, customer_email, starts_at, ends_at, status, reference
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference
FROM bookings
WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2
ORDER BY starts_at ASC
@@ -30,6 +30,7 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID
&record.LocationID,
&record.CustomerName,
&record.CustomerEmail,
&record.CustomerPhone,
&record.StartsAt,
&record.EndsAt,
&record.Status,
@@ -47,10 +48,10 @@ func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingPa
err := r.pool.QueryRow(ctx, `
INSERT INTO bookings (
tenant_id, service_id, class_session_id, staff_id, location_id,
booking_mode, customer_name, customer_email, starts_at, ends_at,
booking_mode, customer_name, customer_email, customer_phone, starts_at, ends_at,
status, reference, notes
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
RETURNING id, reference, status
`,
params.TenantID,
@@ -61,6 +62,7 @@ func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingPa
params.BookingMode,
params.CustomerName,
params.CustomerEmail,
params.CustomerPhone,
params.StartsAt,
params.EndsAt,
params.Status,
+61 -40
View File
@@ -86,6 +86,18 @@ type Repository interface {
// Working Hours
ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error)
UpdateWorkingHours(ctx context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error
// SMS
GetTenantSMSSettings(ctx context.Context, tenantID string) (TenantSMSSettingsRecord, error)
UpsertTenantSMSSettings(ctx context.Context, params TenantSMSSettingsRecord) error
CreateSMSUsageLog(ctx context.Context, params SMSUsageLogRecord) (string, error)
GetSMSUsageThisMonth(ctx context.Context, tenantID string) (SMSUsageSummary, error)
GetSMSUsageForMonth(ctx context.Context, tenantID string, yearMonth string) (SMSMonthlyReportRecord, error)
ListSMSUsageLogs(ctx context.Context, tenantID string, limit int) ([]SMSUsageLogRecord, error)
ListSMSMonthlyReports(ctx context.Context, tenantID string, limit int) ([]SMSMonthlyReportRecord, error)
UpsertSMSMonthlyReport(ctx context.Context, params SMSMonthlyReportRecord) error
MarkSMSReportInvoiceSent(ctx context.Context, tenantID string, yearMonth string) error
ListTenantsWithSMSUsage(ctx context.Context, yearMonth string) ([]TenantRecord, error)
}
type TenantRecord struct {
@@ -124,12 +136,12 @@ type MagicLinkRecord struct {
}
type PlatformStats struct {
TotalTenants int64 `json:"totalTenants"`
TotalUsers int64 `json:"totalUsers"`
TotalTenants int64 `json:"totalTenants"`
TotalUsers int64 `json:"totalUsers"`
ActiveSubscriptions int64 `json:"activeSubscriptions"`
TrialSubscriptions int64 `json:"trialSubscriptions"`
BookingsThisMonth int64 `json:"bookingsThisMonth"`
RevenueThisMonth int64 `json:"revenueThisMonthCents"`
BookingsThisMonth int64 `json:"bookingsThisMonth"`
RevenueThisMonth int64 `json:"revenueThisMonthCents"`
}
type AdminAuditLogParams struct {
@@ -229,6 +241,7 @@ type BookingRecord struct {
LocationID *string
CustomerName string
CustomerEmail string
CustomerPhone string
StartsAt time.Time
EndsAt time.Time
Status string
@@ -244,6 +257,7 @@ type CreateBookingParams struct {
BookingMode string
CustomerName string
CustomerEmail string
CustomerPhone string
StartsAt time.Time
EndsAt time.Time
Status string
@@ -414,6 +428,44 @@ type UpdateWorkingHoursParams struct {
IsOpen *bool
}
// SMS Records
type TenantSMSSettingsRecord struct {
TenantID string
Enabled bool
SenderName string
MonthlyLimit int
StripeSubscriptionItemID string
}
type SMSUsageLogRecord struct {
ID string
TenantID string
RecipientPhone string
MessageBody string
ExternalMessageID string
ExternalRequestID string
Status string
CostCents int
SentAt time.Time
CreatedAt time.Time
}
type SMSUsageSummary struct {
MessageCount int
TotalCostCents int
}
type SMSMonthlyReportRecord struct {
ID string
TenantID string
YearMonth string
MessageCount int
TotalCostCents int
StripeInvoiceID string
InvoiceSentAt *time.Time
CreatedAt time.Time
}
type PGRepository struct {
pool *pgxpool.Pool
}
@@ -428,46 +480,14 @@ func NewRepository(pools *Pools, demoMode bool) Repository {
return NewMemoryRepository()
}
// ============================================
// LOCATION / ZONE METHODS - PG REPOSITORY (STUBS)
// ============================================
// ============================================
// BLOCKED DAYS METHODS - PG REPOSITORY (STUBS)
// ============================================
// ============================================
// CUSTOMER METHODS - PG REPOSITORY (STUBS)
// ============================================
@@ -572,11 +592,11 @@ func (r *PGRepository) GetBookingByReference(ctx context.Context, reference stri
var rec BookingRecord
err := r.pool.QueryRow(ctx, `
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
customer_name, customer_email, starts_at, ends_at, status, reference
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference
FROM bookings
WHERE reference = $1
`, reference).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID,
&rec.CustomerName, &rec.CustomerEmail, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference)
&rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference)
return rec, err
}
@@ -598,8 +618,6 @@ func (r *PGRepository) RescheduleBooking(ctx context.Context, bookingID string,
// WORKING HOURS METHODS - PG REPOSITORY (STUBS)
// ============================================
type MemoryRepository struct {
tenant TenantRecord
membership TenantMembershipRecord
@@ -617,6 +635,9 @@ type MemoryRepository struct {
blockedDays []BlockedDayRecord
customers []CustomerRecord
workingHours []WorkingHoursRecord
smsSettings TenantSMSSettingsRecord
smsLogs []SMSUsageLogRecord
smsReports []SMSMonthlyReportRecord
}
func NewMemoryRepository() *MemoryRepository {
+280
View File
@@ -0,0 +1,280 @@
package db
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// ============================================
// SMS SETTINGS - PG REPOSITORY
// ============================================
func (r *PGRepository) GetTenantSMSSettings(ctx context.Context, tenantID string) (TenantSMSSettingsRecord, error) {
var rec TenantSMSSettingsRecord
err := r.pool.QueryRow(ctx, `
SELECT tenant_id, enabled, COALESCE(sender_name, ''), COALESCE(monthly_limit, 0), COALESCE(stripe_subscription_item_id, '')
FROM tenant_sms_settings
WHERE tenant_id = $1
`, tenantID).Scan(&rec.TenantID, &rec.Enabled, &rec.SenderName, &rec.MonthlyLimit, &rec.StripeSubscriptionItemID)
if err != nil {
if err == pgx.ErrNoRows {
return TenantSMSSettingsRecord{TenantID: tenantID}, nil
}
return TenantSMSSettingsRecord{}, err
}
return rec, nil
}
func (r *PGRepository) UpsertTenantSMSSettings(ctx context.Context, params TenantSMSSettingsRecord) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO tenant_sms_settings (tenant_id, enabled, sender_name, monthly_limit, stripe_subscription_item_id, updated_at)
VALUES ($1, $2, $3, $4, $5, now())
ON CONFLICT (tenant_id) DO UPDATE SET
enabled = EXCLUDED.enabled,
sender_name = EXCLUDED.sender_name,
monthly_limit = EXCLUDED.monthly_limit,
stripe_subscription_item_id = EXCLUDED.stripe_subscription_item_id,
updated_at = now()
`, params.TenantID, params.Enabled, params.SenderName, params.MonthlyLimit, params.StripeSubscriptionItemID)
return err
}
// ============================================
// SMS USAGE LOGS - PG REPOSITORY
// ============================================
func (r *PGRepository) CreateSMSUsageLog(ctx context.Context, params SMSUsageLogRecord) (string, error) {
var id string
err := r.pool.QueryRow(ctx, `
INSERT INTO sms_usage_logs (tenant_id, recipient_phone, message_body, external_message_id, external_request_id, status, cost_cents, sent_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id
`, params.TenantID, params.RecipientPhone, params.MessageBody, params.ExternalMessageID, params.ExternalRequestID, params.Status, params.CostCents, params.SentAt).Scan(&id)
return id, err
}
func (r *PGRepository) GetSMSUsageThisMonth(ctx context.Context, tenantID string) (SMSUsageSummary, error) {
var summary SMSUsageSummary
err := r.pool.QueryRow(ctx, `
SELECT COALESCE(COUNT(*), 0), COALESCE(SUM(cost_cents), 0)
FROM sms_usage_logs
WHERE tenant_id = $1 AND date_trunc('month', created_at) = date_trunc('month', now())
`, tenantID).Scan(&summary.MessageCount, &summary.TotalCostCents)
return summary, err
}
func (r *PGRepository) GetSMSUsageForMonth(ctx context.Context, tenantID string, yearMonth string) (SMSMonthlyReportRecord, error) {
var rec SMSMonthlyReportRecord
err := r.pool.QueryRow(ctx, `
SELECT COALESCE(COUNT(*), 0), COALESCE(SUM(cost_cents), 0)
FROM sms_usage_logs
WHERE tenant_id = $1 AND to_char(created_at, 'YYYY-MM') = $2
`, tenantID, yearMonth).Scan(&rec.MessageCount, &rec.TotalCostCents)
if err != nil {
return SMSMonthlyReportRecord{}, err
}
rec.TenantID = tenantID
rec.YearMonth = yearMonth
return rec, nil
}
func (r *PGRepository) ListSMSUsageLogs(ctx context.Context, tenantID string, limit int) ([]SMSUsageLogRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, recipient_phone, message_body, external_message_id, external_request_id, status, cost_cents, created_at
FROM sms_usage_logs
WHERE tenant_id = $1
ORDER BY created_at DESC
LIMIT $2
`, tenantID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var records []SMSUsageLogRecord
for rows.Next() {
var rec SMSUsageLogRecord
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.RecipientPhone, &rec.MessageBody, &rec.ExternalMessageID, &rec.ExternalRequestID, &rec.Status, &rec.CostCents, &rec.CreatedAt); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (r *PGRepository) ListSMSMonthlyReports(ctx context.Context, tenantID string, limit int) ([]SMSMonthlyReportRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, year_month, message_count, total_cost_cents, stripe_invoice_id, invoice_sent_at, created_at
FROM sms_monthly_reports
WHERE tenant_id = $1
ORDER BY year_month DESC
LIMIT $2
`, tenantID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var records []SMSMonthlyReportRecord
for rows.Next() {
var rec SMSMonthlyReportRecord
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.YearMonth, &rec.MessageCount, &rec.TotalCostCents, &rec.StripeInvoiceID, &rec.InvoiceSentAt, &rec.CreatedAt); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (r *PGRepository) UpsertSMSMonthlyReport(ctx context.Context, params SMSMonthlyReportRecord) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO sms_monthly_reports (tenant_id, year_month, message_count, total_cost_cents, stripe_invoice_id)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (tenant_id, year_month) DO UPDATE SET
message_count = EXCLUDED.message_count,
total_cost_cents = EXCLUDED.total_cost_cents,
stripe_invoice_id = EXCLUDED.stripe_invoice_id,
created_at = now()
`, params.TenantID, params.YearMonth, params.MessageCount, params.TotalCostCents, params.StripeInvoiceID)
return err
}
func (r *PGRepository) MarkSMSReportInvoiceSent(ctx context.Context, tenantID string, yearMonth string) error {
_, err := r.pool.Exec(ctx, `
UPDATE sms_monthly_reports
SET invoice_sent_at = now()
WHERE tenant_id = $1 AND year_month = $2
`, tenantID, yearMonth)
return err
}
func (r *PGRepository) ListTenantsWithSMSUsage(ctx context.Context, yearMonth string) ([]TenantRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT DISTINCT t.id, t.slug, t.name, t.preset, t.locale, t.timezone, t.plan_code, t.subscription_status,
t.billing_provider, t.billing_customer_id, t.billing_subscription_id
FROM tenants t
JOIN tenant_sms_settings s ON s.tenant_id = t.id AND s.enabled = true
JOIN sms_usage_logs l ON l.tenant_id = t.id AND to_char(l.created_at, 'YYYY-MM') = $1
`, yearMonth)
if err != nil {
return nil, err
}
defer rows.Close()
var records []TenantRecord
for rows.Next() {
var rec TenantRecord
if err := rows.Scan(&rec.ID, &rec.Slug, &rec.Name, &rec.Preset, &rec.Locale, &rec.Timezone, &rec.PlanCode, &rec.SubscriptionStatus,
&rec.BillingProvider, &rec.BillingCustomerID, &rec.BillingSubscription); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
// ============================================
// SMS SETTINGS - MEMORY REPOSITORY
// ============================================
func (r *MemoryRepository) GetTenantSMSSettings(_ context.Context, tenantID string) (TenantSMSSettingsRecord, error) {
if tenantID != r.tenant.ID {
return TenantSMSSettingsRecord{}, pgx.ErrNoRows
}
return r.smsSettings, nil
}
func (r *MemoryRepository) UpsertTenantSMSSettings(_ context.Context, params TenantSMSSettingsRecord) error {
r.smsSettings = params
return nil
}
func (r *MemoryRepository) CreateSMSUsageLog(_ context.Context, params SMSUsageLogRecord) (string, error) {
params.ID = fmt.Sprintf("sms-%d", len(r.smsLogs))
params.CreatedAt = time.Now().UTC()
r.smsLogs = append([]SMSUsageLogRecord{params}, r.smsLogs...)
return params.ID, nil
}
func (r *MemoryRepository) GetSMSUsageThisMonth(_ context.Context, tenantID string) (SMSUsageSummary, error) {
if tenantID != r.tenant.ID {
return SMSUsageSummary{}, nil
}
now := time.Now().UTC()
var count, cost int
for _, log := range r.smsLogs {
if log.TenantID == tenantID && log.CreatedAt.Year() == now.Year() && log.CreatedAt.Month() == now.Month() {
count++
cost += log.CostCents
}
}
return SMSUsageSummary{MessageCount: count, TotalCostCents: cost}, nil
}
func (r *MemoryRepository) GetSMSUsageForMonth(_ context.Context, tenantID string, yearMonth string) (SMSMonthlyReportRecord, error) {
if tenantID != r.tenant.ID {
return SMSMonthlyReportRecord{}, nil
}
var count, cost int
for _, log := range r.smsLogs {
if log.TenantID == tenantID && log.CreatedAt.Format("2006-01") == yearMonth {
count++
cost += log.CostCents
}
}
return SMSMonthlyReportRecord{TenantID: tenantID, YearMonth: yearMonth, MessageCount: count, TotalCostCents: cost}, nil
}
func (r *MemoryRepository) ListSMSUsageLogs(_ context.Context, tenantID string, limit int) ([]SMSUsageLogRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
}
if limit > len(r.smsLogs) {
limit = len(r.smsLogs)
}
return r.smsLogs[:limit], nil
}
func (r *MemoryRepository) ListSMSMonthlyReports(_ context.Context, tenantID string, limit int) ([]SMSMonthlyReportRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
}
if limit > len(r.smsReports) {
limit = len(r.smsReports)
}
return r.smsReports[:limit], nil
}
func (r *MemoryRepository) UpsertSMSMonthlyReport(_ context.Context, params SMSMonthlyReportRecord) error {
for i, rep := range r.smsReports {
if rep.TenantID == params.TenantID && rep.YearMonth == params.YearMonth {
r.smsReports[i] = params
return nil
}
}
r.smsReports = append([]SMSMonthlyReportRecord{params}, r.smsReports...)
return nil
}
func (r *MemoryRepository) MarkSMSReportInvoiceSent(_ context.Context, tenantID string, yearMonth string) error {
now := time.Now().UTC()
for i, rep := range r.smsReports {
if rep.TenantID == tenantID && rep.YearMonth == yearMonth {
r.smsReports[i].InvoiceSentAt = &now
return nil
}
}
return nil
}
func (r *MemoryRepository) ListTenantsWithSMSUsage(_ context.Context, yearMonth string) ([]TenantRecord, error) {
for _, log := range r.smsLogs {
if log.TenantID == r.tenant.ID && log.CreatedAt.Format("2006-01") == yearMonth && r.smsSettings.Enabled {
return []TenantRecord{r.tenant}, nil
}
}
return nil, nil
}
+133 -76
View File
@@ -132,6 +132,7 @@ type CreateBookingRequest struct {
LocationID *string `json:"locationId,omitempty"`
CustomerName string `json:"customerName"`
CustomerEmail string `json:"customerEmail"`
CustomerPhone string `json:"customerPhone,omitempty"`
Notes string `json:"notes"`
StartsAt string `json:"startsAt"`
EndsAt string `json:"endsAt"`
@@ -146,31 +147,32 @@ type CreateBookingResponse struct {
type PlanEntitlements struct {
MaxLocations int `json:"maxLocations"`
MaxStaff int `json:"maxStaff"`
MaxBookingsMonth int `json:"maxBookingsMonth"` // -1 = unlimited
MaxBookingsMonth int `json:"maxBookingsMonth"` // -1 = unlimited
EmailReminders bool `json:"emailReminders"`
AdvancedReporting bool `json:"advancedReporting"`
WidgetEmbedding bool `json:"widgetEmbedding"`
UmamiTracking bool `json:"umamiTracking"`
APIAccess bool `json:"apiAccess"`
DedicatedManager bool `json:"dedicatedManager"`
SMSAvailable bool `json:"smsAvailable"`
}
type PlanPricing struct {
MonthlyAmountCents int `json:"monthlyAmountCents"`
YearlyAmountCents int `json:"yearlyAmountCents"`
MonthlyFormatted string `json:"monthlyFormatted"`
YearlyFormatted string `json:"yearlyFormatted"`
YearlySavings string `json:"yearlySavings"`
YearlySavingsPercent int `json:"yearlySavingsPercent"`
MonthlyAmountCents int `json:"monthlyAmountCents"`
YearlyAmountCents int `json:"yearlyAmountCents"`
MonthlyFormatted string `json:"monthlyFormatted"`
YearlyFormatted string `json:"yearlyFormatted"`
YearlySavings string `json:"yearlySavings"`
YearlySavingsPercent int `json:"yearlySavingsPercent"`
}
type PlanDisplayPrice struct {
Currency string `json:"currency"`
AmountCents int `json:"amountCents"`
Formatted string `json:"formatted"`
YearlyAmountCents int `json:"yearlyAmountCents,omitempty"`
YearlyFormatted string `json:"yearlyFormatted,omitempty"`
YearlySavings string `json:"yearlySavings,omitempty"`
Currency string `json:"currency"`
AmountCents int `json:"amountCents"`
Formatted string `json:"formatted"`
YearlyAmountCents int `json:"yearlyAmountCents,omitempty"`
YearlyFormatted string `json:"yearlyFormatted,omitempty"`
YearlySavings string `json:"yearlySavings,omitempty"`
YearlySavingsPercent int `json:"yearlySavingsPercent,omitempty"`
}
@@ -205,11 +207,11 @@ type CheckoutSessionRequest struct {
type CheckoutLaunchResponse struct {
// Stripe checkout
CheckoutURL string `json:"checkoutUrl,omitempty"`
CheckoutURL string `json:"checkoutUrl,omitempty"`
// Paddle checkout
PriceID string `json:"priceId,omitempty"`
CustomerID string `json:"customerId,omitempty"`
CustomerEmail string `json:"customerEmail,omitempty"`
PriceID string `json:"priceId,omitempty"`
CustomerID string `json:"customerId,omitempty"`
CustomerEmail string `json:"customerEmail,omitempty"`
// Common
SuccessRedirectURL string `json:"successRedirectUrl,omitempty"`
CancelRedirectURL string `json:"cancelRedirectUrl,omitempty"`
@@ -261,19 +263,19 @@ type UpdateLocationRequest struct {
// ============================================
type BlockedDay struct {
ID string `json:"id"`
TenantID string `json:"tenantId"`
Date time.Time `json:"date"`
Reason string `json:"reason"`
Type string `json:"type"` // full, partial
StaffID *string `json:"staffId,omitempty"`
ID string `json:"id"`
TenantID string `json:"tenantId"`
Date time.Time `json:"date"`
Reason string `json:"reason"`
Type string `json:"type"` // full, partial
StaffID *string `json:"staffId,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type CreateBlockedDayRequest struct {
Date string `json:"date" binding:"required"` // RFC3339
Reason string `json:"reason" binding:"required"`
Type string `json:"type" binding:"required"` // full, partial
Date string `json:"date" binding:"required"` // RFC3339
Reason string `json:"reason" binding:"required"`
Type string `json:"type" binding:"required"` // full, partial
StaffID *string `json:"staffId,omitempty"`
}
@@ -287,32 +289,32 @@ type UpdateBlockedDayRequest struct {
// ============================================
type Customer struct {
ID string `json:"id"`
TenantID string `json:"tenantId"`
Name string `json:"name"`
Email string `json:"email"`
Phone *string `json:"phone,omitempty"`
Status string `json:"status"` // active, inactive, vip
BookingsCount int `json:"bookingsCount"`
ID string `json:"id"`
TenantID string `json:"tenantId"`
Name string `json:"name"`
Email string `json:"email"`
Phone *string `json:"phone,omitempty"`
Status string `json:"status"` // active, inactive, vip
BookingsCount int `json:"bookingsCount"`
LastBookingAt *time.Time `json:"lastBookingAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
Notes string `json:"notes,omitempty"`
CreatedAt time.Time `json:"createdAt"`
Notes string `json:"notes,omitempty"`
}
type CreateCustomerRequest struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Phone *string `json:"phone,omitempty"`
Status string `json:"status,omitempty"` // defaults to active
Notes string `json:"notes,omitempty"`
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Phone *string `json:"phone,omitempty"`
Status string `json:"status,omitempty"` // defaults to active
Notes string `json:"notes,omitempty"`
}
type UpdateCustomerRequest struct {
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"`
Status string `json:"status,omitempty"`
Notes string `json:"notes,omitempty"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"`
Status string `json:"status,omitempty"`
Notes string `json:"notes,omitempty"`
}
// ============================================
@@ -333,9 +335,9 @@ type CustomerBookingView struct {
}
type RescheduleBookingRequest struct {
NewStartsAt string `json:"newStartsAt" binding:"required"` // RFC3339
NewEndsAt string `json:"newEndsAt" binding:"required"` // RFC3339
Reason string `json:"reason,omitempty"`
NewStartsAt string `json:"newStartsAt" binding:"required"` // RFC3339
NewEndsAt string `json:"newEndsAt" binding:"required"` // RFC3339
Reason string `json:"reason,omitempty"`
}
type CancelBookingRequest struct {
@@ -347,35 +349,35 @@ type CancelBookingRequest struct {
// ============================================
type AdminDashboardStats struct {
TotalTenants int64 `json:"totalTenants"`
TotalUsers int64 `json:"totalUsers"`
ActiveSubscriptions int64 `json:"activeSubscriptions"`
TrialSubscriptions int64 `json:"trialSubscriptions"`
BookingsThisMonth int64 `json:"bookingsThisMonth"`
TotalTenants int64 `json:"totalTenants"`
TotalUsers int64 `json:"totalUsers"`
ActiveSubscriptions int64 `json:"activeSubscriptions"`
TrialSubscriptions int64 `json:"trialSubscriptions"`
BookingsThisMonth int64 `json:"bookingsThisMonth"`
RevenueThisMonthCents int64 `json:"revenueThisMonthCents"`
}
type AdminTenantList struct {
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Tenants []AdminTenant `json:"tenants"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Tenants []AdminTenant `json:"tenants"`
}
type AdminTenant struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
PlanCode string `json:"planCode"`
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
PlanCode string `json:"planCode"`
SubscriptionStatus string `json:"subscriptionStatus"`
BillingProvider string `json:"billingProvider"`
BillingProvider string `json:"billingProvider"`
}
type AdminUserList struct {
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Users []AdminUser `json:"users"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Users []AdminUser `json:"users"`
}
type AdminUser struct {
@@ -421,7 +423,7 @@ type UpdateWorkingHoursRequest struct {
type EmailTemplate struct {
ID string `json:"id"`
TenantID string `json:"tenantId"`
Type string `json:"type"` // booking_confirmation, reminder, cancellation, etc.
Type string `json:"type"` // booking_confirmation, reminder, cancellation, etc.
Subject string `json:"subject"`
BodyHTML string `json:"bodyHtml"`
BodyText string `json:"bodyText"`
@@ -436,14 +438,69 @@ type SendEmailRequest struct {
}
type EmailNotification struct {
ID string `json:"id"`
TenantID string `json:"tenantId"`
BookingID string `json:"bookingId,omitempty"`
Channel string `json:"channel"` // email, sms
Type string `json:"type"` // confirmation, reminder, cancellation
Recipient string `json:"recipient"`
Status string `json:"status"` // pending, sent, failed
ID string `json:"id"`
TenantID string `json:"tenantId"`
BookingID string `json:"bookingId,omitempty"`
Channel string `json:"channel"` // email, sms
Type string `json:"type"` // confirmation, reminder, cancellation
Recipient string `json:"recipient"`
Status string `json:"status"` // pending, sent, failed
SentAt *time.Time `json:"sentAt,omitempty"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"createdAt"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
// ============================================
// SMS MODELS
// ============================================
type SMSSettings struct {
Enabled bool `json:"enabled"`
SenderName string `json:"senderName,omitempty"`
MonthlyLimit int `json:"monthlyLimit,omitempty"`
MessagesSent int `json:"messagesSent"`
TotalCostCents int `json:"totalCostCents"`
Available bool `json:"available"`
}
type UpdateSMSSettingsRequest struct {
Enabled bool `json:"enabled"`
SenderName string `json:"senderName,omitempty"`
MonthlyLimit int `json:"monthlyLimit,omitempty"`
}
type SendSMSRequest struct {
To string `json:"to" binding:"required"`
Body string `json:"body" binding:"required,max=1000"`
}
type SendSMSResponse struct {
LogID string `json:"logId"`
MessageID string `json:"messageId,omitempty"`
RequestID string `json:"requestId,omitempty"`
Status string `json:"status"`
CostCents int `json:"costCents"`
}
type SMSUsageLog struct {
ID string `json:"id"`
RecipientPhone string `json:"recipientPhone"`
MessageBody string `json:"messageBody,omitempty"`
Status string `json:"status"`
CostCents int `json:"costCents"`
CreatedAt time.Time `json:"createdAt"`
}
type SMSUsageReport struct {
YearMonth string `json:"yearMonth"`
MessageCount int `json:"messageCount"`
TotalCostCents int `json:"totalCostCents"`
StripeInvoiceID string `json:"stripeInvoiceId,omitempty"`
InvoiceSentAt *time.Time `json:"invoiceSentAt,omitempty"`
}
type SMSInvoiceBatchResponse struct {
YearMonth string `json:"yearMonth"`
ProcessedCount int `json:"processedCount"`
FailedCount int `json:"failedCount"`
}
@@ -458,3 +458,93 @@ func RenderReminderEmail(from string, job db.ReminderJobRecord) EmailMessage {
}
return RenderEmailMessage(data)
}
// ============================================
// SMS USAGE EMAIL TEMPLATE
// ============================================
type SMSUsageEmailData struct {
TenantName string
TenantSlug string
BusinessEmail string
YearMonth string
MessageCount int
TotalCostCents int
Locale string
}
func RenderSMSUsageEmail(data SMSUsageEmailData) EmailMessage {
cs := data.Locale == "cs"
year := data.YearMonth[:4]
month := data.YearMonth[5:]
monthLabel := month + "/" + year
if cs {
monthLabel = month + "." + year
}
totalFormatted := fmt.Sprintf("%.2f Kč", float64(data.TotalCostCents)/100.0)
subject := fmt.Sprintf("Bookra SMS Usage - %s (%s)", monthLabel, totalFormatted)
if cs {
subject = fmt.Sprintf("Bookra SMS Přehled - %s (%s)", monthLabel, totalFormatted)
}
textBody := fmt.Sprintf(
"SMS Usage Summary for %s\n\nPeriod: %s\nMessages sent: %d\nTotal cost: %s\n\nThis amount will be added to your next invoice.",
data.TenantName, monthLabel, data.MessageCount, totalFormatted,
)
if cs {
textBody = fmt.Sprintf(
"Přehled SMS pro %s\n\nObdobí: %s\nOdeslaných zpráv: %d\nCelková cena: %s\n\nTato částka bude přidána k vaší další faktuře.",
data.TenantName, monthLabel, data.MessageCount, totalFormatted,
)
}
htmlBody := fmt.Sprintf(`<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:600px;margin:0 auto;padding:20px;color:#1f2937;">
<div style="text-align:center;margin-bottom:24px;"><span style="font-size:32px;">📱</span></div>
<h2 style="text-align:center;color:#1f2937;margin-bottom:16px;">%s</h2>
<p style="color:#6b7280;text-align:center;margin-bottom:24px;">%s</p>
<table style="width:100%%;border-collapse:collapse;margin-bottom:24px;">
<tr style="border-bottom:1px solid #e5e7eb;">
<td style="padding:12px 0;color:#6b7280;">%s</td>
<td style="padding:12px 0;text-align:right;font-weight:600;">%s</td>
</tr>
<tr style="border-bottom:1px solid #e5e7eb;">
<td style="padding:12px 0;color:#6b7280;">%s</td>
<td style="padding:12px 0;text-align:right;font-weight:600;">%d</td>
</tr>
<tr>
<td style="padding:12px 0;color:#1f2937;font-weight:600;">%s</td>
<td style="padding:12px 0;text-align:right;font-weight:700;font-size:18px;">%s</td>
</tr>
</table>
<p style="color:#9ca3af;font-size:14px;text-align:center;">%s</p>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">
<p style="color:#9ca3af;font-size:12px;text-align:center;">Powered by <a href="https://bookra.eu" style="color:#4f46e5;">Bookra</a></p>
</body></html>`,
ifCS(cs, "SMS Usage Summary", "Přehled SMS využití"),
fmt.Sprintf(ifCS(cs, "Your SMS usage for %s", "Vaše SMS využití za %s"), data.TenantName),
ifCS(cs, "Period", "Období"), monthLabel,
ifCS(cs, "Messages sent", "Odeslaných zpráv"), data.MessageCount,
ifCS(cs, "Total cost (excl. VAT)", "Celková cena (bez DPH)"), totalFormatted,
ifCS(cs, "This amount will be added to your next Stripe invoice.", "Tato částka bude přidána k vaší další faktuře Stripe."),
)
return EmailMessage{
From: "",
To: data.BusinessEmail,
Subject: subject,
Text: textBody,
HTML: htmlBody,
}
}
func ifCS(cs bool, en, csText string) string {
if cs {
return csText
}
return en
}
@@ -334,6 +334,14 @@ func (s *Service) SendUsageWarning(ctx context.Context, tenantID string, locatio
return err
}
// SendRawEmail sends a pre-built email message
func (s *Service) SendRawEmail(ctx context.Context, msg EmailMessage) (DeliveryReceipt, error) {
if msg.From == "" {
msg.From = s.cfg.EmailFrom
}
return s.emailProvider.Send(ctx, msg)
}
func (s *Service) SendTrialEndingEmail(ctx context.Context, tenantID string, daysRemaining int) error {
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
if err != nil {
+80
View File
@@ -0,0 +1,80 @@
package sms
import (
"context"
"fmt"
"strings"
"bookra/apps/backend/internal/config"
"bookra/apps/backend/internal/db"
"github.com/stripe/stripe-go/v81"
"github.com/stripe/stripe-go/v81/invoiceitem"
)
type BillingService struct {
cfg config.Config
repo db.Repository
smsPriceIDs map[string]string // currency -> price ID
}
func NewBillingService(cfg config.Config, repo db.Repository) *BillingService {
return &BillingService{
cfg: cfg,
repo: repo,
smsPriceIDs: cfg.StripeSMSPriceMatrix,
}
}
// PriceIDForCurrency returns the Stripe price ID for SMS in the given currency
func (b *BillingService) PriceIDForCurrency(currency string) string {
c := strings.ToLower(strings.TrimSpace(currency))
if c == "" {
c = "czk"
}
if id := b.smsPriceIDs[c]; id != "" {
return id
}
// Fallback to CZK
return b.smsPriceIDs["czk"]
}
// CreateMonthlyInvoiceItem creates a Stripe InvoiceItem for the total SMS usage of a month.
// This adds a line item to the customer's next invoice — charging all messages together.
func (b *BillingService) CreateMonthlyInvoiceItem(ctx context.Context, customerID string, currency string, yearMonth string, messageCount int, totalCents int) (string, error) {
if customerID == "" {
return "", fmt.Errorf("customer id is empty")
}
priceID := b.PriceIDForCurrency(currency)
c := strings.ToLower(strings.TrimSpace(currency))
if c == "" {
c = "czk"
}
// If a Stripe price is configured, use Price + Quantity for a clean invoice line
if priceID != "" {
item, err := invoiceitem.New(&stripe.InvoiceItemParams{
Customer: stripe.String(customerID),
Price: stripe.String(priceID),
Quantity: stripe.Int64(int64(messageCount)),
Description: stripe.String(fmt.Sprintf("SMS Messages (%s) — %d messages", yearMonth, messageCount)),
})
if err != nil {
return "", fmt.Errorf("failed to create invoice item: %w", err)
}
return item.ID, nil
}
// Fallback: explicit amount (for dev/testing when no price configured yet)
item, err := invoiceitem.New(&stripe.InvoiceItemParams{
Customer: stripe.String(customerID),
Amount: stripe.Int64(int64(totalCents)),
Currency: stripe.String(c),
Description: stripe.String(fmt.Sprintf("SMS Messages (%s) — %d messages", yearMonth, messageCount)),
})
if err != nil {
return "", fmt.Errorf("failed to create invoice item: %w", err)
}
return item.ID, nil
}
+461
View File
@@ -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
}
+91
View File
@@ -0,0 +1,91 @@
package sms
import (
"context"
"testing"
"bookra/apps/backend/internal/config"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
)
func TestSMSServiceEnabled(t *testing.T) {
cfg := config.Config{SMSManagerAPIKey: "test-key"}
svc := NewService(cfg, db.NewMemoryRepository())
if !svc.Enabled() {
t.Fatal("expected SMS service to be enabled")
}
}
func TestSMSServiceDisabledWithoutKey(t *testing.T) {
cfg := config.Config{}
svc := NewService(cfg, db.NewMemoryRepository())
if svc.Enabled() {
t.Fatal("expected SMS service to be disabled")
}
}
func TestGetSettingsForNewTenant(t *testing.T) {
cfg := config.Config{SMSManagerAPIKey: "test-key"}
repo := db.NewMemoryRepository()
svc := NewService(cfg, repo)
ctx := context.Background()
// Use the default tenant ID from memory repository
settings, err := svc.GetSettings(ctx, "5d6b3551-0a3e-4b86-bdf0-e9df20a47148")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if settings.Enabled {
t.Fatal("expected SMS to be disabled by default")
}
if !settings.Available {
t.Fatal("expected SMS to be available when configured")
}
}
func TestUpdateSettingsRequiresProOrBusiness(t *testing.T) {
cfg := config.Config{SMSManagerAPIKey: "test-key"}
repo := db.NewMemoryRepository()
svc := NewService(cfg, repo)
ctx := context.Background()
// Memory repo tenant is "pro" by default. Change to starter by modifying tenant.
_, err := svc.UpdateSettings(ctx, "5d6b3551-0a3e-4b86-bdf0-e9df20a47148", domain.UpdateSMSSettingsRequest{Enabled: true})
// The memory repo tenant is "pro", so this should succeed
if err != nil {
t.Fatalf("unexpected error for pro tenant: %v", err)
}
}
func TestSendMessageRequiresEnabledSMS(t *testing.T) {
cfg := config.Config{SMSManagerAPIKey: "test-key"}
repo := db.NewMemoryRepository()
svc := NewService(cfg, repo)
ctx := context.Background()
_, err := svc.SendMessage(ctx, "5d6b3551-0a3e-4b86-bdf0-e9df20a47148", domain.SendSMSRequest{To: "+420777123456", Body: "Hello"})
if err != ErrSMSNotEnabled {
t.Fatalf("expected ErrSMSNotEnabled, got: %v", err)
}
}
func TestNormalizePhone(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"+420 777 123 456", "420777123456"},
{"420777123456", "420777123456"},
{"777123456", "420777123456"},
{" 777-123-456 ", "420777123456"},
{"+49 151 12345678", "4915112345678"},
}
for _, tc := range tests {
result := normalizePhone(tc.input)
if result != tc.expected {
t.Errorf("normalizePhone(%q) = %q, want %q", tc.input, result, tc.expected)
}
}
}
+4
View File
@@ -31,6 +31,10 @@ func NewService(repo db.Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) GetTenantMembership(ctx context.Context, principal domain.Principal) (db.TenantMembershipRecord, error) {
return s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
}
func (s *Service) Bootstrap(ctx context.Context, principal domain.Principal) (domain.TenantBootstrap, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
if err != nil {
+47
View File
@@ -0,0 +1,47 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS tenant_sms_settings (
tenant_id uuid PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
enabled boolean NOT NULL DEFAULT false,
sender_name text NOT NULL DEFAULT '',
monthly_limit integer,
stripe_subscription_item_id text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS sms_usage_logs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
recipient_phone text NOT NULL,
message_body text NOT NULL,
external_message_id text,
external_request_id text,
status text NOT NULL DEFAULT 'pending',
cost_cents integer NOT NULL DEFAULT 150,
sent_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS sms_monthly_reports (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
year_month text NOT NULL,
message_count integer NOT NULL DEFAULT 0,
total_cost_cents integer NOT NULL DEFAULT 0,
stripe_invoice_id text,
invoice_sent_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, year_month)
);
CREATE INDEX IF NOT EXISTS idx_sms_usage_tenant_month ON sms_usage_logs (tenant_id, date_trunc('month', created_at));
CREATE INDEX IF NOT EXISTS idx_sms_usage_tenant_created ON sms_usage_logs (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_sms_reports_tenant ON sms_monthly_reports (tenant_id, year_month DESC);
-- +goose Down
DROP INDEX IF EXISTS idx_sms_reports_tenant;
DROP INDEX IF EXISTS idx_sms_usage_tenant_created;
DROP INDEX IF EXISTS idx_sms_usage_tenant_month;
DROP TABLE IF EXISTS sms_monthly_reports;
DROP TABLE IF EXISTS sms_usage_logs;
DROP TABLE IF EXISTS tenant_sms_settings;
@@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS customer_phone text;
-- +goose Down
ALTER TABLE bookings DROP COLUMN IF EXISTS customer_phone;