init_hooks(); } /** * Prevent cloning. */ private function __clone() {} /** * Prevent unserializing. * * @throws \Exception When trying to unserialize. */ public function __wakeup() { throw new \Exception( 'Cannot unserialize singleton' ); } /** * Initialize hooks. * * @return void */ private function init_hooks(): void { // Declare HPOS compatibility. add_action( 'before_woocommerce_init', array( $this, 'declare_hpos_compatibility' ) ); // Ensure the IP-allowlist option exists with autoload=no on every // request — runs on the active hook used by WordPress' option layer // so it's harmless when the option already exists. add_action( 'init', array( $this, 'ensure_option_defaults' ), -1 ); // Initialize components when WordPress is fully loaded. add_action( 'init', array( $this, 'init' ), 0 ); // Register REST API routes. add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) ); // Story 8.3: header-piggyback refresh handler. `GC_API_Client` // schedules this when it sees a newer `X-GC-Scope-Version` than // what's cached; WP-cron picks it up out-of-band. add_action( GC_Scopes::REFRESH_ACTION, static function (): void { GC_Scopes::force_refresh(); } ); // Self-healing webhook registration. The één-klik-koppeling leaves the // store coupled but without webhooks (the backend config only exists // after the merchant finishes linking in Genius Commerce), so both an // admin page load and the first authenticated backend call queue this. add_action( GC_Webhook_Manager::BOOTSTRAP_ACTION, static function (): void { GC_Webhook_Manager::run_bootstrap(); } ); // Admin-only hooks. if ( is_admin() ) { $this->settings = new GC_Settings(); add_action( 'admin_init', array( $this, 'admin_init' ) ); // Initialize mapping admin page. require_once GENIUS_COMMERCE_PLUGIN_PATH . 'includes/admin/GC_Mapping_Admin.php'; $this->mapping_admin = new Admin\GC_Mapping_Admin(); } } /** * Ensure plugin options exist with the desired autoload setting. * * `genius_commerce_allowed_ips` is read on every authenticated REST request but never * on the public frontend — autoload=no keeps it out of the always-cached * options bundle. `add_option` is a no-op when the option already exists. * * @return void */ public function ensure_option_defaults(): void { add_option( GC_IP_Allowlist::OPTION_ALLOWED_IPS, '', '', 'no' ); add_option( GC_IP_Allowlist::OPTION_TRUST_XFF, 'no', '', 'no' ); // Story 8.3: scope-enforcement is opt-in so the cutover from // "all scopes allowed" to "config-derived scopes" doesn't break // freshly upgraded installs (beslissing D). add_option( GC_Scopes::FLAG_OPTION, 'no', '', 'no' ); } /** * Declare High-Performance Order Storage (HPOS) compatibility. * * @return void */ public function declare_hpos_compatibility(): void { if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) { \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', GENIUS_COMMERCE_PLUGIN_FILE, true ); } } /** * Initialize plugin components. * * @return void */ public function init(): void { // Initialize discovery engine. $this->init_discovery(); // Initialize mapping configuration system. $this->init_mapping(); // Initialize frontend integrations. if ( ! is_admin() ) { $this->discount_integration = new GC_Discount_Integration(); $this->delivery_integration = new GC_Delivery_Integration(); } } /** * Register REST API routes. * * @return void */ public function register_rest_routes(): void { // Include REST API controllers. require_once GENIUS_COMMERCE_PLUGIN_PATH . 'includes/rest-api/GC_REST_Controller.php'; // Shared permission callback: IP allowlist first, then API key. Mirrors // GC_REST_Controller::check_api_key exactly — same WP_Error shapes and // the same rejected-IP log line — so /debug and /system-info surface a // 403 FORBIDDEN_IP (not a bare 401) and an admin sees *why* the call was // blocked. A bare `return false` here returned 401 with no log, which // read as "wrong API key" and hid IP-allowlist rejections (story 8.1). $auth_check = function ( \WP_REST_Request $req ) { $source_ip = GC_IP_Allowlist::get_source_ip(); if ( ! GC_IP_Allowlist::is_allowed( $source_ip ) ) { self::log( sprintf( 'IP %s rejected by allowlist', $source_ip ), 'warning' ); return new \WP_Error( 'gc_ip_not_allowed', __( 'Toegang vanaf dit IP-adres is niet toegestaan.', 'genius-commerce' ), array( 'status' => 403, 'code' => 'FORBIDDEN_IP', ) ); } $api_key = $req->get_header( 'X-GC-API-Key' ); if ( empty( $api_key ) ) { return new \WP_Error( 'gc_missing_api_key', __( 'API key is verplicht. Voeg de X-GC-API-Key header toe.', 'genius-commerce' ), array( 'status' => 401 ) ); } if ( ! GC_API_Key::validate( $api_key ) ) { return new \WP_Error( 'gc_invalid_api_key', __( 'Ongeldige API key.', 'genius-commerce' ), array( 'status' => 401 ) ); } return true; }; // Debug/health endpoint - requires API key authentication. register_rest_route( 'genius-commerce/v1', '/debug', array( 'methods' => 'GET', 'callback' => array( $this, 'handle_debug_request' ), 'permission_callback' => $auth_check, ) ); // System info endpoint - authenticated, returns active plugins for composite detection. register_rest_route( 'genius-commerce/v1', '/system-info', array( 'methods' => 'GET', 'callback' => array( $this, 'handle_system_info_request' ), 'permission_callback' => $auth_check, ) ); // Products controller (handles both GET /products and POST /products/update). if ( file_exists( GENIUS_COMMERCE_PLUGIN_PATH . 'includes/rest-api/GC_REST_Products.php' ) ) { require_once GENIUS_COMMERCE_PLUGIN_PATH . 'includes/rest-api/GC_REST_Products.php'; $products_controller = new REST_API\GC_REST_Products(); $products_controller->register_routes(); } // Orders controller. if ( file_exists( GENIUS_COMMERCE_PLUGIN_PATH . 'includes/rest-api/GC_REST_Orders.php' ) ) { require_once GENIUS_COMMERCE_PLUGIN_PATH . 'includes/rest-api/GC_REST_Orders.php'; $orders_controller = new REST_API\GC_REST_Orders(); $orders_controller->register_routes(); } // Customers controller. if ( file_exists( GENIUS_COMMERCE_PLUGIN_PATH . 'includes/rest-api/GC_REST_Customers.php' ) ) { require_once GENIUS_COMMERCE_PLUGIN_PATH . 'includes/rest-api/GC_REST_Customers.php'; $customers_controller = new REST_API\GC_REST_Customers(); $customers_controller->register_routes(); } // Categories controller. if ( file_exists( GENIUS_COMMERCE_PLUGIN_PATH . 'includes/rest-api/GC_REST_Categories.php' ) ) { require_once GENIUS_COMMERCE_PLUGIN_PATH . 'includes/rest-api/GC_REST_Categories.php'; $categories_controller = new REST_API\GC_REST_Categories(); $categories_controller->register_routes(); } // Brands controller (requires Perfect WooCommerce Brands plugin). if ( file_exists( GENIUS_COMMERCE_PLUGIN_PATH . 'includes/rest-api/GC_REST_Brands.php' ) ) { require_once GENIUS_COMMERCE_PLUGIN_PATH . 'includes/rest-api/GC_REST_Brands.php'; $brands_controller = new REST_API\GC_REST_Brands(); $brands_controller->register_routes(); } // Attributes controller (for variable products). if ( file_exists( GENIUS_COMMERCE_PLUGIN_PATH . 'includes/rest-api/GC_REST_Attributes.php' ) ) { require_once GENIUS_COMMERCE_PLUGIN_PATH . 'includes/rest-api/GC_REST_Attributes.php'; $attributes_controller = new REST_API\GC_REST_Attributes(); $attributes_controller->register_routes(); } // Variations controller (for variable products). if ( file_exists( GENIUS_COMMERCE_PLUGIN_PATH . 'includes/rest-api/GC_REST_Variations.php' ) ) { require_once GENIUS_COMMERCE_PLUGIN_PATH . 'includes/rest-api/GC_REST_Variations.php'; $variations_controller = new REST_API\GC_REST_Variations(); $variations_controller->register_routes(); } // Invoices controller (requires PDF Invoices & Packing Slips plugin). if ( file_exists( GENIUS_COMMERCE_PLUGIN_PATH . 'includes/rest-api/GC_REST_Invoices.php' ) ) { require_once GENIUS_COMMERCE_PLUGIN_PATH . 'includes/rest-api/GC_REST_Invoices.php'; $invoices_controller = new REST_API\GC_REST_Invoices(); $invoices_controller->register_routes(); } // Taxonomies controller (for discovering custom taxonomies like suppliers, designers, etc.). if ( file_exists( GENIUS_COMMERCE_PLUGIN_PATH . 'includes/rest-api/GC_REST_Taxonomies.php' ) ) { require_once GENIUS_COMMERCE_PLUGIN_PATH . 'includes/rest-api/GC_REST_Taxonomies.php'; $taxonomies_controller = new REST_API\GC_REST_Taxonomies(); $taxonomies_controller->register_routes(); } // Mapping controller (for Smart Sync mapping configuration). if ( file_exists( GENIUS_COMMERCE_PLUGIN_PATH . 'includes/rest-api/GC_REST_Mapping.php' ) ) { require_once GENIUS_COMMERCE_PLUGIN_PATH . 'includes/rest-api/GC_REST_Mapping.php'; $mapping_controller = new REST_API\GC_REST_Mapping(); $mapping_controller->register_routes(); } // B2BKing controller (exposes B2BKing groups and rules if plugin is active). if ( file_exists( GENIUS_COMMERCE_PLUGIN_PATH . 'includes/rest-api/GC_REST_B2BKing.php' ) ) { require_once GENIUS_COMMERCE_PLUGIN_PATH . 'includes/rest-api/GC_REST_B2BKing.php'; $b2bking_controller = new REST_API\GC_REST_B2BKing(); $b2bking_controller->register_routes(); } // Coupons controller (push-only sync target for Genius coupons, Project 36). if ( file_exists( GENIUS_COMMERCE_PLUGIN_PATH . 'includes/rest-api/GC_REST_Coupons.php' ) ) { require_once GENIUS_COMMERCE_PLUGIN_PATH . 'includes/rest-api/GC_REST_Coupons.php'; $coupons_controller = new REST_API\GC_REST_Coupons(); $coupons_controller->register_routes(); } } /** * Initialize admin components. * * @return void */ public function admin_init(): void { // Future: Initialize settings page. // Future: Initialize admin assets. } /** * Initialize field discovery engine. * * @return void */ private function init_discovery(): void { require_once GENIUS_COMMERCE_PLUGIN_PATH . 'includes/discovery/GC_Scanner_Interface.php'; require_once GENIUS_COMMERCE_PLUGIN_PATH . 'includes/discovery/GC_Field_Discovery.php'; require_once GENIUS_COMMERCE_PLUGIN_PATH . 'includes/discovery/GC_Taxonomy_Scanner.php'; require_once GENIUS_COMMERCE_PLUGIN_PATH . 'includes/discovery/GC_Meta_Scanner.php'; require_once GENIUS_COMMERCE_PLUGIN_PATH . 'includes/discovery/GC_Attribute_Scanner.php'; $this->field_discovery = new Discovery\GC_Field_Discovery(); $this->field_discovery->register_scanner( new Discovery\GC_Taxonomy_Scanner() ); $this->field_discovery->register_scanner( new Discovery\GC_Meta_Scanner() ); $this->field_discovery->register_scanner( new Discovery\GC_Attribute_Scanner() ); // Admin action for manual refresh. add_action( 'admin_post_genius_commerce_refresh_discovery', function () { if ( ! current_user_can( 'manage_options' ) ) { wp_die( 'Unauthorized' ); } check_admin_referer( 'genius_commerce_refresh_discovery' ); $this->field_discovery->discover( true ); wp_safe_redirect( admin_url( 'admin.php?page=wc-settings&tab=genius_commerce&subtab=mapping&refreshed=1' ) ); exit; } ); } /** * Initialize mapping configuration system. * * @return void */ private function init_mapping(): void { require_once GENIUS_COMMERCE_PLUGIN_PATH . 'includes/mapping/GC_Mapping_Defaults.php'; require_once GENIUS_COMMERCE_PLUGIN_PATH . 'includes/mapping/GC_Sync_Mapping.php'; require_once GENIUS_COMMERCE_PLUGIN_PATH . 'includes/mapping/GC_Mapping_Resolver.php'; } /** * Get the field discovery engine instance. * * @return Discovery\GC_Field_Discovery|null */ public function get_field_discovery(): ?Discovery\GC_Field_Discovery { return $this->field_discovery; } /** * Get the plugin path. * * @return string */ public function plugin_path(): string { return GENIUS_COMMERCE_PLUGIN_PATH; } /** * Get the plugin URL. * * @return string */ public function plugin_url(): string { return GENIUS_COMMERCE_PLUGIN_URL; } public function handle_debug_request( \WP_REST_Request $request ): \WP_REST_Response { global $wpdb; // Get optional test parameters $test_page = (int) $request->get_param( 'page' ) ?: 1; $test_per_page = (int) $request->get_param( 'per_page' ) ?: 10; $test_status = $request->get_param( 'status' ); $test_modified_after = $request->get_param( 'modified_after' ); // Get order count efficiently using direct SQL. $order_count = 0; $count_method = 'unknown'; // Table names cannot be bound as prepare() placeholders; esc_sql() is the // documented escape for identifiers built from $wpdb->prefix. $hpos_table = esc_sql( $wpdb->prefix . 'wc_orders' ); try { // Try HPOS table first. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table identifier from $wpdb->prefix (esc_sql), not user input; uncached by design. if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $hpos_table ) ) === $hpos_table ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table identifier from $wpdb->prefix (esc_sql), not user input; uncached by design. $order_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$hpos_table}`" ); $count_method = 'hpos_direct_sql'; } else { // Fall back to posts table. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared read with no core API equivalent; sync correctness requires uncached data. $order_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s", 'shop_order' ) ); $count_method = 'posts_direct_sql'; } } catch ( \Exception $e ) { $count_method = 'error: ' . $e->getMessage(); } // Count orders by status. $status_counts = array(); try { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table identifier from $wpdb->prefix (esc_sql), not user input; uncached by design. if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $hpos_table ) ) === $hpos_table ) { // HPOS mode - query wc_orders table. // A table identifier cannot be bound as a prepare() placeholder; // $hpos_table is built from $wpdb->prefix and passed through esc_sql(). // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $results = $wpdb->get_results( "SELECT status, COUNT(*) as count FROM `{$hpos_table}` GROUP BY status ORDER BY count DESC", ARRAY_A ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching foreach ( $results as $row ) { $status_counts[ $row['status'] ] = (int) $row['count']; } } else { // Legacy mode - query posts table. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared read with no core API equivalent; sync correctness requires uncached data. $results = $wpdb->get_results( $wpdb->prepare( "SELECT post_status as status, COUNT(*) as count FROM {$wpdb->posts} WHERE post_type = %s GROUP BY post_status ORDER BY count DESC", 'shop_order' ), ARRAY_A ); foreach ( $results as $row ) { $status_counts[ $row['status'] ] = (int) $row['count']; } } } catch ( \Exception $e ) { $status_counts = array( 'error' => $e->getMessage() ); } // Build query args $query_args = array( 'limit' => $test_per_page, 'page' => $test_page, 'paginate' => true, 'return' => 'ids', 'orderby' => 'date', 'order' => 'DESC', ); // Add status filter if provided if ( ! empty( $test_status ) ) { $statuses = array_map( 'trim', explode( ',', $test_status ) ); $query_args['status'] = array_map( function ( $s ) { return strpos( $s, 'wc-' ) === 0 ? $s : 'wc-' . $s; }, $statuses ); } // Add modified_after filter if provided (same as orders endpoint) if ( ! empty( $test_modified_after ) ) { $query_args['date_modified'] = '>=' . sanitize_text_field( $test_modified_after ); } // Test paginated query with specified page $test_query_result = 'not_tested'; $test_query_error = null; $test_order_ids = array(); try { $test_orders = wc_get_orders( $query_args ); $test_order_ids = $test_orders->orders; $test_query_result = array( 'success' => true, 'page' => $test_page, 'per_page' => $test_per_page, 'status' => $test_status ?: 'all', 'modified_after' => $test_modified_after ?: 'none', 'sample_size' => count( $test_orders->orders ), 'total' => $test_orders->total, 'order_ids' => array_slice( $test_order_ids, 0, 10 ), // Only show first 10 IDs ); } catch ( \Exception $e ) { $test_query_result = 'failed'; $test_query_error = $e->getMessage(); } // Test preparing orders for response (this is where the crash might happen) $prepare_test = array(); $prepare_test_limit = min( count( $test_order_ids ), 10 ); // Only test first 10 for ( $i = 0; $i < $prepare_test_limit; $i++ ) { $order_id = $test_order_ids[ $i ]; try { $order = wc_get_order( $order_id ); if ( ! $order ) { $prepare_test[] = array( 'order_id' => $order_id, 'status' => 'error', 'error' => 'Order not found', ); continue; } // Try to access order data that might cause issues $items_count = count( $order->get_items() ); $has_billing = ! empty( $order->get_billing_first_name() ); $prepare_test[] = array( 'order_id' => $order_id, 'status' => 'ok', 'order_status' => $order->get_status(), 'items_count' => $items_count, 'has_billing' => $has_billing, ); } catch ( \Exception $e ) { $prepare_test[] = array( 'order_id' => $order_id, 'status' => 'error', 'error' => $e->getMessage(), ); } catch ( \Error $e ) { $prepare_test[] = array( 'order_id' => $order_id, 'status' => 'fatal_error', 'error' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine(), ); } } return new \WP_REST_Response( array( 'plugin_version' => GENIUS_COMMERCE_VERSION, 'php_version' => PHP_VERSION, 'wordpress_version' => get_bloginfo( 'version' ), 'woocommerce_version' => defined( 'WC_VERSION' ) ? WC_VERSION : 'not_installed', 'memory_limit' => ini_get( 'memory_limit' ), 'max_execution_time' => ini_get( 'max_execution_time' ), 'order_count' => $order_count, 'status_counts' => $status_counts, 'count_method' => $count_method, 'hpos_enabled' => class_exists( '\Automattic\WooCommerce\Utilities\OrderUtil' ) && \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled(), 'test_query' => $test_query_result, 'test_query_error' => $test_query_error, 'prepare_test' => $prepare_test, 'timestamp' => current_time( 'c' ), ), 200 ); } /** * Handle system-info request (authenticated). * * Returns active plugins and system capabilities for Genius Commerce sync. * * @param \WP_REST_Request $request The request object. * @return \WP_REST_Response Response object. */ public function handle_system_info_request( \WP_REST_Request $request ): \WP_REST_Response { $active_plugins_raw = get_option( 'active_plugins', array() ); $plugins_data = array(); foreach ( $active_plugins_raw as $plugin_path ) { // Plugin path format: "plugin-slug/plugin-file.php" or "plugin-file.php". $parts = explode( '/', $plugin_path ); $slug = count( $parts ) > 1 ? $parts[0] : pathinfo( $parts[0], PATHINFO_FILENAME ); $plugins_data[] = array( 'slug' => $slug, 'plugin' => $plugin_path, ); } // Build tax class map: slug → rate from base tax rates $tax_classes_data = array(); if ( class_exists( 'WC_Tax' ) ) { $slugs = array_merge( array( '' ), \WC_Tax::get_tax_class_slugs() ); foreach ( $slugs as $slug ) { $rates = \WC_Tax::get_base_tax_rates( $slug ); $rate = 0; if ( ! empty( $rates ) ) { $first = reset( $rates ); $rate = (float) $first['rate']; } $tax_classes_data[] = array( 'slug' => $slug === '' ? 'standard' : $slug, 'rate' => $rate, ); } } return new \WP_REST_Response( array( 'data' => array( 'active_plugins' => $plugins_data, 'woocommerce_version' => defined( 'WC_VERSION' ) ? WC_VERSION : null, 'plugin_version' => GENIUS_COMMERCE_VERSION, 'prices_include_tax' => get_option( 'woocommerce_prices_include_tax', 'yes' ) === 'yes', 'tax_classes' => $tax_classes_data, ), ), 200 ); } /** * Logger helper. * * Use the optional `$context` parameter for structured data that may * contain klant-PII (email, NAW, phone) — never interpolate PII into the * `$message`-string. `$context` runs through {@see GC_Log_Redactor::redact()} * so e-mailadressen / billing / shipping / telefoon worden vervangen door * `[REDACTED]` voor de waarde naar WooCommerce-logs gaat. * * Example: * ```php * // Bad — PII in string is NIET geredacteerd: * Genius_Commerce::log( "Customer {$email} synced", 'info' ); * * // Good — PII in $context wordt automatisch geredacteerd: * Genius_Commerce::log( 'Customer synced', 'info', array( 'email' => $email ) ); * ``` * * @param string $message Log message (must NOT contain PII; gebruik $context). * @param string $level Log level (debug, info, notice, warning, error, critical, alert, emergency). * @param array $context Optional structured context — PII-keys worden geredacteerd. * @return void */ public static function log( string $message, string $level = 'info', array $context = array() ): void { if ( 'yes' !== get_option( 'genius_commerce_enable_logging', 'no' ) ) { return; } if ( ! function_exists( 'wc_get_logger' ) ) { return; } $logger = wc_get_logger(); $log_context = array( 'source' => 'genius-commerce' ); if ( ! empty( $context ) ) { $redacted = GC_Log_Redactor::redact( $context ); $log_context = array_merge( $log_context, $redacted ); } $logger->log( $level, $message, $log_context ); } }