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§ion=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.
*