// Onboarding flow: signup → profile → plan → checkout // // Session is a signed cookie containing the in-progress onboarding state. // HMAC-SHA256 keyed with ONBOARDING_SECRET (auto-generated if missing). package main import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "log" "net/http" "os" "strings" "time" ) const onboardingCookieName = "clv_onb" const onboardingCookieMaxAge = 3600 // 1 hour to complete the flow // onboardingSecret is the HMAC key for signing the session cookie. // Loaded from env or generated at startup (sessions are short-lived so // regeneration on restart only kicks people back to step 1). var onboardingSecret []byte func initOnboardingSecret() { if s := readEnv("ONBOARDING_SECRET"); s != "" { onboardingSecret = []byte(s) return } buf := make([]byte, 32) rand.Read(buf) onboardingSecret = buf log.Println("onboarding: generated ephemeral session secret (set ONBOARDING_SECRET to persist)") } // onboardingState is what we carry across pages in the signed cookie. // Kept small — Paddle billing fields are written to corporate.db immediately. type onboardingState struct { Email string `json:"email"` Provider string `json:"provider"` // "email", "google", "apple", "meta" CustomerID string `json:"customer_id,omitempty"` // ctm_xxx after step 1 AddressID string `json:"address_id,omitempty"` // add_xxx after step 2 CreatedAt int64 `json:"created_at"` } func setOnboardingCookie(w http.ResponseWriter, st *onboardingState) { st.CreatedAt = time.Now().Unix() payload, _ := json.Marshal(st) encoded := base64.RawURLEncoding.EncodeToString(payload) mac := hmac.New(sha256.New, onboardingSecret) mac.Write([]byte(encoded)) sig := hex.EncodeToString(mac.Sum(nil)) value := encoded + "." + sig http.SetCookie(w, &http.Cookie{ Name: onboardingCookieName, Value: value, Path: "/", MaxAge: onboardingCookieMaxAge, HttpOnly: true, Secure: !devMode, SameSite: http.SameSiteLaxMode, }) } func getOnboardingState(r *http.Request) *onboardingState { c, err := r.Cookie(onboardingCookieName) if err != nil { return nil } parts := strings.SplitN(c.Value, ".", 2) if len(parts) != 2 { return nil } mac := hmac.New(sha256.New, onboardingSecret) mac.Write([]byte(parts[0])) expected := hex.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(expected), []byte(parts[1])) { return nil } payload, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { return nil } var st onboardingState if err := json.Unmarshal(payload, &st); err != nil { return nil } // Expire stale sessions if time.Now().Unix()-st.CreatedAt > onboardingCookieMaxAge { return nil } return &st } func clearOnboardingCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: onboardingCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: !devMode, SameSite: http.SameSiteLaxMode, }) } // readEnv is a thin wrapper to make testing easier later. func readEnv(key string) string { return os.Getenv(key) } // --- handlers --- // handleSignupStart: GET /signup — renders the email/social choice page. // (Wired in main.go via the existing /signup route.) // handleSignupEmail: POST /onboarding/email — user typed an email address. // Creates the customer in DB+Paddle, sets the cookie, redirects to /onboarding/profile. func handleSignupEmail(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "method not allowed", 405) return } if corpDB == nil { jsonError(w, "onboarding offline (corporate.db not available)", 503) return } var req struct { Email string `json:"email"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, "invalid json", 400) return } email := strings.ToLower(strings.TrimSpace(req.Email)) if !looksLikeEmail(email) { jsonError(w, "invalid email", 400) return } // If a customer with this email exists, reuse it (resume onboarding). ourID, _, found := findCustomerByEmail(email) if !found { res := tlwCreateCustomer(map[string]interface{}{"email": email}) if res.Err != nil { log.Printf("onboarding: tlwCreateCustomer failed for %s: %v", email, res.Err) jsonError(w, "could not create account", 500) return } ourID = res.OurID } setOnboardingCookie(w, &onboardingState{ Email: email, Provider: "email", CustomerID: ourID, }) w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"ok":true,"next":"/onboarding/profile"}`)) } // handleOnboardingProfile: POST /onboarding/profile — name + address. // Updates the customer name and creates the billing address. func handleOnboardingProfile(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "method not allowed", 405) return } st := getOnboardingState(r) if st == nil || st.CustomerID == "" { jsonError(w, "session expired", 401) return } var req struct { Name string `json:"name"` Address1 string `json:"address1"` City string `json:"city"` Region string `json:"region"` Zip string `json:"zip"` CountryCode string `json:"country_code"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, "invalid json", 400) return } if req.Name == "" || req.CountryCode == "" { jsonError(w, "name and country are required", 400) return } if len(req.CountryCode) != 2 { jsonError(w, "country must be a 2-letter ISO code", 400) return } // Update the customer name in our DB. (Paddle update is best-effort: // the customer was created with email only; we PATCH it on the next // /customers/{id} call. For now, just update locally — Paddle picks up // the name when we send the address creation.) corpDB.Exec(`UPDATE customers SET name = ?, updated_at = ? WHERE id = ?`, req.Name, time.Now().Unix(), st.CustomerID) // Create the address via TLW (writes to DB + Paddle). addr := map[string]interface{}{ "customer_id": st.CustomerID, "country_code": strings.ToUpper(req.CountryCode), } if req.City != "" { addr["city"] = req.City } if req.Region != "" { addr["region"] = req.Region } if req.Zip != "" { addr["zip_code"] = req.Zip } if req.Address1 != "" { addr["description"] = req.Address1 } res := tlwCreateAddress(addr) if res.Err != nil { log.Printf("onboarding: tlwCreateAddress failed: %v", res.Err) jsonError(w, "could not save address", 500) return } st.AddressID = res.OurID setOnboardingCookie(w, st) w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"ok":true,"next":"/onboarding/plan"}`)) } // handleOnboardingPlan: POST /onboarding/plan — user picked a subscription tier. // Looks up the Paddle price ID, creates a transaction, returns the checkout URL. func handleOnboardingPlan(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "method not allowed", 405) return } st := getOnboardingState(r) if st == nil || st.CustomerID == "" || st.AddressID == "" { jsonError(w, "session expired", 401) return } var req struct { Plan string `json:"plan"` // e.g. "personal", "family", "pro" } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, "invalid json", 400) return } priceID := lookupPriceID(req.Plan, "usd") if priceID == "" { jsonError(w, "unknown plan", 400) return } // Need the Paddle IDs for both customer and address to create a transaction. var paddleCust, paddleAddr string corpDB.QueryRow(`SELECT COALESCE(paddle_id,'') FROM customers WHERE id = ?`, st.CustomerID).Scan(&paddleCust) corpDB.QueryRow(`SELECT COALESCE(paddle_id,'') FROM addresses WHERE id = ?`, st.AddressID).Scan(&paddleAddr) if paddleCust == "" || paddleAddr == "" { // Paddle wasn't configured / sync failed. Don't fail — let the user // reach the success page; the operator can retry the sync from admin. clearOnboardingCookie(w) w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"ok":true,"checkout_url":"/onboarding/done?pending=1"}`)) return } _, checkoutURL, err := tlwCreateTransaction(paddleCust, paddleAddr, priceID) if err != nil { log.Printf("onboarding: tlwCreateTransaction failed: %v", err) jsonError(w, "could not create checkout", 500) return } // Onboarding state is no longer needed — Paddle takes over. clearOnboardingCookie(w) w.Header().Set("Content-Type", "application/json") resp := map[string]interface{}{"ok": true, "checkout_url": checkoutURL} json.NewEncoder(w).Encode(resp) } // lookupPriceID maps a plan name to a Paddle price ID. Hardcoded for now — // will move to DB-backed config later. Mirrors admin/paddle_checkout.go. func lookupPriceID(plan, currency string) string { key := strings.ToLower(plan) + "_yearly_" + strings.ToLower(currency) priceIDs := map[string]string{ "personal_yearly_usd": "pri_01knejm7ft2ska5r4qff2gm9r4", "family_yearly_usd": "pri_01knejm7xs9kqt0vn61dx6q808", "pro_yearly_usd": "pri_01knejm8djq4p63rmsxze7by58", } return priceIDs[key] } func looksLikeEmail(s string) bool { if len(s) < 3 || len(s) > 254 { return false } at := strings.Index(s, "@") if at < 1 || at == len(s)-1 { return false } if !strings.Contains(s[at:], ".") { return false } return true } func jsonError(w http.ResponseWriter, msg string, status int) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) fmt.Fprintf(w, `{"ok":false,"error":%q}`, msg) }