# onwebchat/3.9.1/includes/woocommerce-orders.php

AI Chatbot for WooCommerce &amp; Live Chat – onWebChat, version 3.9.1. 524 lines.

- Page: https://pluginprobe.com/plugins/onwebchat/3.9.1/code/includes/woocommerce-orders.php
- Raw: https://pluginprobe.com/plugins/onwebchat/3.9.1/raw/includes/woocommerce-orders.php
- Modified: 2026-07-08T10:45:32+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/onwebchat/3.9.1/code/includes/woocommerce-orders.php#L10-L20`.

```php
<?php
/**
 * WooCommerce Order Lookup Module (live order status for the onWebChat AI chatbot)
 *
 * This is the OPPOSITE direction from woocommerce-sync.php:
 *   - woocommerce-sync.php : this plugin pushes catalog data TO onWebChat (plugin -> server).
 *   - woocommerce-orders.php: onWebChat asks THIS store for a single order, live, during a chat
 *     (server -> plugin), when the AI decides it needs "where is my order?" data.
 *
 * Why a separate, real-time path (and not product sync / embeddings):
 *   Order status is per-customer, changes constantly and is sensitive. Putting it in the AI's training
 *   data risks leaking Customer A's order to Customer B. So it must be a scoped, verified, on-demand
 *   lookup instead.
 *
 * Security:
 *   - Exposes a REST endpoint: POST /wp-json/onwebchat/v1/order-lookup
 *   - Authenticated with HMAC-SHA256 over (timestamp.nonce.body) using the SAME shared secret that the
 *     product sync already established (onwebchat_wc_sync_secret). The onWebChat server holds the same
 *     secret and signs every request.
 *   - IDENTITY VERIFICATION IS DONE HERE: order data is only returned when the supplied email OR billing
 *     last name matches the order. A wrong/guessed identifier returns verified=false with no details,
 *     so the AI can never reveal one customer's order to another.
 */

if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}

class OnWebChat_WooCommerce_Orders {

    // Reject requests whose timestamp is more than this many seconds off (replay/clock-skew guard).
    private $max_timestamp_diff = 300; // 5 minutes

    private $use_testing_mode;

    public function __construct() {
        $this->use_testing_mode = defined('ONWEBCHAT_WC_TESTING_MODE') ? ONWEBCHAT_WC_TESTING_MODE : false;

        // The endpoint is always registered so the onWebChat server can reach it; the handler still
        // honours the on/off option below.
        add_action('rest_api_init', array($this, 'register_routes'));

        // Admin: toggle the feature on/off (saves the option + pushes config to onWebChat).
        add_action('wp_ajax_onwebchat_wc_save_order_lookup', array($this, 'ajax_save_order_lookup'));
    }

    /**
     * Register the REST route the onWebChat server calls to fetch a single order.
     */
    public function register_routes() {
        register_rest_route('onwebchat/v1', '/order-lookup', array(
            'methods'             => 'POST',
            'callback'            => array($this, 'handle_order_lookup'),
            // Auth is enforced inside the callback via HMAC; this just lets the request through.
            'permission_callback' => '__return_true',
        ));
    }

    /**
     * Verify the HMAC signature of an incoming server -> plugin request.
     * Scheme: signature = HMAC_SHA256(secret, timestamp . "." . nonce . "." . rawBody)
     *
     * @param WP_REST_Request $request
     * @return bool
     */
    private function verify_request($request) {
        $secret = get_option('onwebchat_wc_sync_secret');
        if (empty($secret)) {
            return false;
        }

        $timestamp = $request->get_header('x-owc-timestamp');
        $nonce     = $request->get_header('x-owc-nonce');
        $signature = $request->get_header('x-owc-signature');

        if (empty($timestamp) || empty($nonce) || empty($signature)) {
            return false;
        }

        // Freshness window (prevents replaying an old captured request).
        if (abs(time() - intval($timestamp)) > $this->max_timestamp_diff) {
            return false;
        }

        // Replay protection: a nonce may be used only once within the freshness window.
        $nonce_key = 'owc_ord_nonce_' . md5($nonce);
        if (get_transient($nonce_key)) {
            return false;
        }

        $body    = $request->get_body();
        $message = $timestamp . '.' . $nonce . '.' . $body;
        $expected = hash_hmac('sha256', $message, $secret);

        if (!hash_equals($expected, (string) $signature)) {
            return false;
        }

        set_transient($nonce_key, 1, $this->max_timestamp_diff);
        return true;
    }

    /**
     * Handle a live order lookup. Returns order status only when the caller proves the customer's
     * identity (email or billing last name matching the order).
     *
     * @param WP_REST_Request $request
     * @return WP_REST_Response
     */
    public function handle_order_lookup($request) {
        // Feature must be enabled by the merchant.
        if (!get_option('onwebchat_wc_order_lookup_enabled', false)) {
            return new WP_REST_Response(array('error' => 'disabled'), 403);
        }

        // WooCommerce must be available.
        if (!function_exists('wc_get_order')) {
            return new WP_REST_Response(array('error' => 'woocommerce_inactive'), 503);
        }

        // Authenticate.
        if (!$this->verify_request($request)) {
            return new WP_REST_Response(array('error' => 'unauthorized'), 401);
        }

        $params = $request->get_json_params();
        if (!is_array($params)) {
            $params = array();
        }

        $order_number = isset($params['order_number']) ? sanitize_text_field($params['order_number']) : '';
        $email        = isset($params['email']) ? sanitize_email($params['email']) : '';
        // last_name is accepted but is NOT a standalone key; the email is always required.
        $last_name    = isset($params['last_name']) ? sanitize_text_field($params['last_name']) : '';
        $action       = isset($params['action']) ? sanitize_text_field($params['action']) : '';

        // "My recent orders" for a signed-in customer: email-only, no order number. Safe because the
        // onWebChat server only ever sends a VERIFIED email here (it recomputed the identity hash this
        // plugin emitted for the logged-in customer), and the request itself is HMAC-authenticated.
        if ($action === 'recent_orders') {
            return $this->handle_recent_orders($email);
        }

        // Both the order number AND the email are required for verification.
        if (empty($order_number) || empty($email)) {
            return new WP_REST_Response(array('found' => false, 'verified' => false, 'error' => 'need_more_info'), 200);
        }

        // Anti-abuse: store-wide failed-attempt throttle. Catches scripted enumeration across many
        // different order numbers, which the per-order throttle below cannot see.
        $site_fail_key = 'owc_ord_fail_global';
        $site_fail = (int) get_transient($site_fail_key);
        if ($site_fail >= 20) {
            return new WP_REST_Response(array('found' => false, 'verified' => false, 'throttled' => true), 200);
        }

        $order = $this->find_order($order_number);
        if (!$order) {
            // Unknown order number: count it toward the store-wide throttle (order-number scanning) and
            // do not reveal whether it exists.
            set_transient($site_fail_key, $site_fail + 1, 10 * MINUTE_IN_SECONDS);
            return new WP_REST_Response(array('found' => false, 'verified' => false), 200);
        }

        // Anti-brute-force: also cap failed verification attempts per order number. Resets after the window.
        $fail_key = 'owc_ord_fail_' . md5((string) $order->get_id());
        $fail_count = (int) get_transient($fail_key);
        if ($fail_count >= 8) {
            return new WP_REST_Response(array('found' => true, 'verified' => false, 'throttled' => true), 200);
        }

        // Identity verification: the order's billing email must match (case-insensitive). The email is
        // the required key; a name alone can never unlock an order.
        $verified = false;
        $order_email = strtolower(trim((string) $order->get_billing_email()));
        if ($order_email !== '' && $order_email === strtolower(trim($email))) {
            $verified = true;
        }

        if (!$verified) {
            // Order exists but the email did not match: count the miss (per-order AND store-wide) and
            // return nothing sensitive.
            set_transient($fail_key, $fail_count + 1, 15 * MINUTE_IN_SECONDS);
            set_transient($site_fail_key, $site_fail + 1, 10 * MINUTE_IN_SECONDS);
            return new WP_REST_Response(array('found' => true, 'verified' => false), 200);
        }

        // Successful match: clear the per-order failed-attempt counter.
        delete_transient($fail_key);

        return new WP_REST_Response($this->build_order_response($order), 200);
    }

    /**
     * List the customer's most recent orders by email (action=recent_orders). Called (already
     * HMAC-authenticated) only with server-verified emails, so no per-order fail counting applies;
     * the response is always scoped to exactly that email.
     *
     * @param string $email
     * @return WP_REST_Response
     */
    private function handle_recent_orders($email) {
        if (empty($email)) {
            return new WP_REST_Response(array('found' => false, 'verified' => false, 'error' => 'need_more_info'), 200);
        }

        $wc_orders = wc_get_orders(array(
            'billing_email' => $email,
            'limit'         => 5,
            'orderby'       => 'date',
            'order'         => 'DESC',
            'type'          => 'shop_order', // exclude refund objects
        ));

        $orders = array();
        foreach ($wc_orders as $order) {
            // Never expose unfinished checkout drafts.
            if ($order->get_status() === 'checkout-draft') {
                continue;
            }
            $orders[] = $this->build_order_response($order);
        }

        if (empty($orders)) {
            return new WP_REST_Response(array('found' => false, 'verified' => true, 'orders' => array()), 200);
        }

        return new WP_REST_Response(array('found' => true, 'verified' => true, 'orders' => $orders), 200);
    }

    /**
     * Build the verified per-order payload in the shape the onWebChat server normalizer expects.
     * Used by both the single-order lookup and the recent-orders listing.
     *
     * @param WC_Order $order
     * @return array
     */
    private function build_order_response($order) {
        $items = array();
        foreach ($order->get_items() as $item) {
            $items[] = array(
                'name'     => $item->get_name(),
                'quantity' => $item->get_quantity(),
            );
            if (count($items) >= 20) {
                break;
            }
        }

        $status = $order->get_status(); // e.g. 'processing', 'completed'
        return array(
            'found'           => true,
            'verified'        => true,
            'order_number'    => (string) $order->get_order_number(),
            'status'          => $status,
            'status_label'    => function_exists('wc_get_order_status_name') ? wc_get_order_status_name($status) : $status,
            'date_created'    => $order->get_date_created() ? wc_format_datetime($order->get_date_created()) : '',
            'total'           => $order->get_total(),
            'currency'        => $order->get_currency(),
            'payment_method'  => $order->get_payment_method_title(),
            'shipping_method' => $order->get_shipping_method(),
            'customer_note'   => $order->get_customer_note(),
            'items'           => $items,
            'tracking'        => $this->get_tracking($order),
            'history'         => $this->get_history($order),
        );
    }

    /**
     * Customer-facing order notes as a status timeline: lets the AI explain how the order has
     * progressed, and merchants often paste carrier + tracking info into these notes. ONLY notes of
     * type "customer" are exposed (internal/private notes may hold merchant-only info). Oldest first,
     * capped at the 6 most recent.
     *
     * @param WC_Order $order
     * @return array
     */
    private function get_history($order) {
        $history = array();

        if (!function_exists('wc_get_order_notes')) {
            return $history;
        }

        $notes = wc_get_order_notes(array(
            'order_id' => $order->get_id(),
            'type'     => 'customer',
            'limit'    => 6,
        ));

        foreach (array_reverse($notes) as $note) {
            $history[] = array(
                'date'    => isset($note->date_created) && $note->date_created ? wc_format_datetime($note->date_created) : '',
                'status'  => '',
                'comment' => isset($note->content) ? (string) $note->content : '',
            );
        }

        return $history;
    }

    /**
     * Resolve an order number (which may be a custom/sequential number, possibly with a leading #)
     * to a WC_Order. Best-effort support for the common order-number plugins.
     *
     * @param string $order_number
     * @return WC_Order|false
     */
    private function find_order($order_number) {
        $order_number = trim($order_number);
        $clean = ltrim($order_number, '#');

        // Sequential Order Numbers Pro / Free.
        if (function_exists('wc_seq_order_number_pro')) {
            $id = wc_seq_order_number_pro()->find_order_by_order_number($order_number);
            if ($id) {
                $order = wc_get_order($id);
                if ($order) {
                    return $order;
                }
            }
        }

        // Let any other plugin map a display order number to an order ID.
        $filtered_id = apply_filters('onwebchat_resolve_order_number', 0, $order_number);
        if ($filtered_id) {
            $order = wc_get_order(intval($filtered_id));
            if ($order) {
                return $order;
            }
        }

        // Fall back to treating it as a numeric order ID.
        if (is_numeric($clean)) {
            $order = wc_get_order(intval($clean));
            if ($order) {
                return $order;
            }
        }

        return false;
    }

    /**
     * Best-effort tracking extraction from the popular shipment-tracking plugins.
     *
     * @param WC_Order $order
     * @return array
     */
    private function get_tracking($order) {
        $tracking = array();

        // WooCommerce Shipment Tracking (official) and compatible plugins.
        $items = $order->get_meta('_wc_shipment_tracking_items');
        if (is_array($items)) {
            foreach ($items as $it) {
                if (!is_array($it)) {
                    continue;
                }
                $carrier = '';
                if (!empty($it['tracking_provider'])) {
                    $carrier = $it['tracking_provider'];
                } elseif (!empty($it['custom_tracking_provider'])) {
                    $carrier = $it['custom_tracking_provider'];
                }
                $tracking[] = array(
                    'carrier' => $carrier,
                    'number'  => isset($it['tracking_number']) ? $it['tracking_number'] : '',
                    'url'     => isset($it['custom_tracking_link']) ? $it['custom_tracking_link'] : '',
                );
            }
        }

        // AfterShip.
        $aftership_num = $order->get_meta('_aftership_tracking_number');
        if (!empty($aftership_num)) {
            $tracking[] = array(
                'carrier' => (string) $order->get_meta('_aftership_tracking_provider_name'),
                'number'  => (string) $aftership_num,
                'url'     => '',
            );
        }

        return $tracking;
    }

    /**
     * AJAX: merchant toggles "AI order status lookup" in the WooCommerce tab.
     * Saves the option and pushes the config (enabled flag + REST url) to onWebChat.
     */
    public function ajax_save_order_lookup() {
        check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error('Insufficient permissions');
        }

        $enabled = isset($_POST['order_lookup_enabled']) && $_POST['order_lookup_enabled'] === '1';

        update_option('onwebchat_wc_order_lookup_enabled', $enabled);

        $pushed = $this->push_order_lookup_config($enabled);

        if (!$pushed['success']) {
            if ($this->use_testing_mode) {
                // Local testing: there may be no onWebChat server reachable on 127.0.0.1:81. Keep the
                // local flag so the REST endpoint can be exercised directly, and just warn that the
                // server was not notified (it would normally store the lookup URL).
                wp_send_json_success(array(
                    'enabled' => $enabled,
                    'warning' => $pushed['error'],
                    'message' => ($enabled ? 'AI order status lookup enabled locally' : 'AI order status lookup disabled locally')
                        . ' (testing mode: onWebChat server not notified: ' . $pushed['error'] . ')',
                ));
            }

            // Production: roll back the local flag if the server could not be told, so the UI reflects reality.
            update_option('onwebchat_wc_order_lookup_enabled', false);
            wp_send_json_error($pushed['error']);
        }

        wp_send_json_success(array(
            'enabled' => $enabled,
            'message' => $enabled ? 'AI order status lookup enabled' : 'AI order status lookup disabled',
        ));
    }

    /**
     * Push the order-lookup configuration to onWebChat (signed with the shared secret).
     *
     * @param bool $enabled
     * @return array ['success' => bool, 'error' => string]
     */
    public function push_order_lookup_config($enabled) {
        $chatId = get_option('onwebchat_plugin_option');
        $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';

        if (empty($chatId)) {
            return array('success' => false, 'error' => 'No Chat ID configured. Please connect onWebChat first.');
        }

        $secret = get_option('onwebchat_wc_sync_secret');
        if (empty($secret)) {
            return array('success' => false, 'error' => 'WooCommerce is not connected. Please connect WooCommerce sync first.');
        }

        $chatIdKey = explode('/', $chatId)[0];

        $endpoint = $this->use_testing_mode
            ? 'http://127.0.0.1:81/api/integrations/woocommerce/order-lookup/config'
            : 'https://www.onwebchat.com/api/integrations/woocommerce/order-lookup/config';

        $payload = array(
            'site_id'   => $chatIdKey,
            // Full REST url so the server reaches us correctly regardless of permalink settings.
            'order_url' => rest_url('onwebchat/v1/order-lookup'),
            'enabled'   => $enabled ? 1 : 0,
        );

        // Sign with the existing plugin -> server scheme: HMAC(secret, siteKey.timestamp.nonce.body).
        $timestamp = time();
        $nonce     = base64_encode(random_bytes(16));
        $body_json = wp_json_encode($payload);
        $message   = $chatIdKey . '.' . $timestamp . '.' . $nonce . '.' . $body_json;
        $signature = hash_hmac('sha256', $message, $secret);

        $request_args = array(
            'method'  => 'POST',
            'timeout' => 15,
            'headers' => array(
                'Content-Type'    => 'application/json',
                'X-OWC-SiteId'    => $chatIdKey,
                'X-OWC-Timestamp' => $timestamp,
                'X-OWC-Nonce'     => $nonce,
                'X-OWC-Signature' => $signature,
            ),
            'body'    => $body_json,
        );

        if ($this->use_testing_mode) {
            $request_args['sslverify'] = false;
        }

        $response = wp_remote_post($endpoint, $request_args);

        if (is_wp_error($response)) {
            error_log('onWebChat Order Lookup - config push error: ' . $response->get_error_message());
            return array('success' => false, 'error' => 'Could not reach onWebChat: ' . $response->get_error_message());
        }

        $code = wp_remote_retrieve_response_code($response);
        if ($code >= 200 && $code < 300) {
            // Clear any previous auth error on success (same convention as product sync).
            delete_transient('onwebchat_wc_auth_error');
            return array('success' => true, 'error' => '');
        }

        // Authentication failed: the shared secret is stale / out of sync with onWebChat. Self-heal the
        // same way product sync does: drop the bad secret and raise the standard "reconnect" notice. The
        // merchant reconnects ONCE and that single secret then works for BOTH product sync and order
        // lookup, so order lookup never needs its own separate authorization.
        if ($code === 401) {
            if (!$this->use_testing_mode) {
                delete_option('onwebchat_wc_sync_secret');
                set_transient('onwebchat_wc_auth_error', 'Authentication failed. Your WooCommerce secret is invalid or out of sync. Please reconnect WooCommerce integration in the plugin settings.', 3600);
                error_log('onWebChat Order Lookup - config push auth failed (401): cleared secret, reconnect required.');
                return array('success' => false, 'error' => 'Authentication expired. Please reconnect WooCommerce (one click), then enable order lookup again.', 'needs_reconnect' => true);
            }
            // Testing mode keeps the manually-managed secret; just report the mismatch.
            error_log('onWebChat Order Lookup - config push auth failed (401) in testing mode: secret kept.');
            return array('success' => false, 'error' => 'Invalid signature (testing mode: ensure the localhost ai_settings.woocommerce_secret matches the plugin secret).');
        }

        $body = json_decode(wp_remote_retrieve_body($response), true);
        $err  = (is_array($body) && isset($body['error'])) ? $body['error'] : ('HTTP ' . $code);
        error_log('onWebChat Order Lookup - config push failed: ' . $err);
        return array('success' => false, 'error' => $err);
    }
}

// Initialize the order-lookup module.
global $onwebchat_wc_orders;
$onwebchat_wc_orders = new OnWebChat_WooCommerce_Orders();

```
