Commit 69f5d91b9e8 for woocommerce
commit 69f5d91b9e8d22219c88e65f01f2c93be7f626d1
Author: Christopher Hertel <mail@christopher-hertel.de>
Date: Fri Nov 13 18:46:08 2026 +0100
Add the woocommerce/order-create ability (#70822)
* Cover the address, shipping line and product line rules of order-update
Characterization tests before those rules move into a trait that the
order-create ability will share. Line tests run with the extensible
abilities feature on and off, since the two paths add lines differently.
* Move the address and line rules of order-update into OrderEditTrait
The order-create ability will apply the same rules to a new order. No
behavior change: order-update keeps its removals, line quantity changes,
stock adjustment and schema. Its copy of the address field list gives way
to the one OrderAbilityTrait already has.
* Add the woocommerce/order-create ability
It creates an order with a customer, addresses, a customer note, product
lines, shipping lines and a status, applying the address and line rules
of order-update through OrderEditTrait. The order starts with the default
status and moves to the sent one on its save, so a paid status reduces
stock and sends the order emails as the order screen does. With the
extensible abilities feature on, extension fields and validators run and
the order saves once; a refusal creates nothing. Orders get created_via
'ability'.
* Run the status hooks of a filtered default status in order-create
A new order read its status from woocommerce_default_order_status, so a
store whose default is on-hold saved the order straight into on-hold, and
no stock reduction or order email ran. The order now starts at pending,
without a recorded transition, and moves to the sent or default status.
Drop the try/catch around update_status(), which catches its own
exceptions, and cover the rollback of the order when the status change
fails with the feature off.
* Give woocommerce_order_edit_status the ID of a new staged order
stage_status() read the order ID when it staged the status and left the
hook to before the write. A new order has no ID until the write, so
order-create with the extensible abilities feature on fired the hook
with ID 0, and a listener that loaded the order got false. For an order
without an ID, the staged hook now fires after the write and reads the
ID then. Orders that already exist keep the hook before the write.
* Say that order-create's status defaults to the store's default status
* Require a line item in order-create
An order-create call without lines created an empty guest order, which an agent is more likely to send by mistake than mean.
* Revert "Give woocommerce_order_edit_status the ID of a new staged order"
This reverts commit 3889959bfdae5ad7e0e20aa040521dfe190d59e0.
* Create orders with a non-manual status change in order-create
The REST API and wc_create_order() set the status of a new order as a
non-manual change, so woocommerce_order_edit_status never fires for an
order without an ID. order-create now does the same with the feature on
and off. Stock, emails and the status hooks still run from the status
transition.
* Return the saved order from order-create
The response formatted the order object in memory, so a woocommerce_after_order_object_save listener that changed it without saving showed up in the output. Read the order again, as order-update-status and the product abilities do, and keep the lines in the output.
* Assert literal order totals in the order-create and order-update tests
* Drop the free replacement example from the order-create description
A line takes the catalog price and the input has no line total, fee or coupon, so the ability cannot create a free order.
* Assert the shipping line IDs in the order-create response
* Refuse unsaved recalculations and exponent shipping totals in order abilities
An order's save() records a failed write instead of throwing, so the recalculation after the save now checks each step and fails the call. A shipping total such as 1e3 passed is_numeric() but wc_format_decimal() saved it as 13, so only plain decimals are accepted.
* Accept shipping totals such as .5 and 5. in order abilities
Only an exponent changes the value that wc_format_decimal() saves, so the check refuses that and keeps every other total that is_numeric() accepted before.
* Fix PHPStan and the posts storage cache in the order recalculation failure
PHPStan reads a numeric total as float|int|string, so stripos() gets a string cast. With posts storage, the rollback left the added lines in the order's item cache, so a failed recalculation deletes them, as a failed save does.
* Refuse customers of other sites in order-create on multisite
get_userdata() finds a user anywhere on the network. Users::get_user_in_current_site() refuses users who are not members of the current site, unless the requester can manage network users or network-wide customers is on, as in REST order creation.
diff --git a/plugins/woocommerce/changelog/wooai-1648-order-create-ability b/plugins/woocommerce/changelog/wooai-1648-order-create-ability
new file mode 100644
index 00000000000..f73e92d1285
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooai-1648-order-create-ability
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Let AI agents create orders, such as phone orders, with the same checks, stock changes and emails as the order screen.
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
index ea3aae4a023..55501db758b 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
@@ -11,6 +11,7 @@ use Automattic\WooCommerce\Abilities\AbilityDefinition;
use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
use Automattic\WooCommerce\Abilities\ActionableAbility;
use Automattic\WooCommerce\Internal\Abilities\Domain\OrderAddNote;
+use Automattic\WooCommerce\Internal\Abilities\Domain\OrderCreate;
use Automattic\WooCommerce\Internal\Abilities\Domain\OrderNotesQuery;
use Automattic\WooCommerce\Internal\Abilities\Domain\OrderSendEmail;
use Automattic\WooCommerce\Internal\Abilities\Domain\OrderUpdate;
@@ -57,6 +58,7 @@ class AbilitiesLoader {
private const CORE_ABILITY_DEFINITION_CLASSES = array(
OrdersQuery::class,
OrderAddNote::class,
+ OrderCreate::class,
OrderNotesQuery::class,
OrderSendEmail::class,
OrderUpdate::class,
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderCreate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderCreate.php
new file mode 100644
index 00000000000..69ffb83d8cb
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderCreate.php
@@ -0,0 +1,305 @@
+<?php
+/**
+ * Order create ability class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\ActionableAbility;
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\DomainAbilityHelpers;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\OrderAbilityTrait;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\OrderEditTrait;
+use Automattic\WooCommerce\Internal\Utilities\Users;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * The WooCommerce order create ability. The order starts at pending and moves to the sent or default
+ * status on its save, so the status hooks run as on the order screen: a paid status reduces the stock
+ * and sends the emails.
+ */
+class OrderCreate extends ActionableAbility {
+
+ use DomainAbilityHelpers;
+ use OrderAbilityTrait;
+ use OrderEditTrait;
+
+ /**
+ * Ability name.
+ */
+ public const ABILITY_NAME = 'woocommerce/order-create';
+
+ /**
+ * The created_via value of the orders that this ability creates.
+ */
+ public const CREATED_VIA = 'ability';
+
+ /**
+ * Status that the input sent, or the default status. With the feature off, save() applies it once the order
+ * has an ID.
+ *
+ * @var string
+ */
+ private string $status = '';
+
+ /**
+ * Get the ability registration arguments.
+ *
+ * @return array
+ *
+ * @since 11.4.0
+ */
+ public static function get_registration_args(): array {
+ return array(
+ 'label' => __( 'Create order', 'woocommerce' ),
+ 'description' => __(
+ 'Create an order, as the order screen does, for example for a phone order. Without customer_id, the order is a guest order. Without status, the order gets the default status of the store, which is pending payment unless an extension changes it. A processing, on-hold or completed status reduces the stock of the products and sends the order emails to the store and the customer, so confirm the status before you send it.',
+ 'woocommerce'
+ ),
+ 'category' => 'woocommerce',
+ 'input_schema' => self::build_input_schema(),
+ 'output_schema' => self::get_entity_output_schema( 'order', self::get_order_output_schema() ),
+ 'permission_callback' => array( __CLASS__, 'can_create_order' ),
+ 'meta' => array(
+ 'show_in_rest' => true,
+ 'mcp' => array(
+ 'public' => true,
+ 'type' => 'tool',
+ ),
+ 'annotations' => array(
+ 'readonly' => false,
+ 'idempotent' => false,
+ 'destructive' => false,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Check that the user may create orders.
+ *
+ * @return bool
+ *
+ * @since 11.4.0
+ */
+ public static function can_create_order(): bool {
+ return wc_rest_check_post_permissions( 'shop_order', 'create' );
+ }
+
+ /**
+ * Resource that the ability changes.
+ *
+ * @return string
+ */
+ public function get_resource(): string {
+ return 'order';
+ }
+
+ /**
+ * Build an order in memory with the store defaults that wc_create_order() sets.
+ *
+ * @param array $input Ability input.
+ * @return \WC_Order
+ */
+ public function load( array $input ) {
+ $order = new \WC_Order();
+ $order->set_created_via( self::CREATED_VIA );
+ $order->set_currency( get_woocommerce_currency() );
+ $order->set_prices_include_tax( wc_prices_include_tax() );
+
+ return $order;
+ }
+
+ /**
+ * Set the customer, addresses and lines in memory, then the status. Every refusal comes back in one error.
+ *
+ * @param \WC_Order $subject New order.
+ * @param array $input Ability input.
+ * @return null|\WP_Error
+ * @throws \WC_Data_Exception When the order class has not adopted a stage method.
+ */
+ public function change( $subject, array $input ) {
+ $this->reset_line_changes();
+ $this->status = $input['status'] ?? $subject->get_status();
+ // Start at pending without recording a transition, so a filtered default such as on-hold still runs its status hooks.
+ $subject->set_object_read( false );
+ $subject->set_status( OrderStatus::PENDING );
+ $subject->set_object_read( true );
+
+ $refused = new \WP_Error();
+ if ( isset( $input['customer_id'] ) ) {
+ if ( is_wp_error( Users::get_user_in_current_site( $input['customer_id'] ) ) ) {
+ $refused->add(
+ 'woocommerce_ability_order_customer_not_found',
+ /* translators: %d: user ID. */
+ sprintf( __( 'User %d is not a user of this site. Send the ID of a customer of this site, or leave out customer_id for a guest order.', 'woocommerce' ), $input['customer_id'] ),
+ array( 'status' => 400 )
+ );
+ } else {
+ $subject->set_customer_id( $input['customer_id'] );
+ }
+ }
+
+ foreach ( array( 'billing', 'shipping' ) as $address_type ) {
+ $error = isset( $input[ $address_type ] ) ? self::set_address( $subject, $address_type, $input[ $address_type ] ) : null;
+ if ( is_wp_error( $error ) ) {
+ $refused->merge_from( $error );
+ }
+ }
+
+ if ( isset( $input['customer_note'] ) ) {
+ $subject->set_customer_note( sanitize_textarea_field( $input['customer_note'] ) );
+ }
+
+ foreach ( $input['shipping_lines'] ?? array() as $line ) {
+ $error = self::set_shipping_line( $subject, $line );
+ if ( is_wp_error( $error ) ) {
+ $refused->merge_from( $error );
+ }
+ }
+
+ foreach ( $input['line_items'] ?? array() as $line ) {
+ $error = $this->add_product_line( $subject, $line );
+ if ( is_wp_error( $error ) ) {
+ $refused->merge_from( $error );
+ }
+ }
+
+ if ( $refused->has_errors() ) {
+ return $refused;
+ }
+
+ if ( ! empty( $input['line_items'] ) || ! empty( $input['shipping_lines'] ) ) {
+ $this->stage_line_totals( $subject, ! empty( $input['line_items'] ) );
+ }
+
+ // Not a manual change, as in REST and wc_create_order(): a new order has no ID for woocommerce_order_edit_status.
+ if ( ExtensibleAbilities::is_enabled() && $subject->get_status() !== $this->status ) {
+ $subject->stage_status( $this->status );
+ }
+
+ return null;
+ }
+
+ /**
+ * Save the order. With the feature off, the order saves as pending with its lines, is recalculated with the
+ * methods that save, and then moves to its status with WC_Order::update_status(), which needs an order ID.
+ *
+ * @param \WC_Order $subject New order.
+ * @return null|\WP_Error
+ */
+ public function save( $subject ) {
+ if ( ExtensibleAbilities::is_enabled() ) {
+ return parent::save( $subject );
+ }
+
+ $added = $this->add_queued_products( $subject );
+ $saved = parent::save( $subject );
+ if ( is_wp_error( $saved ) ) {
+ foreach ( $added as $item ) {
+ $item->delete();
+ }
+ return $saved;
+ }
+
+ $recalculated = $this->recalculate_saved_order( $subject );
+ if ( is_wp_error( $recalculated ) ) {
+ foreach ( $added as $item ) {
+ $item->delete();
+ }
+ return $recalculated;
+ }
+
+ if ( $subject->get_status() === $this->status ) {
+ return null;
+ }
+
+ // The error rolls back the pending order that parent::save() wrote.
+ return $subject->update_status( $this->status ) ? null : new \WP_Error(
+ 'woocommerce_order_status_update_failed',
+ __( 'The order was not created, because its status could not be set.', 'woocommerce' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ /**
+ * The ability output for the saved order. It includes the lines, so the client gets their IDs.
+ *
+ * @param \WC_Order $subject Saved order.
+ * @return array|\WP_Error
+ */
+ public function prepare_response( $subject ) {
+ $stored = wc_get_order( $subject->get_id() );
+ $order = self::format_order_for_response( $stored instanceof \WC_Order ? $stored : $subject, array( 'line_items', 'lines' ) );
+ if ( is_wp_error( $order ) ) {
+ return $order;
+ }
+
+ return array( 'order' => $order );
+ }
+
+ /**
+ * Get the ability input schema.
+ *
+ * @return array
+ */
+ private static function build_input_schema(): array {
+ return array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'customer_id' => array(
+ 'type' => 'integer',
+ 'minimum' => 1,
+ ),
+ 'status' => array(
+ 'type' => 'string',
+ 'description' => __( 'Order status slug without the wc- prefix. Defaults to the default status of the store.', 'woocommerce' ),
+ 'enum' => self::get_allowed_order_status_slugs(),
+ ),
+ 'billing' => self::get_address_input_schema( array_merge( self::shipping_address_fields(), array( 'email' ) ) ),
+ 'shipping' => self::get_address_input_schema( self::shipping_address_fields() ),
+ 'customer_note' => array( 'type' => 'string' ),
+ 'line_items' => array(
+ 'type' => 'array',
+ 'description' => __( 'Product lines. A variation uses its own ID as product_id. The price is the current price of the product.', 'woocommerce' ),
+ 'minItems' => 1,
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'product_id' => array(
+ 'type' => 'integer',
+ 'minimum' => 1,
+ ),
+ 'quantity' => array(
+ 'type' => 'integer',
+ 'minimum' => 1,
+ ),
+ ),
+ 'required' => array( 'product_id' ),
+ 'additionalProperties' => false,
+ ),
+ ),
+ 'shipping_lines' => array(
+ 'type' => 'array',
+ 'minItems' => 1,
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'method_id' => array( 'type' => 'string' ),
+ 'method_title' => array( 'type' => 'string' ),
+ 'total' => array( 'type' => 'string' ),
+ ),
+ 'required' => array( 'method_title' ),
+ 'additionalProperties' => false,
+ ),
+ ),
+ ),
+ 'required' => array( 'line_items' ),
+ 'additionalProperties' => false,
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdate.php
index 555713e8e0c..a9e5b9efd8e 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdate.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdate.php
@@ -8,11 +8,10 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Abilities\Domain;
use Automattic\WooCommerce\Abilities\ActionableAbility;
-use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
use Automattic\WooCommerce\Enums\OrderStatus;
-use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\DomainAbilityHelpers;
use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\OrderAbilityTrait;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\OrderEditTrait;
use Automattic\WooCommerce\Internal\Admin\Orders\ItemQuantityLimits;
use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;
@@ -27,17 +26,13 @@ class OrderUpdate extends ActionableAbility {
use DomainAbilityHelpers;
use OrderAbilityTrait;
+ use OrderEditTrait;
/**
* Ability name.
*/
public const ABILITY_NAME = 'woocommerce/order-update';
- /**
- * Address fields that the shipping address has. The billing address also has `email`.
- */
- private const ADDRESS_FIELDS = array( 'first_name', 'last_name', 'company', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', 'phone' );
-
/**
* Product lines that change() removed, for the stock change in save().
*
@@ -45,30 +40,6 @@ class OrderUpdate extends ActionableAbility {
*/
private array $removed_product_lines = array();
- /**
- * Product lines that change() added or gave a new quantity, for the stock change in save().
- *
- * @var array<int, \WC_Order_Item_Product>
- */
- private array $changed_product_lines = array();
-
- /**
- * Whether save() recalculates the order with the methods that save, and which ones.
- * Only with the feature off, when no validator needs to see the new totals.
- *
- * @var string|null `coupons`, `totals` or null.
- */
- private ?string $recalculate_after_save = null;
-
- /**
- * Product lines to add with WC_Order::add_product() in save(), with the feature off. The saving method
- * runs the rules of an order class that has not adopted stage_product_item(), as order-update-status does
- * with update_status().
- *
- * @var array<int, array{0: \WC_Product, 1: int}>
- */
- private array $products_to_add = array();
-
/**
* Get the ability registration arguments.
*
@@ -145,10 +116,8 @@ class OrderUpdate extends ActionableAbility {
* @throws \WC_Data_Exception When the order class has not adopted a stage method.
*/
public function change( $subject, array $input ) {
- $this->removed_product_lines = array();
- $this->changed_product_lines = array();
- $this->recalculate_after_save = null;
- $this->products_to_add = array();
+ $this->removed_product_lines = array();
+ $this->reset_line_changes();
$refused = new \WP_Error();
foreach ( array( 'billing', 'shipping' ) as $address_type ) {
@@ -208,13 +177,7 @@ class OrderUpdate extends ActionableAbility {
}
}
- $added = array();
- foreach ( $this->products_to_add as list( $product, $quantity ) ) {
- $item = $subject->get_item( $subject->add_product( $product, $quantity, array( 'order' => $subject ) ), false );
- if ( $item instanceof \WC_Order_Item_Product ) {
- $added[] = $item;
- }
- }
+ $added = $this->add_queued_products( $subject );
$saved = parent::save( $subject );
@@ -227,13 +190,12 @@ class OrderUpdate extends ActionableAbility {
$this->changed_product_lines = array_merge( $this->changed_product_lines, $added );
- if ( 'coupons' === $this->recalculate_after_save ) {
- if ( $subject->get_prices_include_tax() ) {
- $subject->calculate_taxes();
+ $recalculated = $this->recalculate_saved_order( $subject );
+ if ( is_wp_error( $recalculated ) ) {
+ foreach ( $added as $item ) {
+ $item->delete();
}
- $subject->recalculate_coupons();
- } elseif ( 'totals' === $this->recalculate_after_save ) {
- $subject->calculate_totals();
+ return $recalculated;
}
if ( in_array( $subject->get_status(), array( OrderStatus::PROCESSING, OrderStatus::COMPLETED, OrderStatus::ON_HOLD ), true ) ) {
@@ -269,95 +231,6 @@ class OrderUpdate extends ActionableAbility {
return array( 'order' => $order );
}
- /**
- * Check the sent fields of an address against the order, then set the fields
- * that pass. A bad country keeps the saved country, state and postcode, because
- * the state and postcode checks depend on the country.
- *
- * @param \WC_Order $order Order.
- * @param string $type Address type: billing or shipping.
- * @param array $address Address fields to change.
- * @return null|\WP_Error
- */
- private static function set_address( \WC_Order $order, string $type, array $address ): ?\WP_Error {
- $address = wc_clean( $address );
- $refused = new \WP_Error();
- if ( ! empty( $address['email'] ) && ! is_email( $address['email'] ) ) {
- $refused->merge_from(
- self::address_error(
- $type,
- 'email',
- __( 'The billing email is not a valid email address. Send a valid address.', 'woocommerce' )
- )
- );
- unset( $address['email'] );
- }
-
- $country = $address['country'] ?? $order->{"get_{$type}_country"}();
- $state = $address['state'] ?? $order->{"get_{$type}_state"}();
- $postcode = $address['postcode'] ?? $order->{"get_{$type}_postcode"}();
-
- if ( '' !== $country && ! array_key_exists( $country, WC()->countries->get_countries() ) ) {
- $refused->merge_from(
- self::address_error(
- $type,
- 'country',
- /* translators: %s: country sent by the client. */
- sprintf( __( '"%s" is not a valid country. Send an ISO 3166-1 alpha-2 country code, such as PT.', 'woocommerce' ), $country )
- )
- );
- unset( $address['country'], $address['state'], $address['postcode'] );
- } else {
- $states = WC()->countries->get_states( $country );
-
- if ( '' !== $state && ! empty( $states ) && ! array_key_exists( $state, $states ) ) {
- $refused->merge_from(
- self::address_error(
- $type,
- 'state',
- /* translators: 1: state sent by the client or kept from the order, 2: country code, 3: comma-separated state codes. */
- sprintf( __( '"%1$s" is not a state of %2$s. Send the state with one of these codes: %3$s.', 'woocommerce' ), $state, $country, implode( ', ', array_keys( $states ) ) )
- )
- );
- unset( $address['state'] );
- }
-
- if ( '' !== $postcode && '' !== $country ) {
- if ( ! \WC_Validation::is_postcode( $postcode, $country ) ) {
- $refused->merge_from(
- self::address_error(
- $type,
- 'postcode',
- /* translators: 1: postcode sent by the client or kept from the order, 2: country code. */
- sprintf( __( '"%1$s" is not a valid postcode for %2$s. Send a postcode in the format of that country.', 'woocommerce' ), $postcode, $country )
- )
- );
- unset( $address['postcode'] );
- } elseif ( isset( $address['postcode'] ) ) {
- $address['postcode'] = wc_format_postcode( $postcode, $country );
- }
- }
- }
-
- foreach ( $address as $key => $value ) {
- $order->{"set_{$type}_{$key}"}( $value );
- }
-
- return $refused->has_errors() ? $refused : null;
- }
-
- /**
- * Build the error for an address field.
- *
- * @param string $type Address type: billing or shipping.
- * @param string $field Address field.
- * @param string $message What is wrong and what to send.
- * @return \WP_Error
- */
- private static function address_error( string $type, string $field, string $message ): \WP_Error {
- return new \WP_Error( "woocommerce_ability_order_{$type}_{$field}_invalid", $message, array( 'status' => 400 ) );
- }
-
/**
* Remove, change and add lines in memory, then recalculate the totals
* without saving. Coupons apply again after a product is added, because
@@ -418,125 +291,7 @@ class OrderUpdate extends ActionableAbility {
return $refused;
}
- $recalculate_coupons = $product_added && ! empty( $order->get_items( 'coupon' ) );
-
- if ( ! ExtensibleAbilities::is_enabled() ) {
- $this->recalculate_after_save = $recalculate_coupons ? 'coupons' : 'totals';
- return null;
- }
-
- if ( $recalculate_coupons ) {
- if ( $order->get_prices_include_tax() ) {
- $order->stage_taxes();
- }
-
- $order->stage_recalculated_coupons();
-
- return null;
- }
-
- $order->stage_totals();
-
- return null;
- }
-
- /**
- * Change a shipping line in memory, or add a new one when the line has no ID.
- *
- * @param \WC_Order $order Order.
- * @param array $line Shipping line input.
- * @return null|\WP_Error
- */
- private static function set_shipping_line( \WC_Order $order, array $line ): ?\WP_Error {
- if ( isset( $line['total'] ) && ( ! is_numeric( $line['total'] ) || (float) $line['total'] < 0 ) ) {
- return new \WP_Error(
- 'woocommerce_ability_order_shipping_line_total_invalid',
- /* translators: %s: shipping total sent by the client. */
- sprintf( __( '"%s" is not a valid shipping total. Send a decimal number that is not negative, such as 9.99.', 'woocommerce' ), $line['total'] ),
- array( 'status' => 400 )
- );
- }
-
- if ( empty( $line['id'] ) ) {
- if ( empty( $line['method_title'] ) ) {
- return new \WP_Error(
- 'woocommerce_ability_order_shipping_line_method_title_required',
- __( 'A new shipping line needs a method_title. Send method_title, or send the id of the shipping line to change.', 'woocommerce' ),
- array( 'status' => 400 )
- );
- }
-
- $item = new \WC_Order_Item_Shipping();
- } else {
- $item = $order->get_item( $line['id'], false );
-
- if ( ! $item instanceof \WC_Order_Item_Shipping ) {
- return self::line_not_found( $line['id'] );
- }
- }
-
- if ( isset( $line['method_id'] ) ) {
- $item->set_method_id( sanitize_text_field( $line['method_id'] ) );
- }
-
- if ( isset( $line['method_title'] ) ) {
- $item->set_method_title( sanitize_text_field( $line['method_title'] ) );
- }
-
- if ( isset( $line['total'] ) ) {
- $item->set_total( wc_format_decimal( $line['total'] ) );
- }
-
- if ( empty( $line['id'] ) ) {
- $order->add_item( $item );
- }
-
- return null;
- }
-
- /**
- * Add a product line in memory. save() reduces its stock. With the feature off, save() adds the line with
- * WC_Order::add_product(), so an order class that has not adopted stage_product_item() keeps working.
- *
- * @param \WC_Order $order Order.
- * @param array $line Product line input.
- * @return null|\WP_Error
- * @throws \WC_Data_Exception With the feature on, when the order class has not adopted stage_product_item().
- */
- private function add_product_line( \WC_Order $order, array $line ): ?\WP_Error {
- $product = wc_get_product( $line['product_id'] );
- $quantity = $line['quantity'] ?? 1;
-
- if ( ! $product instanceof \WC_Product ) {
- return new \WP_Error(
- 'woocommerce_ability_order_product_not_found',
- /* translators: %d: product ID. */
- sprintf( __( 'Product %d does not exist. Use the woocommerce/products-query ability to find the product ID.', 'woocommerce' ), $line['product_id'] ),
- array( 'status' => 400 )
- );
- }
-
- if ( $product->is_type( ProductType::VARIABLE ) ) {
- return new \WP_Error(
- 'woocommerce_ability_order_product_variable',
- /* translators: %d: product ID. */
- sprintf( __( 'Product %d is a variable product and cannot be added. Send the ID of one of its variations as product_id.', 'woocommerce' ), $line['product_id'] ),
- array( 'status' => 400 )
- );
- }
-
- try {
- wc_get_container()->get( ItemQuantityLimits::class )->validate_new_item_quantity( $quantity, $product );
- } catch ( \Exception $exception ) {
- return self::quantity_error( $exception );
- }
-
- if ( ! ExtensibleAbilities::is_enabled() ) {
- $this->products_to_add[] = array( $product, $quantity );
- return null;
- }
-
- $this->changed_product_lines[] = $order->stage_product_item( $product, $quantity, array( 'order' => $order ) );
+ $this->stage_line_totals( $order, $product_added );
return null;
}
@@ -586,31 +341,6 @@ class OrderUpdate extends ActionableAbility {
return null;
}
- /**
- * Build the error for a line that the order does not have.
- *
- * @param int $item_id Order item ID sent by the client.
- * @return \WP_Error
- */
- private static function line_not_found( int $item_id ): \WP_Error {
- return new \WP_Error(
- 'woocommerce_ability_order_line_not_found',
- /* translators: %d: order item ID. */
- sprintf( __( 'Item %d is not a line of this order that this ability can change. Use the woocommerce/orders-query ability with include_line_items and include_lines to read the IDs of the product and shipping lines.', 'woocommerce' ), $item_id ),
- array( 'status' => 400 )
- );
- }
-
- /**
- * Build the error for a quantity below the minimum of a product.
- *
- * @param \Exception $exception The refusal of ItemQuantityLimits, which names the product and the minimum.
- * @return \WP_Error
- */
- private static function quantity_error( \Exception $exception ): \WP_Error {
- return new \WP_Error( 'woocommerce_ability_order_line_item_quantity_invalid', $exception->getMessage(), array( 'status' => 400 ) );
- }
-
/**
* Get the ability input schema.
*
@@ -624,8 +354,8 @@ class OrderUpdate extends ActionableAbility {
'type' => 'integer',
'minimum' => 1,
),
- 'billing' => self::get_address_input_schema( array_merge( self::ADDRESS_FIELDS, array( 'email' ) ) ),
- 'shipping' => self::get_address_input_schema( self::ADDRESS_FIELDS ),
+ 'billing' => self::get_address_input_schema( array_merge( self::shipping_address_fields(), array( 'email' ) ) ),
+ 'shipping' => self::get_address_input_schema( self::shipping_address_fields() ),
'customer_note' => array( 'type' => 'string' ),
'line_items' => array(
'type' => 'array',
@@ -682,20 +412,4 @@ class OrderUpdate extends ActionableAbility {
'additionalProperties' => false,
);
}
-
- /**
- * Get the input schema of an address.
- *
- * @param array<int, string> $fields Address fields.
- * @return array
- */
- private static function get_address_input_schema( array $fields ): array {
- return array(
- 'type' => 'object',
- 'description' => __( 'Countries use ISO 3166-1 alpha-2 codes, and states use WooCommerce state codes.', 'woocommerce' ),
- 'properties' => array_fill_keys( $fields, array( 'type' => 'string' ) ),
- 'additionalProperties' => false,
- 'minProperties' => 1,
- );
- }
}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderEditTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderEditTrait.php
new file mode 100644
index 00000000000..93f5d73f004
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderEditTrait.php
@@ -0,0 +1,371 @@
+<?php
+/**
+ * Order edit trait file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain\Traits;
+
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
+use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Internal\Admin\Orders\ItemQuantityLimits;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Address and line rules of the abilities that write an order, as the order screen applies them. Every
+ * change stays in memory until the ability saves. A class that uses this trait also uses OrderAbilityTrait.
+ */
+trait OrderEditTrait {
+
+ /**
+ * Product lines that change() added or gave a new quantity, for a stock change after the save.
+ *
+ * @var array<int, \WC_Order_Item_Product>
+ */
+ private array $changed_product_lines = array();
+
+ /**
+ * Whether recalculate_saved_order() recalculates with the methods that save, and which ones.
+ * Only with the feature off, when no validator needs to see the new totals.
+ *
+ * @var string|null `coupons`, `totals` or null.
+ */
+ private ?string $recalculate_after_save = null;
+
+ /**
+ * Product lines for add_queued_products(), with the feature off. WC_Order::add_product() runs the rules of
+ * an order class that has not adopted stage_product_item(), as order-update-status does with update_status().
+ *
+ * @var array<int, array{0: \WC_Product, 1: int}>
+ */
+ private array $products_to_add = array();
+
+ /**
+ * Forget the line changes of an earlier call, since the ability object is reused.
+ */
+ private function reset_line_changes(): void {
+ $this->changed_product_lines = array();
+ $this->recalculate_after_save = null;
+ $this->products_to_add = array();
+ }
+
+ /**
+ * Recalculate the totals in memory after the lines changed. Coupons apply again after a product is
+ * added, because they were applied before the new line existed. With the feature off, the totals wait
+ * for recalculate_saved_order(), which uses the methods that save.
+ *
+ * @param \WC_Order $order Order.
+ * @param bool $product_added Whether a product line was added.
+ * @throws \WC_Data_Exception When the order class has not adopted a stage method.
+ */
+ private function stage_line_totals( \WC_Order $order, bool $product_added ): void {
+ $recalculate_coupons = $product_added && ! empty( $order->get_items( 'coupon' ) );
+
+ if ( ! ExtensibleAbilities::is_enabled() ) {
+ $this->recalculate_after_save = $recalculate_coupons ? 'coupons' : 'totals';
+ return;
+ }
+
+ if ( $recalculate_coupons ) {
+ if ( $order->get_prices_include_tax() ) {
+ $order->stage_taxes();
+ }
+
+ $order->stage_recalculated_coupons();
+
+ return;
+ }
+
+ $order->stage_totals();
+ }
+
+ /**
+ * Add the product lines that add_product_line() left for the save with the feature off.
+ * WC_Order::add_product() saves each line right away.
+ *
+ * @param \WC_Order $order Order.
+ * @return array<int, \WC_Order_Item_Product> The added lines, to delete when the order save fails.
+ */
+ private function add_queued_products( \WC_Order $order ): array {
+ $added = array();
+ foreach ( $this->products_to_add as list( $product, $quantity ) ) {
+ $item = $order->get_item( $order->add_product( $product, $quantity, array( 'order' => $order ) ), false );
+ if ( $item instanceof \WC_Order_Item_Product ) {
+ $added[] = $item;
+ }
+ }
+
+ return $added;
+ }
+
+ /**
+ * Recalculate the saved order with the methods that save, when stage_line_totals() left it for later.
+ * Each step is checked on its own, because the next save clears the error of a failed one.
+ *
+ * @param \WC_Order $order Saved order.
+ * @return null|\WP_Error
+ */
+ private function recalculate_saved_order( \WC_Order $order ): ?\WP_Error {
+ $steps = array();
+ if ( 'coupons' === $this->recalculate_after_save ) {
+ if ( $order->get_prices_include_tax() ) {
+ $steps[] = array( $order, 'calculate_taxes' );
+ }
+ $steps[] = array( $order, 'recalculate_coupons' );
+ } elseif ( 'totals' === $this->recalculate_after_save ) {
+ $steps[] = array( $order, 'calculate_totals' );
+ }
+
+ foreach ( $steps as $step ) {
+ $step();
+ $error = $order->get_last_save_error();
+ if ( null !== $error ) {
+ wc_get_logger()->error(
+ sprintf( 'Ability "%s" failed to save the recalculated order: %s', $this->get_name(), $error->getMessage() ),
+ array( 'source' => 'woocommerce-abilities' )
+ );
+ return new \WP_Error(
+ 'woocommerce_ability_save_failed',
+ __( 'The change could not be saved.', 'woocommerce' ),
+ array( 'status' => 500 )
+ );
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Check the sent fields of an address against the order, then set the fields
+ * that pass. A bad country keeps the saved country, state and postcode, because
+ * the state and postcode checks depend on the country.
+ *
+ * @param \WC_Order $order Order.
+ * @param string $type Address type: billing or shipping.
+ * @param array $address Address fields to change.
+ * @return null|\WP_Error
+ */
+ private static function set_address( \WC_Order $order, string $type, array $address ): ?\WP_Error {
+ $address = wc_clean( $address );
+ $refused = new \WP_Error();
+ if ( ! empty( $address['email'] ) && ! is_email( $address['email'] ) ) {
+ $refused->merge_from(
+ self::address_error(
+ $type,
+ 'email',
+ __( 'The billing email is not a valid email address. Send a valid address.', 'woocommerce' )
+ )
+ );
+ unset( $address['email'] );
+ }
+
+ $country = $address['country'] ?? $order->{"get_{$type}_country"}();
+ $state = $address['state'] ?? $order->{"get_{$type}_state"}();
+ $postcode = $address['postcode'] ?? $order->{"get_{$type}_postcode"}();
+
+ if ( '' !== $country && ! array_key_exists( $country, WC()->countries->get_countries() ) ) {
+ $refused->merge_from(
+ self::address_error(
+ $type,
+ 'country',
+ /* translators: %s: country sent by the client. */
+ sprintf( __( '"%s" is not a valid country. Send an ISO 3166-1 alpha-2 country code, such as PT.', 'woocommerce' ), $country )
+ )
+ );
+ unset( $address['country'], $address['state'], $address['postcode'] );
+ } else {
+ $states = WC()->countries->get_states( $country );
+
+ if ( '' !== $state && ! empty( $states ) && ! array_key_exists( $state, $states ) ) {
+ $refused->merge_from(
+ self::address_error(
+ $type,
+ 'state',
+ /* translators: 1: state sent by the client or kept from the order, 2: country code, 3: comma-separated state codes. */
+ sprintf( __( '"%1$s" is not a state of %2$s. Send the state with one of these codes: %3$s.', 'woocommerce' ), $state, $country, implode( ', ', array_keys( $states ) ) )
+ )
+ );
+ unset( $address['state'] );
+ }
+
+ if ( '' !== $postcode && '' !== $country ) {
+ if ( ! \WC_Validation::is_postcode( $postcode, $country ) ) {
+ $refused->merge_from(
+ self::address_error(
+ $type,
+ 'postcode',
+ /* translators: 1: postcode sent by the client or kept from the order, 2: country code. */
+ sprintf( __( '"%1$s" is not a valid postcode for %2$s. Send a postcode in the format of that country.', 'woocommerce' ), $postcode, $country )
+ )
+ );
+ unset( $address['postcode'] );
+ } elseif ( isset( $address['postcode'] ) ) {
+ $address['postcode'] = wc_format_postcode( $postcode, $country );
+ }
+ }
+ }
+
+ foreach ( $address as $key => $value ) {
+ $order->{"set_{$type}_{$key}"}( $value );
+ }
+
+ return $refused->has_errors() ? $refused : null;
+ }
+
+ /**
+ * Build the error for an address field.
+ *
+ * @param string $type Address type: billing or shipping.
+ * @param string $field Address field.
+ * @param string $message What is wrong and what to send.
+ * @return \WP_Error
+ */
+ private static function address_error( string $type, string $field, string $message ): \WP_Error {
+ return new \WP_Error( "woocommerce_ability_order_{$type}_{$field}_invalid", $message, array( 'status' => 400 ) );
+ }
+
+ /**
+ * Change a shipping line in memory, or add a new one when the line has no ID.
+ *
+ * @param \WC_Order $order Order.
+ * @param array $line Shipping line input.
+ * @return null|\WP_Error
+ */
+ private static function set_shipping_line( \WC_Order $order, array $line ): ?\WP_Error {
+ // wc_format_decimal() drops the exponent of a number such as 1e3, which changes its value.
+ if ( isset( $line['total'] ) && ( ! is_numeric( $line['total'] ) || false !== stripos( (string) $line['total'], 'e' ) || (float) $line['total'] < 0 ) ) {
+ return new \WP_Error(
+ 'woocommerce_ability_order_shipping_line_total_invalid',
+ /* translators: %s: shipping total sent by the client. */
+ sprintf( __( '"%s" is not a valid shipping total. Send a decimal number that is not negative, such as 9.99.', 'woocommerce' ), $line['total'] ),
+ array( 'status' => 400 )
+ );
+ }
+
+ if ( empty( $line['id'] ) ) {
+ if ( empty( $line['method_title'] ) ) {
+ return new \WP_Error(
+ 'woocommerce_ability_order_shipping_line_method_title_required',
+ __( 'A new shipping line needs a method_title. Send method_title, or send the id of the shipping line to change.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $item = new \WC_Order_Item_Shipping();
+ } else {
+ $item = $order->get_item( $line['id'], false );
+
+ if ( ! $item instanceof \WC_Order_Item_Shipping ) {
+ return self::line_not_found( $line['id'] );
+ }
+ }
+
+ if ( isset( $line['method_id'] ) ) {
+ $item->set_method_id( sanitize_text_field( $line['method_id'] ) );
+ }
+
+ if ( isset( $line['method_title'] ) ) {
+ $item->set_method_title( sanitize_text_field( $line['method_title'] ) );
+ }
+
+ if ( isset( $line['total'] ) ) {
+ $item->set_total( wc_format_decimal( $line['total'] ) );
+ }
+
+ if ( empty( $line['id'] ) ) {
+ $order->add_item( $item );
+ }
+
+ return null;
+ }
+
+ /**
+ * Add a product line in memory. With the feature off, the line waits for add_queued_products(), so an order
+ * class that has not adopted stage_product_item() keeps working.
+ *
+ * @param \WC_Order $order Order.
+ * @param array $line Product line input.
+ * @return null|\WP_Error
+ * @throws \WC_Data_Exception With the feature on, when the order class has not adopted stage_product_item().
+ */
+ private function add_product_line( \WC_Order $order, array $line ): ?\WP_Error {
+ $product = wc_get_product( $line['product_id'] );
+ $quantity = $line['quantity'] ?? 1;
+
+ if ( ! $product instanceof \WC_Product ) {
+ return new \WP_Error(
+ 'woocommerce_ability_order_product_not_found',
+ /* translators: %d: product ID. */
+ sprintf( __( 'Product %d does not exist. Use the woocommerce/products-query ability to find the product ID.', 'woocommerce' ), $line['product_id'] ),
+ array( 'status' => 400 )
+ );
+ }
+
+ if ( $product->is_type( ProductType::VARIABLE ) ) {
+ return new \WP_Error(
+ 'woocommerce_ability_order_product_variable',
+ /* translators: %d: product ID. */
+ sprintf( __( 'Product %d is a variable product and cannot be added. Send the ID of one of its variations as product_id.', 'woocommerce' ), $line['product_id'] ),
+ array( 'status' => 400 )
+ );
+ }
+
+ try {
+ wc_get_container()->get( ItemQuantityLimits::class )->validate_new_item_quantity( $quantity, $product );
+ } catch ( \Exception $exception ) {
+ return self::quantity_error( $exception );
+ }
+
+ if ( ! ExtensibleAbilities::is_enabled() ) {
+ $this->products_to_add[] = array( $product, $quantity );
+ return null;
+ }
+
+ $this->changed_product_lines[] = $order->stage_product_item( $product, $quantity, array( 'order' => $order ) );
+
+ return null;
+ }
+
+ /**
+ * Build the error for a line that the order does not have.
+ *
+ * @param int $item_id Order item ID sent by the client.
+ * @return \WP_Error
+ */
+ private static function line_not_found( int $item_id ): \WP_Error {
+ return new \WP_Error(
+ 'woocommerce_ability_order_line_not_found',
+ /* translators: %d: order item ID. */
+ sprintf( __( 'Item %d is not a line of this order that this ability can change. Use the woocommerce/orders-query ability with include_line_items and include_lines to read the IDs of the product and shipping lines.', 'woocommerce' ), $item_id ),
+ array( 'status' => 400 )
+ );
+ }
+
+ /**
+ * Build the error for a quantity below the minimum of a product.
+ *
+ * @param \Exception $exception The refusal of ItemQuantityLimits, which names the product and the minimum.
+ * @return \WP_Error
+ */
+ private static function quantity_error( \Exception $exception ): \WP_Error {
+ return new \WP_Error( 'woocommerce_ability_order_line_item_quantity_invalid', $exception->getMessage(), array( 'status' => 400 ) );
+ }
+
+ /**
+ * Get the input schema of an address.
+ *
+ * @param array<int, string> $fields Address fields.
+ * @return array
+ */
+ private static function get_address_input_schema( array $fields ): array {
+ return array(
+ 'type' => 'object',
+ 'description' => __( 'Countries use ISO 3166-1 alpha-2 codes, and states use WooCommerce state codes.', 'woocommerce' ),
+ 'properties' => array_fill_keys( $fields, array( 'type' => 'string' ) ),
+ 'additionalProperties' => false,
+ 'minProperties' => 1,
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
index c816a566a50..42a00d39ba3 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
@@ -45,6 +45,7 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
'woocommerce/product-delete',
'woocommerce/orders-query',
'woocommerce/order-send-email',
+ 'woocommerce/order-create',
'woocommerce/order-update',
'woocommerce/order-update-status',
'woocommerce/order-add-note',
@@ -344,6 +345,11 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
'idempotent' => false,
'destructive' => false,
),
+ 'woocommerce/order-create' => array(
+ 'readonly' => false,
+ 'idempotent' => false,
+ 'destructive' => false,
+ ),
'woocommerce/order-update' => array(
'readonly' => false,
'idempotent' => false,
@@ -529,9 +535,11 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
public function test_order_status_input_schemas_exclude_checkout_draft(): void {
$query_schema = wp_get_ability( 'woocommerce/orders-query' )->get_input_schema();
$update_schema = wp_get_ability( 'woocommerce/order-update-status' )->get_input_schema();
+ $create_schema = wp_get_ability( 'woocommerce/order-create' )->get_input_schema();
$this->assertNotContains( 'checkout-draft', $query_schema['properties']['status']['items']['enum'] );
$this->assertNotContains( 'checkout-draft', $update_schema['properties']['status']['enum'] ?? array() );
+ $this->assertNotContains( 'checkout-draft', $create_schema['properties']['status']['enum'] ?? array() );
}
/**
@@ -1087,6 +1095,10 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
'email' => 'invoice',
),
),
+ 'order-create' => array(
+ 'woocommerce/order-create',
+ array( 'line_items' => array( array( 'product_id' => 1 ) ) ),
+ ),
'order-update' => array(
'woocommerce/order-update',
array(
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/OrderCreateTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/OrderCreateTest.php
new file mode 100644
index 00000000000..7e6b49d91c3
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/OrderCreateTest.php
@@ -0,0 +1,431 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\Abilities\Domain\OrderCreate;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the order-create ability.
+ */
+class OrderCreateTest extends WC_Unit_Test_Case {
+
+ /**
+ * Log in as an administrator, who may create orders.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+ }
+
+ /**
+ * Feature states.
+ *
+ * @return array<string, array{0: bool}>
+ */
+ public function feature_states(): array {
+ return array(
+ 'feature on' => array( true ),
+ 'feature off' => array( false ),
+ );
+ }
+
+ /**
+ * @testdox Should create a processing order with its customer, addresses, note and lines, reduce the stock and fire the paid status hooks.
+ *
+ * @dataProvider feature_states
+ * @param bool $enabled Whether the extensible abilities feature is on.
+ */
+ public function test_creates_a_processing_order( bool $enabled ): void {
+ $this->set_feature( $enabled );
+ $customer = self::factory()->user->create( array( 'role' => 'customer' ) );
+ $simple = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'regular_price' => '10',
+ 'manage_stock' => true,
+ 'stock_quantity' => 10,
+ )
+ );
+ $variation = wc_get_product( WC_Helper_Product::create_variation_product()->get_children()[0] );
+ $paid = did_action( 'woocommerce_order_status_pending_to_processing' );
+ $edits = did_action( 'woocommerce_order_edit_status' );
+
+ $result = $this->execute(
+ array(
+ 'customer_id' => $customer,
+ 'status' => OrderStatus::PROCESSING,
+ 'billing' => array(
+ 'first_name' => 'Ana',
+ 'email' => 'ana@example.com',
+ 'country' => 'PT',
+ 'postcode' => '1000-001',
+ ),
+ 'shipping' => array(
+ 'city' => 'Lisbon',
+ 'country' => 'PT',
+ ),
+ 'customer_note' => 'Phone order.',
+ 'line_items' => array(
+ array(
+ 'product_id' => $simple->get_id(),
+ 'quantity' => 3,
+ ),
+ array( 'product_id' => $variation->get_id() ),
+ ),
+ 'shipping_lines' => array(
+ array(
+ 'method_id' => 'flat_rate',
+ 'method_title' => 'Flat rate',
+ 'total' => '5',
+ ),
+ ),
+ )
+ );
+
+ $order = wc_get_order( $result['order']['id'] );
+ $lines = array_values( $order->get_items() );
+ $this->assertSame( OrderStatus::PROCESSING, $order->get_status() );
+ $this->assertSame( OrderCreate::CREATED_VIA, $order->get_created_via() );
+ $this->assertSame( get_woocommerce_currency(), $order->get_currency() );
+ $this->assertSame( $customer, $order->get_customer_id() );
+ $this->assertSame( 'ana@example.com', $order->get_billing_email() );
+ $this->assertSame( 'Lisbon', $order->get_shipping_city() );
+ $this->assertSame( 'Phone order.', $order->get_customer_note() );
+ $this->assertCount( 2, $lines );
+ $this->assertSame( 3, $lines[0]->get_quantity() );
+ $this->assertSame( $variation->get_id(), $lines[1]->get_variation_id() );
+ $this->assertCount( 1, $order->get_shipping_methods() );
+ $this->assertSame( '45.00', $order->get_total() );
+ $this->assertEqualsCanonicalizing( array_keys( $order->get_items() ), array_column( $result['order']['line_items'], 'id' ) );
+ $this->assertSame( array_keys( $order->get_shipping_methods() ), array_column( $result['order']['shipping_lines'], 'id' ) );
+ $this->assertSame( 7, wc_get_product( $simple->get_id() )->get_stock_quantity(), 'A paid status should reduce the stock once.' );
+ $this->assertSame( $paid + 1, did_action( 'woocommerce_order_status_pending_to_processing' ), 'The order should move from pending, which sends the order emails.' );
+ $this->assertSame( $edits, did_action( 'woocommerce_order_edit_status' ), 'Creating an order is not a manual status change.' );
+ }
+
+ /**
+ * @testdox Should create a pending guest order without reducing the stock when no status or customer is sent.
+ *
+ * @dataProvider feature_states
+ * @param bool $enabled Whether the extensible abilities feature is on.
+ */
+ public function test_creates_a_pending_guest_order( bool $enabled ): void {
+ $this->set_feature( $enabled );
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 10,
+ )
+ );
+
+ $result = $this->execute(
+ array(
+ 'line_items' => array(
+ array(
+ 'product_id' => $product->get_id(),
+ 'quantity' => 2,
+ ),
+ ),
+ )
+ );
+
+ $order = wc_get_order( $result['order']['id'] );
+ $this->assertSame( OrderStatus::PENDING, $order->get_status() );
+ $this->assertSame( 0, $order->get_customer_id() );
+ $this->assertSame( '20.00', $order->get_total() );
+ $this->assertSame( 10, wc_get_product( $product->get_id() )->get_stock_quantity() );
+ }
+
+ /**
+ * @testdox Should move an order without a status from pending to the default status that an extension sets, and reduce the stock.
+ *
+ * @dataProvider feature_states
+ * @param bool $enabled Whether the extensible abilities feature is on.
+ */
+ public function test_uses_the_filtered_default_status( bool $enabled ): void {
+ $this->set_feature( $enabled );
+ add_filter( 'woocommerce_default_order_status', static fn() => OrderStatus::ON_HOLD );
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 10,
+ )
+ );
+ $held = did_action( 'woocommerce_order_status_pending_to_on-hold' );
+
+ $result = $this->execute(
+ array(
+ 'line_items' => array(
+ array(
+ 'product_id' => $product->get_id(),
+ 'quantity' => 2,
+ ),
+ ),
+ )
+ );
+
+ $this->assertSame( OrderStatus::ON_HOLD, wc_get_order( $result['order']['id'] )->get_status() );
+ $this->assertSame( $held + 1, did_action( 'woocommerce_order_status_pending_to_on-hold' ) );
+ $this->assertSame( 8, wc_get_product( $product->get_id() )->get_stock_quantity() );
+ }
+
+ /**
+ * @testdox Should refuse every bad customer, address and line in one error, and create no order.
+ *
+ * @dataProvider feature_states
+ * @param bool $enabled Whether the extensible abilities feature is on.
+ */
+ public function test_refuses_bad_input_and_creates_nothing( bool $enabled ): void {
+ $this->set_feature( $enabled );
+ $variable = WC_Helper_Product::create_variation_product();
+ $before = $this->get_order_ids();
+
+ $error = $this->execute_error(
+ array(
+ 'customer_id' => PHP_INT_MAX,
+ 'status' => OrderStatus::PROCESSING,
+ 'billing' => array( 'email' => 'not-an-email' ),
+ 'line_items' => array(
+ array( 'product_id' => PHP_INT_MAX ),
+ array( 'product_id' => $variable->get_id() ),
+ ),
+ 'shipping_lines' => array(
+ array(
+ 'method_title' => 'Express',
+ 'total' => '-1',
+ ),
+ ),
+ )
+ );
+
+ $this->assertEqualsCanonicalizing(
+ array(
+ 'woocommerce_ability_order_customer_not_found',
+ 'woocommerce_ability_order_billing_email_invalid',
+ 'woocommerce_ability_order_product_not_found',
+ 'woocommerce_ability_order_product_variable',
+ 'woocommerce_ability_order_shipping_line_total_invalid',
+ ),
+ $error->get_error_codes()
+ );
+ $this->assertEqualsCanonicalizing( $before, $this->get_order_ids() );
+ }
+
+ /**
+ * @testdox Should return the saved order, not changes that a listener made in memory after the only save, with the feature on.
+ */
+ public function test_returns_the_saved_order(): void {
+ $this->set_feature( true );
+ $product = WC_Helper_Product::create_simple_product();
+ add_action(
+ 'woocommerce_after_order_object_save',
+ static function ( \WC_Order $order ): void {
+ $order->set_billing_email( 'unsaved@example.com' );
+ }
+ );
+
+ $result = $this->execute(
+ array(
+ 'billing' => array( 'email' => 'ana@example.com' ),
+ 'line_items' => array( array( 'product_id' => $product->get_id() ) ),
+ )
+ );
+
+ $this->assertSame( 'ana@example.com', $result['order']['billing_email'] );
+ $this->assertArrayHasKey( 'date_modified', $result['order'] );
+ $this->assertCount( 1, $result['order']['line_items'] );
+ }
+
+ /**
+ * @testdox Should roll back the saved order when its status cannot be set with the feature off.
+ */
+ public function test_rolls_back_the_order_when_the_status_change_fails(): void {
+ $this->set_feature( false );
+ $before = $this->get_order_ids();
+ $product = WC_Helper_Product::create_simple_product();
+ add_action(
+ 'woocommerce_before_order_object_save',
+ static function ( \WC_Order $order ): void {
+ if ( $order->get_id() && OrderStatus::PROCESSING === $order->get_status() ) {
+ throw new \Exception( 'Forced status save failure' );
+ }
+ }
+ );
+
+ $error = $this->execute_error(
+ array(
+ 'status' => OrderStatus::PROCESSING,
+ 'line_items' => array( array( 'product_id' => $product->get_id() ) ),
+ )
+ );
+
+ $this->assertSame( 'woocommerce_order_status_update_failed', $error->get_error_code() );
+ $this->assertEqualsCanonicalizing( $before, $this->get_order_ids() );
+ }
+
+ /**
+ * @testdox Should accept a customer from another site of a multisite network only for a user who can manage network users.
+ *
+ * @testWith [false]
+ * [true]
+ *
+ * @param bool $network_admin Whether the current user can manage network users.
+ */
+ public function test_accepts_a_customer_from_another_site_only_for_a_network_admin( bool $network_admin ): void {
+ $customer = self::factory()->user->create( array( 'role' => 'customer' ) );
+ $product = WC_Helper_Product::create_simple_product();
+ $this->register_legacy_proxy_function_mocks(
+ array(
+ 'is_multisite' => static fn() => true,
+ 'is_user_member_of_blog' => static fn() => false,
+ 'user_can' => static fn() => $network_admin,
+ )
+ );
+
+ $result = wp_get_ability( OrderCreate::ABILITY_NAME )->execute(
+ array(
+ 'customer_id' => $customer,
+ 'line_items' => array( array( 'product_id' => $product->get_id() ) ),
+ )
+ );
+
+ if ( $network_admin ) {
+ $this->assertIsArray( $result );
+ $this->assertSame( $customer, wc_get_order( $result['order']['id'] )->get_customer_id() );
+ } else {
+ $this->assertWPError( $result );
+ $this->assertSame( array( 'woocommerce_ability_order_customer_not_found' ), $result->get_error_codes() );
+ }
+ }
+
+ /**
+ * @testdox Should refuse a shipping total in exponent notation, which formatting would save as another amount.
+ */
+ public function test_refuses_a_shipping_total_with_an_exponent(): void {
+ $product = WC_Helper_Product::create_simple_product();
+
+ $error = $this->execute_error(
+ array(
+ 'line_items' => array( array( 'product_id' => $product->get_id() ) ),
+ 'shipping_lines' => array(
+ array(
+ 'method_title' => 'Express',
+ 'total' => '1e3',
+ ),
+ ),
+ )
+ );
+
+ $this->assertSame( array( 'woocommerce_ability_order_shipping_line_total_invalid' ), $error->get_error_codes() );
+ }
+
+ /**
+ * @testdox Should refuse and roll back a pending order whose recalculated totals cannot be saved, with the feature off.
+ */
+ public function test_refuses_the_order_when_the_recalculation_does_not_save(): void {
+ $this->set_feature( false );
+ $before = $this->get_order_ids();
+ $product = WC_Helper_Product::create_simple_product();
+ $saves = 0;
+ add_action(
+ 'woocommerce_before_order_object_save',
+ static function () use ( &$saves ): void {
+ if ( ++$saves > 1 ) {
+ throw new \Exception( 'Forced recalculation save failure' );
+ }
+ }
+ );
+
+ $error = $this->execute_error(
+ array( 'line_items' => array( array( 'product_id' => $product->get_id() ) ) )
+ );
+
+ $this->assertSame( 'woocommerce_ability_save_failed', $error->get_error_code() );
+ $this->assertEqualsCanonicalizing( $before, $this->get_order_ids() );
+ }
+
+ /**
+ * @testdox Should refuse an input without a line item, a shipping line without a method title, and a line item without a product.
+ *
+ * @testWith [{}]
+ * [{"customer_note": "Call back."}]
+ * [{"line_items": [{"product_id": 1}], "shipping_lines": [{"total": "3"}]}]
+ * [{"line_items": [{"quantity": 2}]}]
+ *
+ * @param array $input Ability input.
+ */
+ public function test_schema_refuses_missing_lines( array $input ): void {
+ $schema = wp_get_ability( OrderCreate::ABILITY_NAME )->get_input_schema();
+
+ $this->assertWPError( rest_validate_value_from_schema( $input, $schema, 'input' ) );
+ }
+
+ /**
+ * @testdox Should refuse a user who cannot create orders.
+ */
+ public function test_refuses_user_without_create_capability(): void {
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'customer' ) ) );
+
+ $this->assertFalse( OrderCreate::can_create_order() );
+ }
+
+ /**
+ * Run order-create and check it succeeded.
+ *
+ * @param array $input Ability input.
+ * @return array
+ */
+ private function execute( array $input ): array {
+ $result = wp_get_ability( OrderCreate::ABILITY_NAME )->execute( $input );
+
+ $this->assertIsArray( $result, is_wp_error( $result ) ? $result->get_error_message() : '' );
+
+ return $result;
+ }
+
+ /**
+ * Run order-create and check it was refused.
+ *
+ * @param array $input Ability input.
+ * @return \WP_Error
+ */
+ private function execute_error( array $input ): \WP_Error {
+ $result = wp_get_ability( OrderCreate::ABILITY_NAME )->execute( $input );
+
+ $this->assertWPError( $result );
+
+ return $result;
+ }
+
+ /**
+ * IDs of every order, of any status.
+ *
+ * @return int[]
+ */
+ private function get_order_ids(): array {
+ return wc_get_orders(
+ array(
+ 'limit' => -1,
+ 'return' => 'ids',
+ 'status' => array_keys( wc_get_order_statuses() ),
+ )
+ );
+ }
+
+ /**
+ * Turn the extensible abilities feature on or off. The test transaction rolls the option back.
+ *
+ * @param bool $enabled Whether the feature is on.
+ */
+ private function set_feature( bool $enabled ): void {
+ update_option( 'woocommerce_feature_' . ExtensibleAbilities::FEATURE_ID . '_enabled', $enabled ? 'yes' : 'no' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/OrderUpdateTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/OrderUpdateTest.php
new file mode 100644
index 00000000000..b1f98cb9c2f
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/OrderUpdateTest.php
@@ -0,0 +1,398 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
+use Automattic\WooCommerce\Enums\OrderStatus;
+use WC_Helper_Order;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the address, shipping line and product line rules of the order-update ability.
+ */
+class OrderUpdateTest extends WC_Unit_Test_Case {
+
+ /**
+ * Log in as an administrator, who may edit orders.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+ }
+
+ /**
+ * Feature states.
+ *
+ * @return array<string, array{0: bool}>
+ */
+ public function feature_states(): array {
+ return array(
+ 'feature on' => array( true ),
+ 'feature off' => array( false ),
+ );
+ }
+
+ /**
+ * @testdox Should set only the sent address fields and format the postcode for its country.
+ */
+ public function test_sets_only_sent_address_fields(): void {
+ $order = WC_Helper_Order::create_order();
+
+ $this->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'billing' => array(
+ 'first_name' => 'Ana',
+ 'email' => 'ana@example.com',
+ ),
+ 'shipping' => array(
+ 'city' => 'London',
+ 'country' => 'GB',
+ 'postcode' => 'sw1a1aa',
+ ),
+ )
+ );
+
+ $order = wc_get_order( $order->get_id() );
+ $this->assertSame( 'Ana', $order->get_billing_first_name() );
+ $this->assertSame( 'Sormani', $order->get_billing_last_name(), 'A field that is not sent should keep its value.' );
+ $this->assertSame( 'ana@example.com', $order->get_billing_email() );
+ $this->assertSame( 'London', $order->get_shipping_city() );
+ $this->assertSame( 'GB', $order->get_shipping_country() );
+ $this->assertSame( 'SW1A 1AA', $order->get_shipping_postcode() );
+ }
+
+ /**
+ * @testdox Should refuse every bad address field in one error, check the state against the saved country, and save nothing.
+ */
+ public function test_refuses_every_bad_address_field_and_saves_nothing(): void {
+ $order = WC_Helper_Order::create_order();
+
+ $error = $this->execute_error(
+ array(
+ 'id' => $order->get_id(),
+ 'billing' => array(
+ 'email' => 'not-an-email',
+ 'state' => 'ZZ',
+ 'postcode' => 'ABCDE',
+ ),
+ 'shipping' => array(
+ 'country' => 'XX',
+ 'postcode' => 'not checked',
+ ),
+ 'customer_note' => 'Leave at the door.',
+ )
+ );
+
+ $this->assertEqualsCanonicalizing(
+ array(
+ 'woocommerce_ability_order_billing_email_invalid',
+ 'woocommerce_ability_order_billing_state_invalid',
+ 'woocommerce_ability_order_billing_postcode_invalid',
+ 'woocommerce_ability_order_shipping_country_invalid',
+ ),
+ $error->get_error_codes(),
+ 'A bad country should skip the postcode check, which depends on the country.'
+ );
+
+ $order = wc_get_order( $order->get_id() );
+ $this->assertSame( 'admin@example.org', $order->get_billing_email() );
+ $this->assertSame( 'NY', $order->get_billing_state() );
+ $this->assertSame( '', $order->get_customer_note(), 'A refused address should stop the other fields from saving.' );
+ }
+
+ /**
+ * @testdox Should strip tags from the customer note.
+ */
+ public function test_sanitizes_customer_note(): void {
+ $order = WC_Helper_Order::create_order();
+
+ $this->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'customer_note' => '<b>Leave</b> at the door.',
+ )
+ );
+
+ $this->assertSame( 'Leave at the door.', wc_get_order( $order->get_id() )->get_customer_note() );
+ }
+
+ /**
+ * @testdox Should change a shipping line, add one, and recalculate the totals.
+ *
+ * @dataProvider feature_states
+ * @param bool $enabled Whether the extensible abilities feature is on.
+ */
+ public function test_changes_and_adds_shipping_lines( bool $enabled ): void {
+ $this->set_feature( $enabled );
+ $order = WC_Helper_Order::create_order();
+ $shipping_id = array_key_first( $order->get_shipping_methods() );
+
+ $this->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'shipping_lines' => array(
+ array(
+ 'id' => $shipping_id,
+ 'total' => '5',
+ ),
+ array(
+ 'method_id' => 'local_pickup',
+ 'method_title' => 'Pickup',
+ 'total' => '0',
+ ),
+ ),
+ )
+ );
+
+ $order = wc_get_order( $order->get_id() );
+ $lines = array_values( $order->get_shipping_methods() );
+ $this->assertCount( 2, $lines );
+ $this->assertSame( '5', $lines[0]->get_total() );
+ $this->assertSame( 'Flat rate shipping', $lines[0]->get_method_title(), 'A field that is not sent should keep its value.' );
+ $this->assertSame( 'local_pickup', $lines[1]->get_method_id() );
+ $this->assertSame( 'Pickup', $lines[1]->get_method_title() );
+ $this->assertSame( '45.00', $order->get_total(), 'Four products at 10 plus 5 shipping.' );
+ }
+
+ /**
+ * @testdox Should refuse every bad shipping line in one error and save nothing.
+ */
+ public function test_refuses_bad_shipping_lines(): void {
+ $order = WC_Helper_Order::create_order();
+
+ $error = $this->execute_error(
+ array(
+ 'id' => $order->get_id(),
+ 'shipping_lines' => array(
+ array( 'total' => '3' ),
+ array(
+ 'method_title' => 'Express',
+ 'total' => '-1',
+ ),
+ array(
+ 'id' => PHP_INT_MAX,
+ 'total' => '3',
+ ),
+ ),
+ )
+ );
+
+ $this->assertEqualsCanonicalizing(
+ array(
+ 'woocommerce_ability_order_shipping_line_method_title_required',
+ 'woocommerce_ability_order_shipping_line_total_invalid',
+ 'woocommerce_ability_order_line_not_found',
+ ),
+ $error->get_error_codes()
+ );
+ $this->assertCount( 1, wc_get_order( $order->get_id() )->get_shipping_methods() );
+ }
+
+ /**
+ * @testdox Should accept a shipping total that has no digit before or after the decimal point.
+ *
+ * @testWith [".5", "0.50"]
+ * ["5.", "5.00"]
+ *
+ * @param string $total Shipping total sent.
+ * @param string $expected Shipping total saved.
+ */
+ public function test_accepts_a_shipping_total_without_leading_or_trailing_digits( string $total, string $expected ): void {
+ $order = WC_Helper_Order::create_order();
+ $line = current( $order->get_shipping_methods() );
+
+ $this->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'shipping_lines' => array(
+ array(
+ 'id' => $line->get_id(),
+ 'total' => $total,
+ ),
+ ),
+ )
+ );
+
+ $this->assertSame( $expected, wc_format_decimal( current( wc_get_order( $order->get_id() )->get_shipping_methods() )->get_total(), 2 ) );
+ }
+
+ /**
+ * @testdox Should add a simple product and a variation, with one as the default quantity, and recalculate the totals.
+ *
+ * @dataProvider feature_states
+ * @param bool $enabled Whether the extensible abilities feature is on.
+ */
+ public function test_adds_product_lines( bool $enabled ): void {
+ $this->set_feature( $enabled );
+ $order = WC_Helper_Order::create_order();
+ $simple = WC_Helper_Product::create_simple_product( true, array( 'regular_price' => '7' ) );
+ $variation = wc_get_product( WC_Helper_Product::create_variation_product()->get_children()[0] );
+
+ $result = $this->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'line_items' => array(
+ array(
+ 'product_id' => $simple->get_id(),
+ 'quantity' => 2,
+ ),
+ array( 'product_id' => $variation->get_id() ),
+ ),
+ )
+ );
+
+ $order = wc_get_order( $order->get_id() );
+ $added = array_values( array_slice( $order->get_items(), 1 ) );
+ $this->assertCount( 2, $added );
+ $this->assertSame( $simple->get_id(), $added[0]->get_product_id() );
+ $this->assertSame( 2, $added[0]->get_quantity() );
+ $this->assertSame( $variation->get_id(), $added[1]->get_variation_id() );
+ $this->assertSame( 1, $added[1]->get_quantity() );
+ $this->assertEqualsCanonicalizing( array_keys( $order->get_items() ), array_column( $result['order']['line_items'], 'id' ), 'The output should carry the IDs of the added lines.' );
+ $this->assertSame( '74.00', $order->get_total() );
+ }
+
+ /**
+ * @testdox Should refuse an added line whose recalculated totals cannot be saved, with the feature off.
+ */
+ public function test_refuses_a_line_when_the_recalculation_does_not_save(): void {
+ $this->set_feature( false );
+ $order = WC_Helper_Order::create_order();
+ $product = WC_Helper_Product::create_simple_product();
+ $saves = 0;
+ add_action(
+ 'woocommerce_before_order_object_save',
+ static function () use ( &$saves ): void {
+ if ( ++$saves > 1 ) {
+ throw new \Exception( 'Forced recalculation save failure' );
+ }
+ }
+ );
+
+ $error = $this->execute_error(
+ array(
+ 'id' => $order->get_id(),
+ 'line_items' => array( array( 'product_id' => $product->get_id() ) ),
+ )
+ );
+
+ $this->assertSame( 'woocommerce_ability_save_failed', $error->get_error_code() );
+ $this->assertCount( 1, wc_get_order( $order->get_id() )->get_items() );
+ }
+
+ /**
+ * @testdox Should refuse every bad product line in one error and save nothing.
+ */
+ public function test_refuses_bad_product_lines(): void {
+ $order = WC_Helper_Order::create_order();
+ $variable = WC_Helper_Product::create_variation_product();
+ $simple = WC_Helper_Product::create_simple_product();
+ add_filter( 'woocommerce_quantity_input_min_admin', static fn() => 5 );
+
+ $error = $this->execute_error(
+ array(
+ 'id' => $order->get_id(),
+ 'line_items' => array(
+ array( 'product_id' => PHP_INT_MAX ),
+ array( 'product_id' => $variable->get_id() ),
+ array(
+ 'product_id' => $simple->get_id(),
+ 'quantity' => 2,
+ ),
+ array(
+ 'id' => array_key_first( $order->get_items() ),
+ 'product_id' => $simple->get_id(),
+ ),
+ ),
+ )
+ );
+
+ $this->assertEqualsCanonicalizing(
+ array(
+ 'woocommerce_ability_order_product_not_found',
+ 'woocommerce_ability_order_product_variable',
+ 'woocommerce_ability_order_line_item_quantity_invalid',
+ 'woocommerce_ability_order_line_item_target_invalid',
+ ),
+ $error->get_error_codes()
+ );
+ $this->assertCount( 1, wc_get_order( $order->get_id() )->get_items() );
+ }
+
+ /**
+ * @testdox Should reduce the stock of an added product on an on-hold order and note the change.
+ *
+ * @dataProvider feature_states
+ * @param bool $enabled Whether the extensible abilities feature is on.
+ */
+ public function test_added_line_reduces_stock_on_on_hold_order( bool $enabled ): void {
+ $this->set_feature( $enabled );
+ $order = WC_Helper_Order::create_order();
+ $order->set_status( OrderStatus::ON_HOLD );
+ $order->save();
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 10,
+ )
+ );
+
+ $this->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'line_items' => array(
+ array(
+ 'product_id' => $product->get_id(),
+ 'quantity' => 3,
+ ),
+ ),
+ )
+ );
+
+ $this->assertSame( 7, wc_get_product( $product->get_id() )->get_stock_quantity() );
+ $notes = wp_list_pluck( wc_get_order_notes( array( 'order_id' => $order->get_id() ) ), 'content' );
+ $this->assertContains( 'Adjusted stock: ' . $product->get_name() . ' (10→7)', $notes );
+ }
+
+ /**
+ * Run order-update and check it succeeded.
+ *
+ * @param array $input Ability input.
+ * @return array
+ */
+ private function execute( array $input ): array {
+ $result = wp_get_ability( 'woocommerce/order-update' )->execute( $input );
+
+ $this->assertIsArray( $result, is_wp_error( $result ) ? $result->get_error_message() : '' );
+
+ return $result;
+ }
+
+ /**
+ * Run order-update and check it was refused.
+ *
+ * @param array $input Ability input.
+ * @return \WP_Error
+ */
+ private function execute_error( array $input ): \WP_Error {
+ $result = wp_get_ability( 'woocommerce/order-update' )->execute( $input );
+
+ $this->assertWPError( $result );
+
+ return $result;
+ }
+
+ /**
+ * Turn the extensible abilities feature on or off. The test transaction rolls the option back.
+ *
+ * @param bool $enabled Whether the feature is on.
+ */
+ private function set_feature( bool $enabled ): void {
+ update_option( 'woocommerce_feature_' . ExtensibleAbilities::FEATURE_ID . '_enabled', $enabled ? 'yes' : 'no' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
index 09e807c6725..3b512e45a2f 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
@@ -4461,6 +4461,100 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
$this->assertSame( 'high', $saved->get_meta( '_test_priority' ) );
}
+ /**
+ * @testdox Should create an order with an extension field in one save, show validators the sent status, and create nothing when a validator refuses.
+ */
+ public function test_order_create_writes_extension_fields_and_runs_validators(): void {
+ $this->add_test_action(
+ 'woocommerce_extensible_abilities_init',
+ static function () {
+ ExtensibleAbilities::register_field(
+ array(
+ 'resource' => 'order',
+ 'namespace' => 'test-ext',
+ 'field' => 'priority',
+ 'output_schema' => array( 'type' => 'string' ),
+ 'input_schema' => array( 'type' => 'string' ),
+ 'get_callback' => static function ( \WC_Order $order ) {
+ return $order->get_meta( '_test_priority' );
+ },
+ 'set_callback' => static function ( \WC_Order $order, $value ) {
+ $order->update_meta_data( '_test_priority', $value );
+ },
+ )
+ );
+ }
+ );
+ $seen = array();
+ $this->register_validator(
+ 'order',
+ 'test-ext',
+ static function ( \WC_Order $order ) use ( &$seen ) {
+ $seen[] = array( $order->get_status(), count( $order->get_items() ), $order->get_total() );
+ return 'Closed' === $order->get_shipping_city() ? new \WP_Error( 'test_refused', 'Refused.' ) : null;
+ }
+ );
+ $this->reset_registries();
+ AbilitiesLoader::init_extensions();
+ $product = \WC_Helper_Product::create_simple_product( true, array( 'regular_price' => '10' ) );
+ $writes = array(
+ 'woocommerce_new_order' => 0,
+ 'woocommerce_update_order' => 0,
+ );
+ foreach ( array_keys( $writes ) as $hook ) {
+ $this->add_test_action(
+ $hook,
+ static function () use ( &$writes, $hook ) {
+ ++$writes[ $hook ];
+ }
+ );
+ }
+ $before = wc_get_orders(
+ array(
+ 'limit' => -1,
+ 'return' => 'ids',
+ )
+ );
+
+ $output = $this->run_ability(
+ 'woocommerce/order-create',
+ array(
+ 'status' => 'processing',
+ 'shipping' => array( 'city' => 'Lisbon' ),
+ 'line_items' => array( array( 'product_id' => $product->get_id() ) ),
+ 'extensions' => array( 'test-ext' => array( 'priority' => 'high' ) ),
+ )
+ );
+ $refused = $this->run_ability_response(
+ 'woocommerce/order-create',
+ array(
+ 'shipping' => array( 'city' => 'Closed' ),
+ 'line_items' => array( array( 'product_id' => $product->get_id() ) ),
+ 'extensions' => array( 'test-ext' => array( 'priority' => 'low' ) ),
+ )
+ );
+
+ wp_cache_flush();
+ $saved = wc_get_order( $output['order']['id'] );
+ $this->assertSame( 1, $writes['woocommerce_new_order'] );
+ $this->assertSame( 0, $writes['woocommerce_update_order'], 'The order should save once, with its lines, totals and status.' );
+ $this->assertSame( array( 'processing', 1, '10.00' ), $seen[0], 'The validator should see the status and totals that the save writes.' );
+ $this->assertSame( 'high', $output['order']['extensions']['test-ext']['priority'] );
+ $this->assertSame( 'high', $saved->get_meta( '_test_priority' ) );
+ $this->assertSame( 400, $refused->get_status() );
+ $this->assertSame( 'test-ext:test_refused', $refused->get_data()['code'] );
+ $this->assertEqualsCanonicalizing(
+ array_merge( $before, array( $saved->get_id() ) ),
+ wc_get_orders(
+ array(
+ 'limit' => -1,
+ 'return' => 'ids',
+ )
+ ),
+ 'The refused call should create no order.'
+ );
+ }
+
/**
* @testdox Should show an order validator the saved address fields that Core refuses, and save nothing.
*/