Endpoint.php
2 weeks ago
ItemEligibility.php
2 months ago
Meta.php
3 months ago
Scheduler.php
2 months ago
StarRating.php
3 months ago
SubmissionHandler.php
2 weeks ago
SubmissionHandler.php
491 lines
| 1 | <?php |
| 2 | /** |
| 3 | * SubmissionHandler class file. |
| 4 | */ |
| 5 | |
| 6 | declare( strict_types = 1 ); |
| 7 | |
| 8 | namespace Automattic\WooCommerce\Internal\OrderReviews; |
| 9 | |
| 10 | use Automattic\WooCommerce\Enums\OrderStatus; |
| 11 | use WC_Order; |
| 12 | |
| 13 | /** |
| 14 | * Handles the AJAX submission of the Review Order form. |
| 15 | * |
| 16 | * One comment per rated row, with per-row outcome reported back so a single |
| 17 | * row's failure cannot block the rest. Guests submit with the order key; |
| 18 | * logged-in customers must own the order. |
| 19 | * |
| 20 | * @internal Just for internal use. |
| 21 | * |
| 22 | * @since 10.8.0 |
| 23 | */ |
| 24 | class SubmissionHandler { |
| 25 | |
| 26 | /** |
| 27 | * Action name registered with admin-ajax. |
| 28 | */ |
| 29 | public const ACTION = 'woocommerce_submit_order_reviews'; |
| 30 | |
| 31 | /** |
| 32 | * Order meta stamped with the time the Review Order page first had no |
| 33 | * actionable rows left. |
| 34 | * |
| 35 | * Set by the submission handler once every eligible item has a review by |
| 36 | * this customer (approved or pending moderation), and also by the Endpoint |
| 37 | * when the page is loaded with no actionable rows (e.g. all items are |
| 38 | * already-reviewed or skipped because reviews are disabled on the products). |
| 39 | */ |
| 40 | public const COMPLETED_META_KEY = '_wc_review_request_completed_at'; |
| 41 | |
| 42 | /** |
| 43 | * Wire the AJAX endpoints. |
| 44 | * |
| 45 | * Auto-called by the WC dependency container after instantiation. |
| 46 | * |
| 47 | * @internal |
| 48 | */ |
| 49 | final public function init(): void { |
| 50 | add_action( 'wp_ajax_' . self::ACTION, array( $this, 'handle' ) ); |
| 51 | add_action( 'wp_ajax_nopriv_' . self::ACTION, array( $this, 'handle' ) ); |
| 52 | } |
| 53 | |
| 54 | /** |
| 55 | * Entry point fired by `admin-ajax.php`. |
| 56 | * |
| 57 | * Sends a JSON response and exits. |
| 58 | */ |
| 59 | public function handle(): void { |
| 60 | // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce is checked below. |
| 61 | $order_id = isset( $_POST['order_id'] ) ? absint( $_POST['order_id'] ) : 0; |
| 62 | $key = isset( $_POST['key'] ) && is_string( $_POST['key'] ) ? sanitize_text_field( wp_unslash( $_POST['key'] ) ) : ''; |
| 63 | $nonce = isset( $_POST['_wcnonce'] ) && is_string( $_POST['_wcnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wcnonce'] ) ) : ''; |
| 64 | // Row-level fields are sanitized inside process_rows(); the array as a whole only needs unslashing. |
| 65 | $rows_in = isset( $_POST['reviews'] ) && is_array( $_POST['reviews'] ) ? wp_unslash( $_POST['reviews'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized |
| 66 | // phpcs:enable WordPress.Security.NonceVerification.Missing |
| 67 | |
| 68 | if ( ! is_string( $nonce ) || ! wp_verify_nonce( $nonce, self::ACTION ) ) { |
| 69 | wp_send_json_error( array( 'message' => __( 'Security check failed.', 'woocommerce' ) ), 403 ); |
| 70 | } |
| 71 | |
| 72 | $order = $order_id ? wc_get_order( $order_id ) : false; |
| 73 | if ( ! $order instanceof WC_Order ) { |
| 74 | wp_send_json_error( array( 'message' => __( 'Order not found.', 'woocommerce' ) ), 404 ); |
| 75 | } |
| 76 | |
| 77 | if ( '' === $key || ! hash_equals( $order->get_order_key(), $key ) ) { |
| 78 | wp_send_json_error( array( 'message' => __( 'Order not found.', 'woocommerce' ) ), 404 ); |
| 79 | } |
| 80 | |
| 81 | // Logged-in user must own the order. Guests with the right key still pass. |
| 82 | if ( $order->get_customer_id() && is_user_logged_in() && get_current_user_id() !== $order->get_customer_id() ) { |
| 83 | wp_send_json_error( array( 'message' => __( 'Order not found.', 'woocommerce' ) ), 404 ); |
| 84 | } |
| 85 | |
| 86 | // Reuse the same eligibility filter the page-load endpoint uses so the |
| 87 | // submit path can never run on an order whose status no longer permits it. |
| 88 | // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- documented on Endpoint::is_authorised(). |
| 89 | $eligible_statuses = (array) apply_filters( |
| 90 | 'woocommerce_review_order_eligible_statuses', |
| 91 | array( OrderStatus::COMPLETED ), |
| 92 | $order |
| 93 | ); |
| 94 | |
| 95 | if ( ! in_array( $order->get_status(), $eligible_statuses, true ) ) { |
| 96 | wp_send_json_error( array( 'message' => __( 'Order not found.', 'woocommerce' ) ), 404 ); |
| 97 | } |
| 98 | |
| 99 | $results = $this->process_rows( $order, $rows_in ); |
| 100 | |
| 101 | $this->maybe_mark_order_complete( $order ); |
| 102 | |
| 103 | /** |
| 104 | * Fires after the Review Order form has been processed. |
| 105 | * |
| 106 | * @since 10.8.0 |
| 107 | * |
| 108 | * @param WC_Order $order The order. |
| 109 | * @param array $results Per-row outcomes — see `SubmissionHandler::process_rows()`. |
| 110 | */ |
| 111 | do_action( 'woocommerce_review_order_submitted', $order, $results ); |
| 112 | |
| 113 | wp_send_json_success( array( 'results' => $results ) ); |
| 114 | } |
| 115 | |
| 116 | /** |
| 117 | * Process the submitted row payload and return per-row outcomes. |
| 118 | * |
| 119 | * @param WC_Order $order Order being reviewed. |
| 120 | * @param array $rows_in Raw `$_POST['reviews']` value. |
| 121 | * @return array<int, array{product_id:int, status:string, comment_id?:int, error?:string}> |
| 122 | */ |
| 123 | private function process_rows( WC_Order $order, array $rows_in ): array { |
| 124 | $results = array(); |
| 125 | $item_index = $this->index_eligible_order_items( $order ); |
| 126 | $author_name = trim( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() ); |
| 127 | $author_email = $order->get_billing_email(); |
| 128 | $author_ip = $order->get_customer_ip_address(); |
| 129 | $author_agent = $order->get_customer_user_agent(); |
| 130 | |
| 131 | // Drop any per-request memoisation a prior caller may have populated, |
| 132 | // then preload the eligibility cache so the per-row decide() calls |
| 133 | // below don't issue one already-reviewed query each. Reset matters |
| 134 | // inside the suite (multiple submissions in one PHP process) and is |
| 135 | // a no-op in production (admin-ajax runs in a fresh process). |
| 136 | ItemEligibility::reset_cache(); |
| 137 | ItemEligibility::preload_for_items( $item_index, $order ); |
| 138 | |
| 139 | foreach ( $rows_in as $row_index => $row ) { |
| 140 | $row_index = (int) $row_index; |
| 141 | $row = is_array( $row ) ? $row : array(); |
| 142 | |
| 143 | $rating = isset( $row['rating'] ) ? (int) $row['rating'] : 0; |
| 144 | if ( 0 === $rating ) { |
| 145 | // Empty rating means the customer chose to skip this row; allowed. |
| 146 | continue; |
| 147 | } |
| 148 | |
| 149 | $product_id = isset( $row['product_id'] ) ? absint( $row['product_id'] ) : 0; |
| 150 | $order_item_id = isset( $row['order_item_id'] ) ? absint( $row['order_item_id'] ) : 0; |
| 151 | // $rows_in was already unslashed in handle(); avoid double-unslashing. |
| 152 | $text = isset( $row['text'] ) && is_string( $row['text'] ) ? trim( wp_kses_post( $row['text'] ) ) : ''; |
| 153 | |
| 154 | // Per-row result always carries `product_id` (parent product, where |
| 155 | // the review lives) and `variation_id` (0 for simple products) so |
| 156 | // callers don't have to know whether the client posted the parent |
| 157 | // or the variation id as `product_id`. Both are echoed back as |
| 158 | // soon as we resolve the line item; for early validation failures |
| 159 | // they reflect the raw submitted product id with `variation_id: 0`. |
| 160 | $result = array( |
| 161 | 'product_id' => $product_id, |
| 162 | 'variation_id' => 0, |
| 163 | 'status' => 'error', |
| 164 | ); |
| 165 | |
| 166 | if ( $rating < 1 || $rating > 5 ) { |
| 167 | $result['error'] = 'invalid_rating'; |
| 168 | $results[ $row_index ] = $result; |
| 169 | continue; |
| 170 | } |
| 171 | |
| 172 | // invalid_row also covers fully-refunded line items: index_eligible_order_items() |
| 173 | // runs them through woocommerce_review_order_eligible_items, which strips them. |
| 174 | if ( ! $product_id || ! $order_item_id || ! isset( $item_index[ $order_item_id ] ) ) { |
| 175 | $result['error'] = 'invalid_row'; |
| 176 | $results[ $row_index ] = $result; |
| 177 | continue; |
| 178 | } |
| 179 | |
| 180 | $item = $item_index[ $order_item_id ]; |
| 181 | |
| 182 | // Variable products: the row template posts the variation id, |
| 183 | // while $item->get_product_id() returns the parent. Accept either. |
| 184 | $line_product_id = (int) $item->get_product_id(); |
| 185 | $line_variation_id = (int) $item->get_variation_id(); |
| 186 | if ( $product_id !== $line_product_id && $product_id !== $line_variation_id ) { |
| 187 | $result['error'] = 'product_mismatch'; |
| 188 | $results[ $row_index ] = $result; |
| 189 | continue; |
| 190 | } |
| 191 | |
| 192 | // Canonicalise the result fields now that we've resolved the line |
| 193 | // item: parent product id + the line's variation id (0 for simple). |
| 194 | $result['product_id'] = $line_product_id; |
| 195 | $result['variation_id'] = $line_variation_id; |
| 196 | |
| 197 | // Reviews always attach to the parent product so they show on the |
| 198 | // product page regardless of which variation was bought. |
| 199 | $review_post_id = $line_product_id; |
| 200 | |
| 201 | // Reject submissions for products whose review form was never |
| 202 | // rendered (comments disabled on the product). |
| 203 | $decision = ItemEligibility::decide( $item, $order ); |
| 204 | if ( ItemEligibility::STATUS_SKIP === $decision['status'] ) { |
| 205 | $result['error'] = 'reviews_not_open'; |
| 206 | $results[ $row_index ] = $result; |
| 207 | continue; |
| 208 | } |
| 209 | |
| 210 | // Only attribute the comment to a WP user when the current request is |
| 211 | // authenticated as that user. Guests reaching the page via the order |
| 212 | // key are not authenticated, so the comment stays unattributed (0). |
| 213 | $customer_id = (int) $order->get_customer_id(); |
| 214 | $current_user_id = get_current_user_id(); |
| 215 | $comment_user_id = ( $current_user_id > 0 && $current_user_id === $customer_id ) ? $current_user_id : 0; |
| 216 | |
| 217 | // If the customer already has a review tied to this order for this |
| 218 | // product, update it in place instead of stacking duplicates. The |
| 219 | // existing comment id comes from the server-side lookup, not the |
| 220 | // client, so a tampered POST can't target someone else's review. |
| 221 | $existing = $decision['comment'] instanceof \WP_Comment ? $decision['comment'] : null; |
| 222 | |
| 223 | if ( $existing instanceof \WP_Comment ) { |
| 224 | // A moderator's spam/trash decision is final. |
| 225 | if ( in_array( wp_get_comment_status( $existing ), array( 'spam', 'trash' ), true ) ) { |
| 226 | $result['error'] = 'update_failed'; |
| 227 | $results[ $row_index ] = $result; |
| 228 | continue; |
| 229 | } |
| 230 | |
| 231 | $approved = self::comment_approval_status( $author_name, $author_email, $text, $author_ip, $author_agent ); |
| 232 | $update_ok = wp_update_comment( |
| 233 | wp_slash( |
| 234 | array( |
| 235 | 'comment_ID' => (int) $existing->comment_ID, |
| 236 | 'comment_content' => $text, |
| 237 | 'comment_approved' => $approved, |
| 238 | ) |
| 239 | ) |
| 240 | ); |
| 241 | if ( false === $update_ok || is_wp_error( $update_ok ) ) { |
| 242 | $result['error'] = 'update_failed'; |
| 243 | $results[ $row_index ] = $result; |
| 244 | continue; |
| 245 | } |
| 246 | |
| 247 | update_comment_meta( (int) $existing->comment_ID, 'rating', $rating ); |
| 248 | |
| 249 | $result['comment_id'] = (int) $existing->comment_ID; |
| 250 | $result['status'] = 1 === $approved ? 'ok' : 'pending_moderation'; |
| 251 | $results[ $row_index ] = $result; |
| 252 | continue; |
| 253 | } |
| 254 | |
| 255 | if ( self::has_rejected_review( $order, $line_product_id, $line_variation_id ) ) { |
| 256 | $result['error'] = 'update_failed'; |
| 257 | $results[ $row_index ] = $result; |
| 258 | continue; |
| 259 | } |
| 260 | |
| 261 | $approved = self::comment_approval_status( $author_name, $author_email, $text, $author_ip, $author_agent ); |
| 262 | |
| 263 | $comment_data = array( |
| 264 | 'comment_post_ID' => $review_post_id, |
| 265 | 'comment_author' => '' !== $author_name ? $author_name : __( 'Anonymous', 'woocommerce' ), |
| 266 | 'comment_author_email' => $author_email, |
| 267 | 'comment_author_IP' => $author_ip, |
| 268 | 'comment_agent' => $author_agent, |
| 269 | 'comment_content' => $text, |
| 270 | 'comment_type' => 'review', |
| 271 | 'comment_approved' => $approved, |
| 272 | 'user_id' => $comment_user_id, |
| 273 | ); |
| 274 | |
| 275 | $comment_id = wp_insert_comment( wp_slash( $comment_data ) ); |
| 276 | if ( ! $comment_id ) { |
| 277 | $result['error'] = 'insert_failed'; |
| 278 | $results[ $row_index ] = $result; |
| 279 | continue; |
| 280 | } |
| 281 | |
| 282 | add_comment_meta( $comment_id, 'rating', $rating, true ); |
| 283 | add_comment_meta( $comment_id, 'verified', 1, true ); |
| 284 | add_comment_meta( $comment_id, ItemEligibility::ORDER_META_KEY, (int) $order->get_id(), true ); |
| 285 | add_comment_meta( $comment_id, ItemEligibility::VARIATION_META_KEY, $line_variation_id, true ); |
| 286 | |
| 287 | $variation_summary = ItemEligibility::format_variation_summary( $item ); |
| 288 | if ( '' !== $variation_summary ) { |
| 289 | add_comment_meta( $comment_id, ItemEligibility::VARIATION_SUMMARY_META_KEY, $variation_summary, true ); |
| 290 | } |
| 291 | |
| 292 | $result['comment_id'] = (int) $comment_id; |
| 293 | $result['status'] = 1 === $approved ? 'ok' : 'pending_moderation'; |
| 294 | $results[ $row_index ] = $result; |
| 295 | }//end foreach |
| 296 | |
| 297 | return $results; |
| 298 | } |
| 299 | |
| 300 | /** |
| 301 | * Decide whether a review should be auto-approved, via WordPress's own `check_comment()`. |
| 302 | * |
| 303 | * @param string $author Comment author name. |
| 304 | * @param string $email Comment author email. |
| 305 | * @param string $content Comment content. |
| 306 | * @param string $ip Comment author IP. |
| 307 | * @param string $agent Comment author user agent. |
| 308 | * @return int 1 to auto-approve, 0 to hold for moderation. |
| 309 | */ |
| 310 | private static function comment_approval_status( string $author, string $email, string $content, string $ip, string $agent ): int { |
| 311 | add_filter( 'pre_option_comment_previously_approved', '__return_zero' ); |
| 312 | $approved = check_comment( $author, $email, '', $content, $ip, $agent, 'review' ); |
| 313 | remove_filter( 'pre_option_comment_previously_approved', '__return_zero' ); |
| 314 | |
| 315 | return $approved ? 1 : 0; |
| 316 | } |
| 317 | |
| 318 | /** |
| 319 | * Whether a moderator already marked this exact order/product/variation |
| 320 | * review as spam or trash. |
| 321 | * |
| 322 | * @param WC_Order $order Order being reviewed. |
| 323 | * @param int $product_id Parent product id. |
| 324 | * @param int $variation_id Variation id (0 for simple products). |
| 325 | * @return bool |
| 326 | */ |
| 327 | private static function has_rejected_review( WC_Order $order, int $product_id, int $variation_id ): bool { |
| 328 | $email = $order->get_billing_email(); |
| 329 | if ( '' === $email ) { |
| 330 | return false; |
| 331 | } |
| 332 | |
| 333 | $comments = get_comments( |
| 334 | array( |
| 335 | 'post_id' => $product_id, |
| 336 | 'author_email' => $email, |
| 337 | 'type' => 'review', |
| 338 | 'status' => array( 'spam', 'trash' ), |
| 339 | 'number' => 1, |
| 340 | 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded by post_id + author_email. |
| 341 | 'relation' => 'AND', |
| 342 | array( |
| 343 | 'key' => ItemEligibility::ORDER_META_KEY, |
| 344 | 'value' => (string) $order->get_id(), |
| 345 | ), |
| 346 | array( |
| 347 | 'relation' => 'OR', |
| 348 | array( |
| 349 | 'key' => ItemEligibility::VARIATION_META_KEY, |
| 350 | 'value' => (string) $variation_id, |
| 351 | ), |
| 352 | array( |
| 353 | // Pre-10.9.0 reviews have no VARIATION_META_KEY row at all. |
| 354 | 'key' => ItemEligibility::VARIATION_META_KEY, |
| 355 | 'compare' => 'NOT EXISTS', |
| 356 | ), |
| 357 | ), |
| 358 | ), |
| 359 | ) |
| 360 | ); |
| 361 | |
| 362 | return is_array( $comments ) && ! empty( $comments ); |
| 363 | } |
| 364 | |
| 365 | /** |
| 366 | * Set the completed-at meta when every eligible item has a review by this |
| 367 | * customer (approved or pending moderation), whether posted in this |
| 368 | * submission or an earlier one. Spam/trash comments are excluded. |
| 369 | * |
| 370 | * @param WC_Order $order Order being reviewed. |
| 371 | */ |
| 372 | private function maybe_mark_order_complete( WC_Order $order ): void { |
| 373 | // Recording the moment the order first became fully reviewed; never overwrite. |
| 374 | if ( $order->get_meta( self::COMPLETED_META_KEY ) ) { |
| 375 | return; |
| 376 | } |
| 377 | |
| 378 | $customer_email = $order->get_billing_email(); |
| 379 | if ( '' === $customer_email ) { |
| 380 | return; |
| 381 | } |
| 382 | |
| 383 | // Build the same eligible-row set the page uses, then collect the |
| 384 | // distinct (parent product, variation) slots that need a review. |
| 385 | // Counting by slot rather than per-line-item means a double-submit of |
| 386 | // the same variation can't satisfy a sibling variation's quota, and |
| 387 | // the same simple product appearing on multiple rows still only |
| 388 | // needs one review (the page collapses those rows anyway). |
| 389 | // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- documented at the page-template invocation site. |
| 390 | $eligible_items = (array) apply_filters( 'woocommerce_review_order_eligible_items', $order->get_items(), $order ); |
| 391 | |
| 392 | $required_slots = array(); |
| 393 | $product_ids = array(); |
| 394 | foreach ( $eligible_items as $item ) { |
| 395 | if ( ! $item instanceof \WC_Order_Item_Product ) { |
| 396 | continue; |
| 397 | } |
| 398 | $product_id = (int) $item->get_product_id(); |
| 399 | $variation_id = (int) $item->get_variation_id(); |
| 400 | if ( $product_id > 0 ) { |
| 401 | $required_slots[ $product_id . '|' . $variation_id ] = true; |
| 402 | $product_ids[ $product_id ] = $product_id; |
| 403 | } |
| 404 | } |
| 405 | |
| 406 | if ( empty( $required_slots ) ) { |
| 407 | return; |
| 408 | } |
| 409 | |
| 410 | // Single grouped lookup, fetching the comment objects directly so we |
| 411 | // can read comment_post_ID without a follow-up query per row. Limit |
| 412 | // to approved + pending-moderation so spam/trash never count as |
| 413 | // completion, AND to reviews tagged with this order so an older |
| 414 | // review of the same parent product from a previous order doesn't |
| 415 | // satisfy the per-row count for the current one. number=>0 disables |
| 416 | // the default 20-row cap so this still works for orders with many |
| 417 | // reviewable items. |
| 418 | $comments = get_comments( |
| 419 | array( |
| 420 | 'post__in' => array_values( $product_ids ), |
| 421 | 'author_email' => $customer_email, |
| 422 | 'type' => 'review', |
| 423 | 'status' => array( 'approve', 'hold' ), |
| 424 | 'number' => 0, |
| 425 | 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded by post__in + author_email. |
| 426 | array( |
| 427 | 'key' => ItemEligibility::ORDER_META_KEY, |
| 428 | 'value' => (string) $order->get_id(), |
| 429 | ), |
| 430 | ), |
| 431 | ) |
| 432 | ); |
| 433 | |
| 434 | if ( ! is_array( $comments ) || empty( $comments ) ) { |
| 435 | return; |
| 436 | } |
| 437 | |
| 438 | // Index reviewed slots by (parent_id, variation_id); duplicate comments |
| 439 | // for the same slot still count as one toward completion. |
| 440 | $reviewed_slots = array(); |
| 441 | foreach ( $comments as $comment ) { |
| 442 | if ( $comment instanceof \WP_Comment ) { |
| 443 | $slot_key = (int) $comment->comment_post_ID . '|' . (int) get_comment_meta( (int) $comment->comment_ID, ItemEligibility::VARIATION_META_KEY, true ); |
| 444 | $reviewed_slots[ $slot_key ] = true; |
| 445 | } |
| 446 | } |
| 447 | |
| 448 | foreach ( $required_slots as $slot_key => $_ ) { |
| 449 | if ( ! isset( $reviewed_slots[ $slot_key ] ) ) { |
| 450 | return; |
| 451 | } |
| 452 | } |
| 453 | |
| 454 | $order->update_meta_data( self::COMPLETED_META_KEY, (string) time() ); |
| 455 | $order->save(); |
| 456 | } |
| 457 | |
| 458 | /** |
| 459 | * Map order_item_id => `WC_Order_Item_Product` for fast row lookup, |
| 460 | * filtered through `woocommerce_review_order_eligible_items` so the |
| 461 | * handler agrees with the page on which items are reviewable. The |
| 462 | * default callback excludes fully-refunded items. |
| 463 | * |
| 464 | * @param WC_Order $order Order being reviewed. |
| 465 | * @return array<int, \WC_Order_Item_Product> |
| 466 | */ |
| 467 | private function index_eligible_order_items( WC_Order $order ): array { |
| 468 | /** |
| 469 | * Filter the eligible items considered by the Review Order |
| 470 | * submission handler. |
| 471 | * |
| 472 | * Same hook the page uses; documented in |
| 473 | * `templates/order/customer-review-order.php`. |
| 474 | * |
| 475 | * @since 10.8.0 |
| 476 | * |
| 477 | * @param \WC_Order_Item[] $items Order line items. |
| 478 | * @param WC_Order $order The order being reviewed. |
| 479 | */ |
| 480 | $items = (array) apply_filters( 'woocommerce_review_order_eligible_items', $order->get_items(), $order ); |
| 481 | |
| 482 | $index = array(); |
| 483 | foreach ( $items as $item ) { |
| 484 | if ( $item instanceof \WC_Order_Item_Product ) { |
| 485 | $index[ $item->get_id() ] = $item; |
| 486 | } |
| 487 | } |
| 488 | return $index; |
| 489 | } |
| 490 | } |
| 491 |