From b204ed5d86571ebd39e7e7da9ecd59e0f3bb929f Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 25 Mar 2026 11:25:08 +0000 Subject: [PATCH] Preserve typed hosted trial issuance conflicts Keep hosted trial request-verification and checkout pages aligned with the canonical issuance conflict type so duplicate recovery-email usage is surfaced distinctly from organization reuse. --- .../v6/internal/subsystems/cloud-paid.md | 4 ++- internal/cloudcp/trial_signup_handlers.go | 15 +++++++-- .../cloudcp/trial_signup_handlers_test.go | 33 ++++++++++++++++++- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 7a4424c57..2bef44d81 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -264,7 +264,9 @@ still use a secure hosted Stripe-backed session internally, but the customer 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. +instance-bound entitlement handoff. Hosted form-stage issuance conflicts must +also preserve the canonical reason shape: duplicate recovery-email usage must +not be flattened into an organization-level message. 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 diff --git a/internal/cloudcp/trial_signup_handlers.go b/internal/cloudcp/trial_signup_handlers.go index 2785b6e44..fe2f5fa72 100644 --- a/internal/cloudcp/trial_signup_handlers.go +++ b/internal/cloudcp/trial_signup_handlers.go @@ -664,7 +664,7 @@ func (h *TrialSignupHandlers) HandleRequestVerification(w http.ResponseWriter, r return } if conflict != nil { - data.ErrorMessage = "This organization has already used a Pulse Pro trial. Upgrade the existing account or contact support if you need help." + data.ErrorMessage = trialSignupIssuanceConflictMessage(conflict) h.renderTrialSignupPage(w, r, http.StatusConflict, data) return } @@ -842,7 +842,7 @@ func (h *TrialSignupHandlers) HandleCheckout(w http.ResponseWriter, r *http.Requ return } if conflict != nil { - data.ErrorMessage = "This organization has already used a Pulse Pro trial. Upgrade the existing account or contact support if you need help." + data.ErrorMessage = trialSignupIssuanceConflictMessage(conflict) h.renderTrialSignupPage(w, r, http.StatusConflict, data) return } @@ -1428,6 +1428,17 @@ func trialSignupFailureDataForPage(cfg *CPConfig, data trialSignupPageData, kind return page } +func trialSignupIssuanceConflictMessage(conflict *TrialSignupIssuanceConflict) string { + switch { + case conflict == nil: + return "This Pulse Pro trial request conflicts with a previous trial issuance. Upgrade the existing account or contact support if you need help." + case conflict.Kind == trialSignupConflictEmail: + return "This recovery email has already used a Pulse Pro trial. Upgrade the existing account or contact support if you need help." + default: + return "This organization has already used a Pulse Pro trial. Upgrade the existing account or contact support if you need help." + } +} + 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 4fdbf2b67..31a6feece 100644 --- a/internal/cloudcp/trial_signup_handlers_test.go +++ b/internal/cloudcp/trial_signup_handlers_test.go @@ -186,7 +186,7 @@ func TestTrialSignupHandleRequestVerificationRejectsEmailThatAlreadyUsedTrial(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(), "organization has already used a Pulse Pro trial") { + if !strings.Contains(rec.Body.String(), "recovery email has already used a Pulse Pro trial") { t.Fatalf("expected duplicate trial message, got %q", rec.Body.String()) } } @@ -409,6 +409,37 @@ func TestTrialSignupHandleCheckoutCreatesFreshSessionWhenRecordAlreadyHasSession } } +func TestTrialSignupHandleCheckoutRejectsEmailThatAlreadyUsedTrial(t *testing.T) { + h, store, sender := newTrialSignupTestHandler(t) + rawToken := requestTrialVerification(t, h, sender) + verifiedToken := verifyTrialRequest(t, h, rawToken) + requestID := parseVerifiedTokenRequestID(t, h, verifiedToken) + if err := store.MarkTrialIssued(requestID, h.now().UTC()); err != nil { + t.Fatalf("MarkTrialIssued: %v", err) + } + + form := url.Values{ + "org_id": {"default"}, + "return_url": {"https://pulse.example.com/auth/trial-activate"}, + "instance_token": {"tsi_test"}, + "name": {"Test User"}, + "email": {"owner@example.com"}, + "company": {"Pulse Labs"}, + } + req := httptest.NewRequest(http.MethodPost, "/api/trial-signup/checkout", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + h.HandleCheckout(rec, req) + + 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(), "recovery email has already used a Pulse Pro trial") { + t.Fatalf("expected duplicate email trial message, got %q", rec.Body.String()) + } +} + func TestTrialSignupHandleCompleteRedirectsWithActivationToken(t *testing.T) { pub, priv, err := ed25519.GenerateKey(nil) if err != nil {