Initial sanitized code sync
This commit is contained in:
299
pkg/stripe/checkout_flow.go
Normal file
299
pkg/stripe/checkout_flow.go
Normal file
@@ -0,0 +1,299 @@
|
||||
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:]
|
||||
}
|
||||
Reference in New Issue
Block a user