package chatgpt import ( "context" "encoding/json" "errors" "fmt" "io" "log" "math/rand" "net/http" "net/url" "strings" "time" "github.com/google/uuid" "gpt-plus/config" "gpt-plus/pkg/auth" "gpt-plus/pkg/captcha" "gpt-plus/pkg/httpclient" "gpt-plus/pkg/provider/card" "gpt-plus/pkg/provider/email" "gpt-plus/pkg/stripe" ) const ( couponCheckURL = "https://chatgpt.com/backend-api/promo_campaign/check_coupon" teamStripePollAttempts = 20 teamWorkspacePolls = 20 teamWorkspacePollDelay = 2 * time.Second teamSuccessPageAttempts = 3 ) // TeamResult holds the outcome of a Team subscription activation. type TeamResult struct { TeamAccountID string WorkspaceToken string StripeSessionID string } // couponCheckResponse mirrors the JSON from check_coupon. type couponCheckResponse struct { State string `json:"state"` } // CheckTeamEligibility checks whether the account is eligible for a team coupon. func CheckTeamEligibility(client *httpclient.Client, accessToken, deviceID, coupon string) (bool, error) { checkURL := fmt.Sprintf("%s?coupon=%s&is_coupon_from_query_param=false", couponCheckURL, coupon) headers := map[string]string{ "Authorization": "Bearer " + accessToken, "User-Agent": defaultUserAgent, "Accept": "*/*", "Origin": chatGPTOrigin, "Referer": chatGPTOrigin + "/", "oai-device-id": deviceID, "oai-language": defaultLanguage, } resp, err := client.Get(checkURL, headers) if err != nil { return false, fmt.Errorf("coupon check request: %w", err) } body, err := httpclient.ReadBody(resp) if err != nil { return false, fmt.Errorf("read coupon check body: %w", err) } if resp.StatusCode != http.StatusOK { return false, fmt.Errorf("coupon check returned %d: %s", resp.StatusCode, string(body)) } var result couponCheckResponse if err := json.Unmarshal(body, &result); err != nil { return false, fmt.Errorf("parse coupon check response: %w", err) } return result.State == "eligible", nil } // ActivateTeam orchestrates the full Team subscription activation flow. func ActivateTeam(ctx context.Context, session *Session, sentinel *auth.SentinelGenerator, cardProv card.CardProvider, stripeCfg config.StripeConfig, teamCfg config.TeamConfig, fingerprint *stripe.Fingerprint, emailAddr string, solver *captcha.Solver, emailProvider email.EmailProvider, mailboxID string, statusFn StatusFunc) (*TeamResult, error) { sf := func(format string, args ...interface{}) { if statusFn != nil { statusFn(format, args...) } } // Step 1: Check coupon eligibility. sf(" -> Checking Team coupon eligibility...") eligible, err := CheckTeamEligibility(session.Client, session.AccessToken, session.DeviceID, teamCfg.Coupon) if err != nil { return nil, fmt.Errorf("check team eligibility: %w", err) } if !eligible { return nil, fmt.Errorf("account not eligible for team coupon %q", teamCfg.Coupon) } sf(" -> Team coupon is eligible") // Step 2: Generate Sentinel token for checkout creation. sf(" -> Generating Sentinel token...") sentinelToken, err := sentinel.GenerateToken(ctx, nil, "authorize_continue") if err != nil { return nil, fmt.Errorf("generate sentinel token: %w", err) } // Step 3: Fetch the first card before checkout so country/currency match. sf(" -> Fetching payment card...") cardInfo, err := cardProv.GetCard(ctx) if err != nil { return nil, fmt.Errorf("get card for team: %w", err) } workspaceName := fmt.Sprintf("%s-%s", teamCfg.WorkspacePrefix, randomString(8)) sf(" -> Card: ...%s (%s), workspace=%s", last4(cardInfo.Number), cardInfo.Country, workspaceName) checkoutBody := map[string]interface{}{ "plan_name": "chatgptteamplan", "team_plan_data": map[string]interface{}{ "workspace_name": workspaceName, "price_interval": "month", "seat_quantity": teamCfg.SeatQuantity, }, "promo_campaign": map[string]interface{}{ "promo_campaign_id": teamCfg.Coupon, "is_coupon_from_query_param": false, }, "billing_details": map[string]interface{}{ "country": cardInfo.Country, "currency": cardInfo.Currency, }, "checkout_ui_mode": "custom", "cancel_url": "https://chatgpt.com/#pricing", } // Step 4: Create the checkout session. var checkoutResult *stripe.CheckoutResult if stripeCfg.Aimizy.Enabled { sf(" -> Creating Team checkout via Aimizy...") checkoutResult, err = stripe.CreateCheckoutViaAimizy( stripeCfg.Aimizy, session.AccessToken, cardInfo.Country, cardInfo.Currency, "chatgptteamplan", teamCfg.Coupon, teamCfg.SeatQuantity, ) if err != nil { return nil, fmt.Errorf("aimizy create team checkout: %w", err) } } else { sf(" -> Creating Team checkout...") checkoutResult, err = stripe.CreateCheckoutSession( session.Client, session.AccessToken, session.DeviceID, sentinelToken, checkoutBody, ) if err != nil { return nil, fmt.Errorf("create team checkout session: %w", err) } } sf(" -> Checkout created: %s (amount=%d)", checkoutResult.CheckoutSessionID, checkoutResult.ExpectedAmount) // Step 5: Complete Stripe payment (card retry + captcha). teamStripeJsID := uuid.New().String() flowResult, err := stripe.RunPaymentFlow(ctx, &stripe.PaymentFlowParams{ Client: session.Client, CheckoutResult: checkoutResult, FirstCard: cardInfo, CardProv: cardProv, Solver: solver, StripeCfg: stripeCfg, Fingerprint: fingerprint, EmailAddr: emailAddr, StripeJsID: teamStripeJsID, UserAgent: defaultUserAgent, StatusFn: sf, MaxRetries: 20, }) if err != nil { if errors.Is(err, stripe.ErrNoCaptchaSolver) { return nil, ErrCaptchaRequired } return nil, fmt.Errorf("confirm team payment: %w", err) } logSuccessCard(flowResult.CardInfo, emailAddr) // Step 6: Wait for Stripe to finish processing the subscription. returnURL := "" if flowResult.ConfirmResult != nil { returnURL = flowResult.ConfirmResult.ReturnURL } sf(" -> Waiting for Stripe finalization...") pollResult, err := waitForStripePaymentPageSuccess(ctx, session.Client, checkoutResult, stripeCfg, sf) if err != nil { return nil, fmt.Errorf("wait for stripe finalization: %w", err) } if pollResult.ReturnURL != "" { returnURL = pollResult.ReturnURL } teamAccountID := accountIDFromReturnURL(returnURL) if teamAccountID != "" { sf(" -> Stripe return URL provided account_id=%s", teamAccountID) } else { log.Printf("[phase-6] no account_id found in Stripe return_url") } // Flow C fallback: extract account_id from email only after Stripe is truly finalized. if teamAccountID == "" { if emailProvider != nil && mailboxID != "" { sf(" -> Flow C: waiting for workspace email account_id (up to 90s)...") emailAccountID, emailErr := emailProvider.WaitForTeamAccountID(ctx, mailboxID, 90*time.Second, time.Now().Add(-30*time.Second)) if emailErr == nil && emailAccountID != "" { teamAccountID = emailAccountID sf(" -> Flow C: extracted account_id=%s from email", teamAccountID) } else if emailErr != nil { log.Printf("[phase-6] Flow C email extraction failed: %v", emailErr) } else { log.Printf("[phase-6] Flow C did not find account_id in email") } } else { log.Printf("[phase-6] Flow C skipped (no email provider/mailboxID)") } } // Step 7: Visit success-team after Stripe itself is finalized. sf(" -> Visiting Team success page...") if err := visitSuccessTeamPageWithRetry(ctx, session.Client, checkoutResult.CheckoutSessionID, teamAccountID, checkoutResult.ProcessorEntity, false); err != nil { return nil, fmt.Errorf("visit success-team page: %w", err) } // Step 8: Wait for the workspace to actually appear in accounts/check. sf(" -> Waiting for Team workspace to appear in accounts/check...") workspaceAccount, err := waitForTeamWorkspace(ctx, session.Client, session.AccessToken, session.DeviceID, teamAccountID, sf) if err != nil { return nil, err } teamAccountID = workspaceAccount.AccountID teamAccountUserID := workspaceAccount.AccountUserID sf(" -> Team workspace confirmed: %s", teamAccountID) // Step 9: Exchange a workspace-scoped token only after the workspace exists. sf(" -> Exchanging workspace token...") workspaceToken, tokenErr := getWorkspaceTokenWithRetry(ctx, session.Client, teamAccountID, sf) if tokenErr != nil { log.Printf("[phase-6] workspace token exchange failed (non-fatal): %v", tokenErr) } workspaceAuthToken := session.AccessToken if workspaceToken != "" { workspaceAuthToken = workspaceToken } // Step 10: Final server-side subscription check. sf(" -> Verifying Team subscription status...") if err := checkTeamSubscription(session.Client, workspaceAuthToken, session.DeviceID, teamAccountID); err != nil { return nil, fmt.Errorf("verify team subscription: %w", err) } // Step 11: Complete workspace onboarding. sf(" -> Finalizing workspace onboarding...") if userID := extractUserID(teamAccountUserID); userID != "" { log.Printf("[phase-6] patching workspace user: %s", userID) if err := patchWorkspaceUser(session.Client, workspaceAuthToken, session.DeviceID, teamAccountID, userID); err != nil { log.Printf("[phase-6] warning: patch workspace user failed: %v (non-fatal)", err) } else { log.Printf("[phase-6] workspace user patched successfully") } } log.Printf("[phase-6] marking onboarding as seen") if err := markOnboardingSeen(session.Client, workspaceAuthToken, session.DeviceID); err != nil { log.Printf("[phase-6] warning: mark onboarding failed: %v (non-fatal)", err) } else { log.Printf("[phase-6] onboarding marked as seen") } log.Printf("[phase-6] team activation complete: account=%s", teamAccountID) return &TeamResult{ TeamAccountID: teamAccountID, WorkspaceToken: workspaceToken, StripeSessionID: checkoutResult.CheckoutSessionID, }, nil } func waitForStripePaymentPageSuccess(ctx context.Context, client *httpclient.Client, checkoutResult *stripe.CheckoutResult, stripeCfg config.StripeConfig, statusFn StatusFunc) (*stripe.PaymentPagePollResult, error) { var lastResult *stripe.PaymentPagePollResult var lastErr error for attempt := 1; attempt <= teamStripePollAttempts; attempt++ { result, err := stripe.PollPaymentPage(client, &stripe.PaymentPagePollParams{ CheckoutSessionID: checkoutResult.CheckoutSessionID, PublishableKey: checkoutResult.PublishableKey, StripeVersion: stripeCfg.StripeVersion, UserAgent: defaultUserAgent, }) if err != nil { lastErr = err log.Printf("[phase-6] stripe poll attempt %d/%d failed: %v", attempt, teamStripePollAttempts, err) } else { lastResult = result if statusFn != nil { statusFn(" -> Stripe poll %d/%d: state=%s payment_object_status=%s", attempt, teamStripePollAttempts, result.State, result.PaymentObjectStatus) } if result.State == "succeeded" { return result, nil } if result.State == "failed" || result.PaymentObjectStatus == "failed" { return nil, fmt.Errorf("stripe payment page failed: state=%q payment_object_status=%q", result.State, result.PaymentObjectStatus) } } if attempt < teamStripePollAttempts { if err := sleepContext(ctx, time.Second); err != nil { return nil, err } } } if lastResult != nil { return nil, fmt.Errorf("stripe payment page did not reach succeeded (state=%q payment_object_status=%q)", lastResult.State, lastResult.PaymentObjectStatus) } if lastErr != nil { return nil, lastErr } return nil, fmt.Errorf("stripe payment page polling exhausted with no result") } func accountIDFromReturnURL(raw string) string { if raw == "" { return "" } u, err := url.Parse(raw) if err != nil { return "" } return u.Query().Get("account_id") } func visitSuccessTeamPageWithRetry(ctx context.Context, client *httpclient.Client, stripeSessionID, accountID, processorEntity string, refreshAccount bool) error { var lastErr error for attempt := 1; attempt <= teamSuccessPageAttempts; attempt++ { if err := doVisitSuccessTeamPage(client, stripeSessionID, accountID, processorEntity, refreshAccount); err == nil { return nil } else { lastErr = err log.Printf("[phase-6] success-team attempt %d/%d failed: %v", attempt, teamSuccessPageAttempts, err) } if attempt < teamSuccessPageAttempts { if err := sleepContext(ctx, time.Second); err != nil { return err } } } return lastErr } func waitForTeamWorkspace(ctx context.Context, client *httpclient.Client, accessToken, deviceID, preferredAccountID string, statusFn StatusFunc) (*AccountInfo, error) { var lastErr error for attempt := 1; attempt <= teamWorkspacePolls; attempt++ { accounts, err := CheckAccountFull(client, accessToken, deviceID) if err != nil { lastErr = err log.Printf("[phase-6] accounts/check attempt %d/%d failed: %v", attempt, teamWorkspacePolls, err) } else { if acct := selectTeamWorkspace(accounts, preferredAccountID); acct != nil { log.Printf("[phase-6] workspace confirmed: plan=%s, user=%s", acct.PlanType, acct.AccountUserID) return acct, nil } lastErr = fmt.Errorf("workspace not visible yet") if statusFn != nil { statusFn(" -> accounts/check %d/%d: workspace not visible yet", attempt, teamWorkspacePolls) } } if attempt < teamWorkspacePolls { if err := sleepContext(ctx, teamWorkspacePollDelay); err != nil { return nil, err } } } if preferredAccountID != "" { return nil, fmt.Errorf("team workspace %s not visible after %d polls: %w", preferredAccountID, teamWorkspacePolls, lastErr) } return nil, fmt.Errorf("team workspace not visible after %d polls: %w", teamWorkspacePolls, lastErr) } func selectTeamWorkspace(accounts []*AccountInfo, preferredAccountID string) *AccountInfo { if preferredAccountID != "" { for _, acct := range accounts { if acct.AccountID == preferredAccountID && acct.Structure == "workspace" && acct.PlanType == "team" { return acct } } return nil } for _, acct := range accounts { if acct.Structure == "workspace" && acct.PlanType == "team" { return acct } } return nil } func getWorkspaceTokenWithRetry(ctx context.Context, client *httpclient.Client, teamAccountID string, statusFn StatusFunc) (string, error) { var lastErr error for attempt := 1; attempt <= 3; attempt++ { token, err := exchangeWorkspaceToken(client, teamAccountID) if err == nil { if statusFn != nil { statusFn(" -> Workspace token exchange succeeded (%d/3)", attempt) } return token, nil } lastErr = err if statusFn != nil { statusFn(" -> Workspace token exchange %d/3 failed: %v", attempt, err) } if attempt < 3 { if err := sleepContext(ctx, time.Second); err != nil { return "", err } } } for attempt := 1; attempt <= 3; attempt++ { token, err := GetWorkspaceAccessToken(client, teamAccountID) if err == nil { if statusFn != nil { statusFn(" -> Workspace session token succeeded (%d/3)", attempt) } return token, nil } lastErr = err if statusFn != nil { statusFn(" -> Workspace session token %d/3 failed: %v", attempt, err) } if attempt < 3 { if err := sleepContext(ctx, time.Second); err != nil { return "", err } } } return "", lastErr } func sleepContext(ctx context.Context, delay time.Duration) error { timer := time.NewTimer(delay) defer timer.Stop() select { case <-ctx.Done(): return ctx.Err() case <-timer.C: return nil } } // doVisitSuccessTeamPage visits the success-team page after Stripe checkout finalizes. func doVisitSuccessTeamPage(client *httpclient.Client, stripeSessionID, accountID, processorEntity string, refreshAccount bool) error { successURL := fmt.Sprintf( "https://chatgpt.com/payments/success-team?stripe_session_id=%s&processor_entity=%s", stripeSessionID, processorEntity, ) if accountID != "" { successURL += "&account_id=" + accountID } if refreshAccount { successURL += "&refresh_account=true" } req, err := http.NewRequest("GET", successURL, nil) if err != nil { return fmt.Errorf("build success-team request: %w", err) } req.Header.Set("User-Agent", defaultUserAgent) req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") req.Header.Set("Referer", chatGPTOrigin+"/") resp, err := client.Do(req) if err != nil { return fmt.Errorf("success-team request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("read success-team response: %w", err) } if resp.StatusCode != http.StatusOK { return fmt.Errorf("success-team returned %d: %s", resp.StatusCode, string(body)) } log.Printf("[team] success-team visited (status=%d, account_id=%s, refresh=%v)", resp.StatusCode, accountID, refreshAccount) return nil } func checkTeamSubscription(client *httpclient.Client, accessToken, deviceID, accountID string) error { subURL := fmt.Sprintf("https://chatgpt.com/backend-api/subscriptions?account_id=%s", url.QueryEscape(accountID)) req, err := http.NewRequest("GET", subURL, nil) if err != nil { return fmt.Errorf("build subscription request: %w", err) } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("User-Agent", defaultUserAgent) req.Header.Set("Accept", "application/json") req.Header.Set("Origin", chatGPTOrigin) req.Header.Set("Referer", chatGPTOrigin+"/") req.Header.Set("oai-device-id", deviceID) req.Header.Set("oai-language", defaultLanguage) req.Header.Set("chatgpt-account-id", accountID) resp, err := client.Do(req) if err != nil { return fmt.Errorf("subscription request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("read subscription response: %w", err) } if resp.StatusCode != http.StatusOK { return fmt.Errorf("subscription returned %d: %s", resp.StatusCode, string(body)) } var result struct { PlanType string `json:"plan_type"` } if err := json.Unmarshal(body, &result); err != nil { return fmt.Errorf("parse subscription response: %w", err) } if result.PlanType != "team" { return fmt.Errorf("unexpected subscription plan_type=%q", result.PlanType) } log.Printf("[team] subscription verified: account=%s plan=%s", accountID, result.PlanType) return nil } // patchWorkspaceUser finalizes the owner's onboarding inside the workspace. func patchWorkspaceUser(client *httpclient.Client, accessToken, deviceID, accountID, userID string) error { patchURL := fmt.Sprintf("https://chatgpt.com/backend-api/accounts/%s/users/%s", accountID, userID) payload := map[string]interface{}{ "onboarding_information": map[string]interface{}{ "role": "engineering", "departments": []string{}, }, } jsonBody, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshal patch payload: %w", err) } req, err := http.NewRequest("PATCH", patchURL, strings.NewReader(string(jsonBody))) if err != nil { return fmt.Errorf("build patch request: %w", err) } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", defaultUserAgent) req.Header.Set("Origin", chatGPTOrigin) req.Header.Set("Referer", chatGPTOrigin+"/") req.Header.Set("oai-device-id", deviceID) req.Header.Set("oai-language", defaultLanguage) req.Header.Set("chatgpt-account-id", accountID) resp, err := client.Do(req) if err != nil { return fmt.Errorf("patch request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("read patch response: %w", err) } if resp.StatusCode != http.StatusOK { return fmt.Errorf("patch returned %d: %s", resp.StatusCode, string(body)) } log.Printf("[team] patch workspace user response: %s", string(body)) return nil } // markOnboardingSeen tells the backend that onboarding has been completed. func markOnboardingSeen(client *httpclient.Client, accessToken, deviceID string) error { onboardURL := "https://chatgpt.com/backend-api/settings/announcement_viewed?announcement_id=oai%2Fapps%2FhasSeenOnboarding" req, err := http.NewRequest("POST", onboardURL, nil) if err != nil { return fmt.Errorf("build onboarding request: %w", err) } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("User-Agent", defaultUserAgent) req.Header.Set("Origin", chatGPTOrigin) req.Header.Set("Referer", chatGPTOrigin+"/") req.Header.Set("oai-device-id", deviceID) req.Header.Set("oai-language", defaultLanguage) resp, err := client.Do(req) if err != nil { return fmt.Errorf("onboarding request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("read onboarding response: %w", err) } if resp.StatusCode != http.StatusOK { return fmt.Errorf("onboarding returned %d: %s", resp.StatusCode, string(body)) } log.Printf("[team] onboarding marked as seen: %s", string(body)) return nil } // extractUserID extracts the short user ID from "user-xxx__account-id". func extractUserID(accountUserID string) string { if idx := strings.Index(accountUserID, "__"); idx > 0 { return accountUserID[:idx] } return accountUserID } // exchangeWorkspaceToken gets a workspace-scoped JWT via the NextAuth cookie-authenticated endpoint. func exchangeWorkspaceToken(client *httpclient.Client, teamAccountID string) (string, error) { exchangeURL := fmt.Sprintf( "https://chatgpt.com/api/auth/session?exchange_workspace_token=true&workspace_id=%s&reason=setCurrentAccountWithoutRedirect", teamAccountID, ) headers := map[string]string{ "User-Agent": defaultUserAgent, "Accept": "application/json", "Origin": chatGPTOrigin, "Referer": chatGPTOrigin + "/", } resp, err := client.Get(exchangeURL, headers) if err != nil { return "", fmt.Errorf("workspace token request: %w", err) } body, err := httpclient.ReadBody(resp) if err != nil { return "", fmt.Errorf("read workspace token body: %w", err) } if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("workspace token exchange returned %d: %s", resp.StatusCode, string(body)) } var result struct { AccessToken string `json:"accessToken"` } if err := json.Unmarshal(body, &result); err != nil { return "", fmt.Errorf("parse workspace token response: %w", err) } if result.AccessToken == "" { log.Printf("[team] workspace token exchange response body: %s", string(body)) return "", fmt.Errorf("empty access token in workspace exchange response") } return result.AccessToken, nil } // randomString generates a random alphanumeric string of the given length. func randomString(n int) string { rng := rand.New(rand.NewSource(time.Now().UnixNano())) const letters = "abcdefghijklmnopqrstuvwxyz0123456789" b := make([]byte, n) for i := range b { b[i] = letters[rng.Intn(len(letters))] } return string(b) }