mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 11:46:28 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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://")
|
||||
|
||||
Reference in New Issue
Block a user