Persist self-hosted purchase redemption records

This commit is contained in:
rcourtman 2026-04-08 20:42:36 +01:00
parent 9e83d0862d
commit 5edd2ad53a
6 changed files with 928 additions and 77 deletions

View file

@ -592,7 +592,13 @@ that same `portal_handoff_id` into Pulse's activation callback, and Pulse
must compare both `portal_handoff_id` and `purchase_return_jti` against the must compare both `portal_handoff_id` and `purchase_return_jti` against the
commercial checkout-session result before redeeming the activation key, so commercial checkout-session result before redeeming the activation key, so
browser form/query state and Stripe metadata alone never become the source of browser form/query state and Stripe metadata alone never become the source of
truth for a completed self-hosted upgrade. That same owned contract also truth for a completed self-hosted upgrade. Once that commercial binding
verifies, Pulse's owned callback must persist a dedicated local
purchase-return redemption record keyed by `portal_handoff_id` plus
`purchase_return_jti`, use explicit local redemption state
(`started`, `activated`, `failed`) instead of a generic replay tombstone, and
allow retry only from owned failed state rather than by deleting the local
binding outright. That same owned contract also
retires the old compatibility retires the old compatibility
bootstrap surfaces: Pulse must not expose a separate public bootstrap surfaces: Pulse must not expose a separate public
`GET /auth/license-purchase-handoff` resolver, and the commercial server must `GET /auth/license-purchase-handoff` resolver, and the commercial server must

View file

@ -497,6 +497,13 @@ resolved, started, and completed. Stripe success must carry that same
cross-check both `portal_handoff_id` and `purchase_return_jti` against the cross-check both `portal_handoff_id` and `purchase_return_jti` against the
commercial session result before local activation so the browser no longer commercial session result before local activation so the browser no longer
trusts Stripe metadata or local form state alone for return integrity. trusts Stripe metadata or local form state alone for return integrity.
Once that commercial binding verifies, Pulse must not fall back to a generic
JTI replay tombstone. The local activation callback must persist a dedicated
purchase-return redemption record keyed by `portal_handoff_id` plus
`purchase_return_jti`, stamp explicit local redemption state
(`started`, `activated`, `failed`), and use that owned record to make
completed returns idempotent while still allowing retry after transient local
activation failures.
Stripe success now lands on Pulse's public Stripe success now lands on Pulse's public
`frontend-modern/src/utils/pricingHandoff.ts` and `frontend-modern/src/utils/pricingHandoff.ts` and
`frontend-modern/src/pages/PricingHandoff.tsx` may only hand operators into `frontend-modern/src/pages/PricingHandoff.tsx` may only hand operators into

View file

