300 lines
10 KiB
Go
300 lines
10 KiB
Go
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:]
|
||
}
|