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 StripeAPIKey string StripeWebhookKey string StripePriceMatrix map[string]map[string]string AdminEmail string AdminKey string UmamiAPIURL string UmamiAPIKey string SentryDSN string DemoMode bool SMSManagerAPIKey string SMSManagerBaseURL string StripeSMSPriceMatrix map[string]string // currency -> price ID (czk, usd, eur, gbp, ...) } 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(), StripeAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_API_KEY")), StripeWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_WEBHOOK_SECRET")), StripePriceMatrix: stripePriceMatrixFromEnv(), AdminEmail: strings.TrimSpace(os.Getenv("BOOKRA_ADMIN_EMAIL")), AdminKey: strings.TrimSpace(os.Getenv("BOOKRA_ADMIN_KEY")), UmamiAPIURL: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_URL")), UmamiAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_KEY")), SentryDSN: strings.TrimSpace(os.Getenv("BOOKRA_SENTRY_DSN")), DemoMode: boolFromEnv("BOOKRA_DEMO_MODE", false), SMSManagerAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_SMSMANAGER_API_KEY")), SMSManagerBaseURL: valueOrDefault("BOOKRA_SMSMANAGER_BASE_URL", "https://api.smsmngr.com/v2"), StripeSMSPriceMatrix: smsPriceMatrixFromEnv(), } 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 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 (cfg Config) StripeConfigured() bool { return strings.TrimSpace(cfg.StripeAPIKey) != "" } func (cfg Config) StripeWebhookConfigured() bool { return strings.TrimSpace(cfg.StripeWebhookKey) != "" } func (cfg Config) StripeCheckoutConfigured(planCode string) bool { planCode = shared.NormalizePlanCode(planCode) return cfg.StripeConfigured() && cfg.StripeWebhookConfigured() && cfg.StripePriceMatrix[planCode]["czk"] != "" && cfg.StripePriceMatrix[planCode]["usd"] != "" } func (cfg Config) BillingProvider() string { if cfg.StripeConfigured() { return "stripe" } return "paddle" } func (cfg Config) BillingConfigured() bool { return cfg.StripeConfigured() || cfg.PaddleConfigured() } func (cfg Config) BillingWebhookConfigured() bool { return cfg.StripeWebhookConfigured() || cfg.PaddleWebhookConfigured() } func (cfg Config) SMSConfigured() bool { return strings.TrimSpace(cfg.SMSManagerAPIKey) != "" } func (cfg Config) StripeSMSConfigured() bool { return cfg.StripeConfigured() && cfg.StripeSMSPriceMatrix["czk"] != "" } func (cfg Config) StripeSMSPriceID(currency string) string { c := strings.ToLower(strings.TrimSpace(currency)) if c == "" { c = "czk" } if id := cfg.StripeSMSPriceMatrix[c]; id != "" { return id } return cfg.StripeSMSPriceMatrix["czk"] } func paddlePriceMatrixFromEnv() map[string]map[string]string { matrix := map[string]map[string]string{ "starter": {}, "pro": {}, "business": {}, } for _, planCode := range []string{"starter", "pro", "business"} { envPlan := strings.ToUpper(strings.ReplaceAll(planCode, "-", "_")) matrix[planCode]["czk"] = strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_" + envPlan + "_CZK_PRICE_ID")) matrix[planCode]["usd"] = strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_" + envPlan + "_USD_PRICE_ID")) } return matrix } func stripePriceMatrixFromEnv() map[string]map[string]string { matrix := map[string]map[string]string{ "starter": {}, "pro": {}, "business": {}, } for _, planCode := range []string{"starter", "pro", "business"} { envPlan := strings.ToUpper(strings.ReplaceAll(planCode, "-", "_")) // Monthly prices matrix[planCode][planCode+":czk:monthly"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_MONTHLY_PRICE_ID")) matrix[planCode][planCode+":usd:monthly"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_MONTHLY_PRICE_ID")) matrix[planCode][planCode+":czk"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_PRICE_ID")) matrix[planCode][planCode+":usd"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_PRICE_ID")) matrix[planCode]["czk"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_PRICE_ID")) matrix[planCode]["usd"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_PRICE_ID")) // Yearly prices matrix[planCode][planCode+":czk:yearly"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_YEARLY_PRICE_ID")) matrix[planCode][planCode+":usd:yearly"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_YEARLY_PRICE_ID")) matrix[planCode]["yearly:czk"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_YEARLY_PRICE_ID")) matrix[planCode]["yearly:usd"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_YEARLY_PRICE_ID")) } return matrix } func normalizePaddleEnvironment(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "live", "production": 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 smsPriceMatrixFromEnv() map[string]string { matrix := map[string]string{} for _, currency := range []string{"czk", "usd", "eur", "gbp", "pln"} { upper := strings.ToUpper(currency) matrix[currency] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_SMS_" + upper + "_PRICE_ID")) } return matrix } 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 }