Initial sanitized code sync

This commit is contained in:
zqq61
2026-03-15 20:48:19 +08:00
commit 17ee51ba04
126 changed files with 22546 additions and 0 deletions

149
pkg/stripe/aimizy.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)
}