@ -61,6 +61,27 @@ func purchaseReturnJTIFromToken(t *testing.T, handler *LicenseHandlers, token st
return strings.TrimSpace(claims.ID) return strings.TrimSpace(claims.ID)
} }
func issueCheckoutActivationGrant(t *testing.T) string {
t.Helper()
grantJWT, grantPublicKey, err := pkglicensing.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{
LicenseID: "lic_checkout_success",
Tier: "pro_plus",
State: "active",
Features: []string{"relay", "ai_alerts"},
MaxMonitoredSystems: 50,
IssuedAt: time.Now().Unix(),
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
Email: "buyer@example.com",
})
if err != nil {
t.Fatalf("generate grant jwt: %v", err)
}
license.SetPublicKey(grantPublicKey)
t.Cleanup(func() { license.SetPublicKey(nil) })
return grantJWT
}
type licenseFeaturesResponseDTO struct { type licenseFeaturesResponseDTO struct {
LicenseStatus string `json:"license_status"` LicenseStatus string `json:"license_status"`
Features map[string]bool `json:"features"` Features map[string]bool `json:"features"`
@ -866,21 +887,7 @@ func TestHandleCheckoutActivation_RedeemsCompletedCheckoutAndWritesSuccessBridge
returnToken := issuePurchaseReturnToken(t, handler, "default", "max_monitored_systems") returnToken := issuePurchaseReturnToken(t, handler, "default", "max_monitored_systems")
purchaseReturnJTI := purchaseReturnJTIFromToken(t, handler, returnToken) purchaseReturnJTI := purchaseReturnJTIFromToken(t, handler, returnToken)
grantJWT, grantPublicKey, err := pkglicensing.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{ grantJWT := issueCheckoutActivationGrant(t)
LicenseID: "lic_checkout_success",
Tier: "pro_plus",
State: "active",
Features: []string{"relay", "ai_alerts"},
MaxMonitoredSystems: 50,
IssuedAt: time.Now().Unix(),
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
Email: "buyer@example.com",
})
if err != nil {
t.Fatalf("generate grant jwt: %v", err)
}
license.SetPublicKey(grantPublicKey)
t.Cleanup(func() { license.SetPublicKey(nil) })
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { switch r.URL.Path {
@ -957,6 +964,162 @@ func TestHandleCheckoutActivation_RedeemsCompletedCheckoutAndWritesSuccessBridge
} }
} }
func TestHandleCheckoutActivation_BlocksDuplicateRedemptionAfterSuccess(t *testing.T) {
t.Setenv("PULSE_LICENSE_DEV_MODE", "false")
handler := createTestHandler(t)
returnToken := issuePurchaseReturnToken(t, handler, "default", "max_monitored_systems")
purchaseReturnJTI := purchaseReturnJTIFromToken(t, handler, returnToken)
grantJWT := issueCheckoutActivationGrant(t)
activateCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/checkout/session":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"fulfilled","portal_handoff_id":"cph_success","purchase_return_jti":"` + purchaseReturnJTI + `","license_id":"lic_checkout_success","activation_key_prefix":"ppk_live","activation_key":"ppk_live_checkout_activation"}`))
case "/v1/activate":
activateCalls++
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]any{
"license": map[string]any{
"license_id": "lic_checkout_success",
"state": "active",
"tier": "pro_plus",
"features": []string{"relay", "ai_alerts"},
"max_monitored_systems": 50,
},
"installation": map[string]any{
"installation_id": "inst_checkout_success",
"installation_token": "pit_live_checkout_success",
"status": "active",
},
"grant": map[string]any{
"jwt": grantJWT,
"jti": "grant_checkout_success",
"expires_at": time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339),
},
})
default:
t.Fatalf("unexpected path %q", r.URL.Path)
}
}))
defer server.Close()
t.Setenv("PULSE_LICENSE_SERVER_URL", server.URL)
makeRequest := func() *httptest.ResponseRecorder {
req := httptest.NewRequest(
http.MethodPost,
"https://pulse.example.com"+licensePurchaseActivationPath,
strings.NewReader("session_id=cs_success&portal_handoff_id=cph_success&purchase_return_token="+url.QueryEscape(returnToken)),
)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
handler.HandleCheckoutActivation(rec, req)
return rec
}
first := makeRequest()
if first.Code != http.StatusOK {
t.Fatalf("first status = %d, want %d (body=%q)", first.Code, http.StatusOK, first.Body.String())
}
second := makeRequest()
if second.Code != http.StatusConflict {
t.Fatalf("second status = %d, want %d (body=%q)", second.Code, http.StatusConflict, second.Body.String())
}
if !strings.Contains(second.Body.String(), "already returned to Pulse Pro") {
t.Fatalf("second body = %q, want duplicate redemption message", second.Body.String())
}
if activateCalls != 1 {
t.Fatalf("activateCalls = %d, want 1", activateCalls)
}
}
func TestHandleCheckoutActivation_AllowsRetryAfterActivationFailure(t *testing.T) {
t.Setenv("PULSE_LICENSE_DEV_MODE", "false")
handler := createTestHandler(t)
returnToken := issuePurchaseReturnToken(t, handler, "default", "max_monitored_systems")
purchaseReturnJTI := purchaseReturnJTIFromToken(t, handler, returnToken)
grantJWT := issueCheckoutActivationGrant(t)
activateCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/checkout/session":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"fulfilled","portal_handoff_id":"cph_success","purchase_return_jti":"` + purchaseReturnJTI + `","license_id":"lic_checkout_success","activation_key_prefix":"ppk_live","activation_key":"ppk_live_checkout_activation"}`))
case "/v1/activate":
activateCalls++
if activateCalls == 1 {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": "invalid_activation",
"message": "Activation key could not be redeemed",
"retryable": false,
})
return
}
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]any{
"license": map[string]any{
"license_id": "lic_checkout_success",
"state": "active",
"tier": "pro_plus",
"features": []string{"relay", "ai_alerts"},
"max_monitored_systems": 50,
},
"installation": map[string]any{
"installation_id": "inst_checkout_success",
"installation_token": "pit_live_checkout_success",
"status": "active",
},
"grant": map[string]any{
"jwt": grantJWT,
"jti": "grant_checkout_success",
"expires_at": time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339),
},
})
default:
t.Fatalf("unexpected path %q", r.URL.Path)
}
}))
defer server.Close()
t.Setenv("PULSE_LICENSE_SERVER_URL", server.URL)
makeRequest := func() *httptest.ResponseRecorder {
req := httptest.NewRequest(
http.MethodPost,
"https://pulse.example.com"+licensePurchaseActivationPath,
strings.NewReader("session_id=cs_success&portal_handoff_id=cph_success&purchase_return_token="+url.QueryEscape(returnToken)),
)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
handler.HandleCheckoutActivation(rec, req)
return rec
}
first := makeRequest()
if first.Code != http.StatusBadRequest {
t.Fatalf("first status = %d, want %d (body=%q)", first.Code, http.StatusBadRequest, first.Body.String())
}
if !strings.Contains(first.Body.String(), "Activation key could not be redeemed") {
t.Fatalf("first body = %q, want activation failure message", first.Body.String())
}
second := makeRequest()
if second.Code != http.StatusOK {
t.Fatalf("second status = %d, want %d (body=%q)", second.Code, http.StatusOK, second.Body.String())
}
if !strings.Contains(second.Body.String(), "Pulse Pro activated") {
t.Fatalf("second body = %q, want activation success bridge", second.Body.String())
}
if activateCalls != 2 {
t.Fatalf("activateCalls = %d, want 2", activateCalls)
}
}
func TestHandleCheckoutActivation_RejectsMissingPurchaseReturnToken(t *testing.T) { func TestHandleCheckoutActivation_RejectsMissingPurchaseReturnToken(t *testing.T) {
handler := createTestHandler(t) handler := createTestHandler(t)
handler.SetConfig(&config.Config{PublicURL: "https://pulse.example.com"}) handler.SetConfig(&config.Config{PublicURL: "https://pulse.example.com"})

View file

@ -57,6 +57,7 @@ type LicenseHandlers struct {
services sync.Map // map[string]*licenseService services sync.Map // map[string]*licenseService
trialLimiter *RateLimiter trialLimiter *RateLimiter
trialReplay *jtiReplayStore trialReplay *jtiReplayStore
purchaseReturnRedemptions *purchaseReturnRedemptionStore
trialInitiations *trialSignupInitiationStore trialInitiations *trialSignupInitiationStore
trialRedeemer func(token string) (*hostedTrialRedemptionResponse, error) trialRedeemer func(token string) (*hostedTrialRedemptionResponse, error)
monitor *monitoring.Monitor monitor *monitoring.Monitor
@ -76,19 +77,22 @@ func NewLicenseHandlers(mtp *config.MultiTenantPersistence, hostedMode bool, cfg
} }
var trialReplay *jtiReplayStore var trialReplay *jtiReplayStore
var purchaseReturnRedemptions *purchaseReturnRedemptionStore
var trialInitiations *trialSignupInitiationStore var trialInitiations *trialSignupInitiationStore
if mtp != nil { if mtp != nil {
trialReplay = &jtiReplayStore{configDir: mtp.BaseDataDir()} trialReplay = &jtiReplayStore{configDir: mtp.BaseDataDir()}
purchaseReturnRedemptions = &purchaseReturnRedemptionStore{configDir: mtp.BaseDataDir()}
trialInitiations = &trialSignupInitiationStore{configDir: mtp.BaseDataDir()} trialInitiations = &trialSignupInitiationStore{configDir: mtp.BaseDataDir()}
} }
return &LicenseHandlers{ return &LicenseHandlers{
mtPersistence: mtp, mtPersistence: mtp,
hostedMode: hostedMode, hostedMode: hostedMode,
cfg: cfg, cfg: cfg,
trialLimiter: NewRateLimiter(trialStartRateLimitBurst, trialStartRateLimitWindow), trialLimiter: NewRateLimiter(trialStartRateLimitBurst, trialStartRateLimitWindow),
trialReplay: trialReplay, trialReplay: trialReplay,
trialInitiations: trialInitiations, purchaseReturnRedemptions: purchaseReturnRedemptions,
trialInitiations: trialInitiations,
} }
} }
@ -277,6 +281,20 @@ func (h *LicenseHandlers) purchaseReturnSigningKey() ([]byte, error) {
return signingKey, nil return signingKey, nil
} }
func (h *LicenseHandlers) purchaseReturnRedemptionStore() *purchaseReturnRedemptionStore {
if h == nil {
return nil
}
if h.purchaseReturnRedemptions != nil {
return h.purchaseReturnRedemptions
}
dataDir := h.purchaseReturnDataDir()
if strings.TrimSpace(dataDir) == "" {
return nil
}
return &purchaseReturnRedemptionStore{configDir: dataDir}
}
func pulseAccountUpgradeURLForRequest(portalHandoffID string, query url.Values) (string, error) { func pulseAccountUpgradeURLForRequest(portalHandoffID string, query url.Values) (string, error) {
portalURL := strings.TrimSpace(pulseAccountPortalURLFromLicensing("")) portalURL := strings.TrimSpace(pulseAccountPortalURLFromLicensing(""))
if portalURL == "" { if portalURL == "" {
@ -1553,54 +1571,8 @@ func (h *LicenseHandlers) HandleCheckoutActivation(w http.ResponseWriter, r *htt
return return
} }
replayStore := h.trialReplay
if replayStore == nil {
dataDir := h.purchaseReturnDataDir()
if strings.TrimSpace(dataDir) != "" {
replayStore = &jtiReplayStore{configDir: dataDir}
}
}
replaySubject := ""
if claims != nil {
replaySubject = strings.TrimSpace(claims.Subject)
if replaySubject == "" {
replaySubject = strings.TrimSpace(claims.ID)
}
}
if replayStore == nil || replaySubject == "" {
writeLicensePurchaseActivationFailurePage(w, http.StatusServiceUnavailable, feature, selfHostedBillingPurchaseFailed, "Pulse could not verify the purchase activation state for this checkout.")
return
}
replayID := "purchase_activate:" + replaySubject
expiresAt := time.Now().UTC().Add(2 * time.Hour)
if claims != nil && claims.ExpiresAt != nil {
expiresAt = claims.ExpiresAt.Time
}
stored, err := replayStore.checkAndStore(replayID, expiresAt)
if err != nil {
log.Error().Err(err).Msg("Purchase activation replay-store failure")
writeLicensePurchaseActivationFailurePage(w, http.StatusServiceUnavailable, feature, selfHostedBillingPurchaseFailed, "Pulse could not verify the purchase activation state for this checkout.")
return
}
if !stored {
log.Warn().Str("replay_id_prefix", replayID[:24]).Msg("Purchase activation replay blocked")
writeLicensePurchaseActivationFailurePage(w, http.StatusConflict, feature, selfHostedBillingPurchaseActivated, "This completed purchase was already returned to Pulse Pro. Reopen billing if you need to confirm the current entitlement.")
return
}
clearReplay := func(reason string, replayErr error) {
if deleteErr := replayStore.delete(replayID); deleteErr != nil {
log.Warn().Err(deleteErr).Str("replay_id_prefix", replayID[:24]).Str("reason", reason).Msg("Purchase activation replay-store cleanup failed")
return
}
if replayErr != nil {
log.Warn().Err(replayErr).Str("replay_id_prefix", replayID[:24]).Str("reason", reason).Msg("Cleared purchase activation replay marker after activation failure")
}
}
lsClient := newLicenseServerClientFromLicensing("") lsClient := newLicenseServerClientFromLicensing("")
if lsClient == nil { if lsClient == nil {
clearReplay("license_server_unavailable", nil)
writeLicensePurchaseActivationFailurePage(w, http.StatusServiceUnavailable, feature, selfHostedBillingPurchaseFailed, "Pulse could not contact the commercial activation service.") writeLicensePurchaseActivationFailurePage(w, http.StatusServiceUnavailable, feature, selfHostedBillingPurchaseFailed, "Pulse could not contact the commercial activation service.")
return return
} }
@ -1612,7 +1584,6 @@ func (h *LicenseHandlers) HandleCheckoutActivation(w http.ResponseWriter, r *htt
checkoutResult, err := lsClient.GetCheckoutSessionResult(ctx, sessionID) checkoutResult, err := lsClient.GetCheckoutSessionResult(ctx, sessionID)
if err != nil { if err != nil {
clearReplay("checkout_lookup_failed", err)
log.Warn().Err(err).Str("checkout_session_id", sessionID).Msg("Failed to resolve checkout session during purchase activation") log.Warn().Err(err).Str("checkout_session_id", sessionID).Msg("Failed to resolve checkout session during purchase activation")
writeLicensePurchaseActivationFailurePage(w, http.StatusBadGateway, feature, selfHostedBillingPurchaseFailed, "Pulse could not confirm the completed checkout yet. Please try again in a moment.") writeLicensePurchaseActivationFailurePage(w, http.StatusBadGateway, feature, selfHostedBillingPurchaseFailed, "Pulse could not confirm the completed checkout yet. Please try again in a moment.")
return return
@ -1623,7 +1594,6 @@ func (h *LicenseHandlers) HandleCheckoutActivation(w http.ResponseWriter, r *htt
if checkoutResult != nil && strings.TrimSpace(checkoutResult.Message) != "" { if checkoutResult != nil && strings.TrimSpace(checkoutResult.Message) != "" {
message = strings.TrimSpace(checkoutResult.Message) message = strings.TrimSpace(checkoutResult.Message)
} }
clearReplay("checkout_not_fulfilled", nil)
writeLicensePurchaseActivationFailurePage(w, http.StatusConflict, feature, selfHostedBillingPurchaseFailed, message) writeLicensePurchaseActivationFailurePage(w, http.StatusConflict, feature, selfHostedBillingPurchaseFailed, message)
return return
} }
@ -1641,7 +1611,6 @@ func (h *LicenseHandlers) HandleCheckoutActivation(w http.ResponseWriter, r *htt
} }
if portalHandoffID != "" { if portalHandoffID != "" {
if resolvedPortalHandoffID == "" || resolvedPortalHandoffID != portalHandoffID { if resolvedPortalHandoffID == "" || resolvedPortalHandoffID != portalHandoffID {
clearReplay("portal_handoff_id_mismatch", nil)
log.Warn(). log.Warn().
Str("checkout_session_id", sessionID). Str("checkout_session_id", sessionID).
Str("expected_portal_handoff_id", portalHandoffID). Str("expected_portal_handoff_id", portalHandoffID).
@ -1650,14 +1619,17 @@ func (h *LicenseHandlers) HandleCheckoutActivation(w http.ResponseWriter, r *htt
writeLicensePurchaseActivationFailurePage(w, http.StatusConflict, feature, selfHostedBillingPurchaseExpired, "Purchase activation state did not match the completed upgrade flow. Reopen the upgrade flow from Pulse Pro billing.") writeLicensePurchaseActivationFailurePage(w, http.StatusConflict, feature, selfHostedBillingPurchaseExpired, "Purchase activation state did not match the completed upgrade flow. Reopen the upgrade flow from Pulse Pro billing.")
return return
} }
} else if resolvedPortalHandoffID != "" { } else {
portalHandoffID = resolvedPortalHandoffID
}
if portalHandoffID == "" {
log.Warn(). log.Warn().
Str("checkout_session_id", sessionID). Str("checkout_session_id", sessionID).
Str("resolved_portal_handoff_id", resolvedPortalHandoffID). Msg("Rejected checkout activation without canonical portal handoff binding")
Msg("Purchase activation continued without browser portal_handoff_id state") writeLicensePurchaseActivationFailurePage(w, http.StatusConflict, feature, selfHostedBillingPurchaseExpired, "Purchase activation state did not match the completed upgrade flow. Reopen the upgrade flow from Pulse Pro billing.")
return
} }
if expectedPurchaseReturnJTI == "" || resolvedPurchaseReturnJTI == "" || expectedPurchaseReturnJTI != resolvedPurchaseReturnJTI { if expectedPurchaseReturnJTI == "" || resolvedPurchaseReturnJTI == "" || expectedPurchaseReturnJTI != resolvedPurchaseReturnJTI {
clearReplay("purchase_return_jti_mismatch", nil)
log.Warn(). log.Warn().
Str("checkout_session_id", sessionID). Str("checkout_session_id", sessionID).
Str("expected_purchase_return_jti", expectedPurchaseReturnJTI). Str("expected_purchase_return_jti", expectedPurchaseReturnJTI).
@ -1669,17 +1641,82 @@ func (h *LicenseHandlers) HandleCheckoutActivation(w http.ResponseWriter, r *htt
activationKey := strings.TrimSpace(checkoutResult.ActivationKey) activationKey := strings.TrimSpace(checkoutResult.ActivationKey)
if activationKey == "" { if activationKey == "" {
clearReplay("activation_key_missing", nil)
writeLicensePurchaseActivationFailurePage(w, http.StatusBadGateway, feature, selfHostedBillingPurchaseFailed, "The completed checkout did not return an activation key.") writeLicensePurchaseActivationFailurePage(w, http.StatusBadGateway, feature, selfHostedBillingPurchaseFailed, "The completed checkout did not return an activation key.")
return return
} }
redemptionStore := h.purchaseReturnRedemptionStore()
if redemptionStore == nil {
writeLicensePurchaseActivationFailurePage(w, http.StatusServiceUnavailable, feature, selfHostedBillingPurchaseFailed, "Pulse could not verify the purchase activation state for this checkout.")
return
}
expiresAt := time.Now().UTC().Add(2 * time.Hour)
if claims != nil && claims.ExpiresAt != nil {
expiresAt = claims.ExpiresAt.Time
}
redemptionDecision, _, err := redemptionStore.begin(purchaseReturnRedemptionAttempt{
PortalHandoffID: portalHandoffID,
PurchaseReturnJTI: expectedPurchaseReturnJTI,
CheckoutSessionID: sessionID,
LicenseID: strings.TrimSpace(checkoutResult.LicenseID),
ActivationKeyPrefix: strings.TrimSpace(checkoutResult.ActivationKeyPrefix),
ExpiresAt: expiresAt,
})
if err != nil {
log.Error().Err(err).Str("portal_handoff_id", portalHandoffID).Msg("Purchase activation redemption-store failure")
writeLicensePurchaseActivationFailurePage(w, http.StatusServiceUnavailable, feature, selfHostedBillingPurchaseFailed, "Pulse could not verify the purchase activation state for this checkout.")
return
}
switch redemptionDecision {
case purchaseReturnRedemptionDecisionAlreadyActivated:
writeLicensePurchaseActivationFailurePage(w, http.StatusConflict, feature, selfHostedBillingPurchaseActivated, "This completed purchase was already returned to Pulse Pro. Reopen billing if you need to confirm the current entitlement.")
return
case purchaseReturnRedemptionDecisionInProgress:
writeLicensePurchaseActivationFailurePage(w, http.StatusConflict, feature, selfHostedBillingPurchaseFailed, "Pulse is already finalizing this completed purchase. Reopen billing in a moment if this tab does not close automatically.")
return
case purchaseReturnRedemptionDecisionConflict:
log.Warn().
Str("checkout_session_id", sessionID).
Str("portal_handoff_id", portalHandoffID).
Msg("Rejected checkout activation due to local redemption binding conflict")
writeLicensePurchaseActivationFailurePage(w, http.StatusConflict, feature, selfHostedBillingPurchaseExpired, "Purchase activation state did not match the completed upgrade flow. Reopen the upgrade flow from Pulse Pro billing.")
return
}
recordFailure := func(reason string, activationErr error) {
message := ""
if activationErr != nil {
message = activationErr.Error()
}
if markErr := redemptionStore.markFailed(portalHandoffID, expectedPurchaseReturnJTI, reason, message); markErr != nil {
log.Warn().
Err(markErr).
Str("portal_handoff_id", portalHandoffID).
Str("purchase_return_jti", expectedPurchaseReturnJTI).
Str("reason", reason).
Msg("Failed to update purchase activation redemption record")
return
}
if activationErr != nil {
log.Warn().
Err(activationErr).
Str("portal_handoff_id", portalHandoffID).
Str("purchase_return_jti", expectedPurchaseReturnJTI).
Str("reason", reason).
Msg("Marked purchase activation redemption as failed")
}
}
if _, err := h.activateLicenseKey(ctx, activationKey); err != nil { if _, err := h.activateLicenseKey(ctx, activationKey); err != nil {
clearReplay("license_activation_failed", err) recordFailure("license_activation_failed", err)
log.Warn().Err(err).Str("checkout_session_id", sessionID).Msg("Failed to activate completed checkout locally") log.Warn().Err(err).Str("checkout_session_id", sessionID).Msg("Failed to activate completed checkout locally")
writeLicensePurchaseActivationFailurePage(w, http.StatusBadRequest, feature, selfHostedBillingPurchaseFailed, userFriendlyActivationError(err)) writeLicensePurchaseActivationFailurePage(w, http.StatusBadRequest, feature, selfHostedBillingPurchaseFailed, userFriendlyActivationError(err))
return return
} }
if err := redemptionStore.markActivated(portalHandoffID, expectedPurchaseReturnJTI, strings.TrimSpace(checkoutResult.LicenseID), strings.TrimSpace(checkoutResult.ActivationKeyPrefix)); err != nil {
log.Error().Err(err).Str("portal_handoff_id", portalHandoffID).Str("checkout_session_id", sessionID).Msg("Failed to finalize purchase activation redemption record")
writeLicensePurchaseActivationFailurePage(w, http.StatusServiceUnavailable, feature, selfHostedBillingPurchaseFailed, "Pulse activated the license but could not finalize the local purchase record. Reopen billing to confirm the current entitlement.")
return
}
writeLicensePurchaseActivationSuccessPage(w, feature) writeLicensePurchaseActivationSuccessPage(w, feature)
} }

View file

@ -0,0 +1,523 @@
package api
import (
"database/sql"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const (
purchaseReturnRedemptionStateStarted = "started"
purchaseReturnRedemptionStateActivated = "activated"
purchaseReturnRedemptionStateFailed = "failed"
purchaseReturnRedemptionDecisionStarted = "started"
purchaseReturnRedemptionDecisionAlreadyActivated = "already_activated"
purchaseReturnRedemptionDecisionInProgress = "in_progress"
purchaseReturnRedemptionDecisionConflict = "conflict"
purchaseReturnRedemptionStaleAfter = 30 * time.Second
)
type purchaseReturnRedemptionStore struct {
once sync.Once
db *sql.DB
mu sync.Mutex
configDir string
initErr error
}
type purchaseReturnRedemptionAttempt struct {
PortalHandoffID string
PurchaseReturnJTI string
CheckoutSessionID string
LicenseID string
ActivationKeyPrefix string
ExpiresAt time.Time
}
type purchaseReturnRedemptionRecord struct {
PortalHandoffID string
PurchaseReturnJTI string
CheckoutSessionID string
LicenseID string
ActivationKeyPrefix string
Status string
FailureReason string
FailureMessage string
CreatedAt time.Time
UpdatedAt time.Time
ExpiresAt time.Time
RedeemedAt *time.Time
}
func (s *purchaseReturnRedemptionStore) init() {
s.once.Do(func() {
dir := filepath.Clean(s.configDir)
if strings.TrimSpace(dir) == "" {
s.initErr = fmt.Errorf("configDir is required")
return
}
secretsDir := filepath.Join(dir, "secrets")
if err := os.MkdirAll(secretsDir, handoffPrivateDirPerm); err != nil {
s.initErr = fmt.Errorf("create purchase return secrets dir: %w", err)
return
}
if err := os.Chmod(secretsDir, handoffPrivateDirPerm); err != nil {
s.initErr = fmt.Errorf("chmod purchase return secrets dir: %w", err)
return
}
dbPath := filepath.Join(secretsDir, "purchase_return_redemptions.db")
dsn := dbPath + "?" + url.Values{
"_pragma": []string{
"busy_timeout(30000)",
"journal_mode(WAL)",
"synchronous(NORMAL)",
},
}.Encode()
db, err := sql.Open("sqlite", dsn)
if err != nil {
s.initErr = fmt.Errorf("open purchase return redemption db: %w", err)
return
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
db.SetConnMaxLifetime(0)
schema := `
CREATE TABLE IF NOT EXISTS purchase_return_redemptions (
portal_handoff_id TEXT NOT NULL,
purchase_return_jti TEXT NOT NULL,
checkout_session_id TEXT NOT NULL,
license_id TEXT NOT NULL DEFAULT '',
activation_key_prefix TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL,
failure_reason TEXT NOT NULL DEFAULT '',
failure_message TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
redeemed_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (portal_handoff_id, purchase_return_jti)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_purchase_return_redemptions_portal_handoff
ON purchase_return_redemptions(portal_handoff_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_purchase_return_redemptions_checkout_session
ON purchase_return_redemptions(checkout_session_id);
CREATE INDEX IF NOT EXISTS idx_purchase_return_redemptions_status
ON purchase_return_redemptions(status);
`
if _, err := db.Exec(schema); err != nil {
_ = db.Close()
s.initErr = fmt.Errorf("init purchase return redemption schema: %w", err)
return
}
for _, path := range []string{dbPath, dbPath + "-wal", dbPath + "-shm"} {
if err := hardenPrivateFile(path, handoffPrivateFilePerm); err != nil {
_ = db.Close()
s.initErr = fmt.Errorf("harden purchase return redemption file permissions: %w", err)
return
}
}
s.db = db
})
}
func (s *purchaseReturnRedemptionStore) begin(attempt purchaseReturnRedemptionAttempt) (string, *purchaseReturnRedemptionRecord, error) {
s.init()
if s.initErr != nil {
return "", nil, s.initErr
}
if s.db == nil {
return "", nil, fmt.Errorf("purchase return redemption store not initialized")
}
attempt.PortalHandoffID = strings.TrimSpace(attempt.PortalHandoffID)
attempt.PurchaseReturnJTI = strings.TrimSpace(attempt.PurchaseReturnJTI)
attempt.CheckoutSessionID = strings.TrimSpace(attempt.CheckoutSessionID)
attempt.LicenseID = strings.TrimSpace(attempt.LicenseID)
attempt.ActivationKeyPrefix = strings.TrimSpace(attempt.ActivationKeyPrefix)
if attempt.PortalHandoffID == "" {
return "", nil, fmt.Errorf("portal handoff id is required")
}
if attempt.PurchaseReturnJTI == "" {
return "", nil, fmt.Errorf("purchase return jti is required")
}
if attempt.CheckoutSessionID == "" {
return "", nil, fmt.Errorf("checkout session id is required")
}
if attempt.ExpiresAt.IsZero() {
return "", nil, fmt.Errorf("expires at is required")
}
attempt.ExpiresAt = attempt.ExpiresAt.UTC()
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.Begin()
if err != nil {
return "", nil, fmt.Errorf("begin purchase return redemption tx: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
now := time.Now().UTC()
existing, err := loadPurchaseReturnRedemptionExactTx(tx, attempt.PortalHandoffID, attempt.PurchaseReturnJTI)
if err != nil {
return "", nil, err
}
if existing != nil {
if existing.CheckoutSessionID != attempt.CheckoutSessionID {
return purchaseReturnRedemptionDecisionConflict, existing, nil
}
switch existing.Status {
case purchaseReturnRedemptionStateActivated:
committed = true
if err := tx.Commit(); err != nil {
return "", nil, fmt.Errorf("commit activated purchase redemption lookup: %w", err)
}
return purchaseReturnRedemptionDecisionAlreadyActivated, existing, nil
case purchaseReturnRedemptionStateStarted:
if now.Sub(existing.UpdatedAt) < purchaseReturnRedemptionStaleAfter {
committed = true
if err := tx.Commit(); err != nil {
return "", nil, fmt.Errorf("commit in-progress purchase redemption lookup: %w", err)
}
return purchaseReturnRedemptionDecisionInProgress, existing, nil
}
case purchaseReturnRedemptionStateFailed:
default:
return "", nil, fmt.Errorf("unexpected purchase return redemption status %q", existing.Status)
}
if err := updatePurchaseReturnRedemptionStartedTx(tx, attempt, now); err != nil {
return "", nil, err
}
record, err := loadPurchaseReturnRedemptionExactTx(tx, attempt.PortalHandoffID, attempt.PurchaseReturnJTI)
if err != nil {
return "", nil, err
}
if record == nil {
return "", nil, fmt.Errorf("purchase return redemption row disappeared after restart")
}
if err := tx.Commit(); err != nil {
return "", nil, fmt.Errorf("commit restarted purchase return redemption: %w", err)
}
committed = true
return purchaseReturnRedemptionDecisionStarted, record, nil
}
byPortalHandoffID, err := loadPurchaseReturnRedemptionByPortalHandoffTx(tx, attempt.PortalHandoffID)
if err != nil {
return "", nil, err
}
if byPortalHandoffID != nil {
committed = true
if err := tx.Commit(); err != nil {
return "", nil, fmt.Errorf("commit portal handoff conflict lookup: %w", err)
}
return purchaseReturnRedemptionDecisionConflict, byPortalHandoffID, nil
}
bySessionID, err := loadPurchaseReturnRedemptionBySessionTx(tx, attempt.CheckoutSessionID)
if err != nil {
return "", nil, err
}
if bySessionID != nil {
committed = true
if err := tx.Commit(); err != nil {
return "", nil, fmt.Errorf("commit checkout session conflict lookup: %w", err)
}
return purchaseReturnRedemptionDecisionConflict, bySessionID, nil
}
if err := insertPurchaseReturnRedemptionStartedTx(tx, attempt, now); err != nil {
return "", nil, err
}
record, err := loadPurchaseReturnRedemptionExactTx(tx, attempt.PortalHandoffID, attempt.PurchaseReturnJTI)
if err != nil {
return "", nil, err
}
if record == nil {
return "", nil, fmt.Errorf("purchase return redemption row missing after insert")
}
if err := tx.Commit(); err != nil {
return "", nil, fmt.Errorf("commit purchase return redemption insert: %w", err)
}
committed = true
return purchaseReturnRedemptionDecisionStarted, record, nil
}
func (s *purchaseReturnRedemptionStore) markFailed(portalHandoffID, purchaseReturnJTI, reason, message string) error {
return s.update(portalHandoffID, purchaseReturnJTI, func(tx *sql.Tx, now time.Time, existing *purchaseReturnRedemptionRecord) error {
if existing.Status == purchaseReturnRedemptionStateActivated {
return nil
}
if _, err := tx.Exec(
`UPDATE purchase_return_redemptions
SET status = ?, failure_reason = ?, failure_message = ?, updated_at = ?
WHERE portal_handoff_id = ? AND purchase_return_jti = ?`,
purchaseReturnRedemptionStateFailed,
strings.TrimSpace(reason),
strings.TrimSpace(message),
now.Unix(),
strings.TrimSpace(portalHandoffID),
strings.TrimSpace(purchaseReturnJTI),
); err != nil {
return fmt.Errorf("mark purchase return redemption failed: %w", err)
}
return nil
})
}
func (s *purchaseReturnRedemptionStore) markActivated(portalHandoffID, purchaseReturnJTI, licenseID, activationKeyPrefix string) error {
return s.update(portalHandoffID, purchaseReturnJTI, func(tx *sql.Tx, now time.Time, existing *purchaseReturnRedemptionRecord) error {
if existing.Status == purchaseReturnRedemptionStateActivated {
return nil
}
if _, err := tx.Exec(
`UPDATE purchase_return_redemptions
SET status = ?, failure_reason = '', failure_message = '', license_id = ?, activation_key_prefix = ?, updated_at = ?, redeemed_at = ?
WHERE portal_handoff_id = ? AND purchase_return_jti = ?`,
purchaseReturnRedemptionStateActivated,
strings.TrimSpace(licenseID),
strings.TrimSpace(activationKeyPrefix),
now.Unix(),
now.Unix(),
strings.TrimSpace(portalHandoffID),
strings.TrimSpace(purchaseReturnJTI),
); err != nil {
return fmt.Errorf("mark purchase return redemption activated: %w", err)
}
return nil
})
}
func (s *purchaseReturnRedemptionStore) get(portalHandoffID, purchaseReturnJTI string) (*purchaseReturnRedemptionRecord, error) {
s.init()
if s.initErr != nil {
return nil, s.initErr
}
if s.db == nil {
return nil, fmt.Errorf("purchase return redemption store not initialized")
}
s.mu.Lock()
defer s.mu.Unlock()
return loadPurchaseReturnRedemptionExactTx(nil, strings.TrimSpace(portalHandoffID), strings.TrimSpace(purchaseReturnJTI), s.db)
}
func (s *purchaseReturnRedemptionStore) update(portalHandoffID, purchaseReturnJTI string, apply func(tx *sql.Tx, now time.Time, existing *purchaseReturnRedemptionRecord) error) error {
s.init()
if s.initErr != nil {
return s.initErr
}
if s.db == nil {
return fmt.Errorf("purchase return redemption store not initialized")
}
portalHandoffID = strings.TrimSpace(portalHandoffID)
purchaseReturnJTI = strings.TrimSpace(purchaseReturnJTI)
if portalHandoffID == "" {
return fmt.Errorf("portal handoff id is required")
}
if purchaseReturnJTI == "" {
return fmt.Errorf("purchase return jti is required")
}
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin purchase return redemption update tx: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
existing, err := loadPurchaseReturnRedemptionExactTx(tx, portalHandoffID, purchaseReturnJTI)
if err != nil {
return err
}
if existing == nil {
return fmt.Errorf("purchase return redemption not found")
}
if err := apply(tx, time.Now().UTC(), existing); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit purchase return redemption update: %w", err)
}
committed = true
return nil
}
func insertPurchaseReturnRedemptionStartedTx(tx *sql.Tx, attempt purchaseReturnRedemptionAttempt, now time.Time) error {
_, err := tx.Exec(
`INSERT INTO purchase_return_redemptions (
portal_handoff_id, purchase_return_jti, checkout_session_id, license_id, activation_key_prefix,
status, failure_reason, failure_message, created_at, updated_at, expires_at, redeemed_at
) VALUES (?, ?, ?, ?, ?, ?, '', '', ?, ?, ?, 0)`,
attempt.PortalHandoffID,
attempt.PurchaseReturnJTI,
attempt.CheckoutSessionID,
attempt.LicenseID,
attempt.ActivationKeyPrefix,
purchaseReturnRedemptionStateStarted,
now.Unix(),
now.Unix(),
attempt.ExpiresAt.Unix(),
)
if err != nil {
if isSQLiteUniqueViolation(err) {
return fmt.Errorf("purchase return redemption binding already exists: %w", err)
}
return fmt.Errorf("insert purchase return redemption: %w", err)
}
return nil
}
func updatePurchaseReturnRedemptionStartedTx(tx *sql.Tx, attempt purchaseReturnRedemptionAttempt, now time.Time) error {
_, err := tx.Exec(
`UPDATE purchase_return_redemptions
SET checkout_session_id = ?, license_id = ?, activation_key_prefix = ?, status = ?, failure_reason = '', failure_message = '', updated_at = ?, expires_at = ?, redeemed_at = 0
WHERE portal_handoff_id = ? AND purchase_return_jti = ?`,
attempt.CheckoutSessionID,
attempt.LicenseID,
attempt.ActivationKeyPrefix,
purchaseReturnRedemptionStateStarted,
now.Unix(),
attempt.ExpiresAt.Unix(),
attempt.PortalHandoffID,
attempt.PurchaseReturnJTI,
)
if err != nil {
return fmt.Errorf("restart purchase return redemption: %w", err)
}
return nil
}
func loadPurchaseReturnRedemptionExactTx(tx *sql.Tx, portalHandoffID, purchaseReturnJTI string, dbs ...*sql.DB) (*purchaseReturnRedemptionRecord, error) {
const query = `SELECT
portal_handoff_id,
purchase_return_jti,
checkout_session_id,
license_id,
activation_key_prefix,
status,
failure_reason,
failure_message,
created_at,
updated_at,
expires_at,
redeemed_at
FROM purchase_return_redemptions
WHERE portal_handoff_id = ? AND purchase_return_jti = ?`
var row *sql.Row
switch {
case tx != nil:
row = tx.QueryRow(query, portalHandoffID, purchaseReturnJTI)
case len(dbs) > 0 && dbs[0] != nil:
row = dbs[0].QueryRow(query, portalHandoffID, purchaseReturnJTI)
default:
return nil, fmt.Errorf("database handle is required")
}
return scanPurchaseReturnRedemptionRow(row)
}
func loadPurchaseReturnRedemptionByPortalHandoffTx(tx *sql.Tx, portalHandoffID string) (*purchaseReturnRedemptionRecord, error) {
return scanPurchaseReturnRedemptionRow(tx.QueryRow(
`SELECT
portal_handoff_id,
purchase_return_jti,
checkout_session_id,
license_id,
activation_key_prefix,
status,
failure_reason,
failure_message,
created_at,
updated_at,
expires_at,
redeemed_at
FROM purchase_return_redemptions
WHERE portal_handoff_id = ?`,
portalHandoffID,
))
}
func loadPurchaseReturnRedemptionBySessionTx(tx *sql.Tx, checkoutSessionID string) (*purchaseReturnRedemptionRecord, error) {
return scanPurchaseReturnRedemptionRow(tx.QueryRow(
`SELECT
portal_handoff_id,
purchase_return_jti,
checkout_session_id,
license_id,
activation_key_prefix,
status,
failure_reason,
failure_message,
created_at,
updated_at,
expires_at,
redeemed_at
FROM purchase_return_redemptions
WHERE checkout_session_id = ?`,
checkoutSessionID,
))
}
func scanPurchaseReturnRedemptionRow(row *sql.Row) (*purchaseReturnRedemptionRecord, error) {
if row == nil {
return nil, fmt.Errorf("purchase return redemption row is required")
}
record := purchaseReturnRedemptionRecord{}
var createdAt, updatedAt, expiresAt, redeemedAt int64
err := row.Scan(
&record.PortalHandoffID,
&record.PurchaseReturnJTI,
&record.CheckoutSessionID,
&record.LicenseID,
&record.ActivationKeyPrefix,
&record.Status,
&record.FailureReason,
&record.FailureMessage,
&createdAt,
&updatedAt,
&expiresAt,
&redeemedAt,
)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("scan purchase return redemption: %w", err)
}
record.CreatedAt = time.Unix(createdAt, 0).UTC()
record.UpdatedAt = time.Unix(updatedAt, 0).UTC()
record.ExpiresAt = time.Unix(expiresAt, 0).UTC()
if redeemedAt > 0 {
redeemedAtTime := time.Unix(redeemedAt, 0).UTC()
record.RedeemedAt = &redeemedAtTime
}
return &record, nil
}

View file

@ -0,0 +1,115 @@
package api
import (
"testing"
"time"
)
func TestPurchaseReturnRedemptionStore_RetriesFailedRedemptionAndBlocksActivatedReplay(t *testing.T) {
store := &purchaseReturnRedemptionStore{configDir: t.TempDir()}
attempt := purchaseReturnRedemptionAttempt{
PortalHandoffID: "cph_test_upgrade",
PurchaseReturnJTI: "prt_test_jti",
CheckoutSessionID: "cs_test_upgrade",
LicenseID: "lic_test_upgrade",
ActivationKeyPrefix: "ppk_live",
ExpiresAt: time.Now().UTC().Add(2 * time.Hour),
}
decision, record, err := store.begin(attempt)
if err != nil {
t.Fatalf("begin: %v", err)
}
if decision != purchaseReturnRedemptionDecisionStarted {
t.Fatalf("decision = %q, want %q", decision, purchaseReturnRedemptionDecisionStarted)
}
if record == nil || record.Status != purchaseReturnRedemptionStateStarted {
t.Fatalf("record status = %#v, want started", record)
}
if err := store.markFailed(attempt.PortalHandoffID, attempt.PurchaseReturnJTI, "license_activation_failed", "boom"); err != nil {
t.Fatalf("markFailed: %v", err)
}
record, err = store.get(attempt.PortalHandoffID, attempt.PurchaseReturnJTI)
if err != nil {
t.Fatalf("get after failure: %v", err)
}
if record == nil || record.Status != purchaseReturnRedemptionStateFailed {
t.Fatalf("record status after failure = %#v, want failed", record)
}
if record.FailureReason != "license_activation_failed" {
t.Fatalf("failure_reason = %q, want license_activation_failed", record.FailureReason)
}
decision, record, err = store.begin(attempt)
if err != nil {
t.Fatalf("begin retry: %v", err)
}
if decision != purchaseReturnRedemptionDecisionStarted {
t.Fatalf("retry decision = %q, want %q", decision, purchaseReturnRedemptionDecisionStarted)
}
if record == nil || record.Status != purchaseReturnRedemptionStateStarted {
t.Fatalf("retry record status = %#v, want started", record)
}
if record.FailureReason != "" || record.FailureMessage != "" {
t.Fatalf("retry record failure fields = %#v, want cleared", record)
}
if err := store.markActivated(attempt.PortalHandoffID, attempt.PurchaseReturnJTI, attempt.LicenseID, attempt.ActivationKeyPrefix); err != nil {
t.Fatalf("markActivated: %v", err)
}
record, err = store.get(attempt.PortalHandoffID, attempt.PurchaseReturnJTI)
if err != nil {
t.Fatalf("get after activation: %v", err)
}
if record == nil || record.Status != purchaseReturnRedemptionStateActivated {
t.Fatalf("record status after activation = %#v, want activated", record)
}
if record.RedeemedAt == nil {
t.Fatal("redeemed_at was not recorded")
}
decision, _, err = store.begin(attempt)
if err != nil {
t.Fatalf("begin after activation: %v", err)
}
if decision != purchaseReturnRedemptionDecisionAlreadyActivated {
t.Fatalf("decision after activation = %q, want %q", decision, purchaseReturnRedemptionDecisionAlreadyActivated)
}
}
func TestPurchaseReturnRedemptionStore_RejectsConflictingBindings(t *testing.T) {
store := &purchaseReturnRedemptionStore{configDir: t.TempDir()}
attempt := purchaseReturnRedemptionAttempt{
PortalHandoffID: "cph_test_upgrade",
PurchaseReturnJTI: "prt_test_jti",
CheckoutSessionID: "cs_test_upgrade",
LicenseID: "lic_test_upgrade",
ActivationKeyPrefix: "ppk_live",
ExpiresAt: time.Now().UTC().Add(2 * time.Hour),
}
if decision, _, err := store.begin(attempt); err != nil {
t.Fatalf("begin: %v", err)
} else if decision != purchaseReturnRedemptionDecisionStarted {
t.Fatalf("decision = %q, want %q", decision, purchaseReturnRedemptionDecisionStarted)
}
conflictingSession := attempt
conflictingSession.CheckoutSessionID = "cs_conflicting_upgrade"
if decision, _, err := store.begin(conflictingSession); err != nil {
t.Fatalf("begin conflicting session: %v", err)
} else if decision != purchaseReturnRedemptionDecisionConflict {
t.Fatalf("decision = %q, want %q", decision, purchaseReturnRedemptionDecisionConflict)
}
conflictingHandoff := attempt
conflictingHandoff.PortalHandoffID = "cph_other_upgrade"
conflictingHandoff.CheckoutSessionID = attempt.CheckoutSessionID
conflictingHandoff.PurchaseReturnJTI = "prt_other_jti"
if decision, _, err := store.begin(conflictingHandoff); err != nil {
t.Fatalf("begin conflicting handoff: %v", err)
} else if decision != purchaseReturnRedemptionDecisionConflict {
t.Fatalf("decision = %q, want %q", decision, purchaseReturnRedemptionDecisionConflict)
}
}