\d+)', array( 'methods' => 'POST', 'callback' => array( $this, 'get_product_message' ), 'permission_callback' => array( $this, 'check_nonce_permission' ), 'args' => array( 'id' => array( 'required' => true, 'validate_callback' => function ( $param ) { return is_numeric( $param ); }, 'sanitize_callback' => 'absint', ), 'variation_id' => array( 'default' => 0, 'validate_callback' => function ( $param ) { return is_numeric( $param ); }, 'sanitize_callback' => 'absint', ), 'quantity' => array( 'default' => 1, 'validate_callback' => function ( $param ) { return is_numeric( $param ); }, 'sanitize_callback' => 'absint', ), ), ) ); register_rest_route( 'ordercourier/v1', '/message/cart', array( 'methods' => 'POST', 'callback' => array( $this, 'get_cart_message' ), 'permission_callback' => array( $this, 'check_nonce_permission' ), ) ); register_rest_route( 'ordercourier/v1', '/send', array( 'methods' => 'POST', 'callback' => array( $this, 'send_order' ), 'permission_callback' => array( $this, 'check_send_permission' ), ) ); } /** * Permission check for the message endpoints. * * The endpoints are public for guest shoppers, so the check is: a valid wp_rest nonce, * then the store's guest and country rules for the current customer, then the rate limit. * * @param \WP_REST_Request $request Request. * @return bool|\WP_Error */ public function check_nonce_permission( $request ) { return $this->check_permission( $request, null ); } /** * Permission check for the send endpoint. * * Same as check_nonce_permission(), but the country rule uses the billing country * sent with the order when there is one, since that country goes on the order. * * @param \WP_REST_Request $request Request. * @return bool|\WP_Error */ public function check_send_permission( $request ) { $billing = $request->get_param( 'billing' ); $country = ( is_array( $billing ) && isset( $billing['country'] ) && is_string( $billing['country'] ) ) ? $billing['country'] : null; return $this->check_permission( $request, $country ); } /** * Shared permission check: nonce, store rules, rate limit. * * @param \WP_REST_Request $request Request. * @param string|null $country Country to check, or null for the current customer's country. * @return bool|\WP_Error */ private function check_permission( $request, $country ) { // Verify nonce first from header or request parameter. $nonce = $request->get_header( 'X-WP-Nonce' ); if ( empty( $nonce ) ) { $nonce = $request->get_param( '_wpnonce' ); } if ( empty( $nonce ) || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) { return new \WP_Error( 'oc_invalid_nonce', __( 'Invalid security token.', 'ordercourier' ), array( 'status' => 403 ) ); } // Guest ordering and allowed countries are enforced here, not only in the browser. if ( ! did_action( 'woocommerce_load_cart_from_session' ) && function_exists( 'wc_load_cart' ) ) { wc_load_cart(); } $allowed = Restrictions::check( $country ); if ( is_wp_error( $allowed ) ) { return $allowed; } // Then check rate limit. $identifier = Rate_Limiter::get_identifier(); if ( ! Rate_Limiter::check( $identifier, 'api_request' ) ) { return new \WP_Error( 'oc_rate_limited', __( 'Too many requests. Please try again later.', 'ordercourier' ), array( 'status' => 429 ) ); } return true; } /** * Get product message. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function get_product_message( $request ) { $product_id = absint( $request->get_param( 'id' ) ); $variation_id = absint( $request->get_param( 'variation_id' ) ); $quantity = max( 1, absint( $request->get_param( 'quantity' ) ) ); // Only published products without a password; drafts, private and protected products get the same 404. $requested = Restrictions::get_requested_product( $product_id, $variation_id ); if ( is_wp_error( $requested ) ) { return $requested; } $product = $requested['product']; $actual_product = $requested['item']; $variation_id = $actual_product->is_type( 'variation' ) ? $actual_product->get_id() : 0; $mode = Settings::get( 'mode', 'order_first' ); // Check if we should create an order first. if ( 'order_first' === $mode ) { // Load cart session for REST requests. if ( ! did_action( 'woocommerce_load_cart_from_session' ) && function_exists( 'wc_load_cart' ) ) { wc_load_cart(); } try { // Create order directly from product without touching cart. $order = wc_create_order(); if ( ! $order || is_wp_error( $order ) ) { return $this->order_failed_error(); } // Add product to order (quote-only items are not charged). $order->add_product( $actual_product, $quantity ); Order_Manager::zero_quote_items( $order ); // Get price as float (handles empty string as 0). $product_price = (float) $actual_product->get_price(); // Check if this will be a quote order (before calculating shipping). $will_be_quote = empty( $product_price ); // Also check if product is marked as quote-only (check parent for variations). if ( ! $will_be_quote ) { $product_id = $actual_product->is_type( 'variation' ) ? $actual_product->get_parent_id() : $actual_product->get_id(); $quote_only = get_post_meta( $product_id, '_oc_quote_only', true ); if ( 'yes' === $quote_only ) { $will_be_quote = true; } } // Calculate shipping if available and not a quote order. if ( ! $will_be_quote ) { $packages = WC()->shipping()->calculate_shipping_for_package( array( 'contents' => array( array( 'data' => $actual_product, 'quantity' => $quantity, ), ), 'contents_cost' => $product_price * $quantity, 'applied_coupons' => array(), 'destination' => array( 'country' => WC()->customer->get_shipping_country(), 'state' => WC()->customer->get_shipping_state(), 'postcode' => WC()->customer->get_shipping_postcode(), 'city' => WC()->customer->get_shipping_city(), ), ) ); // Add first available shipping method if any. if ( ! empty( $packages['rates'] ) ) { $first_rate = reset( $packages['rates'] ); $item = new \WC_Order_Item_Shipping(); $item->set_shipping_rate( $first_rate ); $order->add_item( $item ); } } // Set customer if logged in. $customer_id = get_current_user_id(); if ( $customer_id ) { $order->set_customer_id( $customer_id ); } // Calculate totals. $order->calculate_totals(); // Set payment method. $order->set_payment_method( 'ordercourier' ); $order->set_payment_method_title( __( 'Chat Order', 'ordercourier' ) ); $order->set_created_via( 'ordercourier' ); // Check if quote mode. $is_quote = Order_Manager::is_quote_order( $order ); if ( $is_quote ) { $order->set_status( 'quote-requested' ); } else { $status = Settings::get( 'order_status', 'pending' ); $order->set_status( $status ); } // Add order meta. $order->update_meta_data( '_oc_channel', 'chat' ); $order->update_meta_data( '_oc_sent_at', time() ); // Resolve agent phone. $agent_context = array( 'product_id' => $product_id, 'category_ids' => wp_get_post_terms( $product_id, 'product_cat', array( 'fields' => 'ids' ) ), 'country' => WC()->customer ? WC()->customer->get_billing_country() : '', 'order' => $order, ); $agent_phone = Agent_Manager::resolve_agent_phone( $agent_context ); if ( $agent_phone ) { $order->update_meta_data( '_oc_agent_phone', $agent_phone ); } $order->save(); /** * Fires after an order is created from product button. * * @since 1.0.0 * @param int $order_id Order ID. * @param WC_Cart $cart Cart object (null in REST). * @param array $data Context data. */ do_action( 'ordercourier_after_create_order', $order->get_id(), null, $agent_context ); // Log events. Events::log( 'order_created', array( 'source' => 'product', 'product_id' => $product_id, 'order_id' => $order->get_id(), ) ); Events::log( 'redirected', array( 'source' => 'product', 'product_id' => $product_id, 'order_id' => $order->get_id(), ) ); $template_type = Order_Manager::is_quote_order( $order ) ? 'quote' : 'order'; $url = Order_Manager::get_order_message_url( $order->get_id(), $template_type ); return new \WP_REST_Response( array( 'message' => Message_Builder::for_order( $order, $template_type ), 'url' => $url, 'order_id' => $order->get_id(), ), 200 ); } catch ( \Exception $e ) { // Clean up any partially created order. if ( isset( $order ) && $order ) { $order->delete( true ); } wc_get_logger()->error( 'OrderCourier product order creation failed: ' . $e->getMessage(), array( 'source' => 'ordercourier' ) ); return $this->order_failed_error(); } } // Direct mode - just build message. $message = Message_Builder::for_product( $product_id, array( 'variation_id' => $variation_id, 'quantity' => $quantity, ) ); $agent_context = array( 'product_id' => $product_id, 'category_ids' => wp_get_post_terms( $product_id, 'product_cat', array( 'fields' => 'ids' ) ), 'country' => WC()->customer ? WC()->customer->get_billing_country() : '', ); $phone = Agent_Manager::resolve_agent_phone( $agent_context ); if ( empty( $phone ) ) { $phone = Settings::get( 'default_phone', '' ); } $url = Message_Builder::build_whatsapp_url( $phone, $message ); // Log event. Events::log( 'click', array( 'source' => 'product', 'product_id' => $product_id, ) ); return new \WP_REST_Response( array( 'message' => $message, 'url' => $url, ), 200 ); } /** * Get cart message. * * @param \WP_REST_Request $request Request (unused, required by REST API signature). * @return \WP_REST_Response|\WP_Error */ public function get_cart_message( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found // Load cart session for REST requests. if ( ! did_action( 'woocommerce_load_cart_from_session' ) && function_exists( 'wc_load_cart' ) ) { wc_load_cart(); } if ( ! WC()->cart || WC()->cart->is_empty() ) { return $this->cart_empty_error(); } $cart_ok = Restrictions::check_cart( WC()->cart ); if ( is_wp_error( $cart_ok ) ) { return $cart_ok; } $mode = Settings::get( 'mode', 'order_first' ); // Check if we should create an order first. if ( 'order_first' === $mode ) { // Create order from cart. $order = Order_Manager::create_order_from_cart( array( 'billing' => array( 'first_name' => '', 'last_name' => '', ), ) ); if ( is_wp_error( $order ) ) { return $order; } if ( ! $order ) { return $this->order_failed_error(); } // Log events. Events::log( 'order_created', array( 'source' => 'cart', 'order_id' => $order->get_id(), ) ); Events::log( 'redirected', array( 'source' => 'cart', 'order_id' => $order->get_id(), ) ); $template_type = Order_Manager::is_quote_order( $order ) ? 'quote' : 'order'; $url = Order_Manager::get_order_message_url( $order->get_id(), $template_type ); return new \WP_REST_Response( array( 'message' => Message_Builder::for_order( $order, $template_type ), 'url' => $url, 'order_id' => $order->get_id(), ), 200 ); } // Direct mode - just build message. $message = Message_Builder::for_cart( WC()->cart ); // Resolve agent phone for cart. $cart_items = WC()->cart->get_cart(); $product_id = null; $category_ids = array(); // If cart has exactly one product, use its product_id. if ( count( $cart_items ) === 1 ) { $cart_item = reset( $cart_items ); if ( $cart_item['data'] ) { $product = $cart_item['data']; $product_id = $product->is_type( 'variation' ) ? $product->get_parent_id() : $product->get_id(); } } // Collect categories from all cart items. foreach ( $cart_items as $cart_item ) { if ( $cart_item['data'] ) { // For variations, get categories from parent product. $product = $cart_item['data']; $item_product_id = $product->is_type( 'variation' ) ? $product->get_parent_id() : $product->get_id(); $cats = wp_get_post_terms( $item_product_id, 'product_cat', array( 'fields' => 'ids' ) ); if ( ! empty( $cats ) ) { $category_ids = array_merge( $category_ids, $cats ); } } } $category_ids = array_unique( $category_ids ); $agent_context = array( 'product_id' => $product_id, 'category_ids' => $category_ids, 'country' => WC()->customer ? WC()->customer->get_billing_country() : '', 'cart' => WC()->cart, ); $phone = Agent_Manager::resolve_agent_phone( $agent_context ); if ( empty( $phone ) ) { $phone = Settings::get( 'default_phone', '' ); } $url = Message_Builder::build_whatsapp_url( $phone, $message ); // Log event. Events::log( 'click', array( 'source' => 'cart', ) ); return new \WP_REST_Response( array( 'message' => $message, 'url' => $url, ), 200 ); } /** * Send order. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function send_order( $request ) { // Load cart session for REST requests. if ( ! did_action( 'woocommerce_load_cart_from_session' ) && function_exists( 'wc_load_cart' ) ) { wc_load_cart(); } // Check rate limit. $identifier = Rate_Limiter::get_identifier(); if ( ! Rate_Limiter::check( $identifier, 'send' ) ) { return new \WP_Error( 'oc_rate_limited', __( 'Too many requests. Please try again later.', 'ordercourier' ), array( 'status' => 429 ) ); } // Check if cart is empty. if ( ! WC()->cart || WC()->cart->is_empty() ) { return $this->cart_empty_error(); } $cart_ok = Restrictions::check_cart( WC()->cart ); if ( is_wp_error( $cart_ok ) ) { return $cart_ok; } // Get billing data from request (sanitized and allowlisted in Order_Manager). $billing = $request->get_param( 'billing' ); $billing = is_array( $billing ) ? $billing : array(); $note = $request->get_param( 'note' ); $note = is_string( $note ) ? $note : ''; $source = $request->get_param( 'source' ); $source = is_string( $source ) ? sanitize_key( $source ) : ''; // Create order. $order = Order_Manager::create_order_from_cart( array( 'billing' => $billing, 'note' => $note, ) ); if ( is_wp_error( $order ) ) { return $order; } if ( ! $order ) { return $this->order_failed_error(); } // Log event. Events::log( 'order_created', array( 'source' => $source ? $source : 'cart', 'order_id' => $order->get_id(), ) ); // Get redirect URL. $template_type = Order_Manager::is_quote_order( $order ) ? 'quote' : 'order'; $redirect_url = Order_Manager::get_order_message_url( $order->get_id(), $template_type ); // Log redirect event. Events::log( 'redirected', array( 'source' => $source ? $source : 'cart', 'order_id' => $order->get_id(), ) ); return new \WP_REST_Response( array( 'success' => true, 'order_id' => $order->get_id(), 'order_key' => $order->get_order_key(), 'redirect_url' => $redirect_url, 'message' => Message_Builder::for_order( $order, $template_type ), ), 200 ); } /** * Error for an empty cart. * * @return \WP_Error */ private function cart_empty_error() { return new \WP_Error( 'oc_cart_empty', __( 'Cart is empty', 'ordercourier' ), array( 'status' => 400 ) ); } /** * Error for a failed order creation. * * @return \WP_Error */ private function order_failed_error() { return new \WP_Error( 'oc_order_failed', __( 'Failed to create order', 'ordercourier' ), array( 'status' => 500 ) ); } }