diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..11d0e86 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "stripe": { + "command": "sh", + "args": [ + "-c", + "set -a && [ -f .env ] && . ./.env; set +a; exec npx -y @stripe/mcp@latest" + ] + } + } +} diff --git a/docker/authelia/config/configuration.server.yml b/docker/authelia/config/configuration.server.yml index 0393bfb..c671ee9 100644 --- a/docker/authelia/config/configuration.server.yml +++ b/docker/authelia/config/configuration.server.yml @@ -58,8 +58,8 @@ session: remember_me: 1M cookies: - domain: {{ env "TRAEFIK_DOMAIN" }} - authelia_url: 'https://login.{{ env "TRAEFIK_DOMAIN" }}' - default_redirection_url: 'https://app.{{ env "TRAEFIK_DOMAIN" }}/dashboard' + authelia_url: 'https://{{ env "TRAEFIK_DOMAIN" }}/login' + default_redirection_url: 'https://{{ env "TRAEFIK_DOMAIN" }}/dashboard' name: 'authelia_session' same_site: 'lax' inactivity: '5m' diff --git a/docker/ss-atlas/internal/config/config.go b/docker/ss-atlas/internal/config/config.go index 59a903f..1aaac15 100644 --- a/docker/ss-atlas/internal/config/config.go +++ b/docker/ss-atlas/internal/config/config.go @@ -1,15 +1,27 @@ package config -import "os" +import ( + "fmt" + "os" + "strings" +) type Config struct { Port string AppURL string AutheliaURL string AutheliaInternalURL string - StripeSecretKey string + StripeSecretKey string StripeWebhookSecret string - StripePriceID string + StripePriceID string // Fallback when tier prices not set + StripePaymentLink string // Optional: legacy Payment Link for $0 + StripePriceIDFree string // $0/3mo, auto-cancel (first 10) + StripePriceIDYear string // $20/year (customers 11–50) + StripePriceIDMonth100 string // $100/month (after year for 11–50) + StripePriceIDMonth200 string // $200/month (customers 51+) + FreeTierLimit int // First N get free tier (default 10) + YearTierLimit int // Up to this count get year tier (default 50) + MaxSignups int // Cap on new signups (0 = no limit) LDAPUrl string LDAPAdminDN string LDAPAdminPassword string @@ -18,31 +30,45 @@ type Config struct { DockerHost string TraefikDomain string TraefikNetwork string - TemplatePath string - CustomerDomain string - ArchivePath string + TemplatePath string + CustomerDomain string + ArchivePath string + LandingTagline string // Main tagline under logo + LandingFeatures []string // Bullet points on subscribe card (comma-separated in env) } func Load() *Config { return &Config{ Port: envOrDefault("PORT", "8080"), - AppURL: envOrDefault("APP_URL", "http://app.bc.a250.ca"), - AutheliaURL: envOrDefault("AUTHELIA_URL", "http://login.bc.a250.ca"), - AutheliaInternalURL: envOrDefault("AUTHELIA_INTERNAL_URL", "http://authelia_dev_main:9091"), - StripeSecretKey: envOrDefault("STRIPE_SECRET_KEY", ""), - StripeWebhookSecret: envOrDefault("STRIPE_WEBHOOK_SECRET", ""), - StripePriceID: envOrDefault("STRIPE_PRICE_ID", ""), - LDAPUrl: envOrDefault("LLDAP_URL", "ldap://lldap_lldap:3890"), - LDAPAdminDN: envOrDefault("LLDAP_ADMIN_DN", "uid=admin,ou=people,dc=a250,dc=ca"), - LDAPAdminPassword: envOrDefault("LLDAP_ADMIN_PASSWORD", ""), - LDAPBaseDN: envOrDefault("LLDAP_BASE_DN", "dc=a250,dc=ca"), - LLDAPHttpURL: envOrDefault("LLDAP_HTTP_URL", "http://lldap:17170"), - DockerHost: envOrDefault("DOCKER_HOST", "unix:///var/run/docker.sock"), - TraefikDomain: envOrDefault("TRAEFIK_DOMAIN", "bc.a250.ca"), - TraefikNetwork: envOrDefault("TRAEFIK_NETWORK", "authelia_dev"), + AppURL: envOrDefault("APP_URL", "https://bc.a250.ca"), + AutheliaURL: envOrDefault("AUTHELIA_URL", "https://bc.a250.ca/login"), + AutheliaInternalURL: envOrDefault("AUTHELIA_INTERNAL_URL", "http://authelia_dev_main:9091/login"), + StripeSecretKey: envOrDefault("STRIPE_SECRET_KEY", ""), + StripeWebhookSecret: envOrDefault("STRIPE_WEBHOOK_SECRET", ""), + StripePriceID: envOrDefault("STRIPE_PRICE_ID", ""), + StripePaymentLink: envOrDefault("STRIPE_PAYMENT_LINK", ""), + StripePriceIDFree: envOrDefault("STRIPE_PRICE_ID_FREE", ""), + StripePriceIDYear: envOrDefault("STRIPE_PRICE_ID_YEAR", ""), + StripePriceIDMonth100: envOrDefault("STRIPE_PRICE_ID_MONTH_100", ""), + StripePriceIDMonth200: envOrDefault("STRIPE_PRICE_ID_MONTH_200", ""), + FreeTierLimit: envIntOrDefault("FREE_TIER_LIMIT", 10), + YearTierLimit: envIntOrDefault("YEAR_TIER_LIMIT", 50), + MaxSignups: envIntOrDefault("MAX_SIGNUPS", 0), + LDAPUrl: envOrDefault("LLDAP_URL", "ldap://lldap_lldap:3890"), + LDAPAdminDN: envOrDefault("LLDAP_ADMIN_DN", "uid=admin,ou=people,dc=a250,dc=ca"), + LDAPAdminPassword: envOrDefault("LLDAP_ADMIN_PASSWORD", ""), + LDAPBaseDN: envOrDefault("LLDAP_BASE_DN", "dc=a250,dc=ca"), + LLDAPHttpURL: envOrDefault("LLDAP_HTTP_URL", "http://lldap:17170"), + DockerHost: envOrDefault("DOCKER_HOST", "unix:///var/run/docker.sock"), + TraefikDomain: envOrDefault("TRAEFIK_DOMAIN", "bc.a250.ca"), + TraefikNetwork: envOrDefault("TRAEFIK_NETWORK", "authelia_dev"), TemplatePath: envOrDefault("TEMPLATE_PATH", "/app/templates"), - CustomerDomain: envOrDefault("CUSTOMER_DOMAIN", "app.a250.ca"), + CustomerDomain: envOrDefault("CUSTOMER_DOMAIN", "bc.a250.ca"), ArchivePath: envOrDefault("ARCHIVE_PATH", "/archives"), + LandingTagline: envOrDefault("LANDING_TAGLINE", + "Your own workspace, ready in minutes."), + LandingFeatures: envListOrDefault("LANDING_FEATURES", + []string{"Dedicated environment", "Secure single sign-on", "Automatic provisioning", "Manage subscription anytime"}), } } @@ -53,3 +79,29 @@ func envOrDefault(key, fallback string) string { return fallback } +func envIntOrDefault(key string, fallback int) int { + if v := os.Getenv(key); v != "" { + var n int + if _, err := fmt.Sscanf(v, "%d", &n); err == nil { + return n + } + } + return fallback +} + +func envListOrDefault(key string, fallback []string) []string { + if v := os.Getenv(key); v != "" { + parts := strings.Split(v, "|") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if s := strings.TrimSpace(p); s != "" { + out = append(out, s) + } + } + if len(out) > 0 { + return out + } + } + return fallback +} + diff --git a/docker/ss-atlas/internal/config/config_test.go b/docker/ss-atlas/internal/config/config_test.go index 787387a..4db4a17 100644 --- a/docker/ss-atlas/internal/config/config_test.go +++ b/docker/ss-atlas/internal/config/config_test.go @@ -26,8 +26,8 @@ func TestEnvOrDefault(t *testing.T) { func TestLoadDefaults(t *testing.T) { // Clear env vars that Load uses envKeys := []string{ - "PORT", "APP_URL", "AUTHELIA_URL", "STRIPE_SECRET_KEY", - "STRIPE_WEBHOOK_SECRET", "STRIPE_PRICE_ID", "LLDAP_URL", + "PORT", "APP_URL", "AUTHELIA_URL", "STRIPE_SECRET_KEY", + "STRIPE_WEBHOOK_SECRET", "STRIPE_PRICE_ID", "STRIPE_PAYMENT_LINK", "MAX_SIGNUPS", "LLDAP_URL", "LLDAP_ADMIN_DN", "LLDAP_ADMIN_PASSWORD", "LLDAP_BASE_DN", "DOCKER_HOST", "TRAEFIK_DOMAIN", "TRAEFIK_NETWORK", "TEMPLATE_PATH", "CUSTOMER_DOMAIN", } @@ -48,8 +48,8 @@ func TestLoadDefaults(t *testing.T) { want string }{ {"Port", cfg.Port, "8080"}, - {"AppURL", cfg.AppURL, "http://app.bc.a250.ca"}, - {"AutheliaURL", cfg.AutheliaURL, "http://login.bc.a250.ca"}, + {"AppURL", cfg.AppURL, "https://bc.a250.ca"}, + {"AutheliaURL", cfg.AutheliaURL, "https://bc.a250.ca/login"}, {"LDAPUrl", cfg.LDAPUrl, "ldap://lldap_lldap:3890"}, {"LDAPAdminDN", cfg.LDAPAdminDN, "uid=admin,ou=people,dc=a250,dc=ca"}, {"LDAPBaseDN", cfg.LDAPBaseDN, "dc=a250,dc=ca"}, @@ -57,13 +57,22 @@ func TestLoadDefaults(t *testing.T) { { "TraefikDomain", cfg.TraefikDomain, "bc.a250.ca"}, {"TraefikNetwork", cfg.TraefikNetwork, "authelia_dev"}, {"TemplatePath", cfg.TemplatePath, "/app/templates"}, - {"CustomerDomain", cfg.CustomerDomain, "app.a250.ca"}, + {"CustomerDomain", cfg.CustomerDomain, "bc.a250.ca"}, } for _, tt := range tests { if tt.got != tt.want { t.Errorf("Load().%s = %q, want %q", tt.field, tt.got, tt.want) } } + if cfg.MaxSignups != 0 { + t.Errorf("Load().MaxSignups = %d, want 0", cfg.MaxSignups) + } + if cfg.FreeTierLimit != 10 { + t.Errorf("Load().FreeTierLimit = %d, want 10", cfg.FreeTierLimit) + } + if cfg.YearTierLimit != 50 { + t.Errorf("Load().YearTierLimit = %d, want 50", cfg.YearTierLimit) + } } func TestLoadFromEnv(t *testing.T) { diff --git a/docker/ss-atlas/internal/handlers/dashboard.go b/docker/ss-atlas/internal/handlers/dashboard.go index 437bbf9..37189b4 100644 --- a/docker/ss-atlas/internal/handlers/dashboard.go +++ b/docker/ss-atlas/internal/handlers/dashboard.go @@ -27,6 +27,14 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) { stackDeployed := false stackRunning := false var subStatus *ssstripe.SubscriptionStatus + paidNotActivated := false + + if remoteUser != "" { + cid, _ := a.ldap.GetStripeCustomerID(remoteUser) + if cid != "" && !isSubscribed { + paidNotActivated = true + } + } if isSubscribed && remoteUser != "" { cid, err := a.ldap.GetStripeCustomerID(remoteUser) @@ -53,20 +61,27 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) { } } + customerDomain := "" + if remoteUser != "" { + customerDomain, _ = a.ldap.GetCustomerDomain(remoteUser) + } + data := map[string]any{ - "AppURL": a.cfg.AppURL, - "AutheliaURL": a.cfg.AutheliaURL, - "User": remoteUser, - "Email": remoteEmail, - "Groups": remoteGroups, - "Domain": a.cfg.TraefikDomain, - "IsSubscribed": isSubscribed, + "AppURL": a.cfg.AppURL, + "AutheliaURL": a.cfg.AutheliaURL, + "User": remoteUser, + "Email": remoteEmail, + "Groups": remoteGroups, + "Domain": a.cfg.TraefikDomain, + "IsSubscribed": isSubscribed, + "PaidNotActivated": paidNotActivated, "CustomerID": customerID, "SubStatus": subStatus, - "StackDeployed": stackDeployed, - "StackRunning": stackRunning, - "Commit": version.Commit, - "BuildTime": version.BuildTime, + "StackDeployed": stackDeployed, + "StackRunning": stackRunning, + "CustomerDomain": customerDomain, + "Commit": version.Commit, + "BuildTime": version.BuildTime, } if err := a.tmpl.ExecuteTemplate(w, "dashboard.html", data); err != nil { diff --git a/docker/ss-atlas/internal/handlers/handlers_test.go b/docker/ss-atlas/internal/handlers/handlers_test.go index 214d229..9377cd0 100644 --- a/docker/ss-atlas/internal/handlers/handlers_test.go +++ b/docker/ss-atlas/internal/handlers/handlers_test.go @@ -56,13 +56,14 @@ func TestSanitizeUsername(t *testing.T) { email string want string }{ - {"user@example.com", "user"}, - {"User.Name@domain.com", "user-name"}, - {"user_name@domain.com", "user_name"}, - {"user123@domain.com", "user123"}, - {"UPPER@domain.com", "upper"}, - {"a.b.c@x.com", "a-b-c"}, - {"spécial@x.com", "sp-cial"}, + {"user@example.com", "user-example"}, + {"User.Name@domain.com", "user-name-domain"}, + {"user_name@domain.com", "user_name-domain"}, + {"user123@domain.com", "user123-domain"}, + {"UPPER@domain.com", "upper-domain"}, + {"a.b.c@x.com", "a-b-c-x"}, + {"spécial@x.com", "sp-cial-x"}, + {"alice@nixc.us", "alice-nixc"}, } for _, tt := range tests { if got := sanitizeUsername(tt.email); got != tt.want { diff --git a/docker/ss-atlas/internal/handlers/subscription.go b/docker/ss-atlas/internal/handlers/subscription.go index 2cffa8e..cc44bb4 100644 --- a/docker/ss-atlas/internal/handlers/subscription.go +++ b/docker/ss-atlas/internal/handlers/subscription.go @@ -1,11 +1,16 @@ package handlers import ( + "errors" "fmt" "log" "net/http" "strings" + "time" + "git.nixc.us/a250/ss-atlas/internal/pricing" + "git.nixc.us/a250/ss-atlas/internal/stripe" + "git.nixc.us/a250/ss-atlas/internal/validation" "git.nixc.us/a250/ss-atlas/internal/version" ) @@ -26,10 +31,22 @@ func (a *App) handleLanding(w http.ResponseWriter, r *http.Request) { } } + count, _ := a.ldap.CountCustomers() + soldOut := a.cfg.MaxSignups > 0 && count >= a.cfg.MaxSignups + tier := pricing.ForCustomer(count, a.cfg.FreeTierLimit, a.cfg.YearTierLimit) + + useForm := a.cfg.StripePriceID != "" || a.cfg.StripePriceIDFree != "" || + a.cfg.StripePriceIDYear != "" || a.cfg.StripePriceIDMonth200 != "" data := map[string]any{ - "AppURL": a.cfg.AppURL, - "Commit": version.Commit, - "BuildTime": version.BuildTime, + "AppURL": a.cfg.AppURL, + "Commit": version.Commit, + "BuildTime": version.BuildTime, + "StripePaymentLink": a.cfg.StripePaymentLink, + "SoldOut": soldOut, + "PricingTier": int(tier), + "UseCheckoutForm": useForm, + "Tagline": a.cfg.LandingTagline, + "Features": a.cfg.LandingFeatures, } if err := a.tmpl.ExecuteTemplate(w, "landing.html", data); err != nil { log.Printf("template error: %v", err) @@ -38,14 +55,43 @@ func (a *App) handleLanding(w http.ResponseWriter, r *http.Request) { } func (a *App) handleCreateCheckout(w http.ResponseWriter, r *http.Request) { - email := r.FormValue("email") + if a.cfg.MaxSignups > 0 { + count, err := a.ldap.CountCustomers() + if err == nil && count >= a.cfg.MaxSignups { + http.Error(w, "signup limit reached, try again later", http.StatusServiceUnavailable) + return + } + } + + rawEmail := r.FormValue("email") + email := validation.SanitizeEmail(rawEmail) if email == "" { - http.Error(w, "email required", http.StatusBadRequest) + http.Error(w, "valid email required", http.StatusBadRequest) + return + } + domain := strings.TrimSpace(r.FormValue("domain")) + if domain == "" { + http.Error(w, "domain required", http.StatusBadRequest) + return + } + if !validation.DomainResolves(domain, 5*time.Second) { + http.Error(w, "domain does not resolve; please enter a valid domain", http.StatusBadRequest) + return + } + rawPhone := r.FormValue("phone") + phone := validation.SanitizePhone(rawPhone) + if phone == "" { + http.Error(w, "valid phone number required", http.StatusBadRequest) return } - sess, err := a.stripe.CreateCheckoutSession(email) + count, _ := a.ldap.CountCustomers() + sess, err := a.stripe.CreateCheckoutSession(email, domain, phone, count) if err != nil { + if errors.Is(err, stripe.ErrNoPriceForTier) { + http.Error(w, "pricing not configured for current tier", http.StatusServiceUnavailable) + return + } log.Printf("stripe checkout error: %v", err) http.Error(w, "failed to create checkout", http.StatusInternalServerError) return @@ -61,6 +107,14 @@ func (a *App) handleSuccess(w http.ResponseWriter, r *http.Request) { return } + if a.cfg.MaxSignups > 0 { + count, err := a.ldap.CountCustomers() + if err == nil && count >= a.cfg.MaxSignups { + http.Error(w, "signup limit reached, contact support", http.StatusServiceUnavailable) + return + } + } + sess, err := a.stripe.GetCheckoutSession(sessionID) if err != nil { log.Printf("stripe get session error: %v", err) @@ -76,32 +130,32 @@ func (a *App) handleSuccess(w http.ResponseWriter, r *http.Request) { email := sess.CustomerDetails.Email customerID := sess.Customer.ID username := sanitizeUsername(email) + phone := "" + if sess.Metadata != nil { + phone = sess.Metadata["customer_phone"] + } - result, err := a.ldap.ProvisionUser(username, email, customerID) + result, err := a.ldap.ProvisionUser(username, email, customerID, phone) if err != nil { log.Printf("ldap provision failed for %s: %v", email, err) http.Error(w, "account creation failed, contact support", http.StatusInternalServerError) return } + if sess.Metadata != nil && sess.Metadata["customer_domain"] != "" { + if err := a.ldap.SetCustomerDomain(result.Username, sess.Metadata["customer_domain"]); err != nil { + log.Printf("ldap set customer domain failed for %s: %v", result.Username, err) + } + } + inGroup, _ := a.ldap.IsInGroup(result.Username, "customers") if result.IsNew || !inGroup { - // New or lapsed customer: send password setup email, show onboarding. - // Group membership and stack deploy happen on /activate after they log in. + // New or lapsed: send password email, show success page. if err := a.triggerPasswordReset(result.Username); err != nil { log.Printf("authelia reset trigger failed for %s: %v", username, err) } - data := map[string]any{ - "Username": result.Username, - "IsNew": result.IsNew, - "Email": email, - "LoginURL": a.cfg.AutheliaURL, - "ActivateURL": a.cfg.AppURL + "/activate", - "DashboardURL": a.cfg.AppURL + "/dashboard", - "InstanceURL": "https://" + result.Username + "." + a.cfg.CustomerDomain, - } - if err := a.tmpl.ExecuteTemplate(w, "welcome.html", data); err != nil { + if err := a.tmpl.ExecuteTemplate(w, "success.html", map[string]any{"AppURL": a.cfg.AppURL}); err != nil { log.Printf("template error: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) } @@ -150,8 +204,13 @@ func (a *App) handleResubscribe(w http.ResponseWriter, r *http.Request) { return } - sess, err := a.stripe.CreateCheckoutForCustomer(customerID) + count, _ := a.ldap.CountCustomers() + sess, err := a.stripe.CreateCheckoutForCustomer(customerID, count) if err != nil { + if errors.Is(err, stripe.ErrNoPriceForTier) { + http.Error(w, "pricing not configured for current tier", http.StatusServiceUnavailable) + return + } log.Printf("stripe resubscribe error: %v", err) http.Error(w, "failed to create checkout session", http.StatusInternalServerError) return diff --git a/docker/ss-atlas/internal/handlers/webhook.go b/docker/ss-atlas/internal/handlers/webhook.go index 6e36c63..df06152 100644 --- a/docker/ss-atlas/internal/handlers/webhook.go +++ b/docker/ss-atlas/internal/handlers/webhook.go @@ -34,6 +34,8 @@ func (a *App) handleWebhook(w http.ResponseWriter, r *http.Request) { a.onSubscriptionDeleted(event) case "customer.subscription.updated": a.onSubscriptionUpdated(event) + case "invoice.paid": + a.onInvoicePaid(event) default: log.Printf("unhandled webhook event: %s", event.Type) } @@ -42,8 +44,7 @@ func (a *App) handleWebhook(w http.ResponseWriter, r *http.Request) { } // Reconciliation backstop: ensures LLDAP user + Stripe ID are set. -// Does NOT send password reset — that's the success page's responsibility -// so it can reliably show the welcome/onboarding page. +// Does NOT send password reset — that's the success page's responsibility. func (a *App) onCheckoutCompleted(event stripego.Event) { var sess stripego.CheckoutSession if err := json.Unmarshal(event.Data.Raw, &sess); err != nil { @@ -54,12 +55,48 @@ func (a *App) onCheckoutCompleted(event stripego.Event) { email := sess.CustomerDetails.Email customerID := sess.Customer.ID username := sanitizeUsername(email) + phone := "" + if sess.Metadata != nil { + phone = sess.Metadata["customer_phone"] + } log.Printf("webhook: checkout completed email=%s customer=%s", email, customerID) - if err := a.ldap.EnsureUser(username, email, customerID); err != nil { + if a.cfg.MaxSignups > 0 { + count, err := a.ldap.CountCustomers() + if err == nil && count >= a.cfg.MaxSignups { + log.Printf("webhook: signup limit reached (%d), skipping provision for %s", a.cfg.MaxSignups, email) + return + } + } + + if err := a.ldap.EnsureUser(username, email, customerID, phone); err != nil { log.Printf("webhook: ldap ensure user failed: %v", err) } + if sess.Metadata != nil { + if d := sess.Metadata["customer_domain"]; d != "" { + if err := a.ldap.SetCustomerDomain(username, d); err != nil { + log.Printf("webhook: ldap set customer domain failed for %s: %v", username, err) + } + } + } + + subID := "" + if sess.Subscription != nil && sess.Subscription.ID != "" { + subID = sess.Subscription.ID + } else { + var raw struct { + Subscription interface{} `json:"subscription"` + } + if json.Unmarshal(event.Data.Raw, &raw) == nil { + if s, ok := raw.Subscription.(string); ok { + subID = s + } + } + } + if subID != "" { + a.maybeScheduleFreeTierCancel(subID) + } } func (a *App) onSubscriptionDeleted(event stripego.Event) { diff --git a/docker/ss-atlas/internal/handlers/webhook_invoice.go b/docker/ss-atlas/internal/handlers/webhook_invoice.go new file mode 100644 index 0000000..7a47376 --- /dev/null +++ b/docker/ss-atlas/internal/handlers/webhook_invoice.go @@ -0,0 +1,96 @@ +package handlers + +import ( + "encoding/json" + "log" + "time" + + stripego "github.com/stripe/stripe-go/v84" + "github.com/stripe/stripe-go/v84/subscription" +) + +const freeTierMonths = 3 + +// maybeScheduleFreeTierCancel fetches the subscription and, if it uses the free-tier +// price, sets cancel_at to 3 months from now so it auto-cancels at end of term. +func (a *App) maybeScheduleFreeTierCancel(subID string) { + sub, err := a.stripe.GetSubscription(subID) + if err != nil { + log.Printf("webhook: could not fetch subscription %s: %v", subID, err) + return + } + if sub.Items == nil || len(sub.Items.Data) == 0 { + return + } + priceID := sub.Items.Data[0].Price.ID + if !a.stripe.IsFreeTierPrice(priceID) { + return + } + cancelAt := time.Now().AddDate(0, freeTierMonths, 0).Unix() + if err := a.stripe.ScheduleSubscriptionCancelAt(subID, cancelAt); err != nil { + log.Printf("webhook: failed to schedule free-tier cancel for sub %s: %v", subID, err) + return + } + log.Printf("webhook: scheduled free-tier sub %s to cancel at %d (3 months)", subID, cancelAt) +} + +type invoicePaidPayload struct { + BillingReason stripego.InvoiceBillingReason `json:"billing_reason"` + Subscription interface{} `json:"subscription"` + Lines struct { + Data []struct { + Price struct { + ID string `json:"id"` + } `json:"price"` + } `json:"data"` + } `json:"lines"` +} + +func (a *App) onInvoicePaid(event stripego.Event) { + var raw invoicePaidPayload + if err := json.Unmarshal(event.Data.Raw, &raw); err != nil { + log.Printf("invoice unmarshal error: %v", err) + return + } + if raw.BillingReason != stripego.InvoiceBillingReasonSubscriptionCycle { + return + } + subID := "" + switch v := raw.Subscription.(type) { + case string: + subID = v + case map[string]interface{}: + if id, ok := v["id"].(string); ok { + subID = id + } + } + if subID == "" || len(raw.Lines.Data) == 0 { + return + } + priceID := raw.Lines.Data[0].Price.ID + if priceID == "" || !a.stripe.IsYearTierPrice(priceID) { + return + } + price100 := a.cfg.StripePriceIDMonth100 + if price100 == "" { + log.Printf("webhook: STRIPE_PRICE_ID_MONTH_100 not set, cannot migrate sub %s", subID) + return + } + sub, err := a.stripe.GetSubscription(subID) + if err != nil || sub.Items == nil || len(sub.Items.Data) == 0 { + log.Printf("webhook: could not get subscription %s for migration: %v", subID, err) + return + } + subItemID := sub.Items.Data[0].ID + params := &stripego.SubscriptionParams{ + Items: []*stripego.SubscriptionItemsParams{ + {ID: stripego.String(subItemID), Price: stripego.String(price100)}, + }, + ProrationBehavior: stripego.String("none"), + } + if _, err := subscription.Update(subID, params); err != nil { + log.Printf("webhook: failed to migrate sub %s to $100/mo: %v", subID, err) + return + } + log.Printf("webhook: migrated sub %s from $20/year to $100/month", subID) +} diff --git a/docker/ss-atlas/internal/ldap/client.go b/docker/ss-atlas/internal/ldap/client.go index 33dd805..7869fc7 100644 --- a/docker/ss-atlas/internal/ldap/client.go +++ b/docker/ss-atlas/internal/ldap/client.go @@ -42,7 +42,7 @@ func (c *Client) connect() (*goldap.Conn, error) { return conn, nil } -func (c *Client) ProvisionUser(username, email, stripeCustomerID string) (*ProvisionResult, error) { +func (c *Client) ProvisionUser(username, email, stripeCustomerID, phone string) (*ProvisionResult, error) { conn, err := c.connect() if err != nil { return nil, err @@ -55,6 +55,9 @@ func (c *Client) ProvisionUser(username, email, stripeCustomerID string) (*Provi } if exists { log.Printf("ldap user %s already exists", username) + if phone != "" { + _ = c.SetCustomerPhone(username, phone) + } return &ProvisionResult{Username: username, IsNew: false}, nil } @@ -67,6 +70,9 @@ func (c *Client) ProvisionUser(username, email, stripeCustomerID string) (*Provi addReq.Attribute("sn", []string{username}) addReq.Attribute("uid", []string{username}) addReq.Attribute("mail", []string{email}) + if phone != "" { + addReq.Attribute("telephoneNumber", []string{phone}) + } if err := conn.Add(addReq); err != nil { return nil, fmt.Errorf("ldap add user %s: %w", username, err) @@ -82,13 +88,18 @@ func (c *Client) ProvisionUser(username, email, stripeCustomerID string) (*Provi log.Printf("warning: failed to set stripe customer id for %s: %v", username, err) } } + if phone != "" { + if err := c.SetCustomerPhone(username, phone); err != nil { + log.Printf("warning: failed to set customer phone for %s: %v", username, err) + } + } log.Printf("created ldap user %s (%s)", username, email) return &ProvisionResult{Username: username, Password: password, IsNew: true}, nil } -func (c *Client) EnsureUser(username, email, stripeCustomerID string) error { - _, err := c.ProvisionUser(username, email, stripeCustomerID) +func (c *Client) EnsureUser(username, email, stripeCustomerID, phone string) error { + _, err := c.ProvisionUser(username, email, stripeCustomerID, phone) return err } @@ -159,6 +170,59 @@ func (c *Client) SetStripeCustomerID(username, customerID string) error { return err } +func (c *Client) SetCustomerPhone(username, phone string) error { + if phone == "" { + return nil + } + query := `mutation($userId: String!, $attrs: [AttributeValueInput!]!) { + updateUser(user: { id: $userId, insertAttributes: $attrs }) { ok } + }` + attrs := []map[string]any{ + {"name": "customer-phone", "value": []string{phone}}, + } + _, err := c.gql.exec(query, map[string]any{"userId": username, "attrs": attrs}) + return err +} + +func (c *Client) SetCustomerDomain(username, domain string) error { + if domain == "" { + return nil + } + query := `mutation($userId: String!, $attrs: [AttributeValueInput!]!) { + updateUser(user: { id: $userId, insertAttributes: $attrs }) { ok } + }` + attrs := []map[string]any{ + {"name": "customer-domain", "value": []string{domain}}, + } + _, err := c.gql.exec(query, map[string]any{"userId": username, "attrs": attrs}) + return err +} + +func (c *Client) GetCustomerDomain(username string) (string, error) { + query := `query($userId: String!) { user(userId: $userId) { attributes { name value } } }` + data, err := c.gql.exec(query, map[string]any{"userId": username}) + if err != nil { + return "", err + } + var result struct { + User struct { + Attributes []struct { + Name string `json:"name"` + Value []string `json:"value"` + } `json:"attributes"` + } `json:"user"` + } + if err := json.Unmarshal(data, &result); err != nil { + return "", err + } + for _, attr := range result.User.Attributes { + if attr.Name == "customer-domain" && len(attr.Value) > 0 { + return attr.Value[0], nil + } + } + return "", nil +} + func (c *Client) GetStripeCustomerID(username string) (string, error) { query := `query($userId: String!) { user(userId: $userId) { attributes { name value } } }` data, err := c.gql.exec(query, map[string]any{"userId": username}) @@ -216,6 +280,39 @@ func (c *Client) FindUserByStripeID(stripeCustomerID string) (string, error) { return "", fmt.Errorf("no user found with stripe customer %s", stripeCustomerID) } +// CountCustomers returns the number of users who have completed checkout (have stripe-customer-id). +// Used to enforce signup limits. +func (c *Client) CountCustomers() (int, error) { + query := `query { users(filters: {}) { id attributes { name value } } }` + data, err := c.gql.exec(query, nil) + if err != nil { + return 0, err + } + + var result struct { + Users []struct { + Attributes []struct { + Name string `json:"name"` + Value []string `json:"value"` + } `json:"attributes"` + } `json:"users"` + } + if err := json.Unmarshal(data, &result); err != nil { + return 0, err + } + + n := 0 + for _, u := range result.Users { + for _, attr := range u.Attributes { + if attr.Name == "stripe-customer-id" && len(attr.Value) > 0 && attr.Value[0] != "" { + n++ + break + } + } + } + return n, nil +} + func (c *Client) getGroupID(groupName string) (int, error) { query := `query { groups { id displayName } }` data, err := c.gql.exec(query, nil) diff --git a/docker/ss-atlas/internal/pricing/tiers.go b/docker/ss-atlas/internal/pricing/tiers.go new file mode 100644 index 0000000..d1cc88e --- /dev/null +++ b/docker/ss-atlas/internal/pricing/tiers.go @@ -0,0 +1,22 @@ +package pricing + +// Tier represents a pricing tier for new signups. +type Tier int + +const ( + TierFree Tier = iota // First 10: $0 for 3 months, auto-cancel + TierYear // Next 40: $20/year, then $100/month after + TierPremium // After 50: $200/month +) + +// ForCustomer returns the tier for a new signup given current customer count. +// Count is the number of customers who have completed checkout (0-indexed slot). +func ForCustomer(count int, freeLimit, yearLimit int) Tier { + if count < freeLimit { + return TierFree + } + if count < yearLimit { + return TierYear + } + return TierPremium +} diff --git a/docker/ss-atlas/internal/stripe/client.go b/docker/ss-atlas/internal/stripe/client.go index 20ea8ce..14b06b2 100644 --- a/docker/ss-atlas/internal/stripe/client.go +++ b/docker/ss-atlas/internal/stripe/client.go @@ -1,16 +1,21 @@ package stripe import ( + "errors" "log" + "strconv" "time" "git.nixc.us/a250/ss-atlas/internal/config" + "git.nixc.us/a250/ss-atlas/internal/pricing" stripego "github.com/stripe/stripe-go/v84" portalsession "github.com/stripe/stripe-go/v84/billingportal/session" checkoutsession "github.com/stripe/stripe-go/v84/checkout/session" "github.com/stripe/stripe-go/v84/subscription" ) +var ErrNoPriceForTier = errors.New("no Stripe price configured for this tier") + type SubscriptionStatus struct { Label string // "Active", "Cancels soon", etc. Badge string // "badge-active", "badge-inactive", etc. @@ -26,33 +31,63 @@ func New(cfg *config.Config) *Client { return &Client{cfg: cfg} } -func (c *Client) CreateCheckoutSession(email string) (*stripego.CheckoutSession, error) { +func (c *Client) priceForTier(t pricing.Tier) string { + switch t { + case pricing.TierFree: + if c.cfg.StripePriceIDFree != "" { + return c.cfg.StripePriceIDFree + } + case pricing.TierYear: + if c.cfg.StripePriceIDYear != "" { + return c.cfg.StripePriceIDYear + } + case pricing.TierPremium: + if c.cfg.StripePriceIDMonth200 != "" { + return c.cfg.StripePriceIDMonth200 + } + } + return c.cfg.StripePriceID +} + +func (c *Client) CreateCheckoutSession(email, customerDomain, customerPhone string, customerCount int) (*stripego.CheckoutSession, error) { + t := pricing.ForCustomer(customerCount, c.cfg.FreeTierLimit, c.cfg.YearTierLimit) + priceID := c.priceForTier(t) + if priceID == "" { + return nil, ErrNoPriceForTier + } + params := &stripego.CheckoutSessionParams{ Mode: stripego.String(string(stripego.CheckoutSessionModeSubscription)), LineItems: []*stripego.CheckoutSessionLineItemParams{ - { - Price: stripego.String(c.cfg.StripePriceID), - Quantity: stripego.Int64(1), - }, + {Price: stripego.String(priceID), Quantity: stripego.Int64(1)}, }, CustomerEmail: stripego.String(email), SuccessURL: stripego.String(c.cfg.AppURL + "/success?session_id={CHECKOUT_SESSION_ID}"), CancelURL: stripego.String(c.cfg.AppURL + "/"), } + if customerDomain != "" { + params.AddMetadata("customer_domain", customerDomain) + } + if customerPhone != "" { + params.AddMetadata("customer_phone", customerPhone) + } + params.AddMetadata("pricing_tier", strconv.Itoa(int(t))) return checkoutsession.New(params) } // CreateCheckoutForCustomer creates a new subscription checkout for an existing -// Stripe customer (e.g. resubscribe after expiry). The new sub is linked to the -// same customer record so payment methods and history are preserved. -func (c *Client) CreateCheckoutForCustomer(customerID string) (*stripego.CheckoutSession, error) { +// Stripe customer (e.g. resubscribe after expiry). Uses current tier by customer count. +func (c *Client) CreateCheckoutForCustomer(customerID string, customerCount int) (*stripego.CheckoutSession, error) { + t := pricing.ForCustomer(customerCount, c.cfg.FreeTierLimit, c.cfg.YearTierLimit) + priceID := c.priceForTier(t) + if priceID == "" { + return nil, ErrNoPriceForTier + } + params := &stripego.CheckoutSessionParams{ Mode: stripego.String(string(stripego.CheckoutSessionModeSubscription)), LineItems: []*stripego.CheckoutSessionLineItemParams{ - { - Price: stripego.String(c.cfg.StripePriceID), - Quantity: stripego.Int64(1), - }, + {Price: stripego.String(priceID), Quantity: stripego.Int64(1)}, }, Customer: stripego.String(customerID), SuccessURL: stripego.String(c.cfg.AppURL + "/success?session_id={CHECKOUT_SESSION_ID}"), @@ -80,6 +115,23 @@ func (c *Client) GetSubscription(subID string) (*stripego.Subscription, error) { return subscription.Get(subID, nil) } +// ScheduleSubscriptionCancelAt sets the subscription to cancel at the given unix timestamp. +// Used for free-tier subs that should auto-cancel after 3 months. +func (c *Client) ScheduleSubscriptionCancelAt(subID string, cancelAt int64) error { + _, err := subscription.Update(subID, &stripego.SubscriptionParams{ + CancelAt: stripego.Int64(cancelAt), + }) + return err +} + +func (c *Client) IsFreeTierPrice(priceID string) bool { + return c.cfg.StripePriceIDFree != "" && priceID == c.cfg.StripePriceIDFree +} + +func (c *Client) IsYearTierPrice(priceID string) bool { + return c.cfg.StripePriceIDYear != "" && priceID == c.cfg.StripePriceIDYear +} + func (c *Client) GetCustomerSubscriptionStatus(customerID string) *SubscriptionStatus { if customerID == "" { return &SubscriptionStatus{Label: "Active", Badge: "badge-active"} diff --git a/docker/ss-atlas/internal/validation/customer.go b/docker/ss-atlas/internal/validation/customer.go new file mode 100644 index 0000000..32dd97d --- /dev/null +++ b/docker/ss-atlas/internal/validation/customer.go @@ -0,0 +1,39 @@ +package validation + +import ( + "regexp" + "strings" +) + +var emailRe = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`) + +// SanitizeEmail trims, lowercases, and validates email format. Returns empty if invalid. +func SanitizeEmail(input string) string { + s := strings.TrimSpace(strings.ToLower(input)) + if s == "" || !emailRe.MatchString(s) || len(s) > 254 { + return "" + } + return s +} + +// SanitizePhone normalizes to E.164-like format: + followed by 10-15 digits. +// Accepts (555) 123-4567, +1 555 123 4567, etc. +func SanitizePhone(input string) string { + var digits strings.Builder + hasPlus := false + for _, r := range input { + if r == '+' { + hasPlus = true + } else if r >= '0' && r <= '9' { + digits.WriteRune(r) + } + } + d := digits.String() + if len(d) < 10 || len(d) > 15 { + return "" + } + if hasPlus { + return "+" + d + } + return "+" + d +} diff --git a/docker/ss-atlas/internal/validation/domain.go b/docker/ss-atlas/internal/validation/domain.go new file mode 100644 index 0000000..0a81290 --- /dev/null +++ b/docker/ss-atlas/internal/validation/domain.go @@ -0,0 +1,42 @@ +package validation + +import ( + "context" + "net" + "net/url" + "strings" + "time" +) + +// DomainResolves checks that the given domain or URL has a DNS record (A or AAAA). +// Input can be "git.example.com" or "https://git.example.com". +func DomainResolves(input string, timeout time.Duration) bool { + host := extractHost(input) + if host == "" { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + var r net.Resolver + addrs, err := r.LookupHost(ctx, host) + return err == nil && len(addrs) > 0 +} + +func extractHost(input string) string { + s := strings.TrimSpace(strings.ToLower(input)) + if s == "" { + return "" + } + if !strings.Contains(s, "//") { + s = "//" + s + } + u, err := url.Parse(s) + if err != nil { + return "" + } + host := u.Hostname() + if host == "" || host == "." { + return "" + } + return host +} diff --git a/docker/ss-atlas/templates/pages/dashboard.html b/docker/ss-atlas/templates/pages/dashboard.html index 4a42827..3071b68 100644 --- a/docker/ss-atlas/templates/pages/dashboard.html +++ b/docker/ss-atlas/templates/pages/dashboard.html @@ -181,9 +181,15 @@ Not deployed {{end}} + {{if .CustomerDomain}} +
Your dedicated environment is accessible at:
- {{.User}}.{{.Domain}} + {{.Domain}}/i/{{.User}} {{else if and .StackDeployed (not .StackRunning)}}Your stack is stopped. Start it to access your environment.
{{end}} @@ -249,6 +255,14 @@ Change Password + {{else if .PaidNotActivated}} +You've completed checkout. Click below to provision your dedicated environment.
+ Activate Now +Sign in to access your dashboard.
+Check your email for the password setup link. Once you've set your password and signed in, you can activate your stack here.
Sign In {{end}}Your own managed infrastructure stack, provisioned instantly when you subscribe.
+{{.Tagline}}
We've reached our limit for new signups. Check back later.
+ {{else if and .StripePaymentLink (eq .PricingTier 0) (not .UseCheckoutForm)}} +Pricing is being configured. Check back soon.
+ {{end}}