Fix inconsistent order total on checkout vs manual order page (#33812)

* fix: tax calculation and coupon sequence

* add phpunit skeleton

* Refactor calc_line_taxes and add_coupon_discount

The methods are moved to separate classes in src/Internal,
and two new "core" methods that exclude HTTP processing are added.

* The test partially passes now

* Final fix to unit tests, and fix remaining formatting issues

* Add changelog file

* Fix path to html-order-items.php

Co-authored-by: Siddharth Thevaril <siddharth.thevaril@gmail.com>
This commit is contained in:
Néstor Soriano 2022-08-17 11:26:28 +02:00 committed by GitHub
parent 115cac05de
commit 5f257ed7d0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 254 additions and 84 deletions

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Fix inconsistent order total on checkout vs manual order page when the shop is configured for tax-inclusive prices and a coupon is applied

View File

@ -100,7 +100,7 @@ function wc_create_page( $slug, $option = '', $page_title = '', $page_content =
if ( strlen( $page_content ) > 0 ) {
// Search for an existing page with the specified page content (typically a shortcode).
$shortcode = str_replace( array( '<!-- wp:shortcode -->', '<!-- /wp:shortcode -->' ), '', $page_content );
$shortcode = str_replace( array( '<!-- wp:shortcode -->', '<!-- /wp:shortcode -->' ), '', $page_content );
$valid_page_found = $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM $wpdb->posts WHERE post_type='page' AND post_status NOT IN ( 'pending', 'trash', 'future', 'auto-draft' ) AND post_content LIKE %s LIMIT 1;", "%{$shortcode}%" ) );
} else {
// Search for an existing page with the specified page slug.
@ -365,7 +365,7 @@ function wc_save_order_items( $order_id, $items ) {
$item->save();
if ( in_array( $order->get_status(), array( 'processing', 'completed', 'on-hold' ) ) ) {
if ( in_array( $order->get_status(), array( 'processing', 'completed', 'on-hold' ), true ) ) {
$changed_stock = wc_maybe_adjust_line_item_product_stock( $item );
if ( $changed_stock && ! is_wp_error( $changed_stock ) ) {
$qty_change_order_notes[] = $item->get_name() . ' (' . $changed_stock['from'] . '&rarr;' . $changed_stock['to'] . ')';

View File

@ -7,6 +7,8 @@
*/
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Orders\CouponsController;
use Automattic\WooCommerce\Internal\Orders\TaxesController;
use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\CustomMetaBox;
use Automattic\WooCommerce\Utilities\NumberUtil;
@ -1161,61 +1163,7 @@ class WC_AJAX {
* @throws Exception If order or coupon is invalid.
*/
public static function add_coupon_discount() {
check_ajax_referer( 'order-item', 'security' );
if ( ! current_user_can( 'edit_shop_orders' ) ) {
wp_die( -1 );
}
$response = array();
try {
$order_id = isset( $_POST['order_id'] ) ? absint( $_POST['order_id'] ) : 0;
$order = wc_get_order( $order_id );
$calculate_tax_args = array(
'country' => isset( $_POST['country'] ) ? wc_strtoupper( wc_clean( wp_unslash( $_POST['country'] ) ) ) : '',
'state' => isset( $_POST['state'] ) ? wc_strtoupper( wc_clean( wp_unslash( $_POST['state'] ) ) ) : '',
'postcode' => isset( $_POST['postcode'] ) ? wc_strtoupper( wc_clean( wp_unslash( $_POST['postcode'] ) ) ) : '',
'city' => isset( $_POST['city'] ) ? wc_strtoupper( wc_clean( wp_unslash( $_POST['city'] ) ) ) : '',
);
if ( ! $order ) {
throw new Exception( __( 'Invalid order', 'woocommerce' ) );
}
if ( empty( $_POST['coupon'] ) ) {
throw new Exception( __( 'Invalid coupon', 'woocommerce' ) );
}
// Add user ID and/or email so validation for coupon limits works.
$user_id_arg = isset( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : 0;
$user_email_arg = isset( $_POST['user_email'] ) ? sanitize_email( wp_unslash( $_POST['user_email'] ) ) : '';
if ( $user_id_arg ) {
$order->set_customer_id( $user_id_arg );
}
if ( $user_email_arg ) {
$order->set_billing_email( $user_email_arg );
}
$result = $order->apply_coupon( wc_format_coupon_code( wp_unslash( $_POST['coupon'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
if ( is_wp_error( $result ) ) {
throw new Exception( html_entity_decode( wp_strip_all_tags( $result->get_error_message() ) ) );
}
$order->calculate_taxes( $calculate_tax_args );
$order->calculate_totals( false );
ob_start();
include __DIR__ . '/admin/meta-boxes/views/html-order-items.php';
$response['html'] = ob_get_clean();
} catch ( Exception $e ) {
wp_send_json_error( array( 'error' => $e->getMessage() ) );
}
// wp_send_json_success must be outside the try block not to break phpunit tests.
wp_send_json_success( $response );
wc_get_container()->get( CouponsController::class )->add_coupon_discount_via_ajax();
}
/**
@ -1418,33 +1366,7 @@ class WC_AJAX {
* Calc line tax.
*/
public static function calc_line_taxes() {
check_ajax_referer( 'calc-totals', 'security' );
if ( ! current_user_can( 'edit_shop_orders' ) || ! isset( $_POST['order_id'], $_POST['items'] ) ) {
wp_die( -1 );
}
$order_id = absint( $_POST['order_id'] );
$calculate_tax_args = array(
'country' => isset( $_POST['country'] ) ? wc_strtoupper( wc_clean( wp_unslash( $_POST['country'] ) ) ) : '',
'state' => isset( $_POST['state'] ) ? wc_strtoupper( wc_clean( wp_unslash( $_POST['state'] ) ) ) : '',
'postcode' => isset( $_POST['postcode'] ) ? wc_strtoupper( wc_clean( wp_unslash( $_POST['postcode'] ) ) ) : '',
'city' => isset( $_POST['city'] ) ? wc_strtoupper( wc_clean( wp_unslash( $_POST['city'] ) ) ) : '',
);
// Parse the jQuery serialized items.
$items = array();
parse_str( wp_unslash( $_POST['items'] ), $items ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
// Save order items first.
wc_save_order_items( $order_id, $items );
// Grab the order and recalculate taxes.
$order = wc_get_order( $order_id );
$order->calculate_taxes( $calculate_tax_args );
$order->calculate_totals( false );
include __DIR__ . '/admin/meta-boxes/views/html-order-items.php';
wp_die();
wc_get_container()->get( TaxesController::class )->calc_line_taxes_via_ajax();
}
/**

View File

@ -9,6 +9,7 @@ use Automattic\WooCommerce\Internal\DependencyManagement\ExtendedContainer;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\COTMigrationServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\DownloadPermissionsAdjusterServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\AssignDefaultCategoryServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\OrdersControllersServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\OrderMetaBoxServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\ObjectCacheServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\OrdersDataStoreServiceProvider;
@ -56,6 +57,7 @@ final class Container {
RestockRefundedItemsAdjusterServiceProvider::class,
UtilsClassesServiceProvider::class,
COTMigrationServiceProvider::class,
OrdersControllersServiceProvider::class,
ObjectCacheServiceProvider::class,
BatchProcessingServiceProvider::class,
OrderMetaBoxServiceProvider::class,

View File

@ -0,0 +1,34 @@
<?php
/**
* OrdersControllersServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\Orders\CouponsController;
use Automattic\WooCommerce\Internal\Orders\TaxesController;
/**
* Service provider for the orders controller classes in the Automattic\WooCommerce\Internal\Orders namespace.
*/
class OrdersControllersServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
CouponsController::class,
TaxesController::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( CouponsController::class );
$this->share( TaxesController::class );
}
}

View File

@ -0,0 +1,85 @@
<?php
namespace Automattic\WooCommerce\Internal\Orders;
/**
* Class with methods for handling order coupons.
*/
class CouponsController {
/**
* Add order discount via Ajax.
*
* @throws Exception If order or coupon is invalid.
*/
public function add_coupon_discount_via_ajax(): void {
check_ajax_referer( 'order-item', 'security' );
if ( ! current_user_can( 'edit_shop_orders' ) ) {
wp_die( -1 );
}
$response = array();
try {
$order = $this->add_coupon_discount( $_POST );
ob_start();
include __DIR__ . '/../../../includes/admin/meta-boxes/views/html-order-items.php';
$response['html'] = ob_get_clean();
} catch ( Exception $e ) {
wp_send_json_error( array( 'error' => $e->getMessage() ) );
}
// wp_send_json_success must be outside the try block not to break phpunit tests.
wp_send_json_success( $response );
}
/**
* Add order discount programmatically.
*
* @param array $post_variables Contents of the $_POST array that would be passed in an Ajax call.
* @return object The retrieved order object.
* @throws \Exception Invalid order or coupon.
*/
public function add_coupon_discount( array $post_variables ): object {
$order_id = isset( $post_variables['order_id'] ) ? absint( $post_variables['order_id'] ) : 0;
$order = wc_get_order( $order_id );
$calculate_tax_args = array(
'country' => isset( $post_variables['country'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['country'] ) ) ) : '',
'state' => isset( $post_variables['state'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['state'] ) ) ) : '',
'postcode' => isset( $post_variables['postcode'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['postcode'] ) ) ) : '',
'city' => isset( $post_variables['city'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['city'] ) ) ) : '',
);
if ( ! $order ) {
throw new Exception( __( 'Invalid order', 'woocommerce' ) );
}
if ( empty( $post_variables['coupon'] ) ) {
throw new Exception( __( 'Invalid coupon', 'woocommerce' ) );
}
// Add user ID and/or email so validation for coupon limits works.
$user_id_arg = isset( $post_variables['user_id'] ) ? absint( $post_variables['user_id'] ) : 0;
$user_email_arg = isset( $post_variables['user_email'] ) ? sanitize_email( wp_unslash( $post_variables['user_email'] ) ) : '';
if ( $user_id_arg ) {
$order->set_customer_id( $user_id_arg );
}
if ( $user_email_arg ) {
$order->set_billing_email( $user_email_arg );
}
$order->calculate_taxes( $calculate_tax_args );
$order->calculate_totals( false );
$result = $order->apply_coupon( wc_format_coupon_code( wp_unslash( $post_variables['coupon'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
if ( is_wp_error( $result ) ) {
throw new Exception( html_entity_decode( wp_strip_all_tags( $result->get_error_message() ) ) );
}
return $order;
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace Automattic\WooCommerce\Internal\Orders;
/**
* Class with methods for handling order taxes.
*/
class TaxesController {
/**
* Calculate line taxes via Ajax call.
*/
public function calc_line_taxes_via_ajax(): void {
check_ajax_referer( 'calc-totals', 'security' );
if ( ! current_user_can( 'edit_shop_orders' ) || ! isset( $_POST['order_id'], $_POST['items'] ) ) {
wp_die( -1 );
}
$order = $this->calc_line_taxes( $_POST );
include __DIR__ . '/../../../includes/admin/meta-boxes/views/html-order-items.php';
wp_die();
}
/**
* Calculate line taxes programmatically.
*
* @param array $post_variables Contents of the $_POST array that would be passed in an Ajax call.
* @return object The retrieved order object.
*/
public function calc_line_taxes( array $post_variables ): object {
$order_id = absint( $post_variables['order_id'] );
$calculate_tax_args = array(
'country' => isset( $post_variables['country'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['country'] ) ) ) : '',
'state' => isset( $post_variables['state'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['state'] ) ) ) : '',
'postcode' => isset( $post_variables['postcode'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['postcode'] ) ) ) : '',
'city' => isset( $post_variables['city'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['city'] ) ) ) : '',
);
// Parse the jQuery serialized items.
$items = array();
parse_str( wp_unslash( $post_variables['items'] ), $items );
// Save order items first.
wc_save_order_items( $order_id, $items );
// Grab the order and recalculate taxes.
$order = wc_get_order( $order_id );
$order->calculate_taxes( $calculate_tax_args );
$order->calculate_totals( false );
return $order;
}
}

View File

@ -5,6 +5,9 @@
* @package WooCommerce\Tests\WC_AJAX.
*/
use Automattic\WooCommerce\Internal\Orders\CouponsController;
use Automattic\WooCommerce\Internal\Orders\TaxesController;
/**
* Class WC_AJAX_Test file.
*/
@ -124,4 +127,69 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
$this->markTestSkipped( 'Waiting for WordPress compatibility with PHP 8.1' );
}
}
/**
* Test coupon and recalculation of totals sequences when product prices are tax inclusive.
*/
public function test_apply_coupon_with_tax_inclusive_settings() {
update_option( 'woocommerce_prices_include_tax', 'yes' );
update_option( 'woocommerce_tax_based_on', 'base' );
update_option( 'woocommerce_calc_taxes', 'yes' );
update_option( 'woocommerce_default_country', 'IN:AP' );
$tax_rate = array(
'tax_rate_country' => 'IN',
'tax_rate_state' => '',
'tax_rate' => '20',
'tax_rate_name' => 'tax',
'tax_rate_order' => '1',
'tax_rate_class' => '',
);
WC_Tax::_insert_tax_rate( $tax_rate );
$product = WC_Helper_Product::create_simple_product();
$product->set_regular_price( 120 );
$product->save();
$coupon = new WC_Coupon();
$coupon->set_code( '10off' );
$coupon->set_discount_type( 'percent' );
$coupon->set_amount( 10 );
$coupon->save();
$order = wc_create_order();
$order->add_product( $product, 1 );
$container = wc_get_container();
$coupons_controller = $container->get( CouponsController::class );
$taxes_controller = $container->get( TaxesController::class );
$item = current( $order->get_items() );
$item_id = $item->get_id();
$items_array = array(
'order_item_id' => array( $item_id ),
'order_item_qty' => array( $item_id => $item->get_quantity() ),
'line_subtotal' => array( $item_id => $item->get_subtotal() ),
'line_total' => array( $item_id => $item->get_total() ),
);
$calc_taxes_post_variables = array(
'order_id' => $order->get_id(),
'items' => http_build_query( $items_array ),
'country' => $tax_rate['tax_rate_country'],
'state' => $tax_rate['tax_rate_state'],
);
$add_coupon_post_variables = array(
'order_id' => $order->get_id(),
'coupon' => $coupon->get_code(),
);
$taxes_controller->calc_line_taxes( $calc_taxes_post_variables );
$coupons_controller->add_coupon_discount( $add_coupon_post_variables );
$order = wc_get_order( $order->get_id() );
$this->assertEquals( 108, $order->get_total() );
}
}