diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md
index a71f26f50..7a4424c57 100644
--- a/docs/release-control/v6/internal/subsystems/cloud-paid.md
+++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md
@@ -269,7 +269,9 @@ 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.
+Pulse return target and initiation binding are still known. Terminal conflicts
+such as "trial already used" must not present restart as the recommended next
+step.
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 36640be34..2785b6e44 100644
--- a/internal/cloudcp/trial_signup_handlers.go
+++ b/internal/cloudcp/trial_signup_handlers.go
@@ -485,9 +485,7 @@ var trialSignupFailureTemplate = template.Must(template.New("trial-signup-failur
{{.Title}}
{{.Message}}
-
- Return to Pulse and start the secure trial handoff again if you still want to activate Pro on this instance.
-
+ {{.StatusMessage}}
Pulse instance
@@ -536,15 +534,24 @@ type trialSignupSuccessData struct {
}
type trialSignupFailureData struct {
- Title string
- Message string
- ReturnTarget string
- ActionURL string
- ActionLabel string
- FinePrint string
- Nonce string
+ Title string
+ Message string
+ ReturnTarget string
+ StatusMessage string
+ ActionURL string
+ ActionLabel string
+ FinePrint string
+ Nonce string
}
+type trialSignupFailureKind string
+
+const (
+ trialSignupFailureRetryable trialSignupFailureKind = "retryable"
+ trialSignupFailureConflict trialSignupFailureKind = "conflict"
+ trialSignupFailureUnavailable trialSignupFailureKind = "unavailable"
+)
+
type trialSignupRedeemRequest struct {
Token string `json:"token"`
}
@@ -929,17 +936,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))
+ fail := func(status int, kind trialSignupFailureKind, data trialSignupPageData, message string) {
+ h.renderTrialSignupFailurePage(w, r, status, trialSignupFailureDataForPage(h.cfg, data, kind, message))
}
sessionID := strings.TrimSpace(r.URL.Query().Get("session_id"))
if sessionID == "" {
- fail(http.StatusBadRequest, trialSignupPageData{}, "This secure trial session is missing its completion token.")
+ fail(http.StatusBadRequest, trialSignupFailureRetryable, trialSignupPageData{}, "This secure trial session is missing its completion token.")
return
}
if strings.TrimSpace(h.cfg.StripeAPIKey) == "" {
- fail(http.StatusServiceUnavailable, trialSignupPageData{}, "Secure trial setup is unavailable right now.")
+ fail(http.StatusServiceUnavailable, trialSignupFailureUnavailable, trialSignupPageData{}, "Secure trial setup is unavailable right now.")
return
}
@@ -947,7 +954,7 @@ 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")
- fail(http.StatusBadRequest, trialSignupPageData{}, "This secure trial session could not be confirmed.")
+ fail(http.StatusBadRequest, trialSignupFailureRetryable, trialSignupPageData{}, "This secure trial session could not be confirmed.")
return
}
data := trialSignupPageData{
@@ -956,26 +963,26 @@ func (h *TrialSignupHandlers) HandleTrialSignupComplete(w http.ResponseWriter, r
ReturnTarget: summarizeTrialReturnTarget(session.Metadata["return_url"]),
}
if session.Status != stripe.CheckoutSessionStatusComplete {
- fail(http.StatusBadRequest, data, "This secure trial session has not completed yet.")
+ fail(http.StatusBadRequest, trialSignupFailureRetryable, data, "This secure trial session has not completed yet.")
return
}
if session.Mode != stripe.CheckoutSessionModeSubscription {
- fail(http.StatusBadRequest, data, "This secure trial session is not valid for a Pulse Pro trial.")
+ fail(http.StatusBadRequest, trialSignupFailureRetryable, data, "This secure trial session is not valid for a Pulse Pro trial.")
return
}
if strings.TrimSpace(session.Metadata["signup_source"]) != "pulse_pro_trial" {
- fail(http.StatusBadRequest, data, "This secure trial session is not valid for a Pulse Pro trial.")
+ fail(http.StatusBadRequest, trialSignupFailureRetryable, data, "This secure trial session is not valid for a Pulse Pro trial.")
return
}
returnURL := strings.TrimSpace(session.Metadata["return_url"])
if !isValidTrialReturnURL(returnURL) {
- fail(http.StatusBadRequest, data, "This trial return target is no longer valid.")
+ fail(http.StatusBadRequest, trialSignupFailureRetryable, data, "This trial return target is no longer valid.")
return
}
instanceHost, err := trialSignupReturnURLHost(returnURL)
if err != nil {
- fail(http.StatusBadRequest, data, "This trial return target is no longer valid.")
+ fail(http.StatusBadRequest, trialSignupFailureRetryable, data, "This trial return target is no longer valid.")
return
}
@@ -991,16 +998,16 @@ func (h *TrialSignupHandlers) HandleTrialSignupComplete(w http.ResponseWriter, r
now := h.now().UTC()
requestID := strings.TrimSpace(session.Metadata["trial_request_id"])
if requestID == "" {
- fail(http.StatusBadRequest, data, "This secure trial session is missing its request binding.")
+ fail(http.StatusBadRequest, trialSignupFailureRetryable, data, "This secure trial session is missing its request binding.")
return
}
if h.verificationStore == nil {
- fail(http.StatusServiceUnavailable, data, "Secure trial setup is unavailable right now.")
+ fail(http.StatusServiceUnavailable, trialSignupFailureUnavailable, data, "Secure trial setup is unavailable right now.")
return
}
record, err := h.verificationStore.GetRecord(requestID)
if err != nil {
- fail(http.StatusBadRequest, data, "This secure trial request could not be confirmed.")
+ fail(http.StatusBadRequest, trialSignupFailureRetryable, data, "This secure trial request could not be confirmed.")
return
}
data.InstanceToken = record.InstanceToken
@@ -1008,55 +1015,55 @@ func (h *TrialSignupHandlers) HandleTrialSignupComplete(w http.ResponseWriter, r
data.Email = record.Email
data.Company = record.Company
if record.VerifiedAt.IsZero() {
- fail(http.StatusBadRequest, data, "This secure trial request could not be confirmed.")
+ fail(http.StatusBadRequest, trialSignupFailureRetryable, data, "This secure trial request could not be confirmed.")
return
}
if strings.TrimSpace(record.OrgID) != orgID {
- fail(http.StatusBadRequest, data, "This secure trial request does not match the originating workspace.")
+ fail(http.StatusBadRequest, trialSignupFailureRetryable, data, "This secure trial request does not match the originating workspace.")
return
}
if strings.TrimSpace(record.ReturnURL) != returnURL {
- fail(http.StatusBadRequest, data, "This secure trial request does not match the originating Pulse instance.")
+ fail(http.StatusBadRequest, trialSignupFailureRetryable, data, "This secure trial request does not match the originating Pulse instance.")
return
}
if strings.TrimSpace(record.InstanceToken) == "" {
- fail(http.StatusBadRequest, data, "This secure trial request is missing its Pulse initiation binding.")
+ fail(http.StatusBadRequest, trialSignupFailureRetryable, data, "This secure trial request is missing its Pulse initiation binding.")
return
}
if verifiedEmail := normalizeTrialSignupEmail(record.Email); verifiedEmail == "" || normalizeTrialSignupEmail(email) != verifiedEmail {
- fail(http.StatusBadRequest, data, "This secure trial request does not match the verified recovery contact.")
+ fail(http.StatusBadRequest, trialSignupFailureRetryable, data, "This secure trial request does not match the verified recovery contact.")
return
}
if existingSessionID := strings.TrimSpace(record.CheckoutSessionID); existingSessionID != "" && existingSessionID != sessionID {
- fail(http.StatusBadRequest, data, "This secure trial request does not match the checkout session that was already started.")
+ fail(http.StatusBadRequest, trialSignupFailureRetryable, 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):
- fail(http.StatusBadRequest, data, "This secure trial request could not be confirmed.")
+ fail(http.StatusBadRequest, trialSignupFailureRetryable, 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")
- fail(http.StatusInternalServerError, data, "Pulse could not finish recording this secure trial checkout.")
+ fail(http.StatusInternalServerError, trialSignupFailureUnavailable, 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):
- fail(http.StatusConflict, data, "This recovery email has already used a Pulse Pro trial.")
+ fail(http.StatusConflict, trialSignupFailureConflict, data, "This recovery email has already used a Pulse Pro trial.")
case errors.Is(err, ErrTrialSignupOrganizationUsed):
- fail(http.StatusConflict, data, "This organization has already used a Pulse Pro trial.")
+ fail(http.StatusConflict, trialSignupFailureConflict, data, "This organization has already used a Pulse Pro trial.")
case errors.Is(err, ErrTrialSignupRecordNotFound), errors.Is(err, ErrTrialSignupVerificationInvalid):
- fail(http.StatusBadRequest, data, "This secure trial request could not be confirmed.")
+ fail(http.StatusBadRequest, trialSignupFailureRetryable, data, "This secure trial request could not be confirmed.")
default:
log.Error().Err(err).Str("request_id", requestID).Msg("failed to record trial issuance")
- fail(http.StatusInternalServerError, data, "Pulse could not finalize this trial issuance.")
+ fail(http.StatusInternalServerError, trialSignupFailureUnavailable, data, "Pulse could not finalize this trial issuance.")
}
return
}
if h.entitlements == nil {
- fail(http.StatusServiceUnavailable, data, "Secure trial activation is unavailable right now.")
+ fail(http.StatusServiceUnavailable, trialSignupFailureUnavailable, data, "Secure trial activation is unavailable right now.")
return
}
token, err := h.entitlements.IssueTrialActivation(entitlements.TrialActivationInput{
@@ -1073,7 +1080,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")
- fail(http.StatusInternalServerError, data, "Pulse could not prepare the activation handoff for this trial.")
+ fail(http.StatusInternalServerError, trialSignupFailureUnavailable, data, "Pulse could not prepare the activation handoff for this trial.")
return
}
@@ -1082,7 +1089,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")
- fail(http.StatusInternalServerError, data, "Pulse could not prepare the return link back to your instance.")
+ fail(http.StatusInternalServerError, trialSignupFailureUnavailable, data, "Pulse could not prepare the return link back to your instance.")
return
}
h.renderTrialSignupSuccessPage(w, r, http.StatusOK, trialSignupSuccessData{
@@ -1377,24 +1384,28 @@ func normalizeTrialOrgID(raw string) string {
return orgID
}
-func trialSignupFailureDataForPage(cfg *CPConfig, data trialSignupPageData, message string) trialSignupFailureData {
+func trialSignupFailureDataForPage(cfg *CPConfig, data trialSignupPageData, kind trialSignupFailureKind, 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."
+ statusMessage := "Return to Pulse and start the secure trial handoff again if you still want to activate Pro on this instance."
+ finePrint := "Pulse could not complete this secure trial handoff."
- switch {
- case strings.Contains(strings.ToLower(message), "already used"):
+ switch kind {
+ case trialSignupFailureConflict:
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"):
+ statusMessage = "This trial request cannot be restarted for the same recovery contact or organization."
+ finePrint = "Upgrade the existing account or contact support if you need help reconciling prior trial usage."
+ case trialSignupFailureUnavailable:
title = "Trial setup is unavailable"
- finePrint = "Pulse could not finish the secure trial handoff right now. Return to Pulse and try again later."
+ statusMessage = "Pulse could not finish the secure trial handoff right now."
+ finePrint = "Return to Pulse and try again later."
}
page := trialSignupFailureData{
- Title: title,
- Message: strings.TrimSpace(message),
- ReturnTarget: strings.TrimSpace(data.ReturnTarget),
- FinePrint: finePrint,
+ Title: title,
+ Message: strings.TrimSpace(message),
+ ReturnTarget: strings.TrimSpace(data.ReturnTarget),
+ StatusMessage: statusMessage,
+ FinePrint: finePrint,
}
if page.Message == "" {
page.Message = "Pulse could not complete this secure trial handoff."
@@ -1402,6 +1413,9 @@ func trialSignupFailureDataForPage(cfg *CPConfig, data trialSignupPageData, mess
if page.ReturnTarget == "" {
page.ReturnTarget = "your Pulse instance"
}
+ if kind != trialSignupFailureRetryable {
+ return page
+ }
if strings.TrimSpace(data.ReturnURL) == "" || strings.TrimSpace(data.InstanceToken) == "" {
return page
}
diff --git a/internal/cloudcp/trial_signup_handlers_test.go b/internal/cloudcp/trial_signup_handlers_test.go
index 071da2530..4fdbf2b67 100644
--- a/internal/cloudcp/trial_signup_handlers_test.go
+++ b/internal/cloudcp/trial_signup_handlers_test.go
@@ -1080,8 +1080,9 @@ func TestTrialSignupHandleCompleteRejectsDuplicateIssuedEmail(t *testing.T) {
"Trial already used",
"This recovery email has already used a Pulse Pro trial.",
"pulse.example.com",
- "Start Trial Again",
+ "This trial request cannot be restarted for the same recovery contact or organization.",
)
+ assertTrialSignupFailurePageOmits(t, rec.Body.String(), "Start Trial Again")
}
func TestTrialSignupHandleCompleteRejectsDuplicateCorporateDomainIssuedEmail(t *testing.T) {
@@ -1154,8 +1155,9 @@ func TestTrialSignupHandleCompleteRejectsDuplicateCorporateDomainIssuedEmail(t *
"Trial already used",
"This organization has already used a Pulse Pro trial.",
"pulse.example.com",
- "Start Trial Again",
+ "This trial request cannot be restarted for the same recovery contact or organization.",
)
+ assertTrialSignupFailurePageOmits(t, rec.Body.String(), "Start Trial Again")
}
func TestTrialSignupHandleCompleteRejectsCheckoutSessionMismatch(t *testing.T) {
@@ -1426,6 +1428,15 @@ func assertTrialSignupFailurePageContains(t *testing.T, body string, parts ...st
}
}
+func assertTrialSignupFailurePageOmits(t *testing.T, body string, parts ...string) {
+ t.Helper()
+ for _, part := range parts {
+ if strings.Contains(body, part) {
+ t.Fatalf("expected response body to omit %q, got %q", part, body)
+ }
+ }
+}
+
func extractQueryValueFromTextURL(t *testing.T, text, key string) string {
t.Helper()
start := strings.Index(text, "https://")