'Order aangemaakt', 'order.updated' => 'Order gewijzigd', 'order.deleted' => 'Order verwijderd', 'product.created' => 'Product aangemaakt', 'product.updated' => 'Product gewijzigd', 'product.deleted' => 'Product verwijderd', 'customer.created' => 'Klant aangemaakt', 'customer.updated' => 'Klant gewijzigd', ); /** * Webhook topic groups. * * @var array */ public const TOPIC_GROUPS = array( 'orders' => array( 'order.created', 'order.updated', 'order.deleted' ), 'products' => array( 'product.created', 'product.updated', 'product.deleted' ), 'customers' => array( 'customer.created', 'customer.updated' ), ); /** * Group labels (Dutch). * * @var array */ public const GROUP_LABELS = array( 'orders' => 'Orders', 'products' => 'Producten', 'customers' => 'Klanten', ); /** * Option key for storing registered webhook IDs. * * @var string */ private const OPTION_WEBHOOKS = 'genius_commerce_registered_webhooks'; /** * Action hook used by `wp_schedule_single_event` for the self-healing * registration (see {@see maybe_schedule_bootstrap()}). */ public const BOOTSTRAP_ACTION = 'genius_commerce_bootstrap_webhooks'; /** * Transient that throttles the self-healing registration. A store that * cannot reach the backend must not retry on every admin page load. */ private const BOOTSTRAP_LOCK = 'genius_commerce_webhook_bootstrap_lock'; /** * Throttle window for the self-healing registration (seconds). */ private const BOOTSTRAP_LOCK_TTL = 600; /** * Whether this store is coupled but has no working webhooks. * * True right after the één-klik-koppeling: the key is stored, but nothing * fetched the webhook secret or created the WooCommerce webhooks yet. * * @return bool */ public static function needs_bootstrap(): bool { if ( ! GC_API_Key::is_configured() ) { return false; } return ! GC_API_Client::has_webhook_secret() || ! self::has_registered_webhooks(); } /** * Queue a self-healing registration when the store needs one. * * Runs out-of-band on WP-cron so neither an admin page load nor an * incoming Genius Commerce API call waits on two HTTP round-trips. The * transient throttles retries; it is cleared on success so a later * "Ontkoppelen" → "Verbinden" heals immediately instead of after the TTL. * * @return void */ public static function maybe_schedule_bootstrap(): void { if ( ! self::needs_bootstrap() ) { return; } if ( get_transient( self::BOOTSTRAP_LOCK ) ) { return; } if ( function_exists( 'wp_next_scheduled' ) && false !== wp_next_scheduled( self::BOOTSTRAP_ACTION ) ) { return; } set_transient( self::BOOTSTRAP_LOCK, 1, self::BOOTSTRAP_LOCK_TTL ); if ( function_exists( 'wp_schedule_single_event' ) ) { wp_schedule_single_event( time() + 1, self::BOOTSTRAP_ACTION ); } } /** * Drop the retry throttle so the next trigger heals immediately. * * Called on (re)connect and disconnect: the store's situation changed, so * a throttle set by an earlier failed attempt is no longer meaningful. * * @return void */ public static function clear_bootstrap_throttle(): void { delete_transient( self::BOOTSTRAP_LOCK ); } /** * Fetch the webhook secret and register the webhooks. * * The handler behind {@see BOOTSTRAP_ACTION}, and the shared path for the * "Test Verbinding"-knop. Safe to call when nothing is missing — it exits * before doing any work. * * @return array{success: bool, registered: int, errors: array} */ public static function run_bootstrap(): array { if ( ! self::needs_bootstrap() ) { return array( 'success' => true, 'registered' => self::get_webhook_count(), 'errors' => array(), ); } $client = new GC_API_Client(); if ( ! GC_API_Client::has_webhook_secret() && ! $client->fetch_and_store_webhook_secret() ) { Genius_Commerce::log( 'Webhook bootstrap aborted: could not fetch webhook secret', 'warning' ); return array( 'success' => false, 'registered' => 0, 'errors' => array( __( 'Webhook secret kon niet worden opgehaald.', 'genius-commerce' ) ), ); } $manager = new self(); $result = $manager->register_webhooks(); if ( $result['success'] ) { delete_transient( self::BOOTSTRAP_LOCK ); } return $result; } /** * Owner to attach the WooCommerce webhooks to. * * WooCommerce builds the webhook payload through the REST API using this * user's capabilities, so user 0 (WP-cron, REST) would deliver empty * payloads. Fall back to a shop manager when there is no current user. * * @return int User ID (0 when the site has no capable user at all). */ private static function resolve_webhook_user_id(): int { $current = get_current_user_id(); if ( $current && user_can( $current, 'manage_woocommerce' ) ) { return $current; } $capable = get_users( array( 'capability' => 'manage_woocommerce', 'number' => 1, 'orderby' => 'ID', 'order' => 'ASC', 'fields' => 'ID', ) ); return ! empty( $capable ) ? (int) $capable[0] : $current; } /** * Register all webhooks with WooCommerce. * * @return array{success: bool, registered: int, errors: array} */ public function register_webhooks(): array { // Check if webhook secret is available. $secret = GC_API_Client::get_stored_webhook_secret(); if ( empty( $secret ) ) { Genius_Commerce::log( 'Cannot register webhooks: webhook secret not available', 'warning' ); return array( 'success' => false, 'registered' => 0, 'errors' => array( __( 'Webhook secret niet beschikbaar. Test eerst de verbinding.', 'genius-commerce' ) ), ); } // Get delivery URL. $delivery_url = $this->get_delivery_url(); if ( empty( $delivery_url ) ) { Genius_Commerce::log( 'Cannot register webhooks: delivery URL not configured', 'error' ); return array( 'success' => false, 'registered' => 0, 'errors' => array( __( 'Backend URL niet geconfigureerd.', 'genius-commerce' ) ), ); } // Check for WooCommerce webhook class. if ( ! class_exists( 'WC_Webhook' ) ) { Genius_Commerce::log( 'Cannot register webhooks: WC_Webhook class not found', 'error' ); return array( 'success' => false, 'registered' => 0, 'errors' => array( __( 'WooCommerce webhooks niet beschikbaar.', 'genius-commerce' ) ), ); } // Clean up any existing webhooks first. $this->cleanup_webhooks(); $registered = array(); $errors = array(); foreach ( self::WEBHOOK_TOPICS as $topic ) { try { $webhook = new \WC_Webhook(); $webhook->set_name( 'Genius Commerce - ' . $topic ); $webhook->set_topic( $topic ); $webhook->set_delivery_url( $delivery_url ); $webhook->set_secret( $secret ); $webhook->set_status( 'active' ); $webhook->set_user_id( self::resolve_webhook_user_id() ); $webhook->save(); if ( $webhook->get_id() ) { $registered[] = array( 'id' => $webhook->get_id(), 'topic' => $topic, ); Genius_Commerce::log( sprintf( 'Webhook registered: %s (ID: %d)', $topic, $webhook->get_id() ), 'info' ); } else { $errors[] = sprintf( /* translators: %s: webhook topic */ __( 'Kon webhook niet aanmaken: %s', 'genius-commerce' ), $topic ); } } catch ( \Exception $e ) { $errors[] = sprintf( /* translators: 1: webhook topic, 2: error message */ __( 'Fout bij aanmaken webhook %1$s: %2$s', 'genius-commerce' ), $topic, $e->getMessage() ); Genius_Commerce::log( sprintf( 'Error registering webhook %s: %s', $topic, $e->getMessage() ), 'error' ); } } // Store registered webhook IDs. if ( ! empty( $registered ) ) { update_option( self::OPTION_WEBHOOKS, $registered, false ); } $success = count( $registered ) === count( self::WEBHOOK_TOPICS ); Genius_Commerce::log( sprintf( 'Webhook registration complete: %d/%d registered', count( $registered ), count( self::WEBHOOK_TOPICS ) ), $success ? 'info' : 'warning' ); return array( 'success' => $success, 'registered' => count( $registered ), 'errors' => $errors, ); } /** * Cleanup all registered webhooks. * * @return int Number of webhooks removed. */ public function cleanup_webhooks(): int { $webhooks = get_option( self::OPTION_WEBHOOKS, array() ); $removed = 0; if ( empty( $webhooks ) || ! is_array( $webhooks ) ) { return 0; } foreach ( $webhooks as $webhook_data ) { if ( ! isset( $webhook_data['id'] ) ) { continue; } try { $webhook = wc_get_webhook( $webhook_data['id'] ); if ( $webhook ) { $webhook->delete( true ); $removed++; Genius_Commerce::log( sprintf( 'Webhook removed: %s (ID: %d)', $webhook_data['topic'] ?? 'unknown', $webhook_data['id'] ), 'info' ); } } catch ( \Exception $e ) { Genius_Commerce::log( sprintf( 'Error removing webhook %d: %s', $webhook_data['id'], $e->getMessage() ), 'error' ); } } // Clear stored webhook IDs. delete_option( self::OPTION_WEBHOOKS ); Genius_Commerce::log( sprintf( 'Webhook cleanup complete: %d removed', $removed ), 'info' ); return $removed; } /** * Get the delivery URL for webhooks. * * Constructs the webhook endpoint URL from the configured backend URL. * * @return string|null Delivery URL or null if not configured. */ private function get_delivery_url(): ?string { $backend_url = GC_API_Client::get_backend_url(); if ( empty( $backend_url ) ) { return null; } // Backend URL format: https://example.com/api/integrations/woocommerce // Webhook URL should be: https://example.com/api/webhooks/woocommerce // Replace /integrations/woocommerce with /webhooks/woocommerce. $delivery_url = preg_replace( '#/integrations/woocommerce/?$#', '/webhooks/woocommerce', rtrim( $backend_url, '/' ) ); return $delivery_url; } /** * Get list of registered webhooks with their status. * * @return array Array of webhook data. */ public function get_registered_webhooks(): array { $stored_webhooks = get_option( self::OPTION_WEBHOOKS, array() ); $webhooks = array(); if ( empty( $stored_webhooks ) || ! is_array( $stored_webhooks ) ) { return array(); } foreach ( $stored_webhooks as $webhook_data ) { if ( ! isset( $webhook_data['id'] ) ) { continue; } try { $webhook = wc_get_webhook( $webhook_data['id'] ); if ( $webhook ) { $webhooks[] = array( 'id' => $webhook->get_id(), 'topic' => $webhook->get_topic(), 'status' => $webhook->get_status(), 'delivery_url' => $webhook->get_delivery_url(), 'failure_count' => $webhook->get_failure_count(), 'date_modified' => $webhook->get_date_modified() ? $webhook->get_date_modified()->format( 'c' ) : null, ); } } catch ( \Exception $e ) { // Webhook may have been deleted manually. continue; } } return $webhooks; } /** * Check if webhooks are registered. * * @return bool True if webhooks are registered. */ public static function has_registered_webhooks(): bool { $webhooks = get_option( self::OPTION_WEBHOOKS, array() ); return ! empty( $webhooks ) && is_array( $webhooks ); } /** * Update the status of a specific webhook. * * @param string $topic Webhook topic. * @param string $status New status ('active' or 'paused'). * @return bool True if updated successfully. */ public function update_webhook_status( string $topic, string $status ): bool { if ( ! in_array( $topic, self::WEBHOOK_TOPICS, true ) ) { return false; } if ( ! in_array( $status, array( 'active', 'paused' ), true ) ) { return false; } $stored_webhooks = get_option( self::OPTION_WEBHOOKS, array() ); foreach ( $stored_webhooks as $webhook_data ) { if ( ! isset( $webhook_data['id'], $webhook_data['topic'] ) ) { continue; } if ( $webhook_data['topic'] !== $topic ) { continue; } try { $webhook = wc_get_webhook( $webhook_data['id'] ); if ( $webhook ) { $webhook->set_status( $status ); $webhook->save(); Genius_Commerce::log( sprintf( 'Webhook status updated: %s -> %s', $topic, $status ), 'info' ); return true; } } catch ( \Exception $e ) { Genius_Commerce::log( sprintf( 'Error updating webhook %s: %s', $topic, $e->getMessage() ), 'error' ); return false; } } return false; } /** * Update status for all webhooks in a group. * * @param string $group Group name ('orders', 'products', 'customers'). * @param string $status New status ('active' or 'paused'). * @return int Number of webhooks updated. */ public function update_group_status( string $group, string $status ): int { if ( ! isset( self::TOPIC_GROUPS[ $group ] ) ) { return 0; } $updated = 0; foreach ( self::TOPIC_GROUPS[ $group ] as $topic ) { if ( $this->update_webhook_status( $topic, $status ) ) { $updated++; } } return $updated; } /** * Get count of registered webhooks. * * @return int Number of registered webhooks. */ public static function get_webhook_count(): int { $webhooks = get_option( self::OPTION_WEBHOOKS, array() ); return is_array( $webhooks ) ? count( $webhooks ) : 0; } /** * Get webhooks with problems (paused or with failures). * * @return array Array of problematic webhooks. */ public function get_problematic_webhooks(): array { $webhooks = $this->get_registered_webhooks(); $problematic = array(); foreach ( $webhooks as $webhook ) { if ( 'paused' === $webhook['status'] || $webhook['failure_count'] > 0 ) { $problematic[] = $webhook; } } return $problematic; } /** * Get the WooCommerce admin URL for a webhook. * * @param int $webhook_id Webhook ID. * @return string Admin URL. */ public static function get_webhook_admin_url( int $webhook_id ): string { return admin_url( 'admin.php?page=wc-settings&tab=advanced§ion=webhooks&edit-webhook=' . $webhook_id ); } /** * Get webhook status summary for display. * * @return array Summary with counts. */ public function get_status_summary(): array { $webhooks = $this->get_registered_webhooks(); $summary = array( 'total' => count( $webhooks ), 'active' => 0, 'paused' => 0, 'disabled' => 0, 'failures' => 0, ); foreach ( $webhooks as $webhook ) { if ( isset( $summary[ $webhook['status'] ] ) ) { $summary[ $webhook['status'] ]++; } if ( $webhook['failure_count'] > 0 ) { $summary['failures']++; } } return $summary; } }