nded product and variation IDs along with their sum of product_net_revenue from the parent order. $partial_refund_products = $wpdb->get_results( $wpdb->prepare( " SELECT product_lookup.product_id, product_lookup.variation_id, SUM( product_lookup.product_net_revenue ) AS product_net_revenue FROM %i AS product_lookup INNER JOIN {$wpdb->prefix}wc_order_stats AS order_stats ON order_stats.order_id = product_lookup.order_id WHERE 1 = 1 AND order_stats.parent_id = %d AND product_lookup.product_net_revenue < 0 GROUP BY product_lookup.product_id, product_lookup.variation_id ", $table_name, $parent_order_id ) ); /** * Create a lookup table for partially refunded products. * E.g. [ * '1' => -20, * '2' => -40, * '51' => -10, * '52' => -30, * ] */ foreach ( $partial_refund_products as $product ) { $id = $product->variation_id ? $product->variation_id : $product->product_id; $partial_refund_product_revenue[ $id ] = (float) $product->product_net_revenue; } } foreach ( $order_items as $order_item ) { $order_item_id = $order_item->get_id(); unset( $existing_items[ $order_item_id ] ); $product_qty = $order_item->get_quantity( 'edit' ); $product_id = $order_item->get_product_id( 'edit' ); $variation_id = $order_item->get_variation_id( 'edit' ); $shipping_amount = $order->get_item_shipping_amount( $order_item ); $shipping_tax_amount = $order->get_item_shipping_tax_amount( $order_item ); $coupon_amount = $order->get_item_coupon_amount( $order_item ); $tax_amount = $order->get_item_cart_tax_amount( $order_item ); $net_revenue = round( $order_item->get_total( 'edit' ), $decimals ); // If the order is a full refund and there is no order items. The order item here is the parent order item. if ( $is_full_refund_without_line_items ) { $id = $variation_id ? $variation_id : $product_id; $partial_refund = $partial_refund_product_revenue[ $id ] ?? 0; // If a single line item was refunded 60% then fully refunded after, we need store the difference in the product lookup table. // E.g. A product costs $100, it was previously partially refunded $60, then fully refunded $40. // So it will be -abs( 100 + (-60) ) = -40. $net_revenue = -abs( $net_revenue + $partial_refund ); // Skip items that have already been fully refunded (single or multiple partial refunds). if ( 0.0 === $net_revenue ) { continue; } $product_qty = -abs( $product_qty ); // Set coupon amount to 0 for full refunds without line items. $coupon_amount = 0; if ( $parent_order ) { $remaining_refund_items = $parent_order->get_remaining_refund_items(); // Calculate the shipping amount to refund from the parent order. $total_shipping_refunded = $parent_order->get_total_shipping_refunded(); $shipping_total = (float) $parent_order->get_shipping_total(); $total_shipping_to_refund = $shipping_total - $total_shipping_refunded; if ( $total_shipping_to_refund > 0 ) { $shipping_amount = -abs( $parent_order->get_item_shipping_amount( $order_item, $remaining_refund_items, $total_shipping_to_refund ) ); } // Calculate the shipping tax amount to refund from the parent order. $shipping_tax = (float) $parent_order->get_shipping_tax(); $total_shipping_tax_refunded = $parent_order->get_total_shipping_tax_refunded(); $total_shipping_tax_to_refund = $shipping_tax - $total_shipping_tax_refunded; if ( $total_shipping_tax_to_refund > 0 ) { $shipping_tax_amount = -abs( $parent_order->get_item_shipping_tax_amount( $order_item, $remaining_refund_items, $total_shipping_tax_to_refund ) ); } // Calculate cart tax amount of the item from the parent order. $tax_amount = -abs( $parent_order->get_item_cart_tax_amount( $order_item ) ); } } $is_refund = $net_revenue < 0; // Skip line items without changes to product quantity. if ( ! $product_qty && ! $is_refund ) { ++$num_updated; continue; } if ( $round_tax ) { $tax_amount = round( $tax_amount, $decimals ); } $result = $wpdb->replace( self::get_db_table_name(), array( 'order_item_id' => $order_item_id, 'order_id' => $order->get_id(), 'product_id' => $product_id, 'variation_id' => $variation_id, 'customer_id' => $order->get_report_customer_id(), 'product_qty' => $product_qty, 'product_net_revenue' => $net_revenue, 'date_created' => $order->get_date_created( 'edit' )->date( TimeInterval::$sql_datetime_format ), 'coupon_amount' => $coupon_amount, 'tax_amount' => $tax_amount, 'shipping_amount' => $shipping_amount, 'shipping_tax_amount' => $shipping_tax_amount, // @todo Can this be incorrect if modified by filters? 'product_gross_revenue' => $net_revenue + $tax_amount + $shipping_amount + $shipping_tax_amount, ), array( '%d', // order_item_id. '%d', // order_id. '%d', // product_id. '%d', // variation_id. '%d', // customer_id. '%d', // product_qty. '%f', // product_net_revenue. '%s', // date_created. '%f', // coupon_amount. '%f', // tax_amount. '%f', // shipping_amount. '%f', // shipping_tax_amount. '%f', // product_gross_revenue. ) ); // WPCS: cache ok, DB call ok, unprepared SQL ok. /** * Fires when product's reports are updated. * * @param int $order_item_id Order Item ID. * @param int $order_id Order ID. */ do_action( 'woocommerce_analytics_update_product', $order_item_id, $order->get_id() ); // Sum the rows affected. Using REPLACE can affect 2 rows if the row already exists. $num_updated += 2 === intval( $result ) ? 1 : intval( $result ); } if ( ! empty( $existing_items ) ) { $existing_items = array_flip( $existing_items ); $format = array_fill( 0, count( $existing_items ), '%d' ); $format = implode( ',', $format ); array_unshift( $existing_items, $order_id ); $wpdb->query( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared "DELETE FROM {$table_name} WHERE order_id = %d AND order_item_id in ({$format})", $existing_items ) ); } return ( count( $order_items ) === $num_updated ); } /** * Clean products data when an order is deleted. * * @param int $order_id Order ID. */ public static function sync_on_order_delete( $order_id ) { global $wpdb; $wpdb->delete( self::get_db_table_name(), array( 'order_id' => $order_id ) ); /** * Fires when product's reports are removed from database. * * @param int $product_id Product ID. * @param int $order_id Order ID. */ do_action( 'woocommerce_analytics_delete_product', 0, $order_id ); ReportsCache::invalidate(); } /** * Initialize query objects. */ protected function initialize_queries() { $this->clear_all_clauses(); $this->subquery = new SqlQuery( $this->context . '_subquery' ); $this->subquery->add_sql_clause( 'select', 'product_id' ); $this->subquery->add_sql_clause( 'from', self::get_db_table_name() ); $this->subquery->add_sql_clause( 'group_by', 'product_id' ); } }