package stripe import ( "context" "errors" "fmt" "log" "strings" "time" "gpt-plus/config" "gpt-plus/pkg/captcha" "gpt-plus/pkg/httpclient" "gpt-plus/pkg/provider/card" ) // ErrNoCaptchaSolver indicates hCaptcha was triggered but no solver is configured. var ErrNoCaptchaSolver = errors.New("payment requires hCaptcha challenge but no solver configured") // PaymentFlowParams holds all parameters for the common payment retry loop. type PaymentFlowParams struct { Client *httpclient.Client CheckoutResult *CheckoutResult FirstCard *card.CardInfo // first card (already used for checkout creation) CardProv card.CardProvider Solver *captcha.Solver StripeCfg config.StripeConfig Fingerprint *Fingerprint EmailAddr string StripeJsID string UserAgent string StatusFn func(string, ...interface{}) MaxRetries int // default 20 CheckAmountFn func(amount int) error // optional: return error to reject amount (e.g. Plus $20 check) } // PaymentFlowResult holds the outcome of a successful payment flow. type PaymentFlowResult struct { ConfirmResult *ConfirmResult CardInfo *card.CardInfo // the card that succeeded } // RunPaymentFlow executes the card retry + payment + captcha loop shared by Plus and Team flows. // // Internal flow (per card attempt): // 1. GetCard (first attempt uses FirstCard, retries get new card) // 2. InitCheckoutSession → init_checksum // 3. UpdateCheckoutSession (billing address, tax recalculation) // 4. CheckAmountFn (optional, e.g. $20 = no free trial for Plus) // 5. First attempt only: send telemetry events + solve passive captcha // 6. ConfirmPaymentDirect (with js_checksum, rv_timestamp, passive_captcha_token) // 7. No captcha → success // 8. hCaptcha triggered → solve → verify → success // 9. Card error / captcha failure → continue with new card func RunPaymentFlow(ctx context.Context, params *PaymentFlowParams) (*PaymentFlowResult, error) { sf := func(format string, args ...interface{}) { if params.StatusFn != nil { params.StatusFn(format, args...) } } maxRetries := params.MaxRetries if maxRetries <= 0 { maxRetries = 20 } // Set solver status function for progress printing if params.Solver != nil { params.Solver.SetStatusFn(params.StatusFn) } cardInfo := params.FirstCard var paymentSuccess bool var lastErr error var confirmResult *ConfirmResult for attempt := 1; attempt <= maxRetries; attempt++ { // Get new card for retries (first attempt uses FirstCard) if attempt > 1 { sf(" → 换卡重试 (%d/%d): 获取新卡片...", attempt, maxRetries) var err error cardInfo, err = params.CardProv.GetCard(ctx) if err != nil { return nil, fmt.Errorf("get card (attempt %d): %w", attempt, err) } } sf(" → 当前卡片 [%d/%d]: %s | %s/%s | %s | %s %s %s %s", attempt, maxRetries, cardInfo.Number, cardInfo.ExpMonth, cardInfo.ExpYear, cardInfo.Country, cardInfo.Address, cardInfo.City, cardInfo.State, cardInfo.PostalCode) expectedAmount := params.CheckoutResult.ExpectedAmount // Step 1: Init checkout session (get init_checksum), with network retry sf(" → 初始化 Checkout 会话...") var initResult *InitResult var initErr error for initTry := 1; initTry <= 3; initTry++ { initResult, initErr = InitCheckoutSession( params.Client, params.CheckoutResult.CheckoutSessionID, params.CheckoutResult.PublishableKey, params.StripeCfg.StripeVersion, params.StripeJsID, params.UserAgent, cardInfo.Country, ) if initErr == nil { break } if initTry < 3 { sf(" → 初始化失败 (%d/3),%d秒后重试: %v", initTry, initTry*2, initErr) log.Printf("[payment-flow] init attempt %d failed: %v, retrying...", initTry, initErr) time.Sleep(time.Duration(initTry*2) * time.Second) } } if initErr != nil { return nil, fmt.Errorf("init checkout session (3 attempts): %w", initErr) } log.Printf("[payment-flow] init done, checksum=%s", initResult.InitChecksum) // Step 2: Update checkout session with billing address (tax recalculation), with retry sf(" → 更新账单地址 (税费计算)...") var updateResult *UpdateResult var updateErr error for updTry := 1; updTry <= 3; updTry++ { updateResult, updateErr = UpdateCheckoutSession(params.Client, &UpdateCheckoutParams{ CheckoutSessionID: params.CheckoutResult.CheckoutSessionID, PublishableKey: params.CheckoutResult.PublishableKey, StripeVersion: params.StripeCfg.StripeVersion, StripeJsID: params.StripeJsID, BillingCountry: cardInfo.Country, BillingAddress: cardInfo.Address, BillingCity: cardInfo.City, BillingState: cardInfo.State, BillingZip: cardInfo.PostalCode, UserAgent: params.UserAgent, }) if updateErr == nil { break } if updTry < 3 { sf(" → 更新地址失败 (%d/3),重试: %v", updTry, updateErr) time.Sleep(time.Duration(updTry*2) * time.Second) } } if updateErr != nil { return nil, fmt.Errorf("update checkout session (3 attempts): %w", updateErr) } if updateResult.UpdatedAmount > 0 { log.Printf("[payment-flow] amount updated: %d -> %d (tax recalculated)", expectedAmount, updateResult.UpdatedAmount) expectedAmount = updateResult.UpdatedAmount } // Step 3: Check amount (optional callback, e.g. Plus $20 = no free trial) if params.CheckAmountFn != nil { if err := params.CheckAmountFn(expectedAmount); err != nil { return nil, err } } // Step 4: Confirm payment with inline card data sf(" → 确认支付 (金额=$%.2f)...", float64(expectedAmount)/100) var confirmErr error confirmResult, confirmErr = ConfirmPaymentDirect(params.Client, &DirectConfirmParams{ CheckoutSessionID: params.CheckoutResult.CheckoutSessionID, CardNumber: cardInfo.Number, CardCVC: cardInfo.CVC, CardExpMonth: cardInfo.ExpMonth, CardExpYear: cardInfo.ExpYear, BillingName: cardInfo.Name, BillingEmail: params.EmailAddr, BillingCountry: cardInfo.Country, BillingAddress: cardInfo.Address, BillingCity: cardInfo.City, BillingState: cardInfo.State, BillingZip: cardInfo.PostalCode, GUID: params.Fingerprint.GUID, MUID: params.Fingerprint.MUID, SID: params.Fingerprint.SID, ExpectedAmount: expectedAmount, InitChecksum: initResult.InitChecksum, PublishableKey: params.CheckoutResult.PublishableKey, BuildHash: params.StripeCfg.BuildHash, StripeVersion: params.StripeCfg.StripeVersion, StripeJsID: params.StripeJsID, UserAgent: params.UserAgent, }) if confirmErr != nil { errMsg := confirmErr.Error() if flowIsCardError(errMsg) { sf(" ⚠ 卡号被拒 (%d/%d): %s", attempt, maxRetries, errMsg) params.CardProv.ReportResult(ctx, cardInfo, false) log.Printf("[payment-flow] card rejected (attempt %d/%d): %v", attempt, maxRetries, confirmErr) lastErr = confirmErr time.Sleep(1 * time.Second) continue // retry with new card } return nil, fmt.Errorf("confirm payment: %w", confirmErr) } // Step 6: Payment confirmed — check if captcha challenge required if !confirmResult.RequiresAction { sf(" → 卡号 ...%s 支付成功 (第 %d 次尝试, 无验证码)", flowLast4(cardInfo.Number), attempt) paymentSuccess = true break } // Step 7: hCaptcha challenge triggered if params.Solver == nil { return nil, ErrNoCaptchaSolver } sf(" → 触发 hCaptcha 验证码,正在解决...") captchaToken, captchaEKey, solveErr := params.Solver.SolveHCaptcha(ctx, confirmResult.SiteKey, "https://b.stripecdn.com", confirmResult.RqData, ) if solveErr != nil { // Solver failure = skip this activation, not a card issue sf(" ⚠ 验证码解决失败,跳过: %v", solveErr) return nil, fmt.Errorf("captcha solve failed: %w", solveErr) } sf(" → hCaptcha 已解决,验证中...") verifyErr := VerifyChallenge(params.Client, &VerifyChallengeParams{ SetupIntentID: confirmResult.SetupIntentID, ClientSecret: confirmResult.ClientSecret, CaptchaToken: captchaToken, CaptchaEKey: captchaEKey, PublishableKey: params.CheckoutResult.PublishableKey, StripeVersion: params.StripeCfg.StripeVersion, UserAgent: params.UserAgent, MUID: params.Fingerprint.MUID, SID: params.Fingerprint.SID, }) if verifyErr != nil { verifyMsg := verifyErr.Error() log.Printf("[payment-flow] captcha verify failed (attempt %d/%d): %v", attempt, maxRetries, verifyErr) // Card decline after captcha → reject card, switch card, retry if flowIsCardError(verifyMsg) { sf(" ⚠ 验证码后卡被拒 (%d/%d): %v", attempt, maxRetries, verifyErr) params.CardProv.ReportResult(ctx, cardInfo, false) lastErr = verifyErr time.Sleep(1 * time.Second) continue // retry with new card } // Non-card failure (e.g. "Captcha challenge failed") → skip activation sf(" ⚠ 验证码验证失败,跳过: %v", verifyErr) return nil, fmt.Errorf("captcha verify failed: %w", verifyErr) } sf(" → 卡号 ...%s 支付+验证码通过 (第 %d 次尝试)", flowLast4(cardInfo.Number), attempt) paymentSuccess = true break } if !paymentSuccess { return nil, fmt.Errorf("all %d payment attempts failed: %w", maxRetries, lastErr) } params.CardProv.ReportResult(ctx, cardInfo, true) return &PaymentFlowResult{ ConfirmResult: confirmResult, CardInfo: cardInfo, }, nil } // flowIsCardError checks if the error message indicates a Stripe card decline that warrants switching cards. func flowIsCardError(errMsg string) bool { cardErrors := []string{ "card_declined", "incorrect_number", "invalid_number", "invalid_expiry", "invalid_cvc", "expired_card", "processing_error", "type=card_error", "requires new payment method", "Your card number is incorrect", "Your card was declined", } for _, e := range cardErrors { if strings.Contains(errMsg, e) { return true } } return false } // flowLast4 returns the last 4 characters of a string. func flowLast4(s string) string { if len(s) <= 4 { return s } return s[len(s)-4:] }