Files
gpt-plus-gpt/pkg/stripe/checkout_flow.go
2026-03-15 20:48:19 +08:00

300 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:]
}