Commit 638136dd0cd for woocommerce
commit 638136dd0cd4926a1d43d7b9fb6378dda91aa06a
Author: Adam Grzybkowski <agrzybkowski@outlook.com>
Date: Mon May 18 11:09:04 2026 +0200
Add high-value order threshold to store_order push notifications (#64509)
* Add min_amount sub-field to store_order notification preference
Extend the store_order preference shape from `{ enabled: bool }` to
`{ enabled: bool, min_amount: float|null }`. `min_amount: null` is
the default and means "no threshold"; a positive number is the
order-total floor for the notification.
The REST schema accepts either null or a positive number; 0 and
negatives are rejected via `exclusiveMinimum: true` so the value's
"high-value" semantics line up with the eventual UI (radio button:
all-orders / high-value-with-amount), and so a stored 0 can never
masquerade as a meaningful filter.
No schema-version bump needed: the v1 envelope already uses a
nested object per pref key, so this is a forward-compatible add.
Filtering by the threshold lands in a follow-up.
* Filter store_order notifications by min_amount threshold
Override `NewOrderNotification::should_send_to_user()` to consult the
user's stored `min_amount` alongside the parent's enabled check. When
`min_amount` is null or non-positive, no threshold filter is applied
(the parent's enabled-check decision stands). When set, the order's
total must meet or exceed the threshold for the notification to fire.
`NotificationProcessor::filter_tokens_by_preferences` already memoizes
the per-user decision, so an order that lands for N tokens still
results in at most one threshold evaluation per recipient user.
* Add changefile(s) from automation for the following project(s): woocommerce
* Document that min_amount is compared in the order's currency
Address review feedback on PR #64509: spell out in the docblock that
the threshold is compared against the order's total in the order's
own currency, with no currency conversion. Mirrors how
WC_Coupon::minimum_amount behaves; multi-currency merchants should
set thresholds with that in mind.
No behaviour change.
---------
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/64509-issue-RSM-1550 b/plugins/woocommerce/changelog/64509-issue-RSM-1550
new file mode 100644
index 00000000000..12b162b4abe
--- /dev/null
+++ b/plugins/woocommerce/changelog/64509-issue-RSM-1550
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a merchant-configurable minimum-amount threshold to the store_order push notification, so only orders at or above the threshold trigger a notification. Default behaviour (notify on every enabled order) is unchanged.
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestController.php b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestController.php
index a1a148588c3..71939936756 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestController.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestController.php
@@ -198,7 +198,23 @@ class NotificationPreferencesRestController extends RestApiControllerBase {
$args = array();
$defaults = $this->preferences_service->get_defaults();
- foreach ( array_keys( $defaults ) as $key ) {
+ foreach ( $defaults as $key => $shape ) {
+ $properties = array(
+ 'enabled' => array(
+ 'type' => 'boolean',
+ 'description' => __( 'Whether this notification type is enabled.', 'woocommerce' ),
+ ),
+ );
+
+ if ( array_key_exists( 'min_amount', $shape ) ) {
+ $properties['min_amount'] = array(
+ 'type' => array( 'number', 'null' ),
+ 'minimum' => 0,
+ 'exclusiveMinimum' => true,
+ 'description' => __( 'Minimum order amount required to trigger this notification, or null to disable the threshold.', 'woocommerce' ),
+ );
+ }
+
$args[ $key ] = array(
'description' => sprintf(
/* translators: %s: notification preference key (e.g. store_order). */
@@ -206,16 +222,11 @@ class NotificationPreferencesRestController extends RestApiControllerBase {
$key
),
'type' => 'object',
- 'properties' => array(
- 'enabled' => array(
- 'type' => 'boolean',
- 'description' => __( 'Whether this notification type is enabled.', 'woocommerce' ),
- ),
- ),
+ 'properties' => $properties,
'required' => false,
'validate_callback' => 'rest_validate_request_arg',
);
- }
+ }//end foreach
return $args;
}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewOrderNotification.php b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewOrderNotification.php
index fc51c190144..b6756411147 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewOrderNotification.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewOrderNotification.php
@@ -83,6 +83,45 @@ class NewOrderNotification extends Notification {
);
}
+ /**
+ * {@inheritDoc}
+ *
+ * Extends the base enabled-toggle check with a minimum-amount threshold.
+ * When `min_amount` is set in the user's preferences, the order total must
+ * meet or exceed it for the notification to be sent.
+ *
+ * The threshold is interpreted in the order's currency; no currency
+ * conversion is performed. This mirrors how `WC_Coupon::minimum_amount`
+ * behaves, so multi-currency merchants should set thresholds with that
+ * in mind.
+ *
+ * @param mixed $pref_value The user's stored preference value, or null.
+ * @return bool
+ *
+ * @since 10.9.0
+ */
+ public function should_send_to_user( $pref_value ): bool {
+ if ( ! parent::should_send_to_user( $pref_value ) ) {
+ return false;
+ }
+
+ if ( ! is_array( $pref_value ) || ! isset( $pref_value['min_amount'] ) ) {
+ return true;
+ }
+
+ $min_amount = (float) $pref_value['min_amount'];
+ if ( $min_amount <= 0 ) {
+ return true;
+ }
+
+ $order = WC()->call_function( 'wc_get_order', $this->get_resource_id() );
+ if ( ! $order instanceof WC_Order ) {
+ return false;
+ }
+
+ return (float) $order->get_total() >= $min_amount;
+ }
+
/**
* {@inheritDoc}
*
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationPreferencesService.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationPreferencesService.php
index fc56810d2d7..d117d518e9e 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationPreferencesService.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationPreferencesService.php
@@ -115,6 +115,9 @@ class NotificationPreferencesService {
foreach ( array_keys( Notification::NOTIFICATION_CLASSES ) as $type ) {
$defaults[ $type ] = array( 'enabled' => true );
}
+
+ $defaults['store_order']['min_amount'] = null;
+
return $defaults;
}
@@ -153,7 +156,6 @@ class NotificationPreferencesService {
* @return array<string, mixed>
*/
protected function sanitize_value( string $key, array $value, array $default_shape ): array {
- // Reserved for per-key dispatch when sub-fields are added.
unset( $key );
$sanitized = array();
@@ -165,7 +167,16 @@ class NotificationPreferencesService {
: (bool) $sub_default;
continue;
}
- // Future sub-fields (thresholds, sub-toggles) extend this switch.
+
+ if ( 'min_amount' === $sub_key ) {
+ if ( ! array_key_exists( $sub_key, $value ) || null === $value[ $sub_key ] ) {
+ $sanitized[ $sub_key ] = null;
+ continue;
+ }
+ $amount = (float) $value[ $sub_key ];
+ $sanitized[ $sub_key ] = $amount > 0 ? $amount : null;
+ continue;
+ }
}
return $sanitized;
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestControllerTest.php
index 735a7dd1f13..3c30c83fd5f 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestControllerTest.php
@@ -278,6 +278,99 @@ class NotificationPreferencesRestControllerTest extends WC_REST_Unit_Test_Case {
$this->assertStringNotContainsString( $internal_message, (string) $serialized );
}
+ /**
+ * @testdox POST should reject non-numeric min_amount via the REST validation layer.
+ */
+ public function test_post_preferences_rejects_non_numeric_min_amount() {
+ wp_set_current_user( $this->user_id );
+ $this->mock_jetpack_connection_manager_is_connected( true );
+ $this->register_routes();
+
+ $request = new WP_REST_Request( 'POST', '/wc-push-notifications/preferences' );
+ $request->set_param( 'store_order', array( 'min_amount' => 'not-a-number' ) );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( WP_Http::BAD_REQUEST, $response->get_status() );
+ }
+
+ /**
+ * @testdox POST should reject non-positive min_amount via the REST validation layer.
+ *
+ * @testWith [-10]
+ * [0]
+ *
+ * @param int|float $value The invalid value.
+ */
+ public function test_post_preferences_rejects_non_positive_min_amount( $value ) {
+ wp_set_current_user( $this->user_id );
+ $this->mock_jetpack_connection_manager_is_connected( true );
+ $this->register_routes();
+
+ $request = new WP_REST_Request( 'POST', '/wc-push-notifications/preferences' );
+ $request->set_param( 'store_order', array( 'min_amount' => $value ) );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( WP_Http::BAD_REQUEST, $response->get_status() );
+ }
+
+ /**
+ * @testdox POST should accept a positive min_amount and persist it.
+ */
+ public function test_post_preferences_accepts_valid_min_amount() {
+ wp_set_current_user( $this->user_id );
+ $this->mock_jetpack_connection_manager_is_connected( true );
+ $this->register_routes();
+
+ $request = new WP_REST_Request( 'POST', '/wc-push-notifications/preferences' );
+ $request->set_param( 'store_order', array( 'min_amount' => 100 ) );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( WP_Http::OK, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertSame( 100.0, $data['store_order']['min_amount'] );
+ }
+
+ /**
+ * @testdox POST should accept null min_amount and persist it.
+ */
+ public function test_post_preferences_accepts_null_min_amount() {
+ wp_set_current_user( $this->user_id );
+ $this->mock_jetpack_connection_manager_is_connected( true );
+ $this->register_routes();
+
+ $request = new WP_REST_Request( 'POST', '/wc-push-notifications/preferences' );
+ $request->set_param( 'store_order', array( 'min_amount' => null ) );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( WP_Http::OK, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertNull( $data['store_order']['min_amount'] );
+ }
+
+ /**
+ * @testdox GET should include a null min_amount in store_order by default.
+ */
+ public function test_get_preferences_includes_min_amount_in_store_order() {
+ wp_set_current_user( $this->user_id );
+ $this->mock_jetpack_connection_manager_is_connected( true );
+ $this->register_routes();
+
+ $request = new WP_REST_Request( 'GET', '/wc-push-notifications/preferences' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( WP_Http::OK, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertArrayHasKey( 'min_amount', $data['store_order'] );
+ $this->assertNull( $data['store_order']['min_amount'] );
+ }
+
/**
* @testdox Should not collide with PushTokenRestController on the WC REST namespaces filter.
*
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewOrderNotificationTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewOrderNotificationTest.php
index 451997fc4cf..d1b52d32cd5 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewOrderNotificationTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewOrderNotificationTest.php
@@ -90,4 +90,120 @@ class NewOrderNotificationTest extends WC_Unit_Test_Case {
$this->assertNull( $notification->to_payload() );
}
+
+ /**
+ * @testdox should_send_to_user should return true when order total exceeds min_amount.
+ */
+ public function test_should_send_to_user_when_order_total_above_threshold(): void {
+ $order = $this->create_order_with_total( 100 );
+
+ $notification = new NewOrderNotification( $order->get_id() );
+
+ $this->assertTrue(
+ $notification->should_send_to_user(
+ array(
+ 'enabled' => true,
+ 'min_amount' => 50,
+ )
+ )
+ );
+ }
+
+ /**
+ * @testdox should_send_to_user should return true when order total equals min_amount.
+ */
+ public function test_should_send_to_user_when_order_total_equals_threshold(): void {
+ $order = $this->create_order_with_total( 50 );
+
+ $notification = new NewOrderNotification( $order->get_id() );
+
+ $this->assertTrue(
+ $notification->should_send_to_user(
+ array(
+ 'enabled' => true,
+ 'min_amount' => 50,
+ )
+ )
+ );
+ }
+
+ /**
+ * @testdox should_send_to_user should return false when order total is below min_amount.
+ */
+ public function test_should_not_send_to_user_when_order_total_below_threshold(): void {
+ $order = $this->create_order_with_total( 30 );
+
+ $notification = new NewOrderNotification( $order->get_id() );
+
+ $this->assertFalse(
+ $notification->should_send_to_user(
+ array(
+ 'enabled' => true,
+ 'min_amount' => 50,
+ )
+ )
+ );
+ }
+
+ /**
+ * @testdox should_send_to_user should return true when min_amount is null (no threshold).
+ */
+ public function test_should_send_to_user_when_min_amount_is_null(): void {
+ $order = $this->create_order_with_total( 1 );
+
+ $notification = new NewOrderNotification( $order->get_id() );
+
+ $this->assertTrue(
+ $notification->should_send_to_user(
+ array(
+ 'enabled' => true,
+ 'min_amount' => null,
+ )
+ )
+ );
+ }
+
+ /**
+ * @testdox should_send_to_user should return false when notification is disabled, regardless of amount.
+ */
+ public function test_should_not_send_to_user_when_disabled(): void {
+ $order = $this->create_order_with_total( 1000 );
+
+ $notification = new NewOrderNotification( $order->get_id() );
+
+ $this->assertFalse(
+ $notification->should_send_to_user(
+ array(
+ 'enabled' => false,
+ 'min_amount' => null,
+ )
+ )
+ );
+ }
+
+ /**
+ * @testdox should_send_to_user should return true when min_amount key is missing (backwards compat).
+ */
+ public function test_should_send_to_user_when_min_amount_missing(): void {
+ $order = $this->create_order_with_total( 1 );
+
+ $notification = new NewOrderNotification( $order->get_id() );
+
+ $this->assertTrue(
+ $notification->should_send_to_user( array( 'enabled' => true ) )
+ );
+ }
+
+ /**
+ * Creates an order with a specific total.
+ *
+ * @param float $total The order total.
+ * @return \WC_Order
+ */
+ private function create_order_with_total( float $total ): \WC_Order {
+ $order = wc_create_order( array( 'status' => 'processing' ) );
+ $order->set_total( (string) $total );
+ $order->save();
+ return $order;
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationPreferencesServiceTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationPreferencesServiceTest.php
index 67b5ada0413..f41bc528b53 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationPreferencesServiceTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationPreferencesServiceTest.php
@@ -243,63 +243,8 @@ class NotificationPreferencesServiceTest extends WC_Unit_Test_Case {
/**
* @testdox Should perform a deep merge so partial updates preserve unrelated sub-fields.
- *
- * Locks in the contract for forward-compatible sub-fields. When stored preferences contain
- * multiple sub-fields per pref (e.g. RSM-1550's `min_amount` alongside `enabled`), a partial
- * update that only sends one sub-field must not clobber the others. With a shallow merge
- * (`array_merge`), the entire sub-object is replaced; with a deep merge
- * (`array_replace_recursive`), only the specified sub-fields are overridden.
- *
- * Today's schema only has `enabled` per pref, so the bug is invisible. This test extends
- * the schema via an anonymous subclass to exercise the multi-sub-field case the future
- * tickets rely on.
*/
public function test_save_preferences_deep_merges_partial_updates(): void {
- $service = new class() extends NotificationPreferencesService {
- /**
- * Extended schema for the test: a second sub-field alongside `enabled`.
- *
- * @return array<string, array<string, mixed>>
- */
- public function get_defaults(): array {
- return array(
- 'store_order' => array(
- 'enabled' => true,
- 'min_amount' => 0,
- ),
- );
- }
-
- /**
- * Permissive sanitize for the test: preserve every sub-key in the default shape,
- * coercing to the type implied by its default value.
- *
- * @param string $key Preference key.
- * @param array $value Submitted sub-options.
- * @param array<string, mixed> $default_shape Default sub-options.
- * @return array<string, mixed>
- */
- protected function sanitize_value( string $key, array $value, array $default_shape ): array {
- $sanitized = array();
- foreach ( $default_shape as $sub_key => $sub_default ) {
- if ( ! array_key_exists( $sub_key, $value ) ) {
- $sanitized[ $sub_key ] = $sub_default;
- continue;
- }
- if ( is_bool( $sub_default ) ) {
- $sanitized[ $sub_key ] = (bool) $value[ $sub_key ];
- } elseif ( is_int( $sub_default ) ) {
- $sanitized[ $sub_key ] = (int) $value[ $sub_key ];
- } else {
- $sanitized[ $sub_key ] = $value[ $sub_key ];
- }
- }
- return $sanitized;
- }
- };
- $service->init( $this->data_store );
-
- // Stored state already has a non-default `min_amount`.
$this->data_store->method( 'read' )->willReturn(
array(
'schema_version' => NotificationPreferencesDataStore::CURRENT_SCHEMA_VERSION,
@@ -312,7 +257,6 @@ class NotificationPreferencesServiceTest extends WC_Unit_Test_Case {
)
);
- // Verify that a partial update of just `enabled` preserves `min_amount`.
$this->data_store
->expects( $this->once() )
->method( 'write' )
@@ -321,12 +265,12 @@ class NotificationPreferencesServiceTest extends WC_Unit_Test_Case {
$this->callback(
function ( $envelope ) {
$prefs = $envelope['preferences']['store_order'];
- return false === $prefs['enabled'] && 500 === $prefs['min_amount'];
+ return false === $prefs['enabled'] && 500.0 === $prefs['min_amount'];
}
)
);
- $service->save_preferences(
+ $this->sut->save_preferences(
$this->user_id,
array( 'store_order' => array( 'enabled' => false ) )
);
@@ -348,4 +292,63 @@ class NotificationPreferencesServiceTest extends WC_Unit_Test_Case {
$this->assertIsBool( $shape['enabled'] );
}
}
+
+ /**
+ * @testdox Should default min_amount to null in store_order defaults.
+ */
+ public function test_get_defaults_includes_min_amount_for_store_order(): void {
+ $defaults = $this->sut->get_defaults();
+
+ $this->assertArrayHasKey( 'min_amount', $defaults['store_order'] );
+ $this->assertNull( $defaults['store_order']['min_amount'] );
+ }
+
+ /**
+ * @testdox Should preserve explicit null min_amount.
+ */
+ public function test_sanitize_preserves_null_min_amount(): void {
+ $this->data_store->method( 'read' )->willReturn( null );
+
+ $result = $this->sut->save_preferences(
+ $this->user_id,
+ array( 'store_order' => array( 'min_amount' => null ) )
+ );
+
+ $this->assertNull( $result['store_order']['min_amount'] );
+ }
+
+ /**
+ * @testdox Should fall back to null when min_amount is non-positive.
+ */
+ public function test_sanitize_falls_back_to_null_for_non_positive_min_amount(): void {
+ $this->data_store->method( 'read' )->willReturn( null );
+
+ $result = $this->sut->save_preferences(
+ $this->user_id,
+ array( 'store_order' => array( 'min_amount' => -50 ) )
+ );
+
+ $this->assertNull( $result['store_order']['min_amount'] );
+
+ $result = $this->sut->save_preferences(
+ $this->user_id,
+ array( 'store_order' => array( 'min_amount' => 0 ) )
+ );
+
+ $this->assertNull( $result['store_order']['min_amount'] );
+ }
+
+ /**
+ * @testdox Should coerce string min_amount to float.
+ */
+ public function test_sanitize_coerces_min_amount_to_float(): void {
+ $this->data_store->method( 'read' )->willReturn( null );
+
+ $result = $this->sut->save_preferences(
+ $this->user_id,
+ array( 'store_order' => array( 'min_amount' => '50' ) )
+ );
+
+ $this->assertSame( 50.0, $result['store_order']['min_amount'] );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php
index 8b6e66996cb..269e94211f9 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php
@@ -75,7 +75,10 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
// the dedicated preferences tests below.
$this->preferences_service->method( 'get_preferences' )->willReturn(
array(
- 'store_order' => array( 'enabled' => true ),
+ 'store_order' => array(
+ 'enabled' => true,
+ 'min_amount' => null,
+ ),
'store_review' => array( 'enabled' => true ),
)
);
@@ -533,4 +536,37 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
$this->assertNotEmpty( $order->get_meta( NotificationProcessor::SENT_META_KEY ) );
}
+
+ /**
+ * @testdox Should skip dispatch when the order total is below the user's min_amount threshold.
+ */
+ public function test_process_skips_dispatch_when_order_below_min_amount(): void {
+ $order = wc_create_order( array( 'status' => 'processing' ) );
+ $order->set_total( '100' );
+ $order->save();
+
+ $preferences_service = $this->createMock( NotificationPreferencesService::class );
+ $preferences_service->method( 'get_preferences' )->willReturn(
+ array(
+ 'store_order' => array(
+ 'enabled' => true,
+ 'min_amount' => 500,
+ ),
+ 'store_review' => array( 'enabled' => true ),
+ )
+ );
+
+ $this->dispatcher->expects( $this->never() )->method( 'dispatch' );
+
+ $sut = new NotificationProcessor();
+ $sut->init( $this->dispatcher, $this->data_store, $preferences_service );
+
+ $notification = new NewOrderNotification( $order->get_id() );
+ $result = $sut->process( $notification );
+
+ $this->assertTrue( $result );
+ $this->assertNotEmpty(
+ wc_get_order( $order->get_id() )->get_meta( NotificationProcessor::SENT_META_KEY )
+ );
+ }
}