Self-serve signup: account, owner and plan in one request

Go · Go · intermediate · modification

Adds self-serve signup so sales no longer creates trial accounts by hand. One request creates the account, its owner and a trial subscription to the plan picked on the pricing page, all in a single transaction, so a failed signup leaves nothing half-created. Validation reuses the signup form's `Request.Normalize`, a duplicate email gets a 409, a plan code that no longer exists gets a 422 with a clear message, and the owner receives the welcome email with the sign-in link. A mail provider outage is logged and does not fail the signup.

Go 1.22 API on `net/http` (method and path patterns) and `database/sql` with the pgx stdlib driver on PostgreSQL 16, default isolation (read committed). `deps` in `cmd/api/main.go` (unchanged) already carries `DB *sql.DB`, `Mailer mail.Sender`, `Log *slog.Logger` and the `PublicLimits` middleware (per-IP rate limit and bot check on every public route). Existing, unchanged: `signup.Request` with `Normalize() error` (trims fields, lowercases the email, returns an error whose message is safe to show); `auth.HashPassword` (argon2id); `db.IsUniqueViolation` / `db.IsForeignKeyViolation` (SQLSTATE 23505 / 23503, false for nil). Every writer of `users.email` stores it lowercased via `Normalize`, so the plain unique column is enough; `httpx.WriteJSON` and `httpx.WriteError` (writes `{"error": msg}`); `mail.Welcome(to, name, company)` builds the welcome message with the login link. `mail.Sender.Send(ctx, msg)` posts to the mail provider's HTTPS API with its own 10-second timeout; a message the provider accepted cannot be recalled. Schema: `plans` holds only the public self-serve plans, and a trial of any of them is fine; `users.email` is the only unique constraint a signup can hit; `subscriptions.account_id` references `accounts(id)` and `subscriptions.plan_code` references `plans(code)`.

Requirements

Files touched

--- cmd/api/routes.go
 package main
 
-import "net/http"
+import (
+	"net/http"
+
+	"example.com/workspace/internal/signup"
+)
 
 func routes(d deps) http.Handler {
 	})
 	mux.Handle("POST /api/login", d.Login)
+	mux.Handle("POST /api/signup", &signup.Handler{DB: d.DB, Mailer: d.Mailer, Log: d.Log})
 	mux.Handle("GET /api/me", d.Me)
 	return d.PublicLimits(mux)
--- internal/signup/handler.go
+package signup
+
+import (
+	"database/sql"
+	"encoding/json"
+	"log/slog"
+	"net/http"
+
+	"example.com/workspace/internal/auth"
+	"example.com/workspace/internal/db"
+	"example.com/workspace/internal/httpx"
+	"example.com/workspace/internal/mail"
+)

Review this PR

Go practice