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:
parent
115cac05de
commit
5f257ed7d0
|
@ -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
|
|
@ -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'] . '→' . $changed_stock['to'] . ')';
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 );
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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() );
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue