Commit 0672ab6c410 for woocommerce

commit 0672ab6c41039904c8c2e13383a0107fd4d7c4ec
Author: Tom Cafferkey <tjcafferkey@gmail.com>
Date:   Wed Aug 5 15:40:31 2026 +0100

    Add order withdrawal feature highlight notification when store is eligible (#67295)

    * Add order withdrawal inbox notification

    * Add changelog entry for order withdrawal inbox note

    * Rename to feature highlight

    * Update text

    * Remove unnecessary comment

    * Only register the class if the feature is disabled

    * Tests

    * Fix PHPStan

    * Fix missing test dep

    * Remove unnecessary test

    * Change note type to marketing

    * Move notification register to init hook

    * Public vs private method updates

    * Test that selling to non-EU country returns false

    * Make Note INFORMATIONAL

    * Remove reflection for test

    * Catch exception

    * Reorder is_applicable logic when adding note

    * Run maybe_register

    * Simplify country logic

diff --git a/plugins/woocommerce/changelog/add-order-withdrawal-inbox-notification b/plugins/woocommerce/changelog/add-order-withdrawal-inbox-notification
new file mode 100644
index 00000000000..cfe626d4353
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-order-withdrawal-inbox-notification
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add an order withdrawal inbox notification for EU-selling live stores.
diff --git a/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalController.php b/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalController.php
index 0ffa4568780..26e5e738400 100644
--- a/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalController.php
+++ b/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalController.php
@@ -33,18 +33,27 @@ final class OrderWithdrawalController implements RegisterHooksInterface {
 	 */
 	private OrderWithdrawalFormView $form_view;

+	/**
+	 * Feature highlight notification.
+	 *
+	 * @var OrderWithdrawalFeatureHighlightNotification
+	 */
+	private OrderWithdrawalFeatureHighlightNotification $feature_highlight_notification;
+
 	/**
 	 * Initialize dependencies.
 	 *
-	 * @param OrderWithdrawalFormProcessor $form_processor Form processor.
-	 * @param OrderWithdrawalFormView      $form_view Form view.
+	 * @param OrderWithdrawalFormProcessor                $form_processor                 Form processor.
+	 * @param OrderWithdrawalFormView                     $form_view                      Form view.
+	 * @param OrderWithdrawalFeatureHighlightNotification $feature_highlight_notification Feature highlight notification.
 	 * @internal
 	 *
 	 * @since 11.1.0
 	 */
-	final public function init( OrderWithdrawalFormProcessor $form_processor, OrderWithdrawalFormView $form_view ): void { // phpcs:ignore Generic.CodeAnalysis.UnnecessaryFinalModifier.Found -- Required by WooCommerce injection method rules.
-		$this->form_processor = $form_processor;
-		$this->form_view      = $form_view;
+	final public function init( OrderWithdrawalFormProcessor $form_processor, OrderWithdrawalFormView $form_view, OrderWithdrawalFeatureHighlightNotification $feature_highlight_notification ): void { // phpcs:ignore Generic.CodeAnalysis.UnnecessaryFinalModifier.Found -- Required by WooCommerce injection method rules.
+		$this->form_processor                 = $form_processor;
+		$this->form_view                      = $form_view;
+		$this->feature_highlight_notification = $feature_highlight_notification;
 	}

 	/**
@@ -61,6 +70,7 @@ final class OrderWithdrawalController implements RegisterHooksInterface {
 		add_action( 'woocommerce_before_delete_order', array( $this->form_processor, 'delete_order_withdrawal_inbox_note_for_order' ), 10, 1 );
 		add_action( 'before_delete_post', array( $this->form_processor, 'delete_order_withdrawal_inbox_note_for_order' ), 10, 1 );
 		add_action( 'woocommerce_privacy_remove_order_personal_data', array( $this->form_processor, 'delete_order_withdrawal_inbox_note_for_order' ), 10, 1 );
+		add_action( 'init', array( $this, 'maybe_register_feature_highlight_notification' ), 10, 0 );
 	}

 	/**
@@ -98,6 +108,17 @@ final class OrderWithdrawalController implements RegisterHooksInterface {
 		}
 	}

+	/**
+	 * Register the order withdrawal feature highlight notification if the feature is not enabled.
+	 *
+	 * @since 11.1.0
+	 */
+	public function maybe_register_feature_highlight_notification(): void {
+		if ( ! $this->is_enabled() ) {
+			$this->feature_highlight_notification->register();
+		}
+	}
+
 	/**
 	 * Register the order withdrawal query var.
 	 *
diff --git a/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFeatureHighlightNotification.php b/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFeatureHighlightNotification.php
new file mode 100644
index 00000000000..ec92b9991f4
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFeatureHighlightNotification.php
@@ -0,0 +1,176 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\OrderWithdrawal;
+
+use Automattic\WooCommerce\Admin\Notes\Note;
+use Automattic\WooCommerce\Admin\Notes\DataStore as NotesDataStore;
+use Automattic\WooCommerce\Admin\Notes\Notes;
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+use Exception;
+
+/**
+ * Adds an inbox notification about the order withdrawal feature for eligible stores.
+ *
+ * @internal Just for internal use.
+ */
+final class OrderWithdrawalFeatureHighlightNotification implements RegisterHooksInterface {
+
+	public const NOTE_NAME      = 'wc-admin-order-withdrawal-feature';
+	public const CREATED_OPTION = 'woocommerce_order_withdrawal_inbox_notification_created';
+
+	private const COMING_SOON_OPTION    = 'woocommerce_coming_soon';
+	private const FEATURES_SETTINGS_URL = 'admin.php?page=wc-settings&tab=advanced&section=features';
+	private const DOCUMENTATION_URL     = 'https://woocommerce.com/';
+
+	/**
+	 * Register hooks.
+	 *
+	 * @since 11.1.0
+	 */
+	public function register(): void {
+		add_action(
+			'update_option_' . self::COMING_SOON_OPTION,
+			array( $this, 'maybe_add_note_when_store_goes_live' ),
+			10,
+			2
+		);
+		add_action( 'wc_admin_daily', array( $this, 'possibly_add_note' ) );
+	}
+
+	/**
+	 * Add the note when the store is changed from coming soon to live.
+	 *
+	 * This is called when the `woocommerce_coming_soon` option is updated. It checks if the store has gone live and if so, it calls the `possibly_add_note` method to add the note.
+	 *
+	 * @internal
+	 * @since 11.1.0
+	 *
+	 * @param mixed $old_value Previous option value.
+	 * @param mixed $value     New option value.
+	 */
+	public function maybe_add_note_when_store_goes_live( $old_value, $value ): void {
+		if ( 'yes' !== $old_value || 'no' !== $value ) {
+			return;
+		}
+
+		$this->possibly_add_note();
+	}
+
+	/**
+	 * Add the note if the store is eligible and it has never been created before.
+	 */
+	public function possibly_add_note(): void {
+		try {
+			if ( $this->has_note_been_created() ) {
+				return;
+			}
+
+			if ( ! $this->is_applicable() ) {
+				return;
+			}
+
+			if ( ! add_option( self::CREATED_OPTION, 'yes', '', false ) ) {
+				return;
+			}
+
+			$this->get_note()->save();
+		} catch ( Exception $exception ) {
+			delete_option( self::CREATED_OPTION );
+			wc_get_logger()->error(
+				'Unable to create the order withdrawal inbox notification.',
+				array(
+					'source'    => 'order-withdrawal',
+					'exception' => $exception,
+				)
+			);
+		}
+	}
+
+	/**
+	 * Whether the notification is relevant for the current store settings.
+	 */
+	private function is_applicable(): bool {
+		return 'no' === get_option( self::COMING_SOON_OPTION, 'yes' )
+			&& $this->store_sells_to_eu_or_all_countries();
+	}
+
+	/**
+	 * Get the inbox note.
+	 */
+	private function get_note(): Note {
+		$note = new Note();
+
+		$note->set_title(
+			__( 'Enable order withdrawal for EU regulatory requirements', 'woocommerce' )
+		);
+		$note->set_content(
+			__(
+				'Stores selling to EU countries may need to offer customers a way to withdraw from qualifying orders. WooCommerce includes an order withdrawal feature you can enable.',
+				'woocommerce'
+			)
+		);
+		$note->set_content_data( (object) array() );
+		$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
+		$note->set_name( self::NOTE_NAME );
+		$note->set_source( 'woocommerce-admin' );
+		$note->add_action(
+			'review-feature-settings',
+			__( 'Review feature settings', 'woocommerce' ),
+			admin_url( self::FEATURES_SETTINGS_URL ),
+			Note::E_WC_ADMIN_NOTE_ACTIONED,
+			true
+		);
+		$note->add_action(
+			'learn-more',
+			__( 'Learn more', 'woocommerce' ),
+			self::DOCUMENTATION_URL,
+			Note::E_WC_ADMIN_NOTE_UNACTIONED
+		);
+
+		return $note;
+	}
+
+	/**
+	 * Whether this note has already been created, including soft-deleted notes.
+	 */
+	private function has_note_been_created(): bool {
+		if ( 'yes' === get_option( self::CREATED_OPTION, 'no' ) ) {
+			return true;
+		}
+
+		/**
+		 * Data store instance.
+		 *
+		 * @var NotesDataStore $data_store
+		 */
+		$data_store = Notes::load_data_store();
+		$note_ids   = $data_store->get_notes_with_name( self::NOTE_NAME );
+
+		if ( empty( $note_ids ) ) {
+			return false;
+		}
+
+		update_option( self::CREATED_OPTION, 'yes', false );
+
+		return true;
+	}
+
+	/**
+	 * Whether the store sells to at least one EU country.
+	 */
+	private function store_sells_to_eu_or_all_countries(): bool {
+		$woocommerce = function_exists( 'WC' ) ? WC() : null;
+
+		if ( ! $woocommerce || ! $woocommerce->countries instanceof \WC_Countries ) {
+			return false;
+		}
+
+		return ! empty(
+			array_intersect(
+				$woocommerce->countries->get_european_union_countries(),
+				array_keys( $woocommerce->countries->get_allowed_countries() )
+			)
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalFeatureHighlightNotificationTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalFeatureHighlightNotificationTest.php
new file mode 100644
index 00000000000..5df32561c51
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalFeatureHighlightNotificationTest.php
@@ -0,0 +1,247 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\OrderWithdrawal;
+
+use Automattic\WooCommerce\Admin\Notes\Note;
+use Automattic\WooCommerce\Admin\Notes\Notes;
+use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalFeatureHighlightNotification;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the order withdrawal inbox notification.
+ */
+class OrderWithdrawalFeatureHighlightNotificationTest extends WC_Unit_Test_Case {
+
+	private const FEATURE_OPTION              = 'woocommerce_feature_order_withdrawal_enabled';
+	private const COMING_SOON_OPTION          = 'woocommerce_coming_soon';
+	private const ALLOWED_COUNTRIES_OPTION    = 'woocommerce_allowed_countries';
+	private const ALL_EXCEPT_COUNTRIES_OPTION = 'woocommerce_all_except_countries';
+	private const SPECIFIC_COUNTRIES_OPTION   = 'woocommerce_specific_allowed_countries';
+	private const MISSING_OPTION_MARK         = '__woocommerce_order_withdrawal_missing_option__';
+
+	private const OPTION_NAMES = array(
+		self::FEATURE_OPTION,
+		self::COMING_SOON_OPTION,
+		self::ALLOWED_COUNTRIES_OPTION,
+		self::ALL_EXCEPT_COUNTRIES_OPTION,
+		self::SPECIFIC_COUNTRIES_OPTION,
+		OrderWithdrawalFeatureHighlightNotification::CREATED_OPTION,
+	);
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var OrderWithdrawalFeatureHighlightNotification
+	 */
+	private $sut;
+
+	/**
+	 * Original option values.
+	 *
+	 * @var array<string,mixed>
+	 */
+	private array $original_options = array();
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->sut = new OrderWithdrawalFeatureHighlightNotification();
+		$this->store_original_options();
+		$this->delete_notification_state();
+		$this->set_live_eu_store_defaults();
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		$this->delete_notification_state();
+		$this->restore_original_options();
+
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Should add the notification for existing live stores.
+	 */
+	public function test_possibly_add_note_adds_notification_for_existing_live_stores(): void {
+		$this->sut->possibly_add_note();
+
+		$note_ids = $this->get_notification_note_ids();
+
+		$this->assertCount(
+			1,
+			$note_ids,
+			'An eligible existing live store should receive the notification.'
+		);
+		$this->assertSame(
+			'yes',
+			get_option( OrderWithdrawalFeatureHighlightNotification::CREATED_OPTION ),
+			'Creating the note should persist the one-time creation flag.'
+		);
+	}
+
+	/**
+	 * @testdox Should add the notification when a coming soon store goes live.
+	 */
+	public function test_maybe_add_note_when_store_goes_live_adds_notification(): void {
+		update_option( self::COMING_SOON_OPTION, 'no' );
+
+		$this->sut->maybe_add_note_when_store_goes_live( 'yes', 'no' );
+
+		$this->assertCount(
+			1,
+			$this->get_notification_note_ids(),
+			'The notification should be created when coming soon changes to live.'
+		);
+	}
+
+	/**
+	 * @testdox Should only create the notification once.
+	 */
+	public function test_possibly_add_note_prevents_duplicates(): void {
+		$this->sut->possibly_add_note();
+		$this->sut->possibly_add_note();
+
+		$this->assertCount(
+			1,
+			$this->get_notification_note_ids(),
+			'Repeated attempts should not create duplicate notifications.'
+		);
+	}
+
+	/**
+	 * @testdox Should match stores selling to EU countries.
+	 * @dataProvider provide_country_settings
+	 *
+	 * @param string   $allowed_countries Allowed countries setting.
+	 * @param string[] $specific_countries Specific allowed countries.
+	 * @param string[] $excluded_countries Excluded countries.
+	 * @param bool     $expected           Expected applicability.
+	 */
+	public function test_possibly_add_note_checks_country_settings(
+		string $allowed_countries,
+		array $specific_countries,
+		array $excluded_countries,
+		bool $expected
+	): void {
+		update_option( self::ALLOWED_COUNTRIES_OPTION, $allowed_countries );
+		update_option( self::SPECIFIC_COUNTRIES_OPTION, $specific_countries );
+		update_option( self::ALL_EXCEPT_COUNTRIES_OPTION, $excluded_countries );
+
+		$this->sut->possibly_add_note();
+
+		$this->assertCount(
+			$expected ? 1 : 0,
+			$this->get_notification_note_ids(),
+			'Applicability should match the configured selling countries.'
+		);
+	}
+
+	/**
+	 * @testdox Should identify stores that only sell to the US as not selling to EU countries.
+	 */
+	public function test_store_sells_to_eu_or_all_countries_returns_false_for_us_only_store(): void {
+		update_option( self::ALLOWED_COUNTRIES_OPTION, 'specific' );
+		update_option( self::SPECIFIC_COUNTRIES_OPTION, array( 'US' ) );
+
+		$this->sut->possibly_add_note();
+
+		$this->assertCount(
+			0,
+			$this->get_notification_note_ids(),
+			'A store that only sells to the US should not receive the notification.'
+		);
+	}
+
+	/**
+	 * Data provider for {@see test_possibly_add_note_checks_country_settings()}.
+	 *
+	 * @return array<string,array{0:string,1:string[],2:string[],3:bool}>
+	 */
+	public function provide_country_settings(): array {
+		return array(
+			'all countries'             => array( 'all', array(), array(), true ),
+			'specific EU country'       => array( 'specific', array( 'DE' ), array(), true ),
+			'specific non-EU country'   => array( 'specific', array( 'US' ), array(), false ),
+			'all except non-EU country' => array( 'all_except', array(), array( 'US' ), true ),
+		);
+	}
+
+	/**
+	 * Store original option values.
+	 */
+	private function store_original_options(): void {
+		foreach ( self::OPTION_NAMES as $option ) {
+			$this->original_options[ $option ] = get_option( $option, self::MISSING_OPTION_MARK );
+		}
+	}
+
+	/**
+	 * Restore original option values.
+	 */
+	private function restore_original_options(): void {
+		foreach ( $this->original_options as $option => $value ) {
+			$this->restore_option( $option, $value );
+		}
+	}
+
+	/**
+	 * Set baseline options for a live EU store with the feature disabled.
+	 */
+	private function set_live_eu_store_defaults(): void {
+		update_option( self::COMING_SOON_OPTION, 'no' );
+		update_option( self::FEATURE_OPTION, 'no' );
+		update_option( self::ALLOWED_COUNTRIES_OPTION, 'specific' );
+		update_option( self::SPECIFIC_COUNTRIES_OPTION, array( 'DE' ) );
+		delete_option( self::ALL_EXCEPT_COUNTRIES_OPTION );
+	}
+
+	/**
+	 * Delete notification state created by tests.
+	 */
+	private function delete_notification_state(): void {
+		delete_option( OrderWithdrawalFeatureHighlightNotification::CREATED_OPTION );
+
+		foreach ( $this->get_notification_note_ids() as $note_id ) {
+			$note = Notes::get_note( $note_id );
+
+			if ( $note instanceof Note ) {
+				$note->delete();
+			}
+		}
+	}
+
+	/**
+	 * Get test notification note IDs.
+	 *
+	 * @return int[]
+	 */
+	private function get_notification_note_ids(): array {
+		$data_store = Notes::load_data_store();
+
+		return array_map(
+			'absint',
+			$data_store->get_notes_with_name( OrderWithdrawalFeatureHighlightNotification::NOTE_NAME )
+		);
+	}
+
+	/**
+	 * Restore an option to its original state.
+	 *
+	 * @param string $option Option name.
+	 * @param mixed  $value  Original value.
+	 */
+	private function restore_option( string $option, $value ): void {
+		if ( self::MISSING_OPTION_MARK === $value ) {
+			delete_option( $option );
+			return;
+		}
+
+		update_option( $option, $value );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalTest.php
index af70f40538a..5f5d6aaed01 100644
--- a/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalTest.php
@@ -10,6 +10,7 @@ use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalController;
 use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalFormProcessor;
 use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalFormState;
 use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalFormView;
+use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalFeatureHighlightNotification;
 use WC_Order;
 use WC_Rate_Limiter;
 use WC_Unit_Test_Case;
@@ -642,7 +643,7 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
 	 */
 	public function test_controller_registers_order_deletion_cleanup_hooks(): void {
 		$controller = new OrderWithdrawalController();
-		$controller->init( $this->sut, new OrderWithdrawalFormView() );
+		$controller->init( $this->sut, new OrderWithdrawalFormView(), new OrderWithdrawalFeatureHighlightNotification() );

 		try {
 			$controller->register();
@@ -661,6 +662,39 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
 		}
 	}

+	/**
+	 * @testdox Should skip the feature highlight notification hooks when order withdrawal is enabled.
+	 */
+	public function test_controller_skips_feature_highlight_notification_hooks_when_feature_is_enabled(): void {
+		$this->enable_feature();
+
+		$controller   = new OrderWithdrawalController();
+		$notification = new OrderWithdrawalFeatureHighlightNotification();
+
+		$controller->init( $this->sut, new OrderWithdrawalFormView(), $notification );
+
+		try {
+			$controller->register();
+
+			$this->assertNotFalse( has_action( 'init', array( $controller, 'maybe_register_feature_highlight_notification' ) ), 'The controller should defer feature highlight notification registration until init.' );
+
+			$controller->maybe_register_feature_highlight_notification();
+
+			$this->assertFalse( has_action( 'update_option_woocommerce_coming_soon', array( $notification, 'maybe_add_note_when_store_goes_live' ) ), 'The feature highlight notification should not listen for coming-soon changes when the feature is enabled.' );
+			$this->assertFalse( has_action( 'wc_admin_daily', array( $notification, 'possibly_add_note' ) ), 'The feature highlight notification should not run daily when the feature is enabled.' );
+		} finally {
+			remove_action( 'init', array( $controller, 'maybe_register_feature_highlight_notification' ), 10 );
+			remove_action( FeaturesController::FEATURE_ENABLED_CHANGED_ACTION, array( $controller, 'maybe_flush_rewrite_rules' ), 10 );
+			remove_filter( 'woocommerce_get_query_vars', array( $controller, 'add_query_var' ), 10 );
+			remove_filter( 'woocommerce_endpoint_order-withdrawal_title', array( $controller, 'get_endpoint_title' ), 10 );
+			remove_filter( 'woocommerce_settings_pages', array( $controller, 'add_endpoint_setting' ), 10 );
+			remove_action( 'woocommerce_account_order-withdrawal_endpoint', array( $controller, 'render_view' ), 10 );
+			remove_action( 'woocommerce_before_delete_order', array( $this->sut, 'delete_order_withdrawal_inbox_note_for_order' ), 10 );
+			remove_action( 'before_delete_post', array( $this->sut, 'delete_order_withdrawal_inbox_note_for_order' ), 10 );
+			remove_action( 'woocommerce_privacy_remove_order_personal_data', array( $this->sut, 'delete_order_withdrawal_inbox_note_for_order' ), 10 );
+		}
+	}
+
 	/**
 	 * @testdox Should keep the user on review with an error notice when notification emails fail.
 	 */
@@ -1094,6 +1128,13 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
 		update_option( self::FEATURE_OPTION, 'no' );
 	}

+	/**
+	 * Enable the order withdrawal feature.
+	 */
+	private function enable_feature(): void {
+		update_option( self::FEATURE_OPTION, 'yes' );
+	}
+
 	/**
 	 * Restore an option to its original state.
 	 *