Differentiate hosted trial terminal failure guidance

Use explicit hosted trial completion failure kinds so retryable errors can keep restart links while terminal conflicts and service outages show the correct next-step guidance.
This commit is contained in:
rcourtman
2026-03-25 11:22:01 +00:00
parent 4daf838a5e
commit a498c40fb2
3 changed files with 79 additions and 52 deletions
@@ -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
+63 -49
View File
@@ -485,9 +485,7 @@ var trialSignupFailureTemplate = template.Must(template.New("trial-signup-failur
<h1>{{.Title}}</h1>
<p>{{.Message}}</p>
<div class="status">
Return to Pulse and start the secure trial handoff again if you still want to activate Pro on this instance.
</div>
<div class="status">{{.StatusMessage}}</div>
<div class="meta">
<strong>Pulse instance</strong>
@@ -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
}
+13 -2
View File
@@ -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://")