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.
 	 */