mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-03 20:13:00 +00:00
cleanup
This commit is contained in:
+77
-10
@@ -1,15 +1,82 @@
|
||||
# ============================================
|
||||
# Bookra Backend API Configuration
|
||||
# ============================================
|
||||
# Active stack:
|
||||
# - frontend signs users in with Neon Auth
|
||||
# - backend verifies Neon Auth JWTs
|
||||
# - backend serves booking + billing APIs
|
||||
# - Paddle handles SaaS billing + customer portal
|
||||
# ============================================
|
||||
|
||||
BOOKRA_APP_ENV=staging
|
||||
BOOKRA_API_PORT=8080
|
||||
BOOKRA_API_URL=http://localhost:8080
|
||||
BOOKRA_FRONTEND_URL=http://localhost:3000
|
||||
BOOKRA_DATABASE_URL=postgres://user:password@ep-example-pooler.region.aws.neon.tech/neondb?sslmode=require
|
||||
BOOKRA_DATABASE_DIRECT_URL=postgres://user:password@ep-example.region.aws.neon.tech/neondb?sslmode=require
|
||||
BOOKRA_NEON_AUTH_URL=https://example.neonauth.region.aws.neon.tech/neondb/auth
|
||||
|
||||
# --------------------------------------------
|
||||
# DEMO MODE (Standalone showcase mode)
|
||||
# --------------------------------------------
|
||||
# Set to true to enable permanent demo mode with in-memory data.
|
||||
# When enabled:
|
||||
# - API uses MemoryRepository with pre-populated demo data
|
||||
# - All requests are auto-authenticated as demo-owner
|
||||
# - No database connection required
|
||||
# - Perfect for showcasing the app without external dependencies
|
||||
# Note: When DEMO_MODE=false, the following are required:
|
||||
# - BOOKRA_DATABASE_URL
|
||||
# - BOOKRA_NEON_AUTH_URL (for JWT verification)
|
||||
BOOKRA_DEMO_MODE=false
|
||||
|
||||
# --------------------------------------------
|
||||
# DATABASE (Required when DEMO_MODE=false)
|
||||
# --------------------------------------------
|
||||
BOOKRA_DATABASE_URL=postgres://user:password@ep-example-pooler.region.aws.neon.tech/bookra_backend?sslmode=require
|
||||
BOOKRA_DATABASE_DIRECT_URL=postgres://user:password@ep-example.region.aws.neon.tech/bookra_backend?sslmode=require
|
||||
|
||||
# --------------------------------------------
|
||||
# AUTHENTICATION (Required when DEMO_MODE=false)
|
||||
# --------------------------------------------
|
||||
# Neon Auth base URL for JWKS verification.
|
||||
BOOKRA_NEON_AUTH_URL=https://ep-mute-water-alem1v8u.neonauth.c-3.eu-central-1.aws.neon.tech/neondb/auth
|
||||
# Optional emergency/local fallback for non-Neon JWT verification.
|
||||
# Leave blank in normal Neon Auth setup.
|
||||
BOOKRA_AUTH_JWT_SECRET=
|
||||
|
||||
# --------------------------------------------
|
||||
# INTERNAL SERVICES
|
||||
# --------------------------------------------
|
||||
# Job runner key for internal API endpoints (reminders, notifications)
|
||||
BOOKRA_JOB_RUNNER_KEY=job_runner_secret_123
|
||||
BOOKRA_EMAIL_FROM=noreply@bookra.dev
|
||||
BOOKRA_SMS_FROM=Bookra
|
||||
BOOKRA_STRIPE_SECRET_KEY=sk_test_123
|
||||
BOOKRA_STRIPE_WEBHOOK_SECRET=whsec_123
|
||||
BOOKRA_STRIPE_STARTER_PRICE_ID=price_starter_123
|
||||
BOOKRA_STRIPE_GROWTH_PRICE_ID=price_growth_123
|
||||
BOOKRA_STRIPE_MULTI_LOCATION_PRICE_ID=price_multi_location_123
|
||||
|
||||
# --------------------------------------------
|
||||
# PADDLE BILLING (Required for live checkout/webhooks)
|
||||
# --------------------------------------------
|
||||
# BOOKRA_PADDLE_ENV must be "sandbox" or "live".
|
||||
# API key: Paddle dashboard -> Developer tools -> Authentication.
|
||||
# Webhook secret: Paddle dashboard -> Notification settings -> destination secret key.
|
||||
# Price IDs: Paddle dashboard -> Catalog -> Product price IDs.
|
||||
BOOKRA_PADDLE_ENV=sandbox
|
||||
BOOKRA_PADDLE_API_KEY=
|
||||
BOOKRA_PADDLE_WEBHOOK_SECRET=
|
||||
BOOKRA_PADDLE_STARTER_CZK_PRICE_ID=
|
||||
BOOKRA_PADDLE_STARTER_USD_PRICE_ID=
|
||||
BOOKRA_PADDLE_PRO_CZK_PRICE_ID=
|
||||
BOOKRA_PADDLE_PRO_USD_PRICE_ID=
|
||||
BOOKRA_PADDLE_BUSINESS_CZK_PRICE_ID=
|
||||
BOOKRA_PADDLE_BUSINESS_USD_PRICE_ID=
|
||||
|
||||
# --------------------------------------------
|
||||
# EMAIL (Optional)
|
||||
# --------------------------------------------
|
||||
# Only configure if backend needs to send reminder emails directly.
|
||||
BOOKRA_EMAIL_FROM=
|
||||
BOOKRA_SMTP_HOST=
|
||||
BOOKRA_SMTP_PORT=587
|
||||
BOOKRA_SMTP_USERNAME=
|
||||
BOOKRA_SMTP_PASSWORD=
|
||||
|
||||
# --------------------------------------------
|
||||
# ANALYTICS (Optional)
|
||||
# --------------------------------------------
|
||||
BOOKRA_UMAMI_API_URL=
|
||||
BOOKRA_UMAMI_API_KEY=
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
FROM golang:1.26.2-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /app/backend ./cmd/api
|
||||
|
||||
FROM alpine:3.22
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache ca-certificates
|
||||
|
||||
COPY --from=builder /app/backend /app/
|
||||
COPY --from=builder /app/migrations /app/migrations
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["/app/backend"]
|
||||
+44
-4
@@ -1,6 +1,6 @@
|
||||
# Bookra Backend
|
||||
|
||||
Go + Gin API for Bookra, designed for Railway deployment and Neon-backed persistence.
|
||||
Go + Gin API for Bookra, designed for Railway deployment with Neon Auth, Neon Postgres, and Paddle billing.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -18,11 +18,14 @@ npm run db:migrate:up
|
||||
- `BOOKRA_DATABASE_URL` Neon pooled connection
|
||||
- `BOOKRA_DATABASE_DIRECT_URL` Neon direct connection for migrations/admin tasks
|
||||
- `BOOKRA_NEON_AUTH_URL` Neon Auth base URL used for JWKS verification
|
||||
- `BOOKRA_AUTH_JWT_SECRET` optional local JWT fallback when not using Neon Auth
|
||||
- `BOOKRA_JOB_RUNNER_KEY` shared secret for remote reminder dispatch calls
|
||||
- `BOOKRA_EMAIL_FROM` sender identity for email reminders
|
||||
- `BOOKRA_SMS_FROM` sender label for future SMS reminders
|
||||
- `BOOKRA_STRIPE_SECRET_KEY` Stripe API secret
|
||||
- `BOOKRA_STRIPE_WEBHOOK_SECRET` Stripe webhook secret
|
||||
- `BOOKRA_PADDLE_ENV` billing environment: `sandbox` or `live`
|
||||
- `BOOKRA_PADDLE_API_KEY` Paddle API key
|
||||
- `BOOKRA_PADDLE_WEBHOOK_SECRET` Paddle notification destination secret
|
||||
- `BOOKRA_PADDLE_{STARTER,PRO,BUSINESS}_{CZK,USD}_PRICE_ID` Paddle price IDs
|
||||
- `BOOKRA_UMAMI_API_URL` and `BOOKRA_UMAMI_API_KEY` optional analytics integration
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -32,3 +35,40 @@ npm run db:migrate:up
|
||||
- `sqlc.yaml` is wired through `npm run db:generate`.
|
||||
- Goose migrations are wired through `npm run db:migrate:*` and use the Neon direct connection URL.
|
||||
- Reminder dispatch now runs through `POST /v1/internal/jobs/reminders/dispatch` with `X-Bookra-Job-Key`.
|
||||
|
||||
## Production Auth
|
||||
|
||||
Bookra production auth should use Neon Auth directly:
|
||||
|
||||
- frontend uses `VITE_NEON_AUTH_URL`
|
||||
- backend verifies Neon JWTs with `BOOKRA_NEON_AUTH_URL`
|
||||
- auth-service may stay deployed for standalone auth/admin workflows, but backend billing and app APIs do not depend on it
|
||||
|
||||
Trusted redirect domains in Neon Auth should include your frontend origin such as `https://bookra.eu`, plus local dev origins when needed.
|
||||
|
||||
## Paddle Setup
|
||||
|
||||
Get these values from Paddle dashboard:
|
||||
|
||||
- `BOOKRA_PADDLE_ENV`: `sandbox` for testing, `live` for production
|
||||
- `BOOKRA_PADDLE_API_KEY`: Developer tools -> Authentication
|
||||
- `BOOKRA_PADDLE_WEBHOOK_SECRET`: Notification settings -> destination secret key
|
||||
- `BOOKRA_PADDLE_*_PRICE_ID`: Catalog -> each SaaS plan recurring price ID
|
||||
|
||||
Create one recurring price per plan/currency you support:
|
||||
|
||||
- `starter` `czk`
|
||||
- `starter` `usd`
|
||||
- `pro` `czk`
|
||||
- `pro` `usd`
|
||||
- `business` `czk`
|
||||
- `business` `usd`
|
||||
|
||||
Set your webhook destination to:
|
||||
|
||||
```text
|
||||
POST /v1/webhooks/paddle
|
||||
POST /api/paddle_webhook
|
||||
```
|
||||
|
||||
Use Paddle webhook simulator for event testing.
|
||||
|
||||
@@ -29,6 +29,7 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("create server: %v", err)
|
||||
}
|
||||
defer server.Close()
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
|
||||
+4
-1
@@ -3,13 +3,13 @@ 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/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/v83 v83.2.1
|
||||
golang.org/x/time v0.9.0
|
||||
)
|
||||
|
||||
@@ -20,12 +20,15 @@ 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/ggicci/httpin v0.20.3 // indirect
|
||||
github.com/ggicci/owl v0.8.2 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
|
||||
+8
-2
@@ -2,6 +2,8 @@ github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOh
|
||||
github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0=
|
||||
github.com/MicahParks/keyfunc/v3 v3.8.0 h1:Hx2dgIjAXGk9slakM6rV9BOeaWDPEXXZ4Us8guNBfds=
|
||||
github.com/MicahParks/keyfunc/v3 v3.8.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0=
|
||||
github.com/PaddleHQ/paddle-go-sdk/v5 v5.2.0 h1:HJabVGWEsDyogj1Ib/ZGcMcP9Vc/e2tqIDYx0xZA+qI=
|
||||
github.com/PaddleHQ/paddle-go-sdk/v5 v5.2.0/go.mod h1:nlFfZYf7MovyHTE67+u7BARbMiBdMMLXGuQMbCVm9ss=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
@@ -15,6 +17,10 @@ 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/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=
|
||||
github.com/ggicci/owl v0.8.2/go.mod h1:PHRD57u41vFN5UtFz2SF79yTVoM3HlWpjMiE+ZU2dj4=
|
||||
github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q=
|
||||
github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
@@ -40,6 +46,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -81,8 +89,6 @@ 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/v83 v83.2.1 h1:8WPhpMjr8VyMWKUsCMoVvlWxYazuL5edajKX/RulfbA=
|
||||
github.com/stripe/stripe-go/v83 v83.2.1/go.mod h1:nRyDcLrJtwPPQUnKAFs9Bt1NnQvNhNiF6V19XHmPISE=
|
||||
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=
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/auth"
|
||||
@@ -30,16 +31,18 @@ type Server struct {
|
||||
}
|
||||
|
||||
func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
verifier, err := auth.NewVerifier(cfg.NeonAuthURL)
|
||||
verifier, err := auth.NewVerifier(cfg.NeonAuthURL, cfg.AuthJWTSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
repository := db.NewRepository(pools)
|
||||
bookingService := bookings.NewService(repository)
|
||||
tenantService := tenancy.NewService(repository)
|
||||
billingService := billing.NewService(cfg, repository)
|
||||
repository := db.NewRepository(pools, cfg.DemoMode)
|
||||
notificationService := notifications.NewService(cfg, repository)
|
||||
bookingService := bookings.NewService(repository, notificationService)
|
||||
customerBookingService := bookings.NewCustomerService(repository, notificationService)
|
||||
tenantService := tenancy.NewService(repository)
|
||||
catalogService := catalog.NewService(repository)
|
||||
billingService := billing.NewService(cfg, repository)
|
||||
publicRateLimiter := httpx.NewRateLimiter(rate.Every(time.Second), 5)
|
||||
|
||||
server := &Server{
|
||||
@@ -50,7 +53,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
}
|
||||
|
||||
server.router.Use(gin.Logger(), gin.Recovery(), cors.New(cors.Config{
|
||||
AllowOrigins: []string{cfg.FrontendURL},
|
||||
AllowOrigins: allowedOrigins(cfg),
|
||||
AllowHeaders: []string{"Authorization", "Content-Type"},
|
||||
AllowMethods: []string{"GET", "POST", "OPTIONS"},
|
||||
AllowCredentials: true,
|
||||
@@ -69,6 +72,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
"environment": cfg.Environment,
|
||||
"neonAuthEnabled": verifier.Enabled(),
|
||||
"apiUrl": cfg.APIURL,
|
||||
"demoMode": cfg.DemoMode,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -123,7 +127,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
})
|
||||
|
||||
protected := server.router.Group("/v1")
|
||||
protected.Use(auth.RequireAuth(verifier, repository))
|
||||
protected.Use(auth.RequireAuth(verifier, repository, cfg.DemoMode))
|
||||
|
||||
protected.GET("/dashboard/summary", func(c *gin.Context) {
|
||||
response, err := bookingService.DashboardSummary(c.Request.Context(), auth.PrincipalFromContext(c))
|
||||
@@ -166,7 +170,309 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
c.JSON(http.StatusCreated, response)
|
||||
})
|
||||
|
||||
_ = catalog.NewService()
|
||||
// ============================================
|
||||
// CATALOG API - Locations / Zones
|
||||
// ============================================
|
||||
protected.GET("/catalog/locations", func(c *gin.Context) {
|
||||
response, err := catalogService.ListLocations(c.Request.Context(), auth.PrincipalFromContext(c))
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
protected.POST("/catalog/locations", func(c *gin.Context) {
|
||||
var request domain.CreateLocationRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
response, err := catalogService.CreateLocation(c.Request.Context(), auth.PrincipalFromContext(c), request)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, response)
|
||||
})
|
||||
protected.PUT("/catalog/locations/:locationID", func(c *gin.Context) {
|
||||
var request domain.UpdateLocationRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
response, err := catalogService.UpdateLocation(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("locationID"), request)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
} else if errors.Is(err, catalog.ErrLocationNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
protected.DELETE("/catalog/locations/:locationID", func(c *gin.Context) {
|
||||
err := catalogService.DeleteLocation(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("locationID"))
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
} else if errors.Is(err, catalog.ErrLocationNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// CATALOG API - Blocked Days
|
||||
// ============================================
|
||||
protected.GET("/catalog/blocked-days", func(c *gin.Context) {
|
||||
from := time.Now()
|
||||
to := from.AddDate(0, 3, 0) // 3 months ahead
|
||||
response, err := catalogService.ListBlockedDays(c.Request.Context(), auth.PrincipalFromContext(c), from, to)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
protected.POST("/catalog/blocked-days", func(c *gin.Context) {
|
||||
var request domain.CreateBlockedDayRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
response, err := catalogService.CreateBlockedDay(c.Request.Context(), auth.PrincipalFromContext(c), request)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
} else if errors.Is(err, catalog.ErrInvalidBooking) {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, response)
|
||||
})
|
||||
protected.PUT("/catalog/blocked-days/:blockedDayID", func(c *gin.Context) {
|
||||
var request domain.UpdateBlockedDayRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
response, err := catalogService.UpdateBlockedDay(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("blockedDayID"), request)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
} else if errors.Is(err, catalog.ErrBlockedDayNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
protected.DELETE("/catalog/blocked-days/:blockedDayID", func(c *gin.Context) {
|
||||
err := catalogService.DeleteBlockedDay(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("blockedDayID"))
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
} else if errors.Is(err, catalog.ErrBlockedDayNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// CATALOG API - Customers
|
||||
// ============================================
|
||||
protected.GET("/catalog/customers", func(c *gin.Context) {
|
||||
limit := 50
|
||||
offset := 0
|
||||
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 && l <= 100 {
|
||||
limit = l
|
||||
}
|
||||
if o, err := strconv.Atoi(c.Query("offset")); err == nil && o >= 0 {
|
||||
offset = o
|
||||
}
|
||||
response, err := catalogService.ListCustomers(c.Request.Context(), auth.PrincipalFromContext(c), limit, offset)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
protected.POST("/catalog/customers", func(c *gin.Context) {
|
||||
var request domain.CreateCustomerRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
response, err := catalogService.CreateCustomer(c.Request.Context(), auth.PrincipalFromContext(c), request)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, response)
|
||||
})
|
||||
protected.PUT("/catalog/customers/:customerID", func(c *gin.Context) {
|
||||
var request domain.UpdateCustomerRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
response, err := catalogService.UpdateCustomer(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("customerID"), request)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
} else if errors.Is(err, catalog.ErrCustomerNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
protected.DELETE("/catalog/customers/:customerID", func(c *gin.Context) {
|
||||
err := catalogService.DeleteCustomer(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("customerID"))
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
} else if errors.Is(err, catalog.ErrCustomerNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// CATALOG API - Working Hours
|
||||
// ============================================
|
||||
protected.GET("/catalog/working-hours", func(c *gin.Context) {
|
||||
response, err := catalogService.ListWorkingHours(c.Request.Context(), auth.PrincipalFromContext(c))
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
protected.PUT("/catalog/working-hours/:dayOfWeek", func(c *gin.Context) {
|
||||
dayOfWeek, err := strconv.Atoi(c.Param("dayOfWeek"))
|
||||
if err != nil || dayOfWeek < 0 || dayOfWeek > 6 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_day_of_week"})
|
||||
return
|
||||
}
|
||||
var request domain.UpdateWorkingHoursRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
err = catalogService.UpdateWorkingHours(c.Request.Context(), auth.PrincipalFromContext(c), dayOfWeek, request)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// CUSTOMER BOOKING MANAGEMENT API (Public)
|
||||
// ============================================
|
||||
server.router.GET("/v1/public/bookings/:reference", func(c *gin.Context) {
|
||||
token := c.Query("token")
|
||||
response, err := customerBookingService.GetBookingByReference(c.Request.Context(), c.Param("reference"), token)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, bookings.ErrBookingNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
server.router.POST("/v1/public/bookings/:reference/reschedule", publicRateLimiter.Middleware(), func(c *gin.Context) {
|
||||
token := c.Query("token")
|
||||
var request domain.RescheduleBookingRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
err := customerBookingService.RescheduleBooking(c.Request.Context(), c.Param("reference"), request, token)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, bookings.ErrBookingNotFound):
|
||||
status = http.StatusNotFound
|
||||
case errors.Is(err, bookings.ErrBookingCancelled):
|
||||
status = http.StatusConflict
|
||||
case errors.Is(err, bookings.ErrInvalidReschedule):
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "rescheduled"})
|
||||
})
|
||||
server.router.POST("/v1/public/bookings/:reference/cancel", publicRateLimiter.Middleware(), func(c *gin.Context) {
|
||||
token := c.Query("token")
|
||||
err := customerBookingService.CancelBooking(c.Request.Context(), c.Param("reference"), token)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, bookings.ErrBookingNotFound):
|
||||
status = http.StatusNotFound
|
||||
case errors.Is(err, bookings.ErrBookingCancelled):
|
||||
status = http.StatusConflict
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "cancelled"})
|
||||
})
|
||||
|
||||
protected.GET("/billing/subscription", func(c *gin.Context) {
|
||||
response, err := billingService.GetSubscription(c.Request.Context(), auth.PrincipalFromContext(c))
|
||||
@@ -186,7 +492,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)
|
||||
response, err := billingService.CreateCheckoutSession(c.Request.Context(), auth.PrincipalFromContext(c), request.PlanCode, request.Currency)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, billing.ErrBillingMembership) {
|
||||
@@ -195,6 +501,9 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
if errors.Is(err, billing.ErrBillingPlanUnsupported) {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
if errors.Is(err, billing.ErrPaddleNotConfigured) {
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -207,19 +516,41 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
if errors.Is(err, billing.ErrBillingMembership) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
if errors.Is(err, billing.ErrPaddleNotConfigured) {
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
protected.POST("/billing/portal", func(c *gin.Context) {
|
||||
response, err := billingService.CreatePortalSession(c.Request.Context(), auth.PrincipalFromContext(c))
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, billing.ErrBillingMembership):
|
||||
status = http.StatusNotFound
|
||||
case errors.Is(err, billing.ErrBillingCustomerMissing):
|
||||
status = http.StatusBadRequest
|
||||
case errors.Is(err, billing.ErrPaddleNotConfigured):
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
server.router.POST("/v1/webhooks/stripe", func(c *gin.Context) {
|
||||
payload, err := c.GetRawData()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_payload"})
|
||||
server.router.POST("/v1/webhooks/paddle", func(c *gin.Context) {
|
||||
if err := billingService.HandleWebhook(c.Request.Context(), c.Request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := billingService.HandleWebhook(c.Request.Context(), c.GetHeader("Stripe-Signature"), payload); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "accepted"})
|
||||
})
|
||||
server.router.POST("/api/paddle_webhook", func(c *gin.Context) {
|
||||
if err := billingService.HandleWebhook(c.Request.Context(), c.Request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -246,6 +577,13 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
// Widget embeddable script endpoint - serves JavaScript for external sites
|
||||
server.router.GET("/v1/public/widget.js", func(c *gin.Context) {
|
||||
c.Header("Content-Type", "application/javascript; charset=utf-8")
|
||||
c.Header("Cache-Control", "public, max-age=3600") // Cache for 1 hour
|
||||
c.String(http.StatusOK, widgetJavaScript(cfg.APIURL))
|
||||
})
|
||||
|
||||
return server, nil
|
||||
}
|
||||
|
||||
@@ -253,9 +591,185 @@ func (s *Server) Handler() http.Handler {
|
||||
return s.router
|
||||
}
|
||||
|
||||
func (s *Server) Close() {
|
||||
if s.verifier != nil {
|
||||
s.verifier.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func authorizeJobRunner(c *gin.Context, cfg config.Config) bool {
|
||||
if cfg.JobRunnerKey == "" {
|
||||
return cfg.Environment == "development"
|
||||
return false
|
||||
}
|
||||
return c.GetHeader("X-Bookra-Job-Key") == cfg.JobRunnerKey
|
||||
}
|
||||
|
||||
func allowedOrigins(cfg config.Config) []string {
|
||||
origins := []string{cfg.FrontendURL}
|
||||
if cfg.Environment == "development" {
|
||||
origins = append(origins,
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://localhost:4173",
|
||||
"http://127.0.0.1:4173",
|
||||
)
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(origins))
|
||||
unique := origins[:0]
|
||||
for _, origin := range origins {
|
||||
if origin == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[origin]; ok {
|
||||
continue
|
||||
}
|
||||
seen[origin] = struct{}{}
|
||||
unique = append(unique, origin)
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
// widgetJavaScript returns the embeddable widget script that can be included on external sites
|
||||
func widgetJavaScript(apiURL string) string {
|
||||
return `(function() {
|
||||
'use strict';
|
||||
|
||||
// Bookra Widget v1.0 - Embeddable Booking Widget
|
||||
// Usage: <script src="` + apiURL + `/v1/public/widget.js" data-tenant="your-slug" async defer></script>
|
||||
|
||||
const WIDGET_VERSION = '1.0.0';
|
||||
const WIDGET_ORIGIN = '` + apiURL + `';
|
||||
|
||||
// Configuration from script tag attributes
|
||||
const scripts = document.querySelectorAll('script[src*="widget.js"]');
|
||||
|
||||
scripts.forEach(function(script) {
|
||||
const config = {
|
||||
tenant: script.getAttribute('data-tenant') || script.getAttribute('data-tenant-slug'),
|
||||
theme: script.getAttribute('data-theme') || 'auto',
|
||||
size: script.getAttribute('data-size') || 'default',
|
||||
color: script.getAttribute('data-color') || '#a65c3e',
|
||||
position: script.getAttribute('data-position') || 'bottom-right',
|
||||
widgetId: script.getAttribute('data-widget-id') || 'bookra-widget-' + Math.random().toString(36).substr(2, 9)
|
||||
};
|
||||
|
||||
if (!config.tenant) {
|
||||
console.error('[Bookra Widget] Missing data-tenant attribute');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find or create widget container
|
||||
let container = document.getElementById(config.widgetId);
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = config.widgetId;
|
||||
container.className = 'bookra-widget-container';
|
||||
|
||||
// For floating widgets, append to body
|
||||
if (config.size === 'floating' || script.getAttribute('data-floating') === 'true') {
|
||||
container.style.cssText = 'position:fixed;' + getFloatingPosition(config.position) + ';z-index:9999;';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply theme
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const isDark = config.theme === 'dark' || (config.theme === 'auto' && prefersDark);
|
||||
container.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
||||
|
||||
// Build booking URL
|
||||
const bookingUrl = WIDGET_ORIGIN.replace('/api', '') + '/book/' + encodeURIComponent(config.tenant);
|
||||
|
||||
// Create widget content based on type
|
||||
const widgetType = script.getAttribute('data-type') || 'iframe';
|
||||
|
||||
switch (widgetType) {
|
||||
case 'button':
|
||||
container.innerHTML = createButtonWidget(config, bookingUrl);
|
||||
break;
|
||||
case 'modal':
|
||||
container.innerHTML = createModalWidget(config, bookingUrl);
|
||||
break;
|
||||
case 'floating':
|
||||
container.innerHTML = createFloatingWidget(config, bookingUrl);
|
||||
break;
|
||||
case 'inline-calendar':
|
||||
container.innerHTML = createCalendarWidget(config, bookingUrl, WIDGET_ORIGIN);
|
||||
break;
|
||||
default:
|
||||
container.innerHTML = createIframeWidget(config, bookingUrl);
|
||||
}
|
||||
|
||||
// Add styles
|
||||
addWidgetStyles(config.color, isDark);
|
||||
});
|
||||
|
||||
function getFloatingPosition(position) {
|
||||
switch (position) {
|
||||
case 'top-left': return 'top:20px;left:20px';
|
||||
case 'top-right': return 'top:20px;right:20px';
|
||||
case 'bottom-left': return 'bottom:20px;left:20px';
|
||||
default: return 'bottom:20px;right:20px';
|
||||
}
|
||||
}
|
||||
|
||||
function createIframeWidget(config, url) {
|
||||
const height = config.size === 'compact' ? '400px' : config.size === 'full' ? '100vh' : '760px';
|
||||
return '<iframe src="' + url + '" style="width:100%;height:' + height + ';border:none;border-radius:12px;box-shadow:0 4px 20px rgba(0,0,0,0.1);" loading="lazy" title="Book appointment"></iframe>';
|
||||
}
|
||||
|
||||
function createButtonWidget(config, url) {
|
||||
return '<button class="bookra-widget-btn" onclick="window.open(\'' + url + '\', \'_blank\')" style="background:' + config.color + ';color:white;padding:14px 28px;border:none;border-radius:8px;font-size:16px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:8px;transition:opacity 0.2s;">' +
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>' +
|
||||
'Book appointment</button>';
|
||||
}
|
||||
|
||||
function createModalWidget(config, url) {
|
||||
const modalId = config.widgetId + '-modal';
|
||||
return '<button class="bookra-widget-btn" onclick="document.getElementById(\'' + modalId + '\').style.display=\'flex\'" style="background:' + config.color + ';color:white;padding:14px 28px;border:none;border-radius:8px;font-size:16px;font-weight:600;cursor:pointer;">Book now</button>' +
|
||||
'<div id="' + modalId + '" onclick="if(event.target===this)this.style.display=\'none\'" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10000;align-items:center;justify-content:center;padding:20px;">' +
|
||||
'<div style="background:white;border-radius:16px;width:100%;max-width:900px;height:90vh;position:relative;overflow:hidden;">' +
|
||||
'<button onclick="document.getElementById(\'' + modalId + '\').style.display=\'none\'" style="position:absolute;top:16px;right:16px;background:#f5f5f5;border:none;width:36px;height:36px;border-radius:50%;cursor:pointer;font-size:18px;z-index:10;">✕</button>' +
|
||||
'<iframe src="' + url + '" style="width:100%;height:100%;border:none;"></iframe>' +
|
||||
'</div></div>';
|
||||
}
|
||||
|
||||
function createFloatingWidget(config, url) {
|
||||
const modalId = config.widgetId + '-modal';
|
||||
return '<button class="bookra-floating-btn" onclick="document.getElementById(\'' + modalId + '\').style.display=\'flex\'" style="background:' + config.color + ';color:white;width:60px;height:60px;border:none;border-radius:50%;cursor:pointer;box-shadow:0 4px 12px rgba(0,0,0,0.15);display:flex;align-items:center;justify-content:center;transition:transform 0.2s;" onmouseover="this.style.transform=\'scale(1.1)\'" onmouseout="this.style.transform=\'scale(1)\'">' +
|
||||
'<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>' +
|
||||
'</button>' +
|
||||
'<div id="' + modalId + '" onclick="if(event.target===this)this.style.display=\'none\'" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10000;align-items:center;justify-content:center;padding:20px;">' +
|
||||
'<div style="background:white;border-radius:16px;width:100%;max-width:500px;max-height:90vh;position:relative;overflow:hidden;">' +
|
||||
'<button onclick="document.getElementById(\'' + modalId + '\').style.display=\'none\'" style="position:absolute;top:16px;right:16px;background:#f5f5f5;border:none;width:36px;height:36px;border-radius:50%;cursor:pointer;font-size:18px;z-index:10;">✕</button>' +
|
||||
'<iframe src="' + url + '" style="width:100%;height:80vh;border:none;"></iframe>' +
|
||||
'</div></div>';
|
||||
}
|
||||
|
||||
function createCalendarWidget(config, url, apiUrl) {
|
||||
// Placeholder for inline calendar - would fetch availability and render mini-calendar
|
||||
return '<div class="bookra-calendar-widget" style="background:white;border-radius:12px;padding:20px;box-shadow:0 2px 10px rgba(0,0,0,0.1);">' +
|
||||
'<h3 style="margin:0 0 16px 0;font-size:18px;">Select a time</h3>' +
|
||||
'<p style="color:#666;margin:0 0 16px 0;">Loading availability...</p>' +
|
||||
'<a href="' + url + '" target="_blank" style="display:inline-block;background:' + config.color + ';color:white;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:500;">View all times</a>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function addWidgetStyles(primaryColor, isDark) {
|
||||
if (document.getElementById('bookra-widget-styles')) return;
|
||||
|
||||
const styles = document.createElement('style');
|
||||
styles.id = 'bookra-widget-styles';
|
||||
styles.textContent = '.bookra-widget-container { font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, sans-serif; }' +
|
||||
'.bookra-widget-container[data-theme="dark"] iframe { filter: invert(0.95) hue-rotate(180deg); }' +
|
||||
'.bookra-floating-btn:hover { transform: scale(1.1); }' +
|
||||
'@keyframes bookra-pulse { 0%, 100% { box-shadow: 0 4px 12px rgba(0,0,0,0.15); } 50% { box-shadow: 0 4px 20px rgba(0,0,0,0.25); } }' +
|
||||
'.bookra-floating-btn { animation: bookra-pulse 2s infinite; }';
|
||||
document.head.appendChild(styles);
|
||||
}
|
||||
|
||||
// Log initialization
|
||||
console.log('[Bookra Widget] v' + WIDGET_VERSION + ' initialized for ' + scripts.length + ' widget(s)');
|
||||
})();`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"bookra/apps/backend/internal/config"
|
||||
)
|
||||
|
||||
func TestDispatchReminderJobsRequiresJobRunnerKey(t *testing.T) {
|
||||
server, err := NewServer(config.Config{
|
||||
Environment: "development",
|
||||
FrontendURL: "http://localhost:3000",
|
||||
APIURL: "http://localhost:8080",
|
||||
DemoMode: true,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new server: %v", err)
|
||||
}
|
||||
defer server.Close()
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/internal/jobs/reminders/dispatch", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.Handler().ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchReminderJobsAcceptsConfiguredJobRunnerKey(t *testing.T) {
|
||||
server, err := NewServer(config.Config{
|
||||
Environment: "development",
|
||||
FrontendURL: "http://localhost:3000",
|
||||
APIURL: "http://localhost:8080",
|
||||
JobRunnerKey: "job-secret",
|
||||
DemoMode: true,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new server: %v", err)
|
||||
}
|
||||
defer server.Close()
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/internal/jobs/reminders/dispatch", nil)
|
||||
request.Header.Set("X-Bookra-Job-Key", "job-secret")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.Handler().ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,23 @@ import (
|
||||
|
||||
const principalContextKey = "principal"
|
||||
|
||||
func RequireAuth(verifier *Verifier, repo db.Repository) gin.HandlerFunc {
|
||||
// DemoPrincipal is the auto-authenticated user in demo mode
|
||||
var DemoPrincipal = domain.Principal{
|
||||
Subject: "demo-owner",
|
||||
Email: "demo@bookra.dev",
|
||||
Name: "Demo User",
|
||||
Role: "owner",
|
||||
}
|
||||
|
||||
func RequireAuth(verifier *Verifier, repo db.Repository, demoMode bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// In demo mode, auto-authenticate as the demo user
|
||||
if demoMode {
|
||||
c.Set(principalContextKey, DemoPrincipal)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if verifier == nil || !verifier.Enabled() {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "auth_not_configured"})
|
||||
return
|
||||
|
||||
@@ -16,13 +16,18 @@ type Verifier struct {
|
||||
jwks keyfunc.Keyfunc
|
||||
expectedIssuer string
|
||||
enabled bool
|
||||
localSecret []byte
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func NewVerifier(neonAuthURL string) (*Verifier, error) {
|
||||
trimmed := strings.TrimSpace(neonAuthURL)
|
||||
func NewVerifier(neonAuthURL string, localJWTSecret string) (*Verifier, error) {
|
||||
trimmed := strings.TrimRight(strings.TrimSpace(neonAuthURL), "/")
|
||||
if trimmed == "" {
|
||||
return &Verifier{enabled: false}, nil
|
||||
secret := strings.TrimSpace(localJWTSecret)
|
||||
return &Verifier{
|
||||
enabled: secret != "",
|
||||
localSecret: []byte(secret),
|
||||
}, nil
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(trimmed)
|
||||
@@ -45,6 +50,7 @@ func NewVerifier(neonAuthURL string) (*Verifier, error) {
|
||||
jwks: jwks,
|
||||
expectedIssuer: expectedIssuer,
|
||||
enabled: true,
|
||||
localSecret: []byte(strings.TrimSpace(localJWTSecret)),
|
||||
cancel: cancel,
|
||||
}, nil
|
||||
}
|
||||
@@ -64,6 +70,26 @@ func (v *Verifier) Verify(tokenString string) (jwt.MapClaims, error) {
|
||||
return nil, errors.New("neon auth verifier is disabled")
|
||||
}
|
||||
|
||||
if len(v.localSecret) > 0 && v.jwks == nil {
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return v.localSecret, nil
|
||||
}, jwt.WithIssuer("bookra-auth"), jwt.WithAudience("bookra"), jwt.WithLeeway(15*time.Second))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, errors.New("invalid token claims")
|
||||
}
|
||||
if tokenType, _ := claims["type"].(string); tokenType != "access" {
|
||||
return nil, errors.New("invalid token type")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
token, err := jwt.Parse(tokenString, v.jwks.Keyfunc,
|
||||
jwt.WithIssuer(v.expectedIssuer),
|
||||
jwt.WithValidMethods([]string{"EdDSA"}),
|
||||
|
||||
@@ -4,48 +4,82 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/config"
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
|
||||
paddle "github.com/PaddleHQ/paddle-go-sdk/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/stripe/stripe-go/v83"
|
||||
"github.com/stripe/stripe-go/v83/checkout/session"
|
||||
"github.com/stripe/stripe-go/v83/customer"
|
||||
"github.com/stripe/stripe-go/v83/subscription"
|
||||
"github.com/stripe/stripe-go/v83/webhook"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrBillingMembership = errors.New("billing membership not found")
|
||||
ErrBillingPlanUnsupported = errors.New("billing plan is not configured")
|
||||
ErrStripeSignatureMissing = errors.New("stripe signature is missing")
|
||||
ErrBillingCustomerMissing = errors.New("billing customer is not available")
|
||||
ErrPaddleNotConfigured = errors.New("paddle is not configured")
|
||||
ErrPaddleSignatureMissing = errors.New("paddle signature is missing")
|
||||
ErrPaddleWebhookMissing = errors.New("paddle webhook secret is not configured")
|
||||
)
|
||||
|
||||
var allowedWebhookEvents = []string{
|
||||
"checkout.session.completed",
|
||||
"customer.subscription.created",
|
||||
"customer.subscription.updated",
|
||||
"customer.subscription.deleted",
|
||||
"customer.subscription.paused",
|
||||
"customer.subscription.resumed",
|
||||
"invoice.paid",
|
||||
"invoice.payment_failed",
|
||||
"payment_intent.succeeded",
|
||||
"payment_intent.payment_failed",
|
||||
"subscription.created",
|
||||
"subscription.updated",
|
||||
"subscription.activated",
|
||||
"subscription.canceled",
|
||||
"subscription.paused",
|
||||
"subscription.resumed",
|
||||
"subscription.trialing",
|
||||
"transaction.completed",
|
||||
"transaction.updated",
|
||||
"transaction.payment_failed",
|
||||
"transaction.past_due",
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
cfg config.Config
|
||||
repo db.Repository
|
||||
cfg config.Config
|
||||
repo db.Repository
|
||||
client *paddle.SDK
|
||||
verifier *paddle.WebhookVerifier
|
||||
}
|
||||
|
||||
type webhookEnvelope struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
Data struct {
|
||||
ID string `json:"id"`
|
||||
CustomerID string `json:"customer_id"`
|
||||
SubscriptionID string `json:"subscription_id"`
|
||||
CustomData map[string]any `json:"custom_data"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func NewService(cfg config.Config, repo db.Repository) *Service {
|
||||
return &Service{cfg: cfg, repo: repo}
|
||||
service := &Service{cfg: cfg, repo: repo}
|
||||
|
||||
if strings.TrimSpace(cfg.PaddleAPIKey) != "" {
|
||||
var client *paddle.SDK
|
||||
var err error
|
||||
if cfg.PaddleEnvironment == "live" {
|
||||
client, err = paddle.New(cfg.PaddleAPIKey, paddle.WithBaseURL(paddle.ProductionBaseURL))
|
||||
} else {
|
||||
client, err = paddle.NewSandbox(cfg.PaddleAPIKey)
|
||||
}
|
||||
if err == nil {
|
||||
service.client = client
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(cfg.PaddleWebhookKey) != "" {
|
||||
service.verifier = paddle.NewWebhookVerifier(cfg.PaddleWebhookKey, paddle.VerifierWithTimestampTolerance(5*time.Minute))
|
||||
}
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *Service) GetSubscription(ctx context.Context, principal domain.Principal) (domain.SubscriptionSnapshot, error) {
|
||||
@@ -56,80 +90,57 @@ func (s *Service) GetSubscription(ctx context.Context, principal domain.Principa
|
||||
}
|
||||
return domain.SubscriptionSnapshot{}, err
|
||||
}
|
||||
|
||||
record, err := s.repo.GetSubscriptionSnapshot(ctx, membership.Tenant.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{
|
||||
TenantID: membership.Tenant.ID,
|
||||
StripeCustomerID: derefString(membership.Tenant.StripeCustomerID),
|
||||
Status: membership.Tenant.SubscriptionStatus,
|
||||
PlanCode: membership.Tenant.PlanCode,
|
||||
BillingProvider: "paddle",
|
||||
Status: firstNonEmpty(membership.Tenant.SubscriptionStatus, "inactive"),
|
||||
PlanCode: normalizePlanCode(membership.Tenant.PlanCode),
|
||||
Currency: "czk",
|
||||
}, s.cfg), nil
|
||||
}
|
||||
return domain.SubscriptionSnapshot{}, err
|
||||
}
|
||||
|
||||
return toSnapshot(membership.Tenant, record, s.cfg), nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateCheckoutSession(ctx context.Context, principal domain.Principal, planCode string) (domain.CheckoutSessionResponse, error) {
|
||||
func (s *Service) CreateCheckoutSession(ctx context.Context, principal domain.Principal, planCode string, currency string) (domain.CheckoutLaunchResponse, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.CheckoutSessionResponse{}, ErrBillingMembership
|
||||
return domain.CheckoutLaunchResponse{}, ErrBillingMembership
|
||||
}
|
||||
return domain.CheckoutSessionResponse{}, err
|
||||
return domain.CheckoutLaunchResponse{}, err
|
||||
}
|
||||
|
||||
priceID := s.cfg.StripePriceIDs[planCode]
|
||||
priceID, resolvedPlanCode, resolvedCurrency := s.priceForPlan(planCode, currency)
|
||||
if priceID == "" {
|
||||
return domain.CheckoutSessionResponse{}, ErrBillingPlanUnsupported
|
||||
return domain.CheckoutLaunchResponse{}, ErrBillingPlanUnsupported
|
||||
}
|
||||
if !checkoutAvailable(s.cfg, resolvedPlanCode) {
|
||||
return domain.CheckoutLaunchResponse{}, ErrPaddleNotConfigured
|
||||
}
|
||||
|
||||
if s.cfg.StripeSecretKey == "" {
|
||||
mockURL := fmt.Sprintf("%s/dashboard?billing=mock-checkout&plan=%s", s.cfg.FrontendURL, planCode)
|
||||
return domain.CheckoutSessionResponse{URL: mockURL}, nil
|
||||
}
|
||||
|
||||
stripe.Key = s.cfg.StripeSecretKey
|
||||
customerID := derefString(membership.Tenant.StripeCustomerID)
|
||||
if customerID == "" {
|
||||
params := &stripe.CustomerParams{
|
||||
Name: stripe.String(membership.Tenant.Name),
|
||||
Email: stripe.String(principal.Email),
|
||||
Metadata: map[string]string{"tenant_id": membership.Tenant.ID, "tenant_slug": membership.Tenant.Slug},
|
||||
}
|
||||
createdCustomer, err := customer.New(params)
|
||||
if err != nil {
|
||||
return domain.CheckoutSessionResponse{}, err
|
||||
}
|
||||
customerID = createdCustomer.ID
|
||||
if err := s.repo.UpdateTenantStripeCustomerID(ctx, membership.Tenant.ID, customerID); err != nil {
|
||||
return domain.CheckoutSessionResponse{}, err
|
||||
}
|
||||
}
|
||||
|
||||
params := &stripe.CheckoutSessionParams{
|
||||
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
||||
Customer: stripe.String(customerID),
|
||||
SuccessURL: stripe.String(fmt.Sprintf("%s/dashboard?billing=success", s.cfg.FrontendURL)),
|
||||
CancelURL: stripe.String(fmt.Sprintf("%s/dashboard?billing=cancelled", s.cfg.FrontendURL)),
|
||||
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||
{
|
||||
Price: stripe.String(priceID),
|
||||
Quantity: stripe.Int64(1),
|
||||
},
|
||||
return domain.CheckoutLaunchResponse{
|
||||
PriceID: priceID,
|
||||
CustomerID: derefString(membership.Tenant.BillingCustomerID),
|
||||
CustomerEmail: strings.TrimSpace(principal.Email),
|
||||
SuccessRedirectURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard?billing=success",
|
||||
CancelRedirectURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard?billing=cancelled",
|
||||
CustomData: map[string]string{
|
||||
"tenantId": membership.Tenant.ID,
|
||||
"tenantSlug": membership.Tenant.Slug,
|
||||
"userId": principal.Subject,
|
||||
"userEmail": strings.TrimSpace(principal.Email),
|
||||
"planCode": resolvedPlanCode,
|
||||
"currency": resolvedCurrency,
|
||||
"source": "bookra-dashboard",
|
||||
},
|
||||
Metadata: map[string]string{
|
||||
"tenant_id": membership.Tenant.ID,
|
||||
"plan_code": planCode,
|
||||
},
|
||||
}
|
||||
|
||||
checkoutSession, err := session.New(params)
|
||||
if err != nil {
|
||||
return domain.CheckoutSessionResponse{}, err
|
||||
}
|
||||
return domain.CheckoutSessionResponse{URL: checkoutSession.URL}, nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Refresh(ctx context.Context, principal domain.Principal) (domain.SubscriptionSnapshot, error) {
|
||||
@@ -140,139 +151,226 @@ func (s *Service) Refresh(ctx context.Context, principal domain.Principal) (doma
|
||||
}
|
||||
return domain.SubscriptionSnapshot{}, err
|
||||
}
|
||||
customerID := derefString(membership.Tenant.StripeCustomerID)
|
||||
|
||||
customerID := derefString(membership.Tenant.BillingCustomerID)
|
||||
if customerID == "" {
|
||||
return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{
|
||||
TenantID: membership.Tenant.ID,
|
||||
StripeCustomerID: "",
|
||||
Status: "none",
|
||||
PlanCode: membership.Tenant.PlanCode,
|
||||
BillingProvider: "paddle",
|
||||
Status: "inactive",
|
||||
PlanCode: normalizePlanCode(membership.Tenant.PlanCode),
|
||||
Currency: "czk",
|
||||
}, s.cfg), nil
|
||||
}
|
||||
record, err := s.syncStripeData(ctx, membership.Tenant, customerID)
|
||||
if s.client == nil {
|
||||
return domain.SubscriptionSnapshot{}, ErrPaddleNotConfigured
|
||||
}
|
||||
|
||||
record, err := s.syncPaddleData(ctx, membership.Tenant, customerID)
|
||||
if err != nil {
|
||||
return domain.SubscriptionSnapshot{}, err
|
||||
}
|
||||
|
||||
return toSnapshot(membership.Tenant, record, s.cfg), nil
|
||||
}
|
||||
|
||||
func (s *Service) HandleWebhook(ctx context.Context, signature string, payload []byte) error {
|
||||
if s.cfg.StripeSecretKey == "" {
|
||||
return nil
|
||||
func (s *Service) CreatePortalSession(ctx context.Context, principal domain.Principal) (domain.PortalSessionResponse, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.PortalSessionResponse{}, ErrBillingMembership
|
||||
}
|
||||
return domain.PortalSessionResponse{}, err
|
||||
}
|
||||
if signature == "" {
|
||||
return ErrStripeSignatureMissing
|
||||
if s.client == nil {
|
||||
return domain.PortalSessionResponse{}, ErrPaddleNotConfigured
|
||||
}
|
||||
|
||||
event, err := webhook.ConstructEvent(payload, signature, s.cfg.StripeWebhookKey)
|
||||
customerID := derefString(membership.Tenant.BillingCustomerID)
|
||||
if customerID == "" {
|
||||
return domain.PortalSessionResponse{}, ErrBillingCustomerMissing
|
||||
}
|
||||
|
||||
request := &paddle.CreateCustomerPortalSessionRequest{CustomerID: customerID}
|
||||
if subscriptionID := derefString(membership.Tenant.BillingSubscription); subscriptionID != "" {
|
||||
request.SubscriptionIDs = []string{subscriptionID}
|
||||
}
|
||||
|
||||
session, 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 = firstNonEmpty(
|
||||
session.URLs.Subscriptions[0].UpdateSubscriptionPaymentMethod,
|
||||
session.URLs.Subscriptions[0].CancelSubscription,
|
||||
)
|
||||
}
|
||||
if url == "" {
|
||||
return domain.PortalSessionResponse{}, ErrBillingCustomerMissing
|
||||
}
|
||||
|
||||
return domain.PortalSessionResponse{URL: url}, nil
|
||||
}
|
||||
|
||||
func (s *Service) HandleWebhook(ctx context.Context, req *http.Request) error {
|
||||
if s.verifier == nil {
|
||||
return ErrPaddleWebhookMissing
|
||||
}
|
||||
if strings.TrimSpace(req.Header.Get("Paddle-Signature")) == "" {
|
||||
return ErrPaddleSignatureMissing
|
||||
}
|
||||
|
||||
ok, err := s.verifier.Verify(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !slices.Contains(allowedWebhookEvents, string(event.Type)) {
|
||||
if !ok {
|
||||
return errors.New("invalid paddle webhook signature")
|
||||
}
|
||||
|
||||
payload, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var event webhookEnvelope
|
||||
if err := json.Unmarshal(payload, &event); err != nil {
|
||||
return err
|
||||
}
|
||||
if !slices.Contains(allowedWebhookEvents, event.EventType) {
|
||||
return nil
|
||||
}
|
||||
|
||||
customerID := extractCustomerID(event)
|
||||
if customerID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
tenant, err := s.repo.GetTenantByStripeCustomerID(ctx, customerID)
|
||||
tenant, customerID, err := s.resolveWebhookTenant(ctx, event)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if tenant.ID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
inserted, err := s.repo.RecordStripeEvent(ctx, tenant.ID, event.ID, string(event.Type), payload)
|
||||
inserted, err := s.repo.RecordBillingEvent(ctx, tenant.ID, "paddle", event.EventID, event.EventType, payload)
|
||||
if err != nil || !inserted {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s.syncStripeData(ctx, tenant, customerID)
|
||||
if customerID != "" && derefString(tenant.BillingCustomerID) == "" {
|
||||
if err := s.repo.UpdateTenantBillingCustomerID(ctx, tenant.ID, customerID); err != nil {
|
||||
return err
|
||||
}
|
||||
tenant.BillingCustomerID = &customerID
|
||||
}
|
||||
|
||||
customerID = firstNonEmpty(customerID, derefString(tenant.BillingCustomerID))
|
||||
if customerID == "" || s.client == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = s.syncPaddleData(ctx, tenant, customerID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) syncStripeData(ctx context.Context, tenant db.TenantRecord, customerID string) (db.BillingSnapshotRecord, error) {
|
||||
if s.cfg.StripeSecretKey == "" {
|
||||
now := time.Now().UTC()
|
||||
record := db.BillingSnapshotRecord{
|
||||
TenantID: tenant.ID,
|
||||
StripeCustomerID: customerID,
|
||||
StripeSubscriptionID: "",
|
||||
Status: tenant.SubscriptionStatus,
|
||||
PlanCode: tenant.PlanCode,
|
||||
PriceID: s.cfg.StripePriceIDs[tenant.PlanCode],
|
||||
LastSyncedAt: &now,
|
||||
}
|
||||
if err := s.repo.UpsertSubscriptionSnapshot(ctx, record); err != nil {
|
||||
return db.BillingSnapshotRecord{}, err
|
||||
}
|
||||
return record, nil
|
||||
func (s *Service) resolveWebhookTenant(ctx context.Context, event webhookEnvelope) (db.TenantRecord, string, error) {
|
||||
customerID := strings.TrimSpace(event.Data.CustomerID)
|
||||
if tenantID := customDataString(event.Data.CustomData, "tenantId"); tenantID != "" {
|
||||
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
|
||||
return tenant, customerID, err
|
||||
}
|
||||
if customerID != "" {
|
||||
tenant, err := s.repo.GetTenantByBillingCustomerID(ctx, customerID)
|
||||
return tenant, customerID, err
|
||||
}
|
||||
return db.TenantRecord{}, customerID, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (s *Service) syncPaddleData(ctx context.Context, tenant db.TenantRecord, customerID string) (db.BillingSnapshotRecord, error) {
|
||||
if s.client == nil {
|
||||
return db.BillingSnapshotRecord{}, ErrPaddleNotConfigured
|
||||
}
|
||||
|
||||
stripe.Key = s.cfg.StripeSecretKey
|
||||
params := &stripe.SubscriptionListParams{Customer: stripe.String(customerID)}
|
||||
params.Status = stripe.String("all")
|
||||
params.AddExpand("data.default_payment_method")
|
||||
params.AddExpand("data.items.data.price")
|
||||
subscriptions, err := s.client.ListSubscriptions(ctx, &paddle.ListSubscriptionsRequest{
|
||||
CustomerID: []string{customerID},
|
||||
})
|
||||
if err != nil {
|
||||
return db.BillingSnapshotRecord{}, err
|
||||
}
|
||||
|
||||
iter := subscription.List(params)
|
||||
if iter.Err() != nil {
|
||||
return db.BillingSnapshotRecord{}, iter.Err()
|
||||
var selected *paddle.Subscription
|
||||
if err := subscriptions.Iter(ctx, func(subscription *paddle.Subscription) (bool, error) {
|
||||
if subscription == nil {
|
||||
return true, nil
|
||||
}
|
||||
if selected == nil || subscriptionRank(subscription) > subscriptionRank(selected) {
|
||||
selected = subscription
|
||||
}
|
||||
return true, nil
|
||||
}); err != nil {
|
||||
return db.BillingSnapshotRecord{}, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
record := db.BillingSnapshotRecord{
|
||||
TenantID: tenant.ID,
|
||||
StripeCustomerID: customerID,
|
||||
StripeSubscriptionID: "",
|
||||
Status: "none",
|
||||
PlanCode: tenant.PlanCode,
|
||||
PriceID: "",
|
||||
LastSyncedAt: &now,
|
||||
TenantID: tenant.ID,
|
||||
BillingProvider: "paddle",
|
||||
BillingCustomerID: customerID,
|
||||
BillingSubscriptionID: "",
|
||||
Status: "inactive",
|
||||
PlanCode: normalizePlanCode(tenant.PlanCode),
|
||||
Currency: "czk",
|
||||
PriceID: "",
|
||||
LastSyncedAt: &now,
|
||||
}
|
||||
|
||||
if iter.Next() {
|
||||
subscriptionRecord := iter.Subscription()
|
||||
record.StripeSubscriptionID = subscriptionRecord.ID
|
||||
record.Status = string(subscriptionRecord.Status)
|
||||
record.CancelAtPeriodEnd = subscriptionRecord.CancelAtPeriodEnd
|
||||
if len(subscriptionRecord.Items.Data) > 0 {
|
||||
record.PriceID = subscriptionRecord.Items.Data[0].Price.ID
|
||||
if selected != nil {
|
||||
record.BillingSubscriptionID = selected.ID
|
||||
record.Status = normalizeSubscriptionStatus(string(selected.Status))
|
||||
record.Currency = normalizeCurrency(string(selected.CurrencyCode))
|
||||
record.CancelAtPeriodEnd = selected.ScheduledChange != nil && string(selected.ScheduledChange.Action) == "cancel"
|
||||
record.CurrentPeriodStart = parseRFC3339Ptr(timePeriodStart(selected.CurrentBillingPeriod))
|
||||
record.CurrentPeriodEnd = parseRFC3339Ptr(timePeriodEnd(selected.CurrentBillingPeriod))
|
||||
if len(selected.Items) > 0 {
|
||||
record.PriceID = selected.Items[0].Price.ID
|
||||
record.PlanCode = s.planCodeForPrice(record.PriceID, tenant.PlanCode)
|
||||
record.CurrentPeriodStart = toTimePtr(subscriptionRecord.Items.Data[0].CurrentPeriodStart)
|
||||
record.CurrentPeriodEnd = toTimePtr(subscriptionRecord.Items.Data[0].CurrentPeriodEnd)
|
||||
}
|
||||
if subscriptionRecord.DefaultPaymentMethod != nil && subscriptionRecord.DefaultPaymentMethod.Card != nil {
|
||||
record.PaymentMethodBrand = string(subscriptionRecord.DefaultPaymentMethod.Card.Brand)
|
||||
record.PaymentMethodLast4 = subscriptionRecord.DefaultPaymentMethod.Card.Last4
|
||||
}
|
||||
}
|
||||
|
||||
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.StripeSubscriptionID); err != nil {
|
||||
if err := s.repo.UpdateTenantBillingState(ctx, tenant.ID, record.PlanCode, record.Status, record.BillingSubscriptionID); err != nil {
|
||||
return db.BillingSnapshotRecord{}, err
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func toSnapshot(tenant db.TenantRecord, record db.BillingSnapshotRecord, cfg config.Config) domain.SubscriptionSnapshot {
|
||||
if record.PlanCode == "" {
|
||||
record.PlanCode = tenant.PlanCode
|
||||
record.PlanCode = normalizePlanCode(tenant.PlanCode)
|
||||
} else {
|
||||
record.PlanCode = normalizePlanCode(record.PlanCode)
|
||||
}
|
||||
record.Currency = normalizeCurrency(record.Currency)
|
||||
if record.Status == "" {
|
||||
record.Status = tenant.SubscriptionStatus
|
||||
record.Status = firstNonEmpty(tenant.SubscriptionStatus, "inactive")
|
||||
}
|
||||
|
||||
customerID := firstNonEmpty(record.BillingCustomerID, derefString(tenant.BillingCustomerID))
|
||||
|
||||
return domain.SubscriptionSnapshot{
|
||||
TenantID: tenant.ID,
|
||||
CustomerID: record.StripeCustomerID,
|
||||
SubscriptionID: record.StripeSubscriptionID,
|
||||
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,
|
||||
@@ -280,29 +378,175 @@ func toSnapshot(tenant db.TenantRecord, record db.BillingSnapshotRecord, cfg con
|
||||
PaymentMethodBrand: record.PaymentMethodBrand,
|
||||
PaymentMethodLast4: record.PaymentMethodLast4,
|
||||
Entitlements: entitlementsForPlan(record.PlanCode),
|
||||
DisplayPrices: displayPricesForPlan(record.PlanCode),
|
||||
TrialDays: 30,
|
||||
LastSyncedAt: record.LastSyncedAt,
|
||||
CheckoutURLAvailable: cfg.StripePriceIDs[record.PlanCode] != "",
|
||||
CheckoutURLAvailable: checkoutAvailable(cfg, record.PlanCode),
|
||||
SyncAvailable: cfg.PaddleConfigured(),
|
||||
PortalAvailable: cfg.PaddleConfigured() && customerID != "",
|
||||
}
|
||||
}
|
||||
|
||||
func entitlementsForPlan(planCode string) domain.PlanEntitlements {
|
||||
switch planCode {
|
||||
switch normalizePlanCode(planCode) {
|
||||
case "starter":
|
||||
return domain.PlanEntitlements{MaxLocations: 1, MaxStaff: 3, SMSAddonAvailable: false, AdvancedReporting: false}
|
||||
case "multi-location":
|
||||
return domain.PlanEntitlements{MaxLocations: 10, MaxStaff: 30, SMSAddonAvailable: true, AdvancedReporting: true}
|
||||
return domain.PlanEntitlements{MaxLocations: 1, MaxStaff: 3, EmailReminders: true, AdvancedReporting: false, WidgetEmbedding: true, UmamiTracking: false}
|
||||
case "business":
|
||||
return domain.PlanEntitlements{MaxLocations: 10, MaxStaff: 30, EmailReminders: true, AdvancedReporting: true, WidgetEmbedding: true, UmamiTracking: true}
|
||||
default:
|
||||
return domain.PlanEntitlements{MaxLocations: 3, MaxStaff: 10, SMSAddonAvailable: true, AdvancedReporting: true}
|
||||
return domain.PlanEntitlements{MaxLocations: 3, MaxStaff: 10, EmailReminders: true, AdvancedReporting: true, WidgetEmbedding: true, UmamiTracking: true}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) planCodeForPrice(priceID string, fallback string) string {
|
||||
for code, configuredPriceID := range s.cfg.StripePriceIDs {
|
||||
if configuredPriceID != "" && configuredPriceID == priceID {
|
||||
return code
|
||||
for planCode, currencies := range s.cfg.PaddlePriceMatrix {
|
||||
for _, configuredPriceID := range currencies {
|
||||
if configuredPriceID != "" && configuredPriceID == priceID {
|
||||
return normalizePlanCode(planCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
return normalizePlanCode(fallback)
|
||||
}
|
||||
|
||||
func (s *Service) priceForPlan(planCode string, currency string) (string, string, string) {
|
||||
resolvedPlan := normalizePlanCode(strings.TrimSpace(planCode))
|
||||
if resolvedPlan == "" {
|
||||
resolvedPlan = "pro"
|
||||
}
|
||||
resolvedCurrency := normalizeCurrency(currency)
|
||||
if priceID := s.cfg.PaddlePriceMatrix[resolvedPlan][resolvedCurrency]; priceID != "" {
|
||||
return priceID, resolvedPlan, resolvedCurrency
|
||||
}
|
||||
if resolvedCurrency != "czk" {
|
||||
if priceID := s.cfg.PaddlePriceMatrix[resolvedPlan]["czk"]; priceID != "" {
|
||||
return priceID, resolvedPlan, "czk"
|
||||
}
|
||||
}
|
||||
return s.cfg.PaddlePriceMatrix[resolvedPlan]["usd"], resolvedPlan, "usd"
|
||||
}
|
||||
|
||||
func subscriptionRank(subscription *paddle.Subscription) int {
|
||||
switch subscription.Status {
|
||||
case paddle.SubscriptionStatusActive:
|
||||
return 6
|
||||
case paddle.SubscriptionStatusTrialing:
|
||||
return 5
|
||||
case paddle.SubscriptionStatusPastDue:
|
||||
return 4
|
||||
case paddle.SubscriptionStatusPaused:
|
||||
return 3
|
||||
case paddle.SubscriptionStatusCanceled:
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func displayPricesForPlan(planCode string) []domain.PlanDisplayPrice {
|
||||
switch normalizePlanCode(planCode) {
|
||||
case "starter":
|
||||
return []domain.PlanDisplayPrice{
|
||||
{Currency: "czk", AmountCents: 11900, Formatted: "119 Kc"},
|
||||
{Currency: "usd", AmountCents: 500, Formatted: "$5"},
|
||||
}
|
||||
case "business":
|
||||
return []domain.PlanDisplayPrice{
|
||||
{Currency: "czk", AmountCents: 119900, Formatted: "1 199 Kc"},
|
||||
{Currency: "usd", AmountCents: 5000, Formatted: "$50"},
|
||||
}
|
||||
default:
|
||||
return []domain.PlanDisplayPrice{
|
||||
{Currency: "czk", AmountCents: 49900, Formatted: "499 Kc"},
|
||||
{Currency: "usd", AmountCents: 2000, Formatted: "$20"},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePlanCode(planCode string) string {
|
||||
switch strings.TrimSpace(planCode) {
|
||||
case "growth":
|
||||
return "pro"
|
||||
case "multi-location":
|
||||
return "business"
|
||||
default:
|
||||
return strings.TrimSpace(planCode)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCurrency(currency string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(currency)) {
|
||||
case "usd":
|
||||
return "usd"
|
||||
case "eur":
|
||||
return "eur"
|
||||
default:
|
||||
return "czk"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSubscriptionStatus(status string) string {
|
||||
switch strings.TrimSpace(strings.ToLower(status)) {
|
||||
case "active", "trialing", "past_due", "paused", "canceled":
|
||||
return strings.TrimSpace(strings.ToLower(status))
|
||||
default:
|
||||
return "inactive"
|
||||
}
|
||||
}
|
||||
|
||||
func checkoutAvailable(cfg config.Config, planCode string) bool {
|
||||
if !cfg.PaddleConfigured() || !cfg.PaddleWebhookConfigured() {
|
||||
return false
|
||||
}
|
||||
planCode = normalizePlanCode(planCode)
|
||||
for _, priceID := range cfg.PaddlePriceMatrix[planCode] {
|
||||
if strings.TrimSpace(priceID) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func customDataString(data map[string]any, key string) string {
|
||||
if data == nil {
|
||||
return ""
|
||||
}
|
||||
value, ok := data[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(typed)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func parseRFC3339Ptr(value string) *time.Time {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
utc := parsed.UTC()
|
||||
return &utc
|
||||
}
|
||||
|
||||
func timePeriodStart(period *paddle.TimePeriod) string {
|
||||
if period == nil {
|
||||
return ""
|
||||
}
|
||||
return period.StartsAt
|
||||
}
|
||||
|
||||
func timePeriodEnd(period *paddle.TimePeriod) string {
|
||||
if period == nil {
|
||||
return ""
|
||||
}
|
||||
return period.EndsAt
|
||||
}
|
||||
|
||||
func derefString(value *string) string {
|
||||
@@ -312,24 +556,11 @@ func derefString(value *string) string {
|
||||
return *value
|
||||
}
|
||||
|
||||
func toTimePtr(value int64) *time.Time {
|
||||
if value == 0 {
|
||||
return nil
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
timestamp := time.Unix(value, 0).UTC()
|
||||
return ×tamp
|
||||
}
|
||||
|
||||
func extractCustomerID(event stripe.Event) string {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(event.Data.Raw, &payload); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
value, ok := payload["customer"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
customerID, _ := value.(string)
|
||||
return customerID
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -9,13 +9,21 @@ import (
|
||||
"bookra/apps/backend/internal/domain"
|
||||
)
|
||||
|
||||
func TestGetSubscriptionFallsBackToSnapshotAndEntitlements(t *testing.T) {
|
||||
service := NewService(config.Config{
|
||||
FrontendURL: "http://localhost:3000",
|
||||
StripePriceIDs: map[string]string{
|
||||
"growth": "price_growth_123",
|
||||
func testConfig() config.Config {
|
||||
return config.Config{
|
||||
FrontendURL: "http://localhost:3000",
|
||||
PaddleAPIKey: "pdl_sdbx_apikey_123",
|
||||
PaddleWebhookKey: "pdl_ntf_123",
|
||||
PaddlePriceMatrix: map[string]map[string]string{
|
||||
"starter": {"czk": "pri_starter_czk", "usd": "pri_starter_usd"},
|
||||
"pro": {"czk": "pri_pro_czk", "usd": "pri_pro_usd"},
|
||||
"business": {"czk": "pri_business_czk", "usd": "pri_business_usd"},
|
||||
},
|
||||
}, db.NewMemoryRepository())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSubscriptionFallsBackToSnapshotAndEntitlements(t *testing.T) {
|
||||
service := NewService(testConfig(), db.NewMemoryRepository())
|
||||
|
||||
snapshot, err := service.GetSubscription(context.Background(), domain.Principal{
|
||||
Subject: "demo-owner",
|
||||
@@ -25,51 +33,117 @@ func TestGetSubscriptionFallsBackToSnapshotAndEntitlements(t *testing.T) {
|
||||
t.Fatalf("get subscription: %v", err)
|
||||
}
|
||||
|
||||
if snapshot.PlanCode != "growth" {
|
||||
t.Fatalf("expected growth, got %s", snapshot.PlanCode)
|
||||
if snapshot.PlanCode != "pro" {
|
||||
t.Fatalf("expected pro, got %s", snapshot.PlanCode)
|
||||
}
|
||||
if snapshot.Provider != "paddle" {
|
||||
t.Fatalf("expected paddle provider, got %s", snapshot.Provider)
|
||||
}
|
||||
if snapshot.Entitlements.MaxLocations != 3 {
|
||||
t.Fatalf("expected 3 locations, got %d", snapshot.Entitlements.MaxLocations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCheckoutUsesMockURLWithoutStripeKey(t *testing.T) {
|
||||
service := NewService(config.Config{
|
||||
FrontendURL: "http://localhost:3000",
|
||||
StripePriceIDs: map[string]string{
|
||||
"growth": "price_growth_123",
|
||||
},
|
||||
}, db.NewMemoryRepository())
|
||||
func TestCreateCheckoutRequiresPaddleConfig(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.PaddleAPIKey = ""
|
||||
service := NewService(cfg, db.NewMemoryRepository())
|
||||
|
||||
response, err := service.CreateCheckoutSession(context.Background(), domain.Principal{
|
||||
Subject: "demo-owner",
|
||||
Email: "owner@bookra.dev",
|
||||
}, "growth")
|
||||
if err != nil {
|
||||
t.Fatalf("create checkout: %v", err)
|
||||
}
|
||||
if response.URL == "" {
|
||||
t.Fatal("expected checkout url")
|
||||
}, "pro", "czk")
|
||||
if err != ErrPaddleNotConfigured {
|
||||
t.Fatalf("expected ErrPaddleNotConfigured, got response=%v err=%v", response, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshReturnsSnapshotWithoutStripeKey(t *testing.T) {
|
||||
service := NewService(config.Config{
|
||||
FrontendURL: "http://localhost:3000",
|
||||
StripePriceIDs: map[string]string{
|
||||
"growth": "price_growth_123",
|
||||
},
|
||||
}, db.NewMemoryRepository())
|
||||
func TestCreateCheckoutReturnsLaunchPayload(t *testing.T) {
|
||||
service := NewService(testConfig(), db.NewMemoryRepository())
|
||||
|
||||
response, err := service.CreateCheckoutSession(context.Background(), domain.Principal{
|
||||
Subject: "demo-owner",
|
||||
Email: "owner@bookra.dev",
|
||||
}, "pro", "czk")
|
||||
if err != nil {
|
||||
t.Fatalf("create checkout: %v", err)
|
||||
}
|
||||
if response.PriceID != "pri_pro_czk" {
|
||||
t.Fatalf("expected pri_pro_czk, got %s", response.PriceID)
|
||||
}
|
||||
if response.CustomData["tenantId"] == "" {
|
||||
t.Fatal("expected tenantId in customData")
|
||||
}
|
||||
if response.SuccessRedirectURL == "" || response.CancelRedirectURL == "" {
|
||||
t.Fatal("expected redirect URLs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRequiresPaddleKeyWhenCustomerExists(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.PaddleAPIKey = ""
|
||||
service := NewService(cfg, db.NewMemoryRepository())
|
||||
|
||||
snapshot, err := service.Refresh(context.Background(), domain.Principal{
|
||||
Subject: "demo-owner",
|
||||
Email: "owner@bookra.dev",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("refresh: %v", err)
|
||||
}
|
||||
|
||||
if snapshot.Status != "active" {
|
||||
t.Fatalf("expected active status, got %s", snapshot.Status)
|
||||
if err != ErrPaddleNotConfigured {
|
||||
t.Fatalf("expected ErrPaddleNotConfigured, got snapshot=%v err=%v", snapshot, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSubscriptionDisablesCheckoutWhenWebhookMissing(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.PaddleWebhookKey = ""
|
||||
service := NewService(cfg, db.NewMemoryRepository())
|
||||
|
||||
snapshot, err := service.GetSubscription(context.Background(), domain.Principal{
|
||||
Subject: "demo-owner",
|
||||
Email: "owner@bookra.dev",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get subscription: %v", err)
|
||||
}
|
||||
if snapshot.CheckoutURLAvailable {
|
||||
t.Fatal("expected checkout unavailable without webhook secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSubscriptionEnablesCheckoutWhenPaddleConfigured(t *testing.T) {
|
||||
service := NewService(testConfig(), db.NewMemoryRepository())
|
||||
|
||||
snapshot, err := service.GetSubscription(context.Background(), domain.Principal{
|
||||
Subject: "demo-owner",
|
||||
Email: "owner@bookra.dev",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get subscription: %v", err)
|
||||
}
|
||||
if !snapshot.CheckoutURLAvailable {
|
||||
t.Fatal("expected checkout available when paddle is configured")
|
||||
}
|
||||
if !snapshot.PortalAvailable {
|
||||
t.Fatal("expected portal available when customer exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePortalSessionRequiresCustomer(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
membership, err := repo.GetTenantMembershipByUserID(context.Background(), "demo-owner")
|
||||
if err != nil {
|
||||
t.Fatalf("get membership: %v", err)
|
||||
}
|
||||
if err := repo.UpdateTenantBillingCustomerID(context.Background(), membership.Tenant.ID, ""); err != nil {
|
||||
t.Fatalf("clear billing customer: %v", err)
|
||||
}
|
||||
service := NewService(testConfig(), repo)
|
||||
|
||||
_, err = service.CreatePortalSession(context.Background(), domain.Principal{
|
||||
Subject: "demo-owner",
|
||||
Email: "owner@bookra.dev",
|
||||
})
|
||||
if err != ErrBillingCustomerMissing {
|
||||
t.Fatalf("expected ErrBillingCustomerMissing, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package bookings
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
"bookra/apps/backend/internal/notifications"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidReschedule = errors.New("invalid reschedule request")
|
||||
ErrBookingCancelled = errors.New("booking already cancelled")
|
||||
ErrUnauthorized = errors.New("unauthorized access")
|
||||
)
|
||||
|
||||
type CustomerService struct {
|
||||
repo db.Repository
|
||||
notifier CustomerNotifier
|
||||
}
|
||||
|
||||
type CustomerNotifier interface {
|
||||
SendBookingReschedule(ctx context.Context, data notifications.BookingEmailData) error
|
||||
SendBookingCancellation(ctx context.Context, data notifications.BookingEmailData) error
|
||||
}
|
||||
|
||||
func NewCustomerService(repo db.Repository, notifier CustomerNotifier) *CustomerService {
|
||||
if notifier == nil {
|
||||
notifier = &customerNoopNotifier{}
|
||||
}
|
||||
return &CustomerService{repo: repo, notifier: notifier}
|
||||
}
|
||||
|
||||
type customerNoopNotifier struct{}
|
||||
|
||||
func (n *customerNoopNotifier) SendBookingReschedule(ctx context.Context, data notifications.BookingEmailData) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *customerNoopNotifier) SendBookingCancellation(ctx context.Context, data notifications.BookingEmailData) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBookingByReference returns booking details for customer management link
|
||||
func (s *CustomerService) GetBookingByReference(ctx context.Context, reference string, token string) (domain.CustomerBookingView, error) {
|
||||
booking, err := s.repo.GetBookingByReference(ctx, reference)
|
||||
if err != nil {
|
||||
return domain.CustomerBookingView{}, ErrBookingNotFound
|
||||
}
|
||||
|
||||
// Get tenant details for business name
|
||||
tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID)
|
||||
if err != nil {
|
||||
return domain.CustomerBookingView{}, err
|
||||
}
|
||||
|
||||
return domain.CustomerBookingView{
|
||||
Reference: booking.Reference,
|
||||
CustomerName: booking.CustomerName,
|
||||
CustomerEmail: booking.CustomerEmail,
|
||||
Service: "Service", // Would get from service ID in full implementation
|
||||
BusinessName: tenant.Name,
|
||||
StartsAt: booking.StartsAt,
|
||||
EndsAt: booking.EndsAt,
|
||||
Location: "Location", // Would get from location ID in full implementation
|
||||
Status: booking.Status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RescheduleBooking allows customers to reschedule their booking
|
||||
func (s *CustomerService) RescheduleBooking(ctx context.Context, reference string, req domain.RescheduleBookingRequest, token string) error {
|
||||
booking, err := s.repo.GetBookingByReference(ctx, reference)
|
||||
if err != nil {
|
||||
return ErrBookingNotFound
|
||||
}
|
||||
|
||||
if booking.Status == "cancelled" {
|
||||
return ErrBookingCancelled
|
||||
}
|
||||
|
||||
newStartsAt, err := time.Parse(time.RFC3339, req.NewStartsAt)
|
||||
if err != nil {
|
||||
return ErrInvalidReschedule
|
||||
}
|
||||
|
||||
newEndsAt, err := time.Parse(time.RFC3339, req.NewEndsAt)
|
||||
if err != nil {
|
||||
return ErrInvalidReschedule
|
||||
}
|
||||
|
||||
if !newEndsAt.After(newStartsAt) {
|
||||
return ErrInvalidReschedule
|
||||
}
|
||||
|
||||
if newStartsAt.Before(time.Now().UTC()) {
|
||||
return ErrInvalidReschedule
|
||||
}
|
||||
|
||||
if err := s.repo.RescheduleBooking(ctx, booking.ID, newStartsAt, newEndsAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Send reschedule email (async)
|
||||
go s.sendRescheduleEmail(booking, newStartsAt, newEndsAt)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelBooking allows customers to cancel their booking
|
||||
func (s *CustomerService) CancelBooking(ctx context.Context, reference string, token string) error {
|
||||
booking, err := s.repo.GetBookingByReference(ctx, reference)
|
||||
if err != nil {
|
||||
return ErrBookingNotFound
|
||||
}
|
||||
|
||||
if booking.Status == "cancelled" {
|
||||
return ErrBookingCancelled
|
||||
}
|
||||
|
||||
if err := s.repo.UpdateBookingStatus(ctx, booking.ID, "cancelled"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Send cancellation email (async)
|
||||
go s.sendCancellationEmail(booking)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CustomerService) sendRescheduleEmail(booking db.BookingRecord, newStartsAt, newEndsAt time.Time) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
brand, _ := s.repo.GetBrandProfile(ctx, tenant.ID)
|
||||
brandColor := brand.PrimaryColor
|
||||
if brandColor == "" {
|
||||
brandColor = "#a65c3e"
|
||||
}
|
||||
|
||||
managementURL := "https://bookra.eu/manage/" + booking.Reference + "?token=" + booking.Reference
|
||||
|
||||
emailData := notifications.BookingEmailData{
|
||||
TenantName: tenant.Name,
|
||||
TenantSlug: tenant.Slug,
|
||||
BrandColor: brandColor,
|
||||
CustomerName: booking.CustomerName,
|
||||
CustomerEmail: booking.CustomerEmail,
|
||||
Service: "Service",
|
||||
Location: "Location",
|
||||
Reference: booking.Reference,
|
||||
StartsAt: newStartsAt,
|
||||
EndsAt: newEndsAt,
|
||||
Timezone: tenant.Timezone,
|
||||
Locale: tenant.Locale,
|
||||
ManagementURL: managementURL,
|
||||
}
|
||||
|
||||
s.notifier.SendBookingReschedule(ctx, emailData)
|
||||
}
|
||||
|
||||
func (s *CustomerService) sendCancellationEmail(booking db.BookingRecord) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
brand, _ := s.repo.GetBrandProfile(ctx, tenant.ID)
|
||||
brandColor := brand.PrimaryColor
|
||||
if brandColor == "" {
|
||||
brandColor = "#a65c3e"
|
||||
}
|
||||
|
||||
emailData := notifications.BookingEmailData{
|
||||
TenantName: tenant.Name,
|
||||
TenantSlug: tenant.Slug,
|
||||
BrandColor: brandColor,
|
||||
CustomerName: booking.CustomerName,
|
||||
CustomerEmail: booking.CustomerEmail,
|
||||
Service: "Service",
|
||||
Location: "Location",
|
||||
Reference: booking.Reference,
|
||||
StartsAt: booking.StartsAt,
|
||||
EndsAt: booking.EndsAt,
|
||||
Timezone: tenant.Timezone,
|
||||
Locale: tenant.Locale,
|
||||
ManagementURL: "",
|
||||
}
|
||||
|
||||
s.notifier.SendBookingCancellation(ctx, emailData)
|
||||
}
|
||||
@@ -4,11 +4,14 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
"bookra/apps/backend/internal/notifications"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
@@ -18,14 +21,34 @@ var (
|
||||
ErrInvalidBooking = errors.New("invalid booking request")
|
||||
ErrBookingConflict = errors.New("booking conflict")
|
||||
ErrTenantMembership = errors.New("tenant membership not found")
|
||||
ErrBookingNotFound = errors.New("booking not found")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo db.Repository
|
||||
repo db.Repository
|
||||
notifier Notifier
|
||||
}
|
||||
|
||||
func NewService(repo db.Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
type Notifier interface {
|
||||
SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error
|
||||
SendBusinessNotification(ctx context.Context, businessEmail string, data notifications.BookingEmailData) error
|
||||
}
|
||||
|
||||
func NewService(repo db.Repository, notifier Notifier) *Service {
|
||||
if notifier == nil {
|
||||
notifier = &noopNotifier{}
|
||||
}
|
||||
return &Service{repo: repo, notifier: notifier}
|
||||
}
|
||||
|
||||
type noopNotifier struct{}
|
||||
|
||||
func (n *noopNotifier) SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *noopNotifier) SendBusinessNotification(ctx context.Context, businessEmail string, data notifications.BookingEmailData) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Availability(ctx context.Context, tenantSlug string) (domain.PublicAvailability, error) {
|
||||
@@ -92,6 +115,22 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
||||
if !endsAt.After(startsAt) {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: endsAt must be after startsAt", ErrInvalidBooking)
|
||||
}
|
||||
if startsAt.Before(time.Now().UTC()) {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: startsAt must be in the future", ErrInvalidBooking)
|
||||
}
|
||||
|
||||
customerName := strings.TrimSpace(request.CustomerName)
|
||||
customerEmail := strings.TrimSpace(request.CustomerEmail)
|
||||
notes := strings.TrimSpace(request.Notes)
|
||||
if len(customerName) < 2 || len(customerName) > 120 {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: customerName must be between 2 and 120 characters", ErrInvalidBooking)
|
||||
}
|
||||
if _, err := mail.ParseAddress(customerEmail); err != nil {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: customerEmail must be valid", ErrInvalidBooking)
|
||||
}
|
||||
if len(notes) > 1000 {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: notes must be at most 1000 characters", ErrInvalidBooking)
|
||||
}
|
||||
|
||||
existing, err := s.repo.ListBookingsByTenantBetween(ctx, tenant.ID, startsAt, endsAt)
|
||||
if err != nil {
|
||||
@@ -99,23 +138,22 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
||||
}
|
||||
|
||||
status := "confirmed"
|
||||
if request.BookingMode == "class" && request.ClassSessionID != nil {
|
||||
classBookings := countClassBookings(existing, *request.ClassSessionID)
|
||||
classSessions, err := s.repo.ListClassSessionsByTenant(ctx, tenant.ID, startsAt.Add(-1*time.Minute), 16)
|
||||
switch request.BookingMode {
|
||||
case "appointment":
|
||||
if request.ServiceID == nil || request.ClassSessionID != nil {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: appointment bookings require serviceId only", ErrInvalidBooking)
|
||||
}
|
||||
service, ok, err := s.serviceForRequest(ctx, tenant.ID, *request.ServiceID)
|
||||
if err != nil {
|
||||
return domain.CreateBookingResponse{}, err
|
||||
}
|
||||
sessionCapacity := int32(0)
|
||||
for _, session := range classSessions {
|
||||
if session.ID == *request.ClassSessionID {
|
||||
sessionCapacity = session.Capacity
|
||||
break
|
||||
}
|
||||
if !ok {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: serviceId is not available for tenant", ErrInvalidBooking)
|
||||
}
|
||||
if sessionCapacity > 0 && classBookings >= sessionCapacity {
|
||||
status = "waitlisted"
|
||||
expectedDuration := time.Duration(service.DurationMinutes) * time.Minute
|
||||
if !startsAt.Add(expectedDuration).Equal(endsAt) {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: appointment duration must match service duration", ErrInvalidBooking)
|
||||
}
|
||||
} else {
|
||||
for _, booking := range existing {
|
||||
if booking.Status == "cancelled" {
|
||||
continue
|
||||
@@ -124,6 +162,26 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
||||
return domain.CreateBookingResponse{}, ErrBookingConflict
|
||||
}
|
||||
}
|
||||
case "class":
|
||||
if request.ClassSessionID == nil || request.ServiceID != nil {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: class bookings require classSessionId only", ErrInvalidBooking)
|
||||
}
|
||||
session, ok, err := s.classSessionForRequest(ctx, tenant.ID, *request.ClassSessionID, startsAt)
|
||||
if err != nil {
|
||||
return domain.CreateBookingResponse{}, err
|
||||
}
|
||||
if !ok {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: classSessionId is not available for tenant", ErrInvalidBooking)
|
||||
}
|
||||
if !sameSecond(session.StartsAt, startsAt) || !sameSecond(session.EndsAt, endsAt) {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: class booking time must match class session", ErrInvalidBooking)
|
||||
}
|
||||
classBookings := countClassBookings(existing, *request.ClassSessionID)
|
||||
if session.Capacity > 0 && classBookings >= session.Capacity {
|
||||
status = "waitlisted"
|
||||
}
|
||||
default:
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: bookingMode must be appointment or class", ErrInvalidBooking)
|
||||
}
|
||||
|
||||
created, err := s.repo.CreateBooking(ctx, db.CreateBookingParams{
|
||||
@@ -133,13 +191,13 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
||||
StaffID: request.StaffID,
|
||||
LocationID: request.LocationID,
|
||||
BookingMode: request.BookingMode,
|
||||
CustomerName: request.CustomerName,
|
||||
CustomerEmail: request.CustomerEmail,
|
||||
CustomerName: customerName,
|
||||
CustomerEmail: customerEmail,
|
||||
StartsAt: startsAt.UTC(),
|
||||
EndsAt: endsAt.UTC(),
|
||||
Status: status,
|
||||
Reference: db.Reference("BK", time.Now()),
|
||||
Notes: request.Notes,
|
||||
Notes: notes,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.CreateBookingResponse{}, err
|
||||
@@ -150,8 +208,8 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
||||
if err := s.repo.AppendWaitlistEntry(ctx, db.WaitlistEntryParams{
|
||||
TenantID: tenant.ID,
|
||||
ClassSessionID: *request.ClassSessionID,
|
||||
CustomerName: request.CustomerName,
|
||||
CustomerEmail: request.CustomerEmail,
|
||||
CustomerName: customerName,
|
||||
CustomerEmail: customerEmail,
|
||||
Position: waitlistPosition,
|
||||
}); err != nil {
|
||||
return domain.CreateBookingResponse{}, err
|
||||
@@ -169,6 +227,9 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
||||
}
|
||||
}
|
||||
|
||||
// Send confirmation emails (async - don't fail booking if email fails)
|
||||
go s.sendBookingConfirmationEmails(tenant, created, customerName, customerEmail, startsAt, endsAt)
|
||||
|
||||
return domain.CreateBookingResponse{
|
||||
BookingID: created.ID,
|
||||
Reference: created.Reference,
|
||||
@@ -176,6 +237,65 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) sendBookingConfirmationEmails(tenant db.TenantRecord, booking db.CreatedBooking, customerName, customerEmail string, startsAt, endsAt time.Time) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Get brand profile for business details
|
||||
brand, _ := s.repo.GetBrandProfile(ctx, tenant.ID)
|
||||
brandColor := brand.PrimaryColor
|
||||
if brandColor == "" {
|
||||
brandColor = "#a65c3e" // Default terra color
|
||||
}
|
||||
|
||||
managementURL := fmt.Sprintf("https://bookra.eu/manage/%s?token=%s", booking.Reference, booking.Reference)
|
||||
|
||||
emailData := notifications.BookingEmailData{
|
||||
TenantName: tenant.Name,
|
||||
TenantSlug: tenant.Slug,
|
||||
BrandColor: brandColor,
|
||||
CustomerName: customerName,
|
||||
CustomerEmail: customerEmail,
|
||||
Service: "Service", // Would lookup from booking.ServiceID in full implementation
|
||||
Location: "Location", // Would lookup from booking.LocationID in full implementation
|
||||
Reference: booking.Reference,
|
||||
StartsAt: startsAt,
|
||||
EndsAt: endsAt,
|
||||
Timezone: tenant.Timezone,
|
||||
Locale: tenant.Locale,
|
||||
ManagementURL: managementURL,
|
||||
}
|
||||
|
||||
// Send to customer
|
||||
s.notifier.SendBookingConfirmation(ctx, emailData)
|
||||
}
|
||||
|
||||
func (s *Service) serviceForRequest(ctx context.Context, tenantID string, serviceID string) (db.ServiceRecord, bool, error) {
|
||||
services, err := s.repo.ListServicesByTenant(ctx, tenantID)
|
||||
if err != nil {
|
||||
return db.ServiceRecord{}, false, err
|
||||
}
|
||||
for _, service := range services {
|
||||
if service.ID == serviceID {
|
||||
return service, true, nil
|
||||
}
|
||||
}
|
||||
return db.ServiceRecord{}, false, nil
|
||||
}
|
||||
|
||||
func (s *Service) classSessionForRequest(ctx context.Context, tenantID string, classSessionID string, startsAt time.Time) (db.ClassSessionRecord, bool, error) {
|
||||
sessions, err := s.repo.ListClassSessionsByTenant(ctx, tenantID, startsAt.Add(-1*time.Minute), 64)
|
||||
if err != nil {
|
||||
return db.ClassSessionRecord{}, false, err
|
||||
}
|
||||
for _, session := range sessions {
|
||||
if session.ID == classSessionID {
|
||||
return session, true, nil
|
||||
}
|
||||
}
|
||||
return db.ClassSessionRecord{}, false, nil
|
||||
}
|
||||
|
||||
func (s *Service) DashboardSummary(ctx context.Context, principal domain.Principal) (domain.DashboardSummary, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
@@ -191,20 +311,72 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
|
||||
if err != nil {
|
||||
return domain.DashboardSummary{}, err
|
||||
}
|
||||
upcomingRecords, err := s.repo.ListBookingsByTenantBetween(ctx, membership.Tenant.ID, now, weekEnd)
|
||||
if err != nil {
|
||||
return domain.DashboardSummary{}, err
|
||||
}
|
||||
upcoming := make([]domain.UpcomingBooking, 0, len(upcomingRecords))
|
||||
for _, booking := range upcomingRecords {
|
||||
upcoming = append(upcoming, domain.UpcomingBooking{
|
||||
Reference: booking.Reference,
|
||||
CustomerName: booking.CustomerName,
|
||||
CustomerEmail: booking.CustomerEmail,
|
||||
StartsAt: booking.StartsAt,
|
||||
EndsAt: booking.EndsAt,
|
||||
Status: booking.Status,
|
||||
})
|
||||
}
|
||||
if len(upcoming) > 5 {
|
||||
upcoming = upcoming[:5]
|
||||
}
|
||||
|
||||
return domain.DashboardSummary{
|
||||
TenantName: membership.Tenant.Name,
|
||||
Locale: membership.Tenant.Locale,
|
||||
Timezone: membership.Tenant.Timezone,
|
||||
PlanCode: membership.Tenant.PlanCode,
|
||||
TenantName: membership.Tenant.Name,
|
||||
TenantSlug: membership.Tenant.Slug,
|
||||
Locale: membership.Tenant.Locale,
|
||||
Timezone: membership.Tenant.Timezone,
|
||||
PlanCode: normalizePlanCode(membership.Tenant.PlanCode),
|
||||
PublicBookingURL: "/book/" + membership.Tenant.Slug,
|
||||
SetupCompletion: 100,
|
||||
KPIs: []domain.DashboardKPI{
|
||||
{Code: "bookings_this_week", Label: "Bookings this week", Value: fmt.Sprintf("%d", metrics.BookingsCount)},
|
||||
{Code: "cancellations", Label: "Cancellations", Value: fmt.Sprintf("%d", metrics.CancellationsCount)},
|
||||
{Code: "utilization", Label: "Utilization", Value: fmt.Sprintf("%d%%", metrics.UtilizationPercent)},
|
||||
},
|
||||
UpcomingBookings: upcoming,
|
||||
WidgetSnippets: widgetSnippets(membership.Tenant),
|
||||
Tracking: trackingStatus(s.repo, ctx, membership.Tenant),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func widgetSnippets(tenant db.TenantRecord) []domain.WidgetSnippet {
|
||||
path := "/book/" + tenant.Slug
|
||||
return []domain.WidgetSnippet{
|
||||
{Kind: "html", Code: fmt.Sprintf(`<iframe src="%s" title="%s booking" style="width:100%%;min-height:760px;border:0;"></iframe>`, path, tenant.Name)},
|
||||
{Kind: "react", Code: fmt.Sprintf(`export function BookraWidget() { return <iframe src="%s" title="%s booking" style={{ width: "100%%", minHeight: 760, border: 0 }} />; }`, path, tenant.Name)},
|
||||
{Kind: "typescript", Code: fmt.Sprintf(`const bookraWidgetUrl = new URL("%s", window.location.origin);`, path)},
|
||||
}
|
||||
}
|
||||
|
||||
func trackingStatus(repo db.Repository, ctx context.Context, tenant db.TenantRecord) domain.TrackingStatus {
|
||||
brand, err := repo.GetBrandProfile(ctx, tenant.ID)
|
||||
if err != nil || strings.TrimSpace(brand.UmamiSiteID) == "" {
|
||||
return domain.TrackingStatus{Provider: "umami", Connected: false, Message: "Umami tracking is not connected."}
|
||||
}
|
||||
return domain.TrackingStatus{Provider: "umami", Connected: true, SiteID: brand.UmamiSiteID, Message: "Umami tracking is connected."}
|
||||
}
|
||||
|
||||
func normalizePlanCode(planCode string) string {
|
||||
switch planCode {
|
||||
case "growth":
|
||||
return "pro"
|
||||
case "multi-location":
|
||||
return "business"
|
||||
default:
|
||||
return planCode
|
||||
}
|
||||
}
|
||||
|
||||
func generateAppointmentSlots(
|
||||
tenant db.TenantRecord,
|
||||
services []db.ServiceRecord,
|
||||
@@ -318,6 +490,10 @@ func sameResource(left *string, right *string) bool {
|
||||
return *left == *right
|
||||
}
|
||||
|
||||
func sameSecond(left time.Time, right time.Time) bool {
|
||||
return left.UTC().Truncate(time.Second).Equal(right.UTC().Truncate(time.Second))
|
||||
}
|
||||
|
||||
func countClassBookings(bookings []db.BookingRecord, classSessionID string) int32 {
|
||||
var total int32
|
||||
for _, booking := range bookings {
|
||||
|
||||
@@ -2,6 +2,7 @@ package bookings
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -11,7 +12,7 @@ import (
|
||||
|
||||
func TestCreateAppointmentRejectsConflict(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo)
|
||||
service := NewService(repo, nil)
|
||||
|
||||
availability, err := service.Availability(context.Background(), "studio-atelier")
|
||||
if err != nil {
|
||||
@@ -68,7 +69,7 @@ func TestCreateAppointmentRejectsConflict(t *testing.T) {
|
||||
|
||||
func TestCreateClassFallsBackToWaitlistWhenCapacityReached(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo)
|
||||
service := NewService(repo, nil)
|
||||
|
||||
availability, err := service.Availability(context.Background(), "studio-atelier")
|
||||
if err != nil {
|
||||
@@ -123,9 +124,45 @@ func TestCreateClassFallsBackToWaitlistWhenCapacityReached(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAppointmentRequiresTenantService(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo, nil)
|
||||
|
||||
_, err := service.Create(context.Background(), domain.CreateBookingRequest{
|
||||
TenantSlug: "studio-atelier",
|
||||
BookingMode: "appointment",
|
||||
CustomerName: "Missing Service",
|
||||
CustomerEmail: "missing@example.com",
|
||||
StartsAt: time.Now().UTC().Add(24 * time.Hour).Format(time.RFC3339),
|
||||
EndsAt: time.Now().UTC().Add(25 * time.Hour).Format(time.RFC3339),
|
||||
})
|
||||
if !errors.Is(err, ErrInvalidBooking) {
|
||||
t.Fatalf("expected ErrInvalidBooking, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateClassRequiresExistingSession(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo, nil)
|
||||
missingSessionID := "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
_, err := service.Create(context.Background(), domain.CreateBookingRequest{
|
||||
TenantSlug: "studio-atelier",
|
||||
BookingMode: "class",
|
||||
ClassSessionID: &missingSessionID,
|
||||
CustomerName: "Missing Session",
|
||||
CustomerEmail: "missing@example.com",
|
||||
StartsAt: time.Now().UTC().Add(48 * time.Hour).Format(time.RFC3339),
|
||||
EndsAt: time.Now().UTC().Add(49 * time.Hour).Format(time.RFC3339),
|
||||
})
|
||||
if !errors.Is(err, ErrInvalidBooking) {
|
||||
t.Fatalf("expected ErrInvalidBooking, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailabilityGeneratesUpcomingSlots(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo)
|
||||
service := NewService(repo, nil)
|
||||
|
||||
availability, err := service.Availability(context.Background(), "studio-atelier")
|
||||
if err != nil {
|
||||
@@ -148,7 +185,7 @@ func TestAvailabilityGeneratesUpcomingSlots(t *testing.T) {
|
||||
|
||||
func TestCreateSchedulesReminderJobForUpcomingAppointment(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo)
|
||||
service := NewService(repo, nil)
|
||||
|
||||
availability, err := service.Availability(context.Background(), "studio-atelier")
|
||||
if err != nil {
|
||||
|
||||
@@ -1,7 +1,469 @@
|
||||
package catalog
|
||||
|
||||
type Service struct{}
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrLocationNotFound = errors.New("location not found")
|
||||
ErrBlockedDayNotFound = errors.New("blocked day not found")
|
||||
ErrCustomerNotFound = errors.New("customer not found")
|
||||
ErrBookingNotFound = errors.New("booking not found")
|
||||
ErrInvalidBooking = errors.New("invalid booking request")
|
||||
ErrTenantNotFound = errors.New("tenant not found")
|
||||
ErrTenantMembership = errors.New("tenant membership not found")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo db.Repository
|
||||
}
|
||||
|
||||
func NewService(repo db.Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LOCATION / ZONE MANAGEMENT
|
||||
// ============================================
|
||||
|
||||
func (s *Service) ListLocations(ctx context.Context, principal domain.Principal) ([]domain.Location, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return nil, ErrTenantMembership
|
||||
}
|
||||
|
||||
records, err := s.repo.ListLocationsByTenant(ctx, membership.Tenant.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
locations := make([]domain.Location, len(records))
|
||||
for i, rec := range records {
|
||||
locations[i] = domain.Location{
|
||||
ID: rec.ID,
|
||||
TenantID: rec.TenantID,
|
||||
Name: rec.Name,
|
||||
Type: "room", // Default type
|
||||
Capacity: 10, // Default capacity
|
||||
Timezone: rec.Timezone,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
}
|
||||
}
|
||||
return locations, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateLocation(ctx context.Context, principal domain.Principal, req domain.CreateLocationRequest) (domain.Location, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return domain.Location{}, ErrTenantMembership
|
||||
}
|
||||
|
||||
params := db.CreateLocationParams{
|
||||
TenantID: membership.Tenant.ID,
|
||||
Name: req.Name,
|
||||
Timezone: membership.Tenant.Timezone,
|
||||
}
|
||||
|
||||
rec, err := s.repo.CreateLocation(ctx, params)
|
||||
if err != nil {
|
||||
return domain.Location{}, err
|
||||
}
|
||||
|
||||
return domain.Location{
|
||||
ID: rec.ID,
|
||||
TenantID: rec.TenantID,
|
||||
Name: rec.Name,
|
||||
Type: req.Type,
|
||||
Capacity: 10,
|
||||
Timezone: rec.Timezone,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateLocation(ctx context.Context, principal domain.Principal, locationID string, req domain.UpdateLocationRequest) (domain.Location, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return domain.Location{}, ErrTenantMembership
|
||||
}
|
||||
|
||||
// Verify location belongs to tenant
|
||||
loc, err := s.repo.GetLocationByID(ctx, locationID)
|
||||
if err != nil {
|
||||
return domain.Location{}, ErrLocationNotFound
|
||||
}
|
||||
if loc.TenantID != membership.Tenant.ID {
|
||||
return domain.Location{}, ErrLocationNotFound
|
||||
}
|
||||
|
||||
params := db.UpdateLocationParams{}
|
||||
if req.Name != "" {
|
||||
params.Name = &req.Name
|
||||
}
|
||||
|
||||
rec, err := s.repo.UpdateLocation(ctx, locationID, params)
|
||||
if err != nil {
|
||||
return domain.Location{}, err
|
||||
}
|
||||
|
||||
return domain.Location{
|
||||
ID: rec.ID,
|
||||
TenantID: rec.TenantID,
|
||||
Name: rec.Name,
|
||||
Type: req.Type,
|
||||
Capacity: 10,
|
||||
Timezone: rec.Timezone,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteLocation(ctx context.Context, principal domain.Principal, locationID string) error {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return ErrTenantMembership
|
||||
}
|
||||
|
||||
loc, err := s.repo.GetLocationByID(ctx, locationID)
|
||||
if err != nil {
|
||||
return ErrLocationNotFound
|
||||
}
|
||||
if loc.TenantID != membership.Tenant.ID {
|
||||
return ErrLocationNotFound
|
||||
}
|
||||
|
||||
return s.repo.DeleteLocation(ctx, locationID)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// BLOCKED DAYS MANAGEMENT
|
||||
// ============================================
|
||||
|
||||
func (s *Service) ListBlockedDays(ctx context.Context, principal domain.Principal, from time.Time, to time.Time) ([]domain.BlockedDay, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return nil, ErrTenantMembership
|
||||
}
|
||||
|
||||
records, err := s.repo.ListBlockedDaysByTenant(ctx, membership.Tenant.ID, from, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
blockedDays := make([]domain.BlockedDay, len(records))
|
||||
for i, rec := range records {
|
||||
blockedDays[i] = domain.BlockedDay{
|
||||
ID: rec.ID,
|
||||
TenantID: rec.TenantID,
|
||||
Date: rec.StartsAt,
|
||||
Reason: rec.Reason,
|
||||
Type: rec.Kind,
|
||||
StaffID: rec.StaffID,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
}
|
||||
}
|
||||
return blockedDays, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateBlockedDay(ctx context.Context, principal domain.Principal, req domain.CreateBlockedDayRequest) (domain.BlockedDay, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return domain.BlockedDay{}, ErrTenantMembership
|
||||
}
|
||||
|
||||
date, err := time.Parse(time.RFC3339, req.Date)
|
||||
if err != nil {
|
||||
return domain.BlockedDay{}, ErrInvalidBooking
|
||||
}
|
||||
|
||||
params := db.CreateBlockedDayParams{
|
||||
TenantID: membership.Tenant.ID,
|
||||
StaffID: req.StaffID,
|
||||
StartsAt: date,
|
||||
EndsAt: date.Add(24 * time.Hour),
|
||||
Kind: req.Type,
|
||||
Reason: req.Reason,
|
||||
}
|
||||
|
||||
rec, err := s.repo.CreateBlockedDay(ctx, params)
|
||||
if err != nil {
|
||||
return domain.BlockedDay{}, err
|
||||
}
|
||||
|
||||
return domain.BlockedDay{
|
||||
ID: rec.ID,
|
||||
TenantID: rec.TenantID,
|
||||
Date: rec.StartsAt,
|
||||
Reason: rec.Reason,
|
||||
Type: rec.Kind,
|
||||
StaffID: rec.StaffID,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateBlockedDay(ctx context.Context, principal domain.Principal, blockedDayID string, req domain.UpdateBlockedDayRequest) (domain.BlockedDay, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return domain.BlockedDay{}, ErrTenantMembership
|
||||
}
|
||||
|
||||
// Verify blocked day belongs to tenant
|
||||
bd, err := s.repo.ListBlockedDaysByTenant(ctx, membership.Tenant.ID, time.Time{}, time.Now().Add(365*24*time.Hour))
|
||||
if err != nil {
|
||||
return domain.BlockedDay{}, err
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, b := range bd {
|
||||
if b.ID == blockedDayID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return domain.BlockedDay{}, ErrBlockedDayNotFound
|
||||
}
|
||||
|
||||
params := db.UpdateBlockedDayParams{}
|
||||
if req.Reason != "" {
|
||||
params.Reason = &req.Reason
|
||||
}
|
||||
if req.Type != "" {
|
||||
params.Kind = &req.Type
|
||||
}
|
||||
|
||||
rec, err := s.repo.UpdateBlockedDay(ctx, blockedDayID, params)
|
||||
if err != nil {
|
||||
return domain.BlockedDay{}, err
|
||||
}
|
||||
|
||||
return domain.BlockedDay{
|
||||
ID: rec.ID,
|
||||
TenantID: rec.TenantID,
|
||||
Date: rec.StartsAt,
|
||||
Reason: rec.Reason,
|
||||
Type: rec.Kind,
|
||||
StaffID: rec.StaffID,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteBlockedDay(ctx context.Context, principal domain.Principal, blockedDayID string) error {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return ErrTenantMembership
|
||||
}
|
||||
|
||||
// Verify blocked day belongs to tenant
|
||||
bd, err := s.repo.ListBlockedDaysByTenant(ctx, membership.Tenant.ID, time.Time{}, time.Now().Add(365*24*time.Hour))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, b := range bd {
|
||||
if b.ID == blockedDayID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return ErrBlockedDayNotFound
|
||||
}
|
||||
|
||||
return s.repo.DeleteBlockedDay(ctx, blockedDayID)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CUSTOMER MANAGEMENT
|
||||
// ============================================
|
||||
|
||||
func (s *Service) ListCustomers(ctx context.Context, principal domain.Principal, limit int, offset int) ([]domain.Customer, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return nil, ErrTenantMembership
|
||||
}
|
||||
|
||||
records, err := s.repo.ListCustomersByTenant(ctx, membership.Tenant.ID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
customers := make([]domain.Customer, len(records))
|
||||
for i, rec := range records {
|
||||
bookingsCount, _ := s.repo.GetCustomerBookingsCount(ctx, rec.ID)
|
||||
lastBooking, _ := s.repo.GetCustomerLastBooking(ctx, rec.ID)
|
||||
|
||||
customers[i] = domain.Customer{
|
||||
ID: rec.ID,
|
||||
TenantID: rec.TenantID,
|
||||
Name: rec.Name,
|
||||
Email: rec.Email,
|
||||
Phone: rec.Phone,
|
||||
Status: rec.Status,
|
||||
BookingsCount: bookingsCount,
|
||||
LastBookingAt: lastBooking,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
Notes: "",
|
||||
}
|
||||
if rec.Notes != nil {
|
||||
customers[i].Notes = *rec.Notes
|
||||
}
|
||||
}
|
||||
return customers, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateCustomer(ctx context.Context, principal domain.Principal, req domain.CreateCustomerRequest) (domain.Customer, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return domain.Customer{}, ErrTenantMembership
|
||||
}
|
||||
|
||||
status := req.Status
|
||||
if status == "" {
|
||||
status = "active"
|
||||
}
|
||||
|
||||
params := db.CreateCustomerParams{
|
||||
TenantID: membership.Tenant.ID,
|
||||
Name: req.Name,
|
||||
Email: req.Email,
|
||||
Phone: req.Phone,
|
||||
Status: status,
|
||||
Notes: nil,
|
||||
}
|
||||
|
||||
rec, err := s.repo.CreateCustomer(ctx, params)
|
||||
if err != nil {
|
||||
return domain.Customer{}, err
|
||||
}
|
||||
|
||||
return domain.Customer{
|
||||
ID: rec.ID,
|
||||
TenantID: rec.TenantID,
|
||||
Name: rec.Name,
|
||||
Email: rec.Email,
|
||||
Phone: rec.Phone,
|
||||
Status: rec.Status,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
Notes: "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateCustomer(ctx context.Context, principal domain.Principal, customerID string, req domain.UpdateCustomerRequest) (domain.Customer, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return domain.Customer{}, ErrTenantMembership
|
||||
}
|
||||
|
||||
// Verify customer belongs to tenant
|
||||
cust, err := s.repo.GetCustomerByID(ctx, customerID)
|
||||
if err != nil {
|
||||
return domain.Customer{}, ErrCustomerNotFound
|
||||
}
|
||||
if cust.TenantID != membership.Tenant.ID {
|
||||
return domain.Customer{}, ErrCustomerNotFound
|
||||
}
|
||||
|
||||
params := db.UpdateCustomerParams{}
|
||||
if req.Name != "" {
|
||||
params.Name = &req.Name
|
||||
}
|
||||
if req.Email != "" {
|
||||
params.Email = &req.Email
|
||||
}
|
||||
if req.Phone != nil {
|
||||
params.Phone = req.Phone
|
||||
}
|
||||
if req.Status != "" {
|
||||
params.Status = &req.Status
|
||||
}
|
||||
if req.Notes != "" {
|
||||
params.Notes = &req.Notes
|
||||
}
|
||||
|
||||
rec, err := s.repo.UpdateCustomer(ctx, customerID, params)
|
||||
if err != nil {
|
||||
return domain.Customer{}, err
|
||||
}
|
||||
|
||||
bookingsCount, _ := s.repo.GetCustomerBookingsCount(ctx, rec.ID)
|
||||
lastBooking, _ := s.repo.GetCustomerLastBooking(ctx, rec.ID)
|
||||
|
||||
return domain.Customer{
|
||||
ID: rec.ID,
|
||||
TenantID: rec.TenantID,
|
||||
Name: rec.Name,
|
||||
Email: rec.Email,
|
||||
Phone: rec.Phone,
|
||||
Status: rec.Status,
|
||||
BookingsCount: bookingsCount,
|
||||
LastBookingAt: lastBooking,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
Notes: "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteCustomer(ctx context.Context, principal domain.Principal, customerID string) error {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return ErrTenantMembership
|
||||
}
|
||||
|
||||
cust, err := s.repo.GetCustomerByID(ctx, customerID)
|
||||
if err != nil {
|
||||
return ErrCustomerNotFound
|
||||
}
|
||||
if cust.TenantID != membership.Tenant.ID {
|
||||
return ErrCustomerNotFound
|
||||
}
|
||||
|
||||
return s.repo.DeleteCustomer(ctx, customerID)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// WORKING HOURS MANAGEMENT
|
||||
// ============================================
|
||||
|
||||
func (s *Service) ListWorkingHours(ctx context.Context, principal domain.Principal) ([]domain.WorkingHours, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return nil, ErrTenantMembership
|
||||
}
|
||||
|
||||
records, err := s.repo.ListWorkingHoursByTenant(ctx, membership.Tenant.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hours := make([]domain.WorkingHours, len(records))
|
||||
for i, rec := range records {
|
||||
hours[i] = domain.WorkingHours{
|
||||
DayOfWeek: rec.DayOfWeek,
|
||||
Open: rec.StartsLocal,
|
||||
Close: rec.EndsLocal,
|
||||
IsOpen: rec.StartsLocal != "" && rec.EndsLocal != "",
|
||||
}
|
||||
}
|
||||
return hours, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateWorkingHours(ctx context.Context, principal domain.Principal, dayOfWeek int, req domain.UpdateWorkingHoursRequest) error {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return ErrTenantMembership
|
||||
}
|
||||
|
||||
params := db.UpdateWorkingHoursParams{}
|
||||
if req.Open != "" {
|
||||
params.StartsLocal = &req.Open
|
||||
}
|
||||
if req.Close != "" {
|
||||
params.EndsLocal = &req.Close
|
||||
}
|
||||
|
||||
return s.repo.UpdateWorkingHours(ctx, membership.Tenant.ID, dayOfWeek, params)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
@@ -14,12 +15,20 @@ type Config struct {
|
||||
DatabaseURL string
|
||||
DatabaseDirectURL string
|
||||
NeonAuthURL string
|
||||
AuthJWTSecret string
|
||||
JobRunnerKey string
|
||||
EmailFrom string
|
||||
SMSFrom string
|
||||
StripeSecretKey string
|
||||
StripeWebhookKey string
|
||||
StripePriceIDs map[string]string
|
||||
SMTPHost string
|
||||
SMTPPort string
|
||||
SMTPUsername string
|
||||
SMTPPassword string
|
||||
PaddleEnvironment string
|
||||
PaddleAPIKey string
|
||||
PaddleWebhookKey string
|
||||
PaddlePriceMatrix map[string]map[string]string
|
||||
UmamiAPIURL string
|
||||
UmamiAPIKey string
|
||||
DemoMode bool
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
@@ -31,28 +40,141 @@ func Load() (Config, error) {
|
||||
DatabaseURL: strings.TrimSpace(os.Getenv("BOOKRA_DATABASE_URL")),
|
||||
DatabaseDirectURL: strings.TrimSpace(os.Getenv("BOOKRA_DATABASE_DIRECT_URL")),
|
||||
NeonAuthURL: strings.TrimSpace(os.Getenv("BOOKRA_NEON_AUTH_URL")),
|
||||
AuthJWTSecret: strings.TrimSpace(os.Getenv("BOOKRA_AUTH_JWT_SECRET")),
|
||||
JobRunnerKey: strings.TrimSpace(os.Getenv("BOOKRA_JOB_RUNNER_KEY")),
|
||||
EmailFrom: valueOrDefault("BOOKRA_EMAIL_FROM", "noreply@bookra.dev"),
|
||||
SMSFrom: valueOrDefault("BOOKRA_SMS_FROM", "Bookra"),
|
||||
StripeSecretKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_SECRET_KEY")),
|
||||
StripeWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_WEBHOOK_SECRET")),
|
||||
StripePriceIDs: map[string]string{
|
||||
"starter": strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_STARTER_PRICE_ID")),
|
||||
"growth": strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_GROWTH_PRICE_ID")),
|
||||
"multi-location": strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_MULTI_LOCATION_PRICE_ID")),
|
||||
},
|
||||
SMTPHost: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_HOST")),
|
||||
SMTPPort: valueOrDefault("BOOKRA_SMTP_PORT", "587"),
|
||||
SMTPUsername: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_USERNAME")),
|
||||
SMTPPassword: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_PASSWORD")),
|
||||
PaddleEnvironment: normalizePaddleEnvironment(os.Getenv("BOOKRA_PADDLE_ENV")),
|
||||
PaddleAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_API_KEY")),
|
||||
PaddleWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_WEBHOOK_SECRET")),
|
||||
PaddlePriceMatrix: paddlePriceMatrixFromEnv(),
|
||||
UmamiAPIURL: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_URL")),
|
||||
UmamiAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_KEY")),
|
||||
DemoMode: boolFromEnv("BOOKRA_DEMO_MODE", false),
|
||||
}
|
||||
|
||||
if cfg.FrontendURL == "" {
|
||||
return Config{}, errors.New("BOOKRA_FRONTEND_URL is required")
|
||||
}
|
||||
if err := cfg.validateRuntimeRequirements(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (cfg Config) validateRuntimeRequirements() error {
|
||||
if cfg.Environment == "development" || cfg.Environment == "test" {
|
||||
return nil
|
||||
}
|
||||
|
||||
missing := make([]string, 0, 3)
|
||||
if cfg.DatabaseURL == "" {
|
||||
missing = append(missing, "BOOKRA_DATABASE_URL")
|
||||
}
|
||||
if cfg.NeonAuthURL == "" {
|
||||
missing = append(missing, "BOOKRA_NEON_AUTH_URL")
|
||||
}
|
||||
if cfg.JobRunnerKey == "" {
|
||||
missing = append(missing, "BOOKRA_JOB_RUNNER_KEY")
|
||||
}
|
||||
if cfg.SMTPHost == "" {
|
||||
missing = append(missing, "BOOKRA_SMTP_HOST")
|
||||
}
|
||||
if cfg.PaddleAPIKey == "" {
|
||||
missing = append(missing, "BOOKRA_PADDLE_API_KEY")
|
||||
}
|
||||
if cfg.PaddleWebhookKey == "" {
|
||||
missing = append(missing, "BOOKRA_PADDLE_WEBHOOK_SECRET")
|
||||
}
|
||||
for _, planCode := range []string{"starter", "pro", "business"} {
|
||||
if cfg.PaddlePriceMatrix[planCode]["czk"] == "" || cfg.PaddlePriceMatrix[planCode]["usd"] == "" {
|
||||
envPlan := strings.ToUpper(strings.ReplaceAll(planCode, "-", "_"))
|
||||
missing = append(missing, "BOOKRA_PADDLE_"+envPlan+"_CZK_PRICE_ID")
|
||||
missing = append(missing, "BOOKRA_PADDLE_"+envPlan+"_USD_PRICE_ID")
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("%s required when BOOKRA_APP_ENV=%s", strings.Join(uniqueStrings(missing), ", "), cfg.Environment)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg Config) PaddleConfigured() bool {
|
||||
return strings.TrimSpace(cfg.PaddleAPIKey) != ""
|
||||
}
|
||||
|
||||
func (cfg Config) PaddleWebhookConfigured() bool {
|
||||
return strings.TrimSpace(cfg.PaddleWebhookKey) != ""
|
||||
}
|
||||
|
||||
func (cfg Config) PaddleCheckoutConfigured(planCode string) bool {
|
||||
planCode = normalizePlanCode(planCode)
|
||||
return cfg.PaddleConfigured() && cfg.PaddleWebhookConfigured() && cfg.PaddlePriceMatrix[planCode]["czk"] != "" && cfg.PaddlePriceMatrix[planCode]["usd"] != ""
|
||||
}
|
||||
|
||||
func paddlePriceMatrixFromEnv() map[string]map[string]string {
|
||||
matrix := map[string]map[string]string{
|
||||
"starter": {},
|
||||
"pro": {},
|
||||
"business": {},
|
||||
}
|
||||
for _, planCode := range []string{"starter", "pro", "business"} {
|
||||
envPlan := strings.ToUpper(strings.ReplaceAll(planCode, "-", "_"))
|
||||
matrix[planCode]["czk"] = strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_" + envPlan + "_CZK_PRICE_ID"))
|
||||
matrix[planCode]["usd"] = strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_" + envPlan + "_USD_PRICE_ID"))
|
||||
}
|
||||
return matrix
|
||||
}
|
||||
|
||||
func normalizePaddleEnvironment(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "live", "production":
|
||||
return "live"
|
||||
default:
|
||||
return "sandbox"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePlanCode(planCode string) string {
|
||||
switch strings.TrimSpace(planCode) {
|
||||
case "growth":
|
||||
return "pro"
|
||||
case "multi-location":
|
||||
return "business"
|
||||
default:
|
||||
return strings.TrimSpace(planCode)
|
||||
}
|
||||
}
|
||||
|
||||
func valueOrDefault(key string, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func boolFromEnv(key string, fallback bool) bool {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
value = strings.ToLower(value)
|
||||
return value == "true" || value == "1" || value == "yes" || value == "on"
|
||||
}
|
||||
|
||||
func uniqueStrings(values []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPaddleCheckoutConfigured(t *testing.T) {
|
||||
cfg := Config{
|
||||
PaddleAPIKey: "pdl_sdbx_apikey_123",
|
||||
PaddleWebhookKey: "pdl_ntf_123",
|
||||
PaddlePriceMatrix: map[string]map[string]string{
|
||||
"starter": {"czk": "pri_starter_czk", "usd": "pri_starter_usd"},
|
||||
"pro": {"czk": "pri_pro_czk", "usd": "pri_pro_usd"},
|
||||
"business": {"czk": "pri_business_czk", "usd": "pri_business_usd"},
|
||||
},
|
||||
}
|
||||
|
||||
if !cfg.PaddleCheckoutConfigured("pro") {
|
||||
t.Fatal("expected pro checkout configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaddleCheckoutConfiguredRequiresWebhook(t *testing.T) {
|
||||
cfg := Config{
|
||||
PaddleAPIKey: "pdl_sdbx_apikey_123",
|
||||
PaddlePriceMatrix: map[string]map[string]string{
|
||||
"pro": {"czk": "pri_pro_czk", "usd": "pri_pro_usd"},
|
||||
},
|
||||
}
|
||||
|
||||
if cfg.PaddleCheckoutConfigured("pro") {
|
||||
t.Fatal("expected checkout disabled without webhook key")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,30 +15,93 @@ type DashboardKPI struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type UpcomingBooking struct {
|
||||
Reference string `json:"reference"`
|
||||
CustomerName string `json:"customerName"`
|
||||
CustomerEmail string `json:"customerEmail"`
|
||||
StartsAt time.Time `json:"startsAt"`
|
||||
EndsAt time.Time `json:"endsAt"`
|
||||
Status string `json:"status"`
|
||||
Label string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
type WidgetSnippet struct {
|
||||
Kind string `json:"kind"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type TrackingStatus struct {
|
||||
Provider string `json:"provider"`
|
||||
Connected bool `json:"connected"`
|
||||
SiteID string `json:"siteId,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type DashboardSummary struct {
|
||||
TenantName string `json:"tenantName"`
|
||||
Locale string `json:"locale"`
|
||||
Timezone string `json:"timezone"`
|
||||
PlanCode string `json:"planCode"`
|
||||
KPIs []DashboardKPI `json:"kpis"`
|
||||
TenantName string `json:"tenantName"`
|
||||
TenantSlug string `json:"tenantSlug"`
|
||||
Locale string `json:"locale"`
|
||||
Timezone string `json:"timezone"`
|
||||
PlanCode string `json:"planCode"`
|
||||
PublicBookingURL string `json:"publicBookingUrl"`
|
||||
SetupCompletion int `json:"setupCompletion"`
|
||||
KPIs []DashboardKPI `json:"kpis"`
|
||||
UpcomingBookings []UpcomingBooking `json:"upcomingBookings"`
|
||||
WidgetSnippets []WidgetSnippet `json:"widgetSnippets"`
|
||||
Tracking TrackingStatus `json:"tracking"`
|
||||
}
|
||||
|
||||
type BrandProfile struct {
|
||||
Name string `json:"name"`
|
||||
SiteURL string `json:"siteUrl,omitempty"`
|
||||
LogoURL string `json:"logoUrl,omitempty"`
|
||||
PrimaryColor string `json:"primaryColor,omitempty"`
|
||||
}
|
||||
|
||||
type TenantBootstrap struct {
|
||||
TenantID string `json:"tenantId"`
|
||||
TenantName string `json:"tenantName"`
|
||||
Preset string `json:"preset"`
|
||||
Locale string `json:"locale"`
|
||||
Timezone string `json:"timezone"`
|
||||
PlanCode string `json:"planCode,omitempty"`
|
||||
CurrentUser Principal `json:"currentUser"`
|
||||
TenantID string `json:"tenantId"`
|
||||
TenantName string `json:"tenantName"`
|
||||
TenantSlug string `json:"tenantSlug,omitempty"`
|
||||
Preset string `json:"preset"`
|
||||
Locale string `json:"locale"`
|
||||
Timezone string `json:"timezone"`
|
||||
PlanCode string `json:"planCode,omitempty"`
|
||||
OnboardingCompleted bool `json:"onboardingCompleted"`
|
||||
Brand BrandProfile `json:"brand"`
|
||||
CurrentUser Principal `json:"currentUser"`
|
||||
}
|
||||
|
||||
type TeamInviteRequest struct {
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role,omitempty"`
|
||||
}
|
||||
|
||||
type AvailabilityBlockRequest struct {
|
||||
DayOfWeek int `json:"dayOfWeek"`
|
||||
StartsLocal string `json:"startsLocal"`
|
||||
EndsLocal string `json:"endsLocal"`
|
||||
Busy bool `json:"busy,omitempty"`
|
||||
}
|
||||
|
||||
type BookingDefaultsRequest struct {
|
||||
ServiceName string `json:"serviceName"`
|
||||
DurationMinutes int `json:"durationMinutes"`
|
||||
BufferBeforeMinutes int `json:"bufferBeforeMinutes"`
|
||||
BufferAfterMinutes int `json:"bufferAfterMinutes"`
|
||||
CancelWindowHours int `json:"cancelWindowHours"`
|
||||
}
|
||||
|
||||
type OnboardTenantRequest struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Preset string `json:"preset"`
|
||||
Locale string `json:"locale"`
|
||||
Timezone string `json:"timezone"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Preset string `json:"preset"`
|
||||
Locale string `json:"locale"`
|
||||
Timezone string `json:"timezone"`
|
||||
Brand BrandProfile `json:"brand"`
|
||||
LocationName string `json:"locationName"`
|
||||
BookingDefaults BookingDefaultsRequest `json:"bookingDefaults"`
|
||||
AvailabilityBlocks []AvailabilityBlockRequest `json:"availabilityBlocks"`
|
||||
TeamInvites []TeamInviteRequest `json:"teamInvites"`
|
||||
}
|
||||
|
||||
type TimeSlot struct {
|
||||
@@ -83,32 +146,56 @@ type CreateBookingResponse struct {
|
||||
type PlanEntitlements struct {
|
||||
MaxLocations int `json:"maxLocations"`
|
||||
MaxStaff int `json:"maxStaff"`
|
||||
SMSAddonAvailable bool `json:"smsAddonAvailable"`
|
||||
EmailReminders bool `json:"emailReminders"`
|
||||
AdvancedReporting bool `json:"advancedReporting"`
|
||||
WidgetEmbedding bool `json:"widgetEmbedding"`
|
||||
UmamiTracking bool `json:"umamiTracking"`
|
||||
}
|
||||
|
||||
type PlanDisplayPrice struct {
|
||||
Currency string `json:"currency"`
|
||||
AmountCents int `json:"amountCents"`
|
||||
Formatted string `json:"formatted"`
|
||||
}
|
||||
|
||||
type SubscriptionSnapshot struct {
|
||||
TenantID string `json:"tenantId"`
|
||||
CustomerID string `json:"customerId"`
|
||||
SubscriptionID string `json:"subscriptionId"`
|
||||
Status string `json:"status"`
|
||||
PlanCode string `json:"planCode"`
|
||||
PriceID string `json:"priceId"`
|
||||
CancelAtPeriodEnd bool `json:"cancelAtPeriodEnd"`
|
||||
CurrentPeriodStart *time.Time `json:"currentPeriodStart,omitempty"`
|
||||
CurrentPeriodEnd *time.Time `json:"currentPeriodEnd,omitempty"`
|
||||
PaymentMethodBrand string `json:"paymentMethodBrand,omitempty"`
|
||||
PaymentMethodLast4 string `json:"paymentMethodLast4,omitempty"`
|
||||
Entitlements PlanEntitlements `json:"entitlements"`
|
||||
LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"`
|
||||
CheckoutURLAvailable bool `json:"checkoutUrlAvailable,omitempty"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Provider string `json:"provider"`
|
||||
CustomerID string `json:"customerId"`
|
||||
SubscriptionID string `json:"subscriptionId"`
|
||||
Status string `json:"status"`
|
||||
PlanCode string `json:"planCode"`
|
||||
Currency string `json:"currency"`
|
||||
PriceID string `json:"priceId"`
|
||||
CancelAtPeriodEnd bool `json:"cancelAtPeriodEnd"`
|
||||
CurrentPeriodStart *time.Time `json:"currentPeriodStart,omitempty"`
|
||||
CurrentPeriodEnd *time.Time `json:"currentPeriodEnd,omitempty"`
|
||||
PaymentMethodBrand string `json:"paymentMethodBrand,omitempty"`
|
||||
PaymentMethodLast4 string `json:"paymentMethodLast4,omitempty"`
|
||||
Entitlements PlanEntitlements `json:"entitlements"`
|
||||
DisplayPrices []PlanDisplayPrice `json:"displayPrices"`
|
||||
TrialDays int `json:"trialDays"`
|
||||
LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"`
|
||||
CheckoutURLAvailable bool `json:"checkoutUrlAvailable"`
|
||||
SyncAvailable bool `json:"syncAvailable"`
|
||||
PortalAvailable bool `json:"portalAvailable"`
|
||||
}
|
||||
|
||||
type CheckoutSessionRequest struct {
|
||||
PlanCode string `json:"planCode"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
}
|
||||
|
||||
type CheckoutSessionResponse struct {
|
||||
type CheckoutLaunchResponse struct {
|
||||
PriceID string `json:"priceId"`
|
||||
CustomerID string `json:"customerId,omitempty"`
|
||||
CustomerEmail string `json:"customerEmail,omitempty"`
|
||||
SuccessRedirectURL string `json:"successRedirectUrl"`
|
||||
CancelRedirectURL string `json:"cancelRedirectUrl"`
|
||||
CustomData map[string]string `json:"customData"`
|
||||
}
|
||||
|
||||
type PortalSessionResponse struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
@@ -121,3 +208,166 @@ type DispatchReminderJobsResponse struct {
|
||||
SentCount int `json:"sentCount"`
|
||||
FailedCount int `json:"failedCount"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LOCATION / ZONE MODELS
|
||||
// ============================================
|
||||
|
||||
type Location struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // room, private, hall, etc.
|
||||
Capacity int `json:"capacity"`
|
||||
Timezone string `json:"timezone"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type CreateLocationRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
Capacity int `json:"capacity"`
|
||||
}
|
||||
|
||||
type UpdateLocationRequest struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Capacity int `json:"capacity,omitempty"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// BLOCKED DAYS / AVAILABILITY EXCEPTION MODELS
|
||||
// ============================================
|
||||
|
||||
type BlockedDay struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Date time.Time `json:"date"`
|
||||
Reason string `json:"reason"`
|
||||
Type string `json:"type"` // full, partial
|
||||
StaffID *string `json:"staffId,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type CreateBlockedDayRequest struct {
|
||||
Date string `json:"date" binding:"required"` // RFC3339
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
Type string `json:"type" binding:"required"` // full, partial
|
||||
StaffID *string `json:"staffId,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateBlockedDayRequest struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CUSTOMER MODELS
|
||||
// ============================================
|
||||
|
||||
type Customer struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
Status string `json:"status"` // active, inactive, vip
|
||||
BookingsCount int `json:"bookingsCount"`
|
||||
LastBookingAt *time.Time `json:"lastBookingAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
type CreateCustomerRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
Status string `json:"status,omitempty"` // defaults to active
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateCustomerRequest struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CUSTOMER BOOKING MANAGEMENT MODELS
|
||||
// ============================================
|
||||
|
||||
type CustomerBookingView struct {
|
||||
Reference string `json:"reference"`
|
||||
CustomerName string `json:"customerName"`
|
||||
CustomerEmail string `json:"customerEmail"`
|
||||
Service string `json:"service"`
|
||||
BusinessName string `json:"businessName"`
|
||||
StartsAt time.Time `json:"startsAt"`
|
||||
EndsAt time.Time `json:"endsAt"`
|
||||
Location string `json:"location"`
|
||||
Status string `json:"status"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
type RescheduleBookingRequest struct {
|
||||
NewStartsAt string `json:"newStartsAt" binding:"required"` // RFC3339
|
||||
NewEndsAt string `json:"newEndsAt" binding:"required"` // RFC3339
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type CancelBookingRequest struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// WORKING HOURS MODELS
|
||||
// ============================================
|
||||
|
||||
type WorkingHours struct {
|
||||
DayOfWeek int `json:"dayOfWeek"` // 0=Sunday, 1=Monday, etc.
|
||||
Open string `json:"open"` // HH:MM format
|
||||
Close string `json:"close"` // HH:MM format
|
||||
IsOpen bool `json:"isOpen"`
|
||||
}
|
||||
|
||||
type UpdateWorkingHoursRequest struct {
|
||||
Open string `json:"open,omitempty"`
|
||||
Close string `json:"close,omitempty"`
|
||||
IsOpen *bool `json:"isOpen,omitempty"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// EMAIL TEMPLATE MODELS
|
||||
// ============================================
|
||||
|
||||
type EmailTemplate struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Type string `json:"type"` // booking_confirmation, reminder, cancellation, etc.
|
||||
Subject string `json:"subject"`
|
||||
BodyHTML string `json:"bodyHtml"`
|
||||
BodyText string `json:"bodyText"`
|
||||
IsEnabled bool `json:"isEnabled"`
|
||||
}
|
||||
|
||||
type SendEmailRequest struct {
|
||||
To string `json:"to" binding:"required,email"`
|
||||
Subject string `json:"subject" binding:"required"`
|
||||
Body string `json:"body" binding:"required"`
|
||||
Data map[string]string `json:"data,omitempty"` // Template variables
|
||||
}
|
||||
|
||||
type EmailNotification struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenantId"`
|
||||
BookingID string `json:"bookingId,omitempty"`
|
||||
Channel string `json:"channel"` // email, sms
|
||||
Type string `json:"type"` // confirmation, reminder, cancellation
|
||||
Recipient string `json:"recipient"`
|
||||
Status string `json:"status"` // pending, sent, failed
|
||||
SentAt *time.Time `json:"sentAt,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/db"
|
||||
)
|
||||
|
||||
type EmailType string
|
||||
|
||||
const (
|
||||
EmailTypeConfirmation EmailType = "confirmation"
|
||||
EmailTypeReminder EmailType = "reminder"
|
||||
EmailTypeReschedule EmailType = "reschedule"
|
||||
EmailTypeCancellation EmailType = "cancellation"
|
||||
EmailTypeBusinessNotify EmailType = "business_notify"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func RenderEmailMessage(data BookingEmailData) EmailMessage {
|
||||
subject := renderSubject(data)
|
||||
htmlBody := renderHTMLBody(data)
|
||||
textBody := renderTextBody(data)
|
||||
|
||||
return EmailMessage{
|
||||
From: data.BusinessEmail,
|
||||
To: data.CustomerEmail,
|
||||
Subject: subject,
|
||||
Text: textBody,
|
||||
HTML: htmlBody,
|
||||
}
|
||||
}
|
||||
|
||||
func renderSubject(data BookingEmailData) string {
|
||||
localizedTime := formatLocalizedTime(data.StartsAt, data.Timezone, data.Locale)
|
||||
|
||||
switch data.Type {
|
||||
case EmailTypeConfirmation:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf("Potvrzení rezervace %s - %s", data.Reference, data.TenantName)
|
||||
}
|
||||
return fmt.Sprintf("Booking Confirmation %s - %s", data.Reference, data.TenantName)
|
||||
case EmailTypeReminder:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf("Připomínka: Máte rezervaci zítra v %s", localizedTime)
|
||||
}
|
||||
return fmt.Sprintf("Reminder: You have a booking tomorrow at %s", localizedTime)
|
||||
case EmailTypeReschedule:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf("Vaše rezervace byla přesunuta - %s", data.Reference)
|
||||
}
|
||||
return fmt.Sprintf("Your booking has been rescheduled - %s", data.Reference)
|
||||
case EmailTypeCancellation:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf("Vaše rezervace byla zrušena - %s", data.Reference)
|
||||
}
|
||||
return fmt.Sprintf("Your booking has been cancelled - %s", data.Reference)
|
||||
case EmailTypeBusinessNotify:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf("Nová rezervace od %s - %s", data.CustomerName, data.Reference)
|
||||
}
|
||||
return fmt.Sprintf("New booking from %s - %s", data.CustomerName, data.Reference)
|
||||
default:
|
||||
return "Booking Update"
|
||||
}
|
||||
}
|
||||
|
||||
func renderTextBody(data BookingEmailData) string {
|
||||
localizedTime := formatLocalizedDateTime(data.StartsAt, data.Timezone, data.Locale)
|
||||
|
||||
switch data.Type {
|
||||
case EmailTypeConfirmation:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf(`Dobrý den %s,
|
||||
|
||||
vaše rezervace byla potvrzena.
|
||||
|
||||
Detaily rezervace:
|
||||
- Služba: %s
|
||||
- Datum a čas: %s
|
||||
- Místo: %s
|
||||
- Reference: %s
|
||||
|
||||
Pro správu rezervace navštivte: %s
|
||||
|
||||
Děkujeme,
|
||||
%s
|
||||
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName, data.BusinessEmail)
|
||||
}
|
||||
return fmt.Sprintf(`Hello %s,
|
||||
|
||||
Your booking has been confirmed.
|
||||
|
||||
Booking Details:
|
||||
- Service: %s
|
||||
- Date & Time: %s
|
||||
- Location: %s
|
||||
- Reference: %s
|
||||
|
||||
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,
|
||||
|
||||
připomínáme vám zítřejší rezervaci.
|
||||
|
||||
- Služba: %s
|
||||
- Čas: %s
|
||||
- Místo: %s
|
||||
- Reference: %s
|
||||
|
||||
Pro správu rezervace: %s
|
||||
|
||||
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName)
|
||||
}
|
||||
return fmt.Sprintf(`Hello %s,
|
||||
|
||||
This is a reminder for your booking tomorrow.
|
||||
|
||||
- Service: %s
|
||||
- Time: %s
|
||||
- Location: %s
|
||||
- Reference: %s
|
||||
|
||||
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,
|
||||
|
||||
vaše rezervace byla přesunuta na nový termín.
|
||||
|
||||
Nové detaily:
|
||||
- Služba: %s
|
||||
- Datum a čas: %s
|
||||
- Místo: %s
|
||||
- Reference: %s
|
||||
|
||||
Pro správu rezervace: %s
|
||||
|
||||
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName)
|
||||
}
|
||||
return fmt.Sprintf(`Hello %s,
|
||||
|
||||
Your booking has been rescheduled.
|
||||
|
||||
New details:
|
||||
- Service: %s
|
||||
- Date & Time: %s
|
||||
- Location: %s
|
||||
- Reference: %s
|
||||
|
||||
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,
|
||||
|
||||
vaše rezervace byla zrušena.
|
||||
|
||||
Zrušená rezervace:
|
||||
- Služba: %s
|
||||
- Datum a čas: %s
|
||||
- Reference: %s
|
||||
|
||||
Pokud jste rezervaci nezrušili vy, kontaktujte nás: %s
|
||||
|
||||
%s`, data.CustomerName, data.Service, localizedTime, data.Reference, data.BusinessEmail, data.TenantName)
|
||||
}
|
||||
return fmt.Sprintf(`Hello %s,
|
||||
|
||||
Your booking has been cancelled.
|
||||
|
||||
Cancelled booking:
|
||||
- Service: %s
|
||||
- Date & Time: %s
|
||||
- Reference: %s
|
||||
|
||||
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
|
||||
|
||||
Detaily:
|
||||
- Služba: %s
|
||||
- Datum a čas: %s
|
||||
- Reference: %s
|
||||
- Email: %s
|
||||
|
||||
Spravovat v administraci: https://bookra.eu/dashboard`, data.CustomerName, data.Service, localizedTime, data.Reference, data.CustomerEmail)
|
||||
}
|
||||
return fmt.Sprintf(`New booking from %s
|
||||
|
||||
Details:
|
||||
- Service: %s
|
||||
- Date & Time: %s
|
||||
- Reference: %s
|
||||
- Email: %s
|
||||
|
||||
Manage in dashboard: https://bookra.eu/dashboard`, data.CustomerName, data.Service, localizedTime, data.Reference, data.CustomerEmail)
|
||||
|
||||
default:
|
||||
return "Booking update"
|
||||
}
|
||||
}
|
||||
|
||||
func renderHTMLBody(data BookingEmailData) string {
|
||||
// For now, return simple HTML version. In production, this would use proper HTML templates
|
||||
textBody := renderTextBody(data)
|
||||
// Simple conversion: wrap paragraphs in <p> tags and preserve line breaks
|
||||
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 {
|
||||
if len(p) > 0 {
|
||||
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
|
||||
}
|
||||
|
||||
func formatLocalizedTime(t time.Time, timezone, locale string) string {
|
||||
loc, err := time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
localTime := t.In(loc)
|
||||
if locale == "cs" {
|
||||
return localTime.Format("15:04")
|
||||
}
|
||||
return localTime.Format("3:04 PM")
|
||||
}
|
||||
|
||||
func formatLocalizedDateTime(t time.Time, timezone, locale string) string {
|
||||
loc, err := time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
localTime := t.In(loc)
|
||||
if locale == "cs" {
|
||||
return localTime.Format("02.01.2006 15:04")
|
||||
}
|
||||
return localTime.Format("Jan 02, 2006 3:04 PM")
|
||||
}
|
||||
|
||||
func splitParagraphs(text string) []string {
|
||||
var paragraphs []string
|
||||
current := ""
|
||||
for _, line := range splitLines(text) {
|
||||
trimmed := trimSpace(line)
|
||||
if trimmed == "" {
|
||||
if current != "" {
|
||||
paragraphs = append(paragraphs, current)
|
||||
current = ""
|
||||
}
|
||||
} else {
|
||||
if current != "" {
|
||||
current += " "
|
||||
}
|
||||
current += trimmed
|
||||
}
|
||||
}
|
||||
if current != "" {
|
||||
paragraphs = append(paragraphs, current)
|
||||
}
|
||||
return paragraphs
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
var lines []string
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\n' {
|
||||
lines = append(lines, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
lines = append(lines, s[start:])
|
||||
return lines
|
||||
}
|
||||
|
||||
func trimSpace(s string) string {
|
||||
start := 0
|
||||
end := len(s)
|
||||
for start < end && (s[start] == ' ' || s[start] == '\t' || s[start] == '\r' || s[start] == '\n') {
|
||||
start++
|
||||
}
|
||||
for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\r' || s[end-1] == '\n') {
|
||||
end--
|
||||
}
|
||||
return s[start:end]
|
||||
}
|
||||
|
||||
// RenderReminderEmail renders the legacy reminder email from a job record
|
||||
func RenderReminderEmail(from string, job db.ReminderJobRecord) EmailMessage {
|
||||
data := BookingEmailData{
|
||||
Type: EmailTypeReminder,
|
||||
TenantName: job.TenantName,
|
||||
TenantSlug: "", // Not available in job record
|
||||
CustomerName: job.CustomerName,
|
||||
CustomerEmail: job.CustomerEmail,
|
||||
Reference: job.Reference,
|
||||
StartsAt: job.StartsAt,
|
||||
Timezone: job.Timezone,
|
||||
Locale: job.Locale,
|
||||
Service: "Service", // Legacy
|
||||
Location: "Location", // Legacy
|
||||
}
|
||||
return RenderEmailMessage(data)
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/config"
|
||||
@@ -23,36 +26,30 @@ type EmailMessage struct {
|
||||
To string
|
||||
Subject string
|
||||
Text string
|
||||
}
|
||||
|
||||
type SMSMessage struct {
|
||||
From string
|
||||
To string
|
||||
Text string
|
||||
HTML string
|
||||
}
|
||||
|
||||
type EmailProvider interface {
|
||||
Send(context.Context, EmailMessage) (DeliveryReceipt, error)
|
||||
}
|
||||
|
||||
type SMSProvider interface {
|
||||
Send(context.Context, SMSMessage) (DeliveryReceipt, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
cfg config.Config
|
||||
repo db.Repository
|
||||
emailProvider EmailProvider
|
||||
smsProvider SMSProvider
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(cfg config.Config, repo db.Repository) *Service {
|
||||
emailProvider := EmailProvider(noopEmailProvider{})
|
||||
if cfg.SMTPHost != "" {
|
||||
emailProvider = smtpEmailProvider{cfg: cfg}
|
||||
}
|
||||
|
||||
return &Service{
|
||||
cfg: cfg,
|
||||
repo: repo,
|
||||
emailProvider: noopEmailProvider{},
|
||||
smsProvider: noopSMSProvider{},
|
||||
emailProvider: emailProvider,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
}
|
||||
@@ -86,15 +83,6 @@ func (s *Service) DispatchDue(ctx context.Context, limit int) (domain.DispatchRe
|
||||
provider = receipt.Provider
|
||||
externalID = receipt.ExternalID
|
||||
}
|
||||
case "sms":
|
||||
receipt, sendErr := s.smsProvider.Send(ctx, renderSMSMessage(s.cfg.SMSFrom, job))
|
||||
if sendErr != nil {
|
||||
status = "failed"
|
||||
errorMessage = sendErr.Error()
|
||||
} else {
|
||||
provider = receipt.Provider
|
||||
externalID = receipt.ExternalID
|
||||
}
|
||||
default:
|
||||
status = "failed"
|
||||
errorMessage = ErrUnsupportedChannel.Error()
|
||||
@@ -103,8 +91,6 @@ func (s *Service) DispatchDue(ctx context.Context, limit int) (domain.DispatchRe
|
||||
if provider == "unknown" {
|
||||
if job.Channel == "email" {
|
||||
provider = "noop-email"
|
||||
} else if job.Channel == "sms" {
|
||||
provider = "noop-sms"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,23 +120,53 @@ func (s *Service) DispatchDue(ctx context.Context, limit int) (domain.DispatchRe
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func renderEmailMessage(from string, job db.ReminderJobRecord) EmailMessage {
|
||||
subject, body := renderReminderCopy(job)
|
||||
return EmailMessage{
|
||||
From: from,
|
||||
To: job.CustomerEmail,
|
||||
Subject: subject,
|
||||
Text: body,
|
||||
// SendBookingConfirmation sends a booking confirmation email to the customer
|
||||
func (s *Service) SendBookingConfirmation(ctx context.Context, data BookingEmailData) error {
|
||||
if data.BusinessEmail == "" {
|
||||
data.BusinessEmail = s.cfg.EmailFrom
|
||||
}
|
||||
data.Type = EmailTypeConfirmation
|
||||
msg := RenderEmailMessage(data)
|
||||
_, err := s.emailProvider.Send(ctx, msg)
|
||||
return err
|
||||
}
|
||||
|
||||
func renderSMSMessage(from string, job db.ReminderJobRecord) SMSMessage {
|
||||
subject, body := renderReminderCopy(job)
|
||||
return SMSMessage{
|
||||
From: from,
|
||||
To: job.CustomerEmail,
|
||||
Text: fmt.Sprintf("%s: %s", subject, body),
|
||||
// SendBookingReschedule sends a reschedule notification email to the customer
|
||||
func (s *Service) SendBookingReschedule(ctx context.Context, data BookingEmailData) error {
|
||||
if data.BusinessEmail == "" {
|
||||
data.BusinessEmail = s.cfg.EmailFrom
|
||||
}
|
||||
data.Type = EmailTypeReschedule
|
||||
msg := RenderEmailMessage(data)
|
||||
_, err := s.emailProvider.Send(ctx, msg)
|
||||
return err
|
||||
}
|
||||
|
||||
// SendBookingCancellation sends a cancellation notification email to the customer
|
||||
func (s *Service) SendBookingCancellation(ctx context.Context, data BookingEmailData) error {
|
||||
if data.BusinessEmail == "" {
|
||||
data.BusinessEmail = s.cfg.EmailFrom
|
||||
}
|
||||
data.Type = EmailTypeCancellation
|
||||
msg := RenderEmailMessage(data)
|
||||
_, err := s.emailProvider.Send(ctx, msg)
|
||||
return err
|
||||
}
|
||||
|
||||
// SendBusinessNotification sends a notification to the business about a new booking
|
||||
func (s *Service) SendBusinessNotification(ctx context.Context, businessEmail string, data BookingEmailData) error {
|
||||
if businessEmail == "" {
|
||||
return nil // Skip if no business email configured
|
||||
}
|
||||
data.Type = EmailTypeBusinessNotify
|
||||
msg := RenderEmailMessage(data)
|
||||
msg.To = businessEmail
|
||||
_, err := s.emailProvider.Send(ctx, msg)
|
||||
return err
|
||||
}
|
||||
|
||||
func renderEmailMessage(from string, job db.ReminderJobRecord) EmailMessage {
|
||||
return RenderReminderEmail(from, job)
|
||||
}
|
||||
|
||||
func renderReminderCopy(job db.ReminderJobRecord) (string, string) {
|
||||
@@ -190,9 +206,6 @@ func localizedStartsAt(job db.ReminderJobRecord) string {
|
||||
}
|
||||
|
||||
func reminderRecipient(job db.ReminderJobRecord) string {
|
||||
if job.Channel == "email" {
|
||||
return job.CustomerEmail
|
||||
}
|
||||
return job.CustomerEmail
|
||||
}
|
||||
|
||||
@@ -208,14 +221,67 @@ func (noopEmailProvider) Send(_ context.Context, message EmailMessage) (Delivery
|
||||
}, nil
|
||||
}
|
||||
|
||||
type noopSMSProvider struct{}
|
||||
type smtpEmailProvider struct {
|
||||
cfg config.Config
|
||||
}
|
||||
|
||||
func (noopSMSProvider) Send(_ context.Context, message SMSMessage) (DeliveryReceipt, error) {
|
||||
if message.To == "" {
|
||||
return DeliveryReceipt{Provider: "noop-sms"}, errors.New("missing sms recipient")
|
||||
func (p smtpEmailProvider) Send(_ context.Context, message EmailMessage) (DeliveryReceipt, error) {
|
||||
if strings.TrimSpace(message.To) == "" {
|
||||
return DeliveryReceipt{Provider: "smtp"}, errors.New("missing email recipient")
|
||||
}
|
||||
|
||||
host := strings.TrimSpace(p.cfg.SMTPHost)
|
||||
if host == "" {
|
||||
return DeliveryReceipt{Provider: "smtp"}, errors.New("smtp host is not configured")
|
||||
}
|
||||
|
||||
address := net.JoinHostPort(host, strings.TrimSpace(p.cfg.SMTPPort))
|
||||
var auth smtp.Auth
|
||||
if p.cfg.SMTPUsername != "" {
|
||||
auth = smtp.PlainAuth("", p.cfg.SMTPUsername, p.cfg.SMTPPassword, host)
|
||||
}
|
||||
|
||||
// Build multipart email with both plain text and HTML
|
||||
var payload string
|
||||
if message.HTML != "" {
|
||||
boundary := "BOOKRA-BOUNDARY"
|
||||
payload = strings.Join([]string{
|
||||
fmt.Sprintf("From: %s", message.From),
|
||||
fmt.Sprintf("To: %s", message.To),
|
||||
fmt.Sprintf("Subject: %s", message.Subject),
|
||||
"MIME-Version: 1.0",
|
||||
fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"", boundary),
|
||||
"",
|
||||
fmt.Sprintf("--%s", boundary),
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"",
|
||||
message.Text,
|
||||
"",
|
||||
fmt.Sprintf("--%s", boundary),
|
||||
"Content-Type: text/html; charset=UTF-8",
|
||||
"",
|
||||
message.HTML,
|
||||
"",
|
||||
fmt.Sprintf("--%s--", boundary),
|
||||
}, "\r\n")
|
||||
} else {
|
||||
payload = strings.Join([]string{
|
||||
fmt.Sprintf("From: %s", message.From),
|
||||
fmt.Sprintf("To: %s", message.To),
|
||||
fmt.Sprintf("Subject: %s", message.Subject),
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"",
|
||||
message.Text,
|
||||
}, "\r\n")
|
||||
}
|
||||
|
||||
if err := smtp.SendMail(address, auth, message.From, []string{message.To}, []byte(payload)); err != nil {
|
||||
return DeliveryReceipt{Provider: "smtp"}, err
|
||||
}
|
||||
|
||||
return DeliveryReceipt{
|
||||
Provider: "noop-sms",
|
||||
ExternalID: fmt.Sprintf("noop-sms-%d", time.Now().UnixNano()),
|
||||
Provider: "smtp",
|
||||
ExternalID: fmt.Sprintf("smtp-%d", time.Now().UnixNano()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ func TestDispatchDueProcessesPendingEmailReminders(t *testing.T) {
|
||||
service := NewService(config.Config{
|
||||
Environment: "development",
|
||||
EmailFrom: "noreply@bookra.dev",
|
||||
SMSFrom: "Bookra",
|
||||
}, repo)
|
||||
|
||||
response, err := service.DispatchDue(context.Background(), 10)
|
||||
|
||||
@@ -52,12 +52,15 @@ func (s *Service) Bootstrap(ctx context.Context, principal domain.Principal) (do
|
||||
}
|
||||
|
||||
return domain.TenantBootstrap{
|
||||
TenantID: membership.Tenant.ID,
|
||||
TenantName: membership.Tenant.Name,
|
||||
Preset: membership.Tenant.Preset,
|
||||
Locale: membership.Tenant.Locale,
|
||||
Timezone: membership.Tenant.Timezone,
|
||||
PlanCode: membership.Tenant.PlanCode,
|
||||
TenantID: membership.Tenant.ID,
|
||||
TenantName: membership.Tenant.Name,
|
||||
TenantSlug: membership.Tenant.Slug,
|
||||
Preset: membership.Tenant.Preset,
|
||||
Locale: membership.Tenant.Locale,
|
||||
Timezone: membership.Tenant.Timezone,
|
||||
PlanCode: normalizePlanCode(membership.Tenant.PlanCode),
|
||||
OnboardingCompleted: true,
|
||||
Brand: s.brandProfile(ctx, membership.Tenant),
|
||||
CurrentUser: domain.Principal{
|
||||
Subject: principal.Subject,
|
||||
Email: principal.Email,
|
||||
@@ -79,6 +82,21 @@ func (s *Service) Onboard(ctx context.Context, principal domain.Principal, reque
|
||||
preset := strings.TrimSpace(request.Preset)
|
||||
locale := strings.TrimSpace(request.Locale)
|
||||
timezone := strings.TrimSpace(request.Timezone)
|
||||
locationName := strings.TrimSpace(request.LocationName)
|
||||
brand := request.Brand
|
||||
if strings.TrimSpace(brand.Name) == "" {
|
||||
brand.Name = name
|
||||
}
|
||||
defaults := request.BookingDefaults
|
||||
if strings.TrimSpace(defaults.ServiceName) == "" {
|
||||
defaults.ServiceName = "First appointment"
|
||||
}
|
||||
if defaults.DurationMinutes == 0 {
|
||||
defaults.DurationMinutes = 60
|
||||
}
|
||||
if defaults.CancelWindowHours == 0 {
|
||||
defaults.CancelWindowHours = 24
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(name) < 2 || len(name) > 80:
|
||||
@@ -91,18 +109,43 @@ func (s *Service) Onboard(ctx context.Context, principal domain.Principal, reque
|
||||
return domain.TenantBootstrap{}, ErrInvalidOnboarding
|
||||
case timezone == "":
|
||||
return domain.TenantBootstrap{}, ErrInvalidOnboarding
|
||||
case len(locationName) > 120:
|
||||
return domain.TenantBootstrap{}, ErrInvalidOnboarding
|
||||
case defaults.DurationMinutes < 15 || defaults.DurationMinutes > 480:
|
||||
return domain.TenantBootstrap{}, ErrInvalidOnboarding
|
||||
case defaults.BufferBeforeMinutes < 0 || defaults.BufferBeforeMinutes > 180:
|
||||
return domain.TenantBootstrap{}, ErrInvalidOnboarding
|
||||
case defaults.BufferAfterMinutes < 0 || defaults.BufferAfterMinutes > 180:
|
||||
return domain.TenantBootstrap{}, ErrInvalidOnboarding
|
||||
case defaults.CancelWindowHours < 1 || defaults.CancelWindowHours > 720:
|
||||
return domain.TenantBootstrap{}, ErrInvalidOnboarding
|
||||
}
|
||||
if _, err := time.LoadLocation(timezone); err != nil {
|
||||
return domain.TenantBootstrap{}, ErrInvalidOnboarding
|
||||
}
|
||||
if err := validateAvailabilityBlocks(request.AvailabilityBlocks); err != nil {
|
||||
return domain.TenantBootstrap{}, err
|
||||
}
|
||||
|
||||
membership, err := s.repo.CreateTenantForUser(ctx, db.CreateTenantForUserParams{
|
||||
Subject: principal.Subject,
|
||||
Name: name,
|
||||
Slug: slug,
|
||||
Preset: preset,
|
||||
Locale: locale,
|
||||
Timezone: timezone,
|
||||
Subject: principal.Subject,
|
||||
Name: name,
|
||||
Slug: slug,
|
||||
Preset: preset,
|
||||
Locale: locale,
|
||||
Timezone: timezone,
|
||||
BrandName: strings.TrimSpace(brand.Name),
|
||||
SiteURL: strings.TrimSpace(brand.SiteURL),
|
||||
LogoURL: strings.TrimSpace(brand.LogoURL),
|
||||
PrimaryColor: strings.TrimSpace(brand.PrimaryColor),
|
||||
LocationName: locationName,
|
||||
ServiceName: strings.TrimSpace(defaults.ServiceName),
|
||||
DurationMinutes: defaults.DurationMinutes,
|
||||
BufferBeforeMinutes: defaults.BufferBeforeMinutes,
|
||||
BufferAfterMinutes: defaults.BufferAfterMinutes,
|
||||
CancelWindowHours: defaults.CancelWindowHours,
|
||||
AvailabilityBlocks: toAvailabilityBlocks(request.AvailabilityBlocks),
|
||||
TeamInvites: toTeamInvites(request.TeamInvites),
|
||||
})
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
@@ -113,12 +156,20 @@ func (s *Service) Onboard(ctx context.Context, principal domain.Principal, reque
|
||||
}
|
||||
|
||||
return domain.TenantBootstrap{
|
||||
TenantID: membership.Tenant.ID,
|
||||
TenantName: membership.Tenant.Name,
|
||||
Preset: membership.Tenant.Preset,
|
||||
Locale: membership.Tenant.Locale,
|
||||
Timezone: membership.Tenant.Timezone,
|
||||
PlanCode: membership.Tenant.PlanCode,
|
||||
TenantID: membership.Tenant.ID,
|
||||
TenantName: membership.Tenant.Name,
|
||||
TenantSlug: membership.Tenant.Slug,
|
||||
Preset: membership.Tenant.Preset,
|
||||
Locale: membership.Tenant.Locale,
|
||||
Timezone: membership.Tenant.Timezone,
|
||||
PlanCode: normalizePlanCode(membership.Tenant.PlanCode),
|
||||
OnboardingCompleted: true,
|
||||
Brand: domain.BrandProfile{
|
||||
Name: strings.TrimSpace(brand.Name),
|
||||
SiteURL: strings.TrimSpace(brand.SiteURL),
|
||||
LogoURL: strings.TrimSpace(brand.LogoURL),
|
||||
PrimaryColor: strings.TrimSpace(brand.PrimaryColor),
|
||||
},
|
||||
CurrentUser: domain.Principal{
|
||||
Subject: principal.Subject,
|
||||
Email: principal.Email,
|
||||
@@ -127,3 +178,96 @@ func (s *Service) Onboard(ctx context.Context, principal domain.Principal, reque
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) brandProfile(ctx context.Context, tenant db.TenantRecord) domain.BrandProfile {
|
||||
brand, err := s.repo.GetBrandProfile(ctx, tenant.ID)
|
||||
if err != nil {
|
||||
return domain.BrandProfile{Name: tenant.Name}
|
||||
}
|
||||
return domain.BrandProfile{
|
||||
Name: firstNonEmpty(brand.Name, tenant.Name),
|
||||
SiteURL: brand.SiteURL,
|
||||
LogoURL: brand.LogoURL,
|
||||
PrimaryColor: brand.PrimaryColor,
|
||||
}
|
||||
}
|
||||
|
||||
func validateAvailabilityBlocks(blocks []domain.AvailabilityBlockRequest) error {
|
||||
for _, block := range blocks {
|
||||
if block.DayOfWeek < 0 || block.DayOfWeek > 6 {
|
||||
return ErrInvalidOnboarding
|
||||
}
|
||||
starts, err := time.Parse("15:04", block.StartsLocal)
|
||||
if err != nil {
|
||||
starts, err = time.Parse("15:04:05", block.StartsLocal)
|
||||
}
|
||||
if err != nil {
|
||||
return ErrInvalidOnboarding
|
||||
}
|
||||
ends, err := time.Parse("15:04", block.EndsLocal)
|
||||
if err != nil {
|
||||
ends, err = time.Parse("15:04:05", block.EndsLocal)
|
||||
}
|
||||
if err != nil || !ends.After(starts) {
|
||||
return ErrInvalidOnboarding
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func toAvailabilityBlocks(blocks []domain.AvailabilityBlockRequest) []db.AvailabilityBlockRecord {
|
||||
records := make([]db.AvailabilityBlockRecord, 0, len(blocks))
|
||||
for _, block := range blocks {
|
||||
records = append(records, db.AvailabilityBlockRecord{
|
||||
DayOfWeek: block.DayOfWeek,
|
||||
StartsLocal: normalizeClock(block.StartsLocal),
|
||||
EndsLocal: normalizeClock(block.EndsLocal),
|
||||
Busy: block.Busy,
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func toTeamInvites(invites []domain.TeamInviteRequest) []db.TeamInviteRecord {
|
||||
records := make([]db.TeamInviteRecord, 0, len(invites))
|
||||
for _, invite := range invites {
|
||||
email := strings.TrimSpace(strings.ToLower(invite.Email))
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
role := strings.TrimSpace(invite.Role)
|
||||
if role == "" {
|
||||
role = "staff"
|
||||
}
|
||||
records = append(records, db.TeamInviteRecord{Email: email, Role: role})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func normalizeClock(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) == len("15:04") {
|
||||
return value + ":00"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func normalizePlanCode(planCode string) string {
|
||||
switch planCode {
|
||||
case "growth":
|
||||
return "pro"
|
||||
case "multi-location":
|
||||
return "business"
|
||||
default:
|
||||
return planCode
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ INSERT INTO tenants (
|
||||
'studio',
|
||||
'cs',
|
||||
'Europe/Prague',
|
||||
'growth',
|
||||
'pro',
|
||||
'active'
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE tenants
|
||||
ADD COLUMN IF NOT EXISTS billing_provider text NOT NULL DEFAULT 'paddle';
|
||||
|
||||
ALTER TABLE billing_snapshots
|
||||
ADD COLUMN IF NOT EXISTS billing_provider text NOT NULL DEFAULT 'paddle';
|
||||
|
||||
ALTER TABLE subscription_events
|
||||
ADD COLUMN IF NOT EXISTS billing_provider text NOT NULL DEFAULT 'paddle';
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'tenants' AND column_name = 'stripe_customer_id'
|
||||
) THEN
|
||||
ALTER TABLE tenants RENAME COLUMN stripe_customer_id TO billing_customer_id;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'tenants' AND column_name = 'stripe_subscription_id'
|
||||
) THEN
|
||||
ALTER TABLE tenants RENAME COLUMN stripe_subscription_id TO billing_subscription_id;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'billing_snapshots' AND column_name = 'stripe_customer_id'
|
||||
) THEN
|
||||
ALTER TABLE billing_snapshots RENAME COLUMN stripe_customer_id TO billing_customer_id;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'billing_snapshots' AND column_name = 'stripe_subscription_id'
|
||||
) THEN
|
||||
ALTER TABLE billing_snapshots RENAME COLUMN stripe_subscription_id TO billing_subscription_id;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'subscription_events' AND column_name = 'stripe_event_id'
|
||||
) THEN
|
||||
ALTER TABLE subscription_events RENAME COLUMN stripe_event_id TO billing_provider_event_id;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE subscription_events DROP CONSTRAINT IF EXISTS subscription_events_stripe_event_id_key;
|
||||
ALTER TABLE subscription_events
|
||||
ADD CONSTRAINT subscription_events_provider_event_key UNIQUE (billing_provider, billing_provider_event_id);
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE subscription_events DROP CONSTRAINT IF EXISTS subscription_events_provider_event_key;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'subscription_events' AND column_name = 'billing_provider_event_id'
|
||||
) THEN
|
||||
ALTER TABLE subscription_events RENAME COLUMN billing_provider_event_id TO stripe_event_id;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'billing_snapshots' AND column_name = 'billing_subscription_id'
|
||||
) THEN
|
||||
ALTER TABLE billing_snapshots RENAME COLUMN billing_subscription_id TO stripe_subscription_id;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'billing_snapshots' AND column_name = 'billing_customer_id'
|
||||
) THEN
|
||||
ALTER TABLE billing_snapshots RENAME COLUMN billing_customer_id TO stripe_customer_id;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'tenants' AND column_name = 'billing_subscription_id'
|
||||
) THEN
|
||||
ALTER TABLE tenants RENAME COLUMN billing_subscription_id TO stripe_subscription_id;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'tenants' AND column_name = 'billing_customer_id'
|
||||
) THEN
|
||||
ALTER TABLE tenants RENAME COLUMN billing_customer_id TO stripe_customer_id;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE subscription_events DROP COLUMN IF EXISTS billing_provider;
|
||||
ALTER TABLE billing_snapshots DROP COLUMN IF EXISTS billing_provider;
|
||||
ALTER TABLE tenants DROP COLUMN IF EXISTS billing_provider;
|
||||
ALTER TABLE subscription_events
|
||||
ADD CONSTRAINT subscription_events_stripe_event_id_key UNIQUE (stripe_event_id);
|
||||
@@ -0,0 +1,61 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE billing_snapshots
|
||||
ADD COLUMN IF NOT EXISTS currency text NOT NULL DEFAULT 'czk';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS brand_profiles (
|
||||
tenant_id uuid PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
site_url text,
|
||||
logo_url text,
|
||||
primary_color text,
|
||||
umami_site_id text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tenant_settings (
|
||||
tenant_id uuid PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
cancel_window_hours integer NOT NULL DEFAULT 24,
|
||||
onboarding_completed boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS team_invites (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
email text NOT NULL,
|
||||
role text NOT NULL DEFAULT 'staff',
|
||||
status text NOT NULL DEFAULT 'pending',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, email)
|
||||
);
|
||||
|
||||
UPDATE tenants SET plan_code = 'pro' WHERE plan_code = 'growth';
|
||||
UPDATE tenants SET plan_code = 'business' WHERE plan_code = 'multi-location';
|
||||
UPDATE billing_snapshots SET plan_code = 'pro' WHERE plan_code = 'growth';
|
||||
UPDATE billing_snapshots SET plan_code = 'business' WHERE plan_code = 'multi-location';
|
||||
|
||||
INSERT INTO brand_profiles (tenant_id, name)
|
||||
SELECT id, name
|
||||
FROM tenants
|
||||
ON CONFLICT (tenant_id) DO NOTHING;
|
||||
|
||||
INSERT INTO tenant_settings (tenant_id, onboarding_completed)
|
||||
SELECT id, true
|
||||
FROM tenants
|
||||
ON CONFLICT (tenant_id) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
UPDATE tenants SET plan_code = 'growth' WHERE plan_code = 'pro';
|
||||
UPDATE tenants SET plan_code = 'multi-location' WHERE plan_code = 'business';
|
||||
UPDATE billing_snapshots SET plan_code = 'growth' WHERE plan_code = 'pro';
|
||||
UPDATE billing_snapshots SET plan_code = 'multi-location' WHERE plan_code = 'business';
|
||||
|
||||
DROP TABLE IF EXISTS team_invites;
|
||||
DROP TABLE IF EXISTS tenant_settings;
|
||||
DROP TABLE IF EXISTS brand_profiles;
|
||||
|
||||
ALTER TABLE billing_snapshots
|
||||
DROP COLUMN IF EXISTS currency;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- +goose Up
|
||||
-- Create customers table for customer management
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
email text NOT NULL,
|
||||
phone text,
|
||||
status text NOT NULL DEFAULT 'active',
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_customers_tenant ON customers (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_customers_email ON customers (tenant_id, email);
|
||||
CREATE INDEX IF NOT EXISTS idx_customers_status ON customers (tenant_id, status);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS customers;
|
||||
@@ -4,7 +4,7 @@ info:
|
||||
version: 0.1.0
|
||||
description: >
|
||||
Remote-first booking API for Bookra. The Go backend owns business rules,
|
||||
scheduling logic, tenant isolation, and Stripe-backed plan enforcement.
|
||||
scheduling logic, tenant isolation, and Paddle-backed plan enforcement.
|
||||
servers:
|
||||
- url: http://localhost:8080
|
||||
tags:
|
||||
@@ -155,15 +155,17 @@ paths:
|
||||
$ref: "#/components/schemas/CheckoutSessionRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Hosted Stripe checkout session
|
||||
description: Paddle checkout launch payload
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CheckoutSessionResponse"
|
||||
$ref: "#/components/schemas/CheckoutLaunchResponse"
|
||||
"400":
|
||||
description: Invalid request
|
||||
"401":
|
||||
description: Unauthorized
|
||||
"503":
|
||||
description: Billing provider unavailable
|
||||
/v1/billing/refresh:
|
||||
post:
|
||||
tags: [Billing]
|
||||
@@ -179,10 +181,31 @@ paths:
|
||||
$ref: "#/components/schemas/SubscriptionSnapshot"
|
||||
"401":
|
||||
description: Unauthorized
|
||||
/v1/webhooks/stripe:
|
||||
"503":
|
||||
description: Billing provider unavailable
|
||||
/v1/billing/portal:
|
||||
post:
|
||||
tags: [Billing]
|
||||
operationId: handleStripeWebhook
|
||||
operationId: createBillingPortalSession
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: Paddle customer portal session
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PortalSessionResponse"
|
||||
"400":
|
||||
description: Billing customer missing
|
||||
"401":
|
||||
description: Unauthorized
|
||||
"503":
|
||||
description: Billing provider unavailable
|
||||
/v1/webhooks/paddle:
|
||||
post:
|
||||
tags: [Billing]
|
||||
operationId: handlePaddleWebhook
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -238,7 +261,7 @@ components:
|
||||
type: boolean
|
||||
PublicConfig:
|
||||
type: object
|
||||
required: [environment, neonAuthEnabled]
|
||||
required: [environment, neonAuthEnabled, apiUrl, demoMode]
|
||||
properties:
|
||||
environment:
|
||||
type: string
|
||||
@@ -247,6 +270,8 @@ components:
|
||||
apiUrl:
|
||||
type: string
|
||||
format: uri
|
||||
demoMode:
|
||||
type: boolean
|
||||
TimeSlot:
|
||||
type: object
|
||||
required: [startsAt, endsAt, mode]
|
||||
@@ -354,20 +379,84 @@ components:
|
||||
type: string
|
||||
DashboardSummary:
|
||||
type: object
|
||||
required: [tenantName, locale, timezone, planCode, kpis]
|
||||
required: [tenantName, tenantSlug, locale, timezone, planCode, publicBookingUrl, setupCompletion, kpis]
|
||||
properties:
|
||||
tenantName:
|
||||
type: string
|
||||
tenantSlug:
|
||||
type: string
|
||||
locale:
|
||||
type: string
|
||||
timezone:
|
||||
type: string
|
||||
planCode:
|
||||
type: string
|
||||
publicBookingUrl:
|
||||
type: string
|
||||
setupCompletion:
|
||||
type: integer
|
||||
kpis:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DashboardKPI"
|
||||
upcomingBookings:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/UpcomingBooking"
|
||||
widgetSnippets:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/WidgetSnippet"
|
||||
tracking:
|
||||
$ref: "#/components/schemas/TrackingStatus"
|
||||
UpcomingBooking:
|
||||
type: object
|
||||
properties:
|
||||
reference:
|
||||
type: string
|
||||
customerName:
|
||||
type: string
|
||||
customerEmail:
|
||||
type: string
|
||||
startsAt:
|
||||
type: string
|
||||
format: date-time
|
||||
endsAt:
|
||||
type: string
|
||||
format: date-time
|
||||
status:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
WidgetSnippet:
|
||||
type: object
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
code:
|
||||
type: string
|
||||
TrackingStatus:
|
||||
type: object
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
connected:
|
||||
type: boolean
|
||||
siteId:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
BrandProfile:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
siteUrl:
|
||||
type: string
|
||||
logoUrl:
|
||||
type: string
|
||||
primaryColor:
|
||||
type: string
|
||||
TenantBootstrap:
|
||||
type: object
|
||||
required: [tenantId, tenantName, preset, locale, timezone, currentUser]
|
||||
@@ -377,6 +466,8 @@ components:
|
||||
format: uuid
|
||||
tenantName:
|
||||
type: string
|
||||
tenantSlug:
|
||||
type: string
|
||||
preset:
|
||||
type: string
|
||||
locale:
|
||||
@@ -385,6 +476,10 @@ components:
|
||||
type: string
|
||||
planCode:
|
||||
type: string
|
||||
onboardingCompleted:
|
||||
type: boolean
|
||||
brand:
|
||||
$ref: "#/components/schemas/BrandProfile"
|
||||
currentUser:
|
||||
type: object
|
||||
required: [subject, role]
|
||||
@@ -414,25 +509,84 @@ components:
|
||||
enum: [cs, en]
|
||||
timezone:
|
||||
type: string
|
||||
brand:
|
||||
$ref: "#/components/schemas/BrandProfile"
|
||||
locationName:
|
||||
type: string
|
||||
bookingDefaults:
|
||||
$ref: "#/components/schemas/BookingDefaultsRequest"
|
||||
availabilityBlocks:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AvailabilityBlockRequest"
|
||||
teamInvites:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TeamInviteRequest"
|
||||
BookingDefaultsRequest:
|
||||
type: object
|
||||
properties:
|
||||
serviceName:
|
||||
type: string
|
||||
durationMinutes:
|
||||
type: integer
|
||||
bufferBeforeMinutes:
|
||||
type: integer
|
||||
bufferAfterMinutes:
|
||||
type: integer
|
||||
cancelWindowHours:
|
||||
type: integer
|
||||
AvailabilityBlockRequest:
|
||||
type: object
|
||||
required: [dayOfWeek, startsLocal, endsLocal]
|
||||
properties:
|
||||
dayOfWeek:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 7
|
||||
startsLocal:
|
||||
type: string
|
||||
example: "09:00"
|
||||
endsLocal:
|
||||
type: string
|
||||
example: "17:00"
|
||||
busy:
|
||||
type: boolean
|
||||
TeamInviteRequest:
|
||||
type: object
|
||||
required: [email]
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
role:
|
||||
type: string
|
||||
PlanEntitlements:
|
||||
type: object
|
||||
required: [maxLocations, maxStaff, smsAddonAvailable, advancedReporting]
|
||||
required: [maxLocations, maxStaff, emailReminders, advancedReporting, widgetEmbedding, umamiTracking]
|
||||
properties:
|
||||
maxLocations:
|
||||
type: integer
|
||||
maxStaff:
|
||||
type: integer
|
||||
smsAddonAvailable:
|
||||
emailReminders:
|
||||
type: boolean
|
||||
widgetEmbedding:
|
||||
type: boolean
|
||||
umamiTracking:
|
||||
type: boolean
|
||||
advancedReporting:
|
||||
type: boolean
|
||||
SubscriptionSnapshot:
|
||||
type: object
|
||||
required: [tenantId, customerId, subscriptionId, status, planCode, priceId, cancelAtPeriodEnd, entitlements]
|
||||
required: [tenantId, provider, customerId, subscriptionId, status, planCode, currency, priceId, cancelAtPeriodEnd, entitlements, displayPrices, trialDays, checkoutUrlAvailable, syncAvailable, portalAvailable]
|
||||
properties:
|
||||
tenantId:
|
||||
type: string
|
||||
format: uuid
|
||||
provider:
|
||||
type: string
|
||||
example: paddle
|
||||
customerId:
|
||||
type: string
|
||||
subscriptionId:
|
||||
@@ -441,6 +595,9 @@ components:
|
||||
type: string
|
||||
planCode:
|
||||
type: string
|
||||
currency:
|
||||
type: string
|
||||
enum: [czk, usd, eur]
|
||||
priceId:
|
||||
type: string
|
||||
cancelAtPeriodEnd:
|
||||
@@ -459,20 +616,65 @@ components:
|
||||
type: string
|
||||
entitlements:
|
||||
$ref: "#/components/schemas/PlanEntitlements"
|
||||
displayPrices:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/PlanDisplayPrice"
|
||||
trialDays:
|
||||
type: integer
|
||||
lastSyncedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
checkoutUrlAvailable:
|
||||
type: boolean
|
||||
syncAvailable:
|
||||
type: boolean
|
||||
portalAvailable:
|
||||
type: boolean
|
||||
CheckoutSessionRequest:
|
||||
type: object
|
||||
required: [planCode]
|
||||
properties:
|
||||
planCode:
|
||||
type: string
|
||||
enum: [starter, growth, multi-location]
|
||||
CheckoutSessionResponse:
|
||||
enum: [starter, pro, business]
|
||||
currency:
|
||||
type: string
|
||||
enum: [czk, usd]
|
||||
PlanDisplayPrice:
|
||||
type: object
|
||||
required: [currency, amountCents, formatted]
|
||||
properties:
|
||||
currency:
|
||||
type: string
|
||||
enum: [czk, usd]
|
||||
amountCents:
|
||||
type: integer
|
||||
formatted:
|
||||
type: string
|
||||
CheckoutLaunchResponse:
|
||||
type: object
|
||||
required: [priceId, successRedirectUrl, cancelRedirectUrl, customData]
|
||||
properties:
|
||||
priceId:
|
||||
type: string
|
||||
customerId:
|
||||
type: string
|
||||
customerEmail:
|
||||
type: string
|
||||
format: email
|
||||
successRedirectUrl:
|
||||
type: string
|
||||
format: uri
|
||||
cancelRedirectUrl:
|
||||
type: string
|
||||
format: uri
|
||||
customData:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
PortalSessionResponse:
|
||||
type: object
|
||||
required: [url]
|
||||
properties:
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://railway.app/railway.schema.json",
|
||||
"build": {
|
||||
"builder": "DOCKERFILE",
|
||||
"dockerfilePath": "Dockerfile"
|
||||
},
|
||||
"deploy": {
|
||||
"restartPolicyType": "ON_FAILURE",
|
||||
"restartPolicyMaxRetries": 10,
|
||||
"healthcheckPath": "/healthz",
|
||||
"healthcheckTimeout": 30,
|
||||
"numReplicas": 1
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
-- name: GetTenantBySlug :one
|
||||
SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status, stripe_customer_id, stripe_subscription_id, created_at, updated_at
|
||||
SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status, billing_customer_id, billing_subscription_id, created_at, updated_at
|
||||
FROM tenants
|
||||
WHERE slug = $1;
|
||||
|
||||
@@ -8,4 +8,3 @@ SELECT tenant_id, user_id, role, created_at
|
||||
FROM tenant_users
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY created_at ASC;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user