Commit 584d4ab8b13 for woocommerce
commit 584d4ab8b1348d7eedc7a78c4602c2a907cee4cf
Author: Luis Felipe Zaguini <26530524+zaguiini@users.noreply.github.com>
Date: Tue Nov 3 10:14:38 2026 -0300
Stage order status changes so order-update-status writes extension fields and runs validators (#70380)
* Add WC_Order::stage_status() and route update_status() through it
stage_status() sets the status and the dates in memory, like
set_status(), and leaves woocommerce_order_edit_status to the next
save(), which fires it before the write. update_status() is now
stage_status() followed by save().
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Stage order status changes in order-update-status
order-update-status now runs on ActionableAbility. With the extensible
abilities feature on, it stages the status with stage_status(), so
extension fields and validators see the change before the one save.
An order class that overrides update_status() without stage_status()
is refused. With the feature off, it calls update_status() as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Keep the error code of a WC_Data_Exception thrown by stage_status()
An order class that refuses a staged status change with a WC_Data_Exception
now reaches the client with its own code and data, as a validator refusal
does. set_status() clears a status that stage_status() left pending before
it fires woocommerce_order_edit_status, so the action fires once.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Warn when an order class overrides update_status() without stage_status()
The warning fires from update_status() one time per class and request, with
the feature on or off, so an extension sees it before an agent is refused.
The ability and the warning share the same check.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Refuse without warning when an order class has not adopted stage_status()
The ability's refusal is the contract. A notice on every status change
would reach merchants' logs about code they cannot fix, for a feature
that is off by default, and push plugins to migrate before they need to.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Refuse an order class when any ancestor has not adopted stage_status()
The check compared only the classes that declare update_status() and
stage_status(), so a subclass that overrides stage_status() hid an ancestor
that overrides update_status() alone, and the ancestor's rules did not run.
Walk the hierarchy instead, and name the class in the refusal.
State on woocommerce_order_edit_status when it sees the paid and completed
dates.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Cancel the pending edit status action when a later status change replaces it
A non-manual status change after stage_status() saved the new status, but the
next save() still fired woocommerce_order_edit_status with the staged status.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Assert the gift refusal code by its end
The code is bare on this branch and gets `<namespace>:` from #70308, so the
assertion holds with and without it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Let unexpected exceptions from stage_status() reach the ability
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Report a failed order write from the ability save
WC_Abstract_Order::save() catches a failed write and returns the order ID,
so ActionableAbility::save() reported a failed order update as a success.
The order now keeps the caught exception, and the ability reads it with
get_last_save_error().
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Add a changelog entry for the new woocommerce_order_edit_status timing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Describe only the timing of woocommerce_order_edit_status in the changelog
The action passes the order ID, and a listener that reloads the order
reads the stored row, which is the same as before. The dates on the
in-memory order do not reach a listener.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/add-order-stage-status b/plugins/woocommerce/changelog/add-order-stage-status
new file mode 100644
index 00000000000..2e3251f06f8
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-order-stage-status
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add WC_Order::stage_status() and let order-update-status write extension fields with the extensible abilities feature on.
diff --git a/plugins/woocommerce/changelog/update-order-edit-status-timing b/plugins/woocommerce/changelog/update-order-edit-status-timing
new file mode 100644
index 00000000000..ed9ba049ab9
--- /dev/null
+++ b/plugins/woocommerce/changelog/update-order-edit-status-timing
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+WC_Order::update_status() with a manual change now fires woocommerce_order_edit_status from save(), just before the order is written, as before. A direct set_status() call still fires it right away.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
index af70f34d230..8828220a319 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
@@ -44,6 +44,13 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
*/
protected $clone_mode = self::CLONE_MODE_CACHE;
+ /**
+ * The exception that stopped the last save(), or null when the last save() wrote the order.
+ *
+ * @var Exception|null
+ */
+ private $last_save_error = null;
+
/**
* Order Data array. This is the core order data exposed in APIs since 3.0.0.
*
@@ -299,6 +306,8 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
return $this->get_id();
}
+ $this->last_save_error = null;
+
try {
/**
* Trigger action before saving to the DB. Allows you to adjust object props before save.
@@ -325,7 +334,8 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
do_action( 'woocommerce_after_' . $this->object_type . '_object_save', $this, $this->data_store );
} catch ( Exception $e ) {
- $message_id = $this->get_id() ? $this->get_id() : __( '(no ID)', 'woocommerce' );
+ $this->last_save_error = $e;
+ $message_id = $this->get_id() ? $this->get_id() : __( '(no ID)', 'woocommerce' );
$this->handle_exception(
$e,
wp_kses_post(
@@ -341,6 +351,17 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
return $this->get_id();
}
+ /**
+ * Get the exception that stopped the last save(). save() logs that exception and does not throw it.
+ *
+ * @internal
+ * @since 11.4.0
+ * @return Exception|null The exception, or null when the last save() wrote the order.
+ */
+ public function get_last_save_error(): ?Exception {
+ return $this->last_save_error;
+ }
+
/**
* Log an error about this order is exception is encountered.
*
diff --git a/plugins/woocommerce/includes/class-wc-order.php b/plugins/woocommerce/includes/class-wc-order.php
index 4cbda00cd61..3e074562827 100644
--- a/plugins/woocommerce/includes/class-wc-order.php
+++ b/plugins/woocommerce/includes/class-wc-order.php
@@ -29,6 +29,20 @@ class WC_Order extends WC_Abstract_Order {
*/
protected $status_transition = false;
+ /**
+ * Whether set_status() leaves woocommerce_order_edit_status to the next save(). stage_status() sets it.
+ *
+ * @var bool
+ */
+ private $staging_status = false;
+
+ /**
+ * Status that the next save() fires woocommerce_order_edit_status for, or null when there is none.
+ *
+ * @var string|null
+ */
+ private $pending_edit_status = null;
+
/**
* Order Data array. This is the core order data exposed in APIs since 3.0.0.
*
@@ -281,6 +295,24 @@ class WC_Order extends WC_Abstract_Order {
* @return int order ID
*/
public function save() {
+ $status = $this->pending_edit_status;
+ $this->pending_edit_status = null;
+
+ if ( null !== $status ) {
+ /**
+ * Fires for a manual status change of an order, before the change
+ * is written, so a listener that reloads the order sees the old row.
+ * After set_status(), it fires before the paid and completed dates
+ * are set. After stage_status() or update_status(), it fires from
+ * save(), with those dates already set on the order object.
+ *
+ * @since 3.0.0
+ * @param int $order_id Order ID.
+ * @param string $status New status, without the wc- prefix.
+ */
+ do_action( 'woocommerce_order_edit_status', $this->get_id(), $status );
+ }
+
$this->maybe_set_user_billing_email();
parent::save();
$this->status_transition();
@@ -327,7 +359,9 @@ class WC_Order extends WC_Abstract_Order {
'manual' => (bool) $manual_update,
);
- if ( $manual_update ) {
+ $this->pending_edit_status = $manual_update && $this->staging_status ? $result['to'] : null;
+
+ if ( $manual_update && ! $this->staging_status ) {
do_action( 'woocommerce_order_edit_status', $this->get_id(), $result['to'] );
}
@@ -390,10 +424,35 @@ class WC_Order extends WC_Abstract_Order {
}
}
+ /**
+ * Set the order status in memory, like set_status(), including the paid and completed dates, but leave
+ * woocommerce_order_edit_status to the next save(), which fires it before it writes. A later status
+ * change before that save() replaces the pending action, or cancels it when the change is not manual.
+ * update_status() calls this method, then save().
+ *
+ * An order class that overrides update_status() with rules of its own must override this method
+ * too, in the same class, with the same rules. Abilities stage a status change with this method
+ * only when every class that declares update_status() also declares this method.
+ *
+ * @since 11.4.0
+ * @param string $new_status Status to change the order to. No internal wc- prefix is required.
+ * @param string|false $note Optional note to add. False will skip adding a note entirely.
+ * @param bool $manual_update Is this a manual order status change?.
+ * @return array
+ */
+ public function stage_status( $new_status, $note = '', $manual_update = false ) {
+ $this->staging_status = true;
+ try {
+ return $this->set_status( $new_status, $note, $manual_update );
+ } finally {
+ $this->staging_status = false;
+ }
+ }
+
/**
* Updates status of order immediately.
*
- * @uses self::set_status()
+ * @uses self::stage_status()
* @param string $new_status Status to change the order to. No internal wc- prefix is required.
* @param string $note Optional note to add.
* @param bool $manual Is this a manual order status change?.
@@ -405,7 +464,7 @@ class WC_Order extends WC_Abstract_Order {
}
try {
- $this->set_status( $new_status, $note, $manual );
+ $this->stage_status( $new_status, $note, $manual );
$this->save();
} catch ( Exception $e ) {
$logger = wc_get_logger();
diff --git a/plugins/woocommerce/src/Abilities/ActionableAbility.php b/plugins/woocommerce/src/Abilities/ActionableAbility.php
index 6feefa4897c..021b781b597 100644
--- a/plugins/woocommerce/src/Abilities/ActionableAbility.php
+++ b/plugins/woocommerce/src/Abilities/ActionableAbility.php
@@ -75,10 +75,11 @@ abstract class ActionableAbility extends \WP_Ability {
abstract protected function prepare_response( $subject );
/**
- * Save the changed object. The default calls WC_Data::save(). When the save
- * throws or leaves the object without an ID, it logs the error and returns
- * a WP_Error with a fixed message, so the exception text stays out of the
- * response.
+ * Save the changed object. The default calls WC_Data::save(). An order's
+ * save() catches a failed write, so the default reads that error from
+ * WC_Abstract_Order::get_last_save_error(). When the save fails or leaves
+ * the object without an ID, it logs the error and returns a WP_Error with a
+ * fixed message, so the exception text stays out of the response.
*
* @since 11.3.0
*
@@ -88,9 +89,14 @@ abstract class ActionableAbility extends \WP_Ability {
protected function save( $subject ) {
try {
$subject->save();
+ $error = $subject instanceof \WC_Abstract_Order ? $subject->get_last_save_error() : null;
} catch ( \Exception $e ) {
+ $error = $e;
+ }
+
+ if ( null !== $error ) {
wc_get_logger()->error(
- sprintf( 'Ability "%s" failed to save: %s', $this->get_name(), $e->getMessage() ),
+ sprintf( 'Ability "%s" failed to save: %s', $this->get_name(), $error->getMessage() ),
array( 'source' => 'woocommerce-abilities' )
);
return self::save_failed();
diff --git a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
index 4860d8055d3..a50ba416cbf 100644
--- a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
+++ b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
@@ -489,9 +489,11 @@ class ExtensibleAbilities {
* no extension fields. For example, a validator refuses a product change that
* would break a subscription plan.
*
- * Core's product abilities run validators. Core's order abilities keep their
- * trunk write for now, so validators for `order` run only in the
- * ActionableAbility classes of extensions.
+ * Core's product abilities and order-update-status run validators. An order
+ * validator sees the order with its new status staged, including the paid
+ * and completed dates that the status sets. While the feature is on,
+ * order-update-status refuses an order class that cannot stage a status
+ * change. See WC_Order::stage_status().
*
* @since 11.3.0
*
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
index 11b5f6d925b..b1059338835 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
@@ -176,7 +176,7 @@ class AbilitiesLoader {
continue;
}
- // Core's product write abilities extend ActionableAbility, so WordPress builds them from their own class.
+ // Core's write abilities extend ActionableAbility, so WordPress builds them from their own class.
$is_actionable = is_a( $class_name, ActionableAbility::class, true ) && self::is_core_ability_definition_class( $class_name );
if ( ! $is_actionable && ! is_a( $class_name, AbilityDefinition::class, true ) ) {
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php
index ea5274f21cc..cfa594c4c16 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php
@@ -7,29 +7,30 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Abilities\Domain;
-use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Abilities\ActionableAbility;
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\DomainAbilityHelpers;
use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\OrderAbilityTrait;
+use Automattic\WooCommerce\Internal\Orders\StagedMethod;
use Automattic\WooCommerce\Utilities\OrderUtil;
defined( 'ABSPATH' ) || exit;
/**
- * Registers the WooCommerce order update status ability.
+ * The WooCommerce order update status ability. It runs the steps of
+ * ActionableAbility. With the feature on, change() stages the status with
+ * WC_Order::stage_status(), so validators see the new status before the save.
+ * With the feature off, save() calls WC_Order::update_status() as before.
*/
-class OrderUpdateStatus extends AbstractDomainAbility implements AbilityDefinition {
+class OrderUpdateStatus extends ActionableAbility {
+ use DomainAbilityHelpers;
use OrderAbilityTrait;
/**
- * Get the ability name.
- *
- * @return string
- *
- * @since 10.9.0
+ * Ability name.
*/
- public static function get_name(): string {
- return 'woocommerce/order-update-status';
- }
+ public const ABILITY_NAME = 'woocommerce/order-update-status';
/**
* Get the ability registration arguments.
@@ -46,9 +47,8 @@ class OrderUpdateStatus extends AbstractDomainAbility implements AbilityDefiniti
'woocommerce'
),
'category' => 'woocommerce',
- 'input_schema' => self::get_input_schema(),
+ 'input_schema' => self::build_input_schema(),
'output_schema' => self::get_entity_output_schema( 'order', self::get_order_output_schema() ),
- 'execute_callback' => array( __CLASS__, 'execute' ),
'permission_callback' => array( __CLASS__, 'can_edit_order' ),
'meta' => array(
'show_in_rest' => true,
@@ -66,20 +66,36 @@ class OrderUpdateStatus extends AbstractDomainAbility implements AbilityDefiniti
}
/**
- * Update an order status.
- *
- * @param array $input Ability input.
- * @return array|\WP_Error
+ * Resource that the ability changes.
*
- * @since 10.9.0
+ * @return string
*/
- public static function execute( array $input ) {
- $order = self::get_order_from_input( $input );
+ public function get_resource(): string {
+ return 'order';
+ }
- if ( is_wp_error( $order ) ) {
- return $order;
- }
+ /**
+ * Load the order to change.
+ *
+ * @param array $input Ability input.
+ * @return \WC_Order|\WP_Error
+ */
+ public function load( array $input ) {
+ return self::get_order_from_input( $input );
+ }
+ /**
+ * Check the new status. With the feature on, stage it with
+ * WC_Order::stage_status(), or refuse the change when the order class cannot
+ * stage it. With the feature off, no field or validator runs before save(),
+ * so this step changes the status with WC_Order::update_status(), which
+ * also saves, and save() does nothing.
+ *
+ * @param \WC_Order $subject Order.
+ * @param array $input Ability input.
+ * @return null|\WP_Error
+ */
+ public function change( $subject, array $input ) {
if ( empty( $input['status'] ) ) {
return new \WP_Error(
'woocommerce_order_status_required',
@@ -98,7 +114,7 @@ class OrderUpdateStatus extends AbstractDomainAbility implements AbilityDefiniti
);
}
- if ( $status === $order->get_status() ) {
+ if ( $status === $subject->get_status() ) {
return new \WP_Error(
'woocommerce_order_status_unchanged',
__(
@@ -109,7 +125,7 @@ class OrderUpdateStatus extends AbstractDomainAbility implements AbilityDefiniti
);
}
- if ( ! $order->is_valid_status( $status ) ) {
+ if ( ! $subject->is_valid_status( $status ) ) {
return new \WP_Error(
'woocommerce_order_status_invalid',
/* translators: %s: order status slug. */
@@ -118,12 +134,55 @@ class OrderUpdateStatus extends AbstractDomainAbility implements AbilityDefiniti
);
}
- try {
- $updated = $order->update_status(
- $status,
- isset( $input['note'] ) ? wp_kses_post( $input['note'] ) : '',
- true
+ if ( ! ExtensibleAbilities::is_enabled() ) {
+ return self::update_status( $subject, $status, self::get_note( $input ) );
+ }
+
+ $unstaged_class = StagedMethod::unstaged_class( $subject, 'update_status', 'stage_status' );
+ if ( null !== $unstaged_class ) {
+ return new \WP_Error(
+ 'woocommerce_order_status_staging_unsupported',
+ sprintf(
+ /* translators: %s: PHP class name of the order. */
+ __( 'This order type does not support this change yet. The class %s overrides update_status() and needs to override stage_status() too.', 'woocommerce' ),
+ $unstaged_class
+ ),
+ array( 'status' => 400 )
);
+ }
+
+ try {
+ $subject->stage_status( $status, self::get_note( $input ), true );
+ } catch ( \WC_Data_Exception $e ) {
+ return new \WP_Error( $e->getErrorCode(), $e->getMessage(), $e->getErrorData() );
+ }
+
+ return null;
+ }
+
+ /**
+ * Save the order. With the feature off, change() already saved it through
+ * WC_Order::update_status().
+ *
+ * @param \WC_Order $subject Order.
+ * @return null|\WP_Error
+ */
+ public function save( $subject ) {
+ return ExtensibleAbilities::is_enabled() ? parent::save( $subject ) : null;
+ }
+
+ /**
+ * Change the status with WC_Order::update_status(), as a manual change. The
+ * order's own rules run, and the order is saved.
+ *
+ * @param \WC_Order $order Order.
+ * @param string $status New status, without the wc- prefix.
+ * @param string $note Status change note.
+ * @return null|\WP_Error
+ */
+ private static function update_status( \WC_Order $order, string $status, string $note ): ?\WP_Error {
+ try {
+ $updated = $order->update_status( $status, $note, true );
} catch ( \Exception $e ) {
return new \WP_Error(
'woocommerce_order_status_update_failed',
@@ -140,17 +199,37 @@ class OrderUpdateStatus extends AbstractDomainAbility implements AbilityDefiniti
);
}
+ return null;
+ }
+
+ /**
+ * The ability output for the saved order.
+ *
+ * @param \WC_Order $subject Saved order.
+ * @return array
+ */
+ public function prepare_response( $subject ): array {
return array(
- 'order' => self::format_order_for_response( $order, false ),
+ 'order' => self::format_order_for_response( $subject, false ),
);
}
+ /**
+ * Get the status change note from the input.
+ *
+ * @param array $input Ability input.
+ * @return string
+ */
+ private static function get_note( array $input ): string {
+ return isset( $input['note'] ) ? wp_kses_post( $input['note'] ) : '';
+ }
+
/**
* Get the ability input schema.
*
* @return array
*/
- private static function get_input_schema(): array {
+ private static function build_input_schema(): array {
return array(
'type' => 'object',
'properties' => array(
diff --git a/plugins/woocommerce/src/Internal/Orders/StagedMethod.php b/plugins/woocommerce/src/Internal/Orders/StagedMethod.php
new file mode 100644
index 00000000000..b4b1899d2f2
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Orders/StagedMethod.php
@@ -0,0 +1,55 @@
+<?php
+/**
+ * StagedMethod class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Orders;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * A Core order method that sets and saves in one call, paired with the method that only stages the change in
+ * memory. The first method calls the stage method, then save(). For example: update_status() and stage_status().
+ *
+ * A class that overrides the first method with rules of its own must override the stage method too, at the same
+ * level. Otherwise an ability that stages the change skips those rules, so the ability refuses the class.
+ *
+ * @internal
+ * @since 11.4.0
+ */
+final class StagedMethod {
+
+ /**
+ * Name of the first class, from the object's class upward, that declares the method that sets and saves without
+ * declaring the stage method. Null when the object's class can stage the change.
+ *
+ * @param object $subject Object to change, for example an order.
+ * @param string $method Method that sets and saves, for example update_status.
+ * @param string $stage_method Method that only stages the change, for example stage_status.
+ * @return string|null
+ */
+ public static function unstaged_class( object $subject, string $method, string $stage_method ): ?string {
+ $class_name = get_class( $subject );
+ while ( $class_name ) {
+ if ( self::declares( $class_name, $method ) && ! self::declares( $class_name, $stage_method ) ) {
+ return $class_name;
+ }
+ $class_name = get_parent_class( $class_name );
+ }
+
+ return null;
+ }
+
+ /**
+ * Whether the class itself declares the method, rather than inherits it.
+ *
+ * @param string $class_name Class name.
+ * @param string $method Method name.
+ * @return bool
+ */
+ private static function declares( string $class_name, string $method ): bool {
+ return method_exists( $class_name, $method ) && ( new \ReflectionMethod( $class_name, $method ) )->class === $class_name;
+ }
+}
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-crud-orders.php b/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-crud-orders.php
index 1f5135fbeac..31357d989ca 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-crud-orders.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-crud-orders.php
@@ -1094,6 +1094,118 @@ class WC_Tests_CRUD_Orders extends WC_Unit_Test_Case {
$this->assertEquals( OrderStatus::ON_HOLD, $object->get_status() );
}
+ /**
+ * Test: woocommerce_order_edit_status fires once for a manual change. set_status fires it right away, stage_status on the next save.
+ */
+ public function test_edit_status_action_fires_once_for_a_manual_change() {
+ $object = new WC_Order();
+ $object->save();
+ $fired = did_action( 'woocommerce_order_edit_status' );
+
+ $this->assertTrue( $object->update_status( OrderStatus::ON_HOLD, '', true ) );
+ $this->assertSame( 1, did_action( 'woocommerce_order_edit_status' ) - $fired );
+ $this->assertSame( OrderStatus::ON_HOLD, wc_get_order( $object->get_id() )->get_status() );
+
+ $object->set_status( OrderStatus::PROCESSING, '', true );
+ $this->assertSame( 2, did_action( 'woocommerce_order_edit_status' ) - $fired );
+ $object->save();
+
+ $object->stage_status( OrderStatus::COMPLETED, '', true );
+ $this->assertSame( 2, did_action( 'woocommerce_order_edit_status' ) - $fired );
+ $object->save();
+ $this->assertSame( 3, did_action( 'woocommerce_order_edit_status' ) - $fired );
+ $this->assertSame( OrderStatus::COMPLETED, wc_get_order( $object->get_id() )->get_status() );
+ }
+
+ /**
+ * Test: two stage_status calls before one save fire woocommerce_order_edit_status once, with the last status, and set_status after stage_status fires it once.
+ */
+ public function test_edit_status_action_fires_once_for_the_last_staged_status() {
+ $statuses = array();
+ $listener = static function ( $order_id, $status ) use ( &$statuses ) {
+ $statuses[] = $status;
+ };
+ add_action( 'woocommerce_order_edit_status', $listener, 10, 2 );
+
+ $object = new WC_Order();
+ $object->save();
+ $object->stage_status( OrderStatus::ON_HOLD, '', true );
+ $object->stage_status( OrderStatus::PROCESSING, '', true );
+ $object->save();
+
+ $object->stage_status( OrderStatus::ON_HOLD, '', true );
+ $object->set_status( OrderStatus::COMPLETED, '', true );
+ $object->save();
+
+ remove_action( 'woocommerce_order_edit_status', $listener, 10 );
+ $this->assertSame( array( OrderStatus::PROCESSING, OrderStatus::COMPLETED ), $statuses );
+ $this->assertSame( OrderStatus::COMPLETED, wc_get_order( $object->get_id() )->get_status() );
+ }
+
+ /**
+ * Test: a non-manual status change after stage_status cancels the pending woocommerce_order_edit_status, and a repeat of the staged status keeps it.
+ */
+ public function test_edit_status_action_follows_the_last_status_change() {
+ $statuses = array();
+ $listener = static function ( $order_id, $status ) use ( &$statuses ) {
+ $statuses[] = $status;
+ };
+ add_action( 'woocommerce_order_edit_status', $listener, 10, 2 );
+
+ $object = new WC_Order();
+ $object->save();
+ $object->stage_status( OrderStatus::COMPLETED, '', true );
+ $object->set_status( OrderStatus::ON_HOLD );
+ $object->save();
+ $persisted = wc_get_order( $object->get_id() )->get_status();
+ $canceled = $statuses;
+
+ $object->stage_status( OrderStatus::PROCESSING, '', true );
+ $object->stage_status( OrderStatus::COMPLETED );
+ $object->save();
+ $canceled_by_stage = $statuses;
+
+ $object->stage_status( OrderStatus::ON_HOLD, '', true );
+ $object->set_status( OrderStatus::ON_HOLD );
+ $object->save();
+
+ remove_action( 'woocommerce_order_edit_status', $listener, 10 );
+ $this->assertSame( OrderStatus::ON_HOLD, $persisted );
+ $this->assertEmpty( $canceled );
+ $this->assertEmpty( $canceled_by_stage );
+ $this->assertSame( array( OrderStatus::ON_HOLD ), $statuses );
+ }
+
+ /**
+ * Test: update_status fires woocommerce_order_edit_status before it writes and after the paid and completed dates are set, and set_status fires it before those dates are set.
+ */
+ public function test_edit_status_action_fires_before_the_write() {
+ $events = array();
+ $object = null;
+ $listener = static function ( $order_id ) use ( &$events, &$object ) {
+ $events[] = array(
+ wc_get_order( $order_id )->get_status(),
+ null === $object->get_date_paid( 'edit' ),
+ null === $object->get_date_completed( 'edit' ),
+ );
+ };
+ add_action( 'woocommerce_order_edit_status', $listener );
+
+ $object = new WC_Order();
+ $object->save();
+ $object->update_status( OrderStatus::COMPLETED, '', true );
+
+ $object = new WC_Order();
+ $object->save();
+ $object->set_status( OrderStatus::COMPLETED, '', true );
+ $set = array( null !== $object->get_date_paid( 'edit' ), null !== $object->get_date_completed( 'edit' ) );
+
+ remove_action( 'woocommerce_order_edit_status', $listener );
+ $this->assertSame( array( OrderStatus::PENDING, false, false ), $events[0] );
+ $this->assertSame( array( OrderStatus::PENDING, true, true ), $events[1] );
+ $this->assertSame( array( true, true ), $set );
+ }
+
/**
* Test that exceptions thrown during update_status are handled.
* Note: This can't actually test the transaction rollbacks since WC transactions are disabled in unit tests.
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
index 8adbbc172d7..00ff82a6983 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
@@ -173,6 +173,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
);
$writable = array(
array( 'product', 'test-ext', 'color', array( 'type' => 'string' ) ),
+ array( 'order', 'test-ext', 'gift_message', array( 'type' => 'string' ) ),
array( 'product', 'other_ext', 'count', array( 'type' => 'integer' ) ),
array(
'product',
@@ -1781,9 +1782,9 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
}
/**
- * @testdox Should accept extension fields in product create, and none in order status update.
+ * @testdox Should write extension fields in product create and in order status update.
*/
- public function test_product_create_writes_fields_and_order_status_update_does_not(): void {
+ public function test_product_create_and_order_status_update_write_fields(): void {
$order = \WC_Helper_Order::create_order();
$created = $this->run_ability(
@@ -1793,10 +1794,20 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
'extensions' => array( 'test-ext' => array( 'color' => 'blue' ) ),
)
);
- $schema = $this->get_ability( 'woocommerce/order-update-status' )['input_schema'];
+ $updated = $this->run_ability(
+ 'woocommerce/order-update-status',
+ array(
+ 'id' => $order->get_id(),
+ 'status' => 'on-hold',
+ 'extensions' => array( 'test-ext' => array( 'gift_message' => 'Happy birthday' ) ),
+ )
+ );
+ $saved = wc_get_order( $order->get_id() );
$this->assertSame( 'blue', wc_get_product( $created['product']['id'] )->get_meta( '_test_color' ) );
- $this->assertArrayNotHasKey( 'extensions', $schema['properties'] );
+ $this->assertSame( 'on-hold', $saved->get_status() );
+ $this->assertSame( 'Happy birthday', $saved->get_meta( '_test_gift_message' ) );
+ $this->assertSame( 'Happy birthday', $updated['order']['extensions']['test-ext']['gift_message'] );
}
/**
@@ -1901,7 +1912,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
}
/**
- * @testdox Should call WC_Order::update_status() with the feature off and on.
+ * @testdox Should call WC_Order::update_status() with the feature off, and refuse an order class that only overrides it with the feature on.
*/
public function test_the_feature_off_path_calls_update_status(): void {
$calls = 0;
@@ -1937,7 +1948,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
$order_class::$calls = 0;
$order = \WC_Helper_Order::create_order();
- $this->run_ability(
+ $response = $this->run_ability_response(
'woocommerce/order-update-status',
array(
'id' => $order->get_id(),
@@ -1946,8 +1957,16 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
);
$calls = $order_class::$calls;
- $this->assertSame( 1, $calls, $enabled ? 'feature on' : 'feature off' );
- $this->assertSame( 'completed', wc_get_order( $order->get_id() )->get_status() );
+ if ( $enabled ) {
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'woocommerce_order_status_staging_unsupported', $response->get_data()['code'] );
+ $this->assertSame( 0, $calls );
+ $this->assertSame( 'pending', wc_get_order( $order->get_id() )->get_status() );
+ } else {
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( 1, $calls );
+ $this->assertSame( 'completed', wc_get_order( $order->get_id() )->get_status() );
+ }
}
remove_filter( 'woocommerce_order_class', $filter_class, 100 );
@@ -2053,6 +2072,34 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
$this->assertSame( 'Pen', wc_get_product( $product->get_id() )->get_name() );
}
+ /**
+ * @testdox Should return a fixed 500 error when the order write fails, though the order's save() catches the exception.
+ */
+ public function test_a_failed_order_write_returns_a_fixed_error(): void {
+ $this->register_order_status_ability();
+ $order = \WC_Helper_Order::create_order();
+ $order->set_status( 'pending' );
+ $order->save();
+ $throw = static function () {
+ throw new \RuntimeException( 'Storage text.' );
+ };
+ add_action( 'woocommerce_before_order_object_save', $throw );
+
+ $response = $this->run_ability_response(
+ 'test-ext/order-status',
+ array(
+ 'id' => $order->get_id(),
+ 'status' => 'completed',
+ )
+ );
+
+ remove_action( 'woocommerce_before_order_object_save', $throw );
+ $this->assertSame( 500, $response->get_status() );
+ $this->assertSame( 'woocommerce_ability_save_failed', $response->get_data()['code'] );
+ $this->assertStringNotContainsString( 'Storage text.', wp_json_encode( $response->get_data() ) );
+ $this->assertSame( 'pending', wc_get_order( $order->get_id() )->get_status() );
+ }
+
/**
* @testdox Should list the extensions key in anyOf and allOf branches that refuse other properties, and accept extension fields through an anyOf branch.
*/
@@ -2123,7 +2170,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
$this->assertArrayNotHasKey( 'oneOf', $schema );
}
$order = $this->get_ability( 'woocommerce/order-update-status' )['input_schema'];
- $this->assertArrayNotHasKey( 'extensions', $order['properties'] );
+ $this->assertSame( array( 'string', 'null' ), $order['properties']['extensions']['properties']['test-ext']['properties']['gift_message']['type'] );
}
/**
@@ -2279,6 +2326,237 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
$this->assertSame( 'test-ext/order-status', $ability );
}
+ /**
+ * @testdox Should stage the status on a plain order, so an order validator sees it, and save nothing or fire no status action when the validator refuses.
+ */
+ public function test_an_order_validator_sees_the_staged_status(): void {
+ $order = \WC_Helper_Order::create_order();
+ $order->set_status( 'processing' );
+ $order->save();
+ $seen = array();
+ $this->register_validator(
+ 'order',
+ 'other_ext',
+ static function ( \WC_Order $subject ) use ( &$seen ) {
+ $seen[] = array( $subject->get_status(), $subject->get_meta( '_test_gift_message' ) );
+ if ( 'completed' === $subject->get_status() && 'Hi' === $subject->get_meta( '_test_gift_message' ) ) {
+ return new \WP_Error( 'test_gift_not_sent', 'Send the gift message first.' );
+ }
+ return null;
+ }
+ );
+ $edits = did_action( 'woocommerce_order_edit_status' );
+ $changes = did_action( 'woocommerce_order_status_changed' );
+
+ $refused = $this->run_ability_response(
+ 'woocommerce/order-update-status',
+ array(
+ 'id' => $order->get_id(),
+ 'status' => 'completed',
+ 'extensions' => array( 'test-ext' => array( 'gift_message' => 'Hi' ) ),
+ )
+ );
+
+ $saved = wc_get_order( $order->get_id() );
+ $this->assertSame( 400, $refused->get_status() );
+ $this->assertStringEndsWith( 'test_gift_not_sent', $refused->get_data()['code'] );
+ $this->assertSame( 'processing', $saved->get_status() );
+ $this->assertFalse( $saved->meta_exists( '_test_gift_message' ) );
+ $this->assertSame( 0, did_action( 'woocommerce_order_edit_status' ) - $edits );
+ $this->assertSame( 0, did_action( 'woocommerce_order_status_changed' ) - $changes );
+
+ $this->run_ability(
+ 'woocommerce/order-update-status',
+ array(
+ 'id' => $order->get_id(),
+ 'status' => 'completed',
+ 'extensions' => array( 'test-ext' => array( 'gift_message' => 'Thanks' ) ),
+ )
+ );
+
+ $saved = wc_get_order( $order->get_id() );
+ $this->assertSame( 'completed', $saved->get_status() );
+ $this->assertSame( 'Thanks', $saved->get_meta( '_test_gift_message' ) );
+ $this->assertSame( 1, did_action( 'woocommerce_order_edit_status' ) - $edits );
+ $this->assertSame( 1, did_action( 'woocommerce_order_status_changed' ) - $changes );
+ $this->assertSame( array( array( 'completed', 'Hi' ), array( 'completed', 'Thanks' ) ), $seen );
+ }
+
+ /**
+ * @testdox Should show the staged status and its completed date to an order validator, and fire woocommerce_order_edit_status before the write.
+ */
+ public function test_an_order_validator_sees_the_dates_that_the_status_sets(): void {
+ $order = \WC_Helper_Order::create_order();
+ $seen = array();
+ $this->register_validator(
+ 'order',
+ 'other_ext',
+ static function ( \WC_Order $data ) use ( &$seen ) {
+ $seen[] = array( $data->get_status(), null !== $data->get_date_completed( 'edit' ) );
+ return null;
+ }
+ );
+ $events = array();
+ $listener = static function ( $order_id ) use ( &$events ) {
+ $events[] = wc_get_order( $order_id )->get_status();
+ };
+ add_action( 'woocommerce_order_edit_status', $listener );
+
+ $this->run_ability(
+ 'woocommerce/order-update-status',
+ array(
+ 'id' => $order->get_id(),
+ 'status' => 'completed',
+ )
+ );
+
+ remove_action( 'woocommerce_order_edit_status', $listener );
+ $this->assertSame( array( array( 'completed', true ) ), $seen );
+ $this->assertSame( array( 'pending' ), $events );
+ $this->assertSame( 'completed', wc_get_order( $order->get_id() )->get_status() );
+ }
+
+ /**
+ * @testdox Should stage the status on an order class that overrides update_status() and stage_status(), with its rule, and refuse a subclass that overrides update_status() again.
+ */
+ public function test_an_order_class_that_stages_keeps_its_rule(): void {
+ $order_class = TestStagingOrder::class;
+ $filter_class = static function () use ( &$order_class ) {
+ return $order_class;
+ };
+ add_filter( 'woocommerce_order_class', $filter_class, 100 );
+ $order = \WC_Helper_Order::create_order();
+
+ $refused = $this->run_ability_response(
+ 'woocommerce/order-update-status',
+ array(
+ 'id' => $order->get_id(),
+ 'status' => 'on-hold',
+ )
+ );
+ $updated = $this->run_ability(
+ 'woocommerce/order-update-status',
+ array(
+ 'id' => $order->get_id(),
+ 'status' => 'processing',
+ )
+ );
+ $order_class = get_class(
+ new class() extends TestStagingOrder {
+ /**
+ * Mark the order, then change the status.
+ *
+ * @param string $new_status Status to change the order to.
+ * @param string $note Optional note.
+ * @param bool $manual_update Whether a person made the change.
+ * @return bool
+ */
+ public function update_status( $new_status, $note = '', $manual_update = false ) {
+ $this->update_meta_data( '_test_child_rule', 'yes' );
+ return parent::update_status( $new_status, $note, $manual_update );
+ }
+ }
+ );
+ $child = \WC_Helper_Order::create_order();
+ $again = $this->run_ability_response(
+ 'woocommerce/order-update-status',
+ array(
+ 'id' => $child->get_id(),
+ 'status' => 'completed',
+ )
+ );
+
+ remove_filter( 'woocommerce_order_class', $filter_class, 100 );
+ $this->assertSame( 409, $refused->get_status() );
+ $this->assertSame( 'test_on_hold_refused', $refused->get_data()['code'] );
+ $this->assertSame( 'processing', $updated['order']['status'] );
+ $this->assertSame( 400, $again->get_status() );
+ $this->assertSame( 'woocommerce_order_status_staging_unsupported', $again->get_data()['code'] );
+ $this->assertSame( 'processing', wc_get_order( $order->get_id() )->get_status() );
+ $this->assertSame( 'pending', wc_get_order( $child->get_id() )->get_status() );
+ }
+
+ /**
+ * @testdox Should stage the status on an order class that overrides stage_status() only.
+ */
+ public function test_an_order_class_that_overrides_stage_status_only_is_staged(): void {
+ $order_class = get_class(
+ new class() extends \WC_Order {
+ /**
+ * Mark the order, then stage the status.
+ *
+ * @param string $new_status Status to change the order to.
+ * @param string|false $note Optional note.
+ * @param bool $manual_update Whether a person made the change.
+ * @return array
+ */
+ public function stage_status( $new_status, $note = '', $manual_update = false ) {
+ $this->update_meta_data( '_test_staged', 'yes' );
+ return parent::stage_status( $new_status, $note, $manual_update );
+ }
+ }
+ );
+ $filter_class = static function () use ( $order_class ) {
+ return $order_class;
+ };
+ add_filter( 'woocommerce_order_class', $filter_class, 100 );
+ $order = \WC_Helper_Order::create_order();
+
+ $updated = $this->run_ability(
+ 'woocommerce/order-update-status',
+ array(
+ 'id' => $order->get_id(),
+ 'status' => 'completed',
+ )
+ );
+
+ remove_filter( 'woocommerce_order_class', $filter_class, 100 );
+ $this->assertSame( 'completed', $updated['order']['status'] );
+ $saved = wc_get_order( $order->get_id() );
+ $this->assertSame( 'completed', $saved->get_status() );
+ $this->assertSame( 'yes', $saved->get_meta( '_test_staged' ) );
+ }
+
+ /**
+ * @testdox Should refuse an order class whose parent overrides update_status() without stage_status(), and name that parent.
+ */
+ public function test_an_order_class_below_an_unstaged_parent_is_refused(): void {
+ $order_class = get_class(
+ new class() extends TestUnstagedOrder {
+ /**
+ * Mark the order, then stage the status.
+ *
+ * @param string $new_status Status to change the order to.
+ * @param string|false $note Optional note.
+ * @param bool $manual_update Whether a person made the change.
+ * @return array
+ */
+ public function stage_status( $new_status, $note = '', $manual_update = false ) {
+ $this->update_meta_data( '_test_staged', 'yes' );
+ return parent::stage_status( $new_status, $note, $manual_update );
+ }
+ }
+ );
+ $filter_class = static fn() => $order_class;
+ add_filter( 'woocommerce_order_class', $filter_class, 100 );
+ $order = \WC_Helper_Order::create_order();
+
+ $response = $this->run_ability_response(
+ 'woocommerce/order-update-status',
+ array(
+ 'id' => $order->get_id(),
+ 'status' => 'completed',
+ )
+ );
+
+ remove_filter( 'woocommerce_order_class', $filter_class, 100 );
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'woocommerce_order_status_staging_unsupported', $response->get_data()['code'] );
+ $this->assertStringContainsString( TestUnstagedOrder::class, $response->get_data()['message'] );
+ $this->assertStringContainsString( 'stage_status()', $response->get_data()['message'] );
+ $this->assertSame( 'pending', wc_get_order( $order->get_id() )->get_status() );
+ }
+
/**
* @testdox Should run a validator on the changed product, after the field callbacks, and save nothing when it refuses.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/TestStagingOrder.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/TestStagingOrder.php
new file mode 100644
index 00000000000..57bb3290a8b
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/TestStagingOrder.php
@@ -0,0 +1,54 @@
+<?php
+/**
+ * TestStagingOrder class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\AbilitiesApi;
+
+/**
+ * An extension's order class that refuses on-hold in update_status() and in stage_status().
+ */
+class TestStagingOrder extends \WC_Order {
+
+ /**
+ * Refuse on-hold, then change the status.
+ *
+ * @param string $new_status Status to change the order to.
+ * @param string $note Optional note.
+ * @param bool $manual_update Whether a person made the change.
+ * @return bool
+ * @throws \WC_Data_Exception When the order refuses the status.
+ */
+ public function update_status( $new_status, $note = '', $manual_update = false ) {
+ $this->refuse_on_hold( $new_status );
+ return parent::update_status( $new_status, $note, $manual_update );
+ }
+
+ /**
+ * Refuse on-hold, then stage the status.
+ *
+ * @param string $new_status Status to change the order to.
+ * @param string|false $note Optional note.
+ * @param bool $manual_update Whether a person made the change.
+ * @return array
+ * @throws \WC_Data_Exception When the order refuses the status.
+ */
+ public function stage_status( $new_status, $note = '', $manual_update = false ) {
+ $this->refuse_on_hold( $new_status );
+ return parent::stage_status( $new_status, $note, $manual_update );
+ }
+
+ /**
+ * Throw for on-hold.
+ *
+ * @param string $new_status Status to change the order to.
+ * @throws \WC_Data_Exception When the status is on-hold.
+ */
+ private function refuse_on_hold( $new_status ): void {
+ if ( 'on-hold' === $new_status ) {
+ throw new \WC_Data_Exception( 'test_on_hold_refused', 'Unable to change this order to "on-hold".', 409 );
+ }
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/TestUnstagedOrder.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/TestUnstagedOrder.php
new file mode 100644
index 00000000000..1b7843f8358
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/TestUnstagedOrder.php
@@ -0,0 +1,27 @@
+<?php
+/**
+ * TestUnstagedOrder class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\AbilitiesApi;
+
+/**
+ * An extension's order class that overrides update_status() and not stage_status().
+ */
+class TestUnstagedOrder extends \WC_Order {
+
+ /**
+ * Mark the order, then change the status.
+ *
+ * @param string $new_status Status to change the order to.
+ * @param string $note Optional note.
+ * @param bool $manual_update Whether a person made the change.
+ * @return bool
+ */
+ public function update_status( $new_status, $note = '', $manual_update = false ) {
+ $this->update_meta_data( '_test_parent_rule', 'yes' );
+ return parent::update_status( $new_status, $note, $manual_update );
+ }
+}