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.' );
+	}
 }