refactor(db): replace sqlc with handwritten pgx repos

- Finish Paddle-to-Stripe billing migration; drop paddle/stripe libs
- Renumber migrations; add 00012 memberships+manage_token,
  00013 location_type_capacity
- Add tests: admin, auth, bookings customer service
- Remove graphify-out artifacts and stale env files

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-17 20:21:34 +02:00
1 parent 2039669e2c
commit f2f7439050
93 files changed
+6987 -69060

No files matched your search

+74 -58
View File
@@ -6,8 +6,8 @@ import (
"errors"
"net/http"
"strings"
"time"
"bookra/apps/backend/internal/auth"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
@@ -21,9 +21,9 @@ var (
)
type Service struct {
repo db.Repository
adminEmail string
adminKey string
repo db.Repository
adminEmail string
adminKey string
}
func NewService(repo db.Repository, adminEmail, adminKey string) *Service {
@@ -52,20 +52,39 @@ func (s *Service) ValidateAdminLogin(email, key string) bool {
return emailMatch && keyMatch
}
// RequireAdmin is middleware that checks for admin authentication
// It supports two modes:
// 1. Admin credentials via X-Admin-Email and X-Admin-Key headers (for API access)
// 2. Session-based auth where the user has role "admin" or "superadmin"
func RequireAdmin(adminSvc *Service, authSvc interface{ IsAdmin(ctx context.Context, userID string) (bool, error) }) gin.HandlerFunc {
const adminActorKey = "adminActor"
// ActorFromContext returns the admin identity recorded by RequireAdmin.
func ActorFromContext(c *gin.Context) string {
value, ok := c.Get(adminActorKey)
if !ok {
return ""
}
actor, _ := value.(string)
return actor
}
// RequireAdmin is middleware that checks for admin authentication.
// It supports three modes:
// 1. Admin credentials via X-Admin-Email and X-Admin-Key headers (for API access)
// 2. Bearer tokens verified against the auth verifier, where the token subject
// maps to a user with role "admin" or "superadmin"
// 3. Demo mode, where the demo owner is treated as admin
func RequireAdmin(adminSvc *Service, verifier *auth.Verifier, repo db.Repository, demoMode bool) gin.HandlerFunc {
return func(c *gin.Context) {
if demoMode {
c.Set(adminActorKey, auth.DemoPrincipal.Subject)
c.Next()
return
}
// Check for admin header credentials (direct admin login)
adminEmail := c.GetHeader("X-Admin-Email")
adminKey := c.GetHeader("X-Admin-Key")
if adminEmail != "" && adminKey != "" {
if adminEmail != "" || adminKey != "" {
if adminSvc.ValidateAdminLogin(adminEmail, adminKey) {
c.Set("isAdmin", true)
c.Set("adminMode", "credentials")
c.Set(adminActorKey, adminEmail)
c.Next()
return
}
@@ -74,31 +93,39 @@ func RequireAdmin(adminSvc *Service, authSvc interface{ IsAdmin(ctx context.Cont
}
// Check for Bearer token with admin role
auth := c.GetHeader("Authorization")
if auth != "" && strings.HasPrefix(auth, "Bearer ") {
// The auth middleware should have already validated the token
// and set the user info in context
userID, exists := c.Get("userID")
if !exists {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
isAdmin, err := authSvc.IsAdmin(c.Request.Context(), userID.(string))
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to check admin status"})
return
}
if isAdmin {
c.Set("isAdmin", true)
c.Set("adminMode", "session")
c.Next()
return
}
tokenString, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
if !ok || strings.TrimSpace(tokenString) == "" {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin access required"})
return
}
if verifier == nil || !verifier.Enabled() {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "auth_not_configured"})
return
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin access required"})
claims, err := verifier.Verify(strings.TrimSpace(tokenString))
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"})
return
}
subject, _ := claims["sub"].(string)
if strings.TrimSpace(subject) == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid_token_subject"})
return
}
isAdmin, err := repo.IsAdminSubject(c.Request.Context(), subject)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to check admin status"})
return
}
if !isAdmin {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin access required"})
return
}
c.Set(adminActorKey, subject)
c.Next()
}
}
@@ -110,11 +137,11 @@ func (s *Service) GetDashboardStats(ctx context.Context) (domain.AdminDashboardS
}
return domain.AdminDashboardStats{
TotalTenants: stats.TotalTenants,
TotalUsers: stats.TotalUsers,
ActiveSubscriptions: stats.ActiveSubscriptions,
TrialSubscriptions: stats.TrialSubscriptions,
BookingsThisMonth: stats.BookingsThisMonth,
TotalTenants: stats.TotalTenants,
TotalUsers: stats.TotalUsers,
ActiveSubscriptions: stats.ActiveSubscriptions,
TrialSubscriptions: stats.TrialSubscriptions,
BookingsThisMonth: stats.BookingsThisMonth,
RevenueThisMonthCents: stats.RevenueThisMonth,
}, nil
}
@@ -144,12 +171,12 @@ func (s *Service) ListTenants(ctx context.Context, page, pageSize int) (domain.A
for i, t := range tenants {
result.Tenants[i] = domain.AdminTenant{
ID: t.ID,
Slug: t.Slug,
Name: t.Name,
PlanCode: t.PlanCode,
ID: t.ID,
Slug: t.Slug,
Name: t.Name,
PlanCode: t.PlanCode,
SubscriptionStatus: t.SubscriptionStatus,
BillingProvider: t.BillingProvider,
BillingProvider: t.BillingProvider,
}
}
@@ -198,8 +225,8 @@ func (s *Service) ListUsers(ctx context.Context, page, pageSize int) (domain.Adm
func (s *Service) UpdateUserRole(ctx context.Context, adminUserID, targetUserID, newRole string, ip, userAgent string) error {
// Validate role
validRoles := map[string]bool{
"user": true,
"admin": true,
"user": true,
"admin": true,
"superadmin": true,
}
if !validRoles[newRole] {
@@ -224,20 +251,9 @@ func (s *Service) UpdateUserRole(ctx context.Context, adminUserID, targetUserID,
})
}
// SyncTenantSubscription manually syncs a tenant's subscription from Stripe
func (s *Service) SyncTenantSubscription(ctx context.Context, tenantID string) error {
// This will be called from the billing service
return nil
}
func stringPtrToStr(s *string) string {
if s == nil {
return ""
}
return *s
}
func init() {
// Ensure time package is imported
_ = time.Now()
}
+117
View File
@@ -0,0 +1,117 @@
package admin
import (
"net/http"
"net/http/httptest"
"testing"
"bookra/apps/backend/internal/auth"
"bookra/apps/backend/internal/db"
"github.com/gin-gonic/gin"
)
func newTestRouter(t *testing.T, adminSvc *Service, verifier *auth.Verifier, repo db.Repository, demoMode bool) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(RequireAdmin(adminSvc, verifier, repo, demoMode))
r.GET("/v1/admin/stats", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"actor": ActorFromContext(c)})
})
return r
}
func request(t *testing.T, r *gin.Engine, headers map[string]string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/v1/admin/stats", nil)
for k, v := range headers {
req.Header.Set(k, v)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
func TestRequireAdminRejectsUnauthenticated(t *testing.T) {
repo := db.NewMemoryRepository()
svc := NewService(repo, "[email protected]", "secret-key")
verifier, _ := auth.NewVerifier("", "jwt-secret")
defer verifier.Close()
r := newTestRouter(t, svc, verifier, repo, false)
if w := request(t, r, nil); w.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d", w.Code)
}
}
func TestRequireAdminRejectsInvalidHeaderCreds(t *testing.T) {
repo := db.NewMemoryRepository()
svc := NewService(repo, "[email protected]", "secret-key")
verifier, _ := auth.NewVerifier("", "jwt-secret")
defer verifier.Close()
r := newTestRouter(t, svc, verifier, repo, false)
w := request(t, r, map[string]string{
"X-Admin-Email": "[email protected]",
"X-Admin-Key": "wrong-key",
})
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
}
func TestRequireAdminAcceptsHeaderCreds(t *testing.T) {
repo := db.NewMemoryRepository()
svc := NewService(repo, "[email protected]", "secret-key")
verifier, _ := auth.NewVerifier("", "jwt-secret")
defer verifier.Close()
r := newTestRouter(t, svc, verifier, repo, false)
w := request(t, r, map[string]string{
"X-Admin-Email": "[email protected]",
"X-Admin-Key": "secret-key",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}
func TestRequireAdminVerifiesBearerTokenAndRole(t *testing.T) {
repo := db.NewMemoryRepository()
secret := "jwt-secret"
svc := NewService(repo, "[email protected]", "secret-key")
verifier, _ := auth.NewVerifier("", secret)
defer verifier.Close()
r := newTestRouter(t, svc, verifier, repo, false)
// A locally issued token for a non-admin subject must be rejected even though
// the token itself is valid.
authSvc := auth.NewService(repo, secret)
_, tokens, err := authSvc.RegisterWithPassword(t.Context(), "[email protected]", "password123", "Regular User")
if err != nil {
t.Fatalf("register: %v", err)
}
w := request(t, r, map[string]string{"Authorization": "Bearer " + tokens.AccessToken})
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for non-admin subject, got %d", w.Code)
}
// A forged bearer string must be rejected outright.
w = request(t, r, map[string]string{"Authorization": "Bearer not-a-token"})
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for invalid token, got %d", w.Code)
}
}
func TestRequireAdminDemoMode(t *testing.T) {
repo := db.NewMemoryRepository()
svc := NewService(repo, "", "")
verifier, _ := auth.NewVerifier("", "jwt-secret")
defer verifier.Close()
r := newTestRouter(t, svc, verifier, repo, true)
if w := request(t, r, nil); w.Code != http.StatusOK {
t.Fatalf("expected 200 in demo mode, got %d", w.Code)
}
}
+101 -43
View File
@@ -49,8 +49,8 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
repository := db.NewRepository(pools, cfg.DemoMode)
notificationService := notifications.NewService(cfg, repository)
bookingService := bookings.NewService(repository, notificationService)
customerBookingService := bookings.NewCustomerService(repository, notificationService)
bookingService := bookings.NewService(repository, notificationService, cfg.FrontendURL)
customerBookingService := bookings.NewCustomerService(repository, notificationService, cfg.FrontendURL)
tenantService := tenancy.NewService(repository)
billingService := billing.NewService(cfg, repository)
catalogService := catalog.NewService(repository, billingService, notificationService)
@@ -73,9 +73,10 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
server.router.Use(gin.Logger(), gin.Recovery(), cors.New(cors.Config{
AllowOrigins: allowedOrigins(cfg),
AllowHeaders: []string{"Authorization", "Content-Type"},
AllowMethods: []string{"GET", "POST", "OPTIONS"},
AllowHeaders: []string{"Authorization", "Content-Type", "X-Admin-Email", "X-Admin-Key"},
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowCredentials: true,
MaxAge: 12 * time.Hour,
}), httpx.SecurityHeaders())
server.router.GET("/healthz", func(c *gin.Context) {
@@ -86,24 +87,24 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
})
})
// Test endpoint for Sentry
server.router.GET("/debug/sentry", func(c *gin.Context) {
sentry.CaptureMessage("Test message from Bookra API")
c.JSON(http.StatusOK, gin.H{"status": "sent", "message": "Test error sent to Sentry"})
})
// Test endpoint for billing
server.router.GET("/debug/billing-test", func(c *gin.Context) {
if billingService != nil {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"billingService": "initialized",
"message": "Billing service is working",
})
} else {
c.JSON(http.StatusOK, gin.H{"status": "ok", "billingService": "not initialized"})
}
})
if cfg.Environment == "development" || cfg.Environment == "test" {
// Test endpoints, only exposed outside production
server.router.GET("/debug/sentry", func(c *gin.Context) {
sentry.CaptureMessage("Test message from Bookra API")
c.JSON(http.StatusOK, gin.H{"status": "sent", "message": "Test error sent to Sentry"})
})
server.router.GET("/debug/billing-test", func(c *gin.Context) {
if billingService != nil {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"billingService": "initialized",
"message": "Billing service is working",
})
} else {
c.JSON(http.StatusOK, gin.H{"status": "ok", "billingService": "not initialized"})
}
})
}
server.router.GET("/v1/meta/config", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
@@ -119,6 +120,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
// AUTH API
// ============================================
authGroup := server.router.Group("/v1/auth")
authGroup.Use(publicRateLimiter.Middleware())
{
authGroup.POST("/register", func(c *gin.Context) {
var request struct {
@@ -255,7 +257,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
// ADMIN API
// ============================================
adminGroup := server.router.Group("/v1/admin")
adminGroup.Use(admin.RequireAdmin(adminService, authService))
adminGroup.Use(admin.RequireAdmin(adminService, verifier, repository, cfg.DemoMode))
{
adminGroup.GET("/stats", func(c *gin.Context) {
stats, err := adminService.GetDashboardStats(c.Request.Context())
@@ -294,10 +296,9 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
adminUserID, _ := c.Get("userID")
err := adminService.UpdateUserRole(
c.Request.Context(),
adminUserID.(string),
admin.ActorFromContext(c),
c.Param("userID"),
request.Role,
c.ClientIP(),
@@ -433,6 +434,45 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
c.JSON(http.StatusCreated, response)
})
protected.PUT("/tenants/brand", func(c *gin.Context) {
var request domain.UpdateBrandRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := tenantService.UpdateBrand(c.Request.Context(), auth.PrincipalFromContext(c), request)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.GET("/tenants/email-templates", func(c *gin.Context) {
response, err := tenantService.ListEmailTemplates(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"templates": response})
})
protected.PUT("/tenants/email-template", func(c *gin.Context) {
var request domain.UpdateEmailTemplateRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := tenantService.UpdateEmailTemplate(c.Request.Context(), auth.PrincipalFromContext(c), request)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, tenancy.ErrInvalidOnboarding) {
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
// ============================================
// CATALOG API - Locations / Zones
// ============================================
@@ -501,6 +541,29 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
})
protected.POST("/catalog/bookings", func(c *gin.Context) {
var request domain.DashboardBookingRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.CreateBooking(c.Request.Context(), auth.PrincipalFromContext(c), request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership):
status = http.StatusNotFound
case errors.Is(err, catalog.ErrInvalidBooking):
status = http.StatusBadRequest
case errors.Is(err, catalog.ErrBookingConflict):
status = http.StatusConflict
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, response)
})
// ============================================
// CATALOG API - Blocked Days
// ============================================
@@ -691,8 +754,11 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
response, err := customerBookingService.GetBookingByReference(c.Request.Context(), c.Param("reference"), token)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, bookings.ErrBookingNotFound) {
switch {
case errors.Is(err, bookings.ErrBookingNotFound):
status = http.StatusNotFound
case errors.Is(err, bookings.ErrUnauthorized):
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
@@ -712,10 +778,14 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
switch {
case errors.Is(err, bookings.ErrBookingNotFound):
status = http.StatusNotFound
case errors.Is(err, bookings.ErrUnauthorized):
status = http.StatusForbidden
case errors.Is(err, bookings.ErrBookingCancelled):
status = http.StatusConflict
case errors.Is(err, bookings.ErrInvalidReschedule):
status = http.StatusBadRequest
case errors.Is(err, bookings.ErrBookingConflict):
status = http.StatusConflict
}
c.JSON(status, gin.H{"error": err.Error()})
return
@@ -730,6 +800,8 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
switch {
case errors.Is(err, bookings.ErrBookingNotFound):
status = http.StatusNotFound
case errors.Is(err, bookings.ErrUnauthorized):
status = http.StatusForbidden
case errors.Is(err, bookings.ErrBookingCancelled):
status = http.StatusConflict
}
@@ -766,7 +838,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
if errors.Is(err, billing.ErrBillingPlanUnsupported) {
status = http.StatusBadRequest
}
if errors.Is(err, billing.ErrPaddleNotConfigured) {
if errors.Is(err, billing.ErrStripeNotConfigured) {
status = http.StatusServiceUnavailable
}
c.JSON(status, gin.H{"error": err.Error()})
@@ -781,7 +853,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
if errors.Is(err, billing.ErrBillingMembership) {
status = http.StatusNotFound
}
if errors.Is(err, billing.ErrPaddleNotConfigured) {
if errors.Is(err, billing.ErrStripeNotConfigured) {
status = http.StatusServiceUnavailable
}
c.JSON(status, gin.H{"error": err.Error()})
@@ -798,7 +870,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
status = http.StatusNotFound
case errors.Is(err, billing.ErrBillingCustomerMissing):
status = http.StatusBadRequest
case errors.Is(err, billing.ErrPaddleNotConfigured):
case errors.Is(err, billing.ErrStripeNotConfigured):
status = http.StatusServiceUnavailable
}
c.JSON(status, gin.H{"error": err.Error()})
@@ -942,13 +1014,6 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
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()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "accepted"})
})
server.router.POST("/v1/webhooks/stripe", func(c *gin.Context) {
if err := billingService.HandleStripeWebhook(c.Request.Context(), c.Request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -956,13 +1021,6 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
}
c.JSON(http.StatusOK, gin.H{"status": "accepted"})
})
server.router.POST("/api/paddle_webhook", func(c *gin.Context) {
if err := billingService.HandleWebhook(c.Request.Context(), c.Request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "accepted"})
})
server.router.POST("/v1/internal/jobs/reminders/dispatch", func(c *gin.Context) {
if !authorizeJobRunner(c, cfg) {
+29 -16
View File
@@ -65,29 +65,42 @@ func (v *Verifier) Close() {
}
}
func (v *Verifier) verifyLocal(tokenString string) (jwt.MapClaims, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return v.localSecret, nil
}, jwt.WithIssuer("bookra-auth"), jwt.WithAudience("bookra"), jwt.WithLeeway(15*time.Second))
if err != nil {
return nil, err
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid {
return nil, errors.New("invalid token claims")
}
if tokenType, _ := claims["type"].(string); tokenType != "access" {
return nil, errors.New("invalid token type")
}
return claims, nil
}
func (v *Verifier) Verify(tokenString string) (jwt.MapClaims, error) {
if !v.enabled {
return nil, errors.New("neon auth verifier is disabled")
}
if len(v.localSecret) > 0 && v.jwks == nil {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return v.localSecret, nil
}, jwt.WithIssuer("bookra-auth"), jwt.WithAudience("bookra"), jwt.WithLeeway(15*time.Second))
if err != nil {
if len(v.localSecret) > 0 {
claims, err := v.verifyLocal(tokenString)
if err == nil {
return claims, nil
}
if v.jwks == nil {
return nil, err
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid {
return nil, errors.New("invalid token claims")
}
if tokenType, _ := claims["type"].(string); tokenType != "access" {
return nil, errors.New("invalid token type")
}
return claims, nil
}
if v.jwks == nil {
return nil, errors.New("no token verification mechanism configured")
}
token, err := jwt.Parse(tokenString, v.jwks.Keyfunc,
+7 -1
View File
@@ -20,6 +20,8 @@ const (
refreshTokenTTL = 30 * 24 * time.Hour
magicLinkTTL = 15 * time.Minute
passwordResetTTL = 30 * time.Minute
tokenIssuer = "bookra-auth"
tokenAudience = "bookra"
)
var (
@@ -227,7 +229,7 @@ func (s *Service) ValidateToken(tokenString string) (*Claims, error) {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return s.jwtSecret, nil
})
}, jwt.WithIssuer(tokenIssuer), jwt.WithAudience(tokenAudience))
if err != nil {
return nil, err
@@ -266,6 +268,8 @@ func (s *Service) generateTokenPair(userID, email, name, role string) (*TokenPai
Role: role,
Type: "access",
RegisteredClaims: jwt.RegisteredClaims{
Issuer: tokenIssuer,
Audience: jwt.ClaimStrings{tokenAudience},
ExpiresAt: jwt.NewNumericDate(now.Add(accessTokenTTL)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
@@ -285,6 +289,8 @@ func (s *Service) generateTokenPair(userID, email, name, role string) (*TokenPai
Role: role,
Type: "refresh",
RegisteredClaims: jwt.RegisteredClaims{
Issuer: tokenIssuer,
Audience: jwt.ClaimStrings{tokenAudience},
ExpiresAt: jwt.NewNumericDate(now.Add(refreshTokenTTL)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
@@ -0,0 +1,69 @@
package auth
import (
"context"
"testing"
"bookra/apps/backend/internal/db"
)
func TestLocalTokensVerifyWithIssuerAndAudience(t *testing.T) {
repo := db.NewMemoryRepository()
secret := "test-secret-that-is-long-enough"
svc := NewService(repo, secret)
_, tokens, err := svc.RegisterWithPassword(context.Background(), "[email protected]", "password123", "Test User")
if err != nil {
t.Fatalf("register: %v", err)
}
verifier, err := NewVerifier("", secret)
if err != nil {
t.Fatalf("create verifier: %v", err)
}
defer verifier.Close()
claims, err := verifier.Verify(tokens.AccessToken)
if err != nil {
t.Fatalf("verify access token: %v", err)
}
if iss, _ := claims["iss"].(string); iss != tokenIssuer {
t.Fatalf("expected issuer %s, got %v", tokenIssuer, claims["iss"])
}
if sub, _ := claims["sub"].(string); sub == "" {
t.Fatal("expected non-empty sub")
}
}
func TestLocalVerifierRejectsTamperedAndWrongTypeTokens(t *testing.T) {
repo := db.NewMemoryRepository()
secret := "test-secret-that-is-long-enough"
svc := NewService(repo, secret)
_, tokens, err := svc.RegisterWithPassword(context.Background(), "[email protected]", "password123", "Test User")
if err != nil {
t.Fatalf("register: %v", err)
}
verifier, err := NewVerifier("", secret)
if err != nil {
t.Fatalf("create verifier: %v", err)
}
defer verifier.Close()
// Refresh tokens carry type=refresh and must not authenticate API calls.
if _, err := verifier.Verify(tokens.RefreshToken); err == nil {
t.Fatal("expected refresh token to be rejected as access token")
}
// Token signed with a different secret must fail.
other := NewService(repo, "different-secret")
otherUser, otherTokens, err := other.RegisterWithPassword(context.Background(), "[email protected]", "password123", "Other")
if err != nil {
t.Fatalf("register other: %v", err)
}
_ = otherUser
if _, err := verifier.Verify(otherTokens.AccessToken); err == nil {
t.Fatal("expected token signed with a different secret to be rejected")
}
}
+32 -427
View File
@@ -16,7 +16,6 @@ import (
"bookra/apps/backend/internal/domain"
"bookra/apps/backend/internal/shared"
paddle "github.com/PaddleHQ/paddle-go-sdk/v5"
"github.com/jackc/pgx/v5"
"github.com/stripe/stripe-go/v81"
portalsession "github.com/stripe/stripe-go/v81/billingportal/session"
@@ -30,28 +29,11 @@ var (
ErrBillingMembership = errors.New("billing membership not found")
ErrBillingPlanUnsupported = errors.New("billing plan is not configured")
ErrBillingCustomerMissing = errors.New("billing customer is not available")
ErrPaddleNotConfigured = errors.New("paddle is not configured")
ErrPaddleSignatureMissing = errors.New("paddle signature is missing")
ErrPaddleWebhookMissing = errors.New("paddle webhook secret is not configured")
ErrStripeNotConfigured = errors.New("stripe is not configured")
ErrStripeSignatureMissing = errors.New("stripe signature is missing")
ErrStripeWebhookMissing = errors.New("stripe webhook secret is not configured")
)
var allowedPaddleWebhookEvents = []string{
"subscription.created",
"subscription.updated",
"subscription.activated",
"subscription.canceled",
"subscription.paused",
"subscription.resumed",
"subscription.trialing",
"transaction.completed",
"transaction.updated",
"transaction.payment_failed",
"transaction.past_due",
}
var allowedStripeWebhookEvents = []stripe.EventType{
stripe.EventTypeCheckoutSessionCompleted,
stripe.EventTypeCustomerSubscriptionCreated,
@@ -64,48 +46,15 @@ var allowedStripeWebhookEvents = []stripe.EventType{
}
type Service struct {
cfg config.Config
repo db.Repository
client *paddle.SDK
verifier *paddle.WebhookVerifier
stripeEnabled bool
}
type webhookEnvelope struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
Data struct {
ID string `json:"id"`
CustomerID string `json:"customer_id"`
SubscriptionID string `json:"subscription_id"`
CustomData map[string]any `json:"custom_data"`
} `json:"data"`
cfg config.Config
repo db.Repository
}
func NewService(cfg config.Config, repo db.Repository) *Service {
service := &Service{cfg: cfg, repo: repo}
// Initialize Paddle client
if strings.TrimSpace(cfg.PaddleAPIKey) != "" {
var client *paddle.SDK
var err error
if cfg.PaddleEnvironment == "live" {
client, err = paddle.New(cfg.PaddleAPIKey, paddle.WithBaseURL(paddle.ProductionBaseURL))
} else {
client, err = paddle.NewSandbox(cfg.PaddleAPIKey)
}
if err == nil {
service.client = client
}
}
// Initialize Stripe
if strings.TrimSpace(cfg.StripeAPIKey) != "" {
stripe.Key = cfg.StripeAPIKey
service.stripeEnabled = true
}
return service
return &Service{cfg: cfg, repo: repo}
}
// GetEntitlements returns the plan entitlements for a tenant (used by other services for limit enforcement)
@@ -122,7 +71,7 @@ func (s *Service) GetEntitlements(ctx context.Context, tenantID string) (domain.
}
func (s *Service) GetSubscription(ctx context.Context, principal domain.Principal) (domain.SubscriptionSnapshot, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.SubscriptionSnapshot{}, ErrBillingMembership
@@ -135,7 +84,7 @@ func (s *Service) GetSubscription(ctx context.Context, principal domain.Principa
if errors.Is(err, pgx.ErrNoRows) {
return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{
TenantID: membership.Tenant.ID,
BillingProvider: s.cfg.BillingProvider(),
BillingProvider: "stripe",
Status: firstNonEmpty(membership.Tenant.SubscriptionStatus, "inactive"),
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
Currency: "czk",
@@ -148,26 +97,23 @@ func (s *Service) GetSubscription(ctx context.Context, principal domain.Principa
}
func (s *Service) CreateCheckoutSession(ctx context.Context, principal domain.Principal, planCode string, currency string, billingInterval string) (domain.CheckoutLaunchResponse, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.CheckoutLaunchResponse{}, ErrBillingMembership
}
return domain.CheckoutLaunchResponse{}, err
}
if !s.cfg.StripeConfigured() {
return domain.CheckoutLaunchResponse{}, ErrStripeNotConfigured
}
// Default to monthly if not specified
if billingInterval == "" {
billingInterval = "monthly"
}
// Prefer Stripe if configured
if s.cfg.StripeConfigured() {
return s.createStripeCheckoutSession(ctx, principal, membership, planCode, currency, billingInterval)
}
// Fall back to Paddle
return s.createPaddleCheckoutSession(ctx, principal, membership, planCode, currency)
return s.createStripeCheckoutSession(ctx, principal, membership, planCode, currency, billingInterval)
}
func (s *Service) createStripeCheckoutSession(ctx context.Context, principal domain.Principal, membership db.TenantMembershipRecord, planCode string, currency string, billingInterval string) (domain.CheckoutLaunchResponse, error) {
@@ -191,7 +137,7 @@ func (s *Service) createStripeCheckoutSession(ctx context.Context, principal dom
return domain.CheckoutLaunchResponse{}, fmt.Errorf("failed to create stripe customer: %w", err)
}
customerID = cust.ID
if err := s.repo.UpdateTenantBillingCustomerID(ctx, membership.Tenant.ID, customerID); err != nil {
if err := s.repo.UpdateTenantBillingCustomerID(ctx, membership.Tenant.ID, "stripe", customerID); err != nil {
return domain.CheckoutLaunchResponse{}, err
}
}
@@ -241,35 +187,8 @@ func (s *Service) createStripeCheckoutSession(ctx context.Context, principal dom
}, nil
}
func (s *Service) createPaddleCheckoutSession(ctx context.Context, principal domain.Principal, membership db.TenantMembershipRecord, planCode string, currency string) (domain.CheckoutLaunchResponse, error) {
priceID, resolvedPlanCode, resolvedCurrency := s.paddlePriceForPlan(planCode, currency)
if priceID == "" {
return domain.CheckoutLaunchResponse{}, ErrBillingPlanUnsupported
}
if !checkoutAvailable(s.cfg, resolvedPlanCode) {
return domain.CheckoutLaunchResponse{}, ErrPaddleNotConfigured
}
return domain.CheckoutLaunchResponse{
PriceID: priceID,
CustomerID: derefString(membership.Tenant.BillingCustomerID),
CustomerEmail: strings.TrimSpace(principal.Email),
SuccessRedirectURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard?billing=success",
CancelRedirectURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard?billing=cancelled",
CustomData: map[string]string{
"tenantId": membership.Tenant.ID,
"tenantSlug": membership.Tenant.Slug,
"userId": principal.Subject,
"userEmail": strings.TrimSpace(principal.Email),
"planCode": resolvedPlanCode,
"currency": resolvedCurrency,
"source": "bookra-dashboard",
},
}, nil
}
func (s *Service) Refresh(ctx context.Context, principal domain.Principal) (domain.SubscriptionSnapshot, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.SubscriptionSnapshot{}, ErrBillingMembership
@@ -281,36 +200,26 @@ func (s *Service) Refresh(ctx context.Context, principal domain.Principal) (doma
if customerID == "" {
return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{
TenantID: membership.Tenant.ID,
BillingProvider: s.cfg.BillingProvider(),
BillingProvider: "stripe",
Status: "inactive",
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
Currency: "czk",
}, s.cfg), nil
}
// Prefer Stripe if configured
if s.cfg.StripeConfigured() {
record, err := s.syncStripeDataToKV(ctx, membership.Tenant, customerID)
if err != nil {
return domain.SubscriptionSnapshot{}, err
}
return toSnapshot(membership.Tenant, record, s.cfg), nil
if !s.cfg.StripeConfigured() {
return domain.SubscriptionSnapshot{}, ErrStripeNotConfigured
}
// Fall back to Paddle
if s.client == nil {
return domain.SubscriptionSnapshot{}, ErrPaddleNotConfigured
}
record, err := s.syncPaddleData(ctx, membership.Tenant, customerID)
record, err := s.syncStripeDataToKV(ctx, membership.Tenant, customerID)
if err != nil {
return domain.SubscriptionSnapshot{}, err
}
return toSnapshot(membership.Tenant, record, s.cfg), nil
}
func (s *Service) CreatePortalSession(ctx context.Context, principal domain.Principal) (domain.PortalSessionResponse, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.PortalSessionResponse{}, ErrBillingMembership
@@ -322,14 +231,11 @@ func (s *Service) CreatePortalSession(ctx context.Context, principal domain.Prin
if customerID == "" {
return domain.PortalSessionResponse{}, ErrBillingCustomerMissing
}
// Prefer Stripe if configured
if s.cfg.StripeConfigured() {
return s.createStripePortalSession(customerID)
if !s.cfg.StripeConfigured() {
return domain.PortalSessionResponse{}, ErrStripeNotConfigured
}
// Fall back to Paddle
return s.createPaddlePortalSession(ctx, membership, customerID)
return s.createStripePortalSession(customerID)
}
func (s *Service) createStripePortalSession(customerID string) (domain.PortalSessionResponse, error) {
@@ -344,51 +250,6 @@ func (s *Service) createStripePortalSession(customerID string) (domain.PortalSes
return domain.PortalSessionResponse{URL: sess.URL}, nil
}
func (s *Service) createPaddlePortalSession(ctx context.Context, membership db.TenantMembershipRecord, customerID string) (domain.PortalSessionResponse, error) {
if s.client == nil {
return domain.PortalSessionResponse{}, ErrPaddleNotConfigured
}
request := &paddle.CreateCustomerPortalSessionRequest{CustomerID: customerID}
if subscriptionID := derefString(membership.Tenant.BillingSubscription); subscriptionID != "" {
request.SubscriptionIDs = []string{subscriptionID}
}
sess, err := s.client.CreateCustomerPortalSession(ctx, request)
if err != nil {
return domain.PortalSessionResponse{}, err
}
url := strings.TrimSpace(sess.URLs.General.Overview)
if url == "" && len(sess.URLs.Subscriptions) > 0 {
url = firstNonEmpty(
sess.URLs.Subscriptions[0].UpdateSubscriptionPaymentMethod,
sess.URLs.Subscriptions[0].CancelSubscription,
)
}
if url == "" {
return domain.PortalSessionResponse{}, ErrBillingCustomerMissing
}
return domain.PortalSessionResponse{URL: url}, nil
}
func (s *Service) HandleWebhook(ctx context.Context, req *http.Request) error {
// Detect provider based on signature header
stripeSig := req.Header.Get("Stripe-Signature")
paddleSig := req.Header.Get("Paddle-Signature")
if stripeSig != "" {
return s.handleStripeWebhook(ctx, req)
}
if paddleSig != "" {
return s.handlePaddleWebhook(ctx, req)
}
return errors.New("missing webhook signature header")
}
func (s *Service) HandleStripeWebhook(ctx context.Context, req *http.Request) error {
return s.handleStripeWebhook(ctx, req)
}
@@ -397,6 +258,9 @@ func (s *Service) handleStripeWebhook(ctx context.Context, req *http.Request) er
if s.cfg.StripeWebhookKey == "" {
return ErrStripeWebhookMissing
}
if strings.TrimSpace(req.Header.Get("Stripe-Signature")) == "" {
return ErrStripeSignatureMissing
}
body, err := io.ReadAll(req.Body)
if err != nil {
@@ -433,7 +297,7 @@ func (s *Service) handleStripeWebhook(ctx context.Context, req *http.Request) er
return err
}
if customerID != "" && derefString(tenant.BillingCustomerID) == "" {
if err := s.repo.UpdateTenantBillingCustomerID(ctx, tenant.ID, customerID); err != nil {
if err := s.repo.UpdateTenantBillingCustomerID(ctx, tenant.ID, "stripe", customerID); err != nil {
return err
}
tenant.BillingCustomerID = &customerID
@@ -476,141 +340,6 @@ func (s *Service) handleStripeWebhook(ctx context.Context, req *http.Request) er
return err
}
func (s *Service) handlePaddleWebhook(ctx context.Context, req *http.Request) error {
if s.verifier == nil {
return ErrPaddleWebhookMissing
}
if strings.TrimSpace(req.Header.Get("Paddle-Signature")) == "" {
return ErrPaddleSignatureMissing
}
ok, err := s.verifier.Verify(req)
if err != nil {
return err
}
if !ok {
return errors.New("invalid paddle webhook signature")
}
payload, err := io.ReadAll(req.Body)
if err != nil {
return err
}
var event webhookEnvelope
if err := json.Unmarshal(payload, &event); err != nil {
return err
}
if !slices.Contains(allowedPaddleWebhookEvents, event.EventType) {
return nil
}
tenant, customerID, err := s.resolveWebhookTenant(ctx, event)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
return err
}
if tenant.ID == "" {
return nil
}
inserted, err := s.repo.RecordBillingEvent(ctx, tenant.ID, "paddle", event.EventID, event.EventType, payload)
if err != nil || !inserted {
return err
}
if customerID != "" && derefString(tenant.BillingCustomerID) == "" {
if err := s.repo.UpdateTenantBillingCustomerID(ctx, tenant.ID, customerID); err != nil {
return err
}
tenant.BillingCustomerID = &customerID
}
customerID = firstNonEmpty(customerID, derefString(tenant.BillingCustomerID))
if customerID == "" || s.client == nil {
return nil
}
_, err = s.syncPaddleData(ctx, tenant, customerID)
return err
}
func (s *Service) resolveWebhookTenant(ctx context.Context, event webhookEnvelope) (db.TenantRecord, string, error) {
customerID := strings.TrimSpace(event.Data.CustomerID)
if tenantID := customDataString(event.Data.CustomData, "tenantId"); tenantID != "" {
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
return tenant, customerID, err
}
if customerID != "" {
tenant, err := s.repo.GetTenantByBillingCustomerID(ctx, customerID)
return tenant, customerID, err
}
return db.TenantRecord{}, customerID, pgx.ErrNoRows
}
func (s *Service) syncPaddleData(ctx context.Context, tenant db.TenantRecord, customerID string) (db.BillingSnapshotRecord, error) {
if s.client == nil {
return db.BillingSnapshotRecord{}, ErrPaddleNotConfigured
}
subscriptions, err := s.client.ListSubscriptions(ctx, &paddle.ListSubscriptionsRequest{
CustomerID: []string{customerID},
})
if err != nil {
return db.BillingSnapshotRecord{}, err
}
var selected *paddle.Subscription
if err := subscriptions.Iter(ctx, func(subscription *paddle.Subscription) (bool, error) {
if subscription == nil {
return true, nil
}
if selected == nil || subscriptionRank(subscription) > subscriptionRank(selected) {
selected = subscription
}
return true, nil
}); err != nil {
return db.BillingSnapshotRecord{}, err
}
now := time.Now().UTC()
record := db.BillingSnapshotRecord{
TenantID: tenant.ID,
BillingProvider: "paddle",
BillingCustomerID: customerID,
BillingSubscriptionID: "",
Status: "inactive",
PlanCode: shared.NormalizePlanCode(tenant.PlanCode),
Currency: "czk",
PriceID: "",
LastSyncedAt: &now,
}
if selected != nil {
record.BillingSubscriptionID = selected.ID
record.Status = normalizeSubscriptionStatus(string(selected.Status))
record.Currency = normalizeCurrency(string(selected.CurrencyCode))
record.CancelAtPeriodEnd = selected.ScheduledChange != nil && string(selected.ScheduledChange.Action) == "cancel"
record.CurrentPeriodStart = parseRFC3339Ptr(timePeriodStart(selected.CurrentBillingPeriod))
record.CurrentPeriodEnd = parseRFC3339Ptr(timePeriodEnd(selected.CurrentBillingPeriod))
if len(selected.Items) > 0 {
record.PriceID = selected.Items[0].Price.ID
record.PlanCode = s.paddlePlanCodeForPrice(record.PriceID, tenant.PlanCode)
}
}
if err := s.repo.UpsertSubscriptionSnapshot(ctx, record); err != nil {
return db.BillingSnapshotRecord{}, err
}
if err := s.repo.UpdateTenantBillingState(ctx, tenant.ID, record.PlanCode, record.Status, record.BillingSubscriptionID); err != nil {
return db.BillingSnapshotRecord{}, err
}
return record, nil
}
// syncStripeDataToKV is the core sync function following the KV sync model.
// It fetches full subscription state from Stripe and stores it in the database.
// This function is called after checkout success and on every relevant webhook event.
@@ -669,7 +398,7 @@ func (s *Service) syncStripeDataToKV(ctx context.Context, tenant db.TenantRecord
if err := s.repo.UpsertSubscriptionSnapshot(ctx, record); err != nil {
return db.BillingSnapshotRecord{}, err
}
if err := s.repo.UpdateTenantBillingState(ctx, tenant.ID, record.PlanCode, record.Status, record.BillingSubscriptionID); err != nil {
if err := s.repo.UpdateTenantBillingState(ctx, tenant.ID, "stripe", record.PlanCode, record.Status, record.BillingSubscriptionID); err != nil {
return db.BillingSnapshotRecord{}, err
}
@@ -732,11 +461,7 @@ func toSnapshot(tenant db.TenantRecord, record db.BillingSnapshotRecord, cfg con
}
customerID := firstNonEmpty(record.BillingCustomerID, derefString(tenant.BillingCustomerID))
provider := firstNonEmpty(record.BillingProvider, tenant.BillingProvider, cfg.BillingProvider())
syncAvailable := cfg.BillingConfigured()
portalAvailable := cfg.BillingConfigured() && customerID != ""
checkoutAvailable := billingCheckoutAvailable(cfg, record.PlanCode)
provider := firstNonEmpty(record.BillingProvider, tenant.BillingProvider, "stripe")
return domain.SubscriptionSnapshot{
TenantID: tenant.ID,
@@ -761,9 +486,9 @@ func toSnapshot(tenant db.TenantRecord, record db.BillingSnapshotRecord, cfg con
return 0
}(),
LastSyncedAt: record.LastSyncedAt,
CheckoutURLAvailable: checkoutAvailable,
SyncAvailable: syncAvailable,
PortalAvailable: portalAvailable,
CheckoutURLAvailable: billingCheckoutAvailable(cfg, record.PlanCode),
SyncAvailable: cfg.StripeConfigured(),
PortalAvailable: cfg.StripeConfigured() && customerID != "",
}
}
@@ -812,17 +537,6 @@ func entitlementsForPlan(planCode string) domain.PlanEntitlements {
}
}
func (s *Service) paddlePlanCodeForPrice(priceID string, fallback string) string {
for planCode, currencies := range s.cfg.PaddlePriceMatrix {
for _, configuredPriceID := range currencies {
if configuredPriceID != "" && configuredPriceID == priceID {
return shared.NormalizePlanCode(planCode)
}
}
}
return shared.NormalizePlanCode(fallback)
}
func (s *Service) stripePlanCodeForPrice(priceID string, fallback string) string {
for planCode, currencies := range s.cfg.StripePriceMatrix {
for _, configuredPriceID := range currencies {
@@ -834,23 +548,6 @@ func (s *Service) stripePlanCodeForPrice(priceID string, fallback string) string
return shared.NormalizePlanCode(fallback)
}
func (s *Service) paddlePriceForPlan(planCode string, currency string) (string, string, string) {
resolvedPlan := shared.NormalizePlanCode(strings.TrimSpace(planCode))
if resolvedPlan == "" {
resolvedPlan = "pro"
}
resolvedCurrency := normalizeCurrency(currency)
if priceID := s.cfg.PaddlePriceMatrix[resolvedPlan][resolvedCurrency]; priceID != "" {
return priceID, resolvedPlan, resolvedCurrency
}
if resolvedCurrency != "czk" {
if priceID := s.cfg.PaddlePriceMatrix[resolvedPlan]["czk"]; priceID != "" {
return priceID, resolvedPlan, "czk"
}
}
return s.cfg.PaddlePriceMatrix[resolvedPlan]["usd"], resolvedPlan, "usd"
}
func (s *Service) stripePriceForPlan(planCode string, currency string, billingInterval string) (string, string, string) {
resolvedPlan := shared.NormalizePlanCode(strings.TrimSpace(planCode))
if resolvedPlan == "" {
@@ -893,23 +590,6 @@ func (s *Service) stripePriceForPlan(planCode string, currency string, billingIn
return s.cfg.StripePriceMatrix[resolvedPlan][resolvedPlan+":czk"], resolvedPlan, "czk"
}
func subscriptionRank(subscription *paddle.Subscription) int {
switch subscription.Status {
case paddle.SubscriptionStatusActive:
return 6
case paddle.SubscriptionStatusTrialing:
return 5
case paddle.SubscriptionStatusPastDue:
return 4
case paddle.SubscriptionStatusPaused:
return 3
case paddle.SubscriptionStatusCanceled:
return 2
default:
return 1
}
}
func displayPricesForPlan(planCode string) []domain.PlanDisplayPrice {
switch shared.NormalizePlanCode(planCode) {
case "starter":
@@ -944,21 +624,12 @@ func normalizeCurrency(currency string) string {
}
}
func normalizeSubscriptionStatus(status string) string {
switch strings.TrimSpace(strings.ToLower(status)) {
case "active", "trialing", "past_due", "paused", "canceled":
return strings.TrimSpace(strings.ToLower(status))
default:
return "inactive"
}
}
func checkoutAvailable(cfg config.Config, planCode string) bool {
if !cfg.PaddleConfigured() || !cfg.PaddleWebhookConfigured() {
func billingCheckoutAvailable(cfg config.Config, planCode string) bool {
if !cfg.StripeConfigured() || !cfg.StripeWebhookConfigured() {
return false
}
planCode = shared.NormalizePlanCode(planCode)
for _, priceID := range cfg.PaddlePriceMatrix[planCode] {
for _, priceID := range cfg.StripePriceMatrix[planCode] {
if strings.TrimSpace(priceID) != "" {
return true
}
@@ -966,72 +637,6 @@ func checkoutAvailable(cfg config.Config, planCode string) bool {
return false
}
func billingCheckoutAvailable(cfg config.Config, planCode string) bool {
planCode = shared.NormalizePlanCode(planCode)
// Prefer Stripe
if cfg.StripeConfigured() && cfg.StripeWebhookConfigured() {
for _, priceID := range cfg.StripePriceMatrix[planCode] {
if strings.TrimSpace(priceID) != "" {
return true
}
}
}
// Fall back to Paddle
if cfg.PaddleConfigured() && cfg.PaddleWebhookConfigured() {
for _, priceID := range cfg.PaddlePriceMatrix[planCode] {
if strings.TrimSpace(priceID) != "" {
return true
}
}
}
return false
}
func customDataString(data map[string]any, key string) string {
if data == nil {
return ""
}
value, ok := data[key]
if !ok {
return ""
}
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
default:
return ""
}
}
func parseRFC3339Ptr(value string) *time.Time {
if strings.TrimSpace(value) == "" {
return nil
}
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
return nil
}
utc := parsed.UTC()
return &utc
}
func timePeriodStart(period *paddle.TimePeriod) string {
if period == nil {
return ""
}
return period.StartsAt
}
func timePeriodEnd(period *paddle.TimePeriod) string {
if period == nil {
return ""
}
return period.EndsAt
}
func derefString(value *string) string {
if value == nil {
return ""
+28 -35
View File
@@ -12,12 +12,12 @@ import (
func testConfig() config.Config {
return config.Config{
FrontendURL: "http://localhost:3000",
PaddleAPIKey: "pdl_sdbx_apikey_123",
PaddleWebhookKey: "pdl_ntf_123",
PaddlePriceMatrix: map[string]map[string]string{
"starter": {"czk": "pri_starter_czk", "usd": "pri_starter_usd"},
"pro": {"czk": "pri_pro_czk", "usd": "pri_pro_usd"},
"business": {"czk": "pri_business_czk", "usd": "pri_business_usd"},
StripeAPIKey: "sk_test_123",
StripeWebhookKey: "whsec_test_123",
StripePriceMatrix: map[string]map[string]string{
"starter": {"czk": "price_starter_czk", "usd": "price_starter_usd"},
"pro": {"czk": "price_pro_czk", "usd": "price_pro_usd"},
"business": {"czk": "price_business_czk", "usd": "price_business_usd"},
},
}
}
@@ -36,66 +36,59 @@ func TestGetSubscriptionFallsBackToSnapshotAndEntitlements(t *testing.T) {
if snapshot.PlanCode != "pro" {
t.Fatalf("expected pro, got %s", snapshot.PlanCode)
}
if snapshot.Provider != "paddle" {
t.Fatalf("expected paddle provider, got %s", snapshot.Provider)
if snapshot.Provider != "stripe" {
t.Fatalf("expected stripe provider, got %s", snapshot.Provider)
}
if snapshot.Entitlements.MaxLocations != 3 {
t.Fatalf("expected 3 locations, got %d", snapshot.Entitlements.MaxLocations)
}
}
func TestCreateCheckoutRequiresPaddleConfig(t *testing.T) {
func TestCreateCheckoutRequiresStripeConfig(t *testing.T) {
cfg := testConfig()
cfg.PaddleAPIKey = ""
cfg.StripeAPIKey = ""
service := NewService(cfg, db.NewMemoryRepository())
response, err := service.CreateCheckoutSession(context.Background(), domain.Principal{
Subject: "demo-owner",
Email: "[email protected]",
}, "pro", "czk", "monthly")
if err != ErrPaddleNotConfigured {
t.Fatalf("expected ErrPaddleNotConfigured, got response=%v err=%v", response, err)
if err != ErrStripeNotConfigured {
t.Fatalf("expected ErrStripeNotConfigured, got response=%v err=%v", response, err)
}
}
func TestCreateCheckoutReturnsLaunchPayload(t *testing.T) {
service := NewService(testConfig(), db.NewMemoryRepository())
func TestCreateCheckoutRejectsUnconfiguredPlan(t *testing.T) {
cfg := testConfig()
cfg.StripePriceMatrix["pro"] = map[string]string{}
service := NewService(cfg, db.NewMemoryRepository())
response, err := service.CreateCheckoutSession(context.Background(), domain.Principal{
_, err := service.CreateCheckoutSession(context.Background(), domain.Principal{
Subject: "demo-owner",
Email: "[email protected]",
}, "pro", "czk", "monthly")
if err != nil {
t.Fatalf("create checkout: %v", err)
}
if response.PriceID != "pri_pro_czk" {
t.Fatalf("expected pri_pro_czk, got %s", response.PriceID)
}
if response.CustomData["tenantId"] == "" {
t.Fatal("expected tenantId in customData")
}
if response.SuccessRedirectURL == "" || response.CancelRedirectURL == "" {
t.Fatal("expected redirect URLs")
if err != ErrBillingPlanUnsupported {
t.Fatalf("expected ErrBillingPlanUnsupported, got %v", err)
}
}
func TestRefreshRequiresPaddleKeyWhenCustomerExists(t *testing.T) {
func TestRefreshRequiresStripeKeyWhenCustomerExists(t *testing.T) {
cfg := testConfig()
cfg.PaddleAPIKey = ""
cfg.StripeAPIKey = ""
service := NewService(cfg, db.NewMemoryRepository())
snapshot, err := service.Refresh(context.Background(), domain.Principal{
Subject: "demo-owner",
Email: "[email protected]",
})
if err != ErrPaddleNotConfigured {
t.Fatalf("expected ErrPaddleNotConfigured, got snapshot=%v err=%v", snapshot, err)
if err != ErrStripeNotConfigured {
t.Fatalf("expected ErrStripeNotConfigured, got snapshot=%v err=%v", snapshot, err)
}
}
func TestGetSubscriptionDisablesCheckoutWhenWebhookMissing(t *testing.T) {
cfg := testConfig()
cfg.PaddleWebhookKey = ""
cfg.StripeWebhookKey = ""
service := NewService(cfg, db.NewMemoryRepository())
snapshot, err := service.GetSubscription(context.Background(), domain.Principal{
@@ -110,7 +103,7 @@ func TestGetSubscriptionDisablesCheckoutWhenWebhookMissing(t *testing.T) {
}
}
func TestGetSubscriptionEnablesCheckoutWhenPaddleConfigured(t *testing.T) {
func TestGetSubscriptionEnablesCheckoutWhenStripeConfigured(t *testing.T) {
service := NewService(testConfig(), db.NewMemoryRepository())
snapshot, err := service.GetSubscription(context.Background(), domain.Principal{
@@ -121,7 +114,7 @@ func TestGetSubscriptionEnablesCheckoutWhenPaddleConfigured(t *testing.T) {
t.Fatalf("get subscription: %v", err)
}
if !snapshot.CheckoutURLAvailable {
t.Fatal("expected checkout available when paddle is configured")
t.Fatal("expected checkout available when stripe is configured")
}
if !snapshot.PortalAvailable {
t.Fatal("expected portal available when customer exists")
@@ -130,11 +123,11 @@ func TestGetSubscriptionEnablesCheckoutWhenPaddleConfigured(t *testing.T) {
func TestCreatePortalSessionRequiresCustomer(t *testing.T) {
repo := db.NewMemoryRepository()
membership, err := repo.GetTenantMembershipByUserID(context.Background(), "demo-owner")
membership, err := repo.GetTenantMembershipBySubject(context.Background(), "demo-owner")
if err != nil {
t.Fatalf("get membership: %v", err)
}
if err := repo.UpdateTenantBillingCustomerID(context.Background(), membership.Tenant.ID, ""); err != nil {
if err := repo.UpdateTenantBillingCustomerID(context.Background(), membership.Tenant.ID, "stripe", ""); err != nil {
t.Fatalf("clear billing customer: %v", err)
}
service := NewService(testConfig(), repo)
@@ -2,7 +2,10 @@ package bookings
import (
"context"
"crypto/subtle"
"errors"
"fmt"
"strings"
"time"
"bookra/apps/backend/internal/db"
@@ -17,8 +20,9 @@ var (
)
type CustomerService struct {
repo db.Repository
notifier CustomerNotifier
repo db.Repository
notifier CustomerNotifier
frontendURL string
}
type CustomerNotifier interface {
@@ -26,11 +30,14 @@ type CustomerNotifier interface {
SendBookingCancellation(ctx context.Context, data notifications.BookingEmailData) error
}
func NewCustomerService(repo db.Repository, notifier CustomerNotifier) *CustomerService {
func NewCustomerService(repo db.Repository, notifier CustomerNotifier, frontendURL string) *CustomerService {
if notifier == nil {
notifier = &customerNoopNotifier{}
}
return &CustomerService{repo: repo, notifier: notifier}
if frontendURL == "" {
frontendURL = "http://localhost:3000"
}
return &CustomerService{repo: repo, notifier: notifier, frontendURL: strings.TrimRight(frontendURL, "/")}
}
type customerNoopNotifier struct{}
@@ -43,29 +50,70 @@ func (n *customerNoopNotifier) SendBookingCancellation(ctx context.Context, data
return nil
}
// authorize compares the caller-supplied management token against the stored
// per-booking token in constant time. Bookings without a token (created before
// the feature) cannot be managed through the public endpoints.
func authorize(booking db.BookingRecord, token string) error {
if booking.ManageToken == "" || token == "" {
return ErrUnauthorized
}
if subtle.ConstantTimeCompare([]byte(token), []byte(booking.ManageToken)) != 1 {
return ErrUnauthorized
}
return nil
}
func (s *CustomerService) managementURL(booking db.BookingRecord) string {
return fmt.Sprintf("%s/manage/%s?token=%s", s.frontendURL, booking.Reference, booking.ManageToken)
}
func (s *CustomerService) descriptors(ctx context.Context, booking db.BookingRecord) (serviceName string, locationName string) {
if booking.ServiceID != nil {
if services, err := s.repo.ListServicesByTenant(ctx, booking.TenantID); err == nil {
for _, service := range services {
if service.ID == *booking.ServiceID {
serviceName = service.Name
break
}
}
}
}
if booking.LocationID != nil {
if location, err := s.repo.GetLocationByID(ctx, *booking.LocationID); err == nil {
locationName = location.Name
}
}
return serviceName, locationName
}
// GetBookingByReference returns booking details for customer management link
func (s *CustomerService) GetBookingByReference(ctx context.Context, reference string, token string) (domain.CustomerBookingView, error) {
booking, err := s.repo.GetBookingByReference(ctx, reference)
if err != nil {
return domain.CustomerBookingView{}, ErrBookingNotFound
}
if err := authorize(booking, token); err != nil {
return domain.CustomerBookingView{}, err
}
// Get tenant details for business name
tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID)
if err != nil {
return domain.CustomerBookingView{}, err
}
serviceName, locationName := s.descriptors(ctx, booking)
return domain.CustomerBookingView{
Reference: booking.Reference,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
Service: "Service", // Would get from service ID in full implementation
Service: serviceName,
BusinessName: tenant.Name,
StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt,
Location: "Location", // Would get from location ID in full implementation
Location: locationName,
Status: booking.Status,
Notes: booking.Notes,
}, nil
}
@@ -75,6 +123,9 @@ func (s *CustomerService) RescheduleBooking(ctx context.Context, reference strin
if err != nil {
return ErrBookingNotFound
}
if err := authorize(booking, token); err != nil {
return err
}
if booking.Status == "cancelled" {
return ErrBookingCancelled
@@ -98,6 +149,20 @@ func (s *CustomerService) RescheduleBooking(ctx context.Context, reference strin
return ErrInvalidReschedule
}
// Re-check resource conflicts at the requested window, excluding self.
existing, err := s.repo.ListBookingsByTenantBetween(ctx, booking.TenantID, newStartsAt, newEndsAt)
if err != nil {
return err
}
for _, other := range existing {
if other.ID == booking.ID || other.Status == "cancelled" || other.Status == "waitlisted" {
continue
}
if resourcesCollide(other, booking.StaffID, booking.LocationID) {
return ErrBookingConflict
}
}
if err := s.repo.RescheduleBooking(ctx, booking.ID, newStartsAt, newEndsAt); err != nil {
return err
}
@@ -114,6 +179,9 @@ func (s *CustomerService) CancelBooking(ctx context.Context, reference string, t
if err != nil {
return ErrBookingNotFound
}
if err := authorize(booking, token); err != nil {
return err
}
if booking.Status == "cancelled" {
return ErrBookingCancelled
@@ -144,22 +212,23 @@ func (s *CustomerService) sendRescheduleEmail(booking db.BookingRecord, newStart
brandColor = "#a65c3e"
}
managementURL := "https://bookra.eu/manage/" + booking.Reference + "?token=" + booking.Reference
serviceName, locationName := s.descriptors(ctx, booking)
emailData := notifications.BookingEmailData{
TenantID: tenant.ID,
TenantName: tenant.Name,
TenantSlug: tenant.Slug,
BrandColor: brandColor,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
Service: "Service",
Location: "Location",
Service: serviceName,
Location: locationName,
Reference: booking.Reference,
StartsAt: newStartsAt,
EndsAt: newEndsAt,
Timezone: tenant.Timezone,
Locale: tenant.Locale,
ManagementURL: managementURL,
ManagementURL: s.managementURL(booking),
}
s.notifier.SendBookingReschedule(ctx, emailData)
@@ -180,14 +249,17 @@ func (s *CustomerService) sendCancellationEmail(booking db.BookingRecord) {
brandColor = "#a65c3e"
}
serviceName, locationName := s.descriptors(ctx, booking)
emailData := notifications.BookingEmailData{
TenantID: tenant.ID,
TenantName: tenant.Name,
TenantSlug: tenant.Slug,
BrandColor: brandColor,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
Service: "Service",
Location: "Location",
Service: serviceName,
Location: locationName,
Reference: booking.Reference,
StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt,
@@ -0,0 +1,113 @@
package bookings
import (
"context"
"testing"
"time"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
)
func newBookingFixture(t *testing.T, repo *db.MemoryRepository) db.BookingRecord {
t.Helper()
created, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
CustomerName: "Test Customer",
CustomerEmail: "[email protected]",
StartsAt: time.Now().Add(24 * time.Hour),
EndsAt: time.Now().Add(25 * time.Hour),
Status: "confirmed",
Reference: "BOK-TEST-1",
ManageToken: "secret-manage-token",
})
if err != nil {
t.Fatalf("create booking: %v", err)
}
booking, err := repo.GetBookingByReference(context.Background(), created.Reference)
if err != nil {
t.Fatalf("get booking: %v", err)
}
return booking
}
func TestGetBookingRequiresManageToken(t *testing.T) {
repo := db.NewMemoryRepository()
booking := newBookingFixture(t, repo)
svc := NewCustomerService(repo, nil, "http://localhost:3000")
if _, err := svc.GetBookingByReference(context.Background(), booking.Reference, ""); err != ErrUnauthorized {
t.Fatalf("expected ErrUnauthorized for missing token, got %v", err)
}
if _, err := svc.GetBookingByReference(context.Background(), booking.Reference, "wrong"); err != ErrUnauthorized {
t.Fatalf("expected ErrUnauthorized for wrong token, got %v", err)
}
view, err := svc.GetBookingByReference(context.Background(), booking.Reference, "secret-manage-token")
if err != nil {
t.Fatalf("expected authorized view, got %v", err)
}
if view.Reference != booking.Reference {
t.Fatalf("expected reference %s, got %s", booking.Reference, view.Reference)
}
}
func TestRescheduleRequiresManageToken(t *testing.T) {
repo := db.NewMemoryRepository()
booking := newBookingFixture(t, repo)
svc := NewCustomerService(repo, nil, "http://localhost:3000")
req := domain.RescheduleBookingRequest{
NewStartsAt: time.Now().Add(48 * time.Hour).Format(time.RFC3339),
NewEndsAt: time.Now().Add(49 * time.Hour).Format(time.RFC3339),
}
if err := svc.RescheduleBooking(context.Background(), booking.Reference, req, ""); err != ErrUnauthorized {
t.Fatalf("expected ErrUnauthorized, got %v", err)
}
if err := svc.RescheduleBooking(context.Background(), booking.Reference, req, "wrong"); err != ErrUnauthorized {
t.Fatalf("expected ErrUnauthorized, got %v", err)
}
}
func TestCancelRequiresManageToken(t *testing.T) {
repo := db.NewMemoryRepository()
booking := newBookingFixture(t, repo)
svc := NewCustomerService(repo, nil, "http://localhost:3000")
if err := svc.CancelBooking(context.Background(), booking.Reference, ""); err != ErrUnauthorized {
t.Fatalf("expected ErrUnauthorized, got %v", err)
}
if err := svc.CancelBooking(context.Background(), booking.Reference, "wrong"); err != ErrUnauthorized {
t.Fatalf("expected ErrUnauthorized, got %v", err)
}
if err := svc.CancelBooking(context.Background(), booking.Reference, "secret-manage-token"); err != nil {
t.Fatalf("expected successful cancel, got %v", err)
}
}
func TestRescheduleRejectsConflictingSlot(t *testing.T) {
repo := db.NewMemoryRepository()
svc := NewCustomerService(repo, nil, "http://localhost:3000")
base := time.Now().Add(48 * time.Hour)
// Existing booking in the same tenant occupying the target slot.
_, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
StartsAt: base,
EndsAt: base.Add(time.Hour),
Status: "confirmed",
Reference: "BOK-OCCUPIED",
ManageToken: "token-a",
})
if err != nil {
t.Fatalf("create occupying booking: %v", err)
}
booking := newBookingFixture(t, repo)
err = svc.RescheduleBooking(context.Background(), booking.Reference, domain.RescheduleBookingRequest{
NewStartsAt: base.Add(30 * time.Minute).Format(time.RFC3339), // overlaps BOK-OCCUPIED
NewEndsAt: base.Add(90 * time.Minute).Format(time.RFC3339),
}, "secret-manage-token")
if err != ErrBookingConflict {
t.Fatalf("expected ErrBookingConflict, got %v", err)
}
}
+155 -70
View File
@@ -2,6 +2,8 @@ package bookings
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"net/mail"
@@ -26,8 +28,9 @@ var (
)
type Service struct {
repo db.Repository
notifier Notifier
repo db.Repository
notifier Notifier
frontendURL string
}
type Notifier interface {
@@ -35,11 +38,14 @@ type Notifier interface {
SendBusinessNotification(ctx context.Context, businessEmail string, data notifications.BookingEmailData) error
}
func NewService(repo db.Repository, notifier Notifier) *Service {
func NewService(repo db.Repository, notifier Notifier, frontendURL string) *Service {
if notifier == nil {
notifier = &noopNotifier{}
}
return &Service{repo: repo, notifier: notifier}
if frontendURL == "" {
frontendURL = "http://localhost:3000"
}
return &Service{repo: repo, notifier: notifier, frontendURL: strings.TrimRight(frontendURL, "/")}
}
type noopNotifier struct{}
@@ -163,7 +169,7 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
if booking.Status == "cancelled" {
continue
}
if sameResource(booking.StaffID, request.StaffID) || sameResource(booking.LocationID, request.LocationID) {
if resourcesCollide(booking, request.StaffID, request.LocationID) {
return domain.CreateBookingResponse{}, ErrBookingConflict
}
}
@@ -203,6 +209,7 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
EndsAt: endsAt.UTC(),
Status: status,
Reference: db.Reference("BK", time.Now()),
ManageToken: generateManageToken(),
Notes: notes,
})
if err != nil {
@@ -234,16 +241,40 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
}
// Send confirmation emails (async - don't fail booking if email fails)
go s.sendBookingConfirmationEmails(tenant, created, customerName, customerEmail, startsAt, endsAt)
go s.sendBookingConfirmationEmails(tenant, created, request.ServiceID, request.LocationID, customerName, customerEmail, startsAt, endsAt)
return domain.CreateBookingResponse{
BookingID: created.ID,
Reference: created.Reference,
Status: created.Status,
ManageURL: s.manageURL(created),
}, nil
}
func (s *Service) sendBookingConfirmationEmails(tenant db.TenantRecord, booking db.CreatedBooking, customerName, customerEmail string, startsAt, endsAt time.Time) {
func (s *Service) manageURL(booking db.CreatedBooking) string {
return fmt.Sprintf("%s/manage/%s?token=%s", s.frontendURL, booking.Reference, booking.ManageToken)
}
func (s *Service) bookingDescriptors(ctx context.Context, tenantID string, serviceID, locationID *string) (serviceName string, locationName string) {
if serviceID != nil {
if services, err := s.repo.ListServicesByTenant(ctx, tenantID); err == nil {
for _, service := range services {
if service.ID == *serviceID {
serviceName = service.Name
break
}
}
}
}
if locationID != nil {
if location, err := s.repo.GetLocationByID(ctx, *locationID); err == nil {
locationName = location.Name
}
}
return serviceName, locationName
}
func (s *Service) sendBookingConfirmationEmails(tenant db.TenantRecord, booking db.CreatedBooking, serviceID, locationID *string, customerName, customerEmail string, startsAt, endsAt time.Time) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -254,22 +285,23 @@ func (s *Service) sendBookingConfirmationEmails(tenant db.TenantRecord, booking
brandColor = "#a65c3e" // Default terra color
}
managementURL := fmt.Sprintf("https://bookra.eu/manage/%s?token=%s", booking.Reference, booking.Reference)
serviceName, locationName := s.bookingDescriptors(ctx, tenant.ID, serviceID, locationID)
emailData := notifications.BookingEmailData{
TenantID: tenant.ID,
TenantName: tenant.Name,
TenantSlug: tenant.Slug,
BrandColor: brandColor,
CustomerName: customerName,
CustomerEmail: customerEmail,
Service: "Service", // Would lookup from booking.ServiceID in full implementation
Location: "Location", // Would lookup from booking.LocationID in full implementation
Service: serviceName,
Location: locationName,
Reference: booking.Reference,
StartsAt: startsAt,
EndsAt: endsAt,
Timezone: tenant.Timezone,
Locale: tenant.Locale,
ManagementURL: managementURL,
ManagementURL: s.manageURL(booking),
}
// Send to customer
@@ -303,7 +335,7 @@ func (s *Service) classSessionForRequest(ctx context.Context, tenantID string, c
}
func (s *Service) DashboardSummary(ctx context.Context, principal domain.Principal) (domain.DashboardSummary, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.DashboardSummary{}, ErrTenantMembership
@@ -311,6 +343,38 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
return domain.DashboardSummary{}, err
}
serviceNames := map[string]string{}
if services, err := s.repo.ListServicesByTenant(ctx, membership.Tenant.ID); err == nil {
for _, service := range services {
serviceNames[service.ID] = service.Name
}
}
locationNames := map[string]string{}
if locations, err := s.repo.ListLocationsByTenant(ctx, membership.Tenant.ID); err == nil {
for _, location := range locations {
locationNames[location.ID] = location.Name
}
}
toDomain := func(booking db.BookingRecord) domain.UpcomingBooking {
out := domain.UpcomingBooking{
ID: booking.ID,
Reference: booking.Reference,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
Notes: booking.Notes,
StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt,
Status: booking.Status,
}
if booking.ServiceID != nil {
out.Service = serviceNames[*booking.ServiceID]
}
if booking.LocationID != nil {
out.Location = locationNames[*booking.LocationID]
}
return out
}
now := time.Now().UTC()
weekEnd := now.AddDate(0, 0, 7)
metrics, err := s.repo.GetDashboardMetrics(ctx, membership.Tenant.ID, now, weekEnd)
@@ -323,14 +387,10 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
}
upcoming := make([]domain.UpcomingBooking, 0, len(upcomingRecords))
for _, booking := range upcomingRecords {
upcoming = append(upcoming, domain.UpcomingBooking{
Reference: booking.Reference,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt,
Status: booking.Status,
})
if booking.Status == "cancelled" {
continue
}
upcoming = append(upcoming, toDomain(booking))
}
if len(upcoming) > 5 {
upcoming = upcoming[:5]
@@ -345,14 +405,7 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
}
allBookings := make([]domain.UpcomingBooking, 0, len(allRecords))
for _, booking := range allRecords {
allBookings = append(allBookings, domain.UpcomingBooking{
Reference: booking.Reference,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt,
Status: booking.Status,
})
allBookings = append(allBookings, toDomain(booking))
}
return domain.DashboardSummary{
@@ -407,52 +460,55 @@ func generateAppointmentSlots(
location = time.UTC
}
service := services[0]
now := time.Now().In(location)
var slots []domain.TimeSlot
for dayOffset := 0; dayOffset < 7 && len(slots) < 6; dayOffset++ {
day := now.AddDate(0, 0, dayOffset)
for _, rule := range rules {
if int(day.Weekday()) != rule.DayOfWeek {
continue
}
startsLocal, err := time.ParseInLocation("15:04:05", rule.StartsLocal, location)
if err != nil {
continue
}
endsLocal, err := time.ParseInLocation("15:04:05", rule.EndsLocal, location)
if err != nil {
continue
}
windowStart := time.Date(day.Year(), day.Month(), day.Day(), startsLocal.Hour(), startsLocal.Minute(), 0, 0, location)
windowEnd := time.Date(day.Year(), day.Month(), day.Day(), endsLocal.Hour(), endsLocal.Minute(), 0, 0, location)
step := time.Duration(service.DurationMinutes+service.BufferAfterMinutes) * time.Minute
duration := time.Duration(service.DurationMinutes) * time.Minute
for slotStart := windowStart; slotStart.Add(duration).Before(windowEnd) || slotStart.Add(duration).Equal(windowEnd); slotStart = slotStart.Add(step) {
if slotStart.Before(now.Add(2 * time.Hour)) {
for _, service := range services {
serviceSlots := 0
for dayOffset := 0; dayOffset < 7 && serviceSlots < 4; dayOffset++ {
day := now.AddDate(0, 0, dayOffset)
for _, rule := range rules {
if int(day.Weekday()) != rule.DayOfWeek {
continue
}
slotEnd := slotStart.Add(duration)
if collides(existing, rule.StaffID, nil, slotStart.UTC(), slotEnd.UTC()) {
startsLocal, err := time.ParseInLocation("15:04:05", rule.StartsLocal, location)
if err != nil {
continue
}
endsLocal, err := time.ParseInLocation("15:04:05", rule.EndsLocal, location)
if err != nil {
continue
}
serviceID := service.ID
slots = append(slots, domain.TimeSlot{
ServiceID: &serviceID,
StaffID: rule.StaffID,
StartsAt: slotStart.UTC().Format(time.RFC3339),
EndsAt: slotEnd.UTC().Format(time.RFC3339),
Mode: "appointment",
Label: service.Name,
})
if len(slots) >= 6 {
break
windowStart := time.Date(day.Year(), day.Month(), day.Day(), startsLocal.Hour(), startsLocal.Minute(), 0, 0, location)
windowEnd := time.Date(day.Year(), day.Month(), day.Day(), endsLocal.Hour(), endsLocal.Minute(), 0, 0, location)
step := time.Duration(service.DurationMinutes+service.BufferAfterMinutes) * time.Minute
duration := time.Duration(service.DurationMinutes) * time.Minute
for slotStart := windowStart; slotStart.Add(duration).Before(windowEnd) || slotStart.Add(duration).Equal(windowEnd); slotStart = slotStart.Add(step) {
if slotStart.Before(now.Add(2 * time.Hour)) {
continue
}
slotEnd := slotStart.Add(duration)
if collides(existing, rule.StaffID, nil, slotStart.UTC(), slotEnd.UTC()) {
continue
}
serviceID := service.ID
slots = append(slots, domain.TimeSlot{
ServiceID: &serviceID,
StaffID: rule.StaffID,
StartsAt: slotStart.UTC().Format(time.RFC3339),
EndsAt: slotEnd.UTC().Format(time.RFC3339),
Mode: "appointment",
Label: service.Name,
})
serviceSlots++
if serviceSlots >= 4 {
break
}
}
}
}
@@ -491,18 +547,47 @@ func collides(bookings []db.BookingRecord, staffID *string, locationID *string,
if !(booking.StartsAt.Before(endsAt) && booking.EndsAt.After(startsAt)) {
continue
}
if sameResource(booking.StaffID, staffID) || sameResource(booking.LocationID, locationID) {
if resourcesCollide(booking, staffID, locationID) {
return true
}
}
return false
}
func sameResource(left *string, right *string) bool {
if left == nil || right == nil {
return false
// resourcesCollide reports whether an existing booking shares the resource a
// new booking would consume. A booking with neither staff nor location
// assignment occupies the tenant's single implicit resource, so it conflicts
// with any other unassigned booking. Explicit assignments only collide when
// they match exactly.
func resourcesCollide(booking db.BookingRecord, staffID *string, locationID *string) bool {
if booking.StaffID != nil && staffID != nil && *booking.StaffID == *staffID {
return true
}
return *left == *right
if booking.LocationID != nil && locationID != nil && *booking.LocationID == *locationID {
return true
}
return booking.StaffID == nil && staffID == nil && booking.LocationID == nil && locationID == nil
}
func generateManageToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic(fmt.Sprintf("generate manage token: %v", err))
}
return hex.EncodeToString(b)
}
// GenerateManageToken returns a new cryptographically random booking
// management token. Exported for catalog dashboard bookings.
func GenerateManageToken() string {
return generateManageToken()
}
// AppointmentResourceConflict reports whether an existing booking shares the
// resource a new appointment would consume. Exported for catalog dashboard
// bookings.
func AppointmentResourceConflict(booking db.BookingRecord, staffID *string, locationID *string) bool {
return resourcesCollide(booking, staffID, locationID)
}
func sameSecond(left time.Time, right time.Time) bool {
@@ -12,7 +12,7 @@ import (
func TestCreateAppointmentRejectsConflict(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil)
service := NewService(repo, nil, "http://localhost:3000")
availability, err := service.Availability(context.Background(), "studio-atelier")
if err != nil {
@@ -69,7 +69,7 @@ func TestCreateAppointmentRejectsConflict(t *testing.T) {
func TestCreateClassFallsBackToWaitlistWhenCapacityReached(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil)
service := NewService(repo, nil, "http://localhost:3000")
availability, err := service.Availability(context.Background(), "studio-atelier")
if err != nil {
@@ -126,7 +126,7 @@ func TestCreateClassFallsBackToWaitlistWhenCapacityReached(t *testing.T) {
func TestCreateAppointmentRequiresTenantService(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil)
service := NewService(repo, nil, "http://localhost:3000")
_, err := service.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
@@ -143,7 +143,7 @@ func TestCreateAppointmentRequiresTenantService(t *testing.T) {
func TestCreateClassRequiresExistingSession(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil)
service := NewService(repo, nil, "http://localhost:3000")
missingSessionID := "11111111-1111-1111-1111-111111111111"
_, err := service.Create(context.Background(), domain.CreateBookingRequest{
@@ -162,7 +162,7 @@ func TestCreateClassRequiresExistingSession(t *testing.T) {
func TestAvailabilityGeneratesUpcomingSlots(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil)
service := NewService(repo, nil, "http://localhost:3000")
availability, err := service.Availability(context.Background(), "studio-atelier")
if err != nil {
@@ -185,7 +185,7 @@ func TestAvailabilityGeneratesUpcomingSlots(t *testing.T) {
func TestCreateSchedulesReminderJobForUpcomingAppointment(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil)
service := NewService(repo, nil, "http://localhost:3000")
availability, err := service.Availability(context.Background(), "studio-atelier")
if err != nil {
+162 -42
View File
@@ -4,8 +4,11 @@ import (
"context"
"errors"
"fmt"
"net/mail"
"strings"
"time"
"bookra/apps/backend/internal/bookings"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
)
@@ -16,6 +19,7 @@ var (
ErrCustomerNotFound = errors.New("customer not found")
ErrBookingNotFound = errors.New("booking not found")
ErrInvalidBooking = errors.New("invalid booking request")
ErrBookingConflict = errors.New("booking conflicts with an existing reservation")
ErrTenantNotFound = errors.New("tenant not found")
ErrTenantMembership = errors.New("tenant membership not found")
ErrPlanLimitReached = errors.New("plan limit reached")
@@ -44,7 +48,7 @@ func NewService(repo db.Repository, billingService interface {
// ============================================
func (s *Service) ListLocations(ctx context.Context, principal domain.Principal) ([]domain.Location, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
@@ -56,21 +60,25 @@ func (s *Service) ListLocations(ctx context.Context, principal domain.Principal)
locations := make([]domain.Location, len(records))
for i, rec := range records {
locations[i] = domain.Location{
ID: rec.ID,
TenantID: rec.TenantID,
Name: rec.Name,
Type: "room", // Default type
Capacity: 10, // Default capacity
Timezone: rec.Timezone,
CreatedAt: rec.CreatedAt,
}
locations[i] = locationToDomain(rec)
}
return locations, nil
}
func locationToDomain(rec db.LocationRecord) domain.Location {
return domain.Location{
ID: rec.ID,
TenantID: rec.TenantID,
Name: rec.Name,
Type: rec.Type,
Capacity: rec.Capacity,
Timezone: rec.Timezone,
CreatedAt: rec.CreatedAt,
}
}
func (s *Service) CreateLocation(ctx context.Context, principal domain.Principal, req domain.CreateLocationRequest) (domain.Location, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.Location{}, ErrTenantMembership
}
@@ -87,9 +95,20 @@ func (s *Service) CreateLocation(ctx context.Context, principal domain.Principal
}
}
locationType := strings.TrimSpace(req.Type)
if locationType == "" {
locationType = "room"
}
capacity := req.Capacity
if capacity <= 0 {
capacity = 1
}
params := db.CreateLocationParams{
TenantID: membership.Tenant.ID,
Name: req.Name,
Type: locationType,
Capacity: capacity,
Timezone: membership.Tenant.Timezone,
}
@@ -110,19 +129,11 @@ func (s *Service) CreateLocation(ctx context.Context, principal domain.Principal
}
}
return domain.Location{
ID: rec.ID,
TenantID: rec.TenantID,
Name: rec.Name,
Type: req.Type,
Capacity: 10,
Timezone: rec.Timezone,
CreatedAt: rec.CreatedAt,
}, nil
return locationToDomain(rec), nil
}
func (s *Service) UpdateLocation(ctx context.Context, principal domain.Principal, locationID string, req domain.UpdateLocationRequest) (domain.Location, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.Location{}, ErrTenantMembership
}
@@ -140,25 +151,23 @@ func (s *Service) UpdateLocation(ctx context.Context, principal domain.Principal
if req.Name != "" {
params.Name = &req.Name
}
if req.Type != "" {
params.Type = &req.Type
}
if req.Capacity > 0 {
params.Capacity = &req.Capacity
}
rec, err := s.repo.UpdateLocation(ctx, locationID, params)
if err != nil {
return domain.Location{}, err
}
return domain.Location{
ID: rec.ID,
TenantID: rec.TenantID,
Name: rec.Name,
Type: req.Type,
Capacity: 10,
Timezone: rec.Timezone,
CreatedAt: rec.CreatedAt,
}, nil
return locationToDomain(rec), nil
}
func (s *Service) DeleteLocation(ctx context.Context, principal domain.Principal, locationID string) error {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
@@ -179,7 +188,7 @@ func (s *Service) DeleteLocation(ctx context.Context, principal domain.Principal
// ============================================
func (s *Service) ListBlockedDays(ctx context.Context, principal domain.Principal, from time.Time, to time.Time) ([]domain.BlockedDay, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
@@ -205,7 +214,7 @@ func (s *Service) ListBlockedDays(ctx context.Context, principal domain.Principa
}
func (s *Service) CreateBlockedDay(ctx context.Context, principal domain.Principal, req domain.CreateBlockedDayRequest) (domain.BlockedDay, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.BlockedDay{}, ErrTenantMembership
}
@@ -241,7 +250,7 @@ func (s *Service) CreateBlockedDay(ctx context.Context, principal domain.Princip
}
func (s *Service) UpdateBlockedDay(ctx context.Context, principal domain.Principal, blockedDayID string, req domain.UpdateBlockedDayRequest) (domain.BlockedDay, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.BlockedDay{}, ErrTenantMembership
}
@@ -288,7 +297,7 @@ func (s *Service) UpdateBlockedDay(ctx context.Context, principal domain.Princip
}
func (s *Service) DeleteBlockedDay(ctx context.Context, principal domain.Principal, blockedDayID string) error {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
@@ -318,7 +327,7 @@ func (s *Service) DeleteBlockedDay(ctx context.Context, principal domain.Princip
// ============================================
func (s *Service) ListCustomers(ctx context.Context, principal domain.Principal, limit int, offset int) ([]domain.Customer, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
@@ -353,7 +362,7 @@ func (s *Service) ListCustomers(ctx context.Context, principal domain.Principal,
}
func (s *Service) CreateCustomer(ctx context.Context, principal domain.Principal, req domain.CreateCustomerRequest) (domain.Customer, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.Customer{}, ErrTenantMembership
}
@@ -390,7 +399,7 @@ func (s *Service) CreateCustomer(ctx context.Context, principal domain.Principal
}
func (s *Service) UpdateCustomer(ctx context.Context, principal domain.Principal, customerID string, req domain.UpdateCustomerRequest) (domain.Customer, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.Customer{}, ErrTenantMembership
}
@@ -444,7 +453,7 @@ func (s *Service) UpdateCustomer(ctx context.Context, principal domain.Principal
}
func (s *Service) DeleteCustomer(ctx context.Context, principal domain.Principal, customerID string) error {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
@@ -465,7 +474,7 @@ func (s *Service) DeleteCustomer(ctx context.Context, principal domain.Principal
// ============================================
func (s *Service) ListWorkingHours(ctx context.Context, principal domain.Principal) ([]domain.WorkingHours, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
@@ -488,18 +497,129 @@ func (s *Service) ListWorkingHours(ctx context.Context, principal domain.Princip
}
func (s *Service) UpdateWorkingHours(ctx context.Context, principal domain.Principal, dayOfWeek int, req domain.UpdateWorkingHoursRequest) error {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
params := db.UpdateWorkingHoursParams{}
params := db.UpdateWorkingHoursParams{IsOpen: req.IsOpen}
if req.Open != "" {
if _, err := time.Parse("15:04", req.Open); err != nil {
return fmt.Errorf("%w: open must be HH:MM", ErrInvalidBooking)
}
params.StartsLocal = &req.Open
}
if req.Close != "" {
if _, err := time.Parse("15:04", req.Close); err != nil {
return fmt.Errorf("%w: close must be HH:MM", ErrInvalidBooking)
}
params.EndsLocal = &req.Close
}
return s.repo.UpdateWorkingHours(ctx, membership.Tenant.ID, dayOfWeek, params)
}
// CreateBooking creates a manual booking from the dashboard. The service name
// is resolved against the tenant's services; when it matches nothing, the
// tenant's first service is used so the booking still carries a duration.
func (s *Service) CreateBooking(ctx context.Context, principal domain.Principal, req domain.DashboardBookingRequest) (domain.UpcomingBooking, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.UpcomingBooking{}, ErrTenantMembership
}
tenant := membership.Tenant
startsAt, err := time.Parse(time.RFC3339, req.StartsAt)
if err != nil {
return domain.UpcomingBooking{}, fmt.Errorf("%w: startsAt must be RFC3339", ErrInvalidBooking)
}
customerName := strings.TrimSpace(req.CustomerName)
customerEmail := strings.TrimSpace(req.CustomerEmail)
if len(customerName) < 2 || len(customerName) > 120 {
return domain.UpcomingBooking{}, fmt.Errorf("%w: customerName must be between 2 and 120 characters", ErrInvalidBooking)
}
if _, err := mail.ParseAddress(customerEmail); err != nil {
return domain.UpcomingBooking{}, fmt.Errorf("%w: customerEmail must be valid", ErrInvalidBooking)
}
services, err := s.repo.ListServicesByTenant(ctx, tenant.ID)
if err != nil {
return domain.UpcomingBooking{}, err
}
if len(services) == 0 {
return domain.UpcomingBooking{}, fmt.Errorf("%w: tenant has no services configured", ErrInvalidBooking)
}
selected := services[0]
requested := strings.TrimSpace(req.Service)
for _, service := range services {
if strings.EqualFold(service.Name, requested) || service.ID == requested {
selected = service
break
}
}
endsAt := startsAt.Add(time.Duration(selected.DurationMinutes) * time.Minute)
// Upsert the customer record so the booking shows up under the customers page.
customer, err := s.repo.GetCustomerByEmail(ctx, tenant.ID, customerEmail)
if err != nil {
customer, err = s.repo.CreateCustomer(ctx, db.CreateCustomerParams{
TenantID: tenant.ID,
Name: customerName,
Email: customerEmail,
Status: "active",
})
if err != nil {
return domain.UpcomingBooking{}, err
}
}
customerPhone := strings.TrimSpace(req.CustomerPhone)
if customerPhone != "" && (customer.Phone == nil || *customer.Phone != customerPhone) {
_, _ = s.repo.UpdateCustomer(ctx, customer.ID, db.UpdateCustomerParams{Phone: &customerPhone})
}
existing, err := s.repo.ListBookingsByTenantBetween(ctx, tenant.ID, startsAt, endsAt)
if err != nil {
return domain.UpcomingBooking{}, err
}
for _, booking := range existing {
if booking.Status == "cancelled" || booking.Status == "waitlisted" {
continue
}
if bookings.AppointmentResourceConflict(booking, nil, nil) {
return domain.UpcomingBooking{}, ErrBookingConflict
}
}
created, err := s.repo.CreateBooking(ctx, db.CreateBookingParams{
TenantID: tenant.ID,
ServiceID: &selected.ID,
BookingMode: "appointment",
CustomerName: customerName,
CustomerEmail: customerEmail,
CustomerPhone: customerPhone,
StartsAt: startsAt.UTC(),
EndsAt: endsAt.UTC(),
Status: "confirmed",
Reference: db.Reference("BK", time.Now()),
ManageToken: bookings.GenerateManageToken(),
Notes: strings.TrimSpace(req.Notes),
})
if err != nil {
return domain.UpcomingBooking{}, err
}
return domain.UpcomingBooking{
ID: created.ID,
Reference: created.Reference,
CustomerName: customerName,
CustomerEmail: customerEmail,
Service: selected.Name,
StartsAt: startsAt,
EndsAt: endsAt,
Status: created.Status,
Notes: strings.TrimSpace(req.Notes),
}, nil
}
+3 -50
View File
@@ -24,10 +24,6 @@ type Config struct {
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
@@ -58,10 +54,6 @@ func Load() (Config, error) {
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(),
@@ -110,19 +102,6 @@ func (cfg Config) validateRuntimeRequirements() error {
return nil
}
func (cfg Config) PaddleConfigured() bool {
return strings.TrimSpace(cfg.PaddleAPIKey) != ""
}
func (cfg Config) PaddleWebhookConfigured() bool {
return strings.TrimSpace(cfg.PaddleWebhookKey) != ""
}
func (cfg Config) PaddleCheckoutConfigured(planCode string) bool {
planCode = shared.NormalizePlanCode(planCode)
return cfg.PaddleConfigured() && cfg.PaddleWebhookConfigured() && cfg.PaddlePriceMatrix[planCode]["czk"] != "" && cfg.PaddlePriceMatrix[planCode]["usd"] != ""
}
func (cfg Config) StripeConfigured() bool {
return strings.TrimSpace(cfg.StripeAPIKey) != ""
}
@@ -137,18 +116,15 @@ func (cfg Config) StripeCheckoutConfigured(planCode string) bool {
}
func (cfg Config) BillingProvider() string {
if cfg.StripeConfigured() {
return "stripe"
}
return "paddle"
return "stripe"
}
func (cfg Config) BillingConfigured() bool {
return cfg.StripeConfigured() || cfg.PaddleConfigured()
return cfg.StripeConfigured()
}
func (cfg Config) BillingWebhookConfigured() bool {
return cfg.StripeWebhookConfigured() || cfg.PaddleWebhookConfigured()
return cfg.StripeWebhookConfigured()
}
func (cfg Config) SMSConfigured() bool {
@@ -170,20 +146,6 @@ func (cfg Config) StripeSMSPriceID(currency string) string {
return cfg.StripeSMSPriceMatrix["czk"]
}
func paddlePriceMatrixFromEnv() map[string]map[string]string {
matrix := map[string]map[string]string{
"starter": {},
"pro": {},
"business": {},
}
for _, planCode := range []string{"starter", "pro", "business"} {
envPlan := strings.ToUpper(strings.ReplaceAll(planCode, "-", "_"))
matrix[planCode]["czk"] = strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_" + envPlan + "_CZK_PRICE_ID"))
matrix[planCode]["usd"] = strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_" + envPlan + "_USD_PRICE_ID"))
}
return matrix
}
func stripePriceMatrixFromEnv() map[string]map[string]string {
matrix := map[string]map[string]string{
"starter": {},
@@ -210,15 +172,6 @@ func stripePriceMatrixFromEnv() map[string]map[string]string {
return matrix
}
func normalizePaddleEnvironment(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "live", "production":
return "live"
default:
return "sandbox"
}
}
func valueOrDefault(key string, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
+19 -13
View File
@@ -2,31 +2,37 @@ package config
import "testing"
func TestPaddleCheckoutConfigured(t *testing.T) {
func TestStripeCheckoutConfigured(t *testing.T) {
cfg := Config{
PaddleAPIKey: "pdl_sdbx_apikey_123",
PaddleWebhookKey: "pdl_ntf_123",
PaddlePriceMatrix: map[string]map[string]string{
"starter": {"czk": "pri_starter_czk", "usd": "pri_starter_usd"},
"pro": {"czk": "pri_pro_czk", "usd": "pri_pro_usd"},
"business": {"czk": "pri_business_czk", "usd": "pri_business_usd"},
StripeAPIKey: "sk_test_123",
StripeWebhookKey: "whsec_test_123",
StripePriceMatrix: map[string]map[string]string{
"starter": {"czk": "price_starter_czk", "usd": "price_starter_usd"},
"pro": {"czk": "price_pro_czk", "usd": "price_pro_usd"},
"business": {"czk": "price_business_czk", "usd": "price_business_usd"},
},
}
if !cfg.PaddleCheckoutConfigured("pro") {
if !cfg.StripeCheckoutConfigured("pro") {
t.Fatal("expected pro checkout configured")
}
}
func TestPaddleCheckoutConfiguredRequiresWebhook(t *testing.T) {
func TestStripeCheckoutConfiguredRequiresWebhook(t *testing.T) {
cfg := Config{
PaddleAPIKey: "pdl_sdbx_apikey_123",
PaddlePriceMatrix: map[string]map[string]string{
"pro": {"czk": "pri_pro_czk", "usd": "pri_pro_usd"},
StripeAPIKey: "sk_test_123",
StripePriceMatrix: map[string]map[string]string{
"pro": {"czk": "price_pro_czk", "usd": "price_pro_usd"},
},
}
if cfg.PaddleCheckoutConfigured("pro") {
if cfg.StripeCheckoutConfigured("pro") {
t.Fatal("expected checkout disabled without webhook key")
}
}
func TestBillingProviderIsStripe(t *testing.T) {
if (Config{}).BillingProvider() != "stripe" {
t.Fatal("expected stripe billing provider")
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
package db
import (
"context"
"context"
)
func (r *PGRepository) GetSubscriptionSnapshot(ctx context.Context, tenantID string) (BillingSnapshotRecord, error) {
@@ -53,7 +53,7 @@ func (r *PGRepository) UpsertSubscriptionSnapshot(ctx context.Context, params Bi
payment_method_last4 = EXCLUDED.payment_method_last4,
last_synced_at = EXCLUDED.last_synced_at,
updated_at = now()
`, params.TenantID, firstNonEmpty(params.BillingProvider, "paddle"), params.BillingCustomerID, params.BillingSubscriptionID, params.Status, params.PlanCode,
`, params.TenantID, firstNonEmpty(params.BillingProvider, "stripe"), params.BillingCustomerID, params.BillingSubscriptionID, params.Status, params.PlanCode,
firstNonEmpty(params.Currency, "czk"), params.PriceID, params.CancelAtPeriodEnd, params.CurrentPeriodStart, params.CurrentPeriodEnd,
params.PaymentMethodBrand, params.PaymentMethodLast4, params.LastSyncedAt)
return err
@@ -64,7 +64,7 @@ func (r *PGRepository) RecordBillingEvent(ctx context.Context, tenantID string,
INSERT INTO subscription_events (tenant_id, billing_provider, billing_provider_event_id, event_type, payload, processed_at)
VALUES ($1, $2, $3, $4, $5::jsonb, now())
ON CONFLICT (billing_provider, billing_provider_event_id) DO NOTHING
`, tenantID, firstNonEmpty(provider, "paddle"), eventID, eventType, payload)
`, tenantID, firstNonEmpty(provider, "stripe"), eventID, eventType, payload)
if err != nil {
return false, err
}
+9 -5
View File
@@ -8,7 +8,8 @@ import (
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, customer_phone, starts_at, ends_at, status, reference
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference,
COALESCE(manage_token, ''), COALESCE(notes, '')
FROM bookings
WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2
ORDER BY starts_at ASC
@@ -35,6 +36,8 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID
&record.EndsAt,
&record.Status,
&record.Reference,
&record.ManageToken,
&record.Notes,
); err != nil {
return nil, err
}
@@ -49,10 +52,10 @@ func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingPa
INSERT INTO bookings (
tenant_id, service_id, class_session_id, staff_id, location_id,
booking_mode, customer_name, customer_email, customer_phone, starts_at, ends_at,
status, reference, notes
status, reference, manage_token, notes
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
RETURNING id, reference, status
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
RETURNING id, reference, status, manage_token
`,
params.TenantID,
params.ServiceID,
@@ -67,8 +70,9 @@ func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingPa
params.EndsAt,
params.Status,
params.Reference,
params.ManageToken,
params.Notes,
).Scan(&created.ID, &created.Reference, &created.Status)
).Scan(&created.ID, &created.Reference, &created.Status, &created.ManageToken)
return created, err
}
@@ -0,0 +1,55 @@
package db
import (
"context"
)
func (r *PGRepository) ListEmailTemplates(ctx context.Context, tenantID string) ([]EmailTemplateRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, type, subject, body_html, body_text, is_enabled, updated_at
FROM email_templates
WHERE tenant_id = $1
ORDER BY type
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var records []EmailTemplateRecord
for rows.Next() {
var rec EmailTemplateRecord
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.Type, &rec.Subject, &rec.BodyHTML, &rec.BodyText, &rec.IsEnabled, &rec.UpdatedAt); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (r *PGRepository) GetEmailTemplate(ctx context.Context, tenantID string, templateType string) (EmailTemplateRecord, error) {
var rec EmailTemplateRecord
err := r.pool.QueryRow(ctx, `
SELECT id, tenant_id, type, subject, body_html, body_text, is_enabled, updated_at
FROM email_templates
WHERE tenant_id = $1 AND type = $2
`, tenantID, templateType).Scan(&rec.ID, &rec.TenantID, &rec.Type, &rec.Subject, &rec.BodyHTML, &rec.BodyText, &rec.IsEnabled, &rec.UpdatedAt)
return rec, err
}
func (r *PGRepository) UpsertEmailTemplate(ctx context.Context, params EmailTemplateRecord) (EmailTemplateRecord, error) {
var rec EmailTemplateRecord
err := r.pool.QueryRow(ctx, `
INSERT INTO email_templates (tenant_id, type, subject, body_html, body_text, is_enabled)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (tenant_id, type) DO UPDATE SET
subject = EXCLUDED.subject,
body_html = EXCLUDED.body_html,
body_text = EXCLUDED.body_text,
is_enabled = EXCLUDED.is_enabled,
updated_at = now()
RETURNING id, tenant_id, type, subject, body_html, body_text, is_enabled, updated_at
`, params.TenantID, params.Type, params.Subject, params.BodyHTML, params.BodyText, params.IsEnabled).Scan(
&rec.ID, &rec.TenantID, &rec.Type, &rec.Subject, &rec.BodyHTML, &rec.BodyText, &rec.IsEnabled, &rec.UpdatedAt)
return rec, err
}
+42 -19
View File
@@ -1,13 +1,15 @@
package db
import (
"context"
"time"
"context"
"time"
)
const locationColumns = "id, tenant_id, name, type, capacity, timezone, created_at"
func (r *PGRepository) ListLocationsByTenant(ctx context.Context, tenantID string) ([]LocationRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, name, timezone, created_at
SELECT `+locationColumns+`
FROM locations
WHERE tenant_id = $1
ORDER BY name
@@ -20,7 +22,7 @@ func (r *PGRepository) ListLocationsByTenant(ctx context.Context, tenantID strin
var records []LocationRecord
for rows.Next() {
var rec LocationRecord
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt); err != nil {
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Type, &rec.Capacity, &rec.Timezone, &rec.CreatedAt); err != nil {
return nil, err
}
records = append(records, rec)
@@ -31,20 +33,20 @@ func (r *PGRepository) ListLocationsByTenant(ctx context.Context, tenantID strin
func (r *PGRepository) GetLocationByID(ctx context.Context, locationID string) (LocationRecord, error) {
var rec LocationRecord
err := r.pool.QueryRow(ctx, `
SELECT id, tenant_id, name, timezone, created_at
SELECT `+locationColumns+`
FROM locations
WHERE id = $1
`, locationID).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt)
`, locationID).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Type, &rec.Capacity, &rec.Timezone, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) CreateLocation(ctx context.Context, params CreateLocationParams) (LocationRecord, error) {
var rec LocationRecord
err := r.pool.QueryRow(ctx, `
INSERT INTO locations (tenant_id, name, timezone)
VALUES ($1, $2, $3)
RETURNING id, tenant_id, name, timezone, created_at
`, params.TenantID, params.Name, params.Timezone).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt)
INSERT INTO locations (tenant_id, name, type, capacity, timezone)
VALUES ($1, $2, $3, $4, $5)
RETURNING `+locationColumns+`
`, params.TenantID, params.Name, params.Type, params.Capacity, params.Timezone).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Type, &rec.Capacity, &rec.Timezone, &rec.CreatedAt)
return rec, err
}
@@ -53,11 +55,13 @@ func (r *PGRepository) UpdateLocation(ctx context.Context, locationID string, pa
err := r.pool.QueryRow(ctx, `
UPDATE locations
SET name = COALESCE($2, name),
timezone = COALESCE($3, timezone),
type = COALESCE($3, type),
capacity = COALESCE($4, capacity),
timezone = COALESCE($5, timezone),
updated_at = now()
WHERE id = $1
RETURNING id, tenant_id, name, timezone, created_at
`, locationID, params.Name, params.Timezone).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt)
RETURNING `+locationColumns+`
`, locationID, params.Name, params.Type, params.Capacity, params.Timezone).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Type, &rec.Capacity, &rec.Timezone, &rec.CreatedAt)
return rec, err
}
@@ -120,7 +124,7 @@ func (r *PGRepository) DeleteBlockedDay(ctx context.Context, blockedDayID string
func (r *PGRepository) ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT tenant_id, staff_id, day_of_week, starts_local, ends_local
SELECT tenant_id, staff_id, day_of_week, starts_local::text, ends_local::text
FROM availability_rules
WHERE tenant_id = $1 AND staff_id IS NULL
ORDER BY day_of_week
@@ -142,11 +146,30 @@ func (r *PGRepository) ListWorkingHoursByTenant(ctx context.Context, tenantID st
}
func (r *PGRepository) UpdateWorkingHours(ctx context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error {
_, err := r.pool.Exec(ctx, `
UPDATE availability_rules
SET starts_local = COALESCE($3, starts_local),
ends_local = COALESCE($4, ends_local)
if params.IsOpen != nil && !*params.IsOpen {
_, err := r.pool.Exec(ctx, `
DELETE FROM availability_rules
WHERE tenant_id = $1 AND day_of_week = $2 AND staff_id IS NULL
`, tenantID, dayOfWeek)
return err
}
if params.StartsLocal == nil || params.EndsLocal == nil {
return nil
}
// availability_rules has no unique constraint on (tenant, day): replace the
// tenant-level row so repeated updates stay idempotent.
if _, err := r.pool.Exec(ctx, `
DELETE FROM availability_rules
WHERE tenant_id = $1 AND day_of_week = $2 AND staff_id IS NULL
`, tenantID, dayOfWeek, params.StartsLocal, params.EndsLocal)
`, tenantID, dayOfWeek); err != nil {
return err
}
_, err := r.pool.Exec(ctx, `
INSERT INTO availability_rules (id, tenant_id, staff_id, day_of_week, starts_local, ends_local)
VALUES (gen_random_uuid(), $1, NULL, $2, $3::time, $4::time)
`, tenantID, dayOfWeek, *params.StartsLocal, *params.EndsLocal)
return err
}
+152 -26
View File
@@ -19,7 +19,9 @@ type Repository interface {
GetTenantByBillingCustomerID(ctx context.Context, customerID string) (TenantRecord, error)
EnsureUserIdentity(ctx context.Context, subject string, email string, displayName string) error
CreateTenantForUser(ctx context.Context, params CreateTenantForUserParams) (TenantMembershipRecord, error)
GetTenantMembershipByUserID(ctx context.Context, userID string) (TenantMembershipRecord, error)
GetTenantMembershipBySubject(ctx context.Context, subject string) (TenantMembershipRecord, error)
IsAdminSubject(ctx context.Context, subject string) (bool, error)
GetTenantOwnerEmail(ctx context.Context, tenantID string) (string, error)
GetBrandProfile(ctx context.Context, tenantID string) (BrandProfileRecord, error)
ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error)
ListAvailabilityRulesByTenant(ctx context.Context, tenantID string) ([]AvailabilityRuleRecord, error)
@@ -34,8 +36,9 @@ type Repository interface {
GetDashboardMetrics(ctx context.Context, tenantID string, startsAt time.Time, endsAt time.Time) (DashboardMetrics, error)
GetSubscriptionSnapshot(ctx context.Context, tenantID string) (BillingSnapshotRecord, error)
UpsertSubscriptionSnapshot(ctx context.Context, params BillingSnapshotRecord) error
UpdateTenantBillingCustomerID(ctx context.Context, tenantID string, customerID string) error
UpdateTenantBillingState(ctx context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID string) error
UpdateTenantBillingCustomerID(ctx context.Context, tenantID string, provider string, customerID string) error
UpdateTenantBillingState(ctx context.Context, tenantID string, provider string, planCode string, subscriptionStatus string, subscriptionID string) error
UpdateBrandProfile(ctx context.Context, tenantID string, params UpdateBrandProfileParams) (BrandProfileRecord, error)
RecordBillingEvent(ctx context.Context, tenantID string, provider string, eventID string, eventType string, payload []byte) (bool, error)
// Auth methods
@@ -87,6 +90,11 @@ type Repository interface {
ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error)
UpdateWorkingHours(ctx context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error
// Email templates
ListEmailTemplates(ctx context.Context, tenantID string) ([]EmailTemplateRecord, error)
GetEmailTemplate(ctx context.Context, tenantID string, templateType string) (EmailTemplateRecord, error)
UpsertEmailTemplate(ctx context.Context, params EmailTemplateRecord) (EmailTemplateRecord, error)
// SMS
GetTenantSMSSettings(ctx context.Context, tenantID string) (TenantSMSSettingsRecord, error)
UpsertTenantSMSSettings(ctx context.Context, params TenantSMSSettingsRecord) error
@@ -190,6 +198,13 @@ type BrandProfileRecord struct {
UmamiSiteID string
}
type UpdateBrandProfileParams struct {
Name *string
SiteURL *string
LogoURL *string
PrimaryColor *string
}
type AvailabilityBlockRecord struct {
DayOfWeek int
StartsLocal string
@@ -246,6 +261,8 @@ type BookingRecord struct {
EndsAt time.Time
Status string
Reference string
ManageToken string
Notes string
}
type CreateBookingParams struct {
@@ -262,13 +279,15 @@ type CreateBookingParams struct {
EndsAt time.Time
Status string
Reference string
ManageToken string
Notes string
}
type CreatedBooking struct {
ID string
Reference string
Status string
ID string
Reference string
Status string
ManageToken string
}
type WaitlistEntryParams struct {
@@ -341,6 +360,8 @@ type LocationRecord struct {
ID string
TenantID string
Name string
Type string
Capacity int
Timezone string
CreatedAt time.Time
}
@@ -348,11 +369,15 @@ type LocationRecord struct {
type CreateLocationParams struct {
TenantID string
Name string
Type string
Capacity int
Timezone string
}
type UpdateLocationParams struct {
Name *string
Type *string
Capacity *int
Timezone *string
}
@@ -428,6 +453,18 @@ type UpdateWorkingHoursParams struct {
IsOpen *bool
}
// Email Template Records
type EmailTemplateRecord struct {
ID string
TenantID string
Type string
Subject string
BodyHTML string
BodyText string
IsEnabled bool
UpdatedAt time.Time
}
// SMS Records
type TenantSMSSettingsRecord struct {
TenantID string
@@ -592,11 +629,12 @@ 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, customer_phone, starts_at, ends_at, status, reference
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference,
COALESCE(manage_token, ''), COALESCE(notes, '')
FROM bookings
WHERE reference = $1
`, reference).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID,
&rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference)
&rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes)
return rec, err
}
@@ -638,6 +676,7 @@ type MemoryRepository struct {
smsSettings TenantSMSSettingsRecord
smsLogs []SMSUsageLogRecord
smsReports []SMSMonthlyReportRecord
emailTemplates []EmailTemplateRecord
}
func NewMemoryRepository() *MemoryRepository {
@@ -659,7 +698,7 @@ func NewMemoryRepository() *MemoryRepository {
Timezone: "Europe/Prague",
PlanCode: "pro",
SubscriptionStatus: "active",
BillingProvider: "paddle",
BillingProvider: "stripe",
BillingCustomerID: stringPtr("ctm_demo_bookra"),
},
membership: TenantMembershipRecord{
@@ -672,7 +711,7 @@ func NewMemoryRepository() *MemoryRepository {
Timezone: "Europe/Prague",
PlanCode: "pro",
SubscriptionStatus: "active",
BillingProvider: "paddle",
BillingProvider: "stripe",
BillingCustomerID: stringPtr("ctm_demo_bookra"),
},
UserID: "demo-owner",
@@ -719,7 +758,7 @@ func NewMemoryRepository() *MemoryRepository {
deliveryLogs: []NotificationDeliveryLogParams{},
billingSnapshot: BillingSnapshotRecord{
TenantID: tenantID,
BillingProvider: "paddle",
BillingProvider: "stripe",
BillingCustomerID: "ctm_demo_bookra",
BillingSubscriptionID: "",
Status: "active",
@@ -841,16 +880,46 @@ func (r *MemoryRepository) CreateTenantForUser(_ context.Context, params CreateT
return r.membership, nil
}
func (r *MemoryRepository) GetTenantMembershipByUserID(_ context.Context, userID string) (TenantMembershipRecord, error) {
if userID == "" || userID == "demo-owner" {
func (r *MemoryRepository) GetTenantMembershipBySubject(_ context.Context, subject string) (TenantMembershipRecord, error) {
if subject == "" || subject == "demo-owner" {
return r.membership, nil
}
if userID == r.membership.UserID {
if subject == r.membership.UserID {
return r.membership, nil
}
return TenantMembershipRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) IsAdminSubject(_ context.Context, subject string) (bool, error) {
return subject == "demo-owner" || subject == r.membership.UserID, nil
}
func (r *MemoryRepository) GetTenantOwnerEmail(_ context.Context, tenantID string) (string, error) {
if tenantID != r.tenant.ID {
return "", pgx.ErrNoRows
}
return "[email protected]", nil
}
func (r *MemoryRepository) UpdateBrandProfile(_ context.Context, tenantID string, params UpdateBrandProfileParams) (BrandProfileRecord, error) {
if tenantID != r.tenant.ID {
return BrandProfileRecord{}, pgx.ErrNoRows
}
if params.Name != nil {
r.brand.Name = *params.Name
}
if params.SiteURL != nil {
r.brand.SiteURL = *params.SiteURL
}
if params.LogoURL != nil {
r.brand.LogoURL = *params.LogoURL
}
if params.PrimaryColor != nil {
r.brand.PrimaryColor = *params.PrimaryColor
}
return r.brand, nil
}
func (r *MemoryRepository) GetBrandProfile(_ context.Context, tenantID string) (BrandProfileRecord, error) {
if tenantID != r.tenant.ID {
return BrandProfileRecord{}, pgx.ErrNoRows
@@ -909,9 +978,10 @@ func (r *MemoryRepository) ListBookingsByTenantBetween(_ context.Context, tenant
func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBookingParams) (CreatedBooking, error) {
created := CreatedBooking{
ID: uuid.NewString(),
Reference: params.Reference,
Status: params.Status,
ID: uuid.NewString(),
Reference: params.Reference,
Status: params.Status,
ManageToken: params.ManageToken,
}
r.bookings = append(r.bookings, BookingRecord{
ID: created.ID,
@@ -922,10 +992,13 @@ func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBooking
LocationID: params.LocationID,
CustomerName: params.CustomerName,
CustomerEmail: params.CustomerEmail,
CustomerPhone: params.CustomerPhone,
StartsAt: params.StartsAt,
EndsAt: params.EndsAt,
Status: params.Status,
Reference: params.Reference,
ManageToken: params.ManageToken,
Notes: params.Notes,
})
return created, nil
}
@@ -1033,38 +1106,38 @@ func (r *MemoryRepository) UpsertSubscriptionSnapshot(_ context.Context, params
return nil
}
func (r *MemoryRepository) UpdateTenantBillingCustomerID(_ context.Context, tenantID string, customerID string) error {
func (r *MemoryRepository) UpdateTenantBillingCustomerID(_ context.Context, tenantID string, provider string, customerID string) error {
if tenantID != r.tenant.ID {
return pgx.ErrNoRows
}
r.tenant.BillingProvider = "paddle"
r.tenant.BillingProvider = provider
r.tenant.BillingCustomerID = &customerID
r.membership.Tenant.BillingProvider = "paddle"
r.membership.Tenant.BillingProvider = provider
r.membership.Tenant.BillingCustomerID = &customerID
r.billingSnapshot.BillingProvider = "paddle"
r.billingSnapshot.BillingProvider = provider
r.billingSnapshot.BillingCustomerID = customerID
return nil
}
func (r *MemoryRepository) UpdateTenantBillingState(_ context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID string) error {
func (r *MemoryRepository) UpdateTenantBillingState(_ context.Context, tenantID string, provider string, planCode string, subscriptionStatus string, subscriptionID string) error {
if tenantID != r.tenant.ID {
return pgx.ErrNoRows
}
r.tenant.PlanCode = planCode
r.tenant.SubscriptionStatus = subscriptionStatus
r.tenant.BillingProvider = "paddle"
r.tenant.BillingProvider = provider
r.membership.Tenant.PlanCode = planCode
r.membership.Tenant.SubscriptionStatus = subscriptionStatus
r.membership.Tenant.BillingProvider = "paddle"
r.membership.Tenant.BillingProvider = provider
r.billingSnapshot.PlanCode = planCode
r.billingSnapshot.Status = subscriptionStatus
r.billingSnapshot.BillingProvider = "paddle"
r.billingSnapshot.BillingProvider = provider
r.billingSnapshot.BillingSubscriptionID = subscriptionID
return nil
}
func (r *MemoryRepository) RecordBillingEvent(_ context.Context, _ string, provider string, eventID string, _ string, payload []byte) (bool, error) {
key := firstNonEmpty(provider, "paddle") + ":" + eventID
key := firstNonEmpty(provider, "stripe") + ":" + eventID
if _, exists := r.recordedEvents[key]; exists {
return false, nil
}
@@ -1103,6 +1176,8 @@ func (r *MemoryRepository) CreateLocation(_ context.Context, params CreateLocati
ID: uuid.NewString(),
TenantID: params.TenantID,
Name: params.Name,
Type: params.Type,
Capacity: params.Capacity,
Timezone: params.Timezone,
CreatedAt: time.Now(),
}
@@ -1116,6 +1191,12 @@ func (r *MemoryRepository) UpdateLocation(_ context.Context, locationID string,
if params.Name != nil {
r.locations[i].Name = *params.Name
}
if params.Type != nil {
r.locations[i].Type = *params.Type
}
if params.Capacity != nil {
r.locations[i].Capacity = *params.Capacity
}
if params.Timezone != nil {
r.locations[i].Timezone = *params.Timezone
}
@@ -1352,6 +1433,51 @@ func (r *MemoryRepository) RescheduleBooking(_ context.Context, bookingID string
return pgx.ErrNoRows
}
// ============================================
// EMAIL TEMPLATE METHODS - MEMORY REPOSITORY
// ============================================
func (r *MemoryRepository) ListEmailTemplates(_ context.Context, tenantID string) ([]EmailTemplateRecord, error) {
if tenantID != r.tenant.ID {
return nil, pgx.ErrNoRows
}
return r.emailTemplates, nil
}
func (r *MemoryRepository) GetEmailTemplate(_ context.Context, tenantID string, templateType string) (EmailTemplateRecord, error) {
for _, t := range r.emailTemplates {
if t.TenantID == tenantID && t.Type == templateType {
return t, nil
}
}
return EmailTemplateRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) UpsertEmailTemplate(_ context.Context, params EmailTemplateRecord) (EmailTemplateRecord, error) {
for i, t := range r.emailTemplates {
if t.TenantID == params.TenantID && t.Type == params.Type {
r.emailTemplates[i].Subject = params.Subject
r.emailTemplates[i].BodyHTML = params.BodyHTML
r.emailTemplates[i].BodyText = params.BodyText
r.emailTemplates[i].IsEnabled = params.IsEnabled
r.emailTemplates[i].UpdatedAt = time.Now()
return r.emailTemplates[i], nil
}
}
rec := EmailTemplateRecord{
ID: uuid.NewString(),
TenantID: params.TenantID,
Type: params.Type,
Subject: params.Subject,
BodyHTML: params.BodyHTML,
BodyText: params.BodyText,
IsEnabled: params.IsEnabled,
UpdatedAt: time.Now(),
}
r.emailTemplates = append(r.emailTemplates, rec)
return rec, nil
}
// ============================================
// WORKING HOURS METHODS - MEMORY REPOSITORY
// ============================================
+2 -2
View File
@@ -1,8 +1,8 @@
package db
import (
"context"
"time"
"context"
"time"
)
func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error) {
+80 -19
View File
@@ -2,6 +2,8 @@ package db
import (
"context"
"errors"
"strings"
"github.com/jackc/pgx/v5"
)
@@ -49,9 +51,20 @@ func (r *PGRepository) GetTenantByBillingCustomerID(ctx context.Context, custome
}
func (r *PGRepository) EnsureUserIdentity(ctx context.Context, subject string, email string, displayName string) error {
// Link an existing password-auth account to this Neon subject when the
// email already exists but has no subject attached yet.
if trimmed := strings.TrimSpace(email); trimmed != "" {
if _, err := r.pool.Exec(ctx, `
UPDATE users SET neon_subject = $1
WHERE email = $2 AND neon_subject IS NULL
`, subject, trimmed); err != nil {
return err
}
}
_, err := r.pool.Exec(ctx, `
INSERT INTO users (id, neon_subject, email, display_name)
VALUES (gen_random_uuid(), $1, COALESCE(NULLIF($2, ''), $1 || '@users.bookra.invalid'), NULLIF($3, ''))
VALUES (gen_random_uuid(), $1, COALESCE(NULLIF($2, ''), $1 || '@users.bookra.invalid'), COALESCE(NULLIF($3, ''), ''))
ON CONFLICT (neon_subject) DO UPDATE SET email = EXCLUDED.email, display_name = COALESCE(NULLIF($3, ''), users.display_name)
`, subject, email, displayName)
return err
@@ -123,21 +136,22 @@ func (r *PGRepository) CreateTenantForUser(ctx context.Context, params CreateTen
_, err = tx.Exec(ctx, `
INSERT INTO users (id, neon_subject, email, display_name)
VALUES (gen_random_uuid(), $1, $2, $3)
ON CONFLICT (neon_subject) DO NOTHING
`, params.Subject, params.Subject+"@users.bookra.invalid", "")
SELECT gen_random_uuid(), $1, $2, ''
WHERE NOT EXISTS (
SELECT 1 FROM users WHERE neon_subject = $1 OR id::text = $1
)
`, params.Subject, params.Subject+"@users.bookra.invalid")
if err != nil {
return TenantMembershipRecord{}, err
}
var userID string
var membershipID string
err = tx.QueryRow(ctx, `
INSERT INTO tenant_memberships (id, tenant_id, user_neon_subject, role, joined_at)
SELECT gen_random_uuid(), $1, u.neon_subject, 'owner', now()
FROM users u WHERE u.neon_subject = $2
VALUES (gen_random_uuid(), $1, $2, 'owner', now())
ON CONFLICT (tenant_id, user_neon_subject) DO UPDATE SET role = 'owner'
RETURNING id
`, tenantID, params.Subject).Scan(&userID)
`, tenantID, params.Subject).Scan(&membershipID)
if err != nil {
return TenantMembershipRecord{}, err
}
@@ -156,7 +170,7 @@ func (r *PGRepository) CreateTenantForUser(ctx context.Context, params CreateTen
Locale: params.Locale,
Timezone: params.Timezone,
},
UserID: userID,
UserID: membershipID,
Role: "owner",
}, nil
}
@@ -170,7 +184,7 @@ func (r *PGRepository) GetBrandProfile(ctx context.Context, tenantID string) (Br
return record, err
}
func (r *PGRepository) GetTenantMembershipByUserID(ctx context.Context, userID string) (TenantMembershipRecord, error) {
func (r *PGRepository) GetTenantMembershipBySubject(ctx context.Context, subject string) (TenantMembershipRecord, error) {
var record TenantMembershipRecord
err := r.pool.QueryRow(ctx, `
SELECT t.id, t.slug, t.name, t.preset, t.locale, t.timezone, t.plan_code, t.subscription_status,
@@ -178,11 +192,10 @@ func (r *PGRepository) GetTenantMembershipByUserID(ctx context.Context, userID s
tm.user_neon_subject, tm.role
FROM tenant_memberships tm
JOIN tenants t ON t.id = tm.tenant_id
JOIN users u ON u.neon_subject = tm.user_neon_subject
WHERE u.id = $1
WHERE tm.user_neon_subject = $1
ORDER BY tm.joined_at DESC
LIMIT 1
`, userID).Scan(
`, subject).Scan(
&record.Tenant.ID, &record.Tenant.Slug, &record.Tenant.Name, &record.Tenant.Preset,
&record.Tenant.Locale, &record.Tenant.Timezone, &record.Tenant.PlanCode, &record.Tenant.SubscriptionStatus,
&record.Tenant.BillingProvider, &record.Tenant.BillingCustomerID, &record.Tenant.BillingSubscription,
@@ -191,20 +204,68 @@ func (r *PGRepository) GetTenantMembershipByUserID(ctx context.Context, userID s
return record, err
}
func (r *PGRepository) UpdateTenantBillingCustomerID(ctx context.Context, tenantID string, customerID string) error {
func (r *PGRepository) IsAdminSubject(ctx context.Context, subject string) (bool, error) {
var isAdmin bool
err := r.pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM users
WHERE (neon_subject = $1 OR id::text = $1)
AND role IN ('admin', 'superadmin')
)
`, subject).Scan(&isAdmin)
return isAdmin, err
}
func (r *PGRepository) GetTenantOwnerEmail(ctx context.Context, tenantID string) (string, error) {
var email string
err := r.pool.QueryRow(ctx, `
SELECT u.email
FROM tenant_memberships tm
JOIN users u ON u.neon_subject = tm.user_neon_subject OR u.id::text = tm.user_neon_subject
WHERE tm.tenant_id = $1 AND tm.role = 'owner'
ORDER BY tm.joined_at ASC
LIMIT 1
`, tenantID).Scan(&email)
return email, err
}
func (r *PGRepository) UpdateBrandProfile(ctx context.Context, tenantID string, params UpdateBrandProfileParams) (BrandProfileRecord, error) {
var record BrandProfileRecord
err := r.pool.QueryRow(ctx, `
INSERT INTO brand_profiles (tenant_id, name, site_url, logo_url, primary_color)
VALUES ($1, '', NULL, NULL, NULL)
ON CONFLICT (tenant_id) DO NOTHING
`, tenantID).Scan()
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return BrandProfileRecord{}, err
}
err = r.pool.QueryRow(ctx, `
UPDATE brand_profiles
SET name = COALESCE($2, name),
site_url = COALESCE($3, site_url),
logo_url = COALESCE($4, logo_url),
primary_color = COALESCE($5, primary_color)
WHERE tenant_id = $1
RETURNING tenant_id, name, COALESCE(site_url, ''), COALESCE(logo_url, ''), COALESCE(primary_color, ''), COALESCE(umami_site_id, '')
`, tenantID, params.Name, params.SiteURL, params.LogoURL, params.PrimaryColor).Scan(
&record.TenantID, &record.Name, &record.SiteURL, &record.LogoURL, &record.PrimaryColor, &record.UmamiSiteID)
return record, err
}
func (r *PGRepository) UpdateTenantBillingCustomerID(ctx context.Context, tenantID string, provider string, customerID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE tenants
SET billing_provider = 'paddle', billing_customer_id = $2, updated_at = now()
SET billing_provider = $2, billing_customer_id = $3, updated_at = now()
WHERE id = $1
`, tenantID, customerID)
`, tenantID, provider, customerID)
return err
}
func (r *PGRepository) UpdateTenantBillingState(ctx context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID string) error {
func (r *PGRepository) UpdateTenantBillingState(ctx context.Context, tenantID string, provider string, planCode string, subscriptionStatus string, subscriptionID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE tenants
SET billing_provider = 'paddle', plan_code = $2, subscription_status = $3, billing_subscription_id = $4, updated_at = now()
SET billing_provider = $2, plan_code = $3, subscription_status = $4, billing_subscription_id = $5, updated_at = now()
WHERE id = $1
`, tenantID, planCode, subscriptionStatus, subscriptionID)
`, tenantID, provider, planCode, subscriptionStatus, subscriptionID)
return err
}
+32 -10
View File
@@ -16,9 +16,14 @@ type DashboardKPI struct {
}
type UpcomingBooking struct {
ID string `json:"id"`
Reference string `json:"reference"`
CustomerName string `json:"customerName"`
CustomerEmail string `json:"customerEmail"`
Service string `json:"service,omitempty"`
Location string `json:"location,omitempty"`
StaffName string `json:"staffName,omitempty"`
Notes string `json:"notes,omitempty"`
StartsAt time.Time `json:"startsAt"`
EndsAt time.Time `json:"endsAt"`
Status string `json:"status"`
@@ -143,6 +148,7 @@ type CreateBookingResponse struct {
BookingID string `json:"bookingId"`
Reference string `json:"reference"`
Status string `json:"status"`
ManageURL string `json:"manageUrl"`
}
type PlanEntitlements struct {
@@ -207,16 +213,9 @@ type CheckoutSessionRequest struct {
}
type CheckoutLaunchResponse struct {
// Stripe checkout
CheckoutURL string `json:"checkoutUrl,omitempty"`
// Paddle checkout
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"`
CustomData map[string]string `json:"customData,omitempty"`
CheckoutURL string `json:"checkoutUrl,omitempty"`
SuccessRedirectURL string `json:"successRedirectUrl,omitempty"`
CancelRedirectURL string `json:"cancelRedirectUrl,omitempty"`
}
type PortalSessionResponse struct {
@@ -247,6 +246,15 @@ type Location struct {
CreatedAt time.Time `json:"createdAt"`
}
type DashboardBookingRequest struct {
CustomerName string `json:"customerName" binding:"required"`
CustomerEmail string `json:"customerEmail" binding:"required,email"`
CustomerPhone string `json:"customerPhone,omitempty"`
Service string `json:"service,omitempty"`
StartsAt string `json:"startsAt" binding:"required"`
Notes string `json:"notes,omitempty"`
}
type CreateLocationRequest struct {
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required"`
@@ -431,6 +439,20 @@ type EmailTemplate struct {
IsEnabled bool `json:"isEnabled"`
}
type UpdateBrandRequest struct {
Name string `json:"name,omitempty"`
SiteURL string `json:"siteUrl,omitempty"`
LogoURL string `json:"logoUrl,omitempty"`
PrimaryColor string `json:"primaryColor" binding:"required"`
}
type UpdateEmailTemplateRequest struct {
Type string `json:"type" binding:"required"`
Subject string `json:"subject"`
Body string `json:"body"`
IsEnabled *bool `json:"isEnabled,omitempty"`
}
type SendEmailRequest struct {
To string `json:"to" binding:"required,email"`
Subject string `json:"subject" binding:"required"`
+8 -5
View File
@@ -25,10 +25,11 @@ type visitor struct {
}
type RateLimiter struct {
mu sync.Mutex
visitors map[string]*visitor
limit rate.Limit
burst int
mu sync.Mutex
visitors map[string]*visitor
limit rate.Limit
burst int
cleanupOnce sync.Once
}
func NewRateLimiter(limit rate.Limit, burst int) *RateLimiter {
@@ -40,7 +41,9 @@ func NewRateLimiter(limit rate.Limit, burst int) *RateLimiter {
}
func (r *RateLimiter) Middleware() gin.HandlerFunc {
go r.cleanupLoop()
r.cleanupOnce.Do(func() {
go r.cleanupLoop()
})
return func(c *gin.Context) {
ip := c.ClientIP()
@@ -21,6 +21,7 @@ const (
type BookingEmailData struct {
Type EmailType
TenantID string
TenantName string
TenantSlug string
BusinessEmail string
+79 -13
View File
@@ -126,7 +126,7 @@ func (s *Service) SendBookingConfirmation(ctx context.Context, data BookingEmail
data.BusinessEmail = s.cfg.EmailFrom
}
data.Type = EmailTypeConfirmation
msg := RenderEmailMessage(data)
msg := s.renderWithOverride(ctx, data)
_, err := s.emailProvider.Send(ctx, msg)
return err
}
@@ -137,7 +137,7 @@ func (s *Service) SendBookingReschedule(ctx context.Context, data BookingEmailDa
data.BusinessEmail = s.cfg.EmailFrom
}
data.Type = EmailTypeReschedule
msg := RenderEmailMessage(data)
msg := s.renderWithOverride(ctx, data)
_, err := s.emailProvider.Send(ctx, msg)
return err
}
@@ -148,11 +148,73 @@ func (s *Service) SendBookingCancellation(ctx context.Context, data BookingEmail
data.BusinessEmail = s.cfg.EmailFrom
}
data.Type = EmailTypeCancellation
msg := RenderEmailMessage(data)
msg := s.renderWithOverride(ctx, data)
_, err := s.emailProvider.Send(ctx, msg)
return err
}
// renderWithOverride renders the default message and, when the tenant has an
// enabled custom template for this email type, swaps in the stored subject and
// body with {{variable}} substitution.
func (s *Service) renderWithOverride(ctx context.Context, data BookingEmailData) EmailMessage {
msg := RenderEmailMessage(data)
if s.repo == nil || data.TenantID == "" {
return msg
}
tpl, err := s.repo.GetEmailTemplate(ctx, data.TenantID, string(data.Type))
if err != nil || !tpl.IsEnabled {
return msg
}
vars := map[string]string{
"customerName": data.CustomerName,
"customerEmail": data.CustomerEmail,
"service": data.Service,
"location": data.Location,
"reference": data.Reference,
"startsAt": formatLocalizedDateTime(data.StartsAt, data.Timezone, data.Locale),
"tenantName": data.TenantName,
"manageUrl": data.ManagementURL,
}
if tpl.Subject != "" {
msg.Subject = substituteTemplateVars(tpl.Subject, vars)
}
if tpl.BodyText != "" {
msg.Text = substituteTemplateVars(tpl.BodyText, vars)
if tpl.BodyHTML == "" {
msg.HTML = wrapTextAsHTML(msg.Text, data.BrandColor, data.TenantName, data.ManagementURL)
}
}
if tpl.BodyHTML != "" {
msg.HTML = substituteTemplateVars(tpl.BodyHTML, vars)
}
return msg
}
func substituteTemplateVars(body string, vars map[string]string) string {
for key, value := range vars {
body = strings.ReplaceAll(body, "{{"+key+"}}", value)
}
return body
}
func wrapTextAsHTML(text, brandColor, tenantName, manageURL string) string {
if brandColor == "" {
brandColor = "#c25e3a"
}
html := "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
html += "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
html += fmt.Sprintf("<h2 style='color: %s; margin-bottom: 20px;'>%s</h2>", brandColor, tenantName)
for _, p := range splitParagraphs(text) {
if len(p) > 0 {
html += fmt.Sprintf("<p style='margin-bottom: 10px;'>%s</p>", p)
}
}
if manageURL != "" {
html += fmt.Sprintf("<div style='margin-top: 30px;'><a href='%s' style='display: inline-block; background: %s; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px;'>Manage Booking</a></div>", manageURL, brandColor)
}
return html + "</div></body></html>"
}
// SendBusinessNotification sends a notification to the business about a new booking
func (s *Service) SendBusinessNotification(ctx context.Context, businessEmail string, data BookingEmailData) error {
if businessEmail == "" {
@@ -311,22 +373,24 @@ func (s *Service) SendUsageWarning(ctx context.Context, tenantID string, locatio
return fmt.Errorf("failed to get tenant: %w", err)
}
// Use a placeholder admin email - in production, would get from tenant owner
adminEmail := "admin@" + tenant.Slug + ".bookra.eu"
ownerEmail, err := s.repo.GetTenantOwnerEmail(ctx, tenantID)
if err != nil || ownerEmail == "" {
return nil
}
emailData := UsageNotificationData{
Type: EmailTypeUsageWarning,
TenantName: tenant.Name,
TenantSlug: tenant.Slug,
BusinessEmail: s.cfg.EmailFrom,
AdminEmail: adminEmail,
AdminEmail: ownerEmail,
Locale: tenant.Locale,
PlanCode: tenant.PlanCode,
LocationCount: locationCount,
LocationLimit: locationLimit,
UsagePercent: usagePercent,
UpgradeURL: "https://bookra.eu/pricing",
DashboardURL: "https://bookra.eu/dashboard",
UpgradeURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard/billing",
DashboardURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard",
}
msg := RenderUsageNotificationEmail(emailData)
@@ -348,19 +412,21 @@ func (s *Service) SendTrialEndingEmail(ctx context.Context, tenantID string, day
return fmt.Errorf("failed to get tenant: %w", err)
}
// Use a placeholder admin email - in production, would get from tenant owner
adminEmail := "admin@" + tenant.Slug + ".bookra.eu"
ownerEmail, err := s.repo.GetTenantOwnerEmail(ctx, tenantID)
if err != nil || ownerEmail == "" {
return nil
}
emailData := UsageNotificationData{
Type: EmailTypeTrialEnding,
TenantName: tenant.Name,
TenantSlug: tenant.Slug,
BusinessEmail: s.cfg.EmailFrom,
AdminEmail: adminEmail,
AdminEmail: ownerEmail,
Locale: tenant.Locale,
PlanCode: tenant.PlanCode,
UpgradeURL: "https://bookra.eu/pricing",
DashboardURL: "https://bookra.eu/dashboard",
UpgradeURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard/billing",
DashboardURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard",
}
msg := RenderUsageNotificationEmail(emailData)
+1 -1
View File
@@ -3,7 +3,7 @@ package shared
import "strings"
// NormalizePlanCode canonicalizes plan codes from various sources
// (Paddle checkout, webhook payloads, database records) into stable
// (Stripe checkout, webhook payloads, database records) into stable
// internal identifiers used across the billing and tenancy domains.
func NormalizePlanCode(planCode string) string {
switch strings.TrimSpace(planCode) {
+1 -1
View File
@@ -353,7 +353,7 @@ func (s *Service) sendUsageSummaryEmail(ctx context.Context, tenant db.TenantRec
}
// Find owner email
membership, err := s.repo.GetTenantMembershipByUserID(ctx, tenant.ID)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, tenant.ID)
if err == nil {
user, err := s.repo.GetUserByID(ctx, membership.UserID)
if err == nil {
+100 -3
View File
@@ -32,11 +32,11 @@ func NewService(repo db.Repository) *Service {
}
func (s *Service) GetTenantMembership(ctx context.Context, principal domain.Principal) (db.TenantMembershipRecord, error) {
return s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
return s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
}
func (s *Service) Bootstrap(ctx context.Context, principal domain.Principal) (domain.TenantBootstrap, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.TenantBootstrap{
@@ -76,7 +76,7 @@ func (s *Service) Bootstrap(ctx context.Context, principal domain.Principal) (do
}
func (s *Service) Onboard(ctx context.Context, principal domain.Principal, request domain.OnboardTenantRequest) (domain.TenantBootstrap, error) {
if _, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject); err == nil {
if _, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject); err == nil {
return domain.TenantBootstrap{}, ErrTenantAlreadyProvisioned
} else if !errors.Is(err, pgx.ErrNoRows) {
return domain.TenantBootstrap{}, err
@@ -184,6 +184,103 @@ func (s *Service) Onboard(ctx context.Context, principal domain.Principal, reque
}, nil
}
// UpdateBrand persists brand profile fields for the caller's tenant.
func (s *Service) UpdateBrand(ctx context.Context, principal domain.Principal, req domain.UpdateBrandRequest) (domain.BrandProfile, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.BrandProfile{}, err
}
params := db.UpdateBrandProfileParams{
Name: optionalTrimmed(req.Name),
SiteURL: optionalTrimmed(req.SiteURL),
LogoURL: optionalTrimmed(req.LogoURL),
PrimaryColor: optionalTrimmed(req.PrimaryColor),
}
if _, err := s.repo.UpdateBrandProfile(ctx, membership.Tenant.ID, params); err != nil {
return domain.BrandProfile{}, err
}
return s.brandProfile(ctx, membership.Tenant), nil
}
func optionalTrimmed(value string) *string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil
}
return &trimmed
}
var emailTemplateTypes = map[string]struct{}{
"confirmation": {},
"reminder": {},
"reschedule": {},
"cancellation": {},
}
// ListEmailTemplates returns all custom templates stored for the caller's tenant.
func (s *Service) ListEmailTemplates(ctx context.Context, principal domain.Principal) ([]domain.EmailTemplate, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return nil, err
}
records, err := s.repo.ListEmailTemplates(ctx, membership.Tenant.ID)
if err != nil {
return nil, err
}
out := make([]domain.EmailTemplate, len(records))
for i, rec := range records {
out[i] = emailTemplateToDomain(rec)
}
return out, nil
}
// UpdateEmailTemplate stores a subject/body override for a booking email type.
// Supported placeholders: {{customerName}}, {{service}}, {{location}},
// {{reference}}, {{startsAt}}, {{tenantName}}, {{manageUrl}}.
func (s *Service) UpdateEmailTemplate(ctx context.Context, principal domain.Principal, req domain.UpdateEmailTemplateRequest) (domain.EmailTemplate, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.EmailTemplate{}, err
}
templateType := strings.ToLower(strings.TrimSpace(req.Type))
if _, ok := emailTemplateTypes[templateType]; !ok {
return domain.EmailTemplate{}, ErrInvalidOnboarding
}
if len(req.Subject) > 200 || len(req.Body) > 20000 {
return domain.EmailTemplate{}, ErrInvalidOnboarding
}
isEnabled := true
if req.IsEnabled != nil {
isEnabled = *req.IsEnabled
}
rec, err := s.repo.UpsertEmailTemplate(ctx, db.EmailTemplateRecord{
TenantID: membership.Tenant.ID,
Type: templateType,
Subject: strings.TrimSpace(req.Subject),
BodyText: strings.TrimSpace(req.Body),
IsEnabled: isEnabled,
})
if err != nil {
return domain.EmailTemplate{}, err
}
return emailTemplateToDomain(rec), nil
}
func emailTemplateToDomain(rec db.EmailTemplateRecord) domain.EmailTemplate {
return domain.EmailTemplate{
ID: rec.ID,
TenantID: rec.TenantID,
Type: rec.Type,
Subject: rec.Subject,
BodyHTML: rec.BodyHTML,
BodyText: rec.BodyText,
IsEnabled: rec.IsEnabled,
}
}
func (s *Service) brandProfile(ctx context.Context, tenant db.TenantRecord) domain.BrandProfile {
brand, err := s.repo.GetBrandProfile(ctx, tenant.ID)
if err != nil {