mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-03 20:13:00 +00:00
feat(core): consolidate auth service into backend and implement stripe billing
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user