Jake Vanderwerf
2026-01-01 0e4b986e81f8132a44e61fa8df18860301cc3468
inc/integrations/Integrations.php
@@ -62,7 +62,7 @@
    */
   protected array $credentials = [];      // Service credentials (API keys, tokens, etc.)
   protected ?int $userID = null;          // User context for user-specific integrations
   private bool $token_refresh_attempted = false;  // Circuit breaker for token refresh
   protected array $fields = [];       // The fields to generate that become credentials
   protected array $advanced = [];  // The fields that are optional settings
@@ -167,7 +167,7 @@
   {
      $this->cacheName = $this->cacheName ?: $this->service_name;
      $this->userID = $userID;
      $this->cache = new CacheManager('integrations_' . $this->cacheName, $this->ttl);
      $this->cache = CacheManager::for('integrations_' . $this->cacheName, $this->ttl);
      // Load error stats from cache
      $this->loadErrorStats();
@@ -258,10 +258,6 @@
      if (!empty($this->credentials['expires_at'])) {
         $expires_at = intval($this->credentials['expires_at']);
         if ($expires_at <= time()) {
            // Token expired, try to refresh
            if (!empty($this->credentials['refresh_token'])) {
               return $this->refreshOAuthToken();
            }
            return false;
         }
      }
@@ -765,6 +761,12 @@
      array  $options = []
   ): array|WP_Error
   {
      if (!$this->is_healthy) {
         $this->logDebug('Skipping request - integration is unhealthy', [
            'consecutive_errors' => $this->error_stats['consecutive_errors'],
            'last_success' => $this->error_stats['last_success']
         ]);
      }
      $this->ensureInitialized();
      if (!$this->isSetUp()){
         $this->logError('Connection not setup for '.$this->service_name, [
@@ -778,9 +780,6 @@
         return new WP_Error('rate_limit', 'Rate limit exceeded. Please try again later.');
      }
      // Debug: Check if credentials are loaded
      error_log('['.$this->service_name.'] Make Request - Credentials loaded: ' . (!empty($this->credentials) ? 'Yes' : 'No'));
      error_log('With Credentials: '.print_r($this->credentials, true));
      $attempt = 0;
      $lastError = null;
@@ -846,14 +845,14 @@
      $this->logDebug("$method request to: $url: ".print_r($args, true));
      // Standard WordPress HTTP API
      // Use appropriate WordPress HTTP function
      // Make the request
      $response = match($method) {
         'GET' => wp_remote_get($url, $args),
         'POST' => wp_remote_post($url, $args),
         'PUT', 'PATCH', 'DELETE' => wp_remote_request($url, array_merge($args, ['method' => $method])),
         default => null
      };
      if (!$response) {
         $this->logError("Unsupported HTTP method $method for $this->service_name");
         return null;
@@ -867,9 +866,42 @@
      $response_code = wp_remote_retrieve_response_code($response);
      $body = wp_remote_retrieve_body($response);
      // Handle 401 - try to refresh token and retry once
      if ($response_code === 401 && $this->isOAuthService && !empty($this->credentials['refresh_token'])) {
         // Avoid infinite retry loop - only retry once
         static $retry_count = 0;
         if ($retry_count === 0) {
            $retry_count++;
            $this->logDebug('Got 401, attempting token refresh...');
            if ($this->refreshOAuthToken()) {
               $this->logDebug('Token refreshed successfully, retrying request...');
               // Rebuild request args with new token
               $args = $this->buildRequestArgs($method, $data, $options);
               // Retry the request
               $response = match($method) {
                  'GET' => wp_remote_get($url, $args),
                  'POST' => wp_remote_post($url, $args),
                  'PUT', 'PATCH', 'DELETE' => wp_remote_request($url, array_merge($args, ['method' => $method])),
                  default => null
               };
               if ($response && !is_wp_error($response)) {
                  $response_code = wp_remote_retrieve_response_code($response);
                  $body = wp_remote_retrieve_body($response);
               }
            }
            $retry_count = 0; // Reset for next request
         }
      }
      if ($response_code >= 400) {
         $this->handleApiError($response_code, $body, $endpoint);
      }
      $decoded = json_decode($body, true);
      if (json_last_error() !== JSON_ERROR_NONE) {
         return ['raw_response' => $body];
@@ -891,8 +923,11 @@
      bool   $force = false
   ): ?array
   {
      $cacheKey = $this->buildCacheKey('GET', $endpoint, $params);
      $ttl = $this->cacheStrategy[$cacheStrategy] ?? $this->ttl;
      $cacheKey = $this->buildCacheKey('GET', $endpoint, $params, $baseKey);
      $ttl = is_int($cacheStrategy)
         ? max(0, $cacheStrategy)
         : ($this->cacheStrategy[$cacheStrategy] ?? $this->ttl);
      if (!$force && $ttl > 0) {
         $cached = $this->cache->get($cacheKey);
@@ -904,12 +939,24 @@
      $result = $this->makeRequest('GET', $endpoint, $params, $baseKey);
      if ($result && $ttl > 0) {
      // Only cache successful responses (not WP_Error and not error objects)
      if ($result && !is_wp_error($result) && !$this->isErrorResponse($result) && $ttl > 0) {
         $this->cache->set($cacheKey, $result, $ttl);
      }
      return $result;
   }
   /**
    * Check if response contains an error
    * Override in child classes for service-specific error detection
    */
   protected function isErrorResponse(array $response): bool
   {
      // Common error patterns across APIs
      return isset($response['error'])
         || isset($response['errors'])
         || isset($response['error_description']);
   }
   /**
    * POST request
@@ -1171,14 +1218,10 @@
   public function handleAjaxResponse()
   {
      error_log('Ajax Response: '.print_r($_GET, true));
      $code = $_GET['code'];
      $state =  $_GET['state'];
      error_log('OAuth Callback - Code: ' . $code);
      error_log('OAuth Callback - State: ' . $state);
      $state_parts = explode('|', $state);
      $state_key = $state_parts[0] ?? '';
@@ -1186,16 +1229,13 @@
      $user_id = ($user_id === 0) ? null : $user_id;
      $return_url = isset($state_parts[2]) ? base64_decode($state_parts[2]) : admin_url('admin.php?page=jvb-integrations');
      error_log('Service: '.print_r($this->service_name, true));
      $state_data = get_transient('oauth_state_' . $state_key);
      error_log('State Data: '.print_r($state_data, true));
      if (!$state_data || $state_data['service'] !== $this->service_name) {
         wp_die('Invalid state parameter', 'OAuth Error');
      }
      // Delete the transient to prevent reuse
      delete_transient('oauth_state_' . $state_key);
      error_log('Return URL: '.print_r($return_url, true));
      // Handle error from OAuth provider
      if (array_key_exists('error', $_GET)) {
         $error_description = $_GET['error_description'] ?? 'Authorization denied';
@@ -1347,17 +1387,29 @@
         if ($this->isOAuthService && $this->hasOAuthCredentials()) {
            // Check if token is expired first
            if (!$this->isOAuthValid()) {
               $this->logDebug('OAuth token expired, attempting refresh');
               if (!$this->refreshOAuthToken()) {
                  $this->logError('Failed to refresh expired OAuth token');
               // Only attempt refresh once per request
               if (!$this->token_refresh_attempted) {
                  $this->token_refresh_attempted = true;
                  $this->logDebug('OAuth token expired, attempting refresh');
                  if (!$this->refreshOAuthToken()) {
                     $this->logError('Failed to refresh expired OAuth token - stopping execution');
                     // Token refresh failed - DO NOT continue making API requests
                     return;
                  }
               } else {
                  // Already attempted refresh in this request
                  $this->logDebug('Token refresh already attempted, skipping');
                  return;
               }
            }
            // Check if we should proactively refresh (before expiry)
            elseif ($this->shouldRefreshToken()) {
            elseif ($this->shouldRefreshToken() && !$this->token_refresh_attempted) {
               $this->token_refresh_attempted = true;
               $this->logDebug('OAuth token should be refreshed proactively');
               if (!$this->refreshOAuthToken()) {
                  $this->logError('Failed to proactively refresh OAuth token');
                  // Not critical - token is still valid
                  // Not critical - token is still valid, so continue
               }
            }
         }
@@ -1381,6 +1433,7 @@
      // Switch context
      $this->userID = $user_id;
      $this->credentials = [];
      $this->resetTokenRefreshFlag();  // ADD THIS LINE
      $this->ensureInitialized();
   }
@@ -1580,8 +1633,6 @@
      $auth_url = $this->oauth['authorize'] . '?' . http_build_query($params);
      // Debug log for troubleshooting
      error_log("Generated OAuth URL for {$this->service_name}: " . $auth_url);
      return $auth_url;
   }
@@ -1758,12 +1809,7 @@
         'redirect_uri' => $this->getRedirectUri()
      ];
      // Use a custom endpoint key for OAuth (not part of regular API)
      // We need to handle this specially since OAuth endpoints are different
      $oauth_endpoint = $this->oauth['token'];
      // Make the request using the centralized method
      // This automatically includes rate limiting and error handling
      $response = $this->makeOAuthRequest('POST', $oauth_endpoint, $request_data);
      if (is_wp_error($response)) {
@@ -1776,10 +1822,13 @@
      // Parse response
      if (isset($response['access_token'])) {
         $expires_in = $response['expires_in'] ?? 2592000; // 30 days default
         return [
            'access_token' => $response['access_token'],
            'refresh_token' => $response['refresh_token'] ?? '',
            'expires_in' => $response['expires_in'] ?? 2592000, // 30 days default
            'expires_in' => $expires_in,
            'expires_at' => time() + $expires_in, // Calculate expiry timestamp
            'token_type' => $response['token_type'] ?? 'Bearer',
            'merchant_id' => $response['merchant_id'] ?? '',
            'scope' => $response['scope'] ?? ''
@@ -1877,7 +1926,6 @@
         return false;
      }
      // Build refresh request data
      $request_data = [
         'client_id' => $this->credentials['client_id'],
         'client_secret' => $this->credentials['client_secret'],
@@ -1885,12 +1933,24 @@
         'grant_type' => 'refresh_token'
      ];
      // Use centralized OAuth request method
      $response = $this->makeOAuthRequest('POST', $this->oauth['token'], $request_data);
      if (is_wp_error($response)) {
         $this->logError('Failed to refresh Square token', [
            'error' => $response->get_error_message()
         $error_message = $response->get_error_message();
         if (str_contains($error_message, 'invalid_grant')) {
            $this->logError('OAuth refresh token is invalid - user must re-authorize', [
               'error' => $error_message
            ], 'critical');
            // Mark unhealthy immediately
            $this->error_stats['consecutive_errors'] = $this->error_threshold;
            $this->is_healthy = false;
            $this->saveErrorStats();
         }
         $this->logError('Failed to refresh OAuth token for '.$this->service_name, [
            'error' => $error_message
         ]);
         return false;
      }
@@ -1899,7 +1959,7 @@
         $this->credentials['access_token'] = $response['access_token'];
         $this->credentials['expires_at'] = time() + ($response['expires_in'] ?? 2592000); // 30 days
         // Note: Square returns the SAME refresh token
         // Note: Some services return the SAME refresh token
         if (isset($response['refresh_token'])) {
            $this->credentials['refresh_token'] = $response['refresh_token'];
         }
@@ -3029,7 +3089,7 @@
               switch ($action) {
                  case 'save_credentials':
                     $title = $label;
                     $label = jvbIcon('save');
                     $label = jvbIcon('floppy-disk');
                     break;
                  case 'clear_credentials':
                     $title = $label;
@@ -3064,7 +3124,7 @@
      }
      $credentials = $this->getCredentials();
      $hasCredentials = $this->hasOAuthCredentials();
      $returnURL = (is_admin()) ? :get_the_permalink();
      $returnURL = is_admin() ? admin_url('admin.php?page=jvb-integrations') : (get_the_permalink() ?: home_url());
      ?>
      <details <?= $hasCredentials?' open':''?>>
@@ -3483,4 +3543,12 @@
         throw new Exception('Failed to save JPEG image');
      }
   }
   /**
    * Reset token refresh attempt flag
    * Called automatically when switching users
    */
   protected function resetTokenRefreshFlag(): void
   {
      $this->token_refresh_attempted = false;
   }
}