namespace Automattic\WooCommerce\StoreApi\Utilities;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use Automattic\WooCommerce\Internal\Agentic\Enums\Specs\CheckoutSessionStatus;
use Automattic\WooCommerce\Internal\Agentic\Enums\Specs\ErrorCode;
use Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\Enums\SessionKey;
use Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\Errors\Error;
use Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\Error as AgenticError;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\AgenticCheckoutSession;
use Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\Messages\MessageError;
use Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\Messages\Messages;
* AgenticCheckoutUtils class.
* Utility class for shared Agentic Checkout API functionality.
class AgenticCheckoutUtils {
* Get the shared parameters schema for checkout session requests.
* @return array Parameters array.
public static function get_shared_params() {
'description' => __( 'Line items to add to the cart.', 'woocommerce' ),
'description' => __( 'Product ID.', 'woocommerce' ),
'description' => __( 'Quantity.', 'woocommerce' ),
'required' => [ 'id', 'quantity' ],
'description' => __( 'Buyer information.', 'woocommerce' ),
'description' => __( 'First name.', 'woocommerce' ),
'description' => __( 'Last name.', 'woocommerce' ),
'description' => __( 'Email address.', 'woocommerce' ),
'description' => __( 'Phone number.', 'woocommerce' ),
'fulfillment_address' => [
'description' => __( 'Fulfillment/shipping address.', 'woocommerce' ),
'description' => __( 'Full name.', 'woocommerce' ),
'description' => __( 'Address line 1.', 'woocommerce' ),
'description' => __( 'Address line 2.', 'woocommerce' ),
'description' => __( 'City.', 'woocommerce' ),
'description' => __( 'State/province.', 'woocommerce' ),
'description' => __( 'Country code (ISO 3166-1 alpha-2).', 'woocommerce' ),
'description' => __( 'Postal/ZIP code.', 'woocommerce' ),
'required' => [ 'line_one', 'city', 'country', 'postal_code' ],
* Add items to cart from request.
* @param array $items Items array from request.
* @param CartController $cart_controller Cart controller instance.
* @param Messages $messages Error messages instance.
* @return Error|null Returns error response on failure, null on success.
public static function add_items_to_cart( $items, $cart_controller, $messages ) {
foreach ( $items as $item_index => $item ) {
if ( ! ctype_digit( $item['id'] ) ) {
return AgenticError::invalid_request(
__( 'Product ID must be numeric.', 'woocommerce' ),
'$.items[' . $item_index . '].id'
$product_id = (int) $item['id'];
$quantity = (int) $item['quantity'];
$cart_controller->add_to_cart(
} catch ( RouteException $exception ) {
$message = wp_specialchars_decode( $exception->getMessage(), ENT_QUOTES );
$param = '$.items[' . $item_index . ']';
// Map WooCommerce error codes to Agentic Commerce Protocol error codes.
switch ( $exception->getErrorCode() ) {
case 'woocommerce_rest_product_out_of_stock':
case 'woocommerce_rest_product_partially_out_of_stock':
$message_error = MessageError::out_of_stock( $message, $param );
if ( null !== $message_error ) {
$messages->add( $message_error );
// The error code is generally applicable only to MessageErrors, but we can use it here as well.
return AgenticError::invalid_request( ErrorCode::INVALID, $message, $param );
* Set buyer data on customer.
* @param array $buyer Buyer data.
* @param \WC_Customer $customer Customer instance.
public static function set_buyer_data( $buyer, $customer ) {
if ( isset( $buyer['first_name'] ) ) {
$first_name = wc_clean( wp_unslash( $buyer['first_name'] ) );
$customer->set_billing_first_name( $first_name );
$customer->set_shipping_first_name( $first_name );
if ( isset( $buyer['last_name'] ) ) {
$last_name = wc_clean( wp_unslash( $buyer['last_name'] ) );
$customer->set_billing_last_name( $last_name );
$customer->set_shipping_last_name( $last_name );
if ( isset( $buyer['email'] ) ) {
$email = sanitize_email( wp_unslash( $buyer['email'] ) );
if ( is_email( $email ) ) {
$customer->set_billing_email( $email );
if ( isset( $buyer['phone_number'] ) ) {
$phone = wc_clean( wp_unslash( $buyer['phone_number'] ) );
$customer->set_billing_phone( $phone );
* Set fulfillment address on customer.
* @param array $address Address data.
* @param \WC_Customer $customer Customer instance.
public static function set_fulfillment_address( $address, $customer ) {
// Only parse and set name if provided and non-empty.
if ( ! empty( $address['name'] ) ) {
$name = wc_clean( wp_unslash( $address['name'] ) );
$name_parts = explode( ' ', $name, 2 );
$first_name = $name_parts[0];
$last_name = isset( $name_parts[1] ) ? $name_parts[1] : '';
$customer->set_shipping_first_name( $first_name );
$customer->set_shipping_last_name( $last_name );
// Preserve existing shipping names.
$first_name = $customer->get_shipping_first_name();
$last_name = $customer->get_shipping_last_name();
// Sanitize all address fields.
$line_one = wc_clean( wp_unslash( $address['line_one'] ?? '' ) );
$line_two = wc_clean( wp_unslash( $address['line_two'] ?? '' ) );
$city = wc_clean( wp_unslash( $address['city'] ?? '' ) );
$state = wc_clean( wp_unslash( $address['state'] ?? '' ) );
$postal_code = wc_clean( wp_unslash( $address['postal_code'] ?? '' ) );
$country = wc_clean( wp_unslash( $address['country'] ?? '' ) );
// Set shipping address fields.
$customer->set_shipping_address_1( $line_one );
$customer->set_shipping_address_2( $line_two );
$customer->set_shipping_city( $city );
$customer->set_shipping_state( $state );
$customer->set_shipping_postcode( $postal_code );
$customer->set_shipping_country( $country );
// Also set as billing address if not already set.
if ( ! $customer->get_billing_address_1() ) {
// For billing, only set names if provided or use existing billing names.
if ( ! empty( $address['name'] ) ) {
$customer->set_billing_first_name( $first_name );
$customer->set_billing_last_name( $last_name );
$customer->set_billing_address_1( $line_one );
$customer->set_billing_address_2( $line_two );
$customer->set_billing_city( $city );
$customer->set_billing_state( $state );
$customer->set_billing_postcode( $postal_code );
$customer->set_billing_country( $country );
* Clear fulfillment address from customer.
* @param \WC_Customer $customer Customer instance.
public static function clear_fulfillment_address( $customer ) {
// Clear shipping address.
$customer->set_shipping_first_name( '' );
$customer->set_shipping_last_name( '' );
$customer->set_shipping_address_1( '' );
$customer->set_shipping_address_2( '' );
$customer->set_shipping_city( '' );
$customer->set_shipping_state( '' );
$customer->set_shipping_postcode( '' );
$customer->set_shipping_country( '' );
* Set billing address on customer.
* @param array $address Address data.
* @param \WC_Customer $customer Customer instance.
public static function set_billing_address( $address, $customer ) {
// Only parse and set name if provided and non-empty.
if ( ! empty( $address['name'] ) ) {
$name = wc_clean( wp_unslash( $address['name'] ) );
$name_parts = explode( ' ', $name, 2 );
$first_name = $name_parts[0];
$last_name = isset( $name_parts[1] ) ? $name_parts[1] : '';
$customer->set_billing_first_name( $first_name );
$customer->set_billing_last_name( $last_name );
// Sanitize all address fields.
$line_one = wc_clean( wp_unslash( $address['line_one'] ?? '' ) );
$line_two = wc_clean( wp_unslash( $address['line_two'] ?? '' ) );
$city = wc_clean( wp_unslash( $address['city'] ?? '' ) );
$state = wc_clean( wp_unslash( $address['state'] ?? '' ) );
$postal_code = wc_clean( wp_unslash( $address['postal_code'] ?? '' ) );
$country = wc_clean( wp_unslash( $address['country'] ?? '' ) );
// Set billing address fields.
$customer->set_billing_address_1( $line_one );
$customer->set_billing_address_2( $line_two );
$customer->set_billing_city( $city );
$customer->set_billing_state( $state );
$customer->set_billing_postcode( $postal_code );
$customer->set_billing_country( $country );
* Add Agentic Commerce Protocol headers to response.
* @param \WP_REST_Response $response Response object.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response Response with headers.
public static function add_protocol_headers( \WP_REST_Response $response, \WP_REST_Request $request ) {
// Echo Idempotency-Key header if provided.
$idempotency_key = $request->get_header( 'Idempotency-Key' );
if ( $idempotency_key ) {
$response->header( 'Idempotency-Key', $idempotency_key );
// Echo Request-Id header if provided.
$request_id = $request->get_header( 'Request-Id' );
$response->header( 'Request-Id', $request_id );
* Validate that the request is signed with Jetpack blog token.
* @return true|\WP_Error True if valid, WP_Error otherwise.
public static function validate_jetpack_request() {
if ( class_exists( 'Automattic\Jetpack\Connection\Rest_Authentication' ) ) {
if ( \Automattic\Jetpack\Connection\Rest_Authentication::is_signed_with_blog_token() ) {
__( 'This endpoint requires Jetpack blog token authentication.', 'woocommerce' ),
* @param AgenticCheckoutSession $checkout_session Checkout session object.
public static function validate( AgenticCheckoutSession $checkout_session ): void {
$messages = $checkout_session->get_messages();
// Check if ready for payment.
$needs_shipping = $checkout_session->get_cart()->needs_shipping();
$has_address = WC()->customer && WC()->customer->get_shipping_address_1();
// Add info message if shipping is needed.
if ( $needs_shipping && ! $has_address ) {
__( 'Shipping address required.', 'woocommerce' ),
// Check if valid shipping method is selected (not just empty strings).
$chosen_methods = WC()->session ? WC()->session->get( SessionKey::CHOSEN_SHIPPING_METHODS ) : null;
$has_shipping = ! empty( $chosen_methods ) && ! empty( array_filter( $chosen_methods ) );
if ( $needs_shipping && ! $has_shipping ) {
__( 'No shipping method selected.', 'woocommerce' ),
'$.fulfillment_option_id'
* Calculate the status of the checkout session.
* @param AgenticCheckoutSession $checkout_session Checkout session object.
* @return string Status value.
public static function calculate_status( AgenticCheckoutSession $checkout_session ): string {
$wc_session = WC()->session;
if ( null === $wc_session ) {
return CheckoutSessionStatus::CANCELED;
if ( $wc_session->get( SessionKey::AGENTIC_CHECKOUT_COMPLETED_ORDER_ID ) ) {
return CheckoutSessionStatus::COMPLETED;
if ( $wc_session->get( SessionKey::AGENTIC_CHECKOUT_PAYMENT_IN_PROGRESS ) ) {
return CheckoutSessionStatus::IN_PROGRESS;
// Check for validation errors.
$checkout_session->get_messages()->has_errors()
// Once we switch to using the CartController everywhere, there should be no notices and need for this.
|| ! empty( wc_get_notices( 'error' ) )
return CheckoutSessionStatus::NOT_READY_FOR_PAYMENT;
return CheckoutSessionStatus::READY_FOR_PAYMENT;
* Get the agentic commerce payment gateway from available gateways.
* Finds the first gateway that supports agentic commerce and has the required methods.
* @param array $available_gateways Array of available payment gateways.
* @return \WC_Payment_Gateway|null The agentic commerce gateway or null if not found.
public static function get_agentic_commerce_gateway( $available_gateways ) {
if ( empty( $available_gateways ) ) {
foreach ( $available_gateways as $gateway ) {
if ( $gateway->supports( \Automattic\WooCommerce\Enums\PaymentGatewayFeature::AGENTIC_COMMERCE )
&& method_exists( $gateway, 'get_agentic_commerce_provider' )
&& method_exists( $gateway, 'get_agentic_commerce_payment_methods' )
* Whether the current request is within Agentic Commerce session.
public static function is_agentic_commerce_session(): bool {
$wc_session = WC()->session;
if ( null === $wc_session ) {
return ! empty( $wc_session->get( SessionKey::AGENTIC_CHECKOUT_SESSION_ID ) );