package config import ( "errors" "fmt" "os" "strings" "bookra/apps/backend/internal/shared" ) type Config struct { Environment string Port string APIURL string FrontendURL string DatabaseURL string DatabaseDirectURL string NeonAuthURL string AuthJWTSecret string JobRunnerKey string EmailFrom 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) { cfg := Config{ Environment: valueOrDefault("BOOKRA_APP_ENV", "development"), Port: valueOrDefault("BOOKRA_API_PORT", "8080"), APIURL: valueOrDefault("BOOKRA_API_URL", "http://localhost:8080"), FrontendURL: valueOrDefault("BOOKRA_FRONTEND_URL", "http://localhost:3000"), 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"), 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 = shared.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 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 }