920 lines
32 KiB
Go
920 lines
32 KiB
Go
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")
|
|
}
|