Commit 71ad1c3f20b for woocommerce

commit 71ad1c3f20b3a41978d1a56b5ec86e0c54faa080
Author: Oleksandr Aratovskyi <79862886+oaratovskyi@users.noreply.github.com>
Date:   Thu Jul 30 11:17:37 2026 +0300

    Fix coupon updated props accumulating across saves (#66724)

    * test: cover updated props reporting on coupon saves

    WC_Coupon_Data_Store_CPT has no test file, and no test anywhere in the
    repo covers the woocommerce_coupon_object_updated_props payload.

    Add coverage asserting that each save reports only the props that save
    changed: across repeated saves, on a no-op save, when different props
    change, across a create followed by an update, and for a save triggered
    from inside the hook.

    Refs #39281

    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

    * fix: reset coupon updated props at the start of each meta update

    update_post_meta() appended each written prop to the instance property
    $updated_props and fired woocommerce_coupon_object_updated_props without
    ever resetting it. Saving the same WC_Coupon object repeatedly therefore
    handed listeners a growing array with duplicate entries, and a save that
    changed nothing still reported the previous save's props as updated.

    Reset the array at the start of each call, so the hook reports only what
    that save changed. Resetting on entry rather than after the do_action
    also keeps the payload correct when a listener saves the same coupon from
    inside the hook.

    Refs #39281

    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

    * chore: add changelog for coupon updated props reset

    Refs #39281

    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

    * fix: isolate coupon updated props per save invocation

    Metadata hooks can re-enter the coupon data store while an outer save is still writing properties.

    Using the shared property as the accumulator lets nested saves erase or contaminate the outer hook payload.

    Accumulate changes locally per invocation, synchronize the protected property at the hook boundary, and cover mid-loop re-entrancy.

    Refs #39281

    * docs: explain why coupon updated props are restored after the hook

    The assignment after do_action() is textually identical to the one before
    it, so it reads as a redundant duplicate that a later reader could delete
    without any test failing.

    It is load-bearing. A listener may trigger a nested save, whose own
    assignments run to completion before this call resumes. Because PHP unwinds
    LIFO, the outermost call's write is the last to run, which is what makes the
    property hold the most recently completed call's props. Removing it leaves a
    nested save's props behind and falsifies the property docblock.

    Refs #39281

    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

    * test: pin the coupon updated props restore after a nested save

    The six existing tests all assert on the hook's second argument, which the
    invocation-local array already isolates. None reads the protected
    $updated_props property, so deleting either assignment to it passed the whole
    suite.

    Cover it by injecting a data store subclass through the
    woocommerce_coupon_data_store filter, which accepts an object, and exposing
    the property. A listener triggers a nested save, and the test asserts the
    store settles on the outer save's props. Verified to fail without the restore
    after do_action, reporting the nested save's props instead.

    Refs #39281

    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

    * test: note the meta key order the nested-save payloads depend on

    The expected payloads encode that coupon_amount is written before
    individual_use, which is incidental to the declaration order of the
    $meta_key_to_props map in update_post_meta().

    Reordering that map for unrelated reasons changes which write re-enters, so
    the test would fail with no visible connection to the cause. Name the
    dependency so the next reader does not have to rediscover it.

    Refs #39281

    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

    * Simplify coupon updated props to a local variable

    The coupon data store kept the updated props in a $this->updated_props
    instance property, unlike every sibling store (customer, order, refund,
    payment-token) which accumulates into a local variable and passes it
    straight to the hook. Nothing in production reads the property and no
    production subclass exists, so the instance state only added a second
    assignment, a nested-save restore, and a test guarding it.

    Drop the property and accumulate into a local variable, matching the
    siblings. Also remove the test that only existed to read the now-gone
    property.

    * Assert coupon updated-props isolation regardless of meta order

    The nested-save isolation test pinned the exact payload order with
    assertSame, but the inner order (amount before individual_use) only
    reflects the $meta_key_to_props declaration order, which the fix does
    not guarantee. Reordering that map would break the test even though the
    isolation guarantee still holds.

    Compare canonicalized so the test asserts each payload holds only its
    own save's props, independent of the incidental map order.

    * Pin nested-save payload slots in coupon props isolation test

    Comparing the nested-save payloads canonicalized dropped the incidental
    $meta_key_to_props order dependency, but it was applied to the whole
    nested structure. PHPUnit canonicalizes recursively — ArrayComparator
    sorts the outer array before recursing with the flag still set — so the
    assertion also stopped pinning which save's payload landed in which
    slot, and passed even when the two arrived in the opposite order.

    That order is a real guarantee, unlike the order of props within a
    payload: the nested save runs to completion inside the outer save's
    update_or_delete_post_meta() call, so its hook always fires first,
    whichever meta key triggers the re-entry.

    Assert the two slots separately so the firing order stays pinned while
    each payload's contents still compare canonicalized.

    * Restore coupon updated props property as deprecated

    Simplifying the store to a local variable also dropped the protected
    $updated_props declaration. The two are separable: the shared state was
    the bug, the declaration was not.

    That declaration was added in 4.1.0 by bd53e2eb to fix an undefined
    property error, so dropping it reverts that fix. Reading an undeclared
    property yields null, and on PHP 8 both in_array( 'x', $this->updated_props )
    and count( $this->updated_props ) raise a TypeError rather than a warning.
    The idiom is live in the ecosystem: Product Bundles, Bookings, Product
    Recommendations and ATUM all read the identically named property on the
    product store, five in_array calls in Product Bundles alone.

    Declare it again, mark it deprecated, and leave it unwritten, so a
    subclass still reading it gets an empty array instead of a fatal.

    * Update changelog entry for the updated props deprecation

    The entry described only the hook fix. The deprecation is the part
    third-party code has to act on, and one that never reaches the release
    notes is invisible to exactly the people it concerns.

    Name the property and point at the hook argument that replaces it.

    * Mirror coupon updated props onto the deprecated property

    The property was restored as declared-but-never-written, so a subclass
    still reading it got an empty array. That is a silent change: the read
    keeps working but quietly stops matching, which is harder to notice than
    an error would be.

    Assign the current save's props to it just before
    woocommerce_coupon_object_updated_props and empty it right after, the
    shape WC_Product_Data_Store_CPT already uses. Readers inside the hook
    keep working through the deprecation window, and nothing carries across
    saves because the property is empty everywhere else.

    Cover both assignments with a test, so dropping either one fails.

    * Update changelog entry for the updated props mirror

    The entry said the deprecated property is now always empty. It holds the
    current save's props for the duration of the hook, which is exactly the
    part a reader inside that hook depends on.

    ---------

    Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/39281-fix-coupon-updated-props-reset b/plugins/woocommerce/changelog/39281-fix-coupon-updated-props-reset
new file mode 100644
index 00000000000..ff487dfaab8
--- /dev/null
+++ b/plugins/woocommerce/changelog/39281-fix-coupon-updated-props-reset
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix the woocommerce_coupon_object_updated_props hook so it reports only the props changed by the current save, instead of an array that grows with duplicates across repeated saves of the same coupon. WC_Coupon_Data_Store_CPT::$updated_props is deprecated: it now holds the current save's props only while that hook runs, and is empty otherwise. Read the hook's second argument instead.
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-coupon-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-coupon-data-store-cpt.php
index 86c909651c8..3f512bbc011 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-coupon-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-coupon-data-store-cpt.php
@@ -55,9 +55,17 @@ class WC_Coupon_Data_Store_CPT extends WC_Data_Store_WP implements WC_Coupon_Dat
 	);

 	/**
-	 * The updated coupon properties
+	 * The updated coupon properties.
+	 *
+	 * Each save now records its updated properties in a local variable, so this
+	 * property no longer accumulates across saves. It is populated with the current
+	 * save's properties only for the duration of the
+	 * woocommerce_coupon_object_updated_props call, and emptied again afterwards, so
+	 * that code still reading it during that hook keeps working. Read the hook's
+	 * second argument instead.
 	 *
 	 * @since 4.1.0
+	 * @deprecated 11.1.0 Use the second argument of woocommerce_coupon_object_updated_props.
 	 * @var array
 	 */
 	protected $updated_props = array();
