323 lines
9.4 KiB
Go
323 lines
9.4 KiB
Go
// 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)
|
|
}
|
|
|