diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 5f6d52f8b..a71f26f50 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -265,6 +265,11 @@ copy must present the flow as starting a trial for the originating Pulse instance, not as a generic purchase funnel. Recovery-contact fields such as work email and optional company name must remain clearly secondary to the instance-bound entitlement handoff. +That same hosted owner also applies after Stripe returns to +`/trial-signup/complete`: customer-facing completion failures must stay inside +owned trial UX rather than dropping raw control-plane error strings, and they +may only offer a direct "Start Trial Again" restart path when the original +Pulse return target and initiation binding are still known. That same rule applies to the self-hosted Pro settings panel. Trial start errors in `frontend-modern/src/components/Settings/useProLicensePanelState.ts` must route through the shared cloud-paid presentation helper so backend denial diff --git a/internal/cloudcp/trial_signup_handlers.go b/internal/cloudcp/trial_signup_handlers.go index f58c09b6c..36640be34 100644 --- a/internal/cloudcp/trial_signup_handlers.go +++ b/internal/cloudcp/trial_signup_handlers.go @@ -382,6 +382,126 @@ var trialSignupSuccessTemplate = template.Must(template.New("trial-signup-succes `)) +var trialSignupFailureTemplate = template.Must(template.New("trial-signup-failure").Parse(` + + + + + Pulse Pro Trial Setup Issue + + + +
+
+
Pulse Pro Trial
+

{{.Title}}

+

{{.Message}}

+ +
+ Return to Pulse and start the secure trial handoff again if you still want to activate Pro on this instance. +
+ +
+ Pulse instance +
{{.ReturnTarget}}
+
+ + {{if .ActionURL}}{{.ActionLabel}}{{end}} +

{{.FinePrint}}

+
+
+ + +`)) + type TrialSignupHandlers struct { cfg *CPConfig emailSender cpemail.Sender @@ -415,6 +535,16 @@ type trialSignupSuccessData struct { Nonce string } +type trialSignupFailureData struct { + Title string + Message string + ReturnTarget string + ActionURL string + ActionLabel string + FinePrint string + Nonce string +} + type trialSignupRedeemRequest struct { Token string `json:"token"` } @@ -799,13 +929,17 @@ func (h *TrialSignupHandlers) HandleTrialSignupComplete(w http.ResponseWriter, r return } + fail := func(status int, data trialSignupPageData, message string) { + h.renderTrialSignupFailurePage(w, r, status, trialSignupFailureDataForPage(h.cfg, data, message)) + } + sessionID := strings.TrimSpace(r.URL.Query().Get("session_id")) if sessionID == "" { - http.Error(w, "session_id is required", http.StatusBadRequest) + fail(http.StatusBadRequest, trialSignupPageData{}, "This secure trial session is missing its completion token.") return } if strings.TrimSpace(h.cfg.StripeAPIKey) == "" { - http.Error(w, "stripe api key not configured", http.StatusServiceUnavailable) + fail(http.StatusServiceUnavailable, trialSignupPageData{}, "Secure trial setup is unavailable right now.") return } @@ -813,30 +947,35 @@ func (h *TrialSignupHandlers) HandleTrialSignupComplete(w http.ResponseWriter, r session, err := h.getCheckoutSession(sessionID, nil) if err != nil || session == nil { log.Warn().Err(err).Str("session_id", sessionID).Msg("trial signup checkout session lookup failed") - http.Error(w, "invalid checkout session", http.StatusBadRequest) + fail(http.StatusBadRequest, trialSignupPageData{}, "This secure trial session could not be confirmed.") return } + data := trialSignupPageData{ + OrgID: normalizeTrialOrgID(session.Metadata["org_id"]), + ReturnURL: strings.TrimSpace(session.Metadata["return_url"]), + ReturnTarget: summarizeTrialReturnTarget(session.Metadata["return_url"]), + } if session.Status != stripe.CheckoutSessionStatusComplete { - http.Error(w, "checkout session not complete", http.StatusBadRequest) + fail(http.StatusBadRequest, data, "This secure trial session has not completed yet.") return } if session.Mode != stripe.CheckoutSessionModeSubscription { - http.Error(w, "invalid checkout session mode", http.StatusBadRequest) + fail(http.StatusBadRequest, data, "This secure trial session is not valid for a Pulse Pro trial.") return } if strings.TrimSpace(session.Metadata["signup_source"]) != "pulse_pro_trial" { - http.Error(w, "invalid trial signup source", http.StatusBadRequest) + fail(http.StatusBadRequest, data, "This secure trial session is not valid for a Pulse Pro trial.") return } returnURL := strings.TrimSpace(session.Metadata["return_url"]) if !isValidTrialReturnURL(returnURL) { - http.Error(w, "invalid return url", http.StatusBadRequest) + fail(http.StatusBadRequest, data, "This trial return target is no longer valid.") return } instanceHost, err := trialSignupReturnURLHost(returnURL) if err != nil { - http.Error(w, "invalid return url host", http.StatusBadRequest) + fail(http.StatusBadRequest, data, "This trial return target is no longer valid.") return } @@ -852,68 +991,72 @@ func (h *TrialSignupHandlers) HandleTrialSignupComplete(w http.ResponseWriter, r now := h.now().UTC() requestID := strings.TrimSpace(session.Metadata["trial_request_id"]) if requestID == "" { - http.Error(w, "missing trial request id", http.StatusBadRequest) + fail(http.StatusBadRequest, data, "This secure trial session is missing its request binding.") return } if h.verificationStore == nil { - http.Error(w, "trial signup store not configured", http.StatusServiceUnavailable) + fail(http.StatusServiceUnavailable, data, "Secure trial setup is unavailable right now.") return } record, err := h.verificationStore.GetRecord(requestID) if err != nil { - http.Error(w, "invalid trial request", http.StatusBadRequest) + fail(http.StatusBadRequest, data, "This secure trial request could not be confirmed.") return } + data.InstanceToken = record.InstanceToken + data.Name = record.Name + data.Email = record.Email + data.Company = record.Company if record.VerifiedAt.IsZero() { - http.Error(w, "invalid trial request", http.StatusBadRequest) + fail(http.StatusBadRequest, data, "This secure trial request could not be confirmed.") return } if strings.TrimSpace(record.OrgID) != orgID { - http.Error(w, "trial request org mismatch", http.StatusBadRequest) + fail(http.StatusBadRequest, data, "This secure trial request does not match the originating workspace.") return } if strings.TrimSpace(record.ReturnURL) != returnURL { - http.Error(w, "trial request return url mismatch", http.StatusBadRequest) + fail(http.StatusBadRequest, data, "This secure trial request does not match the originating Pulse instance.") return } if strings.TrimSpace(record.InstanceToken) == "" { - http.Error(w, "trial request initiation token missing", http.StatusBadRequest) + fail(http.StatusBadRequest, data, "This secure trial request is missing its Pulse initiation binding.") return } if verifiedEmail := normalizeTrialSignupEmail(record.Email); verifiedEmail == "" || normalizeTrialSignupEmail(email) != verifiedEmail { - http.Error(w, "trial request email mismatch", http.StatusBadRequest) + fail(http.StatusBadRequest, data, "This secure trial request does not match the verified recovery contact.") return } if existingSessionID := strings.TrimSpace(record.CheckoutSessionID); existingSessionID != "" && existingSessionID != sessionID { - http.Error(w, "trial request checkout session mismatch", http.StatusBadRequest) + fail(http.StatusBadRequest, data, "This secure trial request does not match the checkout session that was already started.") return } if err := h.verificationStore.MarkCheckoutCompleted(requestID, sessionID, now); err != nil { switch { case errors.Is(err, ErrTrialSignupRecordNotFound): - http.Error(w, "invalid trial request", http.StatusBadRequest) + fail(http.StatusBadRequest, data, "This secure trial request could not be confirmed.") default: log.Error().Err(err).Str("request_id", requestID).Str("session_id", sessionID).Msg("failed to record checkout completion") - http.Error(w, "failed to record checkout completion", http.StatusInternalServerError) + fail(http.StatusInternalServerError, data, "Pulse could not finish recording this secure trial checkout.") } return } if err := h.verificationStore.MarkTrialIssued(requestID, now); err != nil { switch { case errors.Is(err, ErrTrialSignupEmailAlreadyUsed): - http.Error(w, "trial already used for this email", http.StatusConflict) + fail(http.StatusConflict, data, "This recovery email has already used a Pulse Pro trial.") case errors.Is(err, ErrTrialSignupOrganizationUsed): - http.Error(w, "trial already used for this organization", http.StatusConflict) + fail(http.StatusConflict, data, "This organization has already used a Pulse Pro trial.") case errors.Is(err, ErrTrialSignupRecordNotFound), errors.Is(err, ErrTrialSignupVerificationInvalid): - http.Error(w, "invalid trial request", http.StatusBadRequest) + fail(http.StatusBadRequest, data, "This secure trial request could not be confirmed.") default: log.Error().Err(err).Str("request_id", requestID).Msg("failed to record trial issuance") - http.Error(w, "failed to record trial issuance", http.StatusInternalServerError) + fail(http.StatusInternalServerError, data, "Pulse could not finalize this trial issuance.") } return } if h.entitlements == nil { - http.Error(w, "hosted entitlement service unavailable", http.StatusServiceUnavailable) + fail(http.StatusServiceUnavailable, data, "Secure trial activation is unavailable right now.") return } token, err := h.entitlements.IssueTrialActivation(entitlements.TrialActivationInput{ @@ -930,7 +1073,7 @@ func (h *TrialSignupHandlers) HandleTrialSignupComplete(w http.ResponseWriter, r }) if err != nil { log.Error().Err(err).Str("request_id", requestID).Str("session_id", sessionID).Msg("failed to persist trial activation token") - http.Error(w, "failed to persist activation token", http.StatusInternalServerError) + fail(http.StatusInternalServerError, data, "Pulse could not prepare the activation handoff for this trial.") return } @@ -939,7 +1082,7 @@ func (h *TrialSignupHandlers) HandleTrialSignupComplete(w http.ResponseWriter, r }) if err != nil { log.Error().Err(err).Str("return_url", returnURL).Msg("failed to build trial activation redirect URL") - http.Error(w, "failed to build redirect URL", http.StatusInternalServerError) + fail(http.StatusInternalServerError, data, "Pulse could not prepare the return link back to your instance.") return } h.renderTrialSignupSuccessPage(w, r, http.StatusOK, trialSignupSuccessData{ @@ -1148,6 +1291,15 @@ func (h *TrialSignupHandlers) trialSignupPageDataFromRequest(r *http.Request) tr return data } +func (h *TrialSignupHandlers) renderTrialSignupFailurePage(w http.ResponseWriter, r *http.Request, status int, data trialSignupFailureData) { + data.Nonce = cpsec.NonceFromContext(r.Context()) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + if err := trialSignupFailureTemplate.Execute(w, data); err != nil { + log.Error().Err(err).Msg("trial signup failure page render failed") + } +} + func trialSignupRateLimitMessage(retryAfter int) string { return "Too many trial setup attempts from this browser. " + humanizeTrialSignupRetryAfter(retryAfter) + "." } @@ -1225,6 +1377,43 @@ func normalizeTrialOrgID(raw string) string { return orgID } +func trialSignupFailureDataForPage(cfg *CPConfig, data trialSignupPageData, message string) trialSignupFailureData { + title := "Trial setup could not be completed" + finePrint := "Return to Pulse and restart the secure trial handoff if you still want to activate Pro on this instance." + + switch { + case strings.Contains(strings.ToLower(message), "already used"): + title = "Trial already used" + finePrint = "This hosted trial flow cannot issue another Pro trial for the same recovery contact or organization." + case strings.Contains(strings.ToLower(message), "unavailable"): + title = "Trial setup is unavailable" + finePrint = "Pulse could not finish the secure trial handoff right now. Return to Pulse and try again later." + } + + page := trialSignupFailureData{ + Title: title, + Message: strings.TrimSpace(message), + ReturnTarget: strings.TrimSpace(data.ReturnTarget), + FinePrint: finePrint, + } + if page.Message == "" { + page.Message = "Pulse could not complete this secure trial handoff." + } + if page.ReturnTarget == "" { + page.ReturnTarget = "your Pulse instance" + } + if strings.TrimSpace(data.ReturnURL) == "" || strings.TrimSpace(data.InstanceToken) == "" { + return page + } + baseURL := "" + if cfg != nil { + baseURL = cfg.BaseURL + } + page.ActionURL = buildTrialSignupStartURL(baseURL, data, false) + page.ActionLabel = "Start Trial Again" + return page +} + func buildCPURL(baseURL, path string, query url.Values) string { base := strings.TrimSpace(baseURL) if base == "" { diff --git a/internal/cloudcp/trial_signup_handlers_test.go b/internal/cloudcp/trial_signup_handlers_test.go index 290d7396b..071da2530 100644 --- a/internal/cloudcp/trial_signup_handlers_test.go +++ b/internal/cloudcp/trial_signup_handlers_test.go @@ -1076,9 +1076,12 @@ func TestTrialSignupHandleCompleteRejectsDuplicateIssuedEmail(t *testing.T) { if rec.Code != http.StatusConflict { t.Fatalf("status=%d, want %d body=%q", rec.Code, http.StatusConflict, rec.Body.String()) } - if !strings.Contains(rec.Body.String(), "trial already used for this email") { - t.Fatalf("expected duplicate issuance error, got %q", rec.Body.String()) - } + assertTrialSignupFailurePageContains(t, rec.Body.String(), + "Trial already used", + "This recovery email has already used a Pulse Pro trial.", + "pulse.example.com", + "Start Trial Again", + ) } func TestTrialSignupHandleCompleteRejectsDuplicateCorporateDomainIssuedEmail(t *testing.T) { @@ -1147,9 +1150,12 @@ func TestTrialSignupHandleCompleteRejectsDuplicateCorporateDomainIssuedEmail(t * if rec.Code != http.StatusConflict { t.Fatalf("status=%d, want %d body=%q", rec.Code, http.StatusConflict, rec.Body.String()) } - if !strings.Contains(rec.Body.String(), "trial already used for this organization") { - t.Fatalf("expected organization duplicate issuance error, got %q", rec.Body.String()) - } + assertTrialSignupFailurePageContains(t, rec.Body.String(), + "Trial already used", + "This organization has already used a Pulse Pro trial.", + "pulse.example.com", + "Start Trial Again", + ) } func TestTrialSignupHandleCompleteRejectsCheckoutSessionMismatch(t *testing.T) { @@ -1186,9 +1192,12 @@ func TestTrialSignupHandleCompleteRejectsCheckoutSessionMismatch(t *testing.T) { 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(), "checkout session mismatch") { - t.Fatalf("expected checkout session mismatch, got %q", rec.Body.String()) - } + assertTrialSignupFailurePageContains(t, rec.Body.String(), + "Trial setup could not be completed", + "This secure trial request does not match the checkout session that was already started.", + "pulse.example.com", + "Start Trial Again", + ) } func TestTrialSignupHandleCompleteRejectsNonTrialCheckoutSession(t *testing.T) { @@ -1249,9 +1258,11 @@ func TestTrialSignupHandleCompleteRejectsNonTrialCheckoutSession(t *testing.T) { 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(), "invalid checkout session mode") { - t.Fatalf("expected invalid mode error, got %q", rec.Body.String()) - } + assertTrialSignupFailurePageContains(t, rec.Body.String(), + "Trial setup could not be completed", + "This secure trial session is not valid for a Pulse Pro trial.", + "pulse.example.com", + ) _ = pub } @@ -1314,9 +1325,11 @@ func TestTrialSignupHandleCompleteRejectsMissingVerifiedTrialMetadata(t *testing 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(), "invalid trial signup source") { - t.Fatalf("expected invalid signup source error, got %q", rec.Body.String()) - } + assertTrialSignupFailurePageContains(t, rec.Body.String(), + "Trial setup could not be completed", + "This secure trial session is not valid for a Pulse Pro trial.", + "pulse.example.com", + ) } func newTrialSignupTestHandler(t *testing.T) (*TrialSignupHandlers, *TrialSignupStore, *captureEmailSender) { @@ -1401,6 +1414,18 @@ func parseVerifiedTokenRequestID(t *testing.T, h *TrialSignupHandlers, verifiedT return record.ID } +func assertTrialSignupFailurePageContains(t *testing.T, body string, parts ...string) { + t.Helper() + if !strings.Contains(body, "Pulse Pro Trial") { + t.Fatalf("expected trial failure page chrome, got %q", body) + } + for _, part := range parts { + if !strings.Contains(body, part) { + t.Fatalf("expected response body to contain %q, got %q", part, body) + } + } +} + func extractQueryValueFromTextURL(t *testing.T, text, key string) string { t.Helper() start := strings.Index(text, "https://")