mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Add hosted MSP signup front door to the cloud control plane
Adds public self-serve signup for the hosted MSP offering alongside the existing individual Cloud signup page. New handlers serve /cloud/msp/signup, /cloud/msp/signup/complete, and /api/public/msp/signup, gated behind the same PublicCloudSignupEnabled flag. Per-tier Stripe prices (CP_MSP_STARTER_PRICE_ID, CP_MSP_GROWTH_PRICE_ID, CP_MSP_SCALE_PRICE_ID) are validated against the canonical msp_starter/growth/scale plan versions; the page renders an explicit "not open yet" notice when no MSP price is configured. Checkout sessions carry account_kind=msp metadata so the provisioner seeds an isolated operator workspace. The cloud and MSP JSON signup endpoints now share one checkout skeleton parameterized per path.
This commit is contained in:
@@ -98,6 +98,7 @@ cloud-specific enforcement rules.
|
||||
86. `internal/cloudcp/handoff/handler.go`, `internal/cloudcp/handoff/handoff.go`
|
||||
87. `internal/cloudcp/stripe/grace_enforcer.go`, `internal/cloudcp/stripe/helpers.go`, `internal/cloudcp/stripe/reconciler.go`, `internal/cloudcp/stripe/webhook.go`
|
||||
88. `internal/hosted/hosted_metrics.go`, `internal/hosted/reaper.go`
|
||||
89. `internal/cloudcp/public_msp_signup_handlers.go`
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
@@ -230,6 +231,16 @@ or other self-hosted uncapped continuity plans.
|
||||
tenant-runtime capacity/log retention configuration, or checkout gating
|
||||
through `internal/cloudcp/config.go` and
|
||||
`internal/cloudcp/public_cloud_signup_handlers.go`
|
||||
Public MSP self-serve signup is the operator-account front door for the same
|
||||
boundary and lives in `internal/cloudcp/public_msp_signup_handlers.go`. Its
|
||||
per-tier price configuration (`CP_MSP_STARTER_PRICE_ID`,
|
||||
`CP_MSP_GROWTH_PRICE_ID`, `CP_MSP_SCALE_PRICE_ID`) is validated in
|
||||
`internal/cloudcp/config.go` against the canonical `msp_starter`,
|
||||
`msp_growth`, and `msp_scale` plan versions, and the MSP signup routes stay
|
||||
gated behind the same `PublicCloudSignupEnabled` flag as the individual
|
||||
cloud signup front door. Checkout metadata produced by the MSP front door
|
||||
must mark `account_kind=msp` so provisioning seeds an operator workspace
|
||||
rather than an individual tenant.
|
||||
6. Add or change the hosted account portal API, Pulse Account access/auth/session handling, task-first browser shell, maintained portal frontend/bundle, or account-scoped workspace/access/billing handoff through `internal/cloudcp/account/audit.go`, `internal/cloudcp/account/handlers.go`, `internal/cloudcp/auth/handlers.go`, `internal/cloudcp/auth/session.go`, `internal/cloudcp/portal/`, and `internal/cloudcp/routes.go`
|
||||
That same customer-entry boundary owns the canonical hosted Cloud handoff:
|
||||
public Cloud entry, secure checkout return, and returning-customer sign-in
|
||||
@@ -686,6 +697,19 @@ boundary: hosted Cloud/MSP checkout events may provision tenants, while
|
||||
self-hosted landing purchases are acknowledged and ignored rather than
|
||||
creating hosted containers or Stripe account mappings.
|
||||
|
||||
The hosted MSP offering now has a public self-serve front door alongside the
|
||||
individual cloud signup page. `internal/cloudcp/public_msp_signup_handlers.go`
|
||||
serves `/cloud/msp/signup`, `/cloud/msp/signup/complete`, and
|
||||
`/api/public/msp/signup`, all gated behind the same `PublicCloudSignupEnabled`
|
||||
flag as `/cloud/signup`, so the MSP front door stays dark until an operator
|
||||
explicitly enables public signup. Each MSP tier (starter, growth, scale) is
|
||||
served only when its Stripe price is configured (`CP_MSP_STARTER_PRICE_ID`,
|
||||
`CP_MSP_GROWTH_PRICE_ID`, `CP_MSP_SCALE_PRICE_ID`); the signup page renders an
|
||||
explicit "not open for self-serve signup yet" notice when no MSP tier price is
|
||||
configured rather than offering an unbacked checkout. Checkout sessions started
|
||||
from this front door carry `account_kind=msp` and `signup_source=public_msp_signup`
|
||||
metadata so the provisioner seeds an isolated operator workspace.
|
||||
|
||||
Cloud paid readiness is materially behind architecture work. The main concern is
|
||||
contract coherence between pricing, entitlements, and runtime enforcement.
|
||||
Pulse Account portal workspace copy is part of that same readiness contract:
|
||||
|
||||
@@ -55,6 +55,9 @@ type CPConfig struct {
|
||||
TrialSignupPriceID string // Cloud Starter (default tier) price ID
|
||||
CloudPowerPriceID string // Cloud Power tier price ID (optional)
|
||||
CloudMaxPriceID string // Cloud Max tier price ID (optional)
|
||||
CloudMSPStarterPriceID string // MSP Starter tier price ID (optional)
|
||||
CloudMSPGrowthPriceID string // MSP Growth tier price ID (optional)
|
||||
CloudMSPScalePriceID string // MSP Scale tier price ID (optional)
|
||||
LicenseServerURL string
|
||||
LicenseAdminToken string
|
||||
TrialActivationPrivateKey string
|
||||
@@ -183,6 +186,9 @@ func LoadConfig() (*CPConfig, error) {
|
||||
TrialSignupPriceID: strings.TrimSpace(os.Getenv("CP_TRIAL_SIGNUP_PRICE_ID")),
|
||||
CloudPowerPriceID: strings.TrimSpace(os.Getenv("CP_CLOUD_POWER_PRICE_ID")),
|
||||
CloudMaxPriceID: strings.TrimSpace(os.Getenv("CP_CLOUD_MAX_PRICE_ID")),
|
||||
CloudMSPStarterPriceID: strings.TrimSpace(os.Getenv("CP_MSP_STARTER_PRICE_ID")),
|
||||
CloudMSPGrowthPriceID: strings.TrimSpace(os.Getenv("CP_MSP_GROWTH_PRICE_ID")),
|
||||
CloudMSPScalePriceID: strings.TrimSpace(os.Getenv("CP_MSP_SCALE_PRICE_ID")),
|
||||
LicenseServerURL: envOrDefault("PULSE_LICENSE_SERVER_URL", "https://license.pulserelay.pro"),
|
||||
LicenseAdminToken: strings.TrimSpace(os.Getenv("PULSE_LICENSE_ADMIN_TOKEN")),
|
||||
TrialActivationPrivateKey: strings.TrimSpace(os.Getenv("CP_TRIAL_ACTIVATION_PRIVATE_KEY")),
|
||||
@@ -323,6 +329,15 @@ func (c *CPConfig) validate() error {
|
||||
if err := validateCloudStripePriceID(c.Environment, c.StripeAPIKey, "CP_CLOUD_MAX_PRICE_ID", c.CloudMaxPriceID, "cloud_max"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateCloudStripePriceID(c.Environment, c.StripeAPIKey, "CP_MSP_STARTER_PRICE_ID", c.CloudMSPStarterPriceID, "msp_starter"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateCloudStripePriceID(c.Environment, c.StripeAPIKey, "CP_MSP_GROWTH_PRICE_ID", c.CloudMSPGrowthPriceID, "msp_growth"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateCloudStripePriceID(c.Environment, c.StripeAPIKey, "CP_MSP_SCALE_PRICE_ID", c.CloudMSPScalePriceID, "msp_scale"); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(c.LicenseServerURL) == "" && strings.TrimSpace(c.LicenseAdminToken) != "" {
|
||||
return fmt.Errorf("PULSE_LICENSE_SERVER_URL is required when PULSE_LICENSE_ADMIN_TOKEN is configured")
|
||||
}
|
||||
|
||||
@@ -386,6 +386,48 @@ func TestLoadConfig_AcceptsCanonicalCloudSignupPriceIDs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_AcceptsCanonicalMSPSignupPriceIDs(t *testing.T) {
|
||||
setRequiredCPEnv(t)
|
||||
setTrialSigningEnv(t)
|
||||
t.Setenv("CP_ENV", "production")
|
||||
t.Setenv("STRIPE_API_KEY", "sk_live_123")
|
||||
t.Setenv("CP_MSP_STARTER_PRICE_ID", "price_1T5kgTBrHBocJIGHjOs15LI2")
|
||||
t.Setenv("CP_MSP_GROWTH_PRICE_ID", "price_1T5kgVBrHBocJIGHulNsCTb1")
|
||||
t.Setenv("CP_MSP_SCALE_PRICE_ID", "price_1T5kgWBrHBocJIGHo40iFeRd")
|
||||
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
if cfg.CloudMSPStarterPriceID != "price_1T5kgTBrHBocJIGHjOs15LI2" {
|
||||
t.Fatalf("CloudMSPStarterPriceID=%q want canonical msp_starter price", cfg.CloudMSPStarterPriceID)
|
||||
}
|
||||
if cfg.CloudMSPGrowthPriceID != "price_1T5kgVBrHBocJIGHulNsCTb1" {
|
||||
t.Fatalf("CloudMSPGrowthPriceID=%q want canonical msp_growth price", cfg.CloudMSPGrowthPriceID)
|
||||
}
|
||||
if cfg.CloudMSPScalePriceID != "price_1T5kgWBrHBocJIGHo40iFeRd" {
|
||||
t.Fatalf("CloudMSPScalePriceID=%q want canonical msp_scale price", cfg.CloudMSPScalePriceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_RejectsNonMSPStarterPriceIDInProductionCatalog(t *testing.T) {
|
||||
setRequiredCPEnv(t)
|
||||
setTrialSigningEnv(t)
|
||||
t.Setenv("CP_ENV", "production")
|
||||
t.Setenv("STRIPE_API_KEY", "sk_live_123")
|
||||
// A canonical cloud_power price is a valid Stripe price but maps to the
|
||||
// wrong plan version for the MSP Starter slot, so config must fail closed.
|
||||
t.Setenv("CP_MSP_STARTER_PRICE_ID", "price_1T5kg2BrHBocJIGHmkoF0zXY")
|
||||
|
||||
_, err := LoadConfig()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-msp_starter CP_MSP_STARTER_PRICE_ID")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CP_MSP_STARTER_PRICE_ID must map to the canonical msp_starter Stripe price") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_AllowsMissingTrialSignupPriceWhenPublicCloudSignupDisabled(t *testing.T) {
|
||||
setRequiredCPEnv(t)
|
||||
setTrialSigningEnv(t)
|
||||
|
||||
@@ -377,6 +377,34 @@ func (h *PublicCloudSignupHandlers) HandleSignupComplete(w http.ResponseWriter,
|
||||
}
|
||||
|
||||
func (h *PublicCloudSignupHandlers) HandlePublicSignup(w http.ResponseWriter, r *http.Request) {
|
||||
h.servePublicSignupCheckout(w, r,
|
||||
"Invalid plan tier. Must be one of: starter, power, max",
|
||||
"public cloud signup API checkout creation failed",
|
||||
fmt.Sprintf("Checkout session created. Continue in Stripe to start your %d-day Pulse Cloud trial and provision your workspace.", publicCloudTrialDays),
|
||||
func(tierRaw string) (bool, bool, func(email, orgName string) (string, error)) {
|
||||
tier, ok := parseCloudTier(tierRaw)
|
||||
if !ok {
|
||||
return false, false, nil
|
||||
}
|
||||
_, available := h.priceIDForTier(tier)
|
||||
return true, available, func(email, orgName string) (string, error) {
|
||||
return h.createCheckout(email, orgName, tier)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// servePublicSignupCheckout runs the shared method/decode/validate/checkout
|
||||
// skeleton for the public Cloud and MSP signup JSON endpoints. resolve supplies
|
||||
// the per-path tier parsing, price availability, and checkout creation.
|
||||
func (h *PublicCloudSignupHandlers) servePublicSignupCheckout(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
invalidTierMessage string,
|
||||
checkoutLogMsg string,
|
||||
successMessage string,
|
||||
resolve func(tierRaw string) (tierValid bool, available bool, create func(email, orgName string) (string, error)),
|
||||
) {
|
||||
if r.Method != http.MethodPost {
|
||||
writePublicSignupError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
@@ -389,37 +417,37 @@ func (h *PublicCloudSignupHandlers) HandlePublicSignup(w http.ResponseWriter, r
|
||||
return
|
||||
}
|
||||
|
||||
req.Email = strings.TrimSpace(req.Email)
|
||||
req.OrgName = strings.TrimSpace(req.OrgName)
|
||||
email := strings.TrimSpace(req.Email)
|
||||
orgName := strings.TrimSpace(req.OrgName)
|
||||
|
||||
tier, tierOK := parseCloudTier(req.Tier)
|
||||
if !tierOK {
|
||||
writePublicSignupError(w, http.StatusBadRequest, "invalid_tier", "Invalid plan tier. Must be one of: starter, power, max")
|
||||
tierValid, available, create := resolve(req.Tier)
|
||||
if !tierValid {
|
||||
writePublicSignupError(w, http.StatusBadRequest, "invalid_tier", invalidTierMessage)
|
||||
return
|
||||
}
|
||||
if !isValidCloudSignupEmail(req.Email) {
|
||||
if !isValidCloudSignupEmail(email) {
|
||||
writePublicSignupError(w, http.StatusBadRequest, "invalid_email", "Invalid email format")
|
||||
return
|
||||
}
|
||||
if !isValidCloudSignupOrgName(req.OrgName) {
|
||||
if !isValidCloudSignupOrgName(orgName) {
|
||||
writePublicSignupError(w, http.StatusBadRequest, "invalid_org_name", "Invalid organization name")
|
||||
return
|
||||
}
|
||||
if _, avail := h.priceIDForTier(tier); !avail {
|
||||
if !available {
|
||||
writePublicSignupError(w, http.StatusBadRequest, "tier_unavailable", "The selected plan tier is not currently available")
|
||||
return
|
||||
}
|
||||
|
||||
checkoutURL, err := h.createCheckout(req.Email, req.OrgName, tier)
|
||||
checkoutURL, err := create(email, orgName)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("email", req.Email).Msg("public cloud signup API checkout creation failed")
|
||||
log.Warn().Err(err).Str("email", email).Msg(checkoutLogMsg)
|
||||
writePublicSignupError(w, http.StatusBadGateway, "checkout_failed", "Unable to create checkout session")
|
||||
return
|
||||
}
|
||||
|
||||
writePublicSignupJSON(w, http.StatusCreated, map[string]any{
|
||||
"checkout_url": checkoutURL,
|
||||
"message": fmt.Sprintf("Checkout session created. Continue in Stripe to start your %d-day Pulse Cloud trial and provision your workspace.", publicCloudTrialDays),
|
||||
"message": successMessage,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -501,9 +529,6 @@ func (h *PublicCloudSignupHandlers) createCheckout(email, orgName string, tier c
|
||||
if h.cfg == nil {
|
||||
return "", fmt.Errorf("control plane config is missing")
|
||||
}
|
||||
if strings.TrimSpace(h.cfg.StripeAPIKey) == "" {
|
||||
return "", fmt.Errorf("stripe api key not configured")
|
||||
}
|
||||
priceID, ok := h.priceIDForTier(tier)
|
||||
if !ok || priceID == "" {
|
||||
return "", fmt.Errorf("price id not configured for tier %q", tier)
|
||||
@@ -512,7 +537,6 @@ func (h *PublicCloudSignupHandlers) createCheckout(email, orgName string, tier c
|
||||
return "", err
|
||||
}
|
||||
|
||||
stripe.Key = strings.TrimSpace(h.cfg.StripeAPIKey)
|
||||
successURL := buildCPURL(h.cfg.BaseURL, canonicalPublicCloudSignupPath+"/complete", nil)
|
||||
cancelURL := buildCPURL(h.cfg.BaseURL, canonicalPublicCloudSignupPath, url.Values{
|
||||
"cancelled": {"1"},
|
||||
@@ -520,6 +544,25 @@ func (h *PublicCloudSignupHandlers) createCheckout(email, orgName string, tier c
|
||||
"org_name": {orgName},
|
||||
"tier": {string(tier)},
|
||||
})
|
||||
return h.createTrialCheckoutSession(email, priceID, successURL, cancelURL, h.buildCheckoutMetadata(priceID, orgName))
|
||||
}
|
||||
|
||||
// createTrialCheckoutSession builds a subscription-mode Stripe Checkout session
|
||||
// for a single recurring price with the standard public-signup trial, and
|
||||
// returns the redirect URL. Shared by the individual Cloud and MSP signup paths;
|
||||
// callers own price resolution, success/cancel URLs, and metadata.
|
||||
func (h *PublicCloudSignupHandlers) createTrialCheckoutSession(email, priceID, successURL, cancelURL string, metadata map[string]string) (string, error) {
|
||||
if h.cfg == nil {
|
||||
return "", fmt.Errorf("control plane config is missing")
|
||||
}
|
||||
if strings.TrimSpace(h.cfg.StripeAPIKey) == "" {
|
||||
return "", fmt.Errorf("stripe api key not configured")
|
||||
}
|
||||
if strings.TrimSpace(priceID) == "" {
|
||||
return "", fmt.Errorf("price id not configured")
|
||||
}
|
||||
|
||||
stripe.Key = strings.TrimSpace(h.cfg.StripeAPIKey)
|
||||
params := &stripe.CheckoutSessionParams{
|
||||
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
||||
SuccessURL: stripe.String(successURL),
|
||||
@@ -535,7 +578,7 @@ func (h *PublicCloudSignupHandlers) createCheckout(email, orgName string, tier c
|
||||
SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{
|
||||
TrialPeriodDays: stripe.Int64(publicCloudTrialDays),
|
||||
},
|
||||
Metadata: h.buildCheckoutMetadata(priceID, orgName),
|
||||
Metadata: metadata,
|
||||
}
|
||||
session, err := h.createCheckoutSession(params)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
package cloudcp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/cpsec"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry"
|
||||
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const canonicalPublicMSPSignupPath = "/cloud/msp/signup"
|
||||
|
||||
// mspTier represents a hosted MSP plan tier for public signup. The three tiers
|
||||
// mirror the canonical MSP plan-version ladder in pkg/licensing (msp_starter /
|
||||
// msp_growth / msp_scale), each capped at a different number of client
|
||||
// workspaces.
|
||||
type mspTier string
|
||||
|
||||
const (
|
||||
mspTierStarter mspTier = "starter"
|
||||
mspTierGrowth mspTier = "growth"
|
||||
mspTierScale mspTier = "scale"
|
||||
)
|
||||
|
||||
var validMSPTiers = map[mspTier]bool{
|
||||
mspTierStarter: true,
|
||||
mspTierGrowth: true,
|
||||
mspTierScale: true,
|
||||
}
|
||||
|
||||
// parseMSPTier normalizes a tier string from user input. Returns mspTierStarter
|
||||
// if the input is empty (default). Returns ("", false) if the input is a
|
||||
// non-empty but unrecognized tier.
|
||||
func parseMSPTier(raw string) (mspTier, bool) {
|
||||
t := mspTier(strings.ToLower(strings.TrimSpace(raw)))
|
||||
if t == "" {
|
||||
return mspTierStarter, true
|
||||
}
|
||||
if validMSPTiers[t] {
|
||||
return t, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func expectedPlanVersionForMSPTier(tier mspTier) string {
|
||||
switch tier {
|
||||
case mspTierStarter:
|
||||
return "msp_starter"
|
||||
case mspTierGrowth:
|
||||
return "msp_growth"
|
||||
case mspTierScale:
|
||||
return "msp_scale"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// priceIDForMSPTier returns the configured Stripe price ID for the given MSP
|
||||
// tier. Returns ("", false) if the tier's price ID is not configured. An MSP
|
||||
// tier with no configured price ID is treated as not offered, which is how the
|
||||
// front door stays inert until Richard sets the price IDs in CP env.
|
||||
func (h *PublicCloudSignupHandlers) priceIDForMSPTier(tier mspTier) (string, bool) {
|
||||
if h.cfg == nil {
|
||||
return "", false
|
||||
}
|
||||
switch tier {
|
||||
case mspTierStarter:
|
||||
id := strings.TrimSpace(h.cfg.CloudMSPStarterPriceID)
|
||||
return id, id != ""
|
||||
case mspTierGrowth:
|
||||
id := strings.TrimSpace(h.cfg.CloudMSPGrowthPriceID)
|
||||
return id, id != ""
|
||||
case mspTierScale:
|
||||
id := strings.TrimSpace(h.cfg.CloudMSPScalePriceID)
|
||||
return id, id != ""
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PublicCloudSignupHandlers) hasMSPTier(tier mspTier) bool {
|
||||
_, ok := h.priceIDForMSPTier(tier)
|
||||
return ok
|
||||
}
|
||||
|
||||
// defaultMSPTier returns the lowest configured MSP tier, preferring
|
||||
// starter → growth → scale. The bool is false when no MSP tier is configured.
|
||||
func (h *PublicCloudSignupHandlers) defaultMSPTier() (mspTier, bool) {
|
||||
for _, t := range []mspTier{mspTierStarter, mspTierGrowth, mspTierScale} {
|
||||
if h.hasMSPTier(t) {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func validatePublicMSPSignupPriceID(tier mspTier, priceID string) error {
|
||||
wantPlanVersion := expectedPlanVersionForMSPTier(tier)
|
||||
if wantPlanVersion == "" {
|
||||
return fmt.Errorf("unsupported msp tier %q", tier)
|
||||
}
|
||||
if err := validateCloudStripePriceID("production", "", "public msp signup price", priceID, wantPlanVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var publicMSPSignupPageTemplate = template.Must(template.New("public-msp-signup-page").Parse(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Start Pulse Cloud for MSPs</title>
|
||||
<style nonce="{{.Nonce}}">
|
||||
:root { color-scheme: light; }
|
||||
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: linear-gradient(140deg, #f8fafc, #e2e8f0); color: #0f172a; }
|
||||
.wrap { max-width: 760px; margin: 36px auto; padding: 0 16px; }
|
||||
.card { background: #fff; border-radius: 12px; border: 1px solid #e2e8f0; box-shadow: 0 8px 30px rgba(15,23,42,.08); padding: 24px; }
|
||||
h1 { margin: 0 0 8px; font-size: 30px; }
|
||||
p { margin: 0 0 16px; line-height: 1.5; color: #334155; }
|
||||
.error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; border-radius: 8px; padding: 10px 12px; margin-bottom: 12px; font-size: 14px; }
|
||||
.note { background: #eff6ff; color: #1e3a8a; border: 1px solid #bfdbfe; border-radius: 8px; padding: 10px 12px; margin-bottom: 12px; font-size: 14px; }
|
||||
label { display: block; margin: 12px 0 6px; font-size: 14px; font-weight: 600; color: #0f172a; }
|
||||
input { width: 100%; box-sizing: border-box; border: 1px solid #cbd5e1; border-radius: 8px; padding: 10px 12px; font-size: 15px; }
|
||||
.cta { margin-top: 16px; border: 0; border-radius: 10px; background: #1d4ed8; color: #fff; font-size: 16px; font-weight: 600; padding: 12px 16px; width: 100%; cursor: pointer; }
|
||||
.cta:hover { background: #1e40af; }
|
||||
.fine { font-size: 12px; color: #64748b; margin-top: 12px; }
|
||||
.tier-group { display: flex; flex-direction: column; gap: 6px; margin-bottom: 4px; }
|
||||
.tier-option { display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 400; cursor: pointer; padding: 8px 10px; border: 1px solid #e2e8f0; border-radius: 8px; }
|
||||
.tier-option:has(input:checked) { border-color: #1d4ed8; background: #eff6ff; }
|
||||
ol { margin: 0; padding-left: 20px; color: #334155; }
|
||||
li { margin-bottom: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<h1>Start your {{.TrialDays}}-day Pulse Cloud for MSPs trial</h1>
|
||||
<p>Run Pulse for multiple clients from one hosted operator account. Each client gets an isolated workspace; you manage them all from the MSP portal. Stripe checkout securely collects a payment method, but the subscription starts with a {{.TrialDays}}-day trial and no upfront charge.</p>
|
||||
{{if .ErrorMessage}}<div class="error">{{.ErrorMessage}}</div>{{end}}
|
||||
{{if .Cancelled}}<div class="note">Checkout was cancelled. You can start again below.</div>{{end}}
|
||||
|
||||
{{if .Available}}
|
||||
<form method="POST" action="{{.FormAction}}">
|
||||
{{/* Tier labels show monthly pricing and client-workspace caps for
|
||||
orientation. Stripe checkout displays the actual price from the
|
||||
configured price ID. */}}
|
||||
{{if .ShowTierChoice}}
|
||||
<label>Plan</label>
|
||||
<div class="tier-group">
|
||||
{{if .HasStarter}}<label class="tier-option"><input type="radio" name="tier" value="starter" {{if eq .Tier "starter"}}checked{{end}}> <strong>Starter</strong> — up to 10 client workspaces, $149/mo after trial</label>{{end}}
|
||||
{{if .HasGrowth}}<label class="tier-option"><input type="radio" name="tier" value="growth" {{if eq .Tier "growth"}}checked{{end}}> <strong>Growth</strong> — up to 25 client workspaces, $249/mo after trial</label>{{end}}
|
||||
{{if .HasScale}}<label class="tier-option"><input type="radio" name="tier" value="scale" {{if eq .Tier "scale"}}checked{{end}}> <strong>Scale</strong> — up to 50 client workspaces, $399/mo after trial</label>{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<input type="hidden" name="tier" value="{{.Tier}}">
|
||||
{{end}}
|
||||
|
||||
<label for="email">Work Email</label>
|
||||
<input id="email" name="email" type="email" value="{{.Email}}" autocomplete="email" required>
|
||||
|
||||
<label for="org_name">Company Name</label>
|
||||
<input id="org_name" name="org_name" type="text" value="{{.OrgName}}" autocomplete="organization" required>
|
||||
|
||||
<button class="cta" type="submit">Continue To Secure Checkout</button>
|
||||
</form>
|
||||
|
||||
<p class="fine">After checkout, we will email a Pulse Account sign-in link so you can open your MSP portal.</p>
|
||||
<ol>
|
||||
<li>Stripe starts your {{.TrialDays}}-day trial securely.</li>
|
||||
<li>Pulse Cloud provisions your MSP operator account.</li>
|
||||
<li>The email link opens Pulse Account, where you add client workspaces and continue setup.</li>
|
||||
</ol>
|
||||
{{else}}
|
||||
<div class="note">Pulse Cloud for MSPs is not open for self-serve signup yet. Email support@pulserelay.pro and we will get you set up.</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
|
||||
var publicMSPSignupCompleteTemplate = template.Must(template.New("public-msp-signup-complete").Parse(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Pulse Cloud for MSPs Checkout Complete</title>
|
||||
<style nonce="{{.Nonce}}">
|
||||
:root { color-scheme: light; }
|
||||
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f8fafc; color: #0f172a; }
|
||||
.wrap { max-width: 680px; margin: 48px auto; padding: 0 16px; }
|
||||
.card { background: #fff; border-radius: 12px; border: 1px solid #e2e8f0; box-shadow: 0 8px 30px rgba(15,23,42,.08); padding: 24px; }
|
||||
h1 { margin: 0 0 8px; font-size: 28px; }
|
||||
p { margin: 0 0 14px; line-height: 1.5; color: #334155; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<h1>Trial checkout complete</h1>
|
||||
<p>Your {{.TrialDays}}-day Pulse Cloud for MSPs trial checkout completed. Pulse Cloud is provisioning your MSP operator account.</p>
|
||||
<p>Watch your inbox for a Pulse Account sign-in link. That link lands in Pulse Account, where you can open the MSP portal, add client workspaces, and continue setup.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
|
||||
type publicMSPSignupPageData struct {
|
||||
Email string
|
||||
OrgName string
|
||||
Tier string // selected tier slug ("starter", "growth", "scale")
|
||||
FormAction string
|
||||
ErrorMessage string
|
||||
Cancelled bool
|
||||
Nonce string
|
||||
Available bool // true if at least one MSP tier price is configured
|
||||
ShowTierChoice bool // true if more than one MSP tier is configured
|
||||
HasStarter bool
|
||||
HasGrowth bool
|
||||
HasScale bool
|
||||
TrialDays int
|
||||
}
|
||||
|
||||
// newMSPSignupPageData seeds page data from the currently configured MSP tiers
|
||||
// so every render (initial and error) reflects the same availability state.
|
||||
func (h *PublicCloudSignupHandlers) newMSPSignupPageData() publicMSPSignupPageData {
|
||||
hasStarter := h.hasMSPTier(mspTierStarter)
|
||||
hasGrowth := h.hasMSPTier(mspTierGrowth)
|
||||
hasScale := h.hasMSPTier(mspTierScale)
|
||||
count := 0
|
||||
for _, present := range []bool{hasStarter, hasGrowth, hasScale} {
|
||||
if present {
|
||||
count++
|
||||
}
|
||||
}
|
||||
data := publicMSPSignupPageData{
|
||||
FormAction: canonicalPublicMSPSignupPath,
|
||||
HasStarter: hasStarter,
|
||||
HasGrowth: hasGrowth,
|
||||
HasScale: hasScale,
|
||||
Available: count > 0,
|
||||
ShowTierChoice: count > 1,
|
||||
TrialDays: publicCloudTrialDays,
|
||||
}
|
||||
if def, ok := h.defaultMSPTier(); ok {
|
||||
data.Tier = string(def)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func (h *PublicCloudSignupHandlers) renderMSPSignupPage(w http.ResponseWriter, r *http.Request, status int, data publicMSPSignupPageData) {
|
||||
data.Nonce = cpsec.NonceFromContext(r.Context())
|
||||
if data.TrialDays <= 0 {
|
||||
data.TrialDays = publicCloudTrialDays
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
if err := publicMSPSignupPageTemplate.Execute(w, data); err != nil {
|
||||
log.Error().Err(err).Msg("public msp signup page render failed")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PublicCloudSignupHandlers) HandleMSPSignupPage(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
data := h.newMSPSignupPageData()
|
||||
data.Email = strings.TrimSpace(r.URL.Query().Get("email"))
|
||||
data.OrgName = strings.TrimSpace(r.URL.Query().Get("org_name"))
|
||||
data.Cancelled = strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("cancelled")), "1")
|
||||
if tier, ok := parseMSPTier(strings.TrimSpace(r.URL.Query().Get("tier"))); ok && h.hasMSPTier(tier) {
|
||||
data.Tier = string(tier)
|
||||
}
|
||||
h.renderMSPSignupPage(w, r, http.StatusOK, data)
|
||||
case http.MethodPost:
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Invalid form body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
email := strings.TrimSpace(r.FormValue("email"))
|
||||
orgName := strings.TrimSpace(r.FormValue("org_name"))
|
||||
tierStr := strings.TrimSpace(r.FormValue("tier"))
|
||||
|
||||
renderErr := func(status int, msg string) {
|
||||
data := h.newMSPSignupPageData()
|
||||
data.Email = email
|
||||
data.OrgName = orgName
|
||||
if t, ok := parseMSPTier(tierStr); ok && h.hasMSPTier(t) {
|
||||
data.Tier = string(t)
|
||||
}
|
||||
data.ErrorMessage = msg
|
||||
h.renderMSPSignupPage(w, r, status, data)
|
||||
}
|
||||
|
||||
tier, tierOK := parseMSPTier(tierStr)
|
||||
if !tierOK {
|
||||
renderErr(http.StatusBadRequest, "Invalid plan tier selected.")
|
||||
return
|
||||
}
|
||||
if !isValidCloudSignupEmail(email) {
|
||||
renderErr(http.StatusBadRequest, "A valid email address is required.")
|
||||
return
|
||||
}
|
||||
if !isValidCloudSignupOrgName(orgName) {
|
||||
renderErr(http.StatusBadRequest, "Company name must be 3-64 characters and cannot contain slashes.")
|
||||
return
|
||||
}
|
||||
if _, avail := h.priceIDForMSPTier(tier); !avail {
|
||||
renderErr(http.StatusBadRequest, "The selected plan tier is not currently available.")
|
||||
return
|
||||
}
|
||||
|
||||
checkoutURL, err := h.createMSPCheckout(email, orgName, tier)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("email", email).Str("tier", string(tier)).Msg("public msp signup checkout creation failed")
|
||||
renderErr(http.StatusBadGateway, "Unable to create checkout session. Please try again.")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, checkoutURL, http.StatusSeeOther)
|
||||
default:
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PublicCloudSignupHandlers) HandleMSPSignupComplete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := publicMSPSignupCompleteTemplate.Execute(w, publicCloudSignupCompleteData{
|
||||
Nonce: cpsec.NonceFromContext(r.Context()),
|
||||
TrialDays: publicCloudTrialDays,
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Msg("public msp signup complete page render failed")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PublicCloudSignupHandlers) HandleMSPPublicSignup(w http.ResponseWriter, r *http.Request) {
|
||||
h.servePublicSignupCheckout(w, r,
|
||||
"Invalid plan tier. Must be one of: starter, growth, scale",
|
||||
"public msp signup API checkout creation failed",
|
||||
fmt.Sprintf("Checkout session created. Continue in Stripe to start your %d-day Pulse Cloud for MSPs trial and provision your operator account.", publicCloudTrialDays),
|
||||
func(tierRaw string) (bool, bool, func(email, orgName string) (string, error)) {
|
||||
tier, ok := parseMSPTier(tierRaw)
|
||||
if !ok {
|
||||
return false, false, nil
|
||||
}
|
||||
_, available := h.priceIDForMSPTier(tier)
|
||||
return true, available, func(email, orgName string) (string, error) {
|
||||
return h.createMSPCheckout(email, orgName, tier)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (h *PublicCloudSignupHandlers) createMSPCheckout(email, orgName string, tier mspTier) (string, error) {
|
||||
if h.cfg == nil {
|
||||
return "", fmt.Errorf("control plane config is missing")
|
||||
}
|
||||
priceID, ok := h.priceIDForMSPTier(tier)
|
||||
if !ok || priceID == "" {
|
||||
return "", fmt.Errorf("price id not configured for msp tier %q", tier)
|
||||
}
|
||||
if err := validatePublicMSPSignupPriceID(tier, priceID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
successURL := buildCPURL(h.cfg.BaseURL, canonicalPublicMSPSignupPath+"/complete", nil)
|
||||
cancelURL := buildCPURL(h.cfg.BaseURL, canonicalPublicMSPSignupPath, url.Values{
|
||||
"cancelled": {"1"},
|
||||
"email": {email},
|
||||
"org_name": {orgName},
|
||||
"tier": {string(tier)},
|
||||
})
|
||||
return h.createTrialCheckoutSession(email, priceID, successURL, cancelURL, h.buildMSPCheckoutMetadata(priceID, orgName))
|
||||
}
|
||||
|
||||
func (h *PublicCloudSignupHandlers) buildMSPCheckoutMetadata(priceID, orgName string) map[string]string {
|
||||
meta := map[string]string{
|
||||
"account_kind": string(registry.AccountKindMSP),
|
||||
"account_display_name": orgName,
|
||||
"display_name": orgName,
|
||||
"signup_source": "public_msp_signup",
|
||||
}
|
||||
// Only accept msp_* plan versions on the MSP signup path. This is the
|
||||
// mirror of the individual path's cloud_* guard: it prevents granting
|
||||
// MSP-level workspace limits from a misconfigured non-MSP price.
|
||||
if plan, ok := pkglicensing.PlanVersionForPriceID(priceID); ok && strings.HasPrefix(plan, "msp_") {
|
||||
meta["plan_version"] = plan
|
||||
}
|
||||
return meta
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
package cloudcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
stripe "github.com/stripe/stripe-go/v82"
|
||||
)
|
||||
|
||||
const (
|
||||
testMSPStarterPriceID = "price_1T5kgTBrHBocJIGHjOs15LI2"
|
||||
testMSPGrowthPriceID = "price_1T5kgVBrHBocJIGHulNsCTb1"
|
||||
testMSPScalePriceID = "price_1T5kgWBrHBocJIGHo40iFeRd"
|
||||
)
|
||||
|
||||
func TestParseMSPTier(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want mspTier
|
||||
wantOK bool
|
||||
}{
|
||||
{"", mspTierStarter, true},
|
||||
{"starter", mspTierStarter, true},
|
||||
{"STARTER", mspTierStarter, true},
|
||||
{" Growth ", mspTierGrowth, true},
|
||||
{"growth", mspTierGrowth, true},
|
||||
{"scale", mspTierScale, true},
|
||||
{"SCALE", mspTierScale, true},
|
||||
{"power", "", false},
|
||||
{"max", "", false},
|
||||
{"enterprise", "", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got, ok := parseMSPTier(tt.input)
|
||||
if ok != tt.wantOK {
|
||||
t.Fatalf("parseMSPTier(%q) ok=%v, want %v", tt.input, ok, tt.wantOK)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("parseMSPTier(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceIDForMSPTier(t *testing.T) {
|
||||
h := NewPublicCloudSignupHandlers(&CPConfig{
|
||||
CloudMSPStarterPriceID: testMSPStarterPriceID,
|
||||
CloudMSPGrowthPriceID: testMSPGrowthPriceID,
|
||||
CloudMSPScalePriceID: testMSPScalePriceID,
|
||||
}, nil, nil, nil)
|
||||
|
||||
tests := []struct {
|
||||
tier mspTier
|
||||
want string
|
||||
}{
|
||||
{mspTierStarter, testMSPStarterPriceID},
|
||||
{mspTierGrowth, testMSPGrowthPriceID},
|
||||
{mspTierScale, testMSPScalePriceID},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.tier), func(t *testing.T) {
|
||||
got, ok := h.priceIDForMSPTier(tt.tier)
|
||||
if !ok || got != tt.want {
|
||||
t.Fatalf("priceIDForMSPTier(%q) = (%q,%v), want (%q,true)", tt.tier, got, ok, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMSPSignupPageRendersUnavailableWhenNoTierConfigured(t *testing.T) {
|
||||
h := NewPublicCloudSignupHandlers(&CPConfig{}, nil, nil, nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/cloud/msp/signup", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.HandleMSPSignupPage(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if strings.Contains(body, "<form") {
|
||||
t.Fatal("expected no signup form when no MSP tier is configured")
|
||||
}
|
||||
if !strings.Contains(body, "not open for self-serve signup yet") {
|
||||
t.Fatalf("expected unavailable notice, got %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMSPSignupPageRendersFormWhenTierConfigured(t *testing.T) {
|
||||
h := NewPublicCloudSignupHandlers(&CPConfig{
|
||||
CloudMSPStarterPriceID: testMSPStarterPriceID,
|
||||
}, nil, nil, nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/cloud/msp/signup", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.HandleMSPSignupPage(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "<form") {
|
||||
t.Fatal("expected signup form when an MSP tier is configured")
|
||||
}
|
||||
if !strings.Contains(body, fmt.Sprintf("Start your %d-day Pulse Cloud for MSPs trial", publicCloudTrialDays)) {
|
||||
t.Fatal("expected MSP trial heading")
|
||||
}
|
||||
if !strings.Contains(body, `action="/cloud/msp/signup"`) {
|
||||
t.Fatal("expected form to post to the canonical MSP signup path")
|
||||
}
|
||||
// Single tier configured → hidden input, no radios.
|
||||
if strings.Contains(body, `type="radio"`) {
|
||||
t.Fatal("expected no tier radios when only one MSP tier is configured")
|
||||
}
|
||||
if !strings.Contains(body, `type="hidden" name="tier" value="starter"`) {
|
||||
t.Fatal("expected hidden starter tier input when only starter is configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMSPSignupPageShowsTierRadiosWhenMultipleConfigured(t *testing.T) {
|
||||
h := NewPublicCloudSignupHandlers(&CPConfig{
|
||||
CloudMSPStarterPriceID: testMSPStarterPriceID,
|
||||
CloudMSPGrowthPriceID: testMSPGrowthPriceID,
|
||||
CloudMSPScalePriceID: testMSPScalePriceID,
|
||||
}, nil, nil, nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/cloud/msp/signup?tier=growth", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.HandleMSPSignupPage(rec, req)
|
||||
|
||||
body := rec.Body.String()
|
||||
for _, v := range []string{`value="starter"`, `value="growth"`, `value="scale"`} {
|
||||
if !strings.Contains(body, v) {
|
||||
t.Fatalf("expected radio option %s", v)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(body, `value="growth" checked`) {
|
||||
t.Fatal("expected requested growth tier to be pre-checked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMSPSignupPostValidRedirectsToStripeWithMSPMetadata(t *testing.T) {
|
||||
h := NewPublicCloudSignupHandlers(&CPConfig{
|
||||
BaseURL: "https://cloud.example.com",
|
||||
StripeAPIKey: "sk_test_123",
|
||||
CloudMSPStarterPriceID: testMSPStarterPriceID,
|
||||
}, nil, nil, nil)
|
||||
|
||||
var meta map[string]string
|
||||
h.createCheckoutSession = func(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) {
|
||||
meta = params.Metadata
|
||||
return &stripe.CheckoutSession{URL: "https://checkout.stripe.com/c/pay/cs_msp"}, nil
|
||||
}
|
||||
|
||||
form := url.Values{"email": {"owner@example.com"}, "org_name": {"Quesys MSP"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/cloud/msp/signup", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.HandleMSPSignupPage(rec, req)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status=%d, want %d body=%q", rec.Code, http.StatusSeeOther, rec.Body.String())
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "https://checkout.stripe.com/c/pay/cs_msp" {
|
||||
t.Fatalf("location=%q, want stripe URL", loc)
|
||||
}
|
||||
if meta == nil {
|
||||
t.Fatal("expected checkout metadata")
|
||||
}
|
||||
if got := meta["account_kind"]; got != "msp" {
|
||||
t.Fatalf("account_kind=%q, want %q", got, "msp")
|
||||
}
|
||||
if got := meta["plan_version"]; got != "msp_starter" {
|
||||
t.Fatalf("plan_version=%q, want %q", got, "msp_starter")
|
||||
}
|
||||
if got := meta["signup_source"]; got != "public_msp_signup" {
|
||||
t.Fatalf("signup_source=%q, want %q", got, "public_msp_signup")
|
||||
}
|
||||
if got := meta["account_display_name"]; got != "Quesys MSP" {
|
||||
t.Fatalf("account_display_name=%q, want %q", got, "Quesys MSP")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMSPSignupPostTierSelectionPicksPrice(t *testing.T) {
|
||||
cfg := &CPConfig{
|
||||
BaseURL: "https://cloud.example.com",
|
||||
StripeAPIKey: "sk_test_123",
|
||||
CloudMSPStarterPriceID: testMSPStarterPriceID,
|
||||
CloudMSPGrowthPriceID: testMSPGrowthPriceID,
|
||||
CloudMSPScalePriceID: testMSPScalePriceID,
|
||||
}
|
||||
tests := []struct {
|
||||
tier string
|
||||
wantPriceID string
|
||||
}{
|
||||
{"", testMSPStarterPriceID},
|
||||
{"starter", testMSPStarterPriceID},
|
||||
{"growth", testMSPGrowthPriceID},
|
||||
{"scale", testMSPScalePriceID},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run("tier="+tt.tier, func(t *testing.T) {
|
||||
h := NewPublicCloudSignupHandlers(cfg, nil, nil, nil)
|
||||
var priceID string
|
||||
h.createCheckoutSession = func(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) {
|
||||
if len(params.LineItems) > 0 && params.LineItems[0].Price != nil {
|
||||
priceID = *params.LineItems[0].Price
|
||||
}
|
||||
return &stripe.CheckoutSession{URL: "https://checkout.stripe.com/test"}, nil
|
||||
}
|
||||
form := url.Values{"email": {"o@example.com"}, "org_name": {"Quesys MSP"}, "tier": {tt.tier}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/cloud/msp/signup", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleMSPSignupPage(rec, req)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status=%d, want %d body=%q", rec.Code, http.StatusSeeOther, rec.Body.String())
|
||||
}
|
||||
if priceID != tt.wantPriceID {
|
||||
t.Fatalf("price_id=%q, want %q", priceID, tt.wantPriceID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMSPSignupPostUnconfiguredTierReturns400(t *testing.T) {
|
||||
h := NewPublicCloudSignupHandlers(&CPConfig{
|
||||
BaseURL: "https://cloud.example.com",
|
||||
StripeAPIKey: "sk_test_123",
|
||||
CloudMSPStarterPriceID: testMSPStarterPriceID,
|
||||
// growth/scale intentionally unconfigured
|
||||
}, nil, nil, nil)
|
||||
|
||||
form := url.Values{"email": {"o@example.com"}, "org_name": {"Quesys MSP"}, "tier": {"scale"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/cloud/msp/signup", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleMSPSignupPage(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d, want %d body=%q", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "not currently available") {
|
||||
t.Fatalf("expected tier-unavailable message, got %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMSPSignupRejectsCloudPriceMisconfiguration(t *testing.T) {
|
||||
// MSP starter price slot misconfigured with a cloud_starter price ID.
|
||||
// Must fail closed before calling Stripe — the MSP path only accepts
|
||||
// msp_* plan versions.
|
||||
h := NewPublicCloudSignupHandlers(&CPConfig{
|
||||
BaseURL: "https://cloud.example.com",
|
||||
StripeAPIKey: "sk_test_123",
|
||||
CloudMSPStarterPriceID: testCloudStarterPriceID, // cloud_starter, not msp_*
|
||||
}, nil, nil, nil)
|
||||
|
||||
stripeCalled := false
|
||||
h.createCheckoutSession = func(_ *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) {
|
||||
stripeCalled = true
|
||||
return &stripe.CheckoutSession{URL: "https://checkout.stripe.com/test"}, nil
|
||||
}
|
||||
|
||||
form := url.Values{"email": {"o@example.com"}, "org_name": {"Quesys MSP"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/cloud/msp/signup", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleMSPSignupPage(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status=%d, want %d", rec.Code, http.StatusBadGateway)
|
||||
}
|
||||
if stripeCalled {
|
||||
t.Fatal("expected cloud-price misconfiguration to fail closed before calling Stripe")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMSPSignupAPICreatesCheckout(t *testing.T) {
|
||||
h := NewPublicCloudSignupHandlers(&CPConfig{
|
||||
BaseURL: "https://cloud.example.com",
|
||||
StripeAPIKey: "sk_test_123",
|
||||
CloudMSPStarterPriceID: testMSPStarterPriceID,
|
||||
CloudMSPGrowthPriceID: testMSPGrowthPriceID,
|
||||
}, nil, nil, nil)
|
||||
|
||||
var priceID string
|
||||
h.createCheckoutSession = func(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) {
|
||||
if len(params.LineItems) > 0 && params.LineItems[0].Price != nil {
|
||||
priceID = *params.LineItems[0].Price
|
||||
}
|
||||
return &stripe.CheckoutSession{URL: "https://checkout.stripe.com/c/pay/cs_api"}, nil
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/public/msp/signup", strings.NewReader(`{"email":"o@example.com","org_name":"Quesys MSP","tier":"growth"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleMSPPublicSignup(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status=%d, want %d body=%q", rec.Code, http.StatusCreated, rec.Body.String())
|
||||
}
|
||||
if priceID != testMSPGrowthPriceID {
|
||||
t.Fatalf("price_id=%q, want %q", priceID, testMSPGrowthPriceID)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(asString(payload["checkout_url"])); got != "https://checkout.stripe.com/c/pay/cs_api" {
|
||||
t.Fatalf("checkout_url=%q, want stripe URL", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMSPSignupAPIUnconfiguredTierReturns400(t *testing.T) {
|
||||
h := NewPublicCloudSignupHandlers(&CPConfig{
|
||||
BaseURL: "https://cloud.example.com",
|
||||
StripeAPIKey: "sk_test_123",
|
||||
CloudMSPStarterPriceID: testMSPStarterPriceID,
|
||||
}, nil, nil, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/public/msp/signup", strings.NewReader(`{"email":"o@example.com","org_name":"Quesys MSP","tier":"scale"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleMSPPublicSignup(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d, want %d body=%q", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got := asString(payload["code"]); got != "tier_unavailable" {
|
||||
t.Fatalf("code=%q, want %q", got, "tier_unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMSPSignupCompleteRendersHandoff(t *testing.T) {
|
||||
h := NewPublicCloudSignupHandlers(&CPConfig{}, nil, nil, nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/cloud/msp/signup/complete", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.HandleMSPSignupComplete(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "Trial checkout complete") {
|
||||
t.Fatal("expected completion heading")
|
||||
}
|
||||
if !strings.Contains(body, "MSP portal") {
|
||||
t.Fatal("expected MSP portal handoff copy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMSPSignupCancelURLPreservesTier(t *testing.T) {
|
||||
h := NewPublicCloudSignupHandlers(&CPConfig{
|
||||
BaseURL: "https://cloud.example.com",
|
||||
StripeAPIKey: "sk_test_123",
|
||||
CloudMSPStarterPriceID: testMSPStarterPriceID,
|
||||
CloudMSPGrowthPriceID: testMSPGrowthPriceID,
|
||||
}, nil, nil, nil)
|
||||
|
||||
var cancelURL string
|
||||
h.createCheckoutSession = func(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) {
|
||||
if params.CancelURL != nil {
|
||||
cancelURL = *params.CancelURL
|
||||
}
|
||||
return &stripe.CheckoutSession{URL: "https://checkout.stripe.com/test"}, nil
|
||||
}
|
||||
|
||||
form := url.Values{"email": {"o@example.com"}, "org_name": {"Quesys MSP"}, "tier": {"growth"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/cloud/msp/signup", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleMSPSignupPage(rec, req)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status=%d, want %d", rec.Code, http.StatusSeeOther)
|
||||
}
|
||||
parsed, err := url.Parse(cancelURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse cancel URL: %v", err)
|
||||
}
|
||||
if got := parsed.Query().Get("tier"); got != "growth" {
|
||||
t.Fatalf("cancel URL tier=%q, want %q (URL: %s)", got, "growth", cancelURL)
|
||||
}
|
||||
if !strings.HasPrefix(parsed.Path, "/cloud/msp/signup") {
|
||||
t.Fatalf("cancel URL path=%q, want MSP signup path", parsed.Path)
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,13 @@ func RegisterRoutes(mux *http.ServeMux, deps *Deps) {
|
||||
mux.Handle("/signup/complete", publicSignupLimiter.Middleware(http.HandlerFunc(publicCloudSignupHandlers.HandleSignupComplete)))
|
||||
mux.Handle("/cloud/signup/complete", publicSignupLimiter.Middleware(http.HandlerFunc(publicCloudSignupHandlers.HandleSignupComplete)))
|
||||
mux.Handle("/api/public/signup", publicSignupLimiter.Middleware(http.HandlerFunc(publicCloudSignupHandlers.HandlePublicSignup)))
|
||||
|
||||
// Pulse Cloud for MSPs self-serve signup. Registered under the same
|
||||
// public-signup gate; stays inert (renders an unavailable state) until
|
||||
// an MSP tier price ID is configured in CP env.
|
||||
mux.Handle("/cloud/msp/signup", publicSignupLimiter.Middleware(http.HandlerFunc(publicCloudSignupHandlers.HandleMSPSignupPage)))
|
||||
mux.Handle("/cloud/msp/signup/complete", publicSignupLimiter.Middleware(http.HandlerFunc(publicCloudSignupHandlers.HandleMSPSignupComplete)))
|
||||
mux.Handle("/api/public/msp/signup", publicSignupLimiter.Middleware(http.HandlerFunc(publicCloudSignupHandlers.HandleMSPPublicSignup)))
|
||||
}
|
||||
|
||||
// Admin API (key-authenticated)
|
||||
|
||||
@@ -239,6 +239,52 @@ func TestRegisterRoutes_PublicCloudSignupRoutes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRoutes_PublicMSPSignupRoutes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg, err := registry.NewTenantRegistry(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewTenantRegistry: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = reg.Close() })
|
||||
|
||||
mux := http.NewServeMux()
|
||||
RegisterRoutes(mux, &Deps{
|
||||
Config: &CPConfig{
|
||||
DataDir: dir,
|
||||
AdminKey: "test-admin-key",
|
||||
BaseURL: "https://cloud.example.com",
|
||||
StripeWebhookSecret: "whsec_test",
|
||||
PublicCloudSignupEnabled: true,
|
||||
},
|
||||
Registry: reg,
|
||||
Version: "test",
|
||||
})
|
||||
|
||||
mspPageReq := httptest.NewRequest(http.MethodGet, "/cloud/msp/signup", nil)
|
||||
mspPageRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(mspPageRec, mspPageReq)
|
||||
if mspPageRec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /cloud/msp/signup status=%d, want %d", mspPageRec.Code, http.StatusOK)
|
||||
}
|
||||
if !strings.Contains(mspPageRec.Body.String(), "Pulse Cloud for MSPs") {
|
||||
t.Fatalf("expected public MSP signup page body")
|
||||
}
|
||||
|
||||
mspCompleteReq := httptest.NewRequest(http.MethodGet, "/cloud/msp/signup/complete", nil)
|
||||
mspCompleteRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(mspCompleteRec, mspCompleteReq)
|
||||
if mspCompleteRec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /cloud/msp/signup/complete status=%d, want %d", mspCompleteRec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
mspAPIGetReq := httptest.NewRequest(http.MethodGet, "/api/public/msp/signup", nil)
|
||||
mspAPIGetRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(mspAPIGetRec, mspAPIGetReq)
|
||||
if mspAPIGetRec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("GET /api/public/msp/signup status=%d, want %d", mspAPIGetRec.Code, http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRoutes_PublicCloudSignupRoutesDisabledByDefault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg, err := registry.NewTenantRegistry(dir)
|
||||
@@ -267,6 +313,9 @@ func TestRegisterRoutes_PublicCloudSignupRoutesDisabledByDefault(t *testing.T) {
|
||||
{method: http.MethodGet, path: "/cloud/signup"},
|
||||
{method: http.MethodGet, path: "/signup/complete"},
|
||||
{method: http.MethodPost, path: "/api/public/signup"},
|
||||
{method: http.MethodGet, path: "/cloud/msp/signup"},
|
||||
{method: http.MethodGet, path: "/cloud/msp/signup/complete"},
|
||||
{method: http.MethodPost, path: "/api/public/msp/signup"},
|
||||
} {
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
Reference in New Issue
Block a user