Create New Item
Item Type
File
Folder
Item Name
Search file in folder and subfolders...
Are you sure want to rename?
forbidals
/
wp-content
/
plugins
/
woocommerce
/
src
/
StoreApi
/
Utilities
:
AgenticCheckoutUtils.php
Advanced Search
Upload
New Item
Settings
Back
Back Up
Advanced Editor
Save
<?php declare(strict_types=1); 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() { return [ 'items' => [ 'description' => __( 'Line items to add to the cart.', 'woocommerce' ), 'type' => 'array', 'items' => [ 'type' => 'object', 'properties' => [ 'id' => [ 'description' => __( 'Product ID.', 'woocommerce' ), 'type' => 'string', ], 'quantity' => [ 'description' => __( 'Quantity.', 'woocommerce' ), 'type' => 'integer', 'minimum' => 1, ], ], 'required' => [ 'id', 'quantity' ], ], ], 'buyer' => [ 'description' => __( 'Buyer information.', 'woocommerce' ), 'type' => 'object', 'properties' => [ 'first_name' => [ 'description' => __( 'First name.', 'woocommerce' ), 'type' => 'string', ], 'last_name' => [ 'description' => __( 'Last name.', 'woocommerce' ), 'type' => 'string', ], 'email' => [ 'description' => __( 'Email address.', 'woocommerce' ), 'type' => 'string', 'format' => 'email', ], 'phone_number' => [ 'description' => __( 'Phone number.', 'woocommerce' ), 'type' => 'string', ], ], ], 'fulfillment_address' => [ 'description' => __( 'Fulfillment/shipping address.', 'woocommerce' ), 'type' => 'object', 'properties' => [ 'name' => [ 'description' => __( 'Full name.', 'woocommerce' ), 'type' => 'string', ], 'line_one' => [ 'description' => __( 'Address line 1.', 'woocommerce' ), 'type' => 'string', ], 'line_two' => [ 'description' => __( 'Address line 2.', 'woocommerce' ), 'type' => 'string', ], 'city' => [ 'description' => __( 'City.', 'woocommerce' ), 'type' => 'string', ], 'state' => [ 'description' => __( 'State/province.', 'woocommerce' ), 'type' => 'string', ], 'country' => [ 'description' => __( 'Country code (ISO 3166-1 alpha-2).', 'woocommerce' ), 'type' => 'string', ], 'postal_code' => [ 'description' => __( 'Postal/ZIP code.', 'woocommerce' ), 'type' => 'string', ], ], '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( 'invalid_product_id', __( 'Product ID must be numeric.', 'woocommerce' ), '$.items[' . $item_index . '].id' ); } $product_id = (int) $item['id']; $quantity = (int) $item['quantity']; try { $cart_controller->add_to_cart( [ 'id' => $product_id, 'quantity' => $quantity, ] ); } catch ( RouteException $exception ) { $message = wp_specialchars_decode( $exception->getMessage(), ENT_QUOTES ); $param = '$.items[' . $item_index . ']'; $message_error = null; // 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 ); break; } if ( null !== $message_error ) { $messages->add( $message_error ); } else { // 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 ); } } } return null; } /** * 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 ); } $customer->save(); } /** * 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] : ''; // Set shipping names. $customer->set_shipping_first_name( $first_name ); $customer->set_shipping_last_name( $last_name ); } else { // 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 ); } $customer->save(); } /** * 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( '' ); $customer->save(); } /** * 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] : ''; // Set billing names. $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 ); $customer->save(); } /** * 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' ); if ( $request_id ) { $response->header( 'Request-Id', $request_id ); } return $response; } /** * Check if the Agentic Checkout feature is enabled and request is authorized. * * Validates bearer token against registered agents in the agent registry. * * @param \WP_REST_Request $request Request object. * @return bool|\WP_Error True if authorized, WP_Error otherwise. */ public static function is_authorized( $request = null ) { if ( null === $request ) { return new \WP_Error( 'invalid_request', __( 'Invalid request object.', 'woocommerce' ), array( 'status' => 400, 'type' => 'invalid_request', 'code' => 'invalid_request', ) ); } $auth_header = $request->get_header( 'Authorization' ); if ( empty( $auth_header ) || 0 !== stripos( $auth_header, 'Bearer ' ) ) { return new \WP_Error( 'invalid_request', __( 'Invalid authorization.', 'woocommerce' ), array( 'status' => 400, 'type' => 'invalid_request', 'code' => 'invalid_authorization_format', ) ); } $provided_token = trim( substr( $auth_header, 7 ) ); // "Bearer " is 7 characters if ( empty( $provided_token ) ) { return new \WP_Error( 'invalid_request', __( 'Invalid authorization.', 'woocommerce' ), array( 'status' => 400, 'type' => 'invalid_request', 'code' => 'invalid_authorization_format', ) ); } $registry = get_option( \Automattic\WooCommerce\Internal\Admin\Agentic\AgenticSettingsPage::REGISTRY_OPTION, array() ); $authenticated_provider = null; // Check each provider's bearer token. foreach ( $registry as $provider_id => $provider_config ) { if ( ! is_array( $provider_config ) || empty( $provider_config['bearer_token'] ) ) { continue; } if ( wp_check_password( $provided_token, $provider_config['bearer_token'] ) ) { // Store and continue checking to minimize timing attack. $authenticated_provider = $provider_id; } } if ( null !== $authenticated_provider ) { if ( WC()->session ) { WC()->session->set( SessionKey::AGENTIC_CHECKOUT_PROVIDER_ID, $authenticated_provider ); } return true; } return new \WP_Error( 'invalid_request', __( 'Invalid authorization.', 'woocommerce' ), array( 'status' => 400, 'type' => 'invalid_request', 'code' => 'authentication_failed', ) ); } /** * Validates a session. * * @param AgenticCheckoutSession $checkout_session Checkout session object. * @return void */ 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 ) { $messages->add( MessageError::missing( __( 'Shipping address required.', 'woocommerce' ), '$.fulfillment_address' ) ); } // 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 ) { $messages->add( MessageError::missing( __( '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. if ( $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 ) ) { return null; } 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' ) ) { return $gateway; } } return null; } /** * Whether the current request is within Agentic Commerce session. * * @return bool */ public static function is_agentic_commerce_session(): bool { $wc_session = WC()->session; if ( null === $wc_session ) { return false; } return ! empty( $wc_session->get( SessionKey::AGENTIC_CHECKOUT_SESSION_ID ) ); } }