mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-05 23:36:37 +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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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