Commit 5b284b73f62 for woocommerce
commit 5b284b73f62bdc9082fef6c1ad861f4b53a9f771
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Fri Aug 28 08:12:22 2026 +0300
Fix sequential coupon calculation order (#68030)
* fix(cart): respect sequential coupon application order
[Context]
Sequential discounts use the order supplied to the discounts engine.
[Problem]
Cart totals reordered equal-priority coupons by amount first, so calculation could diverge from application order and change rounding-sensitive totals.
[Solution]
Use private application positions only after filtered-priority and usage-limit ties, while preserving historical fallbacks and adding cart, tax, Store API, extension, and non-sequential regressions.
Refs #37121
* chore: add sequential coupon ordering changelog
[Context]
WooCommerce package changes require a changelog entry.
[Problem]
The sequential coupon ordering fix needs release metadata for downstream packaging.
[Solution]
Add a patch-level fix entry describing the corrected equal-priority application order.
Refs #37121
* test: simplify sequential coupon coverage
[Context]
The sequential coupon regression tests were added before current
test-base cleanup guaranteed per-test rollback and cart reset.
[Problem]
Each scenario repeated database cleanup, option restoration, and
fixture setup, obscuring the comparator contract and doing redundant
writes. The rounding-sized fixtures also made expected discounts harder
to verify at a glance.
[Solution]
Rely on the test transaction and base teardown, consolidate comparator
branches into named data sets with 100.00 arithmetic, and reuse Store API
fixtures while preserving application-order and fallback coverage.
Refs #37121
* Fix WPCS violations in cart totals coupon sorting test
The coupon sorting data provider added in the previous commit declared
its cart and coupon fixtures as compact inline associative arrays, and
the file has never carried a strict_types declaration.
Both trip sniffs the CI Lint job enforces. phpcs reported 23 errors from
WordPress.Arrays.ArrayDeclarationSpacing, which requires each value of a
keyed array to start on its own line, plus 6 double-arrow alignment
warnings. Warnings fail that job too, so the whole set had to go. The
missing strict_types declaration is a separate error: phpcs.xml scopes
Generic.PHP.RequireStrictTypes over tests/ with no exclusion for this
path, and 750 of the 928 files under tests/php already declare it. Only
phpcs-changed's changed-lines filter had kept it out of the CI report.
Expand the fixture arrays and align the arrows via phpcbf, and declare
strict_types in the style the sibling test files use.
The array reformat is behaviour-preserving: comparing token streams
against the previous revision shows the sole additions are the 23
trailing commas phpcbf appends when closing an expanded array, which are
inert in PHP array literals. strict_types is not cosmetic, since it
disables scalar coercion for calls made from this file and the provider
passes money as strings, so it was verified rather than assumed: the
class passes 14 tests and 35 assertions both before and after, with
--testdox confirming all 7 provider data sets execute. The file now
reports zero phpcs findings.
* Avoid an option read on carts that cannot tie-break coupons
Recording coupon application positions requires knowing whether
sequential discount calculation is enabled, so get_coupons_from_cart()
read woocommerce_calc_discounts_sequentially on every totals
calculation.
That setting is registered with autoload disabled, so the first read in
a request is a database query on stores without a persistent object
cache. The read bought nothing on most carts: uasort() never invokes
the comparator with fewer than two coupons, and WC_Discounts only reads
the same option once a coupon is actually applied. Measured against
trunk, a coupon-free cart calculation went from zero reads to one,
adding a query to the most common cart and checkout state there is.
Read the option only when more than one coupon is present, which
restores parity with trunk for coupon-free and single-coupon carts. The
comparator already falls back to null for a missing position, so
leaving the map empty in those cases changes no behavior.
Refs #37121
* Reuse wc_uasort_comparison() for the coupon position tie-break
The application-position tie-break compared two positions with an
inline ternary that reproduced wc_uasort_comparison() exactly.
WooCommerce already centralises this comparison, and
wc_product_attribute_uasort_comparison() applies it to position values
using the same variable names. Duplicating the logic here leaves the
comparator inconsistent with the rest of core for no benefit.
Delegate the direction comparison to the helper. The surrounding
inequality check stays where it is: the helper answers 0 for equal
values, which is correct for a terminal comparator but wrong here,
where equal positions must fall through to the amount and ID fallback.
A comment records that constraint so the guard is not folded into the
helper later.
Refs #37121
* Cover the rounding-sensitive total in sequential coupon order
The coupon sorting provider used a 100.00 product throughout, chosen so
the attribution arithmetic stays easy to follow.
At that price both application orders settle on a 72.00 cart total and
only the per-coupon split moves, so no assertion in the suite could
fail on the total itself. The customer-visible half of this fix, where
calculation order changes what the shopper pays, had no coverage.
Add a 12.34 pair where intermediate rounding makes the order matter:
20% before 10% yields 2.47 and 0.99 for 8.88, while 10% before 20%
yields 1.23 and 2.22 for 8.89. The first case fails against trunk's
comparator, so it guards the regression; its mirror stays green either
way and acts as the control, because the amount fallback calculates the
10% coupon first regardless of application order. The existing 100.00
scenarios are unchanged.
Refs #37121
diff --git a/plugins/woocommerce/changelog/fix-37121-sequential-coupon-order b/plugins/woocommerce/changelog/fix-37121-sequential-coupon-order
new file mode 100644
index 00000000000..32289c98853
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-37121-sequential-coupon-order
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Make sequential coupons with equal priorities calculate in application order.
diff --git a/plugins/woocommerce/includes/class-wc-cart-totals.php b/plugins/woocommerce/includes/class-wc-cart-totals.php
index 8fd2dfe3ef2..e5613588797 100644
--- a/plugins/woocommerce/includes/class-wc-cart-totals.php
+++ b/plugins/woocommerce/includes/class-wc-cart-totals.php
@@ -68,6 +68,13 @@ final class WC_Cart_Totals {
*/
protected $coupons = array();
+ /**
+ * Coupon application positions keyed by coupon object ID.
+ *
+ * @var array<int, int>
+ */
+ private $coupon_application_positions = array();
+
/**
* Item/coupon discount totals.
*
@@ -360,9 +367,19 @@ final class WC_Cart_Totals {
* @since 3.2.0
*/
protected function get_coupons_from_cart() {
- $this->coupons = $this->cart->get_coupons();
+ $this->coupons = $this->cart->get_coupons();
+ $this->coupon_application_positions = array();
+
+ // The position tie-break only applies once two coupons can tie, and this setting is not autoloaded,
+ // so skip the option read for coupon-free and single-coupon carts.
+ $sequential_discounts = count( $this->coupons ) > 1 && 'yes' === get_option( 'woocommerce_calc_discounts_sequentially' );
+ $position = 0;
foreach ( $this->coupons as $coupon ) {
+ if ( $sequential_discounts ) {
+ $this->coupon_application_positions[ spl_object_id( $coupon ) ] = $position++;
+ }
+
switch ( $coupon->get_discount_type() ) {
case 'fixed_product':
$coupon->sort = 1;
@@ -391,6 +408,7 @@ final class WC_Cart_Totals {
* In order of priority;
* - sort param
* - usage restriction
+ * - application position for sequential discounts
* - coupon value
* - ID
*
@@ -401,6 +419,14 @@ final class WC_Cart_Totals {
protected function sort_coupons_callback( $a, $b ) {
if ( $a->sort === $b->sort ) {
if ( $a->get_limit_usage_to_x_items() === $b->get_limit_usage_to_x_items() ) {
+ $a_position = $this->coupon_application_positions[ spl_object_id( $a ) ] ?? null;
+ $b_position = $this->coupon_application_positions[ spl_object_id( $b ) ] ?? null;
+
+ // Equal positions must fall through to the amount fallback, so the inequality check stays outside the helper.
+ if ( null !== $a_position && null !== $b_position && $a_position !== $b_position ) {
+ return wc_uasort_comparison( $a_position, $b_position );
+ }
+
if ( $a->get_amount() === $b->get_amount() ) {
return $b->get_id() - $a->get_id();
}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-cart-totals-test.php b/plugins/woocommerce/tests/php/includes/class-wc-cart-totals-test.php
index 86d060b8c7a..9e9f62353d2 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-cart-totals-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-cart-totals-test.php
@@ -1,4 +1,5 @@
<?php
+declare( strict_types = 1 );
/**
* Class WC_Cart_Totals_Tests. Tests for WC_Cart_Total class.
@@ -232,6 +233,284 @@ class WC_Cart_Totals_Tests extends WC_Unit_Test_Case {
$coupon->delete( true );
}
+ /**
+ * @testdox Coupon sorting should preserve priority and sequential application-order behavior.
+ * @dataProvider data_provider_test_coupon_sorting_preserves_priority_contract
+ *
+ * @param string $product_price Product price.
+ * @param string $sequential_setting Sequential discount setting value.
+ * @param array $coupon_data Coupon data keyed by fixture name.
+ * @param array $application_order Coupon fixture names in application order.
+ * @param array $expected_discounts Expected per-coupon discounts.
+ * @param string $expected_cart_total Expected cart total.
+ * @param array|null $sort_priorities Optional filtered priorities keyed by discount type.
+ */
+ public function test_coupon_sorting_preserves_priority_contract(
+ string $product_price,
+ string $sequential_setting,
+ array $coupon_data,
+ array $application_order,
+ array $expected_discounts,
+ string $expected_cart_total,
+ ?array $sort_priorities = null
+ ): void {
+ $result = $this->calculate_cart_coupon_scenario( $product_price, $sequential_setting, $coupon_data, $application_order, $sort_priorities );
+
+ $this->assertSame( $expected_discounts, $result['discounts'], 'Each coupon should receive the discount implied by its effective calculation order.' );
+ $this->assertSame( $expected_cart_total, $result['cart_total'], 'The cart total should reflect the expected coupon calculation order.' );
+ }
+
+ /**
+ * Data provider for coupon sorting priorities.
+ *
+ * @return array
+ */
+ public function data_provider_test_coupon_sorting_preserves_priority_contract(): array {
+ return array(
+ 'sequential 20% then 10%' => array(
+ '100.00',
+ 'yes',
+ array(
+ 'high' => array(
+ 'discount_type' => 'percent',
+ 'coupon_amount' => '20',
+ ),
+ 'low' => array(
+ 'discount_type' => 'percent',
+ 'coupon_amount' => '10',
+ ),
+ ),
+ array( 'high', 'low' ),
+ array(
+ 'high' => '20.00',
+ 'low' => '8.00',
+ ),
+ '72.00',
+ ),
+ 'sequential 10% then 20%' => array(
+ '100.00',
+ 'yes',
+ array(
+ 'high' => array(
+ 'discount_type' => 'percent',
+ 'coupon_amount' => '20',
+ ),
+ 'low' => array(
+ 'discount_type' => 'percent',
+ 'coupon_amount' => '10',
+ ),
+ ),
+ array( 'low', 'high' ),
+ array(
+ 'high' => '18.00',
+ 'low' => '10.00',
+ ),
+ '72.00',
+ ),
+ // The 100.00 fixtures above hold the cart total steady in both orders, so these two prove
+ // the calculation order also moves the total once intermediate rounding is in play.
+ 'sequential rounding 20% then 10%' => array(
+ '12.34',
+ 'yes',
+ array(
+ 'high' => array(
+ 'discount_type' => 'percent',
+ 'coupon_amount' => '20',
+ ),
+ 'low' => array(
+ 'discount_type' => 'percent',
+ 'coupon_amount' => '10',
+ ),
+ ),
+ array( 'high', 'low' ),
+ array(
+ 'high' => '2.47',
+ 'low' => '0.99',
+ ),
+ '8.88',
+ ),
+ 'sequential rounding 10% then 20%' => array(
+ '12.34',
+ 'yes',
+ array(
+ 'high' => array(
+ 'discount_type' => 'percent',
+ 'coupon_amount' => '20',
+ ),
+ 'low' => array(
+ 'discount_type' => 'percent',
+ 'coupon_amount' => '10',
+ ),
+ ),
+ array( 'low', 'high' ),
+ array(
+ 'high' => '2.22',
+ 'low' => '1.23',
+ ),
+ '8.89',
+ ),
+ 'non-sequential amount fallback' => array(
+ '100.00',
+ 'no',
+ array(
+ 'large' => array(
+ 'discount_type' => 'fixed_cart',
+ 'coupon_amount' => '80',
+ ),
+ 'small' => array(
+ 'discount_type' => 'fixed_cart',
+ 'coupon_amount' => '30',
+ ),
+ ),
+ array( 'large', 'small' ),
+ array(
+ 'large' => '70.00',
+ 'small' => '30.00',
+ ),
+ '0.00',
+ ),
+ 'standard coupon type priority' => array(
+ '100.00',
+ 'yes',
+ array(
+ 'fixed' => array(
+ 'discount_type' => 'fixed_cart',
+ 'coupon_amount' => '30',
+ ),
+ 'percent' => array(
+ 'discount_type' => 'percent',
+ 'coupon_amount' => '10',
+ ),
+ ),
+ array( 'fixed', 'percent' ),
+ array(
+ 'fixed' => '30.00',
+ 'percent' => '10.00',
+ ),
+ '60.00',
+ ),
+ 'usage-limit priority' => array(
+ '100.00',
+ 'yes',
+ array(
+ 'broad' => array(
+ 'discount_type' => 'percent',
+ 'coupon_amount' => '20',
+ 'limit_usage_to_x_items' => 2,
+ ),
+ 'narrow' => array(
+ 'discount_type' => 'percent',
+ 'coupon_amount' => '10',
+ 'limit_usage_to_x_items' => 1,
+ ),
+ ),
+ array( 'broad', 'narrow' ),
+ array(
+ 'broad' => '18.00',
+ 'narrow' => '10.00',
+ ),
+ '72.00',
+ ),
+ 'distinct filtered priorities' => array(
+ '100.00',
+ 'yes',
+ array(
+ 'percent' => array(
+ 'discount_type' => 'percent',
+ 'coupon_amount' => '10',
+ ),
+ 'fixed' => array(
+ 'discount_type' => 'fixed_cart',
+ 'coupon_amount' => '30',
+ ),
+ ),
+ array( 'percent', 'fixed' ),
+ array(
+ 'percent' => '7.00',
+ 'fixed' => '30.00',
+ ),
+ '63.00',
+ array(
+ 'fixed_cart' => 1,
+ 'percent' => 2,
+ ),
+ ),
+ 'equal filtered priorities' => array(
+ '100.00',
+ 'yes',
+ array(
+ 'fixed' => array(
+ 'discount_type' => 'fixed_cart',
+ 'coupon_amount' => '30',
+ ),
+ 'percent' => array(
+ 'discount_type' => 'percent',
+ 'coupon_amount' => '10',
+ ),
+ ),
+ array( 'fixed', 'percent' ),
+ array(
+ 'fixed' => '30.00',
+ 'percent' => '7.00',
+ ),
+ '63.00',
+ array(
+ 'fixed_cart' => 5,
+ 'percent' => 5,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Calculate normalized totals for a cart coupon scenario.
+ *
+ * @param string $product_price Product price.
+ * @param string $sequential_setting Sequential discount setting value.
+ * @param array $coupon_data Coupon data keyed by fixture name.
+ * @param array $application_order Coupon fixture names in application order.
+ * @param array|null $sort_priorities Optional filtered priorities keyed by discount type.
+ * @return array
+ */
+ private function calculate_cart_coupon_scenario( string $product_price, string $sequential_setting, array $coupon_data, array $application_order, ?array $sort_priorities ): array {
+ update_option( 'woocommerce_calc_discounts_sequentially', $sequential_setting );
+ update_option( 'woocommerce_calc_taxes', 'no' );
+
+ $product = WC_Helper_Product::create_simple_product( true, array( 'regular_price' => $product_price ) );
+ $coupons = array();
+ foreach ( $coupon_data as $coupon_key => $data ) {
+ $coupons[ $coupon_key ] = WC_Helper_Coupon::create_coupon( 'test-' . $coupon_key, $data );
+ }
+
+ $sort_filter = null;
+ if ( null !== $sort_priorities ) {
+ $sort_filter = static function ( $sort, $coupon ) use ( $sort_priorities ) {
+ return $sort_priorities[ $coupon->get_discount_type() ] ?? $sort;
+ };
+ add_filter( 'woocommerce_coupon_sort', $sort_filter, 10, 2 );
+ }
+
+ WC()->cart->add_to_cart( $product->get_id() );
+ foreach ( $application_order as $coupon_key ) {
+ WC()->cart->apply_coupon( $coupons[ $coupon_key ]->get_code() );
+ }
+ WC()->cart->calculate_totals();
+
+ if ( null !== $sort_filter ) {
+ remove_filter( 'woocommerce_coupon_sort', $sort_filter, 10 );
+ }
+
+ $discounts = array();
+ foreach ( $coupons as $coupon_key => $coupon ) {
+ $discounts[ $coupon_key ] = wc_format_decimal( WC()->cart->get_coupon_discount_amount( $coupon->get_code() ), 2 );
+ }
+
+ return array(
+ 'discounts' => $discounts,
+ 'cart_total' => wc_format_decimal( WC()->cart->get_total( 'edit' ), 2 ),
+ );
+ }
+
/**
* A fixed_product $7 coupon on a $20 product yields a $7 discount and $13 total.
*/
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartCoupons.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartCoupons.php
index 29906c4c020..29b58d53966 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartCoupons.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartCoupons.php
@@ -56,6 +56,57 @@ class CartCoupons extends ControllerTestCase {
);
}
+ /**
+ * @testdox Sequential percentage coupons should be projected in application order with exact discount totals.
+ */
+ public function test_get_items_projects_sequential_percentage_coupons_in_application_order(): void {
+ update_option( 'woocommerce_calc_discounts_sequentially', 'yes' );
+ update_option( 'woocommerce_calc_taxes', 'no' );
+ wc_empty_cart();
+
+ $this->product->set_regular_price( '100.00' );
+ $this->product->set_price( '100.00' );
+ $this->product->set_tax_status( 'none' );
+ $this->product->save();
+ $this->coupon->set_discount_type( 'percent' );
+ $this->coupon->set_amount( '20' );
+ $this->coupon->save();
+
+ $low_coupon = ( new FixtureData() )->get_coupon(
+ array(
+ 'code' => 'sequential-low',
+ 'discount_type' => 'percent',
+ 'amount' => '10',
+ )
+ );
+
+ wc()->cart->add_to_cart( $this->product->get_id() );
+ wc()->cart->apply_coupon( $this->coupon->get_code() );
+ wc()->cart->apply_coupon( $low_coupon->get_code() );
+ wc()->cart->calculate_totals();
+
+ $this->assertAPIResponse(
+ '/wc/store/v1/cart/coupons',
+ 200,
+ array(
+ array(
+ 'code' => $this->coupon->get_code(),
+ 'totals' => array(
+ 'total_discount' => '2000',
+ 'total_discount_tax' => '0',
+ ),
+ ),
+ array(
+ 'code' => $low_coupon->get_code(),
+ 'totals' => array(
+ 'total_discount' => '800',
+ 'total_discount_tax' => '0',
+ ),
+ ),
+ )
+ );
+ }
+
/**
* Test getting cart item by key.
*/