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;
@@ -0,0 +1,239 @@
import { createSignal, createMemo, Show, For } from "solid-js";
import { useI18n } from "../../providers/i18n-provider";
import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "./icons";
export function CalendarView(props: { bookings: any[]; locale?: string; onBookingClick?: (b: any) => void }) {
const i18n = useI18n();
const [currentDate, setCurrentDate] = createSignal(new Date());
const [selectedDay, setSelectedDay] = createSignal<number | null>(null);
const [modalDay, setModalDay] = createSignal<number | null>(null);
const [isAnimating, setIsAnimating] = createSignal(false);
const isCs = () => props.locale === "cs";
const weekDays = isCs()
? ["Po", "Út", "St", "Čt", "Pá", "So", "Ne"]
: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const monthYear = createMemo(() =>
new Intl.DateTimeFormat(isCs() ? "cs-CZ" : "en-US", { month: "long", year: "numeric" }).format(currentDate())
);
const calendarDays = createMemo(() => {
const date = currentDate();
const year = date.getFullYear();
const month = date.getMonth();
const firstDayOfMonth = new Date(year, month, 1).getDay();
// Adjust so Monday = 0
const firstDay = (firstDayOfMonth + 6) % 7;
const daysInMonth = new Date(year, month + 1, 0).getDate();
const days: Array<{ day: number | null; bookings: any[]; isToday: boolean }> = [];
for (let i = 0; i < firstDay; i++) days.push({ day: null, bookings: [], isToday: false });
const today = new Date();
for (let day = 1; day <= daysInMonth; day++) {
const dayBookings = props.bookings.filter((b) => {
const bd = new Date(b.startsAt);
return bd.getDate() === day && bd.getMonth() === month && bd.getFullYear() === year;
});
const isToday = today.getDate() === day && today.getMonth() === month && today.getFullYear() === year;
days.push({ day, bookings: dayBookings, isToday });
}
return days;
});
const changeMonth = (direction: number) => {
setIsAnimating(true);
setTimeout(() => {
setCurrentDate(new Date(currentDate().getFullYear(), currentDate().getMonth() + direction, 1));
setIsAnimating(false);
setSelectedDay(null);
setModalDay(null);
}, 150);
};
const handleDayClick = (day: number | null) => {
if (!day) return;
const dayData = calendarDays().find((d) => d.day === day);
if (dayData && dayData.bookings.length > 0) {
setModalDay(day);
} else {
setSelectedDay(selectedDay() === day ? null : day);
}
};
const handleBookingClick = (booking: any) => {
props.onBookingClick?.(booking);
setModalDay(null);
};
const modalBookings = createMemo(() => {
const day = modalDay();
if (!day) return [];
return calendarDays().find((d) => d.day === day)?.bookings ?? [];
});
return (
<>
<div class="surface-card p-6 shadow-sm hover:shadow-md transition-all duration-500">
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-lg font-semibold text-ink">{monthYear()}</h3>
<p class="text-sm text-ink-muted mt-0.5">
{props.bookings.length} {isCs() ? i18n.t("dashboard.calendar.bookingsCount") : "bookings"}
</p>
</div>
<div class="flex gap-1">
<button
onClick={() => changeMonth(-1)}
aria-label={i18n.t("dashboard.prevMonth")}
class="p-2 hover:bg-canvas-subtle rounded-xl transition-all hover:scale-105 active:scale-95"
>
<ChevronLeftIcon />
</button>
<button
onClick={() => changeMonth(1)}
aria-label={i18n.t("dashboard.nextMonth")}
class="p-2 hover:bg-canvas-subtle rounded-xl transition-all hover:scale-105 active:scale-95"
>
<ChevronRightIcon />
</button>
</div>
</div>
<div class="grid grid-cols-7 gap-1 mb-2">
{weekDays.map((day) => (
<div class="text-center text-[10px] sm:text-xs font-semibold text-ink-subtle uppercase tracking-wider py-2">
{day}
</div>
))}
</div>
<div class={`grid grid-cols-7 gap-1 transition-opacity duration-200 ${isAnimating() ? "opacity-0" : "opacity-100"}`}>
{calendarDays().map(({ day, bookings, isToday }, index) => (
<button
onClick={() => handleDayClick(day)}
class={`
aspect-square p-1 sm:p-1.5 rounded-xl border transition-all duration-200 relative overflow-hidden
${day ? "bg-canvas border-border/60 hover:border-accent/40 hover:shadow-sm" : "bg-transparent border-transparent pointer-events-none"}
${isToday ? "ring-1 ring-accent bg-accent-subtle/40" : ""}
${selectedDay() === day ? "ring-2 ring-accent ring-offset-1 ring-offset-canvas" : ""}
${bookings.length > 0 && !isToday ? "bg-accent-subtle/20" : ""}
`}
style={{ "animation-delay": `${index * 15}ms` }}
disabled={!day}
>
{day && (
<>
<span class={`text-xs sm:text-sm font-medium ${isToday ? "text-accent" : "text-ink"}`}>{day}</span>
{bookings.length > 0 && (
<div class="absolute bottom-1 left-1 right-1 flex gap-0.5 justify-center">
{bookings.slice(0, 3).map((b: any) => (
<span
class={`w-1 h-1 rounded-full ${
b.status === "confirmed" ? "bg-ink/60" : b.status === "pending" ? "bg-ink/40" : "bg-ink/20"
}`}
/>
))}
{bookings.length > 3 && (
<span class="text-[7px] font-bold text-ink/50 leading-none">+</span>
)}
</div>
)}
</>
)}
</button>
))}
</div>
{/* Inline selected day preview for empty days */}
<Show when={selectedDay() && !modalDay()}>
<div class="mt-4 p-4 bg-canvas-subtle/50 rounded-xl border border-border/60 animate-fade-in">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-ink">
{selectedDay()}. {monthYear()}
</span>
<button onClick={() => setSelectedDay(null)} class="text-xs text-ink-muted hover:text-ink">
{i18n.t("dashboard.close")}
</button>
</div>
<p class="text-sm text-ink-muted">{i18n.t("dashboard.calendar.noBookings")}</p>
</div>
</Show>
</div>
{/* Day bookings modal */}
<Show when={modalDay()}>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-ink/40 backdrop-blur-sm" onClick={() => setModalDay(null)} />
<div class="relative bg-canvas rounded-2xl shadow-2xl w-full max-w-md max-h-[80vh] overflow-hidden animate-scale-in">
<div class="p-5 border-b border-border flex items-center justify-between">
<div>
<h3 class="text-lg font-bold text-ink">
{modalDay()}. {monthYear()}
</h3>
<p class="text-xs text-ink-muted mt-0.5">
{modalBookings().length} {isCs() ? "rezervací" : "bookings"}
</p>
</div>
<button
onClick={() => setModalDay(null)}
class="p-2 hover:bg-canvas-subtle rounded-lg transition-colors"
aria-label={i18n.t("dashboard.close")}
>
<XIcon />
</button>
</div>
<div class="p-5 overflow-y-auto max-h-[60vh] space-y-3">
<For each={modalBookings()}>
{(booking) => (
<button
onClick={() => handleBookingClick(booking)}
class="w-full text-left flex items-center gap-3 p-3 rounded-xl border border-border/60 hover:border-accent/30 hover:bg-accent-subtle/20 transition-all"
>
<div
class={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold shrink-0 ${
booking.status === "confirmed"
? "bg-accent text-canvas"
: booking.status === "pending"
? "bg-canvas-muted text-ink"
: "bg-canvas-subtle text-ink-muted"
}`}
>
{(booking.customerName ?? "?")
.split(" ")
.map((n: string) => n[0])
.join("")
.slice(0, 2)
.toUpperCase()}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-ink truncate">{booking.customerName}</p>
<p class="text-xs text-ink-muted">{booking.service}</p>
</div>
<div class="text-right shrink-0">
<p class="text-xs font-medium text-ink">
{new Date(booking.startsAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</p>
<span
class={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
booking.status === "confirmed"
? "bg-accent/10 text-accent"
: booking.status === "pending"
? "bg-canvas-muted text-ink-muted"
: "bg-canvas-subtle text-ink-subtle"
}`}
>
{i18n.t(`dashboard.${booking.status}`)}
</span>
</div>
</button>
)}
</For>
</div>
</div>
</div>
</Show>
</>
);
}
@@ -176,3 +176,43 @@ export const MailIcon = () => (
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
);
export const EyeIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>
</svg>
);
export const EditIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/>
</svg>
);
export const Trash2Icon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>
</svg>
);
export const XCircleIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>
</svg>
);
export const MoreHorizontalIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>
);
export const BarChart3Icon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg>
);
export const CheckIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
);
export const GlobeIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>
);
@@ -0,0 +1,166 @@
import { createSignal, createMemo, Show, For } from "solid-js";
import { useI18n } from "../../providers/i18n-provider";
import { BellIcon } from "./icons";
interface NotificationItem {
id: string;
type: "booking" | "reminder" | "upgrade" | "trial" | "system";
title: string;
message: string;
time: string;
read: boolean;
}
export function NotificationDropdown(props: { bookings?: any[]; billing?: any }) {
const i18n = useI18n();
const [open, setOpen] = createSignal(false);
const notifications = createMemo<NotificationItem[]>(() => {
const items: NotificationItem[] = [];
const cs = i18n.locale() === "cs";
const bookings = props.bookings ?? [];
// Recent bookings as notifications
bookings.slice(0, 3).forEach((b: any, i: number) => {
items.push({
id: `booking-${i}`,
type: "booking",
title: `${cs ? "Nová rezervace" : "New booking"} ${b.customerName}`,
message: `${b.service}${new Date(b.startsAt).toLocaleDateString()}`,
time: new Date(b.startsAt).toLocaleDateString(undefined, { month: "short", day: "numeric" }),
read: false,
});
});
// Plan/trial notifications
const billing = props.billing;
if (billing?.subscriptionStatus === "trialing") {
const daysLeft = billing?.trialDaysRemaining ?? 3;
items.push({
id: "trial",
type: "trial",
title: cs ? "Zkušební doba končí" : "Trial ending",
message: cs ? `Zbývá ${daysLeft} dní` : `${daysLeft} days remaining`,
time: "",
read: false,
});
}
if (items.length === 0) {
items.push({
id: "welcome",
type: "system",
title: cs ? "Vítejte v Bookra" : "Welcome to Bookra",
message: cs ? "Začněte vytvořením první rezervace." : "Start by creating your first booking.",
time: "",
read: true,
});
}
return items;
});
const [readIds, setReadIds] = createSignal<Set<string>>(new Set());
const filteredNotifications = createMemo(() =>
notifications().map((n) => ({ ...n, read: n.read || readIds().has(n.id) }))
);
const unreadCount = createMemo(() => filteredNotifications().filter((n) => !n.read).length);
const markAllRead = () => {
setReadIds(new Set(filteredNotifications().map((n) => n.id)));
};
const markRead = (id: string) => {
setReadIds((prev) => { const next = new Set(prev); next.add(id); return next; });
};
const typeIcon = (type: string) => {
const base = "w-8 h-8 rounded-full flex items-center justify-center shrink-0";
switch (type) {
case "booking":
return (
<div class={`${base} bg-accent-subtle text-accent`}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
</div>
);
case "reminder":
return (
<div class={`${base} bg-canvas-muted text-ink-muted`}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</div>
);
case "upgrade":
case "trial":
return (
<div class={`${base} bg-canvas-muted text-ink-muted`}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>
</div>
);
default:
return (
<div class={`${base} bg-canvas-muted text-ink-muted`}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></svg>
</div>
);
}
};
return (
<div class="relative">
<button
onClick={() => setOpen(!open())}
class="p-2 text-ink-subtle hover:text-ink hover:bg-canvas-subtle rounded-xl transition-all relative"
aria-label={i18n.t("dashboard.notifications")}
>
<BellIcon />
<Show when={unreadCount() > 0}>
<span class="absolute top-1.5 right-1.5 w-2 h-2 bg-accent rounded-full" />
</Show>
</button>
<Show when={open()}>
<div class="absolute right-0 top-full mt-2 w-80 bg-canvas rounded-2xl shadow-xl border border-border z-50 overflow-hidden animate-scale-in">
<div class="p-4 border-b border-border flex items-center justify-between">
<h3 class="font-semibold text-ink">{i18n.t("dashboard.notifications.title")}</h3>
<Show when={unreadCount() > 0}>
<button onClick={markAllRead} class="text-xs text-accent hover:text-accent-hover font-medium">
{i18n.t("dashboard.markAllRead")}
</button>
</Show>
</div>
<div class="max-h-80 overflow-y-auto">
<Show
when={filteredNotifications().length > 0}
fallback={
<div class="p-6 text-center text-sm text-ink-muted">
{i18n.t("dashboard.noNotifications")}
</div>
}
>
<For each={filteredNotifications()}>
{(n) => (
<button
onClick={() => markRead(n.id)}
class={`w-full text-left flex items-start gap-3 p-4 hover:bg-canvas-subtle/50 transition-colors border-b border-border/50 last:border-0 ${n.read ? "opacity-60" : ""}`}
>
{typeIcon(n.type)}
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-ink truncate">{n.title}</p>
<p class="text-xs text-ink-muted mt-0.5">{n.message}</p>
<Show when={n.time}>
<p class="text-[10px] text-ink-subtle mt-1">{n.time}</p>
</Show>
</div>
{!n.read && <span class="w-2 h-2 rounded-full bg-accent shrink-0 mt-1" />}
</button>
)}
</For>
</Show>
</div>
</div>
<div class="fixed inset-0 z-40" onClick={() => setOpen(false)} />
</Show>
</div>
);
}
@@ -0,0 +1,73 @@
import { createMemo } from "solid-js";
import { useI18n } from "../../providers/i18n-provider";
interface DataPoint {
label: string;
value: number;
}
export function RevenueChart(props: { data?: DataPoint[]; bookings?: any[] }) {
const i18n = useI18n();
const chartData = createMemo(() => {
if (props.data && props.data.length > 0) return props.data;
// Build last 7 days from bookings
const days: DataPoint[] = [];
const bookings = props.bookings ?? [];
const today = new Date();
for (let i = 6; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const dateStr = d.toISOString().split("T")[0];
const count = bookings.filter((b: any) => {
const bd = new Date(b.startsAt).toISOString().split("T")[0];
return bd === dateStr;
}).length;
days.push({
label: d.toLocaleDateString(i18n.locale() === "cs" ? "cs-CZ" : "en-US", { weekday: "short" }),
value: count,
});
}
return days;
});
const maxValue = createMemo(() => Math.max(1, ...chartData().map((d) => d.value)));
const total = createMemo(() => chartData().reduce((s, d) => s + d.value, 0));
return (
<div class="surface-card p-6">
<div class="flex items-start justify-between mb-6">
<div>
<h3 class="text-lg font-semibold text-ink">{i18n.t("dashboard.revenueTitle")}</h3>
<p class="text-sm text-ink-muted mt-0.5">{i18n.t("dashboard.revenueSubtitle")}</p>
</div>
<div class="text-right">
<p class="text-2xl font-bold text-ink tracking-tight">{total()}</p>
<p class="text-xs text-ink-muted">{i18n.t("dashboard.totalBookings")}</p>
</div>
</div>
<div class="flex items-end gap-2 sm:gap-3 h-40">
{chartData().map((point, i) => {
const heightPct = Math.round((point.value / maxValue()) * 100);
return (
<div class="flex-1 flex flex-col items-center gap-2">
<div class="w-full flex-1 flex items-end">
<div
class="w-full rounded-t-md bg-accent/80 hover:bg-accent transition-all duration-300 relative group"
style={{ height: `${Math.max(heightPct, 4)}%` }}
>
<div class="absolute -top-7 left-1/2 -translate-x-1/2 px-2 py-0.5 rounded-md bg-ink text-canvas text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
{point.value}
</div>
</div>
</div>
<span class="text-[10px] sm:text-xs text-ink-muted font-medium uppercase">{point.label}</span>
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,329 @@
import { createResource, createSignal, Show, For, Accessor } from "solid-js";
import { apiClient } from "../lib/api-client";
import { Input } from "./ui/input";
import { useI18n } from "../providers/i18n-provider";
interface SMSSettingsData {
enabled: boolean;
senderName: string;
monthlyLimit: number;
messagesSent: number;
totalCostCents: number;
available: boolean;
}
interface SMSReport {
yearMonth: string;
messageCount: number;
totalCostCents: number;
stripeInvoiceId?: string;
invoiceSentAt?: string;
}
interface SMSLog {
id: string;
recipientPhone: string;
status: string;
costCents: number;
createdAt: string;
}
function formatCents(cents: number) {
return `${(cents / 100).toFixed(2)}`;
}
function formatMonth(yearMonth: string) {
const [y, m] = yearMonth.split("-");
return `${m}/${y}`;
}
interface SMSSettingsProps {
token: Accessor<string | null | undefined>;
}
export function SMSSettings(props: SMSSettingsProps) {
const i18n = useI18n();
const token = props.token;
const [saving, setSaving] = createSignal(false);
const [notice, setNotice] = createSignal<string | null>(null);
const [error, setError] = createSignal<string | null>(null);
const [settings, { refetch: refetchSettings }] = createResource(token, async (bearer) => {
if (!bearer || bearer.startsWith("demo.")) {
return {
enabled: false,
senderName: "",
monthlyLimit: 0,
messagesSent: 0,
totalCostCents: 0,
available: true,
} as SMSSettingsData;
}
const response = await (apiClient as any).GET("/v1/sms/settings", {
headers: { Authorization: `Bearer ${bearer}` },
});
return response.data as SMSSettingsData;
});
const [reports] = createResource(token, async (bearer) => {
if (!bearer || bearer.startsWith("demo.")) return [] as SMSReport[];
const response = await (apiClient as any).GET("/v1/sms/invoices", {
headers: { Authorization: `Bearer ${bearer}` },
});
return (response.data as any)?.reports ?? [];
});
const [logs] = createResource(token, async (bearer) => {
if (!bearer || bearer.startsWith("demo.")) return [] as SMSLog[];
const response = await (apiClient as any).GET("/v1/sms/history", {
headers: { Authorization: `Bearer ${bearer}` },
});
return (response.data as any)?.logs ?? [];
});
const handleToggle = async () => {
const current = settings();
if (!current) return;
const bearer = token();
if (!bearer) return;
setSaving(true);
setError(null);
setNotice(null);
try {
await (apiClient as any).POST("/v1/sms/settings", {
headers: { Authorization: `Bearer ${bearer}` },
body: {
enabled: !current.enabled,
senderName: current.senderName,
monthlyLimit: current.monthlyLimit,
},
});
await refetchSettings();
setNotice(
i18n.locale() === "cs"
? `SMS ${!current.enabled ? "aktivováno" : "deaktivováno"}`
: `SMS ${!current.enabled ? "enabled" : "disabled"}`
);
} catch (e: any) {
setError(
e?.response?.data?.error ||
(i18n.locale() === "cs" ? "Nepodařilo se uložit nastavení." : "Failed to save settings.")
);
} finally {
setSaving(false);
}
};
const handleSaveSettings = async (e: Event) => {
e.preventDefault();
const current = settings();
if (!current) return;
const bearer = token();
if (!bearer) return;
setSaving(true);
setError(null);
setNotice(null);
try {
await (apiClient as any).POST("/v1/sms/settings", {
headers: { Authorization: `Bearer ${bearer}` },
body: {
enabled: current.enabled,
senderName: current.senderName,
monthlyLimit: current.monthlyLimit,
},
});
await refetchSettings();
setNotice(i18n.locale() === "cs" ? "Nastavení uloženo." : "Settings saved.");
} catch (e: any) {
setError(
e?.response?.data?.error ||
(i18n.locale() === "cs" ? "Nepodařilo se uložit nastavení." : "Failed to save settings.")
);
} finally {
setSaving(false);
}
};
const cs = () => i18n.locale() === "cs";
return (
<div class="surface-card p-6">
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-full bg-[hsl(var(--info-subtle))] flex items-center justify-center text-[hsl(var(--info))]">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="16" x="2" y="4" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg>
</div>
<div>
<h3 class="text-lg font-semibold text-ink">{cs() ? "SMS zprávy" : "SMS Messages"}</h3>
<p class="text-sm text-ink-muted">
{cs()
? "1.50 Kč / SMS. Fakturováno měsíčně přes Stripe."
: "1.50 CZK / SMS. Billed monthly via Stripe."}
</p>
</div>
</div>
<Show when={!settings.loading} fallback={<div class="text-ink-muted">{cs() ? "Načítání..." : "Loading..."}</div>}>
<Show when={settings()?.available === false}>
<div class="p-4 bg-canvas-subtle rounded-xl text-ink-muted text-sm">
{cs()
? "SMS není v této instanci nakonfigurováno. Kontaktujte administrátora."
: "SMS is not configured for this instance. Contact your administrator."}
</div>
</Show>
<Show when={settings()?.available === true}>
<Show when={notice()}>
<div class="mb-4 p-3 bg-emerald-50 text-emerald-700 rounded-lg text-sm">{notice()}</div>
</Show>
<Show when={error()}>
<div class="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error()}</div>
</Show>
{/* Toggle */}
<div class="flex items-center justify-between mb-6 p-4 bg-canvas-subtle rounded-xl">
<div>
<p class="font-medium text-ink">{cs() ? "SMS odesílání" : "SMS sending"}</p>
<p class="text-sm text-ink-muted">
{settings()?.enabled
? (cs() ? "Aktivní — účtováno za každou zprávu" : "Active — charged per message")
: (cs() ? "Neaktivní" : "Inactive")}
</p>
</div>
<button
onClick={handleToggle}
disabled={saving()}
class={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 ${
settings()?.enabled ? "bg-accent" : "bg-gray-300"
}`}
>
<span
class={`inline-block h-5 w-5 transform rounded-full bg-white transition-transform ${
settings()?.enabled ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
</div>
<Show when={settings()?.enabled}>
<form onSubmit={handleSaveSettings} class="space-y-4">
<Input
type="text"
label={cs() ? "Jméno odesílatele (max 11 znaků)" : "Sender name (max 11 chars)"}
value={settings()?.senderName || ""}
onInput={(e) => {
const s = settings();
if (s) s.senderName = e.currentTarget.value;
}}
maxLength={11}
placeholder="Bookra"
/>
<Input
type="number"
label={cs() ? "Měsíční limit (0 = bez limitu)" : "Monthly limit (0 = unlimited)"}
value={settings()?.monthlyLimit || 0}
onInput={(e) => {
const s = settings();
if (s) s.monthlyLimit = parseInt(e.currentTarget.value) || 0;
}}
min={0}
/>
<div class="flex justify-end">
<button
type="submit"
disabled={saving()}
class="px-4 py-2 bg-accent text-white rounded-lg text-sm font-medium hover:bg-accent/90 transition-colors disabled:opacity-50"
>
{saving() ? (cs() ? "Ukládání..." : "Saving...") : cs() ? "Uložit" : "Save"}
</button>
</div>
</form>
{/* Current month stats */}
<div class="mt-6 p-4 bg-canvas-subtle rounded-xl">
<h4 class="text-sm font-medium text-ink mb-3">
{cs() ? "Aktuální měsíc" : "Current month"}
</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-2xl font-bold text-ink">{settings()?.messagesSent ?? 0}</p>
<p class="text-xs text-ink-muted">{cs() ? "Odeslaných zpráv" : "Messages sent"}</p>
</div>
<div>
<p class="text-2xl font-bold text-ink">{formatCents(settings()?.totalCostCents ?? 0)}</p>
<p class="text-xs text-ink-muted">{cs() ? "Celková cena" : "Total cost"}</p>
</div>
</div>
</div>
{/* Recent logs */}
<Show when={logs() && logs()!.length > 0}>
<div class="mt-6">
<h4 class="text-sm font-medium text-ink mb-3">
{cs() ? "Historie odesílání" : "Send history"}
</h4>
<div class="space-y-2 max-h-48 overflow-y-auto">
<For each={logs()}>
{(log) => (
<div class="flex items-center justify-between p-3 bg-canvas-subtle rounded-lg text-sm">
<div class="flex items-center gap-3">
<span class="text-ink-muted">{log.recipientPhone}</span>
<span
class={`px-2 py-0.5 rounded-full text-xs ${
log.status === "sent"
? "bg-emerald-100 text-emerald-700"
: "bg-red-100 text-red-700"
}`}
>
{log.status}
</span>
</div>
<span class="text-ink-muted">{formatCents(log.costCents)}</span>
</div>
)}
</For>
</div>
</div>
</Show>
{/* Monthly invoice reports */}
<Show when={reports() && reports()!.length > 0}>
<div class="mt-6">
<h4 class="text-sm font-medium text-ink mb-3">
{cs() ? "Fakturační přehledy" : "Invoice reports"}
</h4>
<div class="space-y-2">
<For each={reports()}>
{(report) => (
<div class="flex items-center justify-between p-3 bg-canvas-subtle rounded-lg text-sm">
<div>
<span class="font-medium text-ink">{formatMonth(report.yearMonth)}</span>
<span class="text-ink-muted ml-2">{report.messageCount} SMS</span>
</div>
<div class="flex items-center gap-3">
<span class="font-medium text-ink">{formatCents(report.totalCostCents)}</span>
<Show when={report.stripeInvoiceId}>
<span class="text-xs text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded-full">
Stripe
</span>
</Show>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
</Show>
</Show>
</Show>
</div>
);
}
@@ -322,6 +322,88 @@ const dictionaries = {
"dashboard.onboarding.timezone": "Časové pásmo",
"dashboard.onboarding.submit": "Vytvořit prostor",
"dashboard.onboarding.pending": "Vytvářím prostor...",
"dashboard.revenueTitle": "Trend rezervací",
"dashboard.revenueSubtitle": "Počet rezervací za posledních 7 dní",
"dashboard.noNotifications": "Žádná nová oznámení",
"dashboard.markAllRead": "Označit vše jako přečtené",
"dashboard.notifications.title": "Oznámení",
"dashboard.demoBanner": "Demo režim",
"dashboard.demoBannerDesc": "Prozkoumáte Bookra s ukázkovými daty. Pro plnou funkčnost se zaregistrujte.",
"dashboard.demoBannerCTA": "Vytvořit účet",
"dashboard.tryDemo": "Vyzkoušet demo",
"dashboard.language": "Jazyk",
"dashboard.calendar.noBookings": "Tento den nemáte žádné rezervace.",
"dashboard.calendar.bookingsCount": "rezervací",
"dashboard.chart.bookingsTrend": "Rezervace",
"dashboard.chart.revenueTrend": "Obrat",
"dashboard.recentActivity.empty": "Zatím žádná aktivita. Rezervace se zobrazí zde.",
"dashboard.filter.all": "Vše",
"dashboard.filter.today": "Dnes",
"dashboard.filter.week": "Týden",
"dashboard.filter.month": "Měsíc",
"dashboard.search.placeholder": "Hledat...",
"dashboard.search.bookings": "Hledat podle jména, služby nebo reference...",
"dashboard.search.customers": "Hledat podle jména, emailu nebo telefonu...",
"dashboard.actions": "Akce",
"dashboard.customer.email": "E-mail",
"dashboard.customer.phone": "Telefon",
"dashboard.customer.totalBookings": "Celkem rezervací",
"dashboard.customer.lastVisit": "Poslední návštěva",
"dashboard.customer.status": "Stav",
"dashboard.customer.notes": "Poznámky",
"dashboard.customer.noNotes": "Žádné poznámky",
"dashboard.customer.bookings": "Rezervace zákazníka",
"dashboard.customer.noBookings": "Žádné rezervace",
"dashboard.zone.add": "Přidat zónu",
"dashboard.zone.name": "Název",
"dashboard.zone.type": "Typ",
"dashboard.zone.capacity": "Kapacita",
"dashboard.zone.limitReached": "Dosáhli jste limitu",
"dashboard.zone.rooms": "Místnost",
"dashboard.zone.private": "Soukromá místnost",
"dashboard.zone.hall": "Sál",
"dashboard.zone.blockedDays": "Blokované dny",
"dashboard.zone.workingHours": "Pracovní doba",
"dashboard.zone.open": "Otevřeno",
"dashboard.zone.close": "Zavřeno",
"dashboard.zone.addBlocked": "Přidat blokovaný den",
"dashboard.zone.reason": "Důvod",
"dashboard.zone.noZones": "Zatím nemáte žádné zóny. Přidejte první.",
"dashboard.billing.planUsage": "Využití plánu",
"dashboard.billing.locations": "Lokace",
"dashboard.billing.bookings": "Rezervace",
"dashboard.billing.staff": "Zaměstnanci",
"dashboard.billing.period": "Období",
"dashboard.billing.currentPlan": "Aktuální plán",
"dashboard.billing.nextBilling": "Další fakturace",
"dashboard.settings.businessInfo": "Informace o podniku",
"dashboard.settings.branding": "Branding a vzhled",
"dashboard.settings.emailNotifications": "E-mailová oznámení",
"dashboard.settings.emailSubject": "Předmět",
"dashboard.settings.emailBody": "Obsah",
"dashboard.settings.save": "Uložit",
"dashboard.bookingModal.customer": "Zákazník",
"dashboard.bookingModal.service": "Služba",
"dashboard.bookingModal.location": "Místo",
"dashboard.bookingModal.dateTime": "Datum a čas",
"dashboard.bookingModal.duration": "Délka",
"dashboard.bookingModal.status": "Stav",
"dashboard.bookingModal.reference": "Reference",
"dashboard.bookingModal.notes": "Poznámky",
"dashboard.bookingModal.reschedule": "Přeplánovat",
"dashboard.bookingModal.confirmCancel": "Opravdu chcete zrušit tuto rezervaci?",
"dashboard.bookingModal.createdAt": "Vytvořeno",
"dashboard.bookingModal.assignedTo": "Přiřazeno",
"dashboard.bookingModal.noAssign": "Nepřiřazeno",
"dashboard.bookingModal.email": "E-mail",
"dashboard.bookingModal.phone": "Telefon",
"dashboard.empty.title": "Zatím prázdné",
"dashboard.empty.bookingsDesc": "Žádné rezervace neodpovídají vašemu filtru.",
"dashboard.empty.customersDesc": "Zatím nemáte žádné zákazníky.",
"dashboard.notification.newBooking": "Nová rezervace od",
"dashboard.notification.reminder": "Připomínka: rezervace u",
"dashboard.notification.upgrade": "Blížíte se limitu plánu",
"dashboard.notification.trialEnding": "Vaše zkušební doba končí za 3 dny",
"booking.title": "Rezervace",
"booking.body": "Vyberte dostupný termín, doplňte kontaktní údaje a potvrzení přijde e-mailem.",
"booking.slots": "Dostupné termíny",
@@ -340,6 +422,7 @@ const dictionaries = {
"booking.customer.body": "Tyto údaje použijeme pro potvrzení rezervace a připomenutí.",
"booking.customer.name": "Jméno",
"booking.customer.email": "E-mail",
"booking.customer.phone": "Telefon",
"booking.customer.notes": "Poznámka",
"booking.customerRequired": "Před rezervací vyplňte jméno a e-mail.",
"booking.failed": "Rezervaci se nepodařilo vytvořit",
@@ -719,6 +802,88 @@ const dictionaries = {
"dashboard.onboarding.timezone": "Timezone",
"dashboard.onboarding.submit": "Create workspace",
"dashboard.onboarding.pending": "Creating workspace...",
"dashboard.revenueTitle": "Booking trend",
"dashboard.revenueSubtitle": "Bookings over the last 7 days",
"dashboard.noNotifications": "No new notifications",
"dashboard.markAllRead": "Mark all as read",
"dashboard.notifications.title": "Notifications",
"dashboard.demoBanner": "Demo mode",
"dashboard.demoBannerDesc": "You're exploring Bookra with sample data. Register for full functionality.",
"dashboard.demoBannerCTA": "Create account",
"dashboard.tryDemo": "Try demo",
"dashboard.language": "Language",
"dashboard.calendar.noBookings": "No bookings for this day.",
"dashboard.calendar.bookingsCount": "bookings",
"dashboard.chart.bookingsTrend": "Bookings",
"dashboard.chart.revenueTrend": "Revenue",
"dashboard.recentActivity.empty": "No activity yet. Bookings will appear here.",
"dashboard.filter.all": "All",
"dashboard.filter.today": "Today",
"dashboard.filter.week": "Week",
"dashboard.filter.month": "Month",
"dashboard.search.placeholder": "Search...",
"dashboard.search.bookings": "Search by name, service or reference...",
"dashboard.search.customers": "Search by name, email or phone...",
"dashboard.actions": "Actions",
"dashboard.customer.email": "Email",
"dashboard.customer.phone": "Phone",
"dashboard.customer.totalBookings": "Total bookings",
"dashboard.customer.lastVisit": "Last visit",
"dashboard.customer.status": "Status",
"dashboard.customer.notes": "Notes",
"dashboard.customer.noNotes": "No notes",
"dashboard.customer.bookings": "Customer bookings",
"dashboard.customer.noBookings": "No bookings",
"dashboard.zone.add": "Add zone",
"dashboard.zone.name": "Name",
"dashboard.zone.type": "Type",
"dashboard.zone.capacity": "Capacity",
"dashboard.zone.limitReached": "Limit reached",
"dashboard.zone.rooms": "Room",
"dashboard.zone.private": "Private room",
"dashboard.zone.hall": "Hall",
"dashboard.zone.blockedDays": "Blocked days",
"dashboard.zone.workingHours": "Working hours",
"dashboard.zone.open": "Open",
"dashboard.zone.close": "Close",
"dashboard.zone.addBlocked": "Add blocked day",
"dashboard.zone.reason": "Reason",
"dashboard.zone.noZones": "No zones yet. Add your first one.",
"dashboard.billing.planUsage": "Plan usage",
"dashboard.billing.locations": "Locations",
"dashboard.billing.bookings": "Bookings",
"dashboard.billing.staff": "Staff",
"dashboard.billing.period": "Period",
"dashboard.billing.currentPlan": "Current plan",
"dashboard.billing.nextBilling": "Next billing",
"dashboard.settings.businessInfo": "Business information",
"dashboard.settings.branding": "Branding & appearance",
"dashboard.settings.emailNotifications": "Email notifications",
"dashboard.settings.emailSubject": "Subject",
"dashboard.settings.emailBody": "Body",
"dashboard.settings.save": "Save",
"dashboard.bookingModal.customer": "Customer",
"dashboard.bookingModal.service": "Service",
"dashboard.bookingModal.location": "Location",
"dashboard.bookingModal.dateTime": "Date & time",
"dashboard.bookingModal.duration": "Duration",
"dashboard.bookingModal.status": "Status",
"dashboard.bookingModal.reference": "Reference",
"dashboard.bookingModal.notes": "Notes",
"dashboard.bookingModal.reschedule": "Reschedule",
"dashboard.bookingModal.confirmCancel": "Are you sure you want to cancel this booking?",
"dashboard.bookingModal.createdAt": "Created",
"dashboard.bookingModal.assignedTo": "Assigned to",
"dashboard.bookingModal.noAssign": "Not assigned",
"dashboard.bookingModal.email": "Email",
"dashboard.bookingModal.phone": "Phone",
"dashboard.empty.title": "Nothing here yet",
"dashboard.empty.bookingsDesc": "No bookings match your filter.",
"dashboard.empty.customersDesc": "You don't have any customers yet.",
"dashboard.notification.newBooking": "New booking from",
"dashboard.notification.reminder": "Reminder: booking at",
"dashboard.notification.upgrade": "You're nearing your plan limit",
"dashboard.notification.trialEnding": "Your trial ends in 3 days",
"booking.title": "Book a visit",
"booking.body": "Choose an available time, add your contact details, and receive confirmation by email.",
"booking.slots": "Available times",
@@ -737,6 +902,7 @@ const dictionaries = {
"booking.customer.body": "These details are used for confirmation and reminders.",
"booking.customer.name": "Name",
"booking.customer.email": "Email",
"booking.customer.phone": "Phone",
"booking.customer.notes": "Note",
"booking.customerRequired": "Add your name and email before booking.",
"booking.failed": "Booking failed",
File diff suppressed because it is too large Load Diff
@@ -15,6 +15,7 @@ export function PublicBookingRoute() {
const [submittingSlot, setSubmittingSlot] = createSignal<string | null>(null);
const [customerName, setCustomerName] = createSignal("");
const [customerEmail, setCustomerEmail] = createSignal("");
const [customerPhone, setCustomerPhone] = createSignal("");
const [notes, setNotes] = createSignal("");
const [highlightContact, setHighlightContact] = createSignal(false);
let contactFormRef: HTMLDivElement | undefined;
@@ -59,6 +60,7 @@ export function PublicBookingRoute() {
locationId: slot.locationId ?? undefined,
customerName: customerName().trim(),
customerEmail: customerEmail().trim(),
customerPhone: customerPhone().trim() || undefined,
notes: notes().trim(),
startsAt: slot.startsAt,
endsAt: slot.endsAt,
@@ -132,6 +134,13 @@ export function PublicBookingRoute() {
type="email"
value={customerEmail()}
/>
<Input
autocomplete="tel"
label={i18n.t("booking.customer.phone")}
onInput={(event) => setCustomerPhone(event.currentTarget.value)}
type="tel"
value={customerPhone()}
/>
<Textarea
label={i18n.t("booking.customer.notes")}
onInput={(event) => setNotes(event.currentTarget.value)}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/components/bookra-character.tsx","./src/components/index.ts","./src/components/integration-modal.tsx","./src/components/location-map.tsx","./src/components/shell.tsx","./src/components/widget-builder.tsx","./src/components/dashboard/icons.tsx","./src/components/dashboard/types.ts","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/lib/api-client.ts","./src/lib/map.ts","./src/lib/paddle.ts","./src/lib/sentry.ts","./src/lib/stripe.ts","./src/lib/types.ts","./src/providers/auth-provider.tsx","./src/providers/i18n-provider.tsx","./src/providers/theme-provider.tsx","./src/routes/about-route.tsx","./src/routes/auth-callback-route.tsx","./src/routes/booking-manage-route.tsx","./src/routes/contact-route.tsx","./src/routes/dashboard-route.tsx","./src/routes/home-route.tsx","./src/routes/legal-route.tsx","./src/routes/not-found-route.tsx","./src/routes/pricing-route.tsx","./src/routes/public-booking-route.tsx","./vite.config.ts"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/components/bookra-character.tsx","./src/components/index.ts","./src/components/integration-modal.tsx","./src/components/location-map.tsx","./src/components/shell.tsx","./src/components/sms-settings.tsx","./src/components/widget-builder.tsx","./src/components/dashboard/calendar-view.tsx","./src/components/dashboard/icons.tsx","./src/components/dashboard/notification-dropdown.tsx","./src/components/dashboard/revenue-chart.tsx","./src/components/dashboard/types.ts","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/lib/api-client.ts","./src/lib/map.ts","./src/lib/paddle.ts","./src/lib/sentry.ts","./src/lib/stripe.ts","./src/lib/types.ts","./src/providers/auth-provider.tsx","./src/providers/i18n-provider.tsx","./src/providers/theme-provider.tsx","./src/routes/about-route.tsx","./src/routes/auth-callback-route.tsx","./src/routes/booking-manage-route.tsx","./src/routes/contact-route.tsx","./src/routes/dashboard-route.tsx","./src/routes/home-route.tsx","./src/routes/legal-route.tsx","./src/routes/not-found-route.tsx","./src/routes/pricing-route.tsx","./src/routes/public-booking-route.tsx","./vite.config.ts"],"version":"5.9.3"}