package eshop import ( "bytes" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "fotbal-club/internal/config" "fotbal-club/internal/models" "fotbal-club/pkg/httpclient" ) // RevolutOAuthService handles OAuth2 authentication for both Revolut Pro and Business accounts type RevolutOAuthService struct { cfg *config.Config client *http.Client } // RevolutOAuthToken represents the OAuth token response type RevolutOAuthToken struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` RefreshToken string `json:"refresh_token,omitempty"` Scope string `json:"scope"` } // RevolutAccountType represents the type of Revolut account type RevolutAccountType string const ( RevolutAccountTypePro RevolutAccountType = "revolut_pro" RevolutAccountTypeBusiness RevolutAccountType = "business" ) // RevolutOAuthConfig holds OAuth configuration for different account types type RevolutOAuthConfig struct { AccountType RevolutAccountType `json:"account_type"` ClientID string `json:"client_id"` AuthBaseURL string `json:"auth_base_url"` TokenURL string `json:"token_url"` APIBaseURL string `json:"api_base_url"` } // NewRevolutOAuthService creates a new OAuth service instance func NewRevolutOAuthService(cfg *config.Config) *RevolutOAuthService { return &RevolutOAuthService{ cfg: cfg, client: httpclient.DefaultClient(), } } // GetOAuthConfig returns configuration for the specified account type and environment func (s *RevolutOAuthService) GetOAuthConfig(accountType RevolutAccountType) RevolutOAuthConfig { isSandbox := s.cfg.RevolutEnvironment == "sandbox" switch accountType { case RevolutAccountTypePro: if isSandbox { return RevolutOAuthConfig{ AccountType: RevolutAccountTypePro, ClientID: "sandbox_pro_client_id", // Get from Revolut for sandbox AuthBaseURL: "https://sandbox-checkout.revolut.com", TokenURL: "https://sandbox-checkout.revolut.com/api/connect/oauth/token", APIBaseURL: "https://sandbox-merchant.revolut.com/api/1.0", } } else { return RevolutOAuthConfig{ AccountType: RevolutAccountTypePro, ClientID: "9cda975e-016c-4b49-b5c6-37d1285ba046", // Production client ID AuthBaseURL: "https://checkout.revolut.com", TokenURL: "https://checkout.revolut.com/api/connect/oauth/token", APIBaseURL: "https://merchant.revolut.com/api/1.0", } } case RevolutAccountTypeBusiness: if isSandbox { return RevolutOAuthConfig{ AccountType: RevolutAccountTypeBusiness, ClientID: "sandbox_business_client_id", AuthBaseURL: "https://sandbox-business.revolut.com", TokenURL: "https://sandbox-business.revolut.com/api/1.0/auth/token", APIBaseURL: "https://sandbox-merchant.revolut.com/api/1.0", } } else { return RevolutOAuthConfig{ AccountType: RevolutAccountTypeBusiness, ClientID: "diiToLZlMJOPtWhdFTxQ", // Production business client ID AuthBaseURL: "https://business.revolut.com", TokenURL: "https://b2b.revolut.com/api/1.0/auth/token", APIBaseURL: "https://merchant.revolut.com/api/1.0", } } default: // Default to Revolut Pro return s.GetOAuthConfig(RevolutAccountTypePro) } } // GenerateAuthURL generates the OAuth2 authorization URL for both account types func (s *RevolutOAuthService) GenerateAuthURL(accountType RevolutAccountType, state string, codeChallenge string) (string, error) { config := s.GetOAuthConfig(accountType) // For Revolut Pro, use the select-user-type endpoint if accountType == RevolutAccountTypePro { params := url.Values{} params.Set("client_id", config.ClientID) params.Set("redirect_uri", s.cfg.RevolutWebhookURL) params.Set("response_type", "code") params.Set("scope", "checkout_extension") params.Set("code_challenge_method", "S256") params.Set("code_challenge", codeChallenge) params.Set("response_mode", "query") params.Set("state", state) params.Set("integration_type", "CUSTOM_PLUGIN") params.Set("rwa_auth_type", "auth") authURL := fmt.Sprintf("%s/s/select-user-type?%s", config.AuthBaseURL, params.Encode()) return authURL, nil } // For Revolut Business, use direct SSO params := url.Values{} params.Set("client_id", config.ClientID) params.Set("redirect_uri", s.cfg.RevolutWebhookURL) params.Set("response_type", "code") params.Set("code_challenge_method", "S256") params.Set("code_challenge", codeChallenge) params.Set("response_mode", "query") params.Set("prompt", "select_account") params.Set("state", state) authURL := fmt.Sprintf("%s/signin?%s", config.AuthBaseURL, params.Encode()) return authURL, nil } // GenerateCodeVerifier generates a PKCE code verifier func GenerateCodeVerifier() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil } // GenerateCodeChallenge generates a PKCE code challenge from verifier func GenerateCodeChallenge(verifier string) string { h := sha256.New() h.Write([]byte(verifier)) return base64.RawURLEncoding.EncodeToString(h.Sum(nil)) } // ExchangeCodeForToken exchanges authorization code for access token func (s *RevolutOAuthService) ExchangeCodeForToken(accountType RevolutAccountType, code, codeVerifier string) (*RevolutOAuthToken, error) { config := s.GetOAuthConfig(accountType) data := url.Values{} data.Set("client_id", config.ClientID) data.Set("code", code) data.Set("code_verifier", codeVerifier) data.Set("grant_type", "authorization_code") data.Set("redirect_uri", s.cfg.RevolutWebhookURL) req, err := http.NewRequest("POST", config.TokenURL, strings.NewReader(data.Encode())) if err != nil { return nil, fmt.Errorf("failed to create token request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := s.client.Do(req) if err != nil { return nil, fmt.Errorf("failed to execute token request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read token response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("token API error: status %d, response: %s", resp.StatusCode, string(body)) } var token RevolutOAuthToken if err := json.Unmarshal(body, &token); err != nil { return nil, fmt.Errorf("failed to parse token response: %w", err) } // Store account type with token for later use token.Scope = fmt.Sprintf("%s account_type:%s", token.Scope, accountType) return &token, nil } // RefreshAccessToken refreshes the access token using refresh token func (s *RevolutOAuthService) RefreshAccessToken(accountType RevolutAccountType, refreshToken string) (*RevolutOAuthToken, error) { config := s.GetOAuthConfig(accountType) data := url.Values{} data.Set("client_id", config.ClientID) data.Set("refresh_token", refreshToken) data.Set("grant_type", "refresh_token") req, err := http.NewRequest("POST", config.TokenURL, strings.NewReader(data.Encode())) if err != nil { return nil, fmt.Errorf("failed to create refresh request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := s.client.Do(req) if err != nil { return nil, fmt.Errorf("failed to execute refresh request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read refresh response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("refresh API error: status %d, response: %s", resp.StatusCode, string(body)) } var token RevolutOAuthToken if err := json.Unmarshal(body, &token); err != nil { return nil, fmt.Errorf("failed to parse refresh response: %w", err) } // Store account type with token token.Scope = fmt.Sprintf("%s account_type:%s", token.Scope, accountType) return &token, nil } // StoreOAuthToken stores the OAuth token with account type information func (s *RevolutOAuthService) StoreOAuthToken(accountType RevolutAccountType, token *RevolutOAuthToken) error { // Store token in database or secure storage // You would typically store: // - access_token // - refresh_token // - expires_at (calculated from expires_in) // - token_type // - scope (includes account_type) // - account_type expiresAt := time.Now().Add(time.Duration(token.ExpiresIn) * time.Second) // TODO: Implement database storage with account type fmt.Printf("Storing OAuth token for %s: expires at %v\n", accountType, expiresAt) return nil } // GetStoredOAuthToken retrieves stored OAuth token and account type func (s *RevolutOAuthService) GetStoredOAuthToken() (*RevolutOAuthToken, RevolutAccountType, error) { // TODO: Implement database retrieval return nil, "", fmt.Errorf("not implemented") } // CreatePaymentWithOAuth creates a payment using OAuth token instead of API key func (s *RevolutOAuthService) CreatePaymentWithOAuth(order *models.EshopOrder) (*PaymentResult, error) { // Get stored OAuth token and account type token, accountType, err := s.GetStoredOAuthToken() if err != nil { return nil, fmt.Errorf("failed to get OAuth token: %w", err) } // Check if token needs refresh if token.ExpiresIn > 0 && token.RefreshToken != "" { // Token might be expired, refresh it newToken, err := s.RefreshAccessToken(accountType, token.RefreshToken) if err == nil { token = newToken s.StoreOAuthToken(accountType, token) } } // Create order using OAuth token return s.createOrder(order, token.AccessToken, accountType) } // createOrder creates a Revolut order using OAuth access token func (s *RevolutOAuthService) createOrder(order *models.EshopOrder, accessToken string, accountType RevolutAccountType) (*PaymentResult, error) { config := s.GetOAuthConfig(accountType) // Create order request orderReq := map[string]interface{}{ "amount": order.TotalAmountCents, "currency": order.Currency, "description": fmt.Sprintf("Order %s", order.OrderNumber), "merchant_order_id": fmt.Sprintf("%d", order.ID), } orderJSON, err := json.Marshal(orderReq) if err != nil { return nil, fmt.Errorf("failed to marshal order request: %w", err) } req, err := http.NewRequest("POST", config.APIBaseURL+"/orders", bytes.NewBuffer(orderJSON)) if err != nil { return nil, fmt.Errorf("failed to create order request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+accessToken) resp, err := s.client.Do(req) if err != nil { return nil, fmt.Errorf("failed to execute order request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read order response: %w", err) } if resp.StatusCode != http.StatusCreated { return nil, fmt.Errorf("order API error: status %d, response: %s", resp.StatusCode, string(body)) } var orderResp map[string]interface{} if err := json.Unmarshal(body, &orderResp); err != nil { return nil, fmt.Errorf("failed to parse order response: %w", err) } orderID, ok := orderResp["id"].(string) if !ok || orderID == "" { return nil, fmt.Errorf("order response missing id") } return &PaymentResult{ RedirectURL: fmt.Sprintf("%s/checkout/%s", config.APIBaseURL, orderID), ProviderPaymentID: orderID, RawPayloadJSON: string(body), }, nil }