mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
Persist self-hosted purchase redemption records
This commit is contained in:
parent
9e83d0862d
commit
5edd2ad53a
6 changed files with 928 additions and 77 deletions
|
|
@ -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
|
||||
commercial checkout-session result before redeeming the activation key, so
|
||||
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
|
||||
bootstrap surfaces: Pulse must not expose a separate public
|
||||
`GET /auth/license-purchase-handoff` resolver, and the commercial server must
|
||||
|
|
|
|||
|
|
@ -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
|
||||
commercial session result before local activation so the browser no longer
|
||||
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
|
||||
`frontend-modern/src/utils/pricingHandoff.ts` and
|
||||
`frontend-modern/src/pages/PricingHandoff.tsx` may only hand operators into
|
||||
|
|
|
|||
|
|
@ -61,6 +61,27 @@ func purchaseReturnJTIFromToken(t *testing.T, handler *LicenseHandlers, token st
|
|||
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 {
|
||||
LicenseStatus string `json:"license_status"`
|
||||
Features map[string]bool `json:"features"`
|
||||
|
|
@ -866,21 +887,7 @@ func TestHandleCheckoutActivation_RedeemsCompletedCheckoutAndWritesSuccessBridge
|
|||
returnToken := issuePurchaseReturnToken(t, handler, "default", "max_monitored_systems")
|
||||
purchaseReturnJTI := purchaseReturnJTIFromToken(t, handler, returnToken)
|
||||
|
||||
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) })
|
||||
grantJWT := issueCheckoutActivationGrant(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
handler := createTestHandler(t)
|
||||
handler.SetConfig(&config.Config{PublicURL: "https://pulse.example.com"})
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ type LicenseHandlers struct {
|
|||
services sync.Map // map[string]*licenseService
|
||||
trialLimiter *RateLimiter
|
||||
trialReplay *jtiReplayStore
|
||||
purchaseReturnRedemptions *purchaseReturnRedemptionStore
|
||||
trialInitiations *trialSignupInitiationStore
|
||||
trialRedeemer func(token string) (*hostedTrialRedemptionResponse, error)
|
||||
monitor *monitoring.Monitor
|
||||
|
|
@ -76,19 +77,22 @@ func NewLicenseHandlers(mtp *config.MultiTenantPersistence, hostedMode bool, cfg
|
|||
}
|
||||
|
||||
var trialReplay *jtiReplayStore
|
||||
var purchaseReturnRedemptions *purchaseReturnRedemptionStore
|
||||
var trialInitiations *trialSignupInitiationStore
|
||||
if mtp != nil {
|
||||
trialReplay = &jtiReplayStore{configDir: mtp.BaseDataDir()}
|
||||
purchaseReturnRedemptions = &purchaseReturnRedemptionStore{configDir: mtp.BaseDataDir()}
|
||||
trialInitiations = &trialSignupInitiationStore{configDir: mtp.BaseDataDir()}
|
||||
}
|
||||
|
||||
return &LicenseHandlers{
|
||||
mtPersistence: mtp,
|
||||
hostedMode: hostedMode,
|
||||
cfg: cfg,
|
||||
trialLimiter: NewRateLimiter(trialStartRateLimitBurst, trialStartRateLimitWindow),
|
||||
trialReplay: trialReplay,
|
||||
trialInitiations: trialInitiations,
|
||||
mtPersistence: mtp,
|
||||
hostedMode: hostedMode,
|
||||
cfg: cfg,
|
||||
trialLimiter: NewRateLimiter(trialStartRateLimitBurst, trialStartRateLimitWindow),
|
||||
trialReplay: trialReplay,
|
||||
purchaseReturnRedemptions: purchaseReturnRedemptions,
|
||||
trialInitiations: trialInitiations,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -277,6 +281,20 @@ func (h *LicenseHandlers) purchaseReturnSigningKey() ([]byte, error) {
|
|||
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) {
|
||||
portalURL := strings.TrimSpace(pulseAccountPortalURLFromLicensing(""))
|
||||
if portalURL == "" {
|
||||
|
|
@ -1553,54 +1571,8 @@ func (h *LicenseHandlers) HandleCheckoutActivation(w http.ResponseWriter, r *htt
|
|||
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("")
|
||||
if lsClient == nil {
|
||||
clearReplay("license_server_unavailable", nil)
|
||||
writeLicensePurchaseActivationFailurePage(w, http.StatusServiceUnavailable, feature, selfHostedBillingPurchaseFailed, "Pulse could not contact the commercial activation service.")
|
||||
return
|
||||
}
|
||||
|
|
@ -1612,7 +1584,6 @@ func (h *LicenseHandlers) HandleCheckoutActivation(w http.ResponseWriter, r *htt
|
|||
|
||||
checkoutResult, err := lsClient.GetCheckoutSessionResult(ctx, sessionID)
|
||||
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")
|
||||
writeLicensePurchaseActivationFailurePage(w, http.StatusBadGateway, feature, selfHostedBillingPurchaseFailed, "Pulse could not confirm the completed checkout yet. Please try again in a moment.")
|
||||
return
|
||||
|
|
@ -1623,7 +1594,6 @@ func (h *LicenseHandlers) HandleCheckoutActivation(w http.ResponseWriter, r *htt
|
|||
if checkoutResult != nil && strings.TrimSpace(checkoutResult.Message) != "" {
|
||||
message = strings.TrimSpace(checkoutResult.Message)
|
||||
}
|
||||
clearReplay("checkout_not_fulfilled", nil)
|
||||
writeLicensePurchaseActivationFailurePage(w, http.StatusConflict, feature, selfHostedBillingPurchaseFailed, message)
|
||||
return
|
||||
}
|
||||
|
|
@ -1641,7 +1611,6 @@ func (h *LicenseHandlers) HandleCheckoutActivation(w http.ResponseWriter, r *htt
|
|||
}
|
||||
if portalHandoffID != "" {
|
||||
if resolvedPortalHandoffID == "" || resolvedPortalHandoffID != portalHandoffID {
|
||||
clearReplay("portal_handoff_id_mismatch", nil)
|
||||
log.Warn().
|
||||
Str("checkout_session_id", sessionID).
|
||||
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.")
|
||||
return
|
||||
}
|
||||
} else if resolvedPortalHandoffID != "" {
|
||||
} else {
|
||||
portalHandoffID = resolvedPortalHandoffID
|
||||
}
|
||||
if portalHandoffID == "" {
|
||||
log.Warn().
|
||||
Str("checkout_session_id", sessionID).
|
||||
Str("resolved_portal_handoff_id", resolvedPortalHandoffID).
|
||||
Msg("Purchase activation continued without browser portal_handoff_id state")
|
||||
Msg("Rejected checkout activation without canonical portal handoff binding")
|
||||
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 {
|
||||
clearReplay("purchase_return_jti_mismatch", nil)
|
||||
log.Warn().
|
||||
Str("checkout_session_id", sessionID).
|
||||
Str("expected_purchase_return_jti", expectedPurchaseReturnJTI).
|
||||
|
|
@ -1669,17 +1641,82 @@ func (h *LicenseHandlers) HandleCheckoutActivation(w http.ResponseWriter, r *htt
|
|||
|
||||
activationKey := strings.TrimSpace(checkoutResult.ActivationKey)
|
||||
if activationKey == "" {
|
||||
clearReplay("activation_key_missing", nil)
|
||||
writeLicensePurchaseActivationFailurePage(w, http.StatusBadGateway, feature, selfHostedBillingPurchaseFailed, "The completed checkout did not return an activation key.")
|
||||
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 {
|
||||
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")
|
||||
writeLicensePurchaseActivationFailurePage(w, http.StatusBadRequest, feature, selfHostedBillingPurchaseFailed, userFriendlyActivationError(err))
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
523
internal/api/purchase_return_redemptions.go
Normal file
523
internal/api/purchase_return_redemptions.go
Normal 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
|
||||
}
|
||||
115
internal/api/purchase_return_redemptions_test.go
Normal file
115
internal/api/purchase_return_redemptions_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue