Initial sanitized code sync
This commit is contained in:
149
pkg/stripe/aimizy.go
Normal file
149
pkg/stripe/aimizy.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package stripe
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gpt-plus/config"
|
||||
)
|
||||
|
||||
// aimizyResponse represents the JSON response from the Aimizy API.
|
||||
type aimizyResponse struct {
|
||||
Success bool `json:"success"`
|
||||
CheckoutSessionID string `json:"checkout_session_id"`
|
||||
URL string `json:"url"`
|
||||
PublishableKey string `json:"publishable_key"`
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
var csLiveRe = regexp.MustCompile(`cs_live_[A-Za-z0-9]+`)
|
||||
|
||||
// CreateCheckoutViaAimizy uses the Aimizy API to generate a $0 checkout session.
|
||||
// This is an alternative to calling ChatGPT's /backend-api/payments/checkout directly.
|
||||
// Returns a CheckoutResult compatible with the normal Stripe flow.
|
||||
func CreateCheckoutViaAimizy(aimizy config.AimizyConfig, accessToken, country, currency, planName, promoCampaignID string, seatQuantity int) (*CheckoutResult, error) {
|
||||
payload := map[string]interface{}{
|
||||
"access_token": accessToken,
|
||||
"check_card_proxy": false,
|
||||
"country": country,
|
||||
"currency": currency,
|
||||
"is_coupon_from_query_param": true,
|
||||
"is_short_link": true,
|
||||
"plan_name": planName,
|
||||
"price_interval": "month",
|
||||
"promo_campaign_id": promoCampaignID,
|
||||
}
|
||||
|
||||
// Team plan needs seat_quantity
|
||||
if seatQuantity > 0 {
|
||||
payload["seat_quantity"] = seatQuantity
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal aimizy payload: %w", err)
|
||||
}
|
||||
|
||||
apiURL := aimizy.BaseURL + "/api/public/generate-payment-link"
|
||||
|
||||
var resp *http.Response
|
||||
var respBody []byte
|
||||
directClient := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
for try := 1; try <= 3; try++ {
|
||||
req, err := http.NewRequest("POST", apiURL, strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build aimizy request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Origin", aimizy.BaseURL)
|
||||
req.Header.Set("Referer", aimizy.BaseURL+"/pay")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||
|
||||
resp, err = directClient.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("[aimizy] attempt %d/3: request error: %v", try, err)
|
||||
if try < 3 {
|
||||
time.Sleep(3 * time.Second)
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("aimizy request failed after 3 attempts: %w", err)
|
||||
}
|
||||
|
||||
respBody, err = io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read aimizy response: %w", err)
|
||||
}
|
||||
|
||||
// Log full response headers and body
|
||||
log.Printf("[aimizy] attempt %d/3: status=%d", try, resp.StatusCode)
|
||||
for k, vs := range resp.Header {
|
||||
for _, v := range vs {
|
||||
log.Printf("[aimizy] response header: %s: %s", k, v)
|
||||
}
|
||||
}
|
||||
log.Printf("[aimizy] response body: %s", string(respBody))
|
||||
|
||||
if resp.StatusCode == 200 {
|
||||
break
|
||||
}
|
||||
log.Printf("[aimizy] attempt %d/3: status %d, retrying...", try, resp.StatusCode)
|
||||
if try < 3 {
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
if resp == nil || resp.StatusCode != 200 {
|
||||
statusCode := 0
|
||||
if resp != nil {
|
||||
statusCode = resp.StatusCode
|
||||
}
|
||||
return nil, fmt.Errorf("aimizy failed: status %d, body: %s", statusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result aimizyResponse
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse aimizy response: %w (body: %s)", err, string(respBody))
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
return nil, fmt.Errorf("aimizy returned success=false: %s", string(respBody))
|
||||
}
|
||||
|
||||
// Extract checkout_session_id
|
||||
csID := result.CheckoutSessionID
|
||||
if csID == "" && result.URL != "" {
|
||||
// Fallback: extract from URL
|
||||
if m := csLiveRe.FindString(result.URL); m != "" {
|
||||
csID = m
|
||||
}
|
||||
}
|
||||
if csID == "" {
|
||||
return nil, fmt.Errorf("aimizy returned no checkout_session_id: %s", string(respBody))
|
||||
}
|
||||
|
||||
// Build compatible CheckoutResult
|
||||
cr := &CheckoutResult{
|
||||
CheckoutSessionID: csID,
|
||||
ProcessorEntity: "openai_llc",
|
||||
PublishableKey: result.PublishableKey,
|
||||
}
|
||||
|
||||
// If publishable_key not in aimizy response, use the known production key
|
||||
if cr.PublishableKey == "" {
|
||||
cr.PublishableKey = "pk_live_51HOrSwC6h1nxGoI3lTAgRjYVrz4dU3fVOabyCcKR3pbEJguCVAlqCxdxCUvoRh1XWwRacViovU3kLKvpkjh7IqkW00iXQsjo3n"
|
||||
}
|
||||
|
||||
log.Printf("[aimizy] checkout session created: %s (key=%s)", cr.CheckoutSessionID, cr.PublishableKey[:20]+"...")
|
||||
|
||||
return cr, nil
|
||||
}
|
||||
110
pkg/stripe/browser_fp.go
Normal file
110
pkg/stripe/browser_fp.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package stripe
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// BrowserFingerprint holds real browser fingerprint data harvested from a real browser.
|
||||
type BrowserFingerprint struct {
|
||||
CookieSupport bool `json:"cookie_support"`
|
||||
DoNotTrack bool `json:"do_not_track"`
|
||||
Language string `json:"language"`
|
||||
Platform string `json:"platform"`
|
||||
ScreenSize string `json:"screen_size"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Plugins string `json:"plugins"`
|
||||
WebGLVendor string `json:"webgl_vendor"`
|
||||
WebGLRenderer string `json:"webgl_renderer"`
|
||||
FontsBits string `json:"fonts_bits"`
|
||||
CanvasHash string `json:"canvas_hash"`
|
||||
OaiDID string `json:"oai_did"`
|
||||
CfClearance string `json:"cf_clearance"`
|
||||
CsrfToken string `json:"csrf_token"`
|
||||
Region string `json:"region"`
|
||||
StripeFpID string `json:"stripe_fingerprint_id"`
|
||||
}
|
||||
|
||||
// BrowserFingerprintPool manages a pool of pre-harvested browser fingerprints.
|
||||
type BrowserFingerprintPool struct {
|
||||
mu sync.Mutex
|
||||
fingerprints []*BrowserFingerprint
|
||||
index int
|
||||
}
|
||||
|
||||
// NewBrowserFingerprintPool loads all fingerprint JSON files from a directory.
|
||||
// If filterLang is not empty, only loads fingerprints matching that language prefix (e.g. "ko").
|
||||
func NewBrowserFingerprintPool(dir string, filterLang ...string) (*BrowserFingerprintPool, error) {
|
||||
files, err := filepath.Glob(filepath.Join(dir, "*.json"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("glob fingerprint dir: %w", err)
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
return nil, fmt.Errorf("no fingerprint files found in %s", dir)
|
||||
}
|
||||
|
||||
langPrefix := ""
|
||||
if len(filterLang) > 0 && filterLang[0] != "" {
|
||||
langPrefix = strings.ToLower(filterLang[0])
|
||||
}
|
||||
|
||||
var fps []*BrowserFingerprint
|
||||
for _, f := range files {
|
||||
data, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
log.Printf("[fingerprint] warning: skip %s: %v", f, err)
|
||||
continue
|
||||
}
|
||||
var fp BrowserFingerprint
|
||||
if err := json.Unmarshal(data, &fp); err != nil {
|
||||
log.Printf("[fingerprint] warning: skip %s: %v", f, err)
|
||||
continue
|
||||
}
|
||||
if fp.CanvasHash == "" || fp.UserAgent == "" {
|
||||
log.Printf("[fingerprint] warning: skip %s: missing canvas_hash or user_agent", f)
|
||||
continue
|
||||
}
|
||||
// Filter by language if specified
|
||||
if langPrefix != "" && !strings.HasPrefix(strings.ToLower(fp.Language), langPrefix) {
|
||||
log.Printf("[fingerprint] skip %s: language %q doesn't match %q", filepath.Base(f), fp.Language, langPrefix)
|
||||
continue
|
||||
}
|
||||
fps = append(fps, &fp)
|
||||
}
|
||||
|
||||
if len(fps) == 0 {
|
||||
return nil, fmt.Errorf("no valid fingerprints loaded from %s", dir)
|
||||
}
|
||||
|
||||
// Shuffle
|
||||
for i := len(fps) - 1; i > 0; i-- {
|
||||
n, _ := rand.Int(rand.Reader, big.NewInt(int64(i+1)))
|
||||
j := int(n.Int64())
|
||||
fps[i], fps[j] = fps[j], fps[i]
|
||||
}
|
||||
|
||||
log.Printf("[fingerprint] loaded %d browser fingerprints from %s", len(fps), dir)
|
||||
return &BrowserFingerprintPool{fingerprints: fps}, nil
|
||||
}
|
||||
|
||||
// Get returns the next fingerprint in round-robin order.
|
||||
func (p *BrowserFingerprintPool) Get() *BrowserFingerprint {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
fp := p.fingerprints[p.index%len(p.fingerprints)]
|
||||
p.index++
|
||||
return fp
|
||||
}
|
||||
|
||||
// Count returns the number of fingerprints in the pool.
|
||||
func (p *BrowserFingerprintPool) Count() int {
|
||||
return len(p.fingerprints)
|
||||
}
|
||||
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:]
|
||||
}
|
||||
47
pkg/stripe/checksum.go
Normal file
47
pkg/stripe/checksum.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package stripe
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// mcL applies XOR(5) to each byte, then base64 encodes, then URL encodes.
|
||||
// Mirrors the MC_L function in stripe.js.
|
||||
func mcL(data string) string {
|
||||
xored := make([]byte, len(data))
|
||||
for i := 0; i < len(data); i++ {
|
||||
xored[i] = data[i] ^ 5
|
||||
}
|
||||
b64 := base64.StdEncoding.EncodeToString(xored)
|
||||
return url.QueryEscape(b64)
|
||||
}
|
||||
|
||||
// lc applies a Caesar cipher on printable ASCII (32-126) with the given shift.
|
||||
// Mirrors the LC function in stripe.js.
|
||||
func lc(s string, shift int) string {
|
||||
result := make([]byte, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := int(s[i])
|
||||
if c >= 32 && c <= 126 {
|
||||
result[i] = byte(((c - 32 + shift) % 95) + 32)
|
||||
} else {
|
||||
result[i] = s[i]
|
||||
}
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// JsChecksum computes the js_checksum field for Stripe confirm requests.
|
||||
// ppageID is the checkout session ID (cs_xxx).
|
||||
// Uses dynamically fetched SV from Stripe.js.
|
||||
func JsChecksum(ppageID string) string {
|
||||
sc := FetchStripeConstants()
|
||||
return mcL(ppageID + ":" + sc.SV)
|
||||
}
|
||||
|
||||
// RvTimestamp computes the rv_timestamp field for Stripe confirm requests.
|
||||
// Uses dynamically fetched RV and RVTS from Stripe.js.
|
||||
func RvTimestamp() string {
|
||||
sc := FetchStripeConstants()
|
||||
return lc(sc.RV+":"+sc.RVTS, 5)
|
||||
}
|
||||
186
pkg/stripe/constants.go
Normal file
186
pkg/stripe/constants.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package stripe
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StripeConstants holds dynamically fetched Stripe.js build constants.
|
||||
type StripeConstants struct {
|
||||
RV string // 40-char hex from stripe.js (STRIPE_JS_BUILD_SALT)
|
||||
SV string // 64-char hex from stripe.js (STRIPE_JS_BUILD_SALT)
|
||||
RVTS string // date string e.g. "2024-01-01 00:00:00 -0000"
|
||||
BuildHash string // first 10 chars of RV (module 6179)
|
||||
TagVersion string // from m.stripe.network/inner.html out-X.X.X.js
|
||||
}
|
||||
|
||||
// Fallback values — used when dynamic fetch fails.
|
||||
var fallbackConstants = StripeConstants{
|
||||
RV: "e5b328e98e63961074bfff3e3ac7f85ffe37b12b",
|
||||
SV: "663ce80473de6178ef298eecdc3e16645c1463af7afe64fff89400f5e02aa0c7",
|
||||
RVTS: "2024-01-01 00:00:00 -0000",
|
||||
BuildHash: "ede17ac9fd",
|
||||
TagVersion: "4.5.43",
|
||||
}
|
||||
|
||||
var (
|
||||
cachedConstants *StripeConstants
|
||||
cachedAt time.Time
|
||||
constantsMu sync.Mutex
|
||||
constantsTTL = 1 * time.Hour
|
||||
)
|
||||
|
||||
// SetFallbackConstants allows overriding fallback values from config.
|
||||
func SetFallbackConstants(buildHash, tagVersion string) {
|
||||
if buildHash != "" {
|
||||
fallbackConstants.BuildHash = buildHash
|
||||
}
|
||||
if tagVersion != "" {
|
||||
fallbackConstants.TagVersion = tagVersion
|
||||
}
|
||||
}
|
||||
|
||||
// FetchStripeConstants returns the current Stripe.js build constants.
|
||||
// Uses a 1-hour cache. Falls back to hardcoded/config values on failure.
|
||||
func FetchStripeConstants() *StripeConstants {
|
||||
constantsMu.Lock()
|
||||
defer constantsMu.Unlock()
|
||||
|
||||
if cachedConstants != nil && time.Since(cachedAt) < constantsTTL {
|
||||
return cachedConstants
|
||||
}
|
||||
|
||||
sc, err := doFetchStripeConstants()
|
||||
if err != nil {
|
||||
log.Printf("[stripe-constants] fetch failed: %v, using fallback values", err)
|
||||
fb := fallbackConstants // copy
|
||||
return &fb
|
||||
}
|
||||
|
||||
cachedConstants = sc
|
||||
cachedAt = time.Now()
|
||||
log.Printf("[stripe-constants] fetched: RV=%s, SV=%s..., BuildHash=%s, TagVersion=%s, RVTS=%s",
|
||||
sc.RV, sc.SV[:16], sc.BuildHash, sc.TagVersion, sc.RVTS)
|
||||
return sc
|
||||
}
|
||||
|
||||
// directHTTPClient creates a direct (no-proxy) HTTP client for fetching Stripe.js.
|
||||
func directHTTPClient() *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
}).DialContext,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Regex patterns for extracting constants from stripe.js bundle.
|
||||
var (
|
||||
reRV = regexp.MustCompile(`STRIPE_JS_BUILD_SALT\s*=\s*\[\s*"([0-9a-f]{40})"`)
|
||||
reSV = regexp.MustCompile(`STRIPE_JS_BUILD_SALT\s*=\s*\[[^]]*"[0-9a-f]{40}"\s*,\s*"([0-9a-f]{64})"`)
|
||||
reRVTS = regexp.MustCompile(`STRIPE_JS_BUILD_SALT\s*=\s*\[[^]]*"[0-9a-f]{40}"\s*,\s*"[0-9a-f]{64}"\s*,\s*"(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\s+[-+]\d{4})"`)
|
||||
reBuildHash = regexp.MustCompile(`"([0-9a-f]{10})"`)
|
||||
reTagVersion = regexp.MustCompile(`out-(\d+\.\d+\.\d+)\.js`)
|
||||
)
|
||||
|
||||
func doFetchStripeConstants() (*StripeConstants, error) {
|
||||
client := directHTTPClient()
|
||||
|
||||
// Step 1: Fetch js.stripe.com/v3/ to extract RV, SV, RVTS, BuildHash
|
||||
stripeJS, err := fetchURL(client, "https://js.stripe.com/v3/")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch stripe.js: %w", err)
|
||||
}
|
||||
|
||||
sc := &StripeConstants{}
|
||||
|
||||
if m := reRV.FindStringSubmatch(stripeJS); len(m) > 1 {
|
||||
sc.RV = m[1]
|
||||
} else {
|
||||
re40 := regexp.MustCompile(`"([0-9a-f]{40})"`)
|
||||
matches := re40.FindAllStringSubmatch(stripeJS, -1)
|
||||
if len(matches) > 0 {
|
||||
sc.RV = matches[0][1]
|
||||
}
|
||||
}
|
||||
|
||||
if m := reSV.FindStringSubmatch(stripeJS); len(m) > 1 {
|
||||
sc.SV = m[1]
|
||||
} else {
|
||||
re64 := regexp.MustCompile(`"([0-9a-f]{64})"`)
|
||||
matches := re64.FindAllStringSubmatch(stripeJS, -1)
|
||||
if len(matches) > 0 {
|
||||
sc.SV = matches[0][1]
|
||||
}
|
||||
}
|
||||
|
||||
if m := reRVTS.FindStringSubmatch(stripeJS); len(m) > 1 {
|
||||
sc.RVTS = m[1]
|
||||
}
|
||||
|
||||
if sc.RV != "" && len(sc.RV) >= 10 {
|
||||
sc.BuildHash = sc.RV[:10]
|
||||
}
|
||||
|
||||
if sc.RV == "" || sc.SV == "" {
|
||||
return nil, fmt.Errorf("failed to extract RV/SV from stripe.js (len=%d)", len(stripeJS))
|
||||
}
|
||||
if sc.RVTS == "" {
|
||||
sc.RVTS = fallbackConstants.RVTS
|
||||
}
|
||||
if sc.BuildHash == "" {
|
||||
sc.BuildHash = fallbackConstants.BuildHash
|
||||
}
|
||||
|
||||
// Step 2: Fetch m.stripe.network/inner.html to extract tagVersion
|
||||
innerHTML, err := fetchURL(client, "https://m.stripe.network/inner.html")
|
||||
if err != nil {
|
||||
log.Printf("[stripe-constants] fetch inner.html failed: %v, using fallback tagVersion", err)
|
||||
sc.TagVersion = fallbackConstants.TagVersion
|
||||
} else {
|
||||
if m := reTagVersion.FindStringSubmatch(innerHTML); len(m) > 1 {
|
||||
sc.TagVersion = m[1]
|
||||
} else {
|
||||
log.Printf("[stripe-constants] tagVersion not found in inner.html, using fallback")
|
||||
sc.TagVersion = fallbackConstants.TagVersion
|
||||
}
|
||||
}
|
||||
|
||||
return sc, nil
|
||||
}
|
||||
|
||||
func fetchURL(client *http.Client, url string) (string, error) {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("status %d for %s", resp.StatusCode, url)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
return string(body), nil
|
||||
}
|
||||
222
pkg/stripe/fingerprint.go
Normal file
222
pkg/stripe/fingerprint.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package stripe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gpt-plus/pkg/httpclient"
|
||||
)
|
||||
|
||||
// Fingerprint holds Stripe anti-fraud device IDs.
|
||||
type Fingerprint struct {
|
||||
GUID string
|
||||
MUID string
|
||||
SID string
|
||||
}
|
||||
|
||||
const fingerprintMaxRetries = 3
|
||||
|
||||
// GetFingerprint sends payload to m.stripe.com/6 and retrieves device fingerprint.
|
||||
// Uses the provided (proxied) client. If it fails, returns an error — never fakes GUID.
|
||||
// If browserFP is provided, real fingerprint data is used in the payload.
|
||||
func GetFingerprint(client *httpclient.Client, userAgent, domain, tagVersion string, browserFP ...*BrowserFingerprint) (*Fingerprint, error) {
|
||||
var bfp *BrowserFingerprint
|
||||
if len(browserFP) > 0 {
|
||||
bfp = browserFP[0]
|
||||
}
|
||||
payload := CreateInitPayload(userAgent, domain, tagVersion, bfp)
|
||||
encoded, err := EncodePayload(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode payload: %w", err)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= fingerprintMaxRetries; attempt++ {
|
||||
fp, err := doFingerprintRequest(nil, client, encoded, userAgent)
|
||||
if err == nil {
|
||||
fp = retryForMUIDSID(fp, nil, client, encoded, userAgent)
|
||||
return fp, nil
|
||||
}
|
||||
lastErr = err
|
||||
if isRetryableError(err) {
|
||||
log.Printf("[stripe] fingerprint attempt %d/%d failed (retryable): %v", attempt, fingerprintMaxRetries, err)
|
||||
if attempt < fingerprintMaxRetries {
|
||||
time.Sleep(time.Duration(attempt*3) * time.Second)
|
||||
}
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("send fingerprint request: %w", err)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("m.stripe.com unreachable after %d retries: %w", fingerprintMaxRetries, lastErr)
|
||||
}
|
||||
|
||||
// GetFingerprintDirect sends payload to m.stripe.com/6 using a direct (no-proxy) HTTPS connection.
|
||||
// This bypasses the SOCKS proxy which may block m.stripe.com.
|
||||
// The direct connection is safe: m.stripe.com is Stripe's telemetry endpoint
|
||||
// and does not leak your real IP to OpenAI — it only communicates with Stripe.
|
||||
func GetFingerprintDirect(userAgent, domain, tagVersion string) (*Fingerprint, error) {
|
||||
payload := CreateInitPayload(userAgent, domain, tagVersion)
|
||||
encoded, err := EncodePayload(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode payload: %w", err)
|
||||
}
|
||||
|
||||
directClient := &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
}).DialContext,
|
||||
},
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= fingerprintMaxRetries; attempt++ {
|
||||
fp, err := doFingerprintRequest(directClient, nil, encoded, userAgent)
|
||||
if err == nil {
|
||||
fp = retryForMUIDSID(fp, directClient, nil, encoded, userAgent)
|
||||
return fp, nil
|
||||
}
|
||||
lastErr = err
|
||||
if isRetryableError(err) {
|
||||
log.Printf("[stripe] fingerprint-direct attempt %d/%d failed (retryable): %v", attempt, fingerprintMaxRetries, err)
|
||||
if attempt < fingerprintMaxRetries {
|
||||
time.Sleep(time.Duration(attempt*2) * time.Second)
|
||||
}
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("send fingerprint-direct request: %w", err)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("m.stripe.com unreachable (direct) after %d retries: %w", fingerprintMaxRetries, lastErr)
|
||||
}
|
||||
|
||||
// retryForMUIDSID retries fingerprint request if GUID is present but MUID/SID are empty.
|
||||
// Makes up to 2 additional attempts with increasing delay, merging results.
|
||||
func retryForMUIDSID(fp *Fingerprint, directClient *http.Client, proxiedClient *httpclient.Client, encoded, userAgent string) *Fingerprint {
|
||||
if fp.GUID == "" || (fp.MUID != "" && fp.SID != "") {
|
||||
return fp
|
||||
}
|
||||
|
||||
log.Printf("[stripe] GUID present but MUID/SID empty, retrying to fill...")
|
||||
for i := 0; i < 2; i++ {
|
||||
time.Sleep(time.Duration(500+i*500) * time.Millisecond)
|
||||
retryFP, err := doFingerprintRequest(directClient, proxiedClient, encoded, userAgent)
|
||||
if err != nil {
|
||||
log.Printf("[stripe] MUID/SID retry %d failed: %v", i+1, err)
|
||||
continue
|
||||
}
|
||||
if fp.MUID == "" && retryFP.MUID != "" {
|
||||
fp.MUID = retryFP.MUID
|
||||
}
|
||||
if fp.SID == "" && retryFP.SID != "" {
|
||||
fp.SID = retryFP.SID
|
||||
}
|
||||
if fp.MUID != "" && fp.SID != "" {
|
||||
log.Printf("[stripe] MUID/SID filled after retry %d", i+1)
|
||||
break
|
||||
}
|
||||
}
|
||||
return fp
|
||||
}
|
||||
|
||||
// GetFingerprintAuto tries the proxied client first; if it fails, falls back to direct connection.
|
||||
func GetFingerprintAuto(ctx context.Context, client *httpclient.Client, userAgent, domain, tagVersion string) (*Fingerprint, error) {
|
||||
fp, err := GetFingerprint(client, userAgent, domain, tagVersion)
|
||||
if err == nil {
|
||||
return fp, nil
|
||||
}
|
||||
log.Printf("[stripe] proxy fingerprint failed (%v), falling back to direct connection", err)
|
||||
return GetFingerprintDirect(userAgent, domain, tagVersion)
|
||||
}
|
||||
|
||||
// doFingerprintRequest executes a single POST to m.stripe.com/6.
|
||||
// Exactly one of directClient / proxiedClient must be non-nil.
|
||||
func doFingerprintRequest(directClient *http.Client, proxiedClient *httpclient.Client, encodedPayload, userAgent string) (*Fingerprint, error) {
|
||||
req, err := http.NewRequest("POST", "https://m.stripe.com/6", strings.NewReader(encodedPayload))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
// Set proper headers — missing these caused silent rejections
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Content-Type", "text/plain;charset=UTF-8")
|
||||
req.Header.Set("Origin", "https://js.stripe.com")
|
||||
req.Header.Set("Referer", "https://js.stripe.com/")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
|
||||
var resp *http.Response
|
||||
if directClient != nil {
|
||||
resp, err = directClient.Do(req)
|
||||
} else {
|
||||
resp, err = proxiedClient.Do(req)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close() // safe: one request per call, no loop
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("fingerprint request failed: status %d, body: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse fingerprint response: %w", err)
|
||||
}
|
||||
|
||||
fp := &Fingerprint{}
|
||||
if v, ok := result["guid"].(string); ok {
|
||||
fp.GUID = v
|
||||
}
|
||||
if v, ok := result["muid"].(string); ok {
|
||||
fp.MUID = v
|
||||
}
|
||||
if v, ok := result["sid"].(string); ok {
|
||||
fp.SID = v
|
||||
}
|
||||
|
||||
// GUID is critical — if missing, Stripe WILL trigger 3DS or decline
|
||||
if fp.GUID == "" {
|
||||
return nil, fmt.Errorf("m.stripe.com returned empty GUID (response: %s)", string(body))
|
||||
}
|
||||
|
||||
// MUID/SID can sometimes be empty on first call, but GUID is the key signal
|
||||
if fp.MUID == "" {
|
||||
log.Printf("[stripe] WARNING: MUID empty in fingerprint response, proceeding with GUID only")
|
||||
}
|
||||
if fp.SID == "" {
|
||||
log.Printf("[stripe] WARNING: SID empty in fingerprint response, proceeding with GUID only")
|
||||
}
|
||||
|
||||
return fp, nil
|
||||
}
|
||||
|
||||
// isRetryableError checks if the error is a transient network issue worth retrying.
|
||||
func isRetryableError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "connection refused") ||
|
||||
strings.Contains(msg, "connection reset") ||
|
||||
strings.Contains(msg, "i/o timeout") ||
|
||||
strings.Contains(msg, "no such host") ||
|
||||
strings.Contains(msg, "network is unreachable")
|
||||
}
|
||||
189
pkg/stripe/payload.go
Normal file
189
pkg/stripe/payload.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package stripe
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
mrand "math/rand"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// randomHex generates n bytes of cryptographically random hex.
|
||||
func randomHex(nBytes int) string {
|
||||
b := make([]byte, nBytes)
|
||||
_, _ = rand.Read(b)
|
||||
return fmt.Sprintf("%x", b)
|
||||
}
|
||||
|
||||
// generateRandomBinaryString generates a string of random 0s and 1s.
|
||||
func generateRandomBinaryString(length int) string {
|
||||
var sb strings.Builder
|
||||
sb.Grow(length)
|
||||
for i := 0; i < length; i++ {
|
||||
n, _ := rand.Int(rand.Reader, big.NewInt(2))
|
||||
sb.WriteByte('0' + byte(n.Int64()))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// transformFeatureValues converts extractedFeatures array to the keyed map format.
|
||||
// [{v, t}, ...] -> {a: {v, t}, b: {v, t}, ...}
|
||||
func transformFeatureValues(features [][]interface{}) map[string]interface{} {
|
||||
result := make(map[string]interface{})
|
||||
for i, f := range features {
|
||||
key := string(rune('a' + i))
|
||||
entry := map[string]interface{}{
|
||||
"v": f[0],
|
||||
"t": f[1],
|
||||
}
|
||||
if len(f) > 2 {
|
||||
entry["at"] = f[2]
|
||||
}
|
||||
result[key] = entry
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// featuresSameLine joins all feature values with spaces (for MD5 id).
|
||||
func featuresSameLine(features [][]interface{}) string {
|
||||
parts := make([]string, len(features))
|
||||
for i, f := range features {
|
||||
parts[i] = fmt.Sprintf("%v", f[0])
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// domainToTabTitle returns a suitable tab title for the given domain.
|
||||
func domainToTabTitle(domain string) string {
|
||||
switch domain {
|
||||
case "chatgpt.com":
|
||||
return "ChatGPT"
|
||||
case "discord.com":
|
||||
return "Discord | Billing | User Settings"
|
||||
default:
|
||||
return domain
|
||||
}
|
||||
}
|
||||
|
||||
// CreateInitPayload builds the m.stripe.com/6 fingerprint payload.
|
||||
// domain should be "chatgpt.com" (the merchant site).
|
||||
// If browserFP is provided, real fingerprint data overrides hardcoded defaults.
|
||||
func CreateInitPayload(userAgent, domain, tagVersion string, browserFP ...*BrowserFingerprint) map[string]interface{} {
|
||||
if tagVersion == "" {
|
||||
tagVersion = "4.5.43"
|
||||
}
|
||||
|
||||
// Defaults (hardcoded)
|
||||
language := "en-US"
|
||||
platform := "Win32"
|
||||
plugins := "Browser PDF plug-in,HqVxgvf2j4FKFpUSJjZUxg368mTJr8Hq,application/pdf,pdf, aNlxBIr0,ECozZzCJECozZrdO,,ZYz, OToct9e,Ar89HqVpzhQQvAn,,tiZ, JavaScript portable-document-format plug in,7CgQIMl5k5kxBAAIjRnb05FKNGqdWTw3,application/x-google-chrome-pdf,pdf"
|
||||
screenSize := "1920w_1032h_24d_1r"
|
||||
canvasHash := "b723b5fba9cb9289de8b7e1e6de668fd"
|
||||
fontsBits := generateRandomBinaryString(55)
|
||||
cookieSupport := "true"
|
||||
doNotTrack := "false"
|
||||
|
||||
// Override with real fingerprint if available
|
||||
if len(browserFP) > 0 && browserFP[0] != nil {
|
||||
fp := browserFP[0]
|
||||
if fp.Language != "" {
|
||||
language = fp.Language
|
||||
}
|
||||
if fp.Platform != "" {
|
||||
platform = fp.Platform
|
||||
}
|
||||
if fp.Plugins != "" {
|
||||
plugins = fp.Plugins
|
||||
}
|
||||
if fp.ScreenSize != "" {
|
||||
screenSize = fp.ScreenSize
|
||||
}
|
||||
if fp.CanvasHash != "" {
|
||||
canvasHash = fp.CanvasHash
|
||||
}
|
||||
if fp.FontsBits != "" {
|
||||
fontsBits = fp.FontsBits
|
||||
}
|
||||
if fp.CookieSupport {
|
||||
cookieSupport = "true"
|
||||
}
|
||||
if fp.DoNotTrack {
|
||||
doNotTrack = "true"
|
||||
}
|
||||
if fp.UserAgent != "" {
|
||||
userAgent = fp.UserAgent
|
||||
}
|
||||
}
|
||||
|
||||
extractedFeatures := [][]interface{}{
|
||||
{cookieSupport, 0},
|
||||
{doNotTrack, 0},
|
||||
{language, 0},
|
||||
{platform, 0},
|
||||
{plugins, 19},
|
||||
{screenSize, 0},
|
||||
{"1", 0},
|
||||
{"false", 0},
|
||||
{"sessionStorage-enabled, localStorage-enabled", 3},
|
||||
{fontsBits, 85},
|
||||
{"", 0},
|
||||
{userAgent, 0},
|
||||
{"", 0},
|
||||
{"false", 85, 1},
|
||||
{canvasHash, 83},
|
||||
}
|
||||
|
||||
randomVal := randomHex(10) // 20 hex chars from 10 bytes
|
||||
|
||||
urlToHash := fmt.Sprintf("https://%s/", domain)
|
||||
hashedURL := HashURL(urlToHash)
|
||||
|
||||
joined := featuresSameLine(extractedFeatures)
|
||||
featureID := fmt.Sprintf("%x", md5.Sum([]byte(joined)))
|
||||
|
||||
tabTitle := domainToTabTitle(domain)
|
||||
|
||||
// Random timing values matching JS: Math.floor(Math.random() * (350 - 200 + 1) + 200)
|
||||
t := mrand.Intn(151) + 200 // 200-350
|
||||
n := mrand.Intn(251) + 100 // 100-350
|
||||
|
||||
return map[string]interface{}{
|
||||
"v2": 1,
|
||||
"id": featureID,
|
||||
"t": t,
|
||||
"tag": tagVersion,
|
||||
"src": "js",
|
||||
"a": transformFeatureValues(extractedFeatures),
|
||||
"b": map[string]interface{}{
|
||||
"a": hashedURL,
|
||||
"b": hashedURL,
|
||||
"c": SHA256WithSalt(tabTitle),
|
||||
"d": "NA",
|
||||
"e": "NA",
|
||||
"f": false,
|
||||
"g": true,
|
||||
"h": true,
|
||||
"i": []string{"location"},
|
||||
"j": []interface{}{},
|
||||
"n": n,
|
||||
"u": domain,
|
||||
"v": domain,
|
||||
"w": GetHashTimestampWithSalt(randomVal),
|
||||
},
|
||||
"h": randomVal,
|
||||
}
|
||||
}
|
||||
|
||||
// EncodePayload JSON-encodes, encodeURIComponent-encodes, then base64-encodes the payload.
|
||||
// Matches JS: Buffer.from(encodeURIComponent(JSON.stringify(payload))).toString('base64')
|
||||
func EncodePayload(payload map[string]interface{}) (string, error) {
|
||||
jsonBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
encoded := EncodeURIComponent(string(jsonBytes))
|
||||
return base64.StdEncoding.EncodeToString([]byte(encoded)), nil
|
||||
}
|
||||
919
pkg/stripe/payment.go
Normal file
919
pkg/stripe/payment.go
Normal file
@@ -0,0 +1,919 @@
|
||||
package stripe
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
mrand "math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gpt-plus/pkg/httpclient"
|
||||
)
|
||||
|
||||
// randomTimeOnPage returns a random time_on_page value between 15000-45000ms.
|
||||
// A constant value (e.g. "30000") is a fingerprint signal for bot detection.
|
||||
func randomTimeOnPage() string {
|
||||
return fmt.Sprintf("%d", mrand.Intn(30001)+15000)
|
||||
}
|
||||
|
||||
// localeForCountry returns a browser locale based on billing country code.
|
||||
func localeForCountry(country string) string {
|
||||
switch country {
|
||||
case "GB":
|
||||
return "en-GB"
|
||||
case "DE", "AT", "CH":
|
||||
return "de-DE"
|
||||
case "FR":
|
||||
return "fr-FR"
|
||||
case "JP":
|
||||
return "ja-JP"
|
||||
case "CN":
|
||||
return "zh-CN"
|
||||
default:
|
||||
return "en-US"
|
||||
}
|
||||
}
|
||||
|
||||
// timezoneForCountry returns a browser timezone based on billing country code.
|
||||
func timezoneForCountry(country string) string {
|
||||
switch country {
|
||||
case "GB":
|
||||
return "Europe/London"
|
||||
case "DE", "AT", "CH":
|
||||
return "Europe/Berlin"
|
||||
case "FR":
|
||||
return "Europe/Paris"
|
||||
case "JP":
|
||||
return "Asia/Tokyo"
|
||||
case "CN":
|
||||
return "Asia/Shanghai"
|
||||
default:
|
||||
return "America/Chicago"
|
||||
}
|
||||
}
|
||||
|
||||
// ErrCardDeclined indicates the card was declined by Stripe.
|
||||
var ErrCardDeclined = errors.New("card declined")
|
||||
|
||||
const (
|
||||
// DefaultLanguage is the browser locale for US cards.
|
||||
DefaultLanguage = "en-US"
|
||||
// DefaultAcceptLanguage is the Accept-Language header for US cards.
|
||||
DefaultAcceptLanguage = "en-US,en;q=0.9"
|
||||
|
||||
chromeSecChUa = `"Google Chrome";v="145", "Chromium";v="145", "Not-A.Brand";v="99"`
|
||||
stripeOrigin = "https://js.stripe.com"
|
||||
)
|
||||
|
||||
// CheckoutResult holds the response from creating a checkout session.
|
||||
type CheckoutResult struct {
|
||||
CheckoutSessionID string
|
||||
PublishableKey string
|
||||
ClientSecret string
|
||||
ExpectedAmount int
|
||||
ProcessorEntity string
|
||||
}
|
||||
|
||||
// CreateCheckoutSession creates a Stripe checkout session via ChatGPT API.
|
||||
func CreateCheckoutSession(client *httpclient.Client, accessToken, deviceID, sentinelToken string, body map[string]interface{}) (*CheckoutResult, error) {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal checkout body: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", "https://chatgpt.com/backend-api/payments/checkout", strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create checkout request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Origin", "https://chatgpt.com")
|
||||
req.Header.Set("Referer", "https://chatgpt.com/")
|
||||
req.Header.Set("Oai-Device-Id", deviceID)
|
||||
req.Header.Set("Openai-Sentinel-Token", sentinelToken)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("send checkout request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read checkout response: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[stripe] checkout response (status=%d): %s", resp.StatusCode, string(respBody))
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("checkout failed: status %d, body: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse checkout response: %w", err)
|
||||
}
|
||||
|
||||
cr := &CheckoutResult{
|
||||
ProcessorEntity: "openai_llc",
|
||||
}
|
||||
|
||||
if v, ok := result["checkout_session_id"].(string); ok {
|
||||
cr.CheckoutSessionID = v
|
||||
} else if v, ok := result["session_id"].(string); ok {
|
||||
cr.CheckoutSessionID = v
|
||||
}
|
||||
|
||||
if v, ok := result["publishable_key"].(string); ok {
|
||||
cr.PublishableKey = v
|
||||
} else if v, ok := result["stripe_publishable_key"].(string); ok {
|
||||
cr.PublishableKey = v
|
||||
}
|
||||
|
||||
if v, ok := result["client_secret"].(string); ok {
|
||||
cr.ClientSecret = v
|
||||
}
|
||||
|
||||
if v, ok := result["amount_total"].(float64); ok {
|
||||
cr.ExpectedAmount = int(v)
|
||||
} else if v, ok := result["amount"].(float64); ok {
|
||||
cr.ExpectedAmount = int(v)
|
||||
}
|
||||
|
||||
if v, ok := result["processor_entity"].(string); ok {
|
||||
cr.ProcessorEntity = v
|
||||
}
|
||||
|
||||
return cr, nil
|
||||
}
|
||||
|
||||
// InitResult holds the response from init checkout session.
|
||||
type InitResult struct {
|
||||
InitChecksum string
|
||||
}
|
||||
|
||||
// stripeVersionWithBetas returns the _stripe_version value with checkout betas appended.
|
||||
func stripeVersionWithBetas(base string) string {
|
||||
return base + "; checkout_server_update_beta=v1; checkout_manual_approval_preview=v1"
|
||||
}
|
||||
|
||||
// InitCheckoutSession calls POST /v1/payment_pages/{cs_id}/init to get init_checksum.
|
||||
func InitCheckoutSession(client *httpclient.Client, csID, publishableKey, stripeVersion, stripeJsID, userAgent, billingCountry string) (*InitResult, error) {
|
||||
initURL := fmt.Sprintf("https://api.stripe.com/v1/payment_pages/%s/init", csID)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("browser_locale", localeForCountry(billingCountry))
|
||||
form.Set("browser_timezone", timezoneForCountry(billingCountry))
|
||||
form.Set("elements_session_client[client_betas][0]", "custom_checkout_server_updates_1")
|
||||
form.Set("elements_session_client[client_betas][1]", "custom_checkout_manual_approval_1")
|
||||
form.Set("elements_session_client[elements_init_source]", "custom_checkout")
|
||||
form.Set("elements_session_client[referrer_host]", "chatgpt.com")
|
||||
form.Set("elements_session_client[stripe_js_id]", stripeJsID)
|
||||
form.Set("elements_session_client[locale]", localeForCountry(billingCountry))
|
||||
form.Set("elements_session_client[is_aggregation_expected]", "false")
|
||||
form.Set("key", publishableKey)
|
||||
form.Set("_stripe_version", stripeVersionWithBetas(stripeVersion))
|
||||
|
||||
req, err := http.NewRequest("POST", initURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create init request: %w", err)
|
||||
}
|
||||
|
||||
setStripeHeaders(req, userAgent)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("send init request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read init response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("init failed: status %d, body: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse init response: %w", err)
|
||||
}
|
||||
|
||||
ir := &InitResult{}
|
||||
if v, ok := result["init_checksum"].(string); ok {
|
||||
ir.InitChecksum = v
|
||||
}
|
||||
|
||||
// Log amount fields from init for debugging
|
||||
log.Printf("[stripe] init response: amount_total=%v, amount=%v",
|
||||
result["amount_total"], result["amount"])
|
||||
|
||||
return ir, nil
|
||||
}
|
||||
|
||||
// UpdateCheckoutParams holds parameters for the intermediate checkout update.
|
||||
type UpdateCheckoutParams struct {
|
||||
CheckoutSessionID string
|
||||
PublishableKey string
|
||||
StripeVersion string // API version e.g. "2025-03-31.basil"
|
||||
StripeJsID string
|
||||
BillingCountry string
|
||||
BillingAddress string
|
||||
BillingCity string
|
||||
BillingState string
|
||||
BillingZip string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// UpdateResult holds the response from updating a checkout session.
|
||||
type UpdateResult struct {
|
||||
UpdatedAmount int // amount_total after tax recalculation
|
||||
}
|
||||
|
||||
// UpdateCheckoutSession sends an intermediate POST to /v1/payment_pages/{cs_id}
|
||||
// to update tax_region info. This must be called before confirm to accept terms.
|
||||
func UpdateCheckoutSession(client *httpclient.Client, params *UpdateCheckoutParams) (*UpdateResult, error) {
|
||||
updateURL := fmt.Sprintf("https://api.stripe.com/v1/payment_pages/%s", params.CheckoutSessionID)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("tax_region[country]", params.BillingCountry)
|
||||
if params.BillingAddress != "" {
|
||||
form.Set("tax_region[line1]", params.BillingAddress)
|
||||
}
|
||||
if params.BillingCity != "" {
|
||||
form.Set("tax_region[city]", params.BillingCity)
|
||||
}
|
||||
if params.BillingState != "" {
|
||||
form.Set("tax_region[state]", params.BillingState)
|
||||
}
|
||||
if params.BillingZip != "" {
|
||||
form.Set("tax_region[postal_code]", params.BillingZip)
|
||||
}
|
||||
form.Set("elements_session_client[client_betas][0]", "custom_checkout_server_updates_1")
|
||||
form.Set("elements_session_client[client_betas][1]", "custom_checkout_manual_approval_1")
|
||||
form.Set("elements_session_client[elements_init_source]", "custom_checkout")
|
||||
form.Set("elements_session_client[referrer_host]", "chatgpt.com")
|
||||
form.Set("elements_session_client[stripe_js_id]", params.StripeJsID)
|
||||
form.Set("elements_session_client[locale]", localeForCountry(params.BillingCountry))
|
||||
form.Set("elements_session_client[is_aggregation_expected]", "false")
|
||||
form.Set("client_attribution_metadata[merchant_integration_additional_elements][0]", "payment")
|
||||
form.Set("client_attribution_metadata[merchant_integration_additional_elements][1]", "address")
|
||||
form.Set("key", params.PublishableKey)
|
||||
form.Set("_stripe_version", stripeVersionWithBetas(params.StripeVersion))
|
||||
|
||||
req, err := http.NewRequest("POST", updateURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create update request: %w", err)
|
||||
}
|
||||
|
||||
setStripeHeaders(req, params.UserAgent)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("send update request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read update response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("update checkout failed: status %d, body: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse update response: %w", err)
|
||||
}
|
||||
|
||||
ur := &UpdateResult{}
|
||||
|
||||
// Try top-level amount fields first
|
||||
if v, ok := result["amount_total"].(float64); ok {
|
||||
ur.UpdatedAmount = int(v)
|
||||
} else if v, ok := result["amount"].(float64); ok {
|
||||
ur.UpdatedAmount = int(v)
|
||||
}
|
||||
|
||||
// Try total_summary.due (Stripe checkout session structure: {"due":2000,"subtotal":2000,"total":2000})
|
||||
if ur.UpdatedAmount == 0 {
|
||||
if ts, ok := result["total_summary"].(map[string]interface{}); ok {
|
||||
if v, ok := ts["due"].(float64); ok {
|
||||
ur.UpdatedAmount = int(v)
|
||||
} else if v, ok := ts["total"].(float64); ok {
|
||||
ur.UpdatedAmount = int(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[stripe] update resolved amount=%d", ur.UpdatedAmount)
|
||||
|
||||
return ur, nil
|
||||
}
|
||||
|
||||
// PaymentMethodParams holds parameters for creating a payment method.
|
||||
type PaymentMethodParams struct {
|
||||
CardNumber string
|
||||
CardCVC string
|
||||
CardExpMonth string
|
||||
CardExpYear string
|
||||
BillingName string
|
||||
BillingEmail string
|
||||
BillingCountry string
|
||||
BillingAddress string
|
||||
BillingCity string
|
||||
BillingState string
|
||||
BillingZip string
|
||||
GUID string
|
||||
MUID string
|
||||
SID string
|
||||
PublishableKey string
|
||||
BuildHash string // e.g. "ede17ac9fd" — used in payment_user_agent
|
||||
StripeVersion string // e.g. "2025-03-31.basil" — used in _stripe_version
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// PaymentMethodResult holds the response from creating a payment method.
|
||||
type PaymentMethodResult struct {
|
||||
PaymentMethodID string
|
||||
}
|
||||
|
||||
// CreatePaymentMethod calls POST /v1/payment_methods to create a pm_xxx.
|
||||
func CreatePaymentMethod(client *httpclient.Client, params *PaymentMethodParams) (*PaymentMethodResult, error) {
|
||||
form := url.Values{}
|
||||
form.Set("type", "card")
|
||||
form.Set("card[number]", params.CardNumber)
|
||||
form.Set("card[cvc]", params.CardCVC)
|
||||
form.Set("card[exp_month]", params.CardExpMonth)
|
||||
form.Set("card[exp_year]", params.CardExpYear)
|
||||
form.Set("billing_details[name]", params.BillingName)
|
||||
form.Set("billing_details[email]", params.BillingEmail)
|
||||
form.Set("billing_details[address][country]", params.BillingCountry)
|
||||
if params.BillingAddress != "" {
|
||||
form.Set("billing_details[address][line1]", params.BillingAddress)
|
||||
}
|
||||
if params.BillingCity != "" {
|
||||
form.Set("billing_details[address][city]", params.BillingCity)
|
||||
}
|
||||
if params.BillingState != "" {
|
||||
form.Set("billing_details[address][state]", params.BillingState)
|
||||
}
|
||||
if params.BillingZip != "" {
|
||||
form.Set("billing_details[address][postal_code]", params.BillingZip)
|
||||
}
|
||||
form.Set("allow_redisplay", "unspecified")
|
||||
form.Set("pasted_fields", "number,exp,cvc")
|
||||
form.Set("payment_user_agent", fmt.Sprintf("stripe.js/%s; stripe-js-v3/%s; payment-element; deferred-intent",
|
||||
params.BuildHash, params.BuildHash))
|
||||
form.Set("referrer", "https://chatgpt.com")
|
||||
form.Set("time_on_page", randomTimeOnPage())
|
||||
form.Set("guid", params.GUID)
|
||||
form.Set("muid", params.MUID)
|
||||
form.Set("sid", params.SID)
|
||||
form.Set("key", params.PublishableKey)
|
||||
if params.StripeVersion != "" {
|
||||
form.Set("_stripe_version", stripeVersionWithBetas(params.StripeVersion))
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", "https://api.stripe.com/v1/payment_methods", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create payment method request: %w", err)
|
||||
}
|
||||
|
||||
setStripeHeaders(req, params.UserAgent)
|
||||
req.AddCookie(&http.Cookie{Name: "__stripe_mid", Value: params.MUID})
|
||||
req.AddCookie(&http.Cookie{Name: "__stripe_sid", Value: params.SID})
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("send payment method request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read payment method response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("create payment method failed: status %d, body: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse payment method response: %w", err)
|
||||
}
|
||||
|
||||
pmr := &PaymentMethodResult{}
|
||||
if v, ok := result["id"].(string); ok {
|
||||
pmr.PaymentMethodID = v
|
||||
}
|
||||
if pmr.PaymentMethodID == "" {
|
||||
return nil, fmt.Errorf("no payment method ID in response: %s", string(respBody))
|
||||
}
|
||||
|
||||
return pmr, nil
|
||||
}
|
||||
|
||||
// ConfirmParams holds all parameters needed to confirm a Stripe payment.
|
||||
type ConfirmParams struct {
|
||||
CheckoutSessionID string
|
||||
PaymentMethodID string
|
||||
InitChecksum string
|
||||
StripeJsID string
|
||||
GUID string
|
||||
MUID string
|
||||
SID string
|
||||
ExpectedAmount int
|
||||
PublishableKey string
|
||||
StripeVersion string // build hash, e.g. "ede17ac9fd"
|
||||
StripeAPIVersion string // API version, e.g. "2025-03-31.basil"
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// DirectConfirmParams holds parameters for confirm with inline card data (no tokenization).
|
||||
type DirectConfirmParams struct {
|
||||
CheckoutSessionID string
|
||||
// Card details (inline)
|
||||
CardNumber string
|
||||
CardCVC string
|
||||
CardExpMonth string
|
||||
CardExpYear string
|
||||
// Billing
|
||||
BillingName string
|
||||
BillingEmail string
|
||||
BillingCountry string
|
||||
BillingAddress string
|
||||
BillingCity string
|
||||
BillingState string
|
||||
BillingZip string
|
||||
// Fingerprint
|
||||
GUID string
|
||||
MUID string
|
||||
SID string
|
||||
// Stripe
|
||||
ExpectedAmount int
|
||||
InitChecksum string // from InitCheckoutSession
|
||||
PublishableKey string
|
||||
BuildHash string // e.g. "ede17ac9fd"
|
||||
StripeVersion string // API version, e.g. "2025-03-31.basil"
|
||||
StripeJsID string // per-session UUID, like stripe.js generates
|
||||
UserAgent string
|
||||
PassiveCaptchaToken string // passive hCaptcha token from telemetry
|
||||
PPageID string // payment page ID for telemetry
|
||||
}
|
||||
|
||||
// ConfirmResult holds the parsed confirm response.
|
||||
type ConfirmResult struct {
|
||||
// RequiresAction is true when setup_intent needs challenge verification.
|
||||
RequiresAction bool
|
||||
// Challenge fields (only set when RequiresAction is true)
|
||||
SetupIntentID string // seti_xxx
|
||||
ClientSecret string // seti_xxx_secret_xxx
|
||||
SiteKey string // hCaptcha site_key from stripe_js
|
||||
RqData string // hCaptcha rqdata from stripe_js
|
||||
VerificationURL string // /v1/setup_intents/{seti}/verify_challenge
|
||||
// ReturnURL is the merchant-configured return URL from the confirm response.
|
||||
// For Team plans, it contains account_id (workspace ID) as a query parameter.
|
||||
ReturnURL string
|
||||
}
|
||||
|
||||
// ConfirmPayment confirms payment directly with Stripe using pm_xxx.
|
||||
func ConfirmPayment(client *httpclient.Client, params *ConfirmParams) (*ConfirmResult, error) {
|
||||
confirmURL := fmt.Sprintf("https://api.stripe.com/v1/payment_pages/%s/confirm", params.CheckoutSessionID)
|
||||
|
||||
returnURL := fmt.Sprintf(
|
||||
"https://checkout.stripe.com/c/pay/%s?returned_from_redirect=true",
|
||||
params.CheckoutSessionID,
|
||||
)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("guid", params.GUID)
|
||||
form.Set("muid", params.MUID)
|
||||
form.Set("sid", params.SID)
|
||||
form.Set("payment_method", params.PaymentMethodID)
|
||||
if params.InitChecksum != "" {
|
||||
form.Set("init_checksum", params.InitChecksum)
|
||||
}
|
||||
form.Set("version", params.StripeVersion)
|
||||
form.Set("expected_amount", fmt.Sprintf("%d", params.ExpectedAmount))
|
||||
form.Set("expected_payment_method_type", "card")
|
||||
form.Set("return_url", returnURL)
|
||||
form.Set("elements_session_client[client_betas][0]", "custom_checkout_server_updates_1")
|
||||
form.Set("elements_session_client[client_betas][1]", "custom_checkout_manual_approval_1")
|
||||
form.Set("elements_session_client[elements_init_source]", "custom_checkout")
|
||||
form.Set("elements_session_client[referrer_host]", "chatgpt.com")
|
||||
form.Set("elements_session_client[stripe_js_id]", params.StripeJsID)
|
||||
form.Set("elements_session_client[locale]", "en-US")
|
||||
form.Set("elements_session_client[is_aggregation_expected]", "false")
|
||||
form.Set("client_attribution_metadata[merchant_integration_source]", "checkout")
|
||||
form.Set("client_attribution_metadata[merchant_integration_version]", "custom")
|
||||
form.Set("client_attribution_metadata[merchant_integration_subtype]", "payment-element")
|
||||
form.Set("client_attribution_metadata[merchant_integration_additional_elements][0]", "payment")
|
||||
form.Set("client_attribution_metadata[merchant_integration_additional_elements][1]", "address")
|
||||
form.Set("client_attribution_metadata[payment_intent_creation_flow]", "deferred")
|
||||
form.Set("client_attribution_metadata[payment_method_selection_flow]", "automatic")
|
||||
form.Set("consent[terms_of_service]", "accepted")
|
||||
form.Set("key", params.PublishableKey)
|
||||
form.Set("_stripe_version", stripeVersionWithBetas(params.StripeAPIVersion))
|
||||
|
||||
req, err := http.NewRequest("POST", confirmURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create confirm request: %w", err)
|
||||
}
|
||||
|
||||
setStripeHeaders(req, params.UserAgent)
|
||||
req.AddCookie(&http.Cookie{Name: "__stripe_mid", Value: params.MUID})
|
||||
req.AddCookie(&http.Cookie{Name: "__stripe_sid", Value: params.SID})
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("send confirm request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read confirm response: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[stripe] confirm response (status=%d): %s", resp.StatusCode, string(respBody))
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("confirm failed: status %d, body: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
return parseConfirmResponse(respBody)
|
||||
}
|
||||
|
||||
// parseConfirmResponse extracts challenge info from the confirm JSON response.
|
||||
func parseConfirmResponse(body []byte) (*ConfirmResult, error) {
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return nil, fmt.Errorf("parse confirm response: %w", err)
|
||||
}
|
||||
|
||||
cr := &ConfirmResult{}
|
||||
|
||||
// Extract return_url — for Team plans this contains the workspace account_id
|
||||
if v, ok := raw["return_url"].(string); ok {
|
||||
cr.ReturnURL = v
|
||||
log.Printf("[stripe] confirm return_url: %s", v)
|
||||
}
|
||||
|
||||
// Check setup_intent.status
|
||||
si, ok := raw["setup_intent"].(map[string]interface{})
|
||||
if !ok {
|
||||
// No setup_intent — payment may have succeeded directly
|
||||
return cr, nil
|
||||
}
|
||||
|
||||
status, _ := si["status"].(string)
|
||||
|
||||
// Check for setup errors that indicate card decline (200 response but failed state)
|
||||
if lastErr, ok := si["last_setup_error"].(map[string]interface{}); ok {
|
||||
errCode, _ := lastErr["code"].(string)
|
||||
errMsg, _ := lastErr["message"].(string)
|
||||
errType, _ := lastErr["type"].(string)
|
||||
if errCode != "" || errType == "card_error" {
|
||||
return nil, fmt.Errorf("setup_intent error: code=%s, type=%s, message=%s", errCode, errType, errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for requires_payment_method (card was rejected, need new card)
|
||||
if status == "requires_payment_method" {
|
||||
return nil, fmt.Errorf("setup_intent requires new payment method (card rejected)")
|
||||
}
|
||||
|
||||
if status != "requires_action" {
|
||||
// succeeded or other non-challenge status
|
||||
return cr, nil
|
||||
}
|
||||
|
||||
cr.RequiresAction = true
|
||||
cr.SetupIntentID, _ = si["id"].(string)
|
||||
cr.ClientSecret, _ = si["client_secret"].(string)
|
||||
|
||||
// Extract challenge details from next_action.use_stripe_sdk.stripe_js
|
||||
nextAction, _ := si["next_action"].(map[string]interface{})
|
||||
if nextAction == nil {
|
||||
return cr, nil
|
||||
}
|
||||
|
||||
useSDK, _ := nextAction["use_stripe_sdk"].(map[string]interface{})
|
||||
if useSDK == nil {
|
||||
return cr, nil
|
||||
}
|
||||
|
||||
// stripe_js contains site_key, rqdata, verification_url
|
||||
stripeJS, _ := useSDK["stripe_js"].(map[string]interface{})
|
||||
if stripeJS != nil {
|
||||
cr.SiteKey, _ = stripeJS["site_key"].(string)
|
||||
cr.RqData, _ = stripeJS["rqdata"].(string)
|
||||
cr.VerificationURL, _ = stripeJS["verification_url"].(string)
|
||||
}
|
||||
|
||||
// Fallback: top-level fields in use_stripe_sdk
|
||||
if cr.SiteKey == "" {
|
||||
cr.SiteKey, _ = useSDK["hcaptcha_site_key"].(string)
|
||||
}
|
||||
if cr.RqData == "" {
|
||||
cr.RqData, _ = useSDK["hcaptcha_rqdata"].(string)
|
||||
}
|
||||
if cr.VerificationURL == "" {
|
||||
cr.VerificationURL, _ = useSDK["verification_url"].(string)
|
||||
}
|
||||
|
||||
log.Printf("[stripe] confirm requires_action: seti=%s, site_key=%s, has_rqdata=%v",
|
||||
cr.SetupIntentID, cr.SiteKey, cr.RqData != "")
|
||||
|
||||
return cr, nil
|
||||
}
|
||||
|
||||
// VerifyChallengeParams holds parameters for the verify_challenge call.
|
||||
type VerifyChallengeParams struct {
|
||||
SetupIntentID string // seti_xxx
|
||||
ClientSecret string // seti_xxx_secret_xxx
|
||||
CaptchaToken string // P1_eyJ... from hCaptcha
|
||||
CaptchaEKey string // E1_... from hCaptcha (respKey)
|
||||
PublishableKey string
|
||||
StripeVersion string // API version e.g. "2025-03-31.basil"
|
||||
UserAgent string
|
||||
MUID string
|
||||
SID string
|
||||
}
|
||||
|
||||
// VerifyChallenge calls POST /v1/setup_intents/{seti}/verify_challenge to complete the hCaptcha challenge.
|
||||
func VerifyChallenge(client *httpclient.Client, params *VerifyChallengeParams) error {
|
||||
verifyURL := fmt.Sprintf("https://api.stripe.com/v1/setup_intents/%s/verify_challenge", params.SetupIntentID)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("challenge_response_token", params.CaptchaToken)
|
||||
if params.CaptchaEKey != "" {
|
||||
form.Set("challenge_response_ekey", params.CaptchaEKey)
|
||||
}
|
||||
form.Set("client_secret", params.ClientSecret)
|
||||
form.Set("captcha_vendor_name", "hcaptcha")
|
||||
form.Set("key", params.PublishableKey)
|
||||
form.Set("_stripe_version", stripeVersionWithBetas(params.StripeVersion))
|
||||
|
||||
req, err := http.NewRequest("POST", verifyURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create verify challenge request: %w", err)
|
||||
}
|
||||
|
||||
setStripeHeaders(req, params.UserAgent)
|
||||
req.AddCookie(&http.Cookie{Name: "__stripe_mid", Value: params.MUID})
|
||||
req.AddCookie(&http.Cookie{Name: "__stripe_sid", Value: params.SID})
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send verify challenge: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read verify challenge response: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[stripe] verify_challenge response (status=%d): %s", resp.StatusCode, string(respBody))
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("verify challenge failed: status %d, body: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
// Check that setup_intent status is now succeeded
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return fmt.Errorf("parse verify challenge response: %w", err)
|
||||
}
|
||||
|
||||
status, _ := result["status"].(string)
|
||||
if status == "succeeded" {
|
||||
log.Printf("[stripe] verify_challenge succeeded")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check for error
|
||||
lastErr, _ := result["last_setup_error"].(map[string]interface{})
|
||||
if lastErr != nil {
|
||||
msg, _ := lastErr["message"].(string)
|
||||
code, _ := lastErr["code"].(string)
|
||||
return fmt.Errorf("verify challenge error: code=%s, message=%s", code, msg)
|
||||
}
|
||||
|
||||
return fmt.Errorf("verify challenge unexpected status: %s", status)
|
||||
}
|
||||
|
||||
// PaymentPagePollParams holds the query parameters for GET /v1/payment_pages/{cs_id}/poll.
|
||||
type PaymentPagePollParams struct {
|
||||
CheckoutSessionID string
|
||||
PublishableKey string
|
||||
StripeVersion string // API version e.g. "2025-03-31.basil"
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// PaymentPagePollResult captures the fields needed to determine whether Stripe has fully finalized checkout.
|
||||
type PaymentPagePollResult struct {
|
||||
State string
|
||||
PaymentObjectStatus string
|
||||
ReturnURL string
|
||||
}
|
||||
|
||||
// PollPaymentPage fetches the current payment page state after confirm/verify_challenge.
|
||||
func PollPaymentPage(client *httpclient.Client, params *PaymentPagePollParams) (*PaymentPagePollResult, error) {
|
||||
q := url.Values{}
|
||||
q.Set("key", params.PublishableKey)
|
||||
q.Set("_stripe_version", stripeVersionWithBetas(params.StripeVersion))
|
||||
|
||||
pollURL := fmt.Sprintf(
|
||||
"https://api.stripe.com/v1/payment_pages/%s/poll?%s",
|
||||
params.CheckoutSessionID,
|
||||
q.Encode(),
|
||||
)
|
||||
|
||||
req, err := http.NewRequest("GET", pollURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create poll request: %w", err)
|
||||
}
|
||||
|
||||
setStripeHeaders(req, params.UserAgent)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("send poll request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read poll response: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[stripe] payment page poll response (status=%d): %s", resp.StatusCode, string(respBody))
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("payment page poll failed: status %d, body: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(respBody, &raw); err != nil {
|
||||
return nil, fmt.Errorf("parse poll response: %w", err)
|
||||
}
|
||||
|
||||
result := &PaymentPagePollResult{}
|
||||
result.State, _ = raw["state"].(string)
|
||||
result.PaymentObjectStatus, _ = raw["payment_object_status"].(string)
|
||||
result.ReturnURL, _ = raw["return_url"].(string)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ConfirmPaymentDirect confirms payment with inline card data — no init or payment_methods step needed.
|
||||
// Flow: checkout → fingerprint(m.stripe.com/6) → confirm(inline card).
|
||||
func ConfirmPaymentDirect(client *httpclient.Client, params *DirectConfirmParams) (*ConfirmResult, error) {
|
||||
confirmURL := fmt.Sprintf("https://api.stripe.com/v1/payment_pages/%s/confirm", params.CheckoutSessionID)
|
||||
|
||||
returnURL := fmt.Sprintf(
|
||||
"https://checkout.stripe.com/c/pay/%s?returned_from_redirect=true",
|
||||
params.CheckoutSessionID,
|
||||
)
|
||||
|
||||
form := url.Values{}
|
||||
// Inline card data
|
||||
form.Set("payment_method_data[type]", "card")
|
||||
form.Set("payment_method_data[card][number]", params.CardNumber)
|
||||
form.Set("payment_method_data[card][cvc]", params.CardCVC)
|
||||
form.Set("payment_method_data[card][exp_month]", params.CardExpMonth)
|
||||
form.Set("payment_method_data[card][exp_year]", params.CardExpYear)
|
||||
form.Set("payment_method_data[billing_details][name]", params.BillingName)
|
||||
if params.BillingEmail != "" {
|
||||
form.Set("payment_method_data[billing_details][email]", params.BillingEmail)
|
||||
}
|
||||
form.Set("payment_method_data[billing_details][address][country]", params.BillingCountry)
|
||||
if params.BillingAddress != "" {
|
||||
form.Set("payment_method_data[billing_details][address][line1]", params.BillingAddress)
|
||||
}
|
||||
if params.BillingCity != "" {
|
||||
form.Set("payment_method_data[billing_details][address][city]", params.BillingCity)
|
||||
}
|
||||
if params.BillingState != "" {
|
||||
form.Set("payment_method_data[billing_details][address][state]", params.BillingState)
|
||||
}
|
||||
if params.BillingZip != "" {
|
||||
form.Set("payment_method_data[billing_details][address][postal_code]", params.BillingZip)
|
||||
}
|
||||
form.Set("payment_method_data[allow_redisplay]", "unspecified")
|
||||
form.Set("payment_method_data[pasted_fields]", "number,exp,cvc")
|
||||
form.Set("payment_method_data[payment_user_agent]", fmt.Sprintf("stripe.js/%s; stripe-js-v3/%s; payment-element; deferred-intent",
|
||||
params.BuildHash, params.BuildHash))
|
||||
form.Set("payment_method_data[referrer]", "https://chatgpt.com")
|
||||
form.Set("payment_method_data[time_on_page]", randomTimeOnPage())
|
||||
// Fingerprint
|
||||
form.Set("payment_method_data[guid]", params.GUID)
|
||||
form.Set("payment_method_data[muid]", params.MUID)
|
||||
form.Set("payment_method_data[sid]", params.SID)
|
||||
form.Set("guid", params.GUID)
|
||||
form.Set("muid", params.MUID)
|
||||
form.Set("sid", params.SID)
|
||||
// Build hash version — required by Stripe, was missing from direct confirm
|
||||
form.Set("version", params.BuildHash)
|
||||
if params.InitChecksum != "" {
|
||||
form.Set("init_checksum", params.InitChecksum)
|
||||
}
|
||||
// Payment details
|
||||
form.Set("expected_amount", fmt.Sprintf("%d", params.ExpectedAmount))
|
||||
form.Set("expected_payment_method_type", "card")
|
||||
form.Set("return_url", returnURL)
|
||||
// Elements session client — required for Stripe risk assessment, was missing from direct confirm
|
||||
form.Set("elements_session_client[client_betas][0]", "custom_checkout_server_updates_1")
|
||||
form.Set("elements_session_client[client_betas][1]", "custom_checkout_manual_approval_1")
|
||||
form.Set("elements_session_client[elements_init_source]", "custom_checkout")
|
||||
form.Set("elements_session_client[referrer_host]", "chatgpt.com")
|
||||
form.Set("elements_session_client[stripe_js_id]", params.StripeJsID)
|
||||
form.Set("elements_session_client[locale]", localeForCountry(params.BillingCountry))
|
||||
form.Set("elements_session_client[is_aggregation_expected]", "false")
|
||||
// Client attribution metadata — required for Stripe risk assessment, was missing from direct confirm
|
||||
form.Set("client_attribution_metadata[merchant_integration_source]", "checkout")
|
||||
form.Set("client_attribution_metadata[merchant_integration_version]", "custom")
|
||||
form.Set("client_attribution_metadata[merchant_integration_subtype]", "payment-element")
|
||||
form.Set("client_attribution_metadata[merchant_integration_additional_elements][0]", "payment")
|
||||
form.Set("client_attribution_metadata[merchant_integration_additional_elements][1]", "address")
|
||||
form.Set("client_attribution_metadata[payment_intent_creation_flow]", "deferred")
|
||||
form.Set("client_attribution_metadata[payment_method_selection_flow]", "automatic")
|
||||
// js_checksum and rv_timestamp (Stripe.js anti-bot fields)
|
||||
ppageID := params.PPageID
|
||||
if ppageID == "" {
|
||||
ppageID = params.CheckoutSessionID
|
||||
}
|
||||
form.Set("js_checksum", JsChecksum(ppageID))
|
||||
form.Set("rv_timestamp", RvTimestamp())
|
||||
// Passive captcha token (from telemetry)
|
||||
if params.PassiveCaptchaToken != "" {
|
||||
form.Set("passive_captcha[passive_token]", params.PassiveCaptchaToken)
|
||||
form.Set("passive_captcha[vendor]", "hcaptcha")
|
||||
}
|
||||
// Consent & auth
|
||||
form.Set("consent[terms_of_service]", "accepted")
|
||||
form.Set("key", params.PublishableKey)
|
||||
form.Set("_stripe_version", stripeVersionWithBetas(params.StripeVersion))
|
||||
|
||||
req, err := http.NewRequest("POST", confirmURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create direct confirm request: %w", err)
|
||||
}
|
||||
|
||||
setStripeHeaders(req, params.UserAgent)
|
||||
req.AddCookie(&http.Cookie{Name: "__stripe_mid", Value: params.MUID})
|
||||
req.AddCookie(&http.Cookie{Name: "__stripe_sid", Value: params.SID})
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("send direct confirm request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read direct confirm response: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[stripe] direct confirm response (status=%d): %s", resp.StatusCode, string(respBody))
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if strings.Contains(string(respBody), "card_declined") {
|
||||
return nil, fmt.Errorf("confirm payment: %w", ErrCardDeclined)
|
||||
}
|
||||
return nil, fmt.Errorf("direct confirm failed: status %d, body: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
return parseConfirmResponse(respBody)
|
||||
}
|
||||
|
||||
// setStripeHeaders sets common headers for Stripe API requests.
|
||||
func setStripeHeaders(req *http.Request, userAgent string) {
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Origin", stripeOrigin)
|
||||
req.Header.Set("Referer", stripeOrigin+"/")
|
||||
if userAgent != "" {
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
}
|
||||
req.Header.Set("Sec-Fetch-Dest", "empty")
|
||||
req.Header.Set("Sec-Fetch-Mode", "cors")
|
||||
req.Header.Set("Sec-Fetch-Site", "cross-site")
|
||||
req.Header.Set("Sec-Ch-Ua", chromeSecChUa)
|
||||
req.Header.Set("Sec-Ch-Ua-Mobile", "?0")
|
||||
req.Header.Set("Sec-Ch-Ua-Platform", `"Windows"`)
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
}
|
||||
285
pkg/stripe/sha256.go
Normal file
285
pkg/stripe/sha256.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package stripe
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SHA256URLSafe computes SHA-256 and returns URL-safe base64 (no padding).
|
||||
// Matches JS: btoa(sha256_raw(input)).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")
|
||||
func SHA256URLSafe(input string) string {
|
||||
h := sha256.Sum256([]byte(input))
|
||||
return base64.RawURLEncoding.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
const urlSalt = "7766e861-8279-424d-87a1-07a6022fd8cd"
|
||||
|
||||
// SHA256WithSalt hashes input with the Stripe URL salt.
|
||||
// Matches JS: sha256WithSalt which does sha256(unescape(encodeURIComponent(e)) + URL_SALT)
|
||||
func SHA256WithSalt(input string) string {
|
||||
if input == "" {
|
||||
return ""
|
||||
}
|
||||
// JS unescape(encodeURIComponent(s)) converts to UTF-8 bytes, which Go strings already are.
|
||||
return SHA256URLSafe(input + urlSalt)
|
||||
}
|
||||
|
||||
// EncodeURIComponent mimics JavaScript's encodeURIComponent.
|
||||
func EncodeURIComponent(s string) string {
|
||||
result := url.QueryEscape(s)
|
||||
result = strings.ReplaceAll(result, "+", "%20")
|
||||
// JS encodeURIComponent does NOT encode: - _ . ! ~ * ' ( )
|
||||
for _, c := range []string{"!", "'", "(", ")", "*", "~"} {
|
||||
result = strings.ReplaceAll(result, url.QueryEscape(c), c)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------- URL Hashing ----------
|
||||
|
||||
const (
|
||||
defaultFullHashLimit = 10
|
||||
totalPartsLimit = 40
|
||||
partialHashLen = 6
|
||||
pathPartsLimit = 30
|
||||
)
|
||||
|
||||
func isStripeAuthority(s string) bool {
|
||||
if s == "//stripe.com" || s == "//stripe.com." {
|
||||
return true
|
||||
}
|
||||
return strings.HasSuffix(s, ".stripe.com") || strings.HasSuffix(s, ".stripe.com.")
|
||||
}
|
||||
|
||||
func isStripeCheckoutAuthority(s string) bool {
|
||||
candidates := []string{
|
||||
"//checkout.stripe.com",
|
||||
"//qa-checkout.stripe.com",
|
||||
"//edge-checkout.stripe.com",
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if s == c {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// removeUserInfo strips user-info from an authority component.
|
||||
func removeUserInfo(e string) string {
|
||||
if e == "" {
|
||||
return e
|
||||
}
|
||||
t := strings.LastIndex(e, "@")
|
||||
if t == -1 {
|
||||
return e
|
||||
}
|
||||
return e[:2] + e[t+1:] // keep "//" prefix
|
||||
}
|
||||
|
||||
// PartitionedUrl parses a URL into RFC-3986 components.
|
||||
type PartitionedUrl struct {
|
||||
Scheme string
|
||||
Authority string
|
||||
Path string
|
||||
Query string
|
||||
Fragment string
|
||||
}
|
||||
|
||||
var urlParseRe = regexp.MustCompile(`^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?`)
|
||||
|
||||
// NewPartitionedUrl parses a URL string.
|
||||
func NewPartitionedUrl(rawURL string) *PartitionedUrl {
|
||||
p := &PartitionedUrl{}
|
||||
if rawURL == "" {
|
||||
return p
|
||||
}
|
||||
m := urlParseRe.FindStringSubmatch(rawURL)
|
||||
if m == nil {
|
||||
return p
|
||||
}
|
||||
p.Scheme = m[1] // e.g. "https:"
|
||||
if m[3] != "" {
|
||||
p.Authority = removeUserInfo(m[3]) // e.g. "//chatgpt.com"
|
||||
}
|
||||
p.Path = m[5] // e.g. "/"
|
||||
p.Query = m[6] // e.g. "?foo=bar"
|
||||
p.Fragment = m[8] // e.g. "#section"
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *PartitionedUrl) String() string {
|
||||
var parts []string
|
||||
for _, s := range []string{p.Scheme, p.Authority, p.Path, p.Query, p.Fragment} {
|
||||
if s != "" {
|
||||
parts = append(parts, s)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
// SequentialHashWithLimit manages individual segment hashing with full/partial limits.
|
||||
type SequentialHashWithLimit struct {
|
||||
s string
|
||||
cur int
|
||||
hashedCount int
|
||||
fullHashLimit int
|
||||
totalHashLimit int
|
||||
}
|
||||
|
||||
func newSequentialHashWithLimit(s string, fullHashLimit, totalHashLimit int) *SequentialHashWithLimit {
|
||||
return &SequentialHashWithLimit{
|
||||
s: s,
|
||||
cur: 0,
|
||||
hashedCount: 0,
|
||||
fullHashLimit: fullHashLimit,
|
||||
totalHashLimit: totalHashLimit,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SequentialHashWithLimit) shouldHash() bool {
|
||||
return h.hashedCount < h.totalHashLimit
|
||||
}
|
||||
|
||||
func (h *SequentialHashWithLimit) isLastHash() bool {
|
||||
return h.hashedCount == h.totalHashLimit-1
|
||||
}
|
||||
|
||||
func (h *SequentialHashWithLimit) shouldPartialHash() bool {
|
||||
return !h.isLastHash() && h.hashedCount >= h.fullHashLimit
|
||||
}
|
||||
|
||||
func (h *SequentialHashWithLimit) replace(e string) {
|
||||
t := e
|
||||
n := strings.Index(h.s[h.cur:], e)
|
||||
if n == -1 {
|
||||
return
|
||||
}
|
||||
n += h.cur
|
||||
|
||||
if h.isLastHash() {
|
||||
t = h.s[n:]
|
||||
}
|
||||
|
||||
r := SHA256WithSalt(t)
|
||||
if h.shouldPartialHash() {
|
||||
if len(r) > partialHashLen {
|
||||
r = r[:partialHashLen]
|
||||
}
|
||||
}
|
||||
|
||||
h.s = h.s[:n] + r + h.s[n+len(t):]
|
||||
h.cur = n + len(r)
|
||||
h.hashedCount++
|
||||
}
|
||||
|
||||
// SequentialSplitterAndHasher tracks remaining hash budget across URL parts.
|
||||
type SequentialSplitterAndHasher struct {
|
||||
remainingHashes int
|
||||
fullHashLimit int
|
||||
}
|
||||
|
||||
func newSequentialSplitterAndHasher(fullHashLimit int) *SequentialSplitterAndHasher {
|
||||
return &SequentialSplitterAndHasher{
|
||||
remainingHashes: totalPartsLimit,
|
||||
fullHashLimit: fullHashLimit,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SequentialSplitterAndHasher) getFullHashLimit(part string) int {
|
||||
if part == "authority" {
|
||||
return totalPartsLimit
|
||||
}
|
||||
return s.fullHashLimit
|
||||
}
|
||||
|
||||
func (s *SequentialSplitterAndHasher) totalHashLimitForPart(part string) int {
|
||||
switch part {
|
||||
case "authority":
|
||||
return totalPartsLimit
|
||||
case "path":
|
||||
v := s.remainingHashes
|
||||
if v > pathPartsLimit {
|
||||
v = pathPartsLimit
|
||||
}
|
||||
if v < 1 {
|
||||
v = 1
|
||||
}
|
||||
return v
|
||||
case "query", "fragment":
|
||||
if s.remainingHashes < 1 {
|
||||
return 1
|
||||
}
|
||||
return s.remainingHashes
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SequentialSplitterAndHasher) splitAndHash(input, partType string, delim *regexp.Regexp) string {
|
||||
if partType == "authority" && input != "" && isStripeCheckoutAuthority(input) {
|
||||
return input
|
||||
}
|
||||
if input == "" {
|
||||
return input
|
||||
}
|
||||
|
||||
h := newSequentialHashWithLimit(input, s.getFullHashLimit(partType), s.totalHashLimitForPart(partType))
|
||||
|
||||
// Split by delimiter, filter empty strings
|
||||
segments := delim.Split(input, -1)
|
||||
for _, seg := range segments {
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
if h.shouldHash() {
|
||||
h.replace(seg)
|
||||
}
|
||||
}
|
||||
s.remainingHashes -= h.hashedCount
|
||||
return h.s
|
||||
}
|
||||
|
||||
// hashURL hashes URL components using SequentialSplitterAndHasher.
|
||||
func hashURL(p *PartitionedUrl, fullHashLimit int) *PartitionedUrl {
|
||||
r := newSequentialSplitterAndHasher(fullHashLimit)
|
||||
|
||||
authorityRe := regexp.MustCompile(`[/.:]`)
|
||||
otherRe := regexp.MustCompile(`[/#?!&+,=]`)
|
||||
|
||||
p.Authority = r.splitAndHash(p.Authority, "authority", authorityRe)
|
||||
p.Path = r.splitAndHash(p.Path, "path", otherRe)
|
||||
p.Query = r.splitAndHash(p.Query, "query", otherRe)
|
||||
p.Fragment = r.splitAndHash(p.Fragment, "fragment", otherRe)
|
||||
return p
|
||||
}
|
||||
|
||||
// hashURLWithAuthorityCheck applies Stripe-specific authority logic before hashing.
|
||||
func hashURLWithAuthorityCheck(p *PartitionedUrl, fullHashLimit int) *PartitionedUrl {
|
||||
n := p.Authority
|
||||
if n != "" && isStripeCheckoutAuthority(n) {
|
||||
return hashURL(p, defaultFullHashLimit)
|
||||
}
|
||||
if n != "" && isStripeAuthority(n) {
|
||||
return p // don't hash stripe.com URLs
|
||||
}
|
||||
return hashURL(p, fullHashLimit)
|
||||
}
|
||||
|
||||
// HashURL is the public entry point for URL hashing.
|
||||
func HashURL(urlStr string) string {
|
||||
p := NewPartitionedUrl(urlStr)
|
||||
return hashURLWithAuthorityCheck(p, defaultFullHashLimit).String()
|
||||
}
|
||||
|
||||
// GetHashTimestampWithSalt returns "timestamp:hash" string.
|
||||
func GetHashTimestampWithSalt(randomValue string) string {
|
||||
now := time.Now().UnixMilli()
|
||||
hash := SHA256URLSafe(randomValue + fmt.Sprintf("%d", now+1))
|
||||
return fmt.Sprintf("%d:%s", now, hash)
|
||||
}
|
||||
306
pkg/stripe/telemetry.go
Normal file
306
pkg/stripe/telemetry.go
Normal file
@@ -0,0 +1,306 @@
|
||||
package stripe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"gpt-plus/pkg/captcha"
|
||||
"gpt-plus/pkg/httpclient"
|
||||
)
|
||||
|
||||
// TelemetrySession holds state for simulating Stripe.js telemetry events.
|
||||
type TelemetrySession struct {
|
||||
Client *httpclient.Client
|
||||
CheckoutSessionID string
|
||||
PublishableKey string
|
||||
StripeJsID string
|
||||
UserAgent string
|
||||
Timezone string // e.g. "America/Chicago"
|
||||
Currency string // e.g. "usd"
|
||||
Merchant string // e.g. "acct_1HOrSwC6h1nxGoI3"
|
||||
|
||||
startTime int64 // controller load time (epoch ms)
|
||||
eventCount int
|
||||
stripeObjID string
|
||||
sessionID string // elements_session_id
|
||||
}
|
||||
|
||||
// NewTelemetrySession creates a telemetry session for simulating stripe.js events.
|
||||
func NewTelemetrySession(client *httpclient.Client, checkoutSessionID, publishableKey, stripeJsID, userAgent, timezone, currency string) *TelemetrySession {
|
||||
return &TelemetrySession{
|
||||
Client: client,
|
||||
CheckoutSessionID: checkoutSessionID,
|
||||
PublishableKey: publishableKey,
|
||||
StripeJsID: stripeJsID,
|
||||
UserAgent: userAgent,
|
||||
Timezone: timezone,
|
||||
Currency: currency,
|
||||
Merchant: "acct_1HOrSwC6h1nxGoI3",
|
||||
startTime: time.Now().UnixMilli() - int64(rand.Intn(2000)+500),
|
||||
stripeObjID: "sobj-" + uuid.New().String(),
|
||||
sessionID: "elements_session_" + randomAlphaNum(11),
|
||||
}
|
||||
}
|
||||
|
||||
// PassiveCaptchaSiteKey is the primary hCaptcha site key used for Stripe's passive captcha.
|
||||
// Updated from packet capture (2025-06): confirmed key for confirm requests.
|
||||
const PassiveCaptchaSiteKey = "24ed0064-62cf-4d42-9960-5dd1a41d4e29"
|
||||
|
||||
// TelemetryStatusFunc is a callback for printing progress to terminal.
|
||||
type TelemetryStatusFunc func(format string, args ...interface{})
|
||||
|
||||
// SendPreConfirmEvents sends telemetry events and solves passive captcha.
|
||||
// Returns the passive captcha token to include in confirm request.
|
||||
// Sends 50+ events across 12 batches to match real browser telemetry volume.
|
||||
func (ts *TelemetrySession) SendPreConfirmEvents(ctx context.Context, solver *captcha.Solver, sf TelemetryStatusFunc) (passiveToken string, err error) {
|
||||
log.Printf("[telemetry] sending pre-confirm events for checkout %s", ts.CheckoutSessionID)
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
// Batch 1: Init events (5)
|
||||
ts.sendBatch([]event{
|
||||
ts.makeEvent("elements.controller.load", now-12000),
|
||||
ts.makeEvent("elements.event.load", now-11800),
|
||||
ts.makeEvent("rum.stripejs", now-11600),
|
||||
ts.makeEvent("elements.create", now-11400),
|
||||
ts.makeEvent("elements.init_payment_page", now-11200),
|
||||
})
|
||||
time.Sleep(time.Duration(200+rand.Intn(300)) * time.Millisecond)
|
||||
|
||||
// Batch 2: Mount + ready events (5)
|
||||
ts.sendBatch([]event{
|
||||
ts.makeEvent("elements.mount", now-10500),
|
||||
ts.makeEvent("elements.event.ready", now-10300),
|
||||
ts.makeEvent("elements.init_payment_page.success", now-10100),
|
||||
ts.makeEvent("elements.retrieve_elements_session.success", now-9900),
|
||||
ts.makeEvent("elements.get_elements_state", now-9700),
|
||||
})
|
||||
time.Sleep(time.Duration(200+rand.Intn(300)) * time.Millisecond)
|
||||
|
||||
// Batch 3: Card number field interaction (4)
|
||||
ts.sendBatch([]event{
|
||||
ts.makeEvent("elements.event.focus", now-9000),
|
||||
ts.makeEvent("elements.event.change", now-8200),
|
||||
ts.makeEvent("elements.event.blur", now-7800),
|
||||
ts.makeEvent("elements.update", now-7700),
|
||||
})
|
||||
time.Sleep(time.Duration(150+rand.Intn(250)) * time.Millisecond)
|
||||
|
||||
// Batch 4: Card expiry field interaction (4)
|
||||
ts.sendBatch([]event{
|
||||
ts.makeEvent("elements.event.focus", now-7200),
|
||||
ts.makeEvent("elements.event.change", now-6800),
|
||||
ts.makeEvent("elements.event.blur", now-6400),
|
||||
ts.makeEvent("elements.update", now-6300),
|
||||
})
|
||||
time.Sleep(time.Duration(100+rand.Intn(200)) * time.Millisecond)
|
||||
|
||||
// Batch 5: Card CVC field interaction (4)
|
||||
ts.sendBatch([]event{
|
||||
ts.makeEvent("elements.event.focus", now-5800),
|
||||
ts.makeEvent("elements.event.change", now-5400),
|
||||
ts.makeEvent("elements.event.blur", now-5000),
|
||||
ts.makeEvent("elements.update", now-4900),
|
||||
})
|
||||
time.Sleep(time.Duration(100+rand.Intn(200)) * time.Millisecond)
|
||||
|
||||
// Batch 6: Name field interaction (4)
|
||||
ts.sendBatch([]event{
|
||||
ts.makeEvent("elements.event.focus", now-4500),
|
||||
ts.makeEvent("elements.event.change", now-4100),
|
||||
ts.makeEvent("elements.event.blur", now-3800),
|
||||
ts.makeEvent("elements.update", now-3700),
|
||||
})
|
||||
time.Sleep(time.Duration(100+rand.Intn(200)) * time.Millisecond)
|
||||
|
||||
// Batch 7: Address field interaction (4)
|
||||
ts.sendBatch([]event{
|
||||
ts.makeEvent("elements.event.focus", now-3300),
|
||||
ts.makeEvent("elements.event.change", now-2900),
|
||||
ts.makeEvent("elements.event.blur", now-2600),
|
||||
ts.makeEvent("elements.update", now-2500),
|
||||
})
|
||||
time.Sleep(time.Duration(100+rand.Intn(200)) * time.Millisecond)
|
||||
|
||||
// Batch 8: Postal code field interaction + address validation (5)
|
||||
ts.sendBatch([]event{
|
||||
ts.makeEvent("elements.event.focus", now-2200),
|
||||
ts.makeEvent("elements.event.change", now-1900),
|
||||
ts.makeEvent("elements.event.blur", now-1600),
|
||||
ts.makeEvent("elements.update", now-1500),
|
||||
ts.makeEvent("elements.update_checkout_session", now-1400),
|
||||
})
|
||||
time.Sleep(time.Duration(100+rand.Intn(200)) * time.Millisecond)
|
||||
|
||||
// Batch 9: Session state refresh (4)
|
||||
ts.sendBatch([]event{
|
||||
ts.makeEvent("elements.retrieve_elements_session.success", now-1200),
|
||||
ts.makeEvent("elements.get_elements_state", now-1100),
|
||||
ts.makeEvent("elements.update", now-1000),
|
||||
ts.makeEvent("elements.validate_elements", now-900),
|
||||
})
|
||||
time.Sleep(time.Duration(100+rand.Intn(200)) * time.Millisecond)
|
||||
|
||||
// Batch 10: Additional focus/blur cycles (6)
|
||||
ts.sendBatch([]event{
|
||||
ts.makeEvent("elements.event.focus", now-800),
|
||||
ts.makeEvent("elements.event.blur", now-700),
|
||||
ts.makeEvent("elements.event.focus", now-650),
|
||||
ts.makeEvent("elements.event.blur", now-550),
|
||||
ts.makeEvent("elements.event.focus", now-500),
|
||||
ts.makeEvent("elements.event.blur", now-400),
|
||||
})
|
||||
time.Sleep(time.Duration(100+rand.Intn(200)) * time.Millisecond)
|
||||
|
||||
// Batch 11: Passive captcha init (2)
|
||||
ts.sendBatch([]event{
|
||||
ts.makeEvent("elements.captcha.passive.init", now-300),
|
||||
ts.makeEvent("elements.captcha.passive.load", now-250),
|
||||
})
|
||||
|
||||
// Solve invisible hCaptcha via captcha solver
|
||||
if solver != nil {
|
||||
if sf != nil {
|
||||
sf(" → 解 Passive hCaptcha (invisible, site_key=%s)...", PassiveCaptchaSiteKey[:8])
|
||||
}
|
||||
log.Printf("[telemetry] solving passive (invisible) hCaptcha, site_key=%s", PassiveCaptchaSiteKey)
|
||||
passiveToken, _, err = solver.SolveInvisibleHCaptcha(ctx, PassiveCaptchaSiteKey, "https://js.stripe.com")
|
||||
if err != nil {
|
||||
if sf != nil {
|
||||
sf(" ⚠ Passive hCaptcha 失败 (非致命): %v", err)
|
||||
}
|
||||
log.Printf("[telemetry] passive captcha failed (non-fatal): %v", err)
|
||||
} else {
|
||||
if sf != nil {
|
||||
sf(" ✓ Passive hCaptcha 成功, token 长度=%d", len(passiveToken))
|
||||
}
|
||||
log.Printf("[telemetry] passive captcha token obtained, len=%d", len(passiveToken))
|
||||
}
|
||||
}
|
||||
|
||||
// Batch 12: Passive captcha completion + pre-confirm (5)
|
||||
ts.sendBatch([]event{
|
||||
ts.makeEvent("elements.captcha.passive.execute", now-100),
|
||||
ts.makeEvent("elements.captcha.passive.success", now-80),
|
||||
ts.makeEvent("elements.get_elements_state", now-60),
|
||||
ts.makeEvent("elements.custom_checkout.confirm", now-30),
|
||||
ts.makeEvent("elements.validate_elements", now-10),
|
||||
})
|
||||
|
||||
log.Printf("[telemetry] sent %d events total across 12 batches", ts.eventCount)
|
||||
return passiveToken, nil
|
||||
}
|
||||
|
||||
// SendPostConfirmEvents sends events after a successful confirm.
|
||||
func (ts *TelemetrySession) SendPostConfirmEvents() {
|
||||
now := time.Now().UnixMilli()
|
||||
ts.sendBatch([]event{
|
||||
ts.makeEvent("elements.confirm_payment_page", now),
|
||||
ts.makeEvent("elements.confirm_payment_page.success", now+100),
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Internal helpers ───
|
||||
|
||||
type event map[string]interface{}
|
||||
|
||||
func (ts *TelemetrySession) makeEvent(name string, created int64) event {
|
||||
ts.eventCount++
|
||||
e := event{
|
||||
"event_name": name,
|
||||
"created": created,
|
||||
"batching_enabled": true,
|
||||
"event_count": fmt.Sprintf("%d", ts.eventCount),
|
||||
"os": "Windows",
|
||||
"browserFamily": "Chrome",
|
||||
"version": FetchStripeConstants().BuildHash,
|
||||
"event_id": uuid.New().String(),
|
||||
"team_identifier": "t_0",
|
||||
"deploy_status": "main",
|
||||
"browserClassification": "modern",
|
||||
"browser_classification_v2": "2024",
|
||||
"connection_rtt": fmt.Sprintf("%d", 50+rand.Intn(200)),
|
||||
"connection_downlink": fmt.Sprintf("%.1f", 1.0+rand.Float64()*9),
|
||||
"connection_effective_type": "4g",
|
||||
"key": ts.PublishableKey,
|
||||
"key_mode": "live",
|
||||
"referrer": "https://chatgpt.com",
|
||||
"betas": "custom_checkout_server_updates_1 custom_checkout_manual_approval_1",
|
||||
"stripe_js_id": ts.StripeJsID,
|
||||
"stripe_obj_id": ts.stripeObjID,
|
||||
"controller_load_time": fmt.Sprintf("%d", ts.startTime),
|
||||
"stripe_js_release_train": "basil",
|
||||
"wrapper": "react-stripe-js",
|
||||
"wrapper_version": "3.10.0",
|
||||
"es_module": "true",
|
||||
"es_module_version": "7.9.0",
|
||||
"browser_timezone": ts.Timezone,
|
||||
"checkout_session_id": ts.CheckoutSessionID,
|
||||
"elements_init_source": "custom_checkout",
|
||||
"decoupled_intent": "true",
|
||||
"merchant": ts.Merchant,
|
||||
"elements_session_id": ts.sessionID,
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(name, "focus"), strings.Contains(name, "blur"),
|
||||
strings.Contains(name, "mount"), strings.Contains(name, "ready"):
|
||||
e["element"] = "payment"
|
||||
e["element_id"] = "payment-" + uuid.New().String()
|
||||
e["frame_width"] = fmt.Sprintf("%d", 400+rand.Intn(200))
|
||||
case strings.Contains(name, "confirm"):
|
||||
e["currency"] = ts.Currency
|
||||
e["frame_width"] = fmt.Sprintf("%d", 800+rand.Intn(200))
|
||||
e["livemode"] = "true"
|
||||
e["uiMode"] = "custom"
|
||||
e["m_sdk_confirm"] = "1"
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
func (ts *TelemetrySession) sendBatch(events []event) error {
|
||||
eventsJSON, err := json.Marshal(events)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal telemetry events: %w", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("client_id", "stripe-js")
|
||||
form.Set("num_requests", fmt.Sprintf("%d", len(events)))
|
||||
form.Set("events", string(eventsJSON))
|
||||
|
||||
headers := map[string]string{
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Origin": "https://js.stripe.com",
|
||||
"Referer": "https://js.stripe.com/",
|
||||
"Accept": "application/json",
|
||||
"Accept-Language": DefaultAcceptLanguage,
|
||||
"User-Agent": ts.UserAgent,
|
||||
}
|
||||
|
||||
resp, err := ts.Client.PostForm("https://r.stripe.com/b", form, headers)
|
||||
if err != nil {
|
||||
log.Printf("[telemetry] send batch failed: %v", err)
|
||||
return err
|
||||
}
|
||||
httpclient.ReadBody(resp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func randomAlphaNum(n int) string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = chars[rand.Intn(len(chars))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
Reference in New Issue
Block a user