Commit bfe06bdcd4e for woocommerce
commit bfe06bdcd4ecd487b01073d5f5f32786e0d54813
Author: Luis Felipe Zaguini <26530524+zaguiini@users.noreply.github.com>
Date: Tue Nov 3 17:25:30 2026 -0300
Add the order-update and order-send-email abilities (#70481)
* Add the order-update ability
Add woocommerce/order-update, an ActionableAbility that changes the shipping address of an order, so extension fields and validators run in the same save. Return the shipping address in order responses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Complete order-update and add order-send-email
Order-update now writes billing and shipping addresses, the customer note, product lines and shipping lines. Line changes stay in memory until save(), and totals use the stage_*() twins, so a validator refusal saves nothing. Order-send-email sends the invoice, processing or completed email to the customer again.
Errors follow woocommerce_ability_<resource>_<reason> and name the fix. Addresses leave the order output until Core can strip customer data. WC_Discounts keeps unsaved order items apart, so stage_recalculated_coupons() works on new lines.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Return the shipping address in order output
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Recalculate with the saving methods when the feature is off
With the feature off no validator needs the new totals before the save, so listeners of woocommerce_order_after_calculate_totals keep the behavior they have today.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Expect false for the staged argument of a direct totals call
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Remove checks that the input schema and the central catch already cover
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Pass the shipping line total to set_total() as a decimal string
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Reduce the stock of product lines that order-update adds
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Keep only the schema fields in the shipping address output
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Ignore the useless override sniff on the test order class
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Expect the namespaced refusal code in the order-update tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Add product lines with add_product() in order-update when the feature is off
With the feature off, order-update called stage_product_item(), so it
refused an order class that overrides add_product() without the twin,
such as WC_Subscription. It now adds the line with add_product() from
save(), as order-update-status falls back to update_status(). A refusal
from change() still saves nothing, because the line is added only in
save().
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Delete the lines that add_product() wrote when the order save fails
With the feature off, add_product() writes each new line before the
order save. When that save fails, order-update now deletes those lines,
so the call leaves the order as it was.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Align the ability annotation arrays in the loader test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Use the read and write schema keys in the order-update field test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Add a stock note when order-update changes product stock
After a successful save, order-update adds one order note that lists
each stock change, in the same format and note group as the order
screen. A merchant can then see the stock change that an ability made.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Return the shipping line total as a string like the other order amounts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Keep the trunk error codes of the shared order lookup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Keep the trunk messages of the shared order lookup
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/wooai-1544-order-update-ability b/plugins/woocommerce/changelog/wooai-1544-order-update-ability
new file mode 100644
index 00000000000..f60e574235f
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooai-1544-order-update-ability
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add the woocommerce/order-update ability for addresses, customer note, product lines and shipping lines, and the woocommerce/order-send-email ability to send an order email to the customer again.
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
index b1059338835..111105b9120 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
@@ -12,6 +12,8 @@ use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
use Automattic\WooCommerce\Abilities\ActionableAbility;
use Automattic\WooCommerce\Internal\Abilities\Domain\OrderAddNote;
use Automattic\WooCommerce\Internal\Abilities\Domain\OrderNotesQuery;
+use Automattic\WooCommerce\Internal\Abilities\Domain\OrderSendEmail;
+use Automattic\WooCommerce\Internal\Abilities\Domain\OrderUpdate;
use Automattic\WooCommerce\Internal\Abilities\Domain\OrderUpdateStatus;
use Automattic\WooCommerce\Internal\Abilities\Domain\OrdersQuery;
use Automattic\WooCommerce\Internal\Abilities\Domain\PaymentGatewaysQuery;
@@ -44,6 +46,8 @@ class AbilitiesLoader {
OrdersQuery::class,
OrderAddNote::class,
OrderNotesQuery::class,
+ OrderSendEmail::class,
+ OrderUpdate::class,
OrderUpdateStatus::class,
PaymentGatewaysQuery::class,
ProductsQuery::class,
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderSendEmail.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderSendEmail.php
new file mode 100644
index 00000000000..6e8ddbadda3
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderSendEmail.php
@@ -0,0 +1,237 @@
+<?php
+/**
+ * Order send email ability definition file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\OrderAbilityTrait;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Registers the WooCommerce order send email ability.
+ */
+class OrderSendEmail extends AbstractDomainAbility implements AbilityDefinition {
+
+ use OrderAbilityTrait;
+
+ /**
+ * Email class for each email that the ability can send.
+ */
+ private const EMAIL_CLASSES = array(
+ 'invoice' => 'WC_Email_Customer_Invoice',
+ 'processing' => 'WC_Email_Customer_Processing_Order',
+ 'completed' => 'WC_Email_Customer_Completed_Order',
+ );
+
+ /**
+ * Order status that each status email needs.
+ */
+ private const REQUIRED_STATUSES = array(
+ 'processing' => OrderStatus::PROCESSING,
+ 'completed' => OrderStatus::COMPLETED,
+ );
+
+ /**
+ * Get the ability name.
+ *
+ * @return string
+ *
+ * @since 11.4.0
+ */
+ public static function get_name(): string {
+ return 'woocommerce/order-send-email';
+ }
+
+ /**
+ * Get the ability registration arguments.
+ *
+ * @return array
+ *
+ * @since 11.4.0
+ */
+ public static function get_registration_args(): array {
+ return array(
+ 'label' => __( 'Send order email', 'woocommerce' ),
+ 'description' => __(
+ 'Send an order email to the customer again.',
+ 'woocommerce'
+ ),
+ 'category' => 'woocommerce',
+ 'input_schema' => self::get_input_schema(),
+ 'output_schema' => self::get_output_schema(),
+ 'execute_callback' => array( __CLASS__, 'execute' ),
+ 'permission_callback' => array( __CLASS__, 'can_edit_order' ),
+ 'meta' => array(
+ 'show_in_rest' => true,
+ 'mcp' => array(
+ 'public' => true,
+ 'type' => 'tool',
+ ),
+ 'annotations' => array(
+ 'readonly' => false,
+ 'idempotent' => false,
+ 'destructive' => false,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Send the order email. EmailLogger notes the email on the order.
+ *
+ * @param array $input Ability input.
+ * @return array|\WP_Error
+ *
+ * @since 11.4.0
+ */
+ public static function execute( array $input ) {
+ $order = self::get_order_from_input( $input );
+
+ if ( is_wp_error( $order ) ) {
+ return $order;
+ }
+
+ $email_name = $input['email'];
+ $refusal = self::get_status_refusal( $order, $email_name );
+
+ if ( is_wp_error( $refusal ) ) {
+ return $refusal;
+ }
+
+ if ( ! is_email( $order->get_billing_email() ) ) {
+ return new \WP_Error(
+ 'woocommerce_ability_order_email_no_recipient',
+ __( 'The order has no valid billing email. Set billing.email with the woocommerce/order-update ability, then send the email again.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ WC()->payment_gateways();
+ WC()->shipping();
+
+ /**
+ * The email to send, or null when a filter removed its class.
+ *
+ * @var \WC_Email_Customer_Invoice|\WC_Email_Customer_Processing_Order|\WC_Email_Customer_Completed_Order|null $email
+ */
+ $email = WC()->mailer()->get_emails()[ self::EMAIL_CLASSES[ $email_name ] ] ?? null;
+
+ if ( ! $email instanceof \WC_Email || ( ! $email->is_manual() && ! $email->is_enabled() ) ) {
+ return new \WP_Error(
+ 'woocommerce_ability_order_email_disabled',
+ /* translators: %s: email name. */
+ sprintf( __( 'The %s email is turned off on this store. Ask the store owner to turn it on in WooCommerce > Settings > Emails.', 'woocommerce' ), $email_name ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $sent = false;
+ $capture = static function ( $result, $email_id ) use ( &$sent, $email ): void {
+ if ( $email_id === $email->id ) {
+ $sent = (bool) $result;
+ }
+ };
+
+ // phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
+ /** This action is documented in includes/admin/meta-boxes/class-wc-meta-box-order-actions.php */
+ do_action( 'woocommerce_before_resend_order_emails', $order, $email->id );
+
+ add_action( 'woocommerce_email_sent', $capture, 10, 2 );
+ $email->trigger( $order->get_id(), $order );
+ remove_action( 'woocommerce_email_sent', $capture, 10 );
+
+ if ( ! $sent ) {
+ return new \WP_Error(
+ 'woocommerce_ability_order_email_send_failed',
+ /* translators: %s: email name. */
+ sprintf( __( 'The site could not send the %s email. Ask the store owner to check the mail setup of the site.', 'woocommerce' ), $email_name ),
+ array( 'status' => 500 )
+ );
+ }
+
+ /** This action is documented in includes/admin/meta-boxes/class-wc-meta-box-order-actions.php */
+ do_action( 'woocommerce_after_resend_order_email', $order, $email->id );
+ // phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment
+
+ return array(
+ 'order_id' => $order->get_id(),
+ 'email' => $email_name,
+ );
+ }
+
+ /**
+ * Refuse a status email when the order does not have that status. The email
+ * would tell the customer something that is not true.
+ *
+ * @param \WC_Order $order Order.
+ * @param string $email_name Email name from the input.
+ * @return null|\WP_Error
+ */
+ private static function get_status_refusal( \WC_Order $order, string $email_name ): ?\WP_Error {
+ $required_status = self::REQUIRED_STATUSES[ $email_name ] ?? null;
+
+ if ( null === $required_status || $order->has_status( $required_status ) ) {
+ return null;
+ }
+
+ return new \WP_Error(
+ 'woocommerce_ability_order_email_status_mismatch',
+ sprintf(
+ /* translators: 1: email name, 2: order status slug. */
+ __( 'The %1$s email only applies to an order with that status, and this order has the "%2$s" status. Send the invoice email, or change the status with the woocommerce/order-update-status ability first.', 'woocommerce' ),
+ $email_name,
+ $order->get_status()
+ ),
+ array( 'status' => 400 )
+ );
+ }
+
+ /**
+ * Get the ability input schema.
+ *
+ * @return array
+ */
+ private static function get_input_schema(): array {
+ return array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array(
+ 'type' => 'integer',
+ 'minimum' => 1,
+ ),
+ 'email' => array(
+ 'type' => 'string',
+ 'description' => __( 'A status email needs the order to have that status.', 'woocommerce' ),
+ 'enum' => array_keys( self::EMAIL_CLASSES ),
+ ),
+ ),
+ 'required' => array( 'id', 'email' ),
+ 'additionalProperties' => false,
+ );
+ }
+
+ /**
+ * Get the ability output schema.
+ *
+ * @return array
+ */
+ private static function get_output_schema(): array {
+ return array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'order_id' => array( 'type' => 'integer' ),
+ 'email' => array(
+ 'type' => 'string',
+ 'enum' => array_keys( self::EMAIL_CLASSES ),
+ ),
+ ),
+ 'additionalProperties' => false,
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdate.php
new file mode 100644
index 00000000000..c8e4db293aa
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdate.php
@@ -0,0 +1,665 @@
+<?php
+/**
+ * Order update 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\Enums\ProductType;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\DomainAbilityHelpers;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\OrderAbilityTrait;
+use Automattic\WooCommerce\Internal\Admin\Orders\ItemQuantityLimits;
+use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * The WooCommerce order update ability. It runs the steps of ActionableAbility.
+ * Every change stays in memory until save(), so a refusal from a later change
+ * or from a validator saves nothing.
+ */
+class OrderUpdate extends ActionableAbility {
+
+ use DomainAbilityHelpers;
+ use OrderAbilityTrait;
+
+ /**
+ * 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().
+ *
+ * @var array<int, \WC_Order_Item_Product>
+ */
+ 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.
+ *
+ * @return array
+ *
+ * @since 11.4.0
+ */
+ public static function get_registration_args(): array {
+ return array(
+ 'label' => __( 'Update order', 'woocommerce' ),
+ 'description' => __(
+ 'Update the addresses, customer note, product lines and shipping lines of an order. Fields that are not sent keep their value. Changing lines recalculates the totals and needs an order status that allows editing. Use the woocommerce/order-update-status ability to change the status.',
+ '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_edit_order' ),
+ 'meta' => array(
+ 'show_in_rest' => true,
+ 'mcp' => array(
+ 'public' => true,
+ 'type' => 'tool',
+ ),
+ 'annotations' => array(
+ 'readonly' => false,
+ 'idempotent' => false,
+ 'destructive' => true,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Resource that the ability changes.
+ *
+ * @return string
+ */
+ public function get_resource(): string {
+ return 'order';
+ }
+
+ /**
+ * Load the order to change.
+ *
+ * @param array $input Ability input.
+ * @return \WC_Order|\WP_Error
+ */
+ public function load( array $input ) {
+ $order = self::get_order_from_input( $input );
+
+ if ( is_wp_error( $order ) ) {
+ return $order;
+ }
+
+ if ( empty( array_diff( array_keys( $input ), array( 'id' ) ) ) ) {
+ return new \WP_Error(
+ 'woocommerce_ability_order_no_fields',
+ __( 'No change was sent. Send at least one order field to change, in addition to the id.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ return $order;
+ }
+
+ /**
+ * Change the order in memory.
+ *
+ * @param \WC_Order $subject 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->removed_product_lines = array();
+ $this->changed_product_lines = array();
+ $this->recalculate_after_save = null;
+ $this->products_to_add = array();
+
+ 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 ) ) {
+ return $error;
+ }
+ }
+
+ if ( isset( $input['customer_note'] ) ) {
+ $subject->set_customer_note( sanitize_textarea_field( $input['customer_note'] ) );
+ }
+
+ if ( empty( $input['remove_items'] ) && empty( $input['shipping_lines'] ) && empty( $input['line_items'] ) ) {
+ return null;
+ }
+
+ if ( ! $subject->is_editable() ) {
+ return new \WP_Error(
+ 'woocommerce_ability_order_not_editable',
+ sprintf(
+ /* translators: %s: order status slug. */
+ __( 'The lines of an order with the "%s" status cannot change. Move the order to an editable status, such as on-hold, with the woocommerce/order-update-status ability, then send the change again.', 'woocommerce' ),
+ $subject->get_status()
+ ),
+ array( 'status' => 400 )
+ );
+ }
+
+ return $this->change_lines( $subject, $input );
+ }
+
+ /**
+ * Save the order, recalculate it with the feature off, and change the product
+ * stock of the changed lines in the same way as the order screen. Validators have already run, so a refused
+ * change never reaches the stock. A removed line restores its stock before
+ * the save, because the save deletes the line that holds the reduced amount. After a successful save, one
+ * order note lists the stock changes, in the same format as the order screen.
+ *
+ * @param \WC_Order $subject Changed order.
+ * @return null|\WP_Error
+ */
+ public function save( $subject ) {
+ require_once WC_ABSPATH . 'includes/admin/wc-admin-functions.php';
+
+ $stock_notes = array();
+ foreach ( $this->removed_product_lines as $item ) {
+ $changed_stock = wc_maybe_adjust_line_item_product_stock( $item, 0 );
+ if ( is_array( $changed_stock ) ) {
+ $stock_notes[] = $item->get_name() . ' – ' . $changed_stock['from'] . '→' . $changed_stock['to'];
+ }
+ }
+
+ $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;
+ }
+ }
+
+ $saved = parent::save( $subject );
+
+ if ( is_wp_error( $saved ) ) {
+ foreach ( $added as $item ) {
+ $item->delete();
+ }
+ return $saved;
+ }
+
+ $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();
+ }
+ $subject->recalculate_coupons();
+ } elseif ( 'totals' === $this->recalculate_after_save ) {
+ $subject->calculate_totals();
+ }
+
+ if ( in_array( $subject->get_status(), array( OrderStatus::PROCESSING, OrderStatus::COMPLETED, OrderStatus::ON_HOLD ), true ) ) {
+ foreach ( $this->changed_product_lines as $item ) {
+ $changed_stock = wc_maybe_adjust_line_item_product_stock( $item );
+ if ( is_array( $changed_stock ) ) {
+ $stock_notes[] = $item->get_name() . ' (' . $changed_stock['from'] . '→' . $changed_stock['to'] . ')';
+ }
+ }
+ }
+
+ if ( ! empty( $stock_notes ) ) {
+ /* translators: %s item name. */
+ $subject->add_order_note( sprintf( __( 'Adjusted stock: %s', 'woocommerce' ), implode( ', ', $stock_notes ) ), 0, true, array( 'note_group' => OrderNoteGroup::PRODUCT_STOCK ) );
+ }
+
+ return null;
+ }
+
+ /**
+ * The ability output for the saved order. It includes the lines, so the
+ * client gets the IDs of the lines it added.
+ *
+ * @param \WC_Order $subject Saved order.
+ * @return array
+ */
+ public function prepare_response( $subject ): array {
+ return array(
+ 'order' => self::format_order_for_response( $subject, true ),
+ );
+ }
+
+ /**
+ * Set the sent fields of an address, then check the resulting address.
+ *
+ * @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 {
+ if ( ! empty( $address['email'] ) && ! is_email( $address['email'] ) ) {
+ return self::address_error(
+ $type,
+ 'email',
+ __( 'The billing email is not a valid email address. Send a valid address.', 'woocommerce' )
+ );
+ }
+
+ foreach ( $address as $key => $value ) {
+ $order->{"set_{$type}_{$key}"}( wc_clean( $value ) );
+ }
+
+ $country = $order->{"get_{$type}_country"}();
+ $state = $order->{"get_{$type}_state"}();
+ $postcode = $order->{"get_{$type}_postcode"}();
+
+ if ( '' !== $country && ! array_key_exists( $country, WC()->countries->get_countries() ) ) {
+ return 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 )
+ );
+ }
+
+ $states = WC()->countries->get_states( $country );
+
+ if ( '' !== $state && ! empty( $states ) && ! array_key_exists( $state, $states ) ) {
+ return 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 ) ) )
+ );
+ }
+
+ if ( '' !== $postcode && '' !== $country ) {
+ if ( ! \WC_Validation::is_postcode( $postcode, $country ) ) {
+ return 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 )
+ );
+ }
+
+ $order->{"set_{$type}_postcode"}( wc_format_postcode( $postcode, $country ) );
+ }
+
+ return 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
+ * they were applied before the new line existed.
+ *
+ * @param \WC_Order $order Order.
+ * @param array $input Ability input.
+ * @return null|\WP_Error
+ * @throws \WC_Data_Exception When the order class has not adopted a stage method.
+ */
+ private function change_lines( \WC_Order $order, array $input ): ?\WP_Error {
+ foreach ( $input['remove_items'] ?? array() as $item_id ) {
+ $item = $order->get_item( $item_id, false );
+
+ if ( ! $item instanceof \WC_Order_Item_Product && ! $item instanceof \WC_Order_Item_Shipping ) {
+ return self::line_not_found( $item_id );
+ }
+
+ if ( $item instanceof \WC_Order_Item_Product ) {
+ $this->removed_product_lines[] = $item;
+ }
+
+ $order->remove_item( $item_id );
+ }
+
+ foreach ( $input['shipping_lines'] ?? array() as $line ) {
+ $error = self::set_shipping_line( $order, $line );
+ if ( is_wp_error( $error ) ) {
+ return $error;
+ }
+ }
+
+ $product_added = false;
+
+ foreach ( $input['line_items'] ?? array() as $line ) {
+ if ( empty( $line['id'] ) === empty( $line['product_id'] ) ) {
+ return new \WP_Error(
+ 'woocommerce_ability_order_line_item_target_invalid',
+ __( 'Each entry in line_items needs exactly one target. Send id to change a line, or product_id to add a line.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $error = empty( $line['id'] ) ? $this->add_product_line( $order, $line ) : $this->change_product_line( $order, $line );
+ if ( is_wp_error( $error ) ) {
+ return $error;
+ }
+
+ $product_added = $product_added || empty( $line['id'] );
+ }
+
+ $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 ) );
+
+ return null;
+ }
+
+ /**
+ * Give a product line a new quantity in memory. The subtotal and the total
+ * keep their price per unit, so a custom price or a discount stays.
+ *
+ * @param \WC_Order $order Order.
+ * @param array $line Product line input.
+ * @return null|\WP_Error
+ */
+ private function change_product_line( \WC_Order $order, array $line ): ?\WP_Error {
+ $item = $order->get_item( $line['id'], false );
+
+ if ( ! $item instanceof \WC_Order_Item_Product ) {
+ return self::line_not_found( $line['id'] );
+ }
+
+ if ( ! isset( $line['quantity'] ) ) {
+ return new \WP_Error(
+ 'woocommerce_ability_order_line_item_quantity_required',
+ /* translators: %d: order item ID. */
+ sprintf( __( 'Nothing to change on line %d. Send quantity to change the line, or send its ID in remove_items to remove it.', 'woocommerce' ), $line['id'] ),
+ array( 'status' => 400 )
+ );
+ }
+
+ try {
+ wc_get_container()->get( ItemQuantityLimits::class )->validate_posted_item_quantities(
+ $order,
+ array( 'order_item_qty' => array( $line['id'] => $line['quantity'] ) )
+ );
+ } catch ( \Exception $exception ) {
+ return self::quantity_error( $exception );
+ }
+
+ $old_quantity = (float) $item->get_quantity();
+ $ratio = $old_quantity > 0 ? $line['quantity'] / $old_quantity : 1;
+
+ $item->set_subtotal( (string) ( (float) $item->get_subtotal() * $ratio ) );
+ $item->set_total( (string) ( (float) $item->get_total() * $ratio ) );
+ $item->set_quantity( $line['quantity'] );
+
+ $this->changed_product_lines[] = $item;
+
+ 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 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.
+ *
+ * @return array
+ */
+ private static function build_input_schema(): array {
+ return array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array(
+ '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 ),
+ 'customer_note' => array( 'type' => 'string' ),
+ 'line_items' => array(
+ 'type' => 'array',
+ 'description' => __( 'Send id and quantity to change a line, or product_id and quantity to add one. A variation uses its own ID as product_id.', 'woocommerce' ),
+ 'minItems' => 1,
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array(
+ 'type' => 'integer',
+ 'minimum' => 1,
+ ),
+ 'product_id' => array(
+ 'type' => 'integer',
+ 'minimum' => 1,
+ ),
+ 'quantity' => array(
+ 'type' => 'integer',
+ 'minimum' => 1,
+ ),
+ ),
+ 'additionalProperties' => false,
+ ),
+ ),
+ 'shipping_lines' => array(
+ 'type' => 'array',
+ 'description' => __( 'Send id to change a line. Without id, this adds a line, which needs method_title.', 'woocommerce' ),
+ 'minItems' => 1,
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array(
+ 'type' => 'integer',
+ 'minimum' => 1,
+ ),
+ 'method_id' => array( 'type' => 'string' ),
+ 'method_title' => array( 'type' => 'string' ),
+ 'total' => array( 'type' => 'string' ),
+ ),
+ 'additionalProperties' => false,
+ ),
+ ),
+ 'remove_items' => array(
+ 'type' => 'array',
+ 'description' => __( 'IDs of the product and shipping lines to remove.', 'woocommerce' ),
+ 'minItems' => 1,
+ 'items' => array(
+ 'type' => 'integer',
+ 'minimum' => 1,
+ ),
+ ),
+ ),
+ 'required' => array( 'id' ),
+ '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/OrdersQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
index 3892bb54b43..d36d413bc0b 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
@@ -312,7 +312,7 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
'include_line_items' => array(
'type' => 'boolean',
'description' => __(
- 'Whether to include order line items in each returned order. Defaults to false.',
+ 'Whether to include order line items and shipping lines in each returned order. Defaults to false.',
'woocommerce'
),
'default' => false,
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
index 6097c081835..fe00879ed92 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
@@ -188,6 +188,26 @@ trait OrderAbilityTrait {
'order_item'
),
),
+ 'shipping' => array(
+ 'type' => 'object',
+ 'description' => __( 'Order shipping address. Countries use ISO 3166-1 alpha-2 codes, and states use WooCommerce state codes.', 'woocommerce' ),
+ 'properties' => array_fill_keys( self::shipping_address_fields(), array( 'type' => 'string' ) ),
+ 'additionalProperties' => false,
+ ),
+ 'shipping_lines' => array(
+ 'type' => 'array',
+ 'description' => __( 'Order shipping lines. Present together with line_items.', 'woocommerce' ),
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array( 'type' => 'integer' ),
+ 'method_id' => array( 'type' => 'string' ),
+ 'method_title' => array( 'type' => 'string' ),
+ 'total' => array( 'type' => 'string' ),
+ ),
+ 'additionalProperties' => false,
+ ),
+ ),
),
'additionalProperties' => false,
);
@@ -308,11 +328,21 @@ trait OrderAbilityTrait {
return $formatted;
}
+ /**
+ * The shipping address fields of the output. The woocommerce_get_order_address
+ * filter can add other keys, and the closed output schema refuses them.
+ *
+ * @return string[]
+ */
+ private static function shipping_address_fields(): array {
+ return array( 'first_name', 'last_name', 'company', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', 'phone' );
+ }
+
/**
* Format an order for ability output.
*
* @param \WC_Order $order Order object.
- * @param bool $include_line_items Whether to include line items.
+ * @param bool $include_line_items Whether to include line items and shipping lines.
* @return array|\WP_Error A WP_Error when an extension field value does not match its schema.
*/
protected static function format_order_for_response( \WC_Order $order, bool $include_line_items ) {
@@ -330,6 +360,7 @@ trait OrderAbilityTrait {
'customer_id' => $order->get_customer_id(),
'payment_method' => $order->get_payment_method(),
'payment_method_title' => $order->get_payment_method_title(),
+ 'shipping' => array_intersect_key( $order->get_address( 'shipping' ), array_flip( self::shipping_address_fields() ) ),
);
if ( $include_line_items ) {
@@ -358,6 +389,20 @@ trait OrderAbilityTrait {
}
$data['line_items'][] = $line_item;
}
+
+ $data['shipping_lines'] = array_values(
+ array_map(
+ static function ( \WC_Order_Item_Shipping $item ): array {
+ return array(
+ 'id' => $item->get_id(),
+ 'method_id' => $item->get_method_id(),
+ 'method_title' => $item->get_method_title(),
+ 'total' => self::format_amount_for_response( $item->get_total() ),
+ );
+ },
+ $order->get_shipping_methods()
+ )
+ );
}
if ( '' !== $billing_email ) {
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
index 84e804719a0..35259762df8 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
@@ -38,6 +38,8 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
'woocommerce/product-update',
'woocommerce/product-delete',
'woocommerce/orders-query',
+ 'woocommerce/order-send-email',
+ 'woocommerce/order-update',
'woocommerce/order-update-status',
'woocommerce/order-add-note',
'woocommerce/order-notes-query',
@@ -288,6 +290,16 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
'idempotent' => true,
'destructive' => false,
),
+ 'woocommerce/order-send-email' => array(
+ 'readonly' => false,
+ 'idempotent' => false,
+ 'destructive' => false,
+ ),
+ 'woocommerce/order-update' => array(
+ 'readonly' => false,
+ 'idempotent' => false,
+ 'destructive' => true,
+ ),
'woocommerce/order-update-status' => array(
'readonly' => false,
'idempotent' => false,
@@ -955,6 +967,20 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
'id' => 1,
),
),
+ 'order-send-email' => array(
+ 'woocommerce/order-send-email',
+ array(
+ 'id' => 1,
+ 'email' => 'invoice',
+ ),
+ ),
+ 'order-update' => array(
+ 'woocommerce/order-update',
+ array(
+ 'id' => 1,
+ 'shipping' => array( 'city' => 'Denied' ),
+ ),
+ ),
'order-update-status' => array(
'woocommerce/order-update-status',
array(
@@ -1033,6 +1059,14 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
wp_set_current_user( $limited_order_manager_id );
$cases = array(
+ 'woocommerce/order-send-email' => array(
+ 'id' => $order->get_id(),
+ 'email' => 'invoice',
+ ),
+ 'woocommerce/order-update' => array(
+ 'id' => $order->get_id(),
+ 'shipping' => array( 'city' => 'Unauthorized city' ),
+ ),
'woocommerce/order-update-status' => array(
'id' => $order->get_id(),
'status' => 'processing',
@@ -1348,6 +1382,20 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
'woocommerce/orders-query',
array( 'id' => -123 ),
),
+ 'order-send-email' => array(
+ 'woocommerce/order-send-email',
+ array(
+ 'id' => -123,
+ 'email' => 'invoice',
+ ),
+ ),
+ 'order-update' => array(
+ 'woocommerce/order-update',
+ array(
+ 'id' => -123,
+ 'shipping' => array( 'city' => 'Invalid' ),
+ ),
+ ),
'order-update-status' => array(
'woocommerce/order-update-status',
array(
@@ -4006,6 +4054,559 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
$this->assertSame( 'woocommerce_order_not_found', $result->get_error_code() );
}
+ /**
+ * @testdox Should change addresses and the customer note of a completed order, keep other fields and the status, and leave addresses out of the output.
+ */
+ public function test_order_update_changes_addresses_and_customer_note(): void {
+ $order = $this->create_order_for_update( 'completed' );
+ $order->set_shipping_address_1( 'Keep this street' );
+ $order->save();
+ $total = $order->get_total();
+
+ $result = wp_get_ability( 'woocommerce/order-update' )->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'billing' => array(
+ 'city' => 'Porto',
+ 'email' => 'new@example.com',
+ ),
+ 'shipping' => array(
+ 'country' => 'PT',
+ 'state' => '',
+ 'postcode' => '1000-001',
+ 'city' => 'Lisbon',
+ ),
+ 'customer_note' => 'Leave at the door.',
+ )
+ );
+
+ $this->assertIsArray( $result );
+ $saved = wc_get_order( $order->get_id() );
+ $this->assertSame( 'Porto', $saved->get_billing_city() );
+ $this->assertSame( 'new@example.com', $saved->get_billing_email() );
+ $this->assertSame( 'Lisbon', $saved->get_shipping_city() );
+ $this->assertSame( 'Keep this street', $saved->get_shipping_address_1() );
+ $this->assertSame( 'Leave at the door.', $saved->get_customer_note() );
+ $this->assertSame( 'completed', $saved->get_status() );
+ $this->assertSame( $total, $saved->get_total() );
+ $this->assertSame( 'Lisbon', $result['order']['shipping']['city'] );
+ $this->assertArrayNotHasKey( 'billing', $result['order'] );
+ $this->assertArrayNotHasKey( 'customer_note', $result['order'] );
+ }
+
+ /**
+ * @testdox Should refuse an invalid order update with a code in the convention, and save nothing.
+ *
+ * @dataProvider provider_invalid_order_updates
+ *
+ * @param array $input Input without the order ID.
+ * @param string $code Expected error code.
+ * @param string $hint Text that the message must have.
+ */
+ public function test_order_update_refuses_invalid_input( array $input, string $code, string $hint ): void {
+ $order = $this->create_order_for_update( 'pending' );
+ $order->set_shipping_country( 'US' );
+ $order->set_shipping_state( 'CA' );
+ $order->set_shipping_city( 'Before' );
+ $order->save();
+
+ $result = wp_get_ability( 'woocommerce/order-update' )->execute( array( 'id' => $order->get_id() ) + $input );
+
+ $this->assertWPError( $result );
+ $this->assertSame( $code, $result->get_error_code() );
+ $this->assertStringContainsString( $hint, $result->get_error_message() );
+ $this->assertSame( 'Before', wc_get_order( $order->get_id() )->get_shipping_city() );
+ }
+
+ /**
+ * Invalid inputs for the order update ability.
+ *
+ * @return array<string, array{0: array, 1: string, 2: string}>
+ */
+ public function provider_invalid_order_updates(): array {
+ return array(
+ 'no fields' => array( array(), 'woocommerce_ability_order_no_fields', 'Send at least one' ),
+ 'country' => array(
+ array(
+ 'shipping' => array(
+ 'city' => 'After',
+ 'country' => 'XX',
+ ),
+ ),
+ 'woocommerce_ability_order_shipping_country_invalid',
+ 'ISO 3166-1',
+ ),
+ 'state kept from old' => array(
+ array(
+ 'shipping' => array(
+ 'city' => 'After',
+ 'country' => 'CA',
+ ),
+ ),
+ 'woocommerce_ability_order_shipping_state_invalid',
+ 'ON',
+ ),
+ 'postcode' => array(
+ array(
+ 'shipping' => array(
+ 'city' => 'After',
+ 'postcode' => 'not a zip',
+ ),
+ ),
+ 'woocommerce_ability_order_shipping_postcode_invalid',
+ 'format of that country',
+ ),
+ 'billing email' => array(
+ array(
+ 'shipping' => array( 'city' => 'After' ),
+ 'billing' => array( 'email' => 'not-an-email' ),
+ ),
+ 'woocommerce_ability_order_billing_email_invalid',
+ 'valid address',
+ ),
+ 'unknown line' => array(
+ array(
+ 'shipping' => array( 'city' => 'After' ),
+ 'remove_items' => array( 999999 ),
+ ),
+ 'woocommerce_ability_order_line_not_found',
+ 'include_line_items',
+ ),
+ 'no line target' => array(
+ array(
+ 'shipping' => array( 'city' => 'After' ),
+ 'line_items' => array( array( 'quantity' => 2 ) ),
+ ),
+ 'woocommerce_ability_order_line_item_target_invalid',
+ 'product_id',
+ ),
+ 'unknown product' => array(
+ array(
+ 'shipping' => array( 'city' => 'After' ),
+ 'line_items' => array( array( 'product_id' => 999999 ) ),
+ ),
+ 'woocommerce_ability_order_product_not_found',
+ 'woocommerce/products-query',
+ ),
+ 'bad shipping total' => array(
+ array(
+ 'shipping' => array( 'city' => 'After' ),
+ 'shipping_lines' => array(
+ array(
+ 'method_title' => 'Courier',
+ 'total' => 'free',
+ ),
+ ),
+ ),
+ 'woocommerce_ability_order_shipping_line_total_invalid',
+ '9.99',
+ ),
+ 'no method title' => array(
+ array(
+ 'shipping' => array( 'city' => 'After' ),
+ 'shipping_lines' => array( array( 'total' => '1.00' ) ),
+ ),
+ 'woocommerce_ability_order_shipping_line_method_title_required',
+ 'method_title',
+ ),
+ );
+ }
+
+ /**
+ * @testdox Should add, change and remove product lines, recalculate totals, and save the order once.
+ */
+ public function test_order_update_writes_product_lines(): void {
+ $order = $this->create_order_for_update( 'pending' );
+ $kept = current( $order->get_items() );
+ $removed = new \WC_Order_Item_Product();
+ $removed->set_props(
+ array(
+ 'name' => 'Removed',
+ 'quantity' => 1,
+ 'subtotal' => 50,
+ 'total' => 50,
+ )
+ );
+ $order->add_item( $removed );
+ $order->calculate_totals( false );
+ $order->save();
+ $product = \WC_Helper_Product::create_simple_product( true, array( 'regular_price' => 30 ) );
+ $saves = 0;
+ $count = static function () use ( &$saves ) {
+ ++$saves;
+ };
+ add_action( 'woocommerce_update_order', $count );
+ $old_quantity = $kept->get_quantity();
+ $old_total = (float) $kept->get_total();
+
+ $result = wp_get_ability( 'woocommerce/order-update' )->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'line_items' => array(
+ array(
+ 'id' => $kept->get_id(),
+ 'quantity' => $old_quantity * 2,
+ ),
+ array(
+ 'product_id' => $product->get_id(),
+ 'quantity' => 2,
+ ),
+ ),
+ 'remove_items' => array( $removed->get_id() ),
+ )
+ );
+ remove_action( 'woocommerce_update_order', $count );
+
+ $this->assertIsArray( $result );
+ $this->assertSame( 3, $saves );
+ $saved = wc_get_order( $order->get_id() );
+ $items = array_values( $saved->get_items() );
+ $this->assertCount( 2, $items );
+ $this->assertSame( $old_quantity * 2, $items[0]->get_quantity() );
+ $this->assertEqualsWithDelta( $old_total * 2, (float) $items[0]->get_total(), 0.001 );
+ $this->assertSame( $product->get_id(), $items[1]->get_product_id() );
+ $this->assertEqualsWithDelta( 60.0, (float) $items[1]->get_total(), 0.001 );
+ $expected = $old_total * 2 + 60 + (float) $saved->get_shipping_total();
+ $this->assertEqualsWithDelta( $expected, (float) $saved->get_total(), 0.001 );
+ $this->assertSame( $items[1]->get_id(), $result['order']['line_items'][1]['id'] );
+ }
+
+ /**
+ * @testdox Should add, change and remove shipping lines, include them in the output, and return them from the orders query.
+ */
+ public function test_order_update_writes_shipping_lines(): void {
+ $order = $this->create_order_for_update( 'pending' );
+ $existing_id = array_key_first( $order->get_shipping_methods() );
+
+ $result = wp_get_ability( 'woocommerce/order-update' )->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'shipping_lines' => array(
+ array(
+ 'method_id' => 'courier',
+ 'method_title' => 'Courier',
+ 'total' => '7.50',
+ ),
+ array(
+ 'method_id' => 'pickup',
+ 'method_title' => 'Pickup',
+ 'total' => '2.50',
+ ),
+ ),
+ 'remove_items' => array( $existing_id ),
+ )
+ );
+
+ $this->assertIsArray( $result );
+ $this->assertCount( 2, $result['order']['shipping_lines'] );
+ $saved = wc_get_order( $order->get_id() );
+ $this->assertEqualsWithDelta( 10.0, (float) $saved->get_shipping_total(), 0.001 );
+ $this->assertArrayNotHasKey( $existing_id, $saved->get_shipping_methods() );
+
+ $change_id = $result['order']['shipping_lines'][1]['id'];
+ $result = wp_get_ability( 'woocommerce/order-update' )->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'shipping_lines' => array(
+ array(
+ 'id' => $change_id,
+ 'total' => '3.00',
+ ),
+ ),
+ )
+ );
+
+ $this->assertIsArray( $result );
+ $this->assertEqualsWithDelta( 10.5, (float) wc_get_order( $order->get_id() )->get_shipping_total(), 0.001 );
+
+ $query = wp_get_ability( 'woocommerce/orders-query' )->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'include_line_items' => true,
+ )
+ );
+ $this->assertSame( 'Pickup', $query['orders'][0]['shipping_lines'][1]['method_title'] );
+ }
+
+ /**
+ * @testdox Should refuse a line change on an order status that does not allow editing, and name the fix.
+ */
+ public function test_order_update_refuses_line_change_on_order_that_is_not_editable(): void {
+ $order = $this->create_order_for_update( 'completed' );
+ $shipping_id = array_key_first( $order->get_shipping_methods() );
+
+ $result = wp_get_ability( 'woocommerce/order-update' )->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'remove_items' => array( $shipping_id ),
+ )
+ );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'woocommerce_ability_order_not_editable', $result->get_error_code() );
+ $this->assertStringContainsString( 'woocommerce/order-update-status', $result->get_error_message() );
+ $this->assertArrayHasKey( $shipping_id, wc_get_order( $order->get_id() )->get_shipping_methods() );
+ }
+
+ /**
+ * @testdox Should refuse a line ID of another type or another order.
+ */
+ public function test_order_update_refuses_line_of_another_type_or_order(): void {
+ $order = $this->create_order_for_update( 'pending' );
+ $other = $this->create_order_for_update( 'pending' );
+ $fee = new \WC_Order_Item_Fee();
+ $fee->set_total( 5 );
+ $order->add_item( $fee );
+ $order->save();
+
+ foreach ( array( $fee->get_id(), array_key_first( $other->get_items() ) ) as $item_id ) {
+ $result = wp_get_ability( 'woocommerce/order-update' )->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'remove_items' => array( $item_id ),
+ )
+ );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'woocommerce_ability_order_line_not_found', $result->get_error_code() );
+ }
+ }
+
+ /**
+ * @testdox Should apply coupons again to two added products.
+ */
+ public function test_order_update_applies_coupons_to_added_products(): void {
+ $order = $this->create_order_for_update( 'pending' );
+ $coupon = \WC_Helper_Coupon::create_coupon(
+ 'tenpercent',
+ array(
+ 'discount_type' => 'percent',
+ 'coupon_amount' => '10',
+ )
+ );
+ $order->apply_coupon( $coupon );
+ $order->save();
+ $discount_before = (float) $order->get_discount_total();
+ $first = \WC_Helper_Product::create_simple_product( true, array( 'regular_price' => 100 ) );
+ $second = \WC_Helper_Product::create_simple_product( true, array( 'regular_price' => 200 ) );
+
+ $result = wp_get_ability( 'woocommerce/order-update' )->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'line_items' => array(
+ array( 'product_id' => $first->get_id() ),
+ array( 'product_id' => $second->get_id() ),
+ ),
+ )
+ );
+
+ $this->assertIsArray( $result );
+ $saved = wc_get_order( $order->get_id() );
+ $items = array_values( $saved->get_items() );
+ $this->assertEqualsWithDelta( 90.0, (float) $items[ count( $items ) - 2 ]->get_total(), 0.001 );
+ $this->assertEqualsWithDelta( 180.0, (float) $items[ count( $items ) - 1 ]->get_total(), 0.001 );
+ $this->assertEqualsWithDelta( $discount_before + 30, (float) $saved->get_discount_total(), 0.001 );
+ }
+
+ /**
+ * @testdox Should change product stock when a line of an order with reduced stock is added, changes quantity, or is removed.
+ */
+ public function test_order_update_adjusts_stock_of_changed_and_removed_lines(): void {
+ $changed = \WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 10,
+ )
+ );
+ $removed = \WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 10,
+ )
+ );
+ $added = \WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 10,
+ )
+ );
+ $order = new \WC_Order();
+ $order->add_product( $changed, 2 );
+ $order->add_product( $removed, 3 );
+ $order->set_status( 'on-hold' );
+ $order->save();
+ wc_reduce_stock_levels( $order->get_id() );
+ $order = wc_get_order( $order->get_id() );
+ $items = array_values( $order->get_items() );
+
+ $result = wp_get_ability( 'woocommerce/order-update' )->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'line_items' => array(
+ array(
+ 'id' => $items[0]->get_id(),
+ 'quantity' => 5,
+ ),
+ array(
+ 'product_id' => $added->get_id(),
+ 'quantity' => 4,
+ ),
+ ),
+ 'remove_items' => array( $items[1]->get_id() ),
+ )
+ );
+
+ $this->assertIsArray( $result );
+ $this->assertSame( 5, wc_get_product( $changed->get_id() )->get_stock_quantity() );
+ $this->assertSame( 10, wc_get_product( $removed->get_id() )->get_stock_quantity() );
+ $this->assertSame( 6, wc_get_product( $added->get_id() )->get_stock_quantity() );
+ $this->assertCount( 2, wc_get_order( $order->get_id() )->get_items() );
+
+ $notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+ $this->assertSame(
+ sprintf(
+ 'Adjusted stock: %1$s – 7→10, %2$s (8→5), %3$s (10→6)',
+ $removed->get_name(),
+ $changed->get_name(),
+ $added->get_name()
+ ),
+ $notes[0]->content
+ );
+
+ wc_get_order( $order->get_id() )->update_status( 'cancelled' );
+
+ $this->assertSame( 10, wc_get_product( $changed->get_id() )->get_stock_quantity() );
+ $this->assertSame( 10, wc_get_product( $added->get_id() )->get_stock_quantity() );
+ }
+
+ /**
+ * @testdox Should send the named email to the billing email and keep the status.
+ *
+ * @dataProvider provider_order_emails
+ *
+ * @param string $email Email name.
+ * @param string $status Order status.
+ */
+ public function test_order_send_email_sends_named_email( string $email, string $status ): void {
+ $order = $this->create_order_for_update( $status );
+ $order->set_billing_email( 'customer@example.com' );
+ $order->save();
+ $mails = array();
+ $capture = static function ( $short_circuit, $atts ) use ( &$mails ) {
+ $mails[] = $atts;
+ return true;
+ };
+ add_filter( 'pre_wp_mail', $capture, 10, 2 );
+
+ $result = wp_get_ability( 'woocommerce/order-send-email' )->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'email' => $email,
+ )
+ );
+ remove_filter( 'pre_wp_mail', $capture );
+
+ $this->assertSame(
+ array(
+ 'order_id' => $order->get_id(),
+ 'email' => $email,
+ ),
+ $result
+ );
+ $this->assertCount( 1, $mails );
+ $this->assertSame( 'customer@example.com', $mails[0]['to'] );
+ $this->assertSame( $status, wc_get_order( $order->get_id() )->get_status() );
+ }
+
+ /**
+ * The emails that the order send email ability sends.
+ *
+ * @return array<string, array{0: string, 1: string}>
+ */
+ public function provider_order_emails(): array {
+ return array(
+ 'invoice' => array( 'invoice', 'pending' ),
+ 'processing' => array( 'processing', 'processing' ),
+ 'completed' => array( 'completed', 'completed' ),
+ );
+ }
+
+ /**
+ * @testdox Should refuse to send an order email that does not apply, and send nothing.
+ */
+ public function test_order_send_email_refusals(): void {
+ $mails = 0;
+ $capture = static function () use ( &$mails ) {
+ ++$mails;
+ return true;
+ };
+ add_filter( 'pre_wp_mail', $capture );
+ $ability = wp_get_ability( 'woocommerce/order-send-email' );
+ $order = $this->create_order_for_update( 'pending' );
+
+ $mismatch = $ability->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'email' => 'completed',
+ )
+ );
+
+ add_filter( 'woocommerce_order_get_billing_email', '__return_empty_string' );
+ $no_recipient = $ability->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'email' => 'invoice',
+ )
+ );
+
+ remove_filter( 'woocommerce_order_get_billing_email', '__return_empty_string' );
+
+ $order = $this->create_order_for_update( 'processing' );
+ add_filter( 'woocommerce_email_enabled_customer_processing_order', '__return_false' );
+ $disabled = $ability->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'email' => 'processing',
+ )
+ );
+ remove_filter( 'woocommerce_email_enabled_customer_processing_order', '__return_false' );
+ remove_filter( 'pre_wp_mail', $capture );
+
+ $this->assertSame( 'woocommerce_ability_order_email_status_mismatch', $mismatch->get_error_code() );
+ $this->assertSame( 'woocommerce_ability_order_email_no_recipient', $no_recipient->get_error_code() );
+ $this->assertSame( 'woocommerce_ability_order_email_disabled', $disabled->get_error_code() );
+ $this->assertSame( 0, $mails );
+
+ add_filter( 'pre_wp_mail', '__return_false' );
+ $failed = $ability->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'email' => 'invoice',
+ )
+ );
+ remove_filter( 'pre_wp_mail', '__return_false' );
+
+ $this->assertSame( 'woocommerce_ability_order_email_send_failed', $failed->get_error_code() );
+ }
+
+ /**
+ * Create an order with a product line and a shipping line, for the order write tests.
+ *
+ * @param string $status Order status.
+ * @return \WC_Order
+ */
+ private function create_order_for_update( string $status ): \WC_Order {
+ $order = \WC_Helper_Order::create_order();
+ $order->set_status( $status );
+ $order->save();
+ $this->created_order_ids[] = $order->get_id();
+
+ return wc_get_order( $order->get_id() );
+ }
+
/**
* @testdox Should query orders by billing email and include line items when requested.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
index c6d6fe951ce..ed6441e5b0c 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
@@ -4034,6 +4034,331 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
$this->assertSame( 'woocommerce_invalid_product_type', $refused->get_data()['code'] );
}
+ /**
+ * @testdox Should write an order extension field and the shipping address in one save, and save nothing when a validator refuses.
+ */
+ public function test_order_update_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 );
+ },
+ )
+ );
+ }
+ );
+ $this->register_validator(
+ 'order',
+ 'test-ext',
+ static function ( \WC_Order $order ) {
+ return 'Closed' === $order->get_shipping_city() ? new \WP_Error( 'test_refused', 'Refused.' ) : null;
+ }
+ );
+ $this->reset_registries();
+ AbilitiesLoader::init_extensions();
+ $order = \WC_Helper_Order::create_order();
+ $saves = 0;
+ $count = static function () use ( &$saves ) {
+ ++$saves;
+ };
+ $this->add_test_action( 'woocommerce_update_order', $count );
+
+ $output = $this->run_ability(
+ 'woocommerce/order-update',
+ array(
+ 'id' => $order->get_id(),
+ 'shipping' => array( 'city' => 'Lisbon' ),
+ 'extensions' => array( 'test-ext' => array( 'priority' => 'high' ) ),
+ )
+ );
+ $refused = $this->run_ability_response(
+ 'woocommerce/order-update',
+ array(
+ 'id' => $order->get_id(),
+ 'shipping' => array( 'city' => 'Closed' ),
+ 'extensions' => array( 'test-ext' => array( 'priority' => 'low' ) ),
+ )
+ );
+
+ wp_cache_flush();
+ $saved = wc_get_order( $order->get_id() );
+ $this->assertSame( 1, $saves );
+ $this->assertSame( 'high', $output['order']['extensions']['test-ext']['priority'] );
+ $this->assertSame( 400, $refused->get_status() );
+ $this->assertSame( 'test-ext:test_refused', $refused->get_data()['code'] );
+ $this->assertSame( 'Lisbon', $saved->get_shipping_city() );
+ $this->assertSame( 'high', $saved->get_meta( '_test_priority' ) );
+ }
+
+ /**
+ * @testdox Should recalculate with calculate_totals() after the save when the feature is off.
+ */
+ public function test_order_update_uses_the_saving_totals_with_the_feature_off(): void {
+ $this->set_feature( false );
+ update_option( 'woocommerce_calc_taxes', 'yes' );
+ \WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => '',
+ 'tax_rate' => '10',
+ 'tax_rate_name' => 'Tax',
+ 'tax_rate_priority' => 1,
+ 'tax_rate_compound' => 0,
+ 'tax_rate_shipping' => 1,
+ 'tax_rate_order' => 1,
+ 'tax_rate_class' => '',
+ )
+ );
+ $product = \WC_Helper_Product::create_simple_product( true, array( 'regular_price' => '10' ) );
+ $order = wc_create_order();
+ $order->add_product( $product, 1 );
+ $order->set_status( 'pending' );
+ $order->save();
+
+ $calls = array();
+ $record = static function ( $and_taxes, $listened, $staged ) use ( &$calls ) {
+ $calls[] = array(
+ 'staged' => $staged,
+ 'quantity' => array_sum( array_map( static fn( $item ) => $item->get_quantity(), wc_get_order( $listened->get_id() )->get_items() ) ),
+ 'tax_ids' => array_map( static fn( $tax ) => $tax->get_id(), array_values( $listened->get_taxes() ) ),
+ );
+ };
+ add_action( 'woocommerce_order_after_calculate_totals', $record, 10, 3 );
+
+ $result = wp_get_ability( 'woocommerce/order-update' )->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'line_items' => array(
+ array(
+ 'id' => array_key_first( $order->get_items() ),
+ 'quantity' => 3,
+ ),
+ ),
+ )
+ );
+ remove_action( 'woocommerce_order_after_calculate_totals', $record, 10 );
+ update_option( 'woocommerce_calc_taxes', 'no' );
+
+ $this->assertNotWPError( $result );
+ $this->assertCount( 1, $calls );
+ $this->assertFalse( $calls[0]['staged'] );
+ $this->assertSame( 3, $calls[0]['quantity'] );
+ $this->assertGreaterThan( 0, $calls[0]['tax_ids'][0] );
+ $this->assertSame( '33.00', wc_get_order( $order->get_id() )->get_total() );
+ }
+
+ /**
+ * @testdox Should save no line change and fire no totals action when a validator refuses, and fire it staged once when the change saves.
+ */
+ public function test_order_update_refusal_saves_no_line_change(): void {
+ $this->register_validator(
+ 'order',
+ 'test-ext',
+ static function ( \WC_Order $order ) {
+ return count( $order->get_shipping_methods() ) === 0 ? new \WP_Error( 'test_refused', 'Keep one shipping line.' ) : null;
+ }
+ );
+ $this->reset_registries();
+ AbilitiesLoader::init_extensions();
+ $order = \WC_Helper_Order::create_order();
+ $order->set_status( 'pending' );
+ $order->save();
+ $shipping_id = array_key_first( $order->get_shipping_methods() );
+ $total = $order->get_total();
+ $staged = array();
+ $record = static function ( $and_taxes, $order, $is_staged = false ) use ( &$staged ) {
+ $staged[] = $is_staged;
+ };
+ add_action( 'woocommerce_order_after_calculate_totals', $record, 10, 3 );
+
+ $refused = $this->run_ability_response(
+ 'woocommerce/order-update',
+ array(
+ 'id' => $order->get_id(),
+ 'remove_items' => array( $shipping_id ),
+ )
+ );
+
+ wp_cache_flush();
+ $saved = wc_get_order( $order->get_id() );
+ $this->assertSame( 'test-ext:test_refused', $refused->get_data()['code'] );
+ $this->assertArrayHasKey( $shipping_id, $saved->get_shipping_methods() );
+ $this->assertSame( $total, $saved->get_total() );
+ $this->assertSame( array(), $staged );
+
+ $saves = 0;
+ $count = static function () use ( &$saves ) {
+ ++$saves;
+ };
+ add_action( 'woocommerce_update_order', $count );
+
+ $output = $this->run_ability(
+ 'woocommerce/order-update',
+ array(
+ 'id' => $order->get_id(),
+ 'shipping_lines' => array(
+ array(
+ 'id' => $shipping_id,
+ 'total' => '25.00',
+ ),
+ ),
+ )
+ );
+
+ remove_action( 'woocommerce_update_order', $count );
+ remove_action( 'woocommerce_order_after_calculate_totals', $record, 10 );
+ $this->assertSame( 1, $saves );
+ $this->assertSame( array( true ), $staged );
+ $this->assertSame( '25.00', wc_format_decimal( $output['order']['shipping_lines'][0]['total'], 2 ) );
+ }
+
+ /**
+ * @testdox Should refuse a line change, and save nothing, when the order class overrides add_product() without its twin.
+ */
+ public function test_order_update_refuses_class_without_stage_twin(): void {
+ $order = \WC_Helper_Order::create_order();
+ $order->set_status( 'pending' );
+ $order->save();
+ $order_class = get_class(
+ new class() extends \WC_Order {
+ /**
+ * Add a product with a rule of this class.
+ *
+ * @param \WC_Product $product Product.
+ * @param int $qty Quantity.
+ * @param array $args Arguments.
+ * @return int
+ */
+ public function add_product( $product, $qty = 1, $args = array() ) { // phpcs:ignore Generic.CodeAnalysis.UselessOverridingMethod.Found -- The test needs an add_product() override without a stage_product_item() twin.
+ return parent::add_product( $product, $qty, $args );
+ }
+ }
+ );
+ $filter_class = static fn() => $order_class;
+ add_filter( 'woocommerce_order_class', $filter_class, 100 );
+ $product = \WC_Helper_Product::create_simple_product();
+ $count = count( $order->get_items() );
+
+ $refused = $this->run_ability_response(
+ 'woocommerce/order-update',
+ array(
+ 'id' => $order->get_id(),
+ 'line_items' => array( array( 'product_id' => $product->get_id() ) ),
+ )
+ );
+
+ remove_filter( 'woocommerce_order_class', $filter_class, 100 );
+ $this->assertSame( 400, $refused->get_status() );
+ $this->assertSame( 'woocommerce_data_staging_unsupported', $refused->get_data()['code'] );
+ $this->assertStringContainsString( 'stage_product_item', $refused->get_data()['message'] );
+ $this->assertCount( $count, ( new \WC_Order( $order->get_id() ) )->get_items() );
+ }
+
+ /**
+ * @testdox Should add a product line through add_product() of a class without the stage twin when the feature is off, and save nothing when a later line is refused.
+ */
+ public function test_order_update_falls_back_to_add_product_with_the_feature_off(): void {
+ $this->set_feature( false );
+ $order = \WC_Helper_Order::create_order();
+ $order->set_status( 'pending' );
+ $order->save();
+ $order_class = get_class(
+ new class() extends \WC_Order {
+ /**
+ * Calls of add_product().
+ *
+ * @var int
+ */
+ public static $calls = 0;
+
+ /**
+ * Add a product with a rule of this class.
+ *
+ * @param \WC_Product $product Product.
+ * @param int $qty Quantity.
+ * @param array $args Arguments.
+ * @return int
+ */
+ public function add_product( $product, $qty = 1, $args = array() ) {
+ ++self::$calls;
+ return parent::add_product( $product, $qty, $args );
+ }
+ }
+ );
+ $filter_class = static fn() => $order_class;
+ add_filter( 'woocommerce_order_class', $filter_class, 100 );
+ $product = \WC_Helper_Product::create_simple_product();
+ $count = count( $order->get_items() );
+
+ try {
+ $refused = $this->run_ability_response(
+ 'woocommerce/order-update',
+ array(
+ 'id' => $order->get_id(),
+ 'line_items' => array(
+ array( 'product_id' => $product->get_id() ),
+ array( 'product_id' => 999999 ),
+ ),
+ )
+ );
+ $updated = $this->run_ability(
+ 'woocommerce/order-update',
+ array(
+ 'id' => $order->get_id(),
+ 'line_items' => array( array( 'product_id' => $product->get_id() ) ),
+ )
+ );
+ } finally {
+ remove_filter( 'woocommerce_order_class', $filter_class, 100 );
+ }
+
+ $this->assertSame( 400, $refused->get_status() );
+ $this->assertSame( 1, $order_class::$calls );
+ $this->assertCount( $count + 1, $updated['order']['line_items'] );
+ $this->assertCount( $count + 1, ( new \WC_Order( $order->get_id() ) )->get_items() );
+ }
+
+ /**
+ * @testdox Should delete the product line that add_product() wrote when the order save fails with the feature off.
+ */
+ public function test_order_update_deletes_the_added_line_when_the_save_fails_with_the_feature_off(): void {
+ $this->set_feature( false );
+ $order = \WC_Helper_Order::create_order();
+ $order->set_status( 'pending' );
+ $order->save();
+ $product = \WC_Helper_Product::create_simple_product();
+ $count = count( $order->get_items() );
+ $fail = static function () {
+ throw new \Exception( 'Disk full.' );
+ };
+
+ add_action( 'woocommerce_before_order_object_save', $fail );
+ try {
+ $refused = $this->run_ability_response(
+ 'woocommerce/order-update',
+ array(
+ 'id' => $order->get_id(),
+ 'line_items' => array( array( 'product_id' => $product->get_id() ) ),
+ )
+ );
+ } finally {
+ remove_action( 'woocommerce_before_order_object_save', $fail );
+ }
+
+ $this->assertGreaterThanOrEqual( 400, $refused->get_status() );
+ $this->assertCount( $count, ( new \WC_Order( $order->get_id() ) )->get_items() );
+ }
+
/**
* @testdox Should not accept extension fields in the write abilities with the feature off.
*/