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:
@@ -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()})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user