@@ -263,6 +271,8 @@ class WC_Coupon_Data_Store_CPT extends WC_Data_Store_WP implements WC_Coupon_Dat
 	 * @since 3.0.0
 	 */
 	private function update_post_meta( &$coupon ) {
+		$updated_props = array();
+
 		$meta_key_to_props = array(
 			'discount_type'              => 'discount_type',
 			'coupon_amount'              => 'amount',
@@ -312,11 +322,26 @@ class WC_Coupon_Data_Store_CPT extends WC_Data_Store_WP implements WC_Coupon_Dat
 			$updated = $this->update_or_delete_post_meta( $coupon, $meta_key, $value );

 			if ( $updated ) {
-				$this->updated_props[] = $prop;
+				$updated_props[] = $prop;
 			}
 		}

-		do_action( 'woocommerce_coupon_object_updated_props', $coupon, $this->updated_props );
+		// Mirror the payload onto the deprecated property so that code still reading it
+		// during the hook sees this save's properties, then empty it again so nothing
+		// carries over to the next save.
+		$this->updated_props = $updated_props;
+
+		/**
+		 * Fires after a coupon's properties have been updated.
+		 *
+		 * @param WC_Coupon $coupon        Coupon object.
+		 * @param string[]  $updated_props Array of updated properties.
+		 *
+		 * @since 3.0.0
+		 */
+		do_action( 'woocommerce_coupon_object_updated_props', $coupon, $updated_props );
+
+		$this->updated_props = array();
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-coupon-data-store-cpt-test.php b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-coupon-data-store-cpt-test.php
new file mode 100644
index 00000000000..3b762162948
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-coupon-data-store-cpt-test.php
@@ -0,0 +1,283 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * Tests for the WC_Coupon_Data_Store_CPT class.
+ *
+ * @package WooCommerce\Tests\DataStores
+ */
+
+/**
+ * Class WC_Coupon_Data_Store_CPT_Test.
+ */
+class WC_Coupon_Data_Store_CPT_Test extends WC_Unit_Test_Case {
+
+	/**
+	 * The payload of every woocommerce_coupon_object_updated_props fire, in order.
+	 *
+	 * @var array[]
+	 */
+	private $captured_payloads = array();
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->captured_payloads = array();
+	}
+
+	/**
+	 * Record the payload of every woocommerce_coupon_object_updated_props fire.
+	 *
+	 * Registered at priority 10 so that it observes the outer payload before any
+	 * listener registered at a later priority can trigger a nested save.
+	 */
+	private function capture_updated_props(): void {
+		add_action(
+			'woocommerce_coupon_object_updated_props',
+			function ( $coupon, $updated_props ) {
+				$this->captured_payloads[] = $updated_props;
+			},
+			10,
+			2
+		);
+	}
+
+	/**
+	 * Create a coupon and save it once before any assertion.
+	 *
+	 * WC_Helper_Coupon::create_coupon() writes every mapped meta key except
+	 * date_expires (it writes the legacy expiry_date instead). On the first save
+	 * that absent row is created with a null value, which counts as an update and
+	 * puts 'date_expires' in the payload. This throwaway save creates the row, and
+	 * reloading the coupon gives the assertions a fresh data store without that
+	 * throwaway write in its updated-props accumulator.
+	 *
+	 * @return WC_Coupon
+	 */
+	private function create_settled_coupon(): WC_Coupon {
+		$coupon = WC_Helper_Coupon::create_coupon( 'updated-props-test' );
+		$coupon->save();
+
+		return new WC_Coupon( $coupon->get_id() );
+	}
+
+	/**
+	 * @testdox Should not accumulate props across repeated saves of the same coupon object.
+	 */
+	public function test_updated_props_do_not_accumulate_across_saves(): void {
+		$coupon = $this->create_settled_coupon();
+		$this->capture_updated_props();
+
+		$coupon->set_amount( 5 );
+		$coupon->save();
+
+		$coupon->set_amount( 10 );
+		$coupon->save();
+
+		$this->assertSame(
+			array( array( 'amount' ), array( 'amount' ) ),
+			$this->captured_payloads,
+			'Each save should report only the props that save changed, with no duplicates carried over.'
+		);
+	}
+
+	/**
+	 * @testdox Should report an empty prop list for a save that changes nothing.
+	 */
+	public function test_no_op_save_reports_no_updated_props(): void {
+		$coupon = $this->create_settled_coupon();
+		$this->capture_updated_props();
+
+		$coupon->set_amount( 5 );
+		$coupon->save();
+
+		$coupon->save();
+
+		$this->assertSame(
+			array( array( 'amount' ), array() ),
+			$this->captured_payloads,
+			'A save that changes nothing must not report props left over from an earlier save.'
+		);
+	}
+
+	/**
+	 * @testdox Should report only the current save's props when different props change.
+	 */
+	public function test_each_save_reports_only_its_own_props(): void {
+		$coupon = $this->create_settled_coupon();
+		$this->capture_updated_props();
+
+		$coupon->set_amount( 5 );
+		$coupon->save();
+
+		$coupon->set_usage_limit( 3 );
+		$coupon->save();
+
+		$this->assertSame(
+			array( array( 'amount' ), array( 'usage_limit' ) ),
+			$this->captured_payloads,
+			'The second save should report usage_limit only, not the amount from the first save.'
+		);
+	}
+
+	/**
+	 * @testdox Should not carry props from a create into a subsequent update.
+	 */
+	public function test_update_after_create_reports_only_changed_props(): void {
+		$this->capture_updated_props();
+
+		$coupon = new WC_Coupon();
+		$coupon->set_code( 'create-then-update' );
+		$coupon->set_amount( 5 );
+		$coupon->save();
+
+		$coupon->set_amount( 10 );
+		$coupon->save();
+
+		$this->assertCount( 2, $this->captured_payloads, 'Create and update should each fire the action exactly once.' );
+		$this->assertContains( 'amount', $this->captured_payloads[0], 'The create should report the amount it wrote.' );
+		$this->assertSame(
+			array( 'amount' ),
+			$this->captured_payloads[1],
+			'The update should report amount only, not the props written during the create.'
+		);
+	}
+
+	/**
+	 * @testdox Should report only its own props for a save triggered from inside the hook.
+	 */
+	public function test_nested_save_from_listener_reports_only_its_own_props(): void {
+		$coupon = $this->create_settled_coupon();
+		$this->capture_updated_props();
+
+		$nested_save_done = false;
+
+		add_action(
+			'woocommerce_coupon_object_updated_props',
+			function ( $listener_coupon ) use ( &$nested_save_done ) {
+				if ( $nested_save_done ) {
+					return;
+				}
+				$nested_save_done = true;
+
+				$listener_coupon->set_usage_limit( 3 );
+				$listener_coupon->save();
+			},
+			20,
+			2
+		);
+
+		$coupon->set_amount( 5 );
+		$coupon->save();
+
+		$this->assertSame(
+			array( array( 'amount' ), array( 'usage_limit' ) ),
+			$this->captured_payloads,
+			'A save triggered from inside the hook must report only its own props, not the outer save\'s.'
+		);
+	}
+
+	/**
+	 * @testdox Should isolate nested and outer props when re-entered during a metadata update.
+	 */
+	public function test_nested_save_from_metadata_hook_does_not_contaminate_outer_payload(): void {
+		$coupon = $this->create_settled_coupon();
+		$this->capture_updated_props();
+
+		// The nested save re-enters mid-write, when individual_use's meta row is written, so both
+		// payloads land in $captured_payloads. Which slot each lands in is guaranteed: the nested
+		// save runs to completion inside the outer save's update_or_delete_post_meta() call, so its
+		// hook always fires first, whichever meta key triggers the re-entry. The order of the props
+		// within a payload is not guaranteed — it follows the $meta_key_to_props map — so the slots
+		// are pinned below but their contents compare canonicalized.
+		$nested_save_done  = false;
+		$metadata_listener = function ( $meta_id, $object_id, $meta_key ) use ( $coupon, &$nested_save_done ) {
+			unset( $meta_id );
+
+			if ( $nested_save_done || $coupon->get_id() !== $object_id || 'individual_use' !== $meta_key ) {
+				return;
+			}
+			$nested_save_done = true;
+
+			$coupon->set_usage_limit( 3 );
+			$coupon->save();
+		};
+
+		add_action( 'updated_post_meta', $metadata_listener, 10, 3 );
+
+		try {
+			$coupon->set_amount( 5 );
+			$coupon->set_individual_use( true );
+			$coupon->save();
+		} finally {
+			remove_action( 'updated_post_meta', $metadata_listener, 10 );
+		}
+
+		$this->assertCount( 2, $this->captured_payloads, 'The nested save and the outer save should each fire the action exactly once.' );
+		$this->assertEqualsCanonicalizing(
+			array( 'usage_limit' ),
+			$this->captured_payloads[0],
+			'The nested save fires first, and must report only the prop it wrote.'
+		);
+		$this->assertEqualsCanonicalizing(
+			array( 'amount', 'individual_use' ),
+			$this->captured_payloads[1],
+			'A nested metadata-hook save must not erase or contaminate the outer save\'s props.'
+		);
+	}
+
+	/**
+	 * @testdox Should populate the deprecated updated-props property only for the duration of the hook.
+	 */
+	public function test_deprecated_updated_props_property_mirrors_the_current_save(): void {
+		$store = new class() extends WC_Coupon_Data_Store_CPT {
+			/**
+			 * Expose the deprecated updated-props state that a subclass may still read.
+			 *
+			 * @return array
+			 */
+			public function get_updated_props(): array {
+				return $this->updated_props;
+			}
+		};
+
+		// Returning an object shares one store instance across every coupon in this test.
+		$store_filter = function () use ( $store ) {
+			return $store;
+		};
+		add_filter( 'woocommerce_coupon_data_store', $store_filter );
+
+		$observed_during_hook = null;
+
+		try {
+			$coupon = $this->create_settled_coupon();
+
+			add_action(
+				'woocommerce_coupon_object_updated_props',
+				function () use ( $store, &$observed_during_hook ) {
+					$observed_during_hook = $store->get_updated_props();
+				},
+				10,
+				2
+			);
+
+			$coupon->set_amount( 5 );
+			$coupon->save();
+		} finally {
+			remove_filter( 'woocommerce_coupon_data_store', $store_filter );
+		}
+
+		$this->assertSame(
+			array( 'amount' ),
+			$observed_during_hook,
+			'A subclass reading the deprecated property during the hook should see the current save\'s props.'
+		);
+		$this->assertSame(
+			array(),
+			$store->get_updated_props(),
+			'The deprecated property must be emptied after the hook so no props carry over to the next save.'
+		);
+	}
+}