Commit 65e8d600dc4 for woocommerce
commit 65e8d600dc458b132e427409d726fdf12470e070
Author: Michal Iwanow <4765119+mcliwanow@users.noreply.github.com>
Date: Wed Nov 4 13:43:36 2026 +0100
Stop Store API checkout when the order could not be saved (#70626)
The Store API checkout and cart routes now check get_last_save_error() after each save and stop with woocommerce_rest_checkout_order_not_saved instead of continuing. HPOS checks value lengths before writing any order table, and the Store API limits phone to 100 characters.
Co-authored-by: Mike Jolley <90977+mikejolley@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/fix-checkout-order-save-errors b/plugins/woocommerce/changelog/fix-checkout-order-save-errors
new file mode 100644
index 00000000000..846d91fbaa2
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-checkout-order-save-errors
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Stop checkout from continuing to payment when the order could not be saved, and reject phone numbers over 100 characters.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
index 1948c9155a6..72e0c38a298 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
@@ -345,6 +345,7 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
return $this->get_id();
}
+ $persisted = false;
$this->last_save_error = null;
$deferred_actions = $this->take_deferred_actions();
@@ -367,6 +368,8 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
$this->save_items();
+ $persisted = true;
+
/**
* Trigger action after saving to the DB.
*
@@ -376,8 +379,12 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
do_action( 'woocommerce_after_' . $this->object_type . '_object_save', $this, $this->data_store );
} catch ( Exception $e ) {
- $this->last_save_error = $e;
- $message_id = $this->get_id() ? $this->get_id() : __( '(no ID)', 'woocommerce' );
+ // A callback on the after-save action throwing doesn't mean the order wasn't saved.
+ if ( ! $persisted ) {
+ $this->last_save_error = $e;
+ }
+
+ $message_id = $this->get_id() ? $this->get_id() : __( '(no ID)', 'woocommerce' );
$this->handle_exception(
$e,
wp_kses_post(
@@ -399,7 +406,7 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
* Get the exception that stopped the last save(). save() logs that exception and does not throw it.
*
* @internal
- * @since 11.4.0
+ * @since 11.2.2
* @return Exception|null The exception, or null when the last save() wrote the order.
*/
public function get_last_save_error(): ?Exception {
diff --git a/plugins/woocommerce/includes/class-wc-order.php b/plugins/woocommerce/includes/class-wc-order.php
index 6ec17694c63..f1ab3033dae 100644
--- a/plugins/woocommerce/includes/class-wc-order.php
+++ b/plugins/woocommerce/includes/class-wc-order.php
@@ -519,7 +519,7 @@ class WC_Order extends WC_Abstract_Order {
* @param string $new_status Status to change the order to. No internal wc- prefix is required.
* @param string $note Optional note to add.
* @param bool $manual Is this a manual order status change?.
- * @return bool
+ * @return bool Whether the order was saved with the new status.
*/
public function update_status( $new_status, $note = '', $manual = false ) {
if ( ! $this->get_id() ) { // Order must exist.
@@ -548,7 +548,9 @@ class WC_Order extends WC_Abstract_Order {
$this->add_order_note( __( 'Update status event failed.', 'woocommerce' ) . ' ' . $e->getMessage(), false, false, array( 'note_group' => OrderNoteGroup::ERROR ) );
return false;
}
- return true;
+
+ // save() swallows its own exceptions, so check whether it could persist the order.
+ return null === $this->get_last_save_error();
}
/**
diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
index a48cee218b2..e120cffa81a 100644
--- a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
+++ b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
@@ -867,6 +867,53 @@ class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements
return $result;
}
+ /**
+ * Throws before any order table is written when a value will not fit its column.
+ *
+ * Rows saved with $wpdb->insert() or $wpdb->update() (the ones with a `where` clause) are refused by wpdb when a
+ * value is too long, which would otherwise happen after the order row was already written. The other rows use a
+ * raw query, where MySQL truncates instead of failing, so they are not checked.
+ *
+ * @param array $db_updates Rows to persist, as returned by get_db_rows_for_order().
+ * @return void
+ * @throws \Exception When a value is longer than its column allows.
+ */
+ private function validate_db_row_lengths( array $db_updates ): void {
+ global $wpdb;
+
+ foreach ( $db_updates as $update ) {
+ if ( ! isset( $update['where'] ) ) {
+ continue;
+ }
+
+ foreach ( $update['data'] as $column => $value ) {
+ if ( ! is_string( $value ) || in_array( $update['format'][ $column ] ?? '%s', array( '%d', '%f' ), true ) ) {
+ continue;
+ }
+
+ $column_length = $wpdb->get_col_length( $update['table'], $column );
+ if ( ! is_array( $column_length ) || empty( $column_length['length'] ) ) {
+ continue;
+ }
+
+ // Same measure wpdb uses: bytes for binary and text columns, characters for char and varchar.
+ $value_length = 'byte' === $column_length['type'] ? strlen( $value ) : mb_strlen( $value, 'UTF-8' );
+ if ( $value_length > $column_length['length'] ) {
+ throw new \Exception(
+ esc_html(
+ sprintf(
+ /* translators: 1: database table name, 2: column name. */
+ __( 'Could not persist order to database table "%1$s": the value for "%2$s" is too long.', 'woocommerce' ),
+ $update['table'],
+ $column
+ )
+ )
+ );
+ }
+ }
+ }
+ }
+
/**
* Get information about whether permissions are granted yet.
*
@@ -2272,6 +2319,8 @@ FROM $order_meta_table
// Figure out what needs to be updated in the database.
$db_updates = $this->get_db_rows_for_order( $order, $context, $only_changes );
+ $this->validate_db_row_lengths( $db_updates );
+
// Persist changes.
foreach ( $db_updates as $update ) {
// Make sure 'data' and 'format' entries match before passing to $wpdb.
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php b/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php
index 423f34b5f2e..93cda02af2b 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php
@@ -305,6 +305,8 @@ abstract class AbstractCartRoute extends AbstractRoute {
* @param \WP_REST_Request $request Full details about the request.
*/
do_action( 'woocommerce_store_api_cart_update_order_from_request', $draft_order, $request );
+
+ $this->forget_draft_order_if_not_saved( $draft_order );
}
}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
index 109d5a400f1..41b9d021737 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
@@ -544,6 +544,7 @@ class Checkout extends AbstractCartRoute {
} catch ( \Throwable $save_exception ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
// Ignore the save exception, the root cause will be bubbled up via re-throwing $exception.
}
+ $this->forget_draft_order_if_not_saved( $this->order );
}
throw $exception;
}
@@ -705,6 +706,8 @@ class Checkout extends AbstractCartRoute {
// Custom statuses are preserved when no payment is needed; payment gateway statuses take precedence otherwise.
$this->order->update_status( 'pending' );
// Order save-point: 2.
+ $this->validate_order_was_saved();
+ $order = $this->get_order_or_throw();
/**
* Fires after the Checkout Block/Store API request has populated and validated the order.
@@ -725,14 +728,18 @@ class Checkout extends AbstractCartRoute {
*
* @param \WC_Order $order Order object.
*/
- do_action( 'woocommerce_store_api_checkout_order_processed', $this->order );
+ do_action( 'woocommerce_store_api_checkout_order_processed', $order );
/**
* Process the payment and return the results.
*/
$payment_result = new PaymentResult();
+ $needs_payment = $order->needs_payment();
+
+ // Callbacks on the action above, or on the needs_payment() filters, may have saved the order.
+ $this->validate_order_was_saved();
- if ( $this->order->needs_payment() ) {
+ if ( $needs_payment ) {
$this->process_payment( $request, $payment_result );
} else {
$this->process_without_payment( $request, $payment_result );
@@ -741,8 +748,8 @@ class Checkout extends AbstractCartRoute {
wc_log_order_step(
'[Store API #9] Order processed',
array(
- 'order_object' => $this->order,
- 'processed_with_payment' => $this->order->needs_payment() ? 'yes' : 'no',
+ 'order_object' => $order,
+ 'processed_with_payment' => $order->needs_payment() ? 'yes' : 'no',
'payment_status' => $payment_result->status,
),
true
@@ -750,7 +757,7 @@ class Checkout extends AbstractCartRoute {
return $this->prepare_item_for_response(
(object) [
- 'order' => wc_get_order( $this->order ),
+ 'order' => wc_get_order( $order ),
'payment_result' => $payment_result,
],
$request
@@ -930,6 +937,8 @@ class Checkout extends AbstractCartRoute {
// Store order ID to session.
$this->set_draft_order_id( $this->order->get_id() );
wc_log_order_step( '[Store API #4::create_or_update_draft_order] Set order draft id', array( 'order_object' => $this->order ) );
+
+ $this->validate_order_was_saved();
}
/**
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/CheckoutOrder.php b/plugins/woocommerce/src/StoreApi/Routes/V1/CheckoutOrder.php
index 06f36d02722..4762da3f060 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/CheckoutOrder.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/CheckoutOrder.php
@@ -165,8 +165,12 @@ class CheckoutOrder extends AbstractCartRoute {
* Process the payment and return the results.
*/
$payment_result = new PaymentResult();
+ $needs_payment = $this->order->needs_payment();
- if ( $this->order->needs_payment() ) {
+ // Callbacks on the action above, or on the needs_payment() filters, may have saved the order.
+ $this->validate_order_was_saved();
+
+ if ( $needs_payment ) {
$this->process_payment( $request, $payment_result );
} else {
$this->process_without_payment( $request, $payment_result );
@@ -235,6 +239,7 @@ class CheckoutOrder extends AbstractCartRoute {
$customer->save();
$this->order->save();
$this->order->calculate_totals();
+ $this->validate_order_was_saved();
}
/**
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php
index 58a462410f8..fa9f9514cbe 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php
@@ -100,6 +100,7 @@ abstract class AbstractAddressSchema extends AbstractSchema {
'phone' => [
'description' => __( 'Phone', 'woocommerce' ),
'type' => 'string',
+ 'maxLength' => 100,
'context' => [ 'view', 'edit' ],
'required' => true,
],
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php b/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php
index 42342f2c999..d8b1bbd512c 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php
@@ -61,13 +61,35 @@ trait CheckoutTrait {
return $this->order;
}
+ /**
+ * Stops the request when the order could not be saved, removing a draft order from the session so a later
+ * request does not resume it.
+ *
+ * @throws RouteException When the order is missing or its last save failed.
+ */
+ private function validate_order_was_saved(): void {
+ $order = $this->get_order_or_throw();
+ $this->forget_draft_order_if_not_saved( $order );
+
+ if ( null === $order->get_last_save_error() ) {
+ return;
+ }
+
+ throw new RouteException(
+ 'woocommerce_rest_checkout_order_not_saved',
+ esc_html__( 'Your order could not be saved. Please try again.', 'woocommerce' ),
+ 500
+ );
+ }
+
/**
* For orders which do not require payment, just update status.
*
* Deliberately no recovery of the kind process_payment() does: nothing was charged, so a
* failure costs the shopper only a retry, and claiming success would be the worse outcome.
*
- * @throws RouteException If the order is missing or a cancelled unpaid order would be marked paid.
+ * @throws RouteException If the order is missing, a cancelled unpaid order would be marked paid, or the order
+ * could not be saved.
*
* @param \WP_REST_Request $request Request object.
* @param PaymentResult $payment_result Payment result object.
@@ -85,6 +107,7 @@ trait CheckoutTrait {
}
$order->payment_complete();
+ $this->validate_order_was_saved();
// Mark the payment as successful.
$payment_result->set_status( 'success' );
@@ -331,6 +354,7 @@ trait CheckoutTrait {
if ( $persist ) {
$order->save();
+ $this->validate_order_was_saved();
}
}
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/DraftOrderTrait.php b/plugins/woocommerce/src/StoreApi/Utilities/DraftOrderTrait.php
index c9adc4fc440..df63c70e73d 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/DraftOrderTrait.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/DraftOrderTrait.php
@@ -33,6 +33,20 @@ trait DraftOrderTrait {
wc()->session->set( 'store_api_draft_order', $order_id );
}
+ /**
+ * Removes the order from the session when it is the draft order and its last save failed.
+ *
+ * A failed save can leave the order partly written, and resuming it would reuse that state.
+ *
+ * @since 11.2.2
+ * @param \WC_Order $order Order object.
+ */
+ protected function forget_draft_order_if_not_saved( \WC_Order $order ): void {
+ if ( null !== $order->get_last_save_error() && (int) $this->get_draft_order_id() === $order->get_id() ) {
+ $this->set_draft_order_id( 0 );
+ }
+ }
+
/**
* Uses the draft order ID to return an order object, if valid.
*
diff --git a/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php b/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php
index 338e9061481..41c23a694c1 100644
--- a/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php
+++ b/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php
@@ -2924,4 +2924,63 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
$this->assertSame( $status, wc_get_order( $order->get_id() )->get_status() );
}
+
+ /**
+ * @testdox save() records the exception when the order could not be persisted, and a later successful save() clears it.
+ */
+ public function test_get_last_save_error_reports_a_failed_save_until_the_next_successful_one(): void {
+ $order = wc_create_order();
+ $this->assertNull( $order->get_last_save_error(), 'A successful save should report no error.' );
+
+ $fail = function () {
+ throw new Exception( 'Forced save failure' );
+ };
+ add_action( 'woocommerce_before_order_object_save', $fail );
+ $order->set_total( '5.00' );
+ $order->save();
+ remove_action( 'woocommerce_before_order_object_save', $fail );
+
+ $this->assertInstanceOf( Exception::class, $order->get_last_save_error() );
+ $this->assertSame( 'Forced save failure', $order->get_last_save_error()->getMessage() );
+
+ $order->save();
+
+ $this->assertNull( $order->get_last_save_error(), 'A later successful save should clear the error.' );
+ $this->assertSame( '5.00', wc_get_order( $order->get_id() )->get_total() );
+ }
+
+ /**
+ * @testdox save() does not report a failure when only a woocommerce_after_order_object_save callback throws, since the order was already persisted.
+ */
+ public function test_get_last_save_error_ignores_exceptions_from_after_save_callbacks(): void {
+ $order = wc_create_order();
+
+ $fail = function () {
+ throw new Exception( 'After-save callback failure' );
+ };
+ add_action( 'woocommerce_after_order_object_save', $fail );
+ $order->set_total( '7.00' );
+ $order->save();
+ remove_action( 'woocommerce_after_order_object_save', $fail );
+
+ $this->assertNull( $order->get_last_save_error() );
+ $this->assertSame( '7.00', wc_get_order( $order->get_id() )->get_total() );
+ }
+
+ /**
+ * @testdox update_status() returns false when the order could not be saved.
+ */
+ public function test_update_status_returns_false_when_the_save_fails(): void {
+ $order = wc_create_order();
+
+ $fail = function () {
+ throw new Exception( 'Forced save failure' );
+ };
+ add_action( 'woocommerce_before_order_object_save', $fail );
+ $result = $order->update_status( OrderStatus::PROCESSING );
+ remove_action( 'woocommerce_before_order_object_save', $fail );
+
+ $this->assertFalse( $result );
+ $this->assertTrue( $order->update_status( OrderStatus::PROCESSING ), 'update_status() should return true once the save succeeds.' );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php
index 683e1054f6b..8dc9b93b2aa 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php
@@ -1728,4 +1728,27 @@ class Cart extends ControllerTestCase {
$this->assertSame( 500, $response->get_status(), 'A cart session failure should return a Store API error response.' );
}
+
+ /**
+ * @testdox A cart change removes the draft order from the session when syncing the change to it fails to save.
+ */
+ public function test_cart_change_forgets_draft_order_that_fails_to_save(): void {
+ $draft_order_id = (int) wc()->session->get( 'store_api_draft_order' );
+ $this->assertGreaterThan( 0, $draft_order_id );
+
+ $fail = function ( $order ) use ( $draft_order_id ) {
+ if ( $order->get_id() === $draft_order_id ) {
+ throw new \Exception( 'Forced save failure' );
+ }
+ };
+ add_action( 'woocommerce_before_order_object_save', $fail );
+
+ $request = new \WP_REST_Request( 'POST', '/wc/store/v1/cart/remove-item' );
+ $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+ $request->set_body_params( array( 'key' => $this->keys[0] ) );
+ $response = rest_get_server()->dispatch( $request );
+
+ $this->assertSame( 200, $response->get_status(), 'The cart change itself should still succeed.' );
+ $this->assertEmpty( wc()->session->get( 'store_api_draft_order' ), 'A draft order that failed to save must not stay in the session to be resumed.' );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
index 4b1f56fdfdd..7a81e3a7fb2 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
@@ -4051,6 +4051,319 @@ class Checkout extends \WP_Test_REST_TestCase {
$this->assertEmpty( WC()->session->get( 'store_api_draft_order' ), 'Successful checkout should clear the draft order pointer when the cart is emptied.' );
}
+ /**
+ * @testdox Checkout stops when the draft order could not be saved.
+ */
+ public function test_post_rejects_draft_order_when_save_fails(): void {
+ $order_id = $this->create_draft_order_then_remove_first_cart_line();
+ $this->fail_saves_of_order( $order_id );
+
+ $response = rest_get_server()->dispatch( $this->build_place_order_request() );
+
+ $this->assertSame( 500, $response->get_status() );
+ $this->assertSame( 'woocommerce_rest_checkout_order_not_saved', $response->get_data()['code'] );
+ $this->assertTrue( wc_get_order( $order_id )->has_status( OrderStatus::CHECKOUT_DRAFT ), 'The order must not move on to payment.' );
+ $this->assertEmpty( WC()->session->get( 'store_api_draft_order' ), 'The order whose save failed must not stay in the session to be resumed.' );
+ }
+
+ /**
+ * @testdox The next checkout after a failed save completes normally.
+ */
+ public function test_post_after_failed_save_places_order(): void {
+ $order_id = $this->create_draft_order_then_remove_first_cart_line();
+ $fail = $this->fail_saves_of_order( $order_id );
+ rest_get_server()->dispatch( $this->build_place_order_request() );
+ remove_action( 'woocommerce_update_order', $fail );
+
+ $response = rest_get_server()->dispatch( $this->build_place_order_request() );
+
+ $this->assertSame( 200, $response->get_status(), print_r( $response->get_data(), true ) );
+ $order = wc_get_order( $response->get_data()['order_id'] );
+ $items = array_values( $order->get_items() );
+ $this->assertCount( 1, $items, 'The order should hold the cart line.' );
+ $this->assertSame( $this->products[1]->get_id(), $items[0]->get_product_id() );
+ $this->assertSame( (float) $items[0]->get_total() + (float) $order->get_shipping_total(), (float) $order->get_total() );
+ }
+
+ /**
+ * @testdox Checkout stops before payment when setting the order to pending fails to save.
+ */
+ public function test_post_rejects_order_when_pending_status_fails_to_save(): void {
+ $fail = function ( $order ) {
+ if ( $order->has_status( OrderStatus::PENDING ) ) {
+ throw new \Exception( 'Forced save failure' );
+ }
+ };
+ add_action( 'woocommerce_before_order_object_save', $fail );
+ $processed = false;
+ add_action(
+ 'woocommerce_store_api_checkout_order_processed',
+ function () use ( &$processed ) {
+ $processed = true;
+ }
+ );
+
+ $response = rest_get_server()->dispatch( $this->build_valid_post_request() );
+
+ $this->assertSame( 500, $response->get_status() );
+ $this->assertSame( 'woocommerce_rest_checkout_order_not_saved', $response->get_data()['code'] );
+ $this->assertFalse( $processed, 'The order must not reach the processed action or payment.' );
+ $this->assertEmpty( WC()->session->get( 'store_api_draft_order' ) );
+ }
+
+ /**
+ * @testdox Checkout stops before payment when an extension's save of the order fails on $hook.
+ * @testWith ["woocommerce_store_api_checkout_order_processed"]
+ * ["woocommerce_valid_order_statuses_for_payment"]
+ * ["woocommerce_order_needs_payment"]
+ *
+ * @param string $hook Action or filter that receives the order after its last save point.
+ */
+ public function test_post_rejects_order_when_extension_save_fails_before_payment( string $hook ): void {
+ $order_id = 0;
+ $this->record_processed_order_id( $order_id );
+ add_filter(
+ $hook,
+ function ( $value, $order = null ) use ( &$order_id ) {
+ $order = $value instanceof \WC_Order ? $value : $order;
+ // The needs_payment() filters also run before the processed action, which the earlier save points cover.
+ if ( $order_id && $order instanceof \WC_Order ) {
+ $this->save_order_with_forced_failure( $order );
+ }
+ return $value;
+ },
+ 10,
+ 2
+ );
+ $payment_attempted = false;
+ add_action(
+ 'woocommerce_rest_checkout_process_payment_with_context',
+ function () use ( &$payment_attempted ) {
+ $payment_attempted = true;
+ }
+ );
+
+ $response = rest_get_server()->dispatch( $this->build_valid_post_request() );
+
+ $this->assertSame( 500, $response->get_status() );
+ $this->assertSame( 'woocommerce_rest_checkout_order_not_saved', $response->get_data()['code'] );
+ $this->assertFalse( $payment_attempted, 'Payment must not be attempted for an order that could not be saved.' );
+ $this->assertTrue( wc_get_order( $order_id )->has_status( OrderStatus::PENDING ), 'The order must stay pending.' );
+ $this->assertEmpty( WC()->session->get( 'store_api_draft_order' ), 'The order whose save failed must not stay in the session to be resumed.' );
+ }
+
+ /**
+ * @testdox Checkout of an order that needs no payment fails when marking it paid cannot save it.
+ */
+ public function test_post_without_payment_rejects_order_when_payment_complete_fails_to_save(): void {
+ add_filter( 'woocommerce_order_needs_payment', '__return_false' );
+ $order_id = 0;
+ $this->record_processed_order_id( $order_id );
+ // As an extension saving invalid data from this filter would, make the payment_complete() save fail.
+ add_filter(
+ 'woocommerce_payment_complete_order_status',
+ function ( $status ) use ( &$order_id ) {
+ if ( $order_id ) {
+ add_action(
+ 'woocommerce_before_order_object_save',
+ function () {
+ throw new \Exception( 'Forced save failure' );
+ }
+ );
+ }
+ return $status;
+ }
+ );
+
+ $response = rest_get_server()->dispatch( $this->build_valid_post_request() );
+
+ $this->assertSame( 500, $response->get_status() );
+ $this->assertSame( 'woocommerce_rest_checkout_order_not_saved', $response->get_data()['code'] );
+ $order = wc_get_order( $order_id );
+ $this->assertTrue( $order->has_status( OrderStatus::PENDING ), 'The order must stay pending.' );
+ $this->assertNull( $order->get_date_paid(), 'The order must not be marked paid.' );
+ }
+
+ /**
+ * Record the ID of the order passed to the processed action, which also marks that the last save point has passed.
+ *
+ * @param int $order_id Set to the order ID when the action fires.
+ */
+ private function record_processed_order_id( int &$order_id ): void {
+ $order_id = 0;
+ add_action(
+ 'woocommerce_store_api_checkout_order_processed',
+ function ( $order ) use ( &$order_id ) {
+ $order_id = $order->get_id();
+ },
+ 1
+ );
+ }
+
+ /**
+ * Save the order while a callback makes the save fail, as saving a value too long for its column would.
+ *
+ * @param \WC_Order $order Order object.
+ */
+ private function save_order_with_forced_failure( \WC_Order $order ): void {
+ $fail = function () {
+ throw new \Exception( 'Forced save failure' );
+ };
+ add_action( 'woocommerce_before_order_object_save', $fail );
+ $order->save();
+ remove_action( 'woocommerce_before_order_object_save', $fail );
+ }
+
+ /**
+ * @testdox Updating the checkout with PATCH fails and forgets the draft order when the order cannot be saved.
+ */
+ public function test_put_rejects_draft_order_that_fails_to_save(): void {
+ $order_id = $this->create_draft_order_then_remove_first_cart_line();
+ $this->fail_saves_of_order( $order_id );
+
+ $request = new \WP_REST_Request( 'PUT', '/wc/store/v1/checkout' );
+ $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+ $request->set_body_params( array( 'customer_note' => 'Leave at the door' ) );
+ $this->simulate_fresh_request();
+ $response = rest_get_server()->dispatch( $request );
+
+ $this->assertSame( 500, $response->get_status() );
+ $this->assertSame( 'woocommerce_rest_checkout_order_not_saved', $response->get_data()['code'] );
+ $this->assertEmpty( WC()->session->get( 'store_api_draft_order' ) );
+ }
+
+ /**
+ * @testdox Pay for order stops before payment when the order cannot be saved.
+ */
+ public function test_checkout_order_rejects_order_that_fails_to_save(): void {
+ $order = \WC_Helper_Order::create_order( 0 );
+ $order_id = $order->get_id();
+ $this->fail_saves_of_order( $order_id );
+
+ $response = rest_get_server()->dispatch( $this->build_pay_for_order_request( $order ) );
+
+ $this->assertSame( 500, $response->get_status() );
+ $this->assertSame( 'woocommerce_rest_checkout_order_not_saved', $response->get_data()['code'] );
+ $this->assertTrue( wc_get_order( $order_id )->has_status( OrderStatus::PENDING ), 'The order must not move on to payment.' );
+ }
+
+ /**
+ * @testdox Pay for order stops before payment when an extension's save of the order fails on $hook.
+ * @testWith ["woocommerce_store_api_checkout_order_processed"]
+ * ["woocommerce_valid_order_statuses_for_payment"]
+ * ["woocommerce_order_needs_payment"]
+ *
+ * @param string $hook Action or filter that receives the order after its last save point.
+ */
+ public function test_checkout_order_rejects_order_when_extension_save_fails_before_payment( string $hook ): void {
+ $order = \WC_Helper_Order::create_order( 0 );
+ $processed_id = 0;
+ $this->record_processed_order_id( $processed_id );
+ add_filter(
+ $hook,
+ function ( $value, $filtered_order = null ) use ( &$processed_id ) {
+ $filtered_order = $value instanceof \WC_Order ? $value : $filtered_order;
+ // The needs_payment() filters also run before the processed action, which the earlier save points cover.
+ if ( $processed_id && $filtered_order instanceof \WC_Order ) {
+ $this->save_order_with_forced_failure( $filtered_order );
+ }
+ return $value;
+ },
+ 10,
+ 2
+ );
+ $payment_attempted = false;
+ add_action(
+ 'woocommerce_rest_checkout_process_payment_with_context',
+ function () use ( &$payment_attempted ) {
+ $payment_attempted = true;
+ }
+ );
+
+ $response = rest_get_server()->dispatch( $this->build_pay_for_order_request( $order ) );
+
+ $this->assertSame( 500, $response->get_status() );
+ $this->assertSame( 'woocommerce_rest_checkout_order_not_saved', $response->get_data()['code'] );
+ $this->assertFalse( $payment_attempted, 'Payment must not be attempted for an order that could not be saved.' );
+ $this->assertTrue( wc_get_order( $order->get_id() )->has_status( OrderStatus::PENDING ), 'The order must stay pending.' );
+ }
+
+ /**
+ * Build a pay for order request for an existing order, sending the order's own billing address.
+ *
+ * The order's own address keeps the request from being refused for re-pricing the order.
+ *
+ * @param \WC_Order $order Order to pay for.
+ * @return \WP_REST_Request
+ */
+ private function build_pay_for_order_request( \WC_Order $order ): \WP_REST_Request {
+ $request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout/' . $order->get_id() );
+ $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+ $request->set_query_params(
+ array(
+ 'key' => $order->get_order_key(),
+ 'billing_email' => $order->get_billing_email(),
+ )
+ );
+ $request->set_body_params(
+ array(
+ 'billing_address' => $order->get_address( 'billing' ),
+ 'payment_method' => WC_Gateway_BACS::ID,
+ )
+ );
+ return $request;
+ }
+
+ /**
+ * Store a draft order for the seeded cart, then remove one cart line.
+ *
+ * @return int Draft order ID.
+ */
+ private function create_draft_order_then_remove_first_cart_line(): int {
+ // A registered but disabled gateway is refused after the draft order is stored.
+ $request = $this->build_valid_post_request();
+ $request->set_param( 'payment_method', 'cheque' );
+ $response = rest_get_server()->dispatch( $request );
+ $this->assertSame( 400, $response->get_status() );
+
+ $order_id = (int) WC()->session->get( 'store_api_draft_order' );
+ $this->assertCount( 2, wc_get_order( $order_id )->get_items(), 'The draft order should hold both cart lines.' );
+
+ wc()->cart->remove_cart_item( wc()->cart->generate_cart_id( $this->products[0]->get_id() ) );
+
+ return $order_id;
+ }
+
+ /**
+ * Build a place order request for the current cart, as a new HTTP request would send it.
+ *
+ * @return \WP_REST_Request
+ */
+ private function build_place_order_request(): \WP_REST_Request {
+ $request = $this->build_valid_post_request();
+ $params = $request->get_body_params();
+ unset( $params['expected_total'] );
+ $request->set_body_params( $params );
+ $this->simulate_fresh_request();
+ return $request;
+ }
+
+ /**
+ * Make every save of the given order fail, as a database error would.
+ *
+ * @param int $order_id Order ID.
+ * @return callable The callback added to woocommerce_update_order.
+ */
+ private function fail_saves_of_order( int $order_id ): callable {
+ $fail = function ( $updated_order_id ) use ( $order_id ) {
+ if ( (int) $updated_order_id === $order_id ) {
+ throw new \Exception( 'Forced save failure' );
+ }
+ };
+ add_action( 'woocommerce_update_order', $fail );
+ return $fail;
+ }
+
/**
* @testdox Checkout places a zero-total pending or failed order from the session instead of creating a new one.
* @testWith ["pending"]
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/V1/AbstractAddressSchemaTest.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/V1/AbstractAddressSchemaTest.php
index e4eb267fe61..176042152b5 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/V1/AbstractAddressSchemaTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/V1/AbstractAddressSchemaTest.php
@@ -156,6 +156,27 @@ class AbstractAddressSchemaTest extends WC_Unit_Test_Case {
$this->assertSame( '+1-555-123-4567', $result['phone'] );
}
+ /**
+ * @testdox Should accept a phone number up to 100 characters, the size of the order address phone column.
+ */
+ public function test_accepts_phone_up_to_column_length(): void {
+ $address = $this->make_address( array( 'phone' => str_repeat( '1', 100 ) ) );
+
+ $this->assertTrue( $this->sut->validate_callback( $address, null, 'billing_address' ) );
+ }
+
+ /**
+ * @testdox Should reject a phone number longer than 100 characters, which cannot be stored against the order.
+ */
+ public function test_rejects_phone_longer_than_column_length(): void {
+ $address = $this->make_address( array( 'phone' => str_repeat( '1', 101 ) ) );
+
+ $result = $this->sut->validate_callback( $address, null, 'billing_address' );
+
+ $this->assertWPError( $result );
+ $this->assertContains( 'invalid_phone', $result->get_error_codes() );
+ }
+
/**
* @testdox Should leave a phone number without invisible characters alone.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php
index 129c334b75a..e283b020dff 100644
--- a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php
@@ -4749,4 +4749,37 @@ class OrdersTableDataStoreTests extends \HposTestCase {
$this->assertNull( get_comment( $note_id ), 'Order note should be deleted' );
$this->assertEmpty( get_comment_meta( $note_id ), 'Commentmeta should be deleted along with the note' );
}
+
+ /**
+ * @testdox An address value too long for its column fails the update before the order row is written, so the order is not left half saved.
+ */
+ public function test_update_with_address_value_too_long_for_its_column_writes_nothing(): void {
+ global $wpdb;
+ $this->toggle_cot_feature_and_usage( true );
+ $this->disable_cot_sync();
+
+ $order = OrderHelper::create_order();
+ $order->set_total( '50.00' );
+ $order->set_billing_phone( '5551234' );
+ $order->save();
+ $order_id = $order->get_id();
+
+ $order->set_total( '5.00' );
+ $order->set_billing_phone( str_repeat( '1', 101 ) );
+ $error = null;
+ try {
+ $this->sut->update( $order );
+ } catch ( \Exception $e ) {
+ $error = $e;
+ }
+
+ //phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $orders_table = $this->sut::get_orders_table_name();
+ $addresses_table = $this->sut::get_addresses_table_name();
+ $this->assertSame( '50.00000000', $wpdb->get_var( $wpdb->prepare( "SELECT total_amount FROM {$orders_table} WHERE id = %d", $order_id ) ), 'The new total must not be written when the address row cannot be.' );
+ $this->assertSame( '5551234', $wpdb->get_var( $wpdb->prepare( "SELECT phone FROM {$addresses_table} WHERE order_id = %d AND address_type = 'billing'", $order_id ) ) );
+ //phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $this->assertInstanceOf( \Exception::class, $error, 'The update should throw when a value does not fit its column.' );
+ $this->assertStringContainsString( 'phone', $error->getMessage(), 'The error should name the column that is too long.' );
+ }
}