mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
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:
1 parent
2039669e2c
commit
f2f7439050
93 files changed
+6987
-69060
No files matched your search
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
// ============================================
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user