backend_url = self::get_backend_url(); } /** * Resolve the backend URL: the saved option, or the default. * * Single source of truth — the settings screen, the webhook delivery URL * and every API call must agree, otherwise a store looks "verbonden" while * half the plugin thinks it has no backend. * * @return string Backend URL without trailing slash. */ public static function get_backend_url(): string { $url = trim( (string) get_option( 'genius_commerce_backend_url', '' ) ); if ( '' === $url ) { $url = self::DEFAULT_BACKEND_URL; } return rtrim( $url, '/' ); } /** * Test connection to the backend. * * @param bool $force_refresh Force a fresh test, ignoring cache. * @return array{success: bool, error?: string, data?: array} */ public function test_connection( bool $force_refresh = false ): array { $cache_key = 'genius_commerce_connection_test'; // Check cache unless force refresh requested. if ( ! $force_refresh ) { $cached = get_transient( $cache_key ); if ( false !== $cached ) { return $cached; } } // Perform connection test. $result = $this->request( 'GET', '/health' ); // Cache the result. set_transient( $cache_key, $result, self::CACHE_TTL ); return $result; } /** * Make an HTTP request to the backend. * * @param string $method HTTP method (GET, POST, PUT, DELETE). * @param string $endpoint API endpoint (relative to backend URL). * @param array $data Request body data (for POST/PUT). * @return array{success: bool, error?: string, data?: array} */ public function request( string $method, string $endpoint, array $data = array() ): array { // Validate configuration. if ( empty( $this->backend_url ) ) { return array( 'success' => false, 'error' => __( 'Backend URL niet geconfigureerd', 'genius-commerce' ), ); } $api_key = GC_API_Key::get(); if ( empty( $api_key ) ) { return array( 'success' => false, 'error' => __( 'API key niet geconfigureerd', 'genius-commerce' ), ); } // Build request arguments. $args = array( 'timeout' => self::TIMEOUT, 'headers' => array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'X-GC-API-Key' => $api_key, ), ); $url = $this->backend_url . $endpoint; // Execute request based on method. switch ( strtoupper( $method ) ) { case 'POST': $args['body'] = wp_json_encode( $data ); $response = wp_remote_post( $url, $args ); break; case 'PUT': $args['method'] = 'PUT'; $args['body'] = wp_json_encode( $data ); $response = wp_remote_request( $url, $args ); break; case 'DELETE': $args['method'] = 'DELETE'; $response = wp_remote_request( $url, $args ); break; case 'GET': default: $response = wp_remote_get( $url, $args ); break; } return $this->handle_response( $response ); } /** * Handle the HTTP response. * * @param array|\WP_Error $response The response from wp_remote_*. * @return array{success: bool, error?: string, data?: array} */ private function handle_response( $response ): array { // Handle WP_Error. if ( is_wp_error( $response ) ) { $error_code = $response->get_error_code(); Genius_Commerce::log( sprintf( 'API request failed: %s - %s', $error_code, $response->get_error_message() ), 'error' ); return array( 'success' => false, 'error' => $this->translate_wp_error( $error_code ), ); } // Get response details. $code = wp_remote_retrieve_response_code( $response ); $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); // Story 8.3: every backend response carries the current scopeVersion // in `X-GC-Scope-Version`. When that diverges from the cached value // we schedule a lazy refresh — this is the "header piggyback" so we // don't need a separate push channel (beslissing B in 8.3). $this->maybe_schedule_scope_refresh( $response ); // Handle successful response. if ( $code >= 200 && $code < 300 ) { return array( 'success' => true, 'data' => $data, ); } // Handle error response. Genius_Commerce::log( sprintf( 'API request returned HTTP %d: %s', $code, $body ), 'error' ); return array( 'success' => false, 'error' => $this->translate_http_error( $code, $data ), ); } /** * Inspect the `X-GC-Scope-Version` response header and trigger a lazy * refresh of the scope cache when it diverges from what we have stored. * * Header-based "piggyback" instead of an eager push from the backend * (Story 8.3, beslissing B): every authenticated response carries the * current `scopeVersion`, so the very next outbound call after a config * change picks up the new value at zero extra cost. Scheduling is * non-blocking — actual `force_refresh()` runs via the WP-cron action * `genius_commerce_refresh_scopes`, registered in `Genius_Commerce::init_hooks()`. * * @param array|\WP_Error $response Result of the `wp_remote_*` call. * @return void */ private function maybe_schedule_scope_refresh( $response ): void { if ( is_wp_error( $response ) ) { return; } // `wp_remote_retrieve_header` already does case-insensitive lookup. $header_value = wp_remote_retrieve_header( $response, 'x-gc-scope-version' ); if ( '' === $header_value || null === $header_value ) { return; } $remote_version = (int) $header_value; if ( $remote_version <= 0 ) { return; } if ( ! GC_Scopes::is_stale_version( $remote_version ) ) { return; } // Use a short delay (1s) so it lands on the next WP-cron tick rather // than blocking the current request. `false` for the args avoids // rescheduling the same event multiple times per request. if ( function_exists( 'wp_next_scheduled' ) ) { $already = wp_next_scheduled( GC_Scopes::REFRESH_ACTION ); if ( false !== $already ) { return; } } if ( function_exists( 'wp_schedule_single_event' ) ) { wp_schedule_single_event( time() + 1, GC_Scopes::REFRESH_ACTION ); } } /** * Translate WP_Error codes to user-friendly messages. * * @param string $code The error code. * @return string Translated error message. */ private function translate_wp_error( string $code ): string { $errors = array( 'http_request_failed' => __( 'Kan geen verbinding maken met de server', 'genius-commerce' ), 'http_request_not_executed' => __( 'Request kon niet worden uitgevoerd', 'genius-commerce' ), 'connect_error' => __( 'Verbindingsfout - controleer de URL', 'genius-commerce' ), 'operation_timedout' => __( 'Timeout - server reageert niet (15s)', 'genius-commerce' ), ); return $errors[ $code ] ?? sprintf( /* translators: %s: error code */ __( 'Onbekende fout: %s', 'genius-commerce' ), $code ); } /** * Translate HTTP status codes to user-friendly messages. * * @param int $code HTTP status code. * @param array|null $data Response body data. * @return string Translated error message. */ private function translate_http_error( int $code, ?array $data ): string { // Check if response contains error message. if ( isset( $data['error'] ) ) { return $data['error']; } if ( isset( $data['message'] ) ) { return $data['message']; } $errors = array( 401 => __( 'Ongeldige API key', 'genius-commerce' ), 403 => __( 'Toegang geweigerd', 'genius-commerce' ), 404 => __( 'Endpoint niet gevonden - controleer de URL', 'genius-commerce' ), 500 => __( 'Server fout bij Genius Commerce', 'genius-commerce' ), 502 => __( 'Genius Commerce server niet bereikbaar', 'genius-commerce' ), 503 => __( 'Genius Commerce service tijdelijk niet beschikbaar', 'genius-commerce' ), ); return $errors[ $code ] ?? sprintf( /* translators: %d: HTTP status code */ __( 'HTTP fout: %d', 'genius-commerce' ), $code ); } /** * Clear the connection test cache. * * @return void */ public static function clear_connection_cache(): void { delete_transient( 'genius_commerce_connection_test' ); } /** * Retrieve the webhook secret from Genius Commerce. * * The secret is automatically generated by the backend if it doesn't exist. * * @return array{success: bool, error?: string, secret?: string} */ public function get_webhook_secret(): array { $result = $this->request( 'GET', '/webhook-secret' ); if ( ! $result['success'] ) { Genius_Commerce::log( sprintf( 'Failed to get webhook secret: %s', $result['error'] ?? 'Unknown error' ), 'error' ); return $result; } // Extract secret from response. if ( isset( $result['data']['secret'] ) ) { return array( 'success' => true, 'secret' => $result['data']['secret'], ); } return array( 'success' => false, 'error' => __( 'Ongeldig antwoord van server', 'genius-commerce' ), ); } /** * Fetch and store the webhook secret. * * @return bool True if secret was successfully fetched and stored. */ public function fetch_and_store_webhook_secret(): bool { $result = $this->get_webhook_secret(); if ( ! $result['success'] || empty( $result['secret'] ) ) { delete_option( 'genius_commerce_webhook_secret' ); return false; } // Store the secret securely. update_option( 'genius_commerce_webhook_secret', $result['secret'], false ); Genius_Commerce::log( 'Webhook secret successfully fetched and stored', 'info' ); return true; } /** * Check if webhook secret is available. * * @return bool True if secret is stored. */ public static function has_webhook_secret(): bool { $secret = get_option( 'genius_commerce_webhook_secret', '' ); return ! empty( $secret ); } /** * Get the stored webhook secret. * * @return string|null The webhook secret or null if not available. */ public static function get_stored_webhook_secret(): ?string { $secret = get_option( 'genius_commerce_webhook_secret', '' ); return ! empty( $secret ) ? $secret : null; } }