feat(core): consolidate auth service into backend and implement stripe billing
CI / Frontend (push) Successful in 9m54s
CI / Go - apps/auth-service (push) Failing after 24s
CI / Go - apps/backend (push) Failing after 5m43s
CI / Docker publish - auth-service (push) Has been skipped
CI / Docker publish - backend (push) Has been skipped

This commit performs a major architectural refactor by migrating the standalone `auth-service` into the main `backend` application, enabling a unified codebase and simplified deployment. It also introduces comprehensive Stripe billing support and a new administrative dashboard.

Key changes:
- **Architecture**: Deleted `apps/auth-service` and integrated its functionality (JWT, magic links, OAuth, user management) into `apps/backend`.
- **Billing**: Added Stripe integration to `backend`, supporting both monthly and yearly subscription cycles with automatic plan entitlement enforcement (e.g., location limits).
- **Admin Dashboard**: Implemented a new administrative service and API endpoints to manage tenants, users, and view platform-wide statistics.
- **Frontend**:
    - Added a new pricing page with monthly/yearly toggle and comparison table.
    - Integrated Stripe and Sentry for payments and error tracking.
    - Improved dashboard UX/UI and added i18n support for new features.
    - Enhanced the public booking flow with better validation and contact form integration.
- **Database**: Added migrations for users, magic links, password resets, OAuth states, admin audit logs, and refresh tokens.
- **DevOps**: Updated environment configurations for Railway and Vercel, and streamlined the project's `package.json` scripts.
This commit is contained in:
Tomas Dvorak
2026-05-09 18:25:25 +02:00
parent cf3315e8fc
commit 164a37e997
69 changed files with 4630 additions and 5260 deletions
+26
View File
@@ -11,14 +11,37 @@ import (
"bookra/apps/backend/internal/api"
"bookra/apps/backend/internal/config"
"bookra/apps/backend/internal/db"
sentry "github.com/getsentry/sentry-go"
)
func initSentry(cfg config.Config) {
if cfg.SentryDSN == "" {
log.Println("Sentry DSN not configured - skipping initialization")
return
}
err := sentry.Init(sentry.ClientOptions{
Dsn: cfg.SentryDSN,
Environment: cfg.Environment,
Release: "bookra@1.0.0",
// Set TracesSampleRate to 1.0 to capture 100% of transactions for testing
TracesSampleRate: 1.0,
})
if err != nil {
log.Fatalf("Sentry initialization failed: %v", err)
}
log.Println("Sentry initialized")
}
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("load config: %v", err)
}
initSentry(cfg)
pools, err := db.NewPools(cfg)
if err != nil {
log.Fatalf("create database pools: %v", err)
@@ -31,6 +54,9 @@ func main() {
}
defer server.Close()
// Start background job for trial ending emails
go server.StartBackgroundJobs()
httpServer := &http.Server{
Addr: ":" + cfg.Port,
Handler: server.Handler(),
+3 -1
View File
@@ -3,13 +3,14 @@ module bookra/apps/backend
go 1.26.2
require (
github.com/PaddleHQ/paddle-go-sdk/v5 v5.2.0
github.com/MicahParks/keyfunc/v3 v3.8.0
github.com/PaddleHQ/paddle-go-sdk/v5 v5.2.0
github.com/gin-contrib/cors v1.7.7
github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.1
github.com/stripe/stripe-go/v81 v81.0.0
golang.org/x/time v0.9.0
)
@@ -20,6 +21,7 @@ require (
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/getsentry/sentry-go v0.46.2 // indirect
github.com/ggicci/httpin v0.20.3 // indirect
github.com/ggicci/owl v0.8.2 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
+12
View File
@@ -17,6 +17,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/getsentry/sentry-go v0.46.2 h1:1jhYwrKGa3sIpo/y5iDNXS5wDoT7I1KNzMHrnK6ojns=
github.com/getsentry/sentry-go v0.46.2/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw=
github.com/ggicci/httpin v0.20.3 h1:qy93bUsF/eGbX8WJfXEjB3bjgUrv7MKZ3qVWR73DIGY=
github.com/ggicci/httpin v0.20.3/go.mod h1:ppHGT8xt99mRnDUuehLLWl2RAVLKG+VGn48GjK5xaLA=
github.com/ggicci/owl v0.8.2 h1:og+lhqpzSMPDdEB+NJfzoAJARP7qCG3f8uUC3xvGukA=
@@ -58,6 +60,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo=
github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
@@ -89,6 +93,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stripe/stripe-go/v81 v81.0.0 h1:7xqKVXIjhFoSEUzXXPON7oYFRupOyhDG5R7tRVyrgeE=
github.com/stripe/stripe-go/v81 v81.0.0/go.mod h1:C/F4jlmnGNacvYtBp/LUHCvVUJEZffFQCobkzwY1WOo=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
@@ -101,17 +107,23 @@ golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+243
View File
@@ -0,0 +1,243 @@
package admin
import (
"context"
"crypto/subtle"
"errors"
"net/http"
"strings"
"time"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
"github.com/gin-gonic/gin"
)
var (
ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden: admin access required")
ErrInvalidAdminCreds = errors.New("invalid admin credentials")
)
type Service struct {
repo db.Repository
adminEmail string
adminKey string
}
func NewService(repo db.Repository, adminEmail, adminKey string) *Service {
return &Service{
repo: repo,
adminEmail: adminEmail,
adminKey: adminKey,
}
}
// IsConfigured returns true if admin credentials are set
func (s *Service) IsConfigured() bool {
return s.adminEmail != "" && s.adminKey != ""
}
// ValidateAdminLogin checks if the provided credentials match the admin credentials
// Uses constant-time comparison to prevent timing attacks
func (s *Service) ValidateAdminLogin(email, key string) bool {
if !s.IsConfigured() {
return false
}
emailMatch := subtle.ConstantTimeCompare([]byte(email), []byte(s.adminEmail)) == 1
keyMatch := subtle.ConstantTimeCompare([]byte(key), []byte(s.adminKey)) == 1
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 {
return func(c *gin.Context) {
// Check for admin header credentials (direct admin login)
adminEmail := c.GetHeader("X-Admin-Email")
adminKey := c.GetHeader("X-Admin-Key")
if adminEmail != "" && adminKey != "" {
if adminSvc.ValidateAdminLogin(adminEmail, adminKey) {
c.Set("isAdmin", true)
c.Set("adminMode", "credentials")
c.Next()
return
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid admin credentials"})
return
}
// 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
}
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin access required"})
}
}
// GetDashboardStats returns platform-wide statistics for admin dashboard
func (s *Service) GetDashboardStats(ctx context.Context) (domain.AdminDashboardStats, error) {
stats, err := s.repo.GetPlatformStats(ctx)
if err != nil {
return domain.AdminDashboardStats{}, err
}
return domain.AdminDashboardStats{
TotalTenants: stats.TotalTenants,
TotalUsers: stats.TotalUsers,
ActiveSubscriptions: stats.ActiveSubscriptions,
TrialSubscriptions: stats.TrialSubscriptions,
BookingsThisMonth: stats.BookingsThisMonth,
RevenueThisMonthCents: stats.RevenueThisMonth,
}, nil
}
// ListTenants returns paginated list of all tenants
func (s *Service) ListTenants(ctx context.Context, page, pageSize int) (domain.AdminTenantList, error) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
offset := (page - 1) * pageSize
tenants, total, err := s.repo.ListAllTenants(ctx, pageSize, offset)
if err != nil {
return domain.AdminTenantList{}, err
}
result := domain.AdminTenantList{
Total: total,
Page: page,
PageSize: pageSize,
Tenants: make([]domain.AdminTenant, len(tenants)),
}
for i, t := range tenants {
result.Tenants[i] = domain.AdminTenant{
ID: t.ID,
Slug: t.Slug,
Name: t.Name,
PlanCode: t.PlanCode,
SubscriptionStatus: t.SubscriptionStatus,
BillingProvider: t.BillingProvider,
}
}
return result, nil
}
// ListUsers returns paginated list of all users
func (s *Service) ListUsers(ctx context.Context, page, pageSize int) (domain.AdminUserList, error) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
offset := (page - 1) * pageSize
users, total, err := s.repo.ListAllUsers(ctx, pageSize, offset)
if err != nil {
return domain.AdminUserList{}, err
}
result := domain.AdminUserList{
Total: total,
Page: page,
PageSize: pageSize,
Users: make([]domain.AdminUser, len(users)),
}
for i, u := range users {
result.Users[i] = domain.AdminUser{
ID: u.ID.String(),
Email: u.Email,
Name: stringPtrToStr(u.Name),
EmailVerified: u.EmailVerified,
Provider: u.Provider,
Role: u.Role,
CreatedAt: u.CreatedAt,
}
}
return result, nil
}
// UpdateUserRole changes a user's role
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,
"superadmin": true,
}
if !validRoles[newRole] {
return errors.New("invalid role")
}
if err := s.repo.UpdateUserRole(ctx, targetUserID, newRole); err != nil {
return err
}
// Log the action
return s.repo.CreateAdminAuditLog(ctx, db.AdminAuditLogParams{
AdminUserID: adminUserID,
Action: "update_user_role",
ResourceType: "user",
ResourceID: targetUserID,
Details: map[string]any{
"newRole": newRole,
},
IPAddress: ip,
UserAgent: userAgent,
})
}
// 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()
}
+314 -14
View File
@@ -1,12 +1,15 @@
package api
import (
"context"
"errors"
"io"
"log"
"net/http"
"strconv"
"time"
"bookra/apps/backend/internal/admin"
"bookra/apps/backend/internal/auth"
"bookra/apps/backend/internal/billing"
"bookra/apps/backend/internal/bookings"
@@ -18,16 +21,21 @@ import (
"bookra/apps/backend/internal/notifications"
"bookra/apps/backend/internal/tenancy"
"github.com/getsentry/sentry-go"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"golang.org/x/time/rate"
)
type Server struct {
router *gin.Engine
cfg config.Config
pools *db.Pools
verifier *auth.Verifier
router *gin.Engine
cfg config.Config
pools *db.Pools
verifier *auth.Verifier
authService *auth.Service
adminService *admin.Service
billingService *billing.Service
notificationService *notifications.Service
}
func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
@@ -41,15 +49,21 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
bookingService := bookings.NewService(repository, notificationService)
customerBookingService := bookings.NewCustomerService(repository, notificationService)
tenantService := tenancy.NewService(repository)
catalogService := catalog.NewService(repository)
billingService := billing.NewService(cfg, repository)
catalogService := catalog.NewService(repository, billingService, notificationService)
authService := auth.NewService(repository, cfg.AuthJWTSecret)
adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey)
publicRateLimiter := httpx.NewRateLimiter(rate.Every(time.Second), 5)
server := &Server{
router: gin.New(),
cfg: cfg,
pools: pools,
verifier: verifier,
router: gin.New(),
cfg: cfg,
pools: pools,
verifier: verifier,
authService: authService,
adminService: adminService,
billingService: billingService,
notificationService: notificationService,
}
server.router.Use(gin.Logger(), gin.Recovery(), cors.New(cors.Config{
@@ -67,15 +81,241 @@ 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"})
}
})
server.router.GET("/v1/meta/config", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"environment": cfg.Environment,
"neonAuthEnabled": verifier.Enabled(),
"apiUrl": cfg.APIURL,
"demoMode": cfg.DemoMode,
"environment": cfg.Environment,
"neonAuthEnabled": verifier.Enabled(),
"apiUrl": cfg.APIURL,
"demoMode": cfg.DemoMode,
"adminLoginEnabled": adminService.IsConfigured(),
})
})
// ============================================
// AUTH API
// ============================================
authGroup := server.router.Group("/v1/auth")
{
authGroup.POST("/register", func(c *gin.Context) {
var request struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
user, tokens, err := authService.RegisterWithPassword(c.Request.Context(), request.Email, request.Password, request.Name)
if err != nil {
if errors.Is(err, auth.ErrEmailAlreadyExists) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
if errors.Is(err, auth.ErrPasswordTooShort) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "registration_failed"})
return
}
c.JSON(http.StatusCreated, gin.H{
"user": gin.H{
"id": user.ID,
"email": user.Email,
"name": user.Name,
},
"accessToken": tokens.AccessToken,
"refreshToken": tokens.RefreshToken,
"tokenType": tokens.TokenType,
"expiresIn": tokens.ExpiresIn,
})
})
authGroup.POST("/login", func(c *gin.Context) {
var request struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
user, tokens, err := authService.LoginWithPassword(c.Request.Context(), request.Email, request.Password)
if err != nil {
if errors.Is(err, auth.ErrInvalidCredentials) {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "login_failed"})
return
}
c.JSON(http.StatusOK, gin.H{
"user": gin.H{
"id": user.ID,
"email": user.Email,
"name": user.Name,
},
"accessToken": tokens.AccessToken,
"refreshToken": tokens.RefreshToken,
"tokenType": tokens.TokenType,
"expiresIn": tokens.ExpiresIn,
})
})
authGroup.POST("/magic-link", func(c *gin.Context) {
var request struct {
Email string `json:"email" binding:"required,email"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
_, err := authService.CreateMagicLink(c.Request.Context(), request.Email)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "magic_link_failed"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "magic_link_sent"})
})
authGroup.POST("/verify", func(c *gin.Context) {
var request struct {
Token string `json:"token" binding:"required"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
user, tokens, err := authService.VerifyMagicLink(c.Request.Context(), request.Token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"user": gin.H{
"id": user.ID,
"email": user.Email,
"name": user.Name,
},
"accessToken": tokens.AccessToken,
"refreshToken": tokens.RefreshToken,
"tokenType": tokens.TokenType,
"expiresIn": tokens.ExpiresIn,
})
})
authGroup.POST("/refresh", func(c *gin.Context) {
var request struct {
RefreshToken string `json:"refreshToken" binding:"required"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
tokens, err := authService.RefreshToken(c.Request.Context(), request.RefreshToken)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"accessToken": tokens.AccessToken,
"refreshToken": tokens.RefreshToken,
"tokenType": tokens.TokenType,
"expiresIn": tokens.ExpiresIn,
})
})
}
// ============================================
// ADMIN API
// ============================================
adminGroup := server.router.Group("/v1/admin")
adminGroup.Use(admin.RequireAdmin(adminService, authService))
{
adminGroup.GET("/stats", func(c *gin.Context) {
stats, err := adminService.GetDashboardStats(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
})
adminGroup.GET("/tenants", func(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
result, err := adminService.ListTenants(c.Request.Context(), page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
})
adminGroup.GET("/users", func(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
result, err := adminService.ListUsers(c.Request.Context(), page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
})
adminGroup.PUT("/users/:userID/role", func(c *gin.Context) {
var request domain.UpdateUserRoleRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
adminUserID, _ := c.Get("userID")
err := adminService.UpdateUserRole(
c.Request.Context(),
adminUserID.(string),
c.Param("userID"),
request.Role,
c.ClientIP(),
c.GetHeader("User-Agent"),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "updated"})
})
// Trigger trial ending email check
adminGroup.POST("/trigger-trial-emails", func(c *gin.Context) {
err := billingService.CheckAndSendTrialEndingEmails(c.Request.Context(), notificationService)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "completed", "message": "Trial ending emails sent"})
})
}
server.router.GET("/v1/public/tenants/:tenantSlug/availability", func(c *gin.Context) {
response, err := bookingService.Availability(c.Request.Context(), c.Param("tenantSlug"))
if err != nil {
@@ -126,6 +366,23 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
c.JSON(http.StatusCreated, response)
})
server.router.POST("/v1/public/contact", publicRateLimiter.Middleware(), func(c *gin.Context) {
var request struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Message string `json:"message" binding:"required,min=10"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
if err := notificationService.SendContactEmail(c.Request.Context(), request.Name, request.Email, request.Message); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to send message"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "sent"})
})
protected := server.router.Group("/v1")
protected.Use(auth.RequireAuth(verifier, repository, cfg.DemoMode))
@@ -196,6 +453,8 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) {
status = http.StatusNotFound
} else if errors.Is(err, catalog.ErrPlanLimitReached) {
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
@@ -492,7 +751,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := billingService.CreateCheckoutSession(c.Request.Context(), auth.PrincipalFromContext(c), request.PlanCode, request.Currency)
response, err := billingService.CreateCheckoutSession(c.Request.Context(), auth.PrincipalFromContext(c), request.PlanCode, request.Currency, request.BillingInterval)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, billing.ErrBillingMembership) {
@@ -549,6 +808,13 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
}
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()})
return
}
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()})
@@ -597,6 +863,40 @@ func (s *Server) Close() {
}
}
// StartBackgroundJobs runs periodic background tasks
func (s *Server) StartBackgroundJobs() {
// Run trial ending check every 6 hours
ticker := time.NewTicker(6 * time.Hour)
defer ticker.Stop()
// Run immediately on startup after a brief delay
time.Sleep(30 * time.Second)
s.runTrialEndingCheck()
for {
select {
case <-ticker.C:
s.runTrialEndingCheck()
}
}
}
func (s *Server) runTrialEndingCheck() {
if s.billingService == nil || s.notificationService == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := s.billingService.CheckAndSendTrialEndingEmails(ctx, s.notificationService)
if err != nil {
log.Printf("Background job: trial ending check failed: %v", err)
} else {
log.Printf("Background job: trial ending check completed")
}
}
func authorizeJobRunner(c *gin.Context, cfg config.Config) bool {
if cfg.JobRunnerKey == "" {
return false
+319
View File
@@ -0,0 +1,319 @@
package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"time"
"bookra/apps/backend/internal/db"
"github.com/golang-jwt/jwt/v5"
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
)
const (
accessTokenTTL = 24 * time.Hour
refreshTokenTTL = 30 * 24 * time.Hour
magicLinkTTL = 15 * time.Minute
passwordResetTTL = 30 * time.Minute
)
var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrInvalidToken = errors.New("invalid or expired token")
ErrUserNotFound = errors.New("user not found")
ErrEmailAlreadyExists = errors.New("email already exists")
ErrPasswordTooShort = errors.New("password must be at least 8 characters")
ErrMagicLinkExpired = errors.New("magic link expired")
ErrMagicLinkUsed = errors.New("magic link already used")
ErrInvalidResetToken = errors.New("invalid or expired reset token")
)
type TokenPair struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken,omitempty"`
TokenType string `json:"tokenType"`
ExpiresIn int `json:"expiresIn"`
}
type Claims struct {
UserID string `json:"sub"`
Email string `json:"email"`
Name string `json:"name,omitempty"`
Role string `json:"role"`
Type string `json:"type"`
jwt.RegisteredClaims
}
type Service struct {
repo db.Repository
jwtSecret []byte
}
func NewService(repo db.Repository, jwtSecret string) *Service {
return &Service{
repo: repo,
jwtSecret: []byte(jwtSecret),
}
}
// RegisterWithPassword creates a new user with email and password
func (s *Service) RegisterWithPassword(ctx context.Context, email, password, name string) (*db.UserRecord, *TokenPair, error) {
if len(password) < 8 {
return nil, nil, ErrPasswordTooShort
}
// Check if user exists
existing, err := s.repo.GetUserByEmail(ctx, email)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, nil, err
}
if existing != nil {
return nil, nil, ErrEmailAlreadyExists
}
// Hash password
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, nil, err
}
// Create user
user, err := s.repo.CreateUser(ctx, email, string(hash), name, "email", "user")
if err != nil {
return nil, nil, err
}
// Generate tokens
tokens, err := s.generateTokenPair(user.ID.String(), email, name, "user")
if err != nil {
return nil, nil, err
}
return user, tokens, nil
}
// LoginWithPassword authenticates a user with email and password
func (s *Service) LoginWithPassword(ctx context.Context, email, password string) (*db.UserRecord, *TokenPair, error) {
user, err := s.repo.GetUserByEmail(ctx, email)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, ErrInvalidCredentials
}
return nil, nil, err
}
if user.PasswordHash == nil {
return nil, nil, ErrInvalidCredentials
}
if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(password)); err != nil {
return nil, nil, ErrInvalidCredentials
}
// Update last login
if err := s.repo.UpdateLastLogin(ctx, user.ID.String()); err != nil {
// Log but don't fail
}
tokens, err := s.generateTokenPair(user.ID.String(), user.Email, derefString(user.Name), user.Role)
if err != nil {
return nil, nil, err
}
return user, tokens, nil
}
// CreateMagicLink generates a magic link for passwordless auth
func (s *Service) CreateMagicLink(ctx context.Context, email string) (string, error) {
// Get or create user
user, err := s.repo.GetUserByEmail(ctx, email)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return "", err
}
if user == nil {
user, err = s.repo.CreateUser(ctx, email, "", "", "email", "user")
if err != nil {
return "", err
}
}
// Generate token
token := generateRandomToken(32)
expiresAt := time.Now().Add(magicLinkTTL)
if err := s.repo.CreateMagicLink(ctx, token, user.ID.String(), email, expiresAt); err != nil {
return "", err
}
return token, nil
}
// VerifyMagicLink validates a magic link and returns tokens
func (s *Service) VerifyMagicLink(ctx context.Context, token string) (*db.UserRecord, *TokenPair, error) {
ml, err := s.repo.GetMagicLink(ctx, token)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, ErrInvalidToken
}
return nil, nil, err
}
if ml.Used {
return nil, nil, ErrMagicLinkUsed
}
if time.Now().After(ml.ExpiresAt) {
return nil, nil, ErrMagicLinkExpired
}
// Mark as used
if err := s.repo.MarkMagicLinkUsed(ctx, token); err != nil {
return nil, nil, err
}
// Get user
user, err := s.repo.GetUserByID(ctx, ml.UserID.String())
if err != nil {
return nil, nil, err
}
// Mark email as verified
if err := s.repo.MarkEmailVerified(ctx, user.ID.String()); err != nil {
// Log but don't fail
}
// Update last login
if err := s.repo.UpdateLastLogin(ctx, user.ID.String()); err != nil {
// Log but don't fail
}
tokens, err := s.generateTokenPair(user.ID.String(), user.Email, derefString(user.Name), user.Role)
if err != nil {
return nil, nil, err
}
return user, tokens, nil
}
// RefreshToken refreshes an access token using a refresh token
func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (*TokenPair, error) {
claims, err := s.ValidateToken(refreshToken)
if err != nil {
return nil, ErrInvalidToken
}
if claims.Type != "refresh" {
return nil, ErrInvalidToken
}
user, err := s.repo.GetUserByID(ctx, claims.UserID)
if err != nil {
return nil, ErrUserNotFound
}
return s.generateTokenPair(user.ID.String(), user.Email, derefString(user.Name), user.Role)
}
// ValidateToken validates a JWT token and returns claims
func (s *Service) ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, 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 s.jwtSecret, nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, ErrInvalidToken
}
// GetUser retrieves a user by ID
func (s *Service) GetUser(ctx context.Context, userID string) (*db.UserRecord, error) {
return s.repo.GetUserByID(ctx, userID)
}
// IsAdmin checks if the user has admin role
func (s *Service) IsAdmin(ctx context.Context, userID string) (bool, error) {
user, err := s.repo.GetUserByID(ctx, userID)
if err != nil {
return false, err
}
return user.Role == "admin" || user.Role == "superadmin", nil
}
// generateTokenPair creates access and refresh tokens
func (s *Service) generateTokenPair(userID, email, name, role string) (*TokenPair, error) {
now := time.Now()
// Access token
accessClaims := Claims{
UserID: userID,
Email: email,
Name: name,
Role: role,
Type: "access",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(accessTokenTTL)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
},
}
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
accessTokenString, err := accessToken.SignedString(s.jwtSecret)
if err != nil {
return nil, err
}
// Refresh token
refreshClaims := Claims{
UserID: userID,
Email: email,
Role: role,
Type: "refresh",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(refreshTokenTTL)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
},
}
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
refreshTokenString, err := refreshToken.SignedString(s.jwtSecret)
if err != nil {
return nil, err
}
return &TokenPair{
AccessToken: accessTokenString,
RefreshToken: refreshTokenString,
TokenType: "Bearer",
ExpiresIn: int(accessTokenTTL.Seconds()),
}, nil
}
func generateRandomToken(length int) string {
b := make([]byte, length)
rand.Read(b)
return base64.URLEncoding.EncodeToString(b)
}
func derefString(s *string) string {
if s == nil {
return ""
}
return *s
}
+587 -52
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"slices"
@@ -17,6 +18,12 @@ import (
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"
checkoutsession "github.com/stripe/stripe-go/v81/checkout/session"
"github.com/stripe/stripe-go/v81/customer"
"github.com/stripe/stripe-go/v81/subscription"
"github.com/stripe/stripe-go/v81/webhook"
)
var (
@@ -26,9 +33,12 @@ var (
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 allowedWebhookEvents = []string{
var allowedPaddleWebhookEvents = []string{
"subscription.created",
"subscription.updated",
"subscription.activated",
@@ -42,11 +52,23 @@ var allowedWebhookEvents = []string{
"transaction.past_due",
}
var allowedStripeWebhookEvents = []stripe.EventType{
stripe.EventTypeCheckoutSessionCompleted,
stripe.EventTypeCustomerSubscriptionCreated,
stripe.EventTypeCustomerSubscriptionUpdated,
stripe.EventTypeCustomerSubscriptionDeleted,
stripe.EventTypeInvoicePaid,
stripe.EventTypeInvoicePaymentFailed,
stripe.EventTypePaymentIntentSucceeded,
stripe.EventTypePaymentIntentPaymentFailed,
}
type Service struct {
cfg config.Config
repo db.Repository
client *paddle.SDK
verifier *paddle.WebhookVerifier
cfg config.Config
repo db.Repository
client *paddle.SDK
verifier *paddle.WebhookVerifier
stripeEnabled bool
}
type webhookEnvelope struct {
@@ -63,6 +85,7 @@ type webhookEnvelope struct {
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
@@ -76,13 +99,28 @@ func NewService(cfg config.Config, repo db.Repository) *Service {
}
}
if strings.TrimSpace(cfg.PaddleWebhookKey) != "" {
service.verifier = paddle.NewWebhookVerifier(cfg.PaddleWebhookKey, paddle.VerifierWithTimestampTolerance(5*time.Minute))
// Initialize Stripe
if strings.TrimSpace(cfg.StripeAPIKey) != "" {
stripe.Key = cfg.StripeAPIKey
service.stripeEnabled = true
}
return service
}
// GetEntitlements returns the plan entitlements for a tenant (used by other services for limit enforcement)
func (s *Service) GetEntitlements(ctx context.Context, tenantID string) (domain.PlanEntitlements, error) {
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// Default to Pro entitlements for tenants without billing
return entitlementsForPlan("pro"), nil
}
return domain.PlanEntitlements{}, err
}
return entitlementsForPlan(tenant.PlanCode), nil
}
func (s *Service) GetSubscription(ctx context.Context, principal domain.Principal) (domain.SubscriptionSnapshot, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
if err != nil {
@@ -97,7 +135,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: "paddle",
BillingProvider: s.cfg.BillingProvider(),
Status: firstNonEmpty(membership.Tenant.SubscriptionStatus, "inactive"),
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
Currency: "czk",
@@ -109,7 +147,7 @@ func (s *Service) GetSubscription(ctx context.Context, principal domain.Principa
return toSnapshot(membership.Tenant, record, s.cfg), nil
}
func (s *Service) CreateCheckoutSession(ctx context.Context, principal domain.Principal, planCode string, currency string) (domain.CheckoutLaunchResponse, error) {
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)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -118,7 +156,93 @@ func (s *Service) CreateCheckoutSession(ctx context.Context, principal domain.Pr
return domain.CheckoutLaunchResponse{}, err
}
priceID, resolvedPlanCode, resolvedCurrency := s.priceForPlan(planCode, currency)
// 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)
}
func (s *Service) createStripeCheckoutSession(ctx context.Context, principal domain.Principal, membership db.TenantMembershipRecord, planCode string, currency string, billingInterval string) (domain.CheckoutLaunchResponse, error) {
priceID, resolvedPlanCode, resolvedCurrency := s.stripePriceForPlan(planCode, currency, billingInterval)
if priceID == "" {
return domain.CheckoutLaunchResponse{}, ErrBillingPlanUnsupported
}
// Ensure customer exists (KV sync model: always pre-create customers)
customerID := derefString(membership.Tenant.BillingCustomerID)
if customerID == "" {
cust, err := customer.New(&stripe.CustomerParams{
Email: stripe.String(strings.TrimSpace(principal.Email)),
Metadata: map[string]string{
"tenantId": membership.Tenant.ID,
"tenantSlug": membership.Tenant.Slug,
"userId": principal.Subject,
},
})
if err != nil {
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 {
return domain.CheckoutLaunchResponse{}, err
}
}
// Create checkout session - 15-day free trial for Starter/Pro only (not Business)
// Trial requires credit card to be entered
trialDays := int64(0)
if resolvedPlanCode == "starter" || resolvedPlanCode == "pro" {
trialDays = 15
}
params := &stripe.CheckoutSessionParams{
Customer: stripe.String(customerID),
SuccessURL: stripe.String(strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard?billing=success&session_id={CHECKOUT_SESSION_ID}"),
CancelURL: stripe.String(strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard?billing=cancelled"),
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
PaymentMethodCollection: stripe.String("always"), // Require credit card even for free trial
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(priceID),
Quantity: stripe.Int64(1),
},
},
SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{
TrialPeriodDays: stripe.Int64(trialDays),
},
Metadata: map[string]string{
"tenantId": membership.Tenant.ID,
"tenantSlug": membership.Tenant.Slug,
"userId": principal.Subject,
"userEmail": strings.TrimSpace(principal.Email),
"planCode": resolvedPlanCode,
"currency": resolvedCurrency,
"billingInterval": billingInterval,
"source": "bookra-dashboard",
},
}
sess, err := checkoutsession.New(params)
if err != nil {
return domain.CheckoutLaunchResponse{}, fmt.Errorf("failed to create stripe checkout session: %w", err)
}
return domain.CheckoutLaunchResponse{
CheckoutURL: sess.URL,
SuccessRedirectURL: sess.SuccessURL,
CancelRedirectURL: sess.CancelURL,
}, 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
}
@@ -157,16 +281,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: "paddle",
BillingProvider: s.cfg.BillingProvider(),
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
}
// Fall back to Paddle
if s.client == nil {
return domain.SubscriptionSnapshot{}, ErrPaddleNotConfigured
}
record, err := s.syncPaddleData(ctx, membership.Tenant, customerID)
if err != nil {
return domain.SubscriptionSnapshot{}, err
@@ -183,30 +317,53 @@ func (s *Service) CreatePortalSession(ctx context.Context, principal domain.Prin
}
return domain.PortalSessionResponse{}, err
}
if s.client == nil {
return domain.PortalSessionResponse{}, ErrPaddleNotConfigured
}
customerID := derefString(membership.Tenant.BillingCustomerID)
if customerID == "" {
return domain.PortalSessionResponse{}, ErrBillingCustomerMissing
}
// Prefer Stripe if configured
if s.cfg.StripeConfigured() {
return s.createStripePortalSession(customerID)
}
// Fall back to Paddle
return s.createPaddlePortalSession(ctx, membership, customerID)
}
func (s *Service) createStripePortalSession(customerID string) (domain.PortalSessionResponse, error) {
params := &stripe.BillingPortalSessionParams{
Customer: stripe.String(customerID),
ReturnURL: stripe.String(s.cfg.FrontendURL + "/dashboard?billing=refresh"),
}
sess, err := portalsession.New(params)
if err != nil {
return domain.PortalSessionResponse{}, fmt.Errorf("failed to create stripe portal session: %w", err)
}
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}
}
session, err := s.client.CreateCustomerPortalSession(ctx, request)
sess, err := s.client.CreateCustomerPortalSession(ctx, request)
if err != nil {
return domain.PortalSessionResponse{}, err
}
url := strings.TrimSpace(session.URLs.General.Overview)
if url == "" && len(session.URLs.Subscriptions) > 0 {
url := strings.TrimSpace(sess.URLs.General.Overview)
if url == "" && len(sess.URLs.Subscriptions) > 0 {
url = firstNonEmpty(
session.URLs.Subscriptions[0].UpdateSubscriptionPaymentMethod,
session.URLs.Subscriptions[0].CancelSubscription,
sess.URLs.Subscriptions[0].UpdateSubscriptionPaymentMethod,
sess.URLs.Subscriptions[0].CancelSubscription,
)
}
if url == "" {
@@ -217,6 +374,109 @@ func (s *Service) CreatePortalSession(ctx context.Context, principal domain.Prin
}
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)
}
func (s *Service) handleStripeWebhook(ctx context.Context, req *http.Request) error {
if s.cfg.StripeWebhookKey == "" {
return ErrStripeWebhookMissing
}
body, err := io.ReadAll(req.Body)
if err != nil {
return err
}
event, err := webhook.ConstructEvent(body, req.Header.Get("Stripe-Signature"), s.cfg.StripeWebhookKey)
if err != nil {
return fmt.Errorf("invalid stripe webhook signature: %w", err)
}
if !slices.Contains(allowedStripeWebhookEvents, event.Type) {
return nil
}
// Extract customer ID from event data
var customerID string
var eventID = event.ID
switch event.Type {
case "checkout.session.completed":
var sess stripe.CheckoutSession
if err := json.Unmarshal(event.Data.Raw, &sess); err != nil {
return err
}
customerID = sess.Customer.ID
if sess.Metadata != nil {
if tenantID := sess.Metadata["tenantId"]; tenantID != "" {
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
return err
}
if customerID != "" && derefString(tenant.BillingCustomerID) == "" {
if err := s.repo.UpdateTenantBillingCustomerID(ctx, tenant.ID, customerID); err != nil {
return err
}
tenant.BillingCustomerID = &customerID
}
_, err = s.syncStripeDataToKV(ctx, tenant, customerID)
return err
}
}
default:
var data struct {
Customer struct {
ID string `json:"id"`
} `json:"customer"`
}
if err := json.Unmarshal(event.Data.Raw, &data); err != nil {
return err
}
customerID = data.Customer.ID
}
if customerID == "" {
return nil
}
tenant, err := s.repo.GetTenantByBillingCustomerID(ctx, customerID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
return err
}
inserted, err := s.repo.RecordBillingEvent(ctx, tenant.ID, "stripe", eventID, string(event.Type), event.Data.Raw)
if err != nil || !inserted {
return err
}
_, err = s.syncStripeDataToKV(ctx, tenant, customerID)
return err
}
func (s *Service) handlePaddleWebhook(ctx context.Context, req *http.Request) error {
if s.verifier == nil {
return ErrPaddleWebhookMissing
}
@@ -241,7 +501,7 @@ func (s *Service) HandleWebhook(ctx context.Context, req *http.Request) error {
if err := json.Unmarshal(payload, &event); err != nil {
return err
}
if !slices.Contains(allowedWebhookEvents, event.EventType) {
if !slices.Contains(allowedPaddleWebhookEvents, event.EventType) {
return nil
}
@@ -337,7 +597,7 @@ func (s *Service) syncPaddleData(ctx context.Context, tenant db.TenantRecord, cu
record.CurrentPeriodEnd = parseRFC3339Ptr(timePeriodEnd(selected.CurrentBillingPeriod))
if len(selected.Items) > 0 {
record.PriceID = selected.Items[0].Price.ID
record.PlanCode = s.planCodeForPrice(record.PriceID, tenant.PlanCode)
record.PlanCode = s.paddlePlanCodeForPrice(record.PriceID, tenant.PlanCode)
}
}
@@ -351,6 +611,115 @@ func (s *Service) syncPaddleData(ctx context.Context, tenant db.TenantRecord, cu
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.
func (s *Service) syncStripeDataToKV(ctx context.Context, tenant db.TenantRecord, customerID string) (db.BillingSnapshotRecord, error) {
// Fetch all subscriptions for this customer from Stripe
iter := subscription.List(&stripe.SubscriptionListParams{
Customer: stripe.String(customerID),
})
var selected *stripe.Subscription
for iter.Next() {
sub := iter.Subscription()
if selected == nil || stripeSubscriptionRank(sub) > stripeSubscriptionRank(selected) {
selected = sub
}
}
if iter.Err() != nil {
return db.BillingSnapshotRecord{}, fmt.Errorf("failed to list stripe subscriptions: %w", iter.Err())
}
now := time.Now().UTC()
record := db.BillingSnapshotRecord{
TenantID: tenant.ID,
BillingProvider: "stripe",
BillingCustomerID: customerID,
BillingSubscriptionID: "",
Status: "inactive",
PlanCode: shared.NormalizePlanCode(tenant.PlanCode),
Currency: "czk",
PriceID: "",
LastSyncedAt: &now,
}
if selected != nil {
record.BillingSubscriptionID = selected.ID
record.Status = normalizeStripeSubscriptionStatus(selected.Status)
record.Currency = strings.ToLower(string(selected.Currency))
record.CancelAtPeriodEnd = selected.CancelAtPeriodEnd
record.CurrentPeriodStart = stripeTimeToPtr(selected.CurrentPeriodStart)
record.CurrentPeriodEnd = stripeTimeToPtr(selected.CurrentPeriodEnd)
// Extract price ID from subscription items
if len(selected.Items.Data) > 0 {
record.PriceID = selected.Items.Data[0].Price.ID
record.PlanCode = s.stripePlanCodeForPrice(record.PriceID, tenant.PlanCode)
}
// Get payment method info if available
if selected.DefaultPaymentMethod != nil && selected.DefaultPaymentMethod.Card != nil {
record.PaymentMethodBrand = string(selected.DefaultPaymentMethod.Card.Brand)
record.PaymentMethodLast4 = selected.DefaultPaymentMethod.Card.Last4
}
}
// Store normalized snapshot in DB (KV cache)
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
}
func stripeSubscriptionRank(sub *stripe.Subscription) int {
switch sub.Status {
case stripe.SubscriptionStatusActive:
return 6
case stripe.SubscriptionStatusTrialing:
return 5
case stripe.SubscriptionStatusPastDue:
return 4
case stripe.SubscriptionStatusPaused:
return 3
case stripe.SubscriptionStatusCanceled:
return 2
default:
return 1
}
}
func normalizeStripeSubscriptionStatus(status stripe.SubscriptionStatus) string {
switch status {
case stripe.SubscriptionStatusActive:
return "active"
case stripe.SubscriptionStatusTrialing:
return "trialing"
case stripe.SubscriptionStatusPastDue:
return "past_due"
case stripe.SubscriptionStatusPaused:
return "paused"
case stripe.SubscriptionStatusCanceled:
return "canceled"
case stripe.SubscriptionStatusUnpaid:
return "canceled"
default:
return "inactive"
}
}
func stripeTimeToPtr(t int64) *time.Time {
if t == 0 {
return nil
}
ts := time.Unix(t, 0).UTC()
return &ts
}
func toSnapshot(tenant db.TenantRecord, record db.BillingSnapshotRecord, cfg config.Config) domain.SubscriptionSnapshot {
if record.PlanCode == "" {
record.PlanCode = shared.NormalizePlanCode(tenant.PlanCode)
@@ -363,43 +732,84 @@ 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)
return domain.SubscriptionSnapshot{
TenantID: tenant.ID,
Provider: firstNonEmpty(record.BillingProvider, tenant.BillingProvider, "paddle"),
CustomerID: customerID,
SubscriptionID: firstNonEmpty(record.BillingSubscriptionID, derefString(tenant.BillingSubscription)),
Status: record.Status,
PlanCode: record.PlanCode,
Currency: record.Currency,
PriceID: record.PriceID,
CancelAtPeriodEnd: record.CancelAtPeriodEnd,
CurrentPeriodStart: record.CurrentPeriodStart,
CurrentPeriodEnd: record.CurrentPeriodEnd,
PaymentMethodBrand: record.PaymentMethodBrand,
PaymentMethodLast4: record.PaymentMethodLast4,
Entitlements: entitlementsForPlan(record.PlanCode),
DisplayPrices: displayPricesForPlan(record.PlanCode),
TrialDays: 30,
TenantID: tenant.ID,
Provider: provider,
CustomerID: customerID,
SubscriptionID: firstNonEmpty(record.BillingSubscriptionID, derefString(tenant.BillingSubscription)),
Status: record.Status,
PlanCode: record.PlanCode,
Currency: record.Currency,
PriceID: record.PriceID,
CancelAtPeriodEnd: record.CancelAtPeriodEnd,
CurrentPeriodStart: record.CurrentPeriodStart,
CurrentPeriodEnd: record.CurrentPeriodEnd,
PaymentMethodBrand: record.PaymentMethodBrand,
PaymentMethodLast4: record.PaymentMethodLast4,
Entitlements: entitlementsForPlan(record.PlanCode),
DisplayPrices: displayPricesForPlan(record.PlanCode),
TrialDays: func() int {
if record.PlanCode == "starter" || record.PlanCode == "pro" {
return 15
}
return 0
}(),
LastSyncedAt: record.LastSyncedAt,
CheckoutURLAvailable: checkoutAvailable(cfg, record.PlanCode),
SyncAvailable: cfg.PaddleConfigured(),
PortalAvailable: cfg.PaddleConfigured() && customerID != "",
CheckoutURLAvailable: checkoutAvailable,
SyncAvailable: syncAvailable,
PortalAvailable: portalAvailable,
}
}
func entitlementsForPlan(planCode string) domain.PlanEntitlements {
switch shared.NormalizePlanCode(planCode) {
case "starter":
return domain.PlanEntitlements{MaxLocations: 1, MaxStaff: 3, EmailReminders: true, AdvancedReporting: false, WidgetEmbedding: true, UmamiTracking: false}
// Starter: 1 location, 1 staff, 50 bookings/month
return domain.PlanEntitlements{
MaxLocations: 1,
MaxStaff: 1,
MaxBookingsMonth: 50,
EmailReminders: false,
AdvancedReporting: false,
WidgetEmbedding: true,
UmamiTracking: false,
APIAccess: false,
}
case "business":
return domain.PlanEntitlements{MaxLocations: 10, MaxStaff: 30, EmailReminders: true, AdvancedReporting: true, WidgetEmbedding: true, UmamiTracking: true}
// Business: Unlimited everything, API access, dedicated manager
return domain.PlanEntitlements{
MaxLocations: -1, // Unlimited
MaxStaff: -1, // Unlimited
MaxBookingsMonth: -1, // Unlimited
EmailReminders: true,
AdvancedReporting: true,
WidgetEmbedding: true,
UmamiTracking: true,
APIAccess: true,
DedicatedManager: true,
}
default:
return domain.PlanEntitlements{MaxLocations: 3, MaxStaff: 10, EmailReminders: true, AdvancedReporting: true, WidgetEmbedding: true, UmamiTracking: true}
// Pro: 3 locations, 10 staff, unlimited bookings, email reminders, analytics
return domain.PlanEntitlements{
MaxLocations: 3,
MaxStaff: 10,
MaxBookingsMonth: -1, // Unlimited
EmailReminders: true,
AdvancedReporting: true,
WidgetEmbedding: true,
UmamiTracking: true,
APIAccess: false,
}
}
}
func (s *Service) planCodeForPrice(priceID string, fallback string) string {
func (s *Service) paddlePlanCodeForPrice(priceID string, fallback string) string {
for planCode, currencies := range s.cfg.PaddlePriceMatrix {
for _, configuredPriceID := range currencies {
if configuredPriceID != "" && configuredPriceID == priceID {
@@ -410,7 +820,18 @@ func (s *Service) planCodeForPrice(priceID string, fallback string) string {
return shared.NormalizePlanCode(fallback)
}
func (s *Service) priceForPlan(planCode string, currency string) (string, string, string) {
func (s *Service) stripePlanCodeForPrice(priceID string, fallback string) string {
for planCode, currencies := range s.cfg.StripePriceMatrix {
for _, configuredPriceID := range currencies {
if configuredPriceID != "" && configuredPriceID == priceID {
return shared.NormalizePlanCode(planCode)
}
}
}
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"
@@ -427,6 +848,48 @@ func (s *Service) priceForPlan(planCode string, currency string) (string, string
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 == "" {
resolvedPlan = "pro"
}
resolvedCurrency := normalizeCurrency(currency)
resolvedInterval := billingInterval
if resolvedInterval == "" {
resolvedInterval = "monthly"
}
// Build the price key: plan:currency:interval (e.g., "pro:usd:monthly", "pro:usd:yearly")
priceKey := resolvedPlan + ":" + resolvedCurrency + ":" + resolvedInterval
if priceID := s.cfg.StripePriceMatrix[resolvedPlan][priceKey]; priceID != "" {
return priceID, resolvedPlan, resolvedCurrency
}
// Fall back to plan:currency format (for backwards compatibility)
priceKey = resolvedPlan + ":" + resolvedCurrency
if priceID := s.cfg.StripePriceMatrix[resolvedPlan][priceKey]; priceID != "" {
return priceID, resolvedPlan, resolvedCurrency
}
// Try just plan code with interval
if resolvedInterval != "monthly" {
priceKey = resolvedPlan + ":" + resolvedInterval
if priceID := s.cfg.StripePriceMatrix[resolvedPlan][priceKey]; priceID != "" {
return priceID, resolvedPlan, resolvedCurrency
}
}
// Default currency fallback
if resolvedCurrency != "usd" {
priceKey = resolvedPlan + ":usd:" + resolvedInterval
if priceID := s.cfg.StripePriceMatrix[resolvedPlan][priceKey]; priceID != "" {
return priceID, resolvedPlan, "usd"
}
}
return s.cfg.StripePriceMatrix[resolvedPlan][resolvedPlan+":czk"], resolvedPlan, "czk"
}
func subscriptionRank(subscription *paddle.Subscription) int {
switch subscription.Status {
case paddle.SubscriptionStatusActive:
@@ -447,19 +910,22 @@ func subscriptionRank(subscription *paddle.Subscription) int {
func displayPricesForPlan(planCode string) []domain.PlanDisplayPrice {
switch shared.NormalizePlanCode(planCode) {
case "starter":
// Starter: $5/month, $50/year (save $10 = ~17%)
return []domain.PlanDisplayPrice{
{Currency: "czk", AmountCents: 11900, Formatted: "119 Kc"},
{Currency: "usd", AmountCents: 500, Formatted: "$5"},
{Currency: "czk", AmountCents: 11900, Formatted: "119 Kč/mo", YearlyAmountCents: 119000, YearlyFormatted: "1 190 Kč/yr", YearlySavings: "Save 199 Kč", YearlySavingsPercent: 17},
{Currency: "usd", AmountCents: 500, Formatted: "$5/mo", YearlyAmountCents: 5000, YearlyFormatted: "$50/yr", YearlySavings: "Save $10", YearlySavingsPercent: 17},
}
case "business":
// Business: $50/month, $500/year (save $100 = ~17%)
return []domain.PlanDisplayPrice{
{Currency: "czk", AmountCents: 119900, Formatted: "1 199 Kc"},
{Currency: "usd", AmountCents: 5000, Formatted: "$50"},
{Currency: "czk", AmountCents: 119900, Formatted: "1 199 Kč/mo", YearlyAmountCents: 1199000, YearlyFormatted: "11 990 Kč/yr", YearlySavings: "Save 1 999 Kč", YearlySavingsPercent: 17},
{Currency: "usd", AmountCents: 5000, Formatted: "$50/mo", YearlyAmountCents: 50000, YearlyFormatted: "$500/yr", YearlySavings: "Save $100", YearlySavingsPercent: 17},
}
default:
// Pro: $20/month, $200/year (save $40 = ~17%)
return []domain.PlanDisplayPrice{
{Currency: "czk", AmountCents: 49900, Formatted: "499 Kc"},
{Currency: "usd", AmountCents: 2000, Formatted: "$20"},
{Currency: "czk", AmountCents: 49900, Formatted: "499 Kč/mo", YearlyAmountCents: 499000, YearlyFormatted: "4 990 Kč/yr", YearlySavings: "Save 999 Kč", YearlySavingsPercent: 17},
{Currency: "usd", AmountCents: 2000, Formatted: "$20/mo", YearlyAmountCents: 20000, YearlyFormatted: "$200/yr", YearlySavings: "Save $40", YearlySavingsPercent: 17},
}
}
}
@@ -497,6 +963,30 @@ 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 ""
@@ -554,3 +1044,48 @@ func firstNonEmpty(values ...string) string {
}
return ""
}
// CheckAndSendTrialEndingEmails checks all tenants with trials and sends emails for those ending soon
func (s *Service) CheckAndSendTrialEndingEmails(ctx context.Context, notificationService interface {
SendTrialEndingEmail(ctx context.Context, tenantID string, daysRemaining int) error
}) error {
// Get all tenants with trial status
tenants, _, err := s.repo.ListAllTenants(ctx, 1000, 0)
if err != nil {
return err
}
now := time.Now().UTC()
for _, tenant := range tenants {
if tenant.SubscriptionStatus != "trialing" && tenant.SubscriptionStatus != "trial" {
continue
}
// Get subscription to check trial end date
snapshot, err := s.repo.GetSubscriptionSnapshot(ctx, tenant.ID)
if err != nil {
continue
}
// Calculate trial end: assume 15-day trial from period start
var trialEnd time.Time
if snapshot.CurrentPeriodStart != nil {
trialEnd = snapshot.CurrentPeriodStart.Add(15 * 24 * time.Hour)
} else {
// Default to 15 days from now if no start date
trialEnd = now.Add(15 * 24 * time.Hour)
}
daysRemaining := int(trialEnd.Sub(now).Hours() / 24)
// Send email if trial ends in 1-3 days
if daysRemaining >= 1 && daysRemaining <= 3 {
if err := notificationService.SendTrialEndingEmail(ctx, tenant.ID, daysRemaining); err != nil {
// Log but don't fail
fmt.Printf("Failed to send trial ending email for tenant %s: %v\n", tenant.ID, err)
}
}
}
return nil
}
+39 -3
View File
@@ -3,6 +3,7 @@ package catalog
import (
"context"
"errors"
"fmt"
"time"
"bookra/apps/backend/internal/db"
@@ -17,14 +18,25 @@ var (
ErrInvalidBooking = errors.New("invalid booking request")
ErrTenantNotFound = errors.New("tenant not found")
ErrTenantMembership = errors.New("tenant membership not found")
ErrPlanLimitReached = errors.New("plan limit reached")
)
type Service struct {
repo db.Repository
repo db.Repository
billingService interface {
GetEntitlements(ctx context.Context, tenantID string) (domain.PlanEntitlements, error)
}
notificationService interface {
SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error
}
}
func NewService(repo db.Repository) *Service {
return &Service{repo: repo}
func NewService(repo db.Repository, billingService interface {
GetEntitlements(ctx context.Context, tenantID string) (domain.PlanEntitlements, error)
}, notificationService interface {
SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error
}) *Service {
return &Service{repo: repo, billingService: billingService, notificationService: notificationService}
}
// ============================================
@@ -63,6 +75,18 @@ func (s *Service) CreateLocation(ctx context.Context, principal domain.Principal
return domain.Location{}, ErrTenantMembership
}
// Check plan entitlements for location limit
if s.billingService != nil {
entitlements, err := s.billingService.GetEntitlements(ctx, membership.Tenant.ID)
if err == nil && entitlements.MaxLocations > 0 {
// Count existing locations
locations, err := s.repo.ListLocationsByTenant(ctx, membership.Tenant.ID)
if err == nil && len(locations) >= entitlements.MaxLocations {
return domain.Location{}, fmt.Errorf("%w: location limit reached (%d/%d). Upgrade your plan to add more locations.", ErrPlanLimitReached, len(locations), entitlements.MaxLocations)
}
}
}
params := db.CreateLocationParams{
TenantID: membership.Tenant.ID,
Name: req.Name,
@@ -74,6 +98,18 @@ func (s *Service) CreateLocation(ctx context.Context, principal domain.Principal
return domain.Location{}, err
}
// Send usage warning if at 80%+ of limit
if s.notificationService != nil && s.billingService != nil {
entitlements, err := s.billingService.GetEntitlements(ctx, membership.Tenant.ID)
if err == nil && entitlements.MaxLocations > 0 {
locations, _ := s.repo.ListLocationsByTenant(ctx, membership.Tenant.ID)
usagePercent := (len(locations) * 100) / entitlements.MaxLocations
if usagePercent >= 80 {
_ = s.notificationService.SendUsageWarning(ctx, membership.Tenant.ID, len(locations), entitlements.MaxLocations, usagePercent)
}
}
}
return domain.Location{
ID: rec.ID,
TenantID: rec.TenantID,
+66
View File
@@ -28,8 +28,14 @@ type Config struct {
PaddleAPIKey string
PaddleWebhookKey string
PaddlePriceMatrix map[string]map[string]string
StripeAPIKey string
StripeWebhookKey string
StripePriceMatrix map[string]map[string]string
AdminEmail string
AdminKey string
UmamiAPIURL string
UmamiAPIKey string
SentryDSN string
DemoMode bool
}
@@ -53,8 +59,14 @@ func Load() (Config, error) {
PaddleAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_API_KEY")),
PaddleWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_WEBHOOK_SECRET")),
PaddlePriceMatrix: paddlePriceMatrixFromEnv(),
StripeAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_API_KEY")),
StripeWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_WEBHOOK_SECRET")),
StripePriceMatrix: stripePriceMatrixFromEnv(),
AdminEmail: strings.TrimSpace(os.Getenv("BOOKRA_ADMIN_EMAIL")),
AdminKey: strings.TrimSpace(os.Getenv("BOOKRA_ADMIN_KEY")),
UmamiAPIURL: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_URL")),
UmamiAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_KEY")),
SentryDSN: strings.TrimSpace(os.Getenv("BOOKRA_SENTRY_DSN")),
DemoMode: boolFromEnv("BOOKRA_DEMO_MODE", false),
}
@@ -118,6 +130,34 @@ func (cfg Config) PaddleCheckoutConfigured(planCode string) bool {
return cfg.PaddleConfigured() && cfg.PaddleWebhookConfigured() && cfg.PaddlePriceMatrix[planCode]["czk"] != "" && cfg.PaddlePriceMatrix[planCode]["usd"] != ""
}
func (cfg Config) StripeConfigured() bool {
return strings.TrimSpace(cfg.StripeAPIKey) != ""
}
func (cfg Config) StripeWebhookConfigured() bool {
return strings.TrimSpace(cfg.StripeWebhookKey) != ""
}
func (cfg Config) StripeCheckoutConfigured(planCode string) bool {
planCode = shared.NormalizePlanCode(planCode)
return cfg.StripeConfigured() && cfg.StripeWebhookConfigured() && cfg.StripePriceMatrix[planCode]["czk"] != "" && cfg.StripePriceMatrix[planCode]["usd"] != ""
}
func (cfg Config) BillingProvider() string {
if cfg.StripeConfigured() {
return "stripe"
}
return "paddle"
}
func (cfg Config) BillingConfigured() bool {
return cfg.StripeConfigured() || cfg.PaddleConfigured()
}
func (cfg Config) BillingWebhookConfigured() bool {
return cfg.StripeWebhookConfigured() || cfg.PaddleWebhookConfigured()
}
func paddlePriceMatrixFromEnv() map[string]map[string]string {
matrix := map[string]map[string]string{
"starter": {},
@@ -132,6 +172,32 @@ func paddlePriceMatrixFromEnv() map[string]map[string]string {
return matrix
}
func stripePriceMatrixFromEnv() 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, "-", "_"))
// Monthly prices
matrix[planCode][planCode+":czk:monthly"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_MONTHLY_PRICE_ID"))
matrix[planCode][planCode+":usd:monthly"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_MONTHLY_PRICE_ID"))
matrix[planCode][planCode+":czk"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_PRICE_ID"))
matrix[planCode][planCode+":usd"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_PRICE_ID"))
matrix[planCode]["czk"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_PRICE_ID"))
matrix[planCode]["usd"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_PRICE_ID"))
// Yearly prices
matrix[planCode][planCode+":czk:yearly"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_YEARLY_PRICE_ID"))
matrix[planCode][planCode+":usd:yearly"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_YEARLY_PRICE_ID"))
matrix[planCode]["yearly:czk"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_YEARLY_PRICE_ID"))
matrix[planCode]["yearly:usd"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_YEARLY_PRICE_ID"))
}
return matrix
}
func normalizePaddleEnvironment(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "live", "production":
+135
View File
@@ -0,0 +1,135 @@
package db
import (
"context"
"encoding/json"
)
func (r *PGRepository) ListAllTenants(ctx context.Context, limit, offset int) ([]TenantRecord, int, error) {
var total int
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM tenants`).Scan(&total)
if err != nil {
return nil, 0, err
}
rows, err := r.pool.Query(ctx, `
SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status,
COALESCE(billing_provider, 'stripe'), billing_customer_id, billing_subscription_id
FROM tenants
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var tenants []TenantRecord
for rows.Next() {
var t TenantRecord
if err := rows.Scan(
&t.ID, &t.Slug, &t.Name, &t.Preset, &t.Locale, &t.Timezone,
&t.PlanCode, &t.SubscriptionStatus, &t.BillingProvider,
&t.BillingCustomerID, &t.BillingSubscription,
); err != nil {
return nil, 0, err
}
tenants = append(tenants, t)
}
return tenants, total, nil
}
func (r *PGRepository) ListAllUsers(ctx context.Context, limit, offset int) ([]UserRecord, int, error) {
var total int
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&total)
if err != nil {
return nil, 0, err
}
rows, err := r.pool.Query(ctx, `
SELECT id, email, name, email_verified, provider, role, created_at, last_login_at
FROM users
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var users []UserRecord
for rows.Next() {
var u UserRecord
if err := rows.Scan(
&u.ID, &u.Email, &u.Name, &u.EmailVerified, &u.Provider, &u.Role,
&u.CreatedAt, &u.LastLoginAt,
); err != nil {
return nil, 0, err
}
users = append(users, u)
}
return users, total, nil
}
func (r *PGRepository) GetPlatformStats(ctx context.Context) (PlatformStats, error) {
var stats PlatformStats
// Total tenants
r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM tenants`).Scan(&stats.TotalTenants)
// Total users
r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&stats.TotalUsers)
// Active subscriptions
r.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM billing_snapshots
WHERE status IN ('active', 'trialing')
`).Scan(&stats.ActiveSubscriptions)
// Trial subscriptions
r.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM billing_snapshots
WHERE status = 'trialing'
`).Scan(&stats.TrialSubscriptions)
// Bookings this month
r.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM bookings
WHERE created_at >= date_trunc('month', CURRENT_DATE)
`).Scan(&stats.BookingsThisMonth)
return stats, nil
}
func (r *PGRepository) CreateAdminAuditLog(ctx context.Context, params AdminAuditLogParams) error {
var detailsJSON []byte
var err error
if params.Details != nil {
detailsJSON, err = json.Marshal(params.Details)
if err != nil {
return err
}
}
_, err = r.pool.Exec(ctx, `
INSERT INTO admin_audit_log (admin_user_id, action, resource_type, resource_id, details, ip_address, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`, nullableUUID(params.AdminUserID), params.Action, params.ResourceType, params.ResourceID, detailsJSON, params.IPAddress, params.UserAgent)
return err
}
func (r *PGRepository) UpdateUserRole(ctx context.Context, userID, role string) error {
_, err := r.pool.Exec(ctx, `
UPDATE users SET role = $1, updated_at = NOW() WHERE id = $2
`, role, userID)
return err
}
func nullableUUID(s string) interface{} {
if s == "" {
return nil
}
return s
}
+137
View File
@@ -0,0 +1,137 @@
package db
import (
"context"
"time"
"github.com/jackc/pgx/v5"
)
func (r *PGRepository) GetUserByEmail(ctx context.Context, email string) (*UserRecord, error) {
var user UserRecord
var name, passwordHash *string
var lastLoginAt *time.Time
err := r.pool.QueryRow(ctx, `
SELECT id, email, name, password_hash, email_verified, provider, role, created_at, last_login_at
FROM users
WHERE email = $1
`, email).Scan(
&user.ID, &user.Email, &name, &passwordHash,
&user.EmailVerified, &user.Provider, &user.Role,
&user.CreatedAt, &lastLoginAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
user.Name = name
user.PasswordHash = passwordHash
user.LastLoginAt = lastLoginAt
return &user, nil
}
func (r *PGRepository) GetUserByID(ctx context.Context, userID string) (*UserRecord, error) {
var user UserRecord
var name, passwordHash *string
var lastLoginAt *time.Time
err := r.pool.QueryRow(ctx, `
SELECT id, email, name, password_hash, email_verified, provider, role, created_at, last_login_at
FROM users
WHERE id = $1
`, userID).Scan(
&user.ID, &user.Email, &name, &passwordHash,
&user.EmailVerified, &user.Provider, &user.Role,
&user.CreatedAt, &lastLoginAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
user.Name = name
user.PasswordHash = passwordHash
user.LastLoginAt = lastLoginAt
return &user, nil
}
func (r *PGRepository) CreateUser(ctx context.Context, email, passwordHash, name, provider, role string) (*UserRecord, error) {
var user UserRecord
var lastLoginAt *time.Time
err := r.pool.QueryRow(ctx, `
INSERT INTO users (email, password_hash, name, provider, role, email_verified)
VALUES ($1, $2, $3, $4, $5, false)
RETURNING id, email, name, password_hash, email_verified, provider, role, created_at, last_login_at
`, email, nullableString(passwordHash), nullableString(name), provider, role).Scan(
&user.ID, &user.Email, &user.Name, &user.PasswordHash,
&user.EmailVerified, &user.Provider, &user.Role,
&user.CreatedAt, &lastLoginAt,
)
if err != nil {
return nil, err
}
user.LastLoginAt = lastLoginAt
return &user, nil
}
func (r *PGRepository) UpdateLastLogin(ctx context.Context, userID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE users SET last_login_at = NOW() WHERE id = $1
`, userID)
return err
}
func (r *PGRepository) MarkEmailVerified(ctx context.Context, userID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE users SET email_verified = true WHERE id = $1
`, userID)
return err
}
func (r *PGRepository) CreateMagicLink(ctx context.Context, token, userID, email string, expiresAt time.Time) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO magic_links (token, user_id, email, expires_at)
VALUES ($1, $2, $3, $4)
`, token, userID, email, expiresAt)
return err
}
func (r *PGRepository) GetMagicLink(ctx context.Context, token string) (*MagicLinkRecord, error) {
var ml MagicLinkRecord
err := r.pool.QueryRow(ctx, `
SELECT token, user_id, email, used, expires_at, created_at
FROM magic_links
WHERE token = $1
`, token).Scan(
&ml.Token, &ml.UserID, &ml.Email, &ml.Used, &ml.ExpiresAt, &ml.CreatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &ml, nil
}
func (r *PGRepository) MarkMagicLinkUsed(ctx context.Context, token string) error {
_, err := r.pool.Exec(ctx, `
UPDATE magic_links SET used = true WHERE token = $1
`, token)
return err
}
func nullableString(s string) interface{} {
if s == "" {
return nil
}
return s
}
+111
View File
@@ -38,6 +38,23 @@ type Repository interface {
UpdateTenantBillingState(ctx context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID string) error
RecordBillingEvent(ctx context.Context, tenantID string, provider string, eventID string, eventType string, payload []byte) (bool, error)
// Auth methods
GetUserByEmail(ctx context.Context, email string) (*UserRecord, error)
GetUserByID(ctx context.Context, userID string) (*UserRecord, error)
CreateUser(ctx context.Context, email, passwordHash, name, provider, role string) (*UserRecord, error)
UpdateLastLogin(ctx context.Context, userID string) error
MarkEmailVerified(ctx context.Context, userID string) error
CreateMagicLink(ctx context.Context, token, userID, email string, expiresAt time.Time) error
GetMagicLink(ctx context.Context, token string) (*MagicLinkRecord, error)
MarkMagicLinkUsed(ctx context.Context, token string) error
// Admin methods
ListAllTenants(ctx context.Context, limit, offset int) ([]TenantRecord, int, error)
ListAllUsers(ctx context.Context, limit, offset int) ([]UserRecord, int, error)
GetPlatformStats(ctx context.Context) (PlatformStats, error)
CreateAdminAuditLog(ctx context.Context, params AdminAuditLogParams) error
UpdateUserRole(ctx context.Context, userID, role string) error
// Location / Zone Management
ListLocationsByTenant(ctx context.Context, tenantID string) ([]LocationRecord, error)
GetLocationByID(ctx context.Context, locationID string) (LocationRecord, error)
@@ -85,6 +102,46 @@ type TenantRecord struct {
BillingSubscription *string
}
type UserRecord struct {
ID uuid.UUID
Email string
Name *string
PasswordHash *string
EmailVerified bool
Provider string
Role string
CreatedAt time.Time
LastLoginAt *time.Time
}
type MagicLinkRecord struct {
Token string
UserID uuid.UUID
Email string
Used bool
ExpiresAt time.Time
CreatedAt time.Time
}
type PlatformStats struct {
TotalTenants int64 `json:"totalTenants"`
TotalUsers int64 `json:"totalUsers"`
ActiveSubscriptions int64 `json:"activeSubscriptions"`
TrialSubscriptions int64 `json:"trialSubscriptions"`
BookingsThisMonth int64 `json:"bookingsThisMonth"`
RevenueThisMonth int64 `json:"revenueThisMonthCents"`
}
type AdminAuditLogParams struct {
AdminUserID string
Action string
ResourceType string
ResourceID string
Details map[string]any
IPAddress string
UserAgent string
}
type TenantMembershipRecord struct {
Tenant TenantRecord
UserID string
@@ -1303,6 +1360,60 @@ func (r *MemoryRepository) UpdateWorkingHours(_ context.Context, tenantID string
return pgx.ErrNoRows
}
// Auth methods for MemoryRepository
func (r *MemoryRepository) GetUserByEmail(_ context.Context, email string) (*UserRecord, error) {
return nil, nil
}
func (r *MemoryRepository) GetUserByID(_ context.Context, userID string) (*UserRecord, error) {
return nil, nil
}
func (r *MemoryRepository) CreateUser(_ context.Context, email, passwordHash, name, provider, role string) (*UserRecord, error) {
return &UserRecord{ID: uuid.New(), Email: email, Name: &name, Provider: provider, Role: role}, nil
}
func (r *MemoryRepository) UpdateLastLogin(_ context.Context, userID string) error {
return nil
}
func (r *MemoryRepository) MarkEmailVerified(_ context.Context, userID string) error {
return nil
}
func (r *MemoryRepository) CreateMagicLink(_ context.Context, token, userID, email string, expiresAt time.Time) error {
return nil
}
func (r *MemoryRepository) GetMagicLink(_ context.Context, token string) (*MagicLinkRecord, error) {
return nil, nil
}
func (r *MemoryRepository) MarkMagicLinkUsed(_ context.Context, token string) error {
return nil
}
// Admin methods for MemoryRepository
func (r *MemoryRepository) ListAllTenants(_ context.Context, limit, offset int) ([]TenantRecord, int, error) {
return []TenantRecord{r.tenant}, 1, nil
}
func (r *MemoryRepository) ListAllUsers(_ context.Context, limit, offset int) ([]UserRecord, int, error) {
return []UserRecord{}, 0, nil
}
func (r *MemoryRepository) GetPlatformStats(_ context.Context) (PlatformStats, error) {
return PlatformStats{TotalTenants: 1, TotalUsers: 1, ActiveSubscriptions: 1}, nil
}
func (r *MemoryRepository) CreateAdminAuditLog(_ context.Context, params AdminAuditLogParams) error {
return nil
}
func (r *MemoryRepository) UpdateUserRole(_ context.Context, userID, role string) error {
return nil
}
func Reference(prefix string, at time.Time) string {
return fmt.Sprintf("%s-%s-%s", prefix, at.UTC().Format("20060102150405"), strings.Split(uuid.NewString(), "-")[0])
}
+85 -9
View File
@@ -146,16 +146,32 @@ type CreateBookingResponse struct {
type PlanEntitlements struct {
MaxLocations int `json:"maxLocations"`
MaxStaff int `json:"maxStaff"`
MaxBookingsMonth int `json:"maxBookingsMonth"` // -1 = unlimited
EmailReminders bool `json:"emailReminders"`
AdvancedReporting bool `json:"advancedReporting"`
WidgetEmbedding bool `json:"widgetEmbedding"`
UmamiTracking bool `json:"umamiTracking"`
APIAccess bool `json:"apiAccess"`
DedicatedManager bool `json:"dedicatedManager"`
}
type PlanPricing struct {
MonthlyAmountCents int `json:"monthlyAmountCents"`
YearlyAmountCents int `json:"yearlyAmountCents"`
MonthlyFormatted string `json:"monthlyFormatted"`
YearlyFormatted string `json:"yearlyFormatted"`
YearlySavings string `json:"yearlySavings"`
YearlySavingsPercent int `json:"yearlySavingsPercent"`
}
type PlanDisplayPrice struct {
Currency string `json:"currency"`
AmountCents int `json:"amountCents"`
Formatted string `json:"formatted"`
Currency string `json:"currency"`
AmountCents int `json:"amountCents"`
Formatted string `json:"formatted"`
YearlyAmountCents int `json:"yearlyAmountCents,omitempty"`
YearlyFormatted string `json:"yearlyFormatted,omitempty"`
YearlySavings string `json:"yearlySavings,omitempty"`
YearlySavingsPercent int `json:"yearlySavingsPercent,omitempty"`
}
type SubscriptionSnapshot struct {
@@ -182,17 +198,22 @@ type SubscriptionSnapshot struct {
}
type CheckoutSessionRequest struct {
PlanCode string `json:"planCode"`
Currency string `json:"currency,omitempty"`
PlanCode string `json:"planCode"`
Currency string `json:"currency,omitempty"`
BillingInterval string `json:"billingInterval,omitempty"` // "monthly" or "yearly", defaults to "monthly"
}
type CheckoutLaunchResponse struct {
PriceID string `json:"priceId"`
// Stripe checkout
CheckoutURL string `json:"checkoutUrl,omitempty"`
// Paddle checkout
PriceID string `json:"priceId,omitempty"`
CustomerID string `json:"customerId,omitempty"`
CustomerEmail string `json:"customerEmail,omitempty"`
SuccessRedirectURL string `json:"successRedirectUrl"`
CancelRedirectURL string `json:"cancelRedirectUrl"`
CustomData map[string]string `json:"customData"`
// Common
SuccessRedirectURL string `json:"successRedirectUrl,omitempty"`
CancelRedirectURL string `json:"cancelRedirectUrl,omitempty"`
CustomData map[string]string `json:"customData,omitempty"`
}
type PortalSessionResponse struct {
@@ -321,6 +342,61 @@ type CancelBookingRequest struct {
Reason string `json:"reason,omitempty"`
}
// ============================================
// ADMIN MODELS
// ============================================
type AdminDashboardStats struct {
TotalTenants int64 `json:"totalTenants"`
TotalUsers int64 `json:"totalUsers"`
ActiveSubscriptions int64 `json:"activeSubscriptions"`
TrialSubscriptions int64 `json:"trialSubscriptions"`
BookingsThisMonth int64 `json:"bookingsThisMonth"`
RevenueThisMonthCents int64 `json:"revenueThisMonthCents"`
}
type AdminTenantList struct {
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Tenants []AdminTenant `json:"tenants"`
}
type AdminTenant struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
PlanCode string `json:"planCode"`
SubscriptionStatus string `json:"subscriptionStatus"`
BillingProvider string `json:"billingProvider"`
}
type AdminUserList struct {
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Users []AdminUser `json:"users"`
}
type AdminUser struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name,omitempty"`
EmailVerified bool `json:"emailVerified"`
Provider string `json:"provider"`
Role string `json:"role"`
CreatedAt time.Time `json:"createdAt"`
}
type AdminLoginRequest struct {
Email string `json:"email" binding:"required,email"`
Key string `json:"key" binding:"required"`
}
type UpdateUserRoleRequest struct {
Role string `json:"role" binding:"required,oneof=user admin superadmin"`
}
// ============================================
// WORKING HOURS MODELS
// ============================================
@@ -10,40 +10,146 @@ import (
type EmailType string
const (
EmailTypeConfirmation EmailType = "confirmation"
EmailTypeConfirmation EmailType = "confirmation"
EmailTypeReminder EmailType = "reminder"
EmailTypeReschedule EmailType = "reschedule"
EmailTypeCancellation EmailType = "cancellation"
EmailTypeBusinessNotify EmailType = "business_notify"
EmailTypeUsageWarning EmailType = "usage_warning"
EmailTypeTrialEnding EmailType = "trial_ending"
)
type BookingEmailData struct {
Type EmailType
TenantName string
TenantSlug string
BusinessEmail string
BusinessPhone string
BusinessAddress string
BrandColor string
CustomerName string
CustomerEmail string
Service string
Location string
Reference string
StartsAt time.Time
EndsAt time.Time
Timezone string
Locale string
Notes string
ManagementURL string
AddToCalendarURL string
}
type UsageNotificationData struct {
Type EmailType
TenantName string
TenantSlug string
BusinessEmail string
BusinessPhone string
BusinessAddress string
BrandColor string
CustomerName string
CustomerEmail string
Service string
Location string
Reference string
StartsAt time.Time
EndsAt time.Time
Timezone string
AdminEmail string
Locale string
Notes string
ManagementURL string
AddToCalendarURL string
PlanCode string
LocationCount int
LocationLimit int
UsagePercent int
UpgradeURL string
DashboardURL string
}
func RenderUsageNotificationEmail(data UsageNotificationData) EmailMessage {
subject := renderUsageSubject(data)
htmlBody := renderUsageHTML(data)
textBody := renderUsageText(data)
return EmailMessage{
From: data.BusinessEmail,
To: data.AdminEmail,
Subject: subject,
Text: textBody,
HTML: htmlBody,
}
}
func renderUsageSubject(data UsageNotificationData) string {
if data.Locale == "cs" {
switch data.Type {
case EmailTypeUsageWarning:
return "⚠️ Blížíte se limitu lokací - Upgrade na vyšší plán"
case EmailTypeTrialEnding:
return "⏰ Vaše zkušební období končí - Pokračujte s Bookra"
}
}
switch data.Type {
case EmailTypeUsageWarning:
return "⚠️ You're nearing your location limit - Upgrade your plan"
case EmailTypeTrialEnding:
return "⏰ Your trial period is ending - Continue with Bookra"
}
return "Bookra notification"
}
func renderUsageHTML(data UsageNotificationData) string {
cs := data.Locale == "cs"
upgradeBtn := `<a href="` + data.UpgradeURL + `" style="display:inline-block;background:#4f46e5;color:#fff;padding:12px 24px;text-decoration:none;border-radius:8px;font-weight:600;margin:8px 0;">` + map[bool]string{true: "Upgradeovat", false: "Upgrade"}[cs] + `</a>`
dashboardBtn := `<a href="` + data.DashboardURL + `" style="display:inline-block;background:#f3f4f6;color:#374151;padding:12px 24px;text-decoration:none;border-radius:8px;font-weight:600;margin:8px 0;">` + map[bool]string{true: "Otevřít dashboard", false: "Open dashboard"}[cs] + `</a>`
if data.Type == EmailTypeUsageWarning {
var msg string
if cs {
msg = fmt.Sprintf("Váš plán %s umožňuje pouze %d lokací. Aktuálně používáte %d (%d%%).", data.PlanCode, data.LocationLimit, data.LocationCount, data.UsagePercent)
} else {
msg = fmt.Sprintf("Your %s plan allows only %d locations. You're currently using %d (%d%%).", data.PlanCode, data.LocationLimit, data.LocationCount, data.UsagePercent)
}
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:600px;margin:0 auto;padding:20px;color:#1f2937;">
<div style="text-align:center;margin-bottom:24px;"><span style="font-size:32px;">📍</span></div>
<h2 style="text-align:center;color:#1f2937;margin-bottom:16px;">` + map[bool]string{true: "Blížíte se limitu lokací", false: "You're nearing your location limit"}[cs] + `</h2>
<p style="color:#6b7280;text-align:center;margin-bottom:24px;">` + msg + `</p>
<div style="text-align:center;margin-bottom:24px;">` + upgradeBtn + `</div>
<p style="color:#9ca3af;font-size:14px;text-align:center;">` + map[bool]string{true: "Přidejte další lokace s vyšším plánem", false: "Add more locations with a higher plan"}[cs] + `</p>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">
<p style="color:#9ca3af;font-size:12px;text-align:center;">Powered by <a href="https://bookra.eu" style="color:#4f46e5;">Bookra</a></p>
</body></html>`
}
// Trial ending email
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:600px;margin:0 auto;padding:20px;color:#1f2937;">
<div style="text-align:center;margin-bottom:24px;"><span style="font-size:32px;">🎉</span></div>
<h2 style="text-align:center;color:#1f2937;margin-bottom:16px;">` + map[bool]string{true: "Děkujeme, že používáte Bookra!", false: "Thank you for using Bookra!"}[cs] + `</h2>
<p style="color:#6b7280;text-align:center;margin-bottom:24px;">` + map[bool]string{true: "Vaše zkušební období brzy končí. Pokud se vám naše služba líbí, můžete pokračovat s vybraným plánem.", false: "Your trial period is ending soon. If you like our service, you can continue with your chosen plan."}[cs] + `</p>
<div style="text-align:center;margin-bottom:24px;">` + upgradeBtn + `</div>
<p style="color:#6b7280;text-align:center;margin-bottom:16px;">` + map[bool]string{true: "Pokud se vám služba nelíbí, můžete ji kdykoliv zrušit. Nechceme vám brát peníze, pokud nejste spokojeni.", false: "If you don't like our service, you can cancel anytime. We don't want to take your money if you're not happy."}[cs] + `</p>
<p style="color:#9ca3af;text-align:center;margin-bottom:24px;">` + map[bool]string{true: "Zrušit můžete zde:", false: "Cancel here:"}[cs] + ` ` + dashboardBtn + `</p>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">
<p style="color:#9ca3af;font-size:12px;text-align:center;">Powered by <a href="https://bookra.eu" style="color:#4f46e5;">Bookra</a></p>
</body></html>`
}
func renderUsageText(data UsageNotificationData) string {
cs := data.Locale == "cs"
if data.Type == EmailTypeUsageWarning {
if cs {
return fmt.Sprintf("Blížíte se limitu lokací! Váš plán %s umožňuje pouze %d lokací. Aktuálně používáte %d (%d%%). Upgradeujte na vyšší plán: %s", data.PlanCode, data.LocationLimit, data.LocationCount, data.UsagePercent, data.UpgradeURL)
}
return fmt.Sprintf("You're nearing your location limit! Your %s plan allows only %d locations. You're currently using %d (%d%%). Upgrade: %s", data.PlanCode, data.LocationLimit, data.LocationCount, data.UsagePercent, data.UpgradeURL)
}
if cs {
return "Vaše zkušební období končí. Pokud se vám služba líbí, můžete pokračovat. Pokud ne, můžete zrušit. Nechceme vám brát peníze, pokud nejste spokojeni. Dashboard: " + data.DashboardURL
}
return "Your trial period is ending. If you like our service, you can continue. If not, you can cancel - we don't want your money if you're not happy. Dashboard: " + data.DashboardURL
}
func RenderEmailMessage(data BookingEmailData) EmailMessage {
subject := renderSubject(data)
htmlBody := renderHTMLBody(data)
textBody := renderTextBody(data)
return EmailMessage{
From: data.BusinessEmail,
To: data.CustomerEmail,
@@ -55,7 +161,7 @@ func RenderEmailMessage(data BookingEmailData) EmailMessage {
func renderSubject(data BookingEmailData) string {
localizedTime := formatLocalizedTime(data.StartsAt, data.Timezone, data.Locale)
switch data.Type {
case EmailTypeConfirmation:
if data.Locale == "cs" {
@@ -89,7 +195,7 @@ func renderSubject(data BookingEmailData) string {
func renderTextBody(data BookingEmailData) string {
localizedTime := formatLocalizedDateTime(data.StartsAt, data.Timezone, data.Locale)
switch data.Type {
case EmailTypeConfirmation:
if data.Locale == "cs" {
@@ -124,7 +230,7 @@ Manage your booking at: %s
Thank you,
%s
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName, data.BusinessEmail)
case EmailTypeReminder:
if data.Locale == "cs" {
return fmt.Sprintf(`Dobrý den %s,
@@ -152,7 +258,7 @@ This is a reminder for your booking tomorrow.
Manage booking: %s
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName)
case EmailTypeReschedule:
if data.Locale == "cs" {
return fmt.Sprintf(`Dobrý den %s,
@@ -182,7 +288,7 @@ New details:
Manage booking: %s
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName)
case EmailTypeCancellation:
if data.Locale == "cs" {
return fmt.Sprintf(`Dobrý den %s,
@@ -210,7 +316,7 @@ Cancelled booking:
If you didn't cancel this, please contact us: %s
%s`, data.CustomerName, data.Service, localizedTime, data.Reference, data.BusinessEmail, data.TenantName)
case EmailTypeBusinessNotify:
if data.Locale == "cs" {
return fmt.Sprintf(`Nová rezervace od %s
@@ -232,7 +338,7 @@ Details:
- Email: %s
Manage in dashboard: https://bookra.eu/dashboard`, data.CustomerName, data.Service, localizedTime, data.Reference, data.CustomerEmail)
default:
return "Booking update"
}
@@ -245,7 +351,7 @@ func renderHTMLBody(data BookingEmailData) string {
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>", data.BrandColor, data.TenantName)
// Convert text to simple HTML
paragraphs := splitParagraphs(textBody)
for _, p := range paragraphs {
@@ -253,12 +359,12 @@ func renderHTMLBody(data BookingEmailData) string {
html += fmt.Sprintf("<p style='margin-bottom: 10px;'>%s</p>", p)
}
}
// Add management button
if data.ManagementURL != "" {
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>", data.ManagementURL, data.BrandColor)
}
html += "</div></body></html>"
return html
}
@@ -347,7 +453,7 @@ func RenderReminderEmail(from string, job db.ReminderJobRecord) EmailMessage {
StartsAt: job.StartsAt,
Timezone: job.Timezone,
Locale: job.Locale,
Service: "Service", // Legacy
Service: "Service", // Legacy
Location: "Location", // Legacy
}
return RenderEmailMessage(data)
@@ -285,3 +285,77 @@ func (p smtpEmailProvider) Send(_ context.Context, message EmailMessage) (Delive
ExternalID: fmt.Sprintf("smtp-%d", time.Now().UnixNano()),
}, nil
}
// SendContactEmail sends a contact form submission to the business email
func (s *Service) SendContactEmail(ctx context.Context, name, email, message string) error {
subject := fmt.Sprintf("Bookra Contact: Message from %s", name)
text := fmt.Sprintf("Name: %s\nEmail: %s\n\nMessage:\n%s", name, email, message)
html := fmt.Sprintf(
"<h2>New contact form submission</h2><p><strong>Name:</strong> %s</p><p><strong>Email:</strong> %s</p><p><strong>Message:</strong></p><p>%s</p>",
name, email, message,
)
msg := EmailMessage{
From: s.cfg.EmailFrom,
To: s.cfg.EmailFrom,
Subject: subject,
Text: text,
HTML: html,
}
_, err := s.emailProvider.Send(ctx, msg)
return err
}
func (s *Service) SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error {
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
if err != nil {
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"
emailData := UsageNotificationData{
Type: EmailTypeUsageWarning,
TenantName: tenant.Name,
TenantSlug: tenant.Slug,
BusinessEmail: s.cfg.EmailFrom,
AdminEmail: adminEmail,
Locale: tenant.Locale,
PlanCode: tenant.PlanCode,
LocationCount: locationCount,
LocationLimit: locationLimit,
UsagePercent: usagePercent,
UpgradeURL: "https://bookra.eu/pricing",
DashboardURL: "https://bookra.eu/dashboard",
}
msg := RenderUsageNotificationEmail(emailData)
_, err = s.emailProvider.Send(ctx, msg)
return err
}
func (s *Service) SendTrialEndingEmail(ctx context.Context, tenantID string, daysRemaining int) error {
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
if err != nil {
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"
emailData := UsageNotificationData{
Type: EmailTypeTrialEnding,
TenantName: tenant.Name,
TenantSlug: tenant.Slug,
BusinessEmail: s.cfg.EmailFrom,
AdminEmail: adminEmail,
Locale: tenant.Locale,
PlanCode: tenant.PlanCode,
UpgradeURL: "https://bookra.eu/pricing",
DashboardURL: "https://bookra.eu/dashboard",
}
msg := RenderUsageNotificationEmail(emailData)
_, err = s.emailProvider.Send(ctx, msg)
return err
}
@@ -0,0 +1,97 @@
-- +goose Up
-- +goose StatementBegin
-- Users table for authentication (migrated from auth-service)
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(255),
password_hash VARCHAR(255),
email_verified BOOLEAN DEFAULT FALSE,
provider VARCHAR(50) NOT NULL DEFAULT 'email',
provider_id VARCHAR(255),
role VARCHAR(50) NOT NULL DEFAULT 'user',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_login_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_provider ON users(provider, provider_id);
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
-- Magic links for passwordless auth
CREATE TABLE IF NOT EXISTS magic_links (
token VARCHAR(255) PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL,
used BOOLEAN DEFAULT FALSE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_magic_links_user_id ON magic_links(user_id);
CREATE INDEX IF NOT EXISTS idx_magic_links_expires ON magic_links(expires_at) WHERE used = FALSE;
-- Password reset tokens
CREATE TABLE IF NOT EXISTS password_resets (
token VARCHAR(255) PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
used BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_password_resets_user_id ON password_resets(user_id);
-- OAuth state tokens
CREATE TABLE IF NOT EXISTS oauth_states (
state VARCHAR(255) PRIMARY KEY,
redirect_url VARCHAR(500),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
);
-- Admin audit log
CREATE TABLE IF NOT EXISTS admin_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
admin_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(100) NOT NULL,
resource_type VARCHAR(100),
resource_id VARCHAR(255),
details JSONB,
ip_address VARCHAR(45),
user_agent TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_admin_audit_log_admin ON admin_audit_log(admin_user_id);
CREATE INDEX IF NOT EXISTS idx_admin_audit_log_action ON admin_audit_log(action);
CREATE INDEX IF NOT EXISTS idx_admin_audit_log_created ON admin_audit_log(created_at);
-- Refresh tokens for JWT
CREATE TABLE IF NOT EXISTS refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
revoked BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS refresh_tokens;
DROP TABLE IF EXISTS admin_audit_log;
DROP TABLE IF EXISTS oauth_states;
DROP TABLE IF EXISTS password_resets;
DROP TABLE IF EXISTS magic_links;
DROP TABLE IF EXISTS users;
-- +goose StatementEnd
+35 -1
View File
@@ -639,9 +639,16 @@ components:
planCode:
type: string
enum: [starter, pro, business]
description: The plan to subscribe to
currency:
type: string
enum: [czk, usd]
description: Currency for the subscription
billingInterval:
type: string
enum: [monthly, yearly]
default: monthly
description: Billing interval. Yearly gets 17% discount.
PlanDisplayPrice:
type: object
required: [currency, amountCents, formatted]
@@ -651,29 +658,56 @@ components:
enum: [czk, usd]
amountCents:
type: integer
description: Monthly price in cents
formatted:
type: string
description: Formatted monthly price string
yearlyAmountCents:
type: integer
description: Yearly price in cents (17% discount)
yearlyFormatted:
type: string
description: Formatted yearly price string
yearlySavings:
type: string
description: Description of yearly savings
yearlySavingsPercent:
type: integer
description: Percentage saved with yearly billing
CheckoutLaunchResponse:
type: object
required: [priceId, successRedirectUrl, cancelRedirectUrl, customData]
description: |
Checkout launch response supporting both Stripe and Paddle providers.
For Stripe: checkoutUrl is returned (redirect-based checkout).
For Paddle: priceId, customerId, customerEmail, customData are returned (client-side checkout).
properties:
checkoutUrl:
type: string
format: uri
description: Stripe checkout URL (redirect the user to this URL)
priceId:
type: string
description: Paddle price ID for client-side checkout
customerId:
type: string
description: Paddle customer ID
customerEmail:
type: string
format: email
description: Customer email for Paddle checkout
successRedirectUrl:
type: string
format: uri
description: URL to redirect after successful checkout
cancelRedirectUrl:
type: string
format: uri
description: URL to redirect after cancelled checkout
customData:
type: object
additionalProperties:
type: string
description: Custom metadata for Paddle checkout
PortalSessionResponse:
type: object
required: [url]