diff --git a/changelog/add-support-agentic_commerce-in-core b/changelog/add-support-agentic_commerce-in-core new file mode 100644 index 00000000000..6b0f131be9d --- /dev/null +++ b/changelog/add-support-agentic_commerce-in-core @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Support Agentic Commerce Protocol diff --git a/includes/class-payment-information.php b/includes/class-payment-information.php index 8548c671443..902b9dc8e54 100644 --- a/includes/class-payment-information.php +++ b/includes/class-payment-information.php @@ -16,7 +16,6 @@ use WCPay\Constants\Payment_Initiated_By; use WCPay\Constants\Payment_Capture_Type; use WCPay\Exceptions\Invalid_Payment_Method_Exception; -use WCPay\Payment_Methods\CC_Payment_Gateway; /** * Mostly a wrapper containing information on a single payment. @@ -127,19 +126,27 @@ class Payment_Information { */ private $error = null; + /** + * Whether the current order is processed by Agentic Commerce. + * + * @var bool + */ + private $is_agentic_commerce_request = false; + /** * Payment information constructor. * - * @param string $payment_method The ID of the payment method used for this payment. - * @param \WC_Order $order The order object. - * @param Payment_Type $payment_type The type of the payment. - * @param \WC_Payment_Token $token The payment token used for this payment. - * @param Payment_Initiated_By $payment_initiated_by Indicates whether the payment is merchant-initiated or customer-initiated. - * @param Payment_Capture_Type $manual_capture Indicates whether the payment will be only authorized or captured immediately. - * @param string $cvc_confirmation The CVC confirmation for this payment method. - * @param string $fingerprint The attached fingerprint. - * @param string $payment_method_stripe_id The Stripe ID of the payment method used for this payment. - * @param string $customer_id The WCPay Customer ID that owns the payment token. + * @param string $payment_method The ID of the payment method used for this payment. + * @param \WC_Order|null $order The order object. + * @param Payment_Type|null $payment_type The type of the payment. + * @param \WC_Payment_Token|null $token The payment token used for this payment. + * @param Payment_Initiated_By|null $payment_initiated_by Indicates whether the payment is merchant-initiated or customer-initiated. + * @param Payment_Capture_Type|null $manual_capture Indicates whether the payment will be only authorized or captured immediately. + * @param string|null $cvc_confirmation The CVC confirmation for this payment method. + * @param string $fingerprint The attached fingerprint. + * @param string|null $payment_method_stripe_id The Stripe ID of the payment method used for this payment. + * @param string|null $customer_id The WCPay Customer ID that owns the payment token. + * @param bool $is_agentic_commerce_request Whether the current order is processed by Agentic Commerce. * * @throws Invalid_Payment_Method_Exception When no payment method is found in the provided request. */ @@ -153,7 +160,8 @@ public function __construct( ?string $cvc_confirmation = null, string $fingerprint = '', ?string $payment_method_stripe_id = null, - ?string $customer_id = null + ?string $customer_id = null, + bool $is_agentic_commerce_request = false ) { if ( empty( $payment_method ) && empty( $token ) && ! \WC_Payments::is_network_saved_cards_enabled() ) { // If network-wide cards are enabled, a payment method or token may not be specified and the platform default one will be used. @@ -162,16 +170,17 @@ public function __construct( 'payment_method_not_provided' ); } - $this->payment_method = $payment_method; - $this->order = $order; - $this->token = $token; - $this->payment_initiated_by = $payment_initiated_by ?? Payment_Initiated_By::CUSTOMER(); - $this->manual_capture = $manual_capture ?? Payment_Capture_Type::AUTOMATIC(); - $this->payment_type = $payment_type ?? Payment_Type::SINGLE(); - $this->cvc_confirmation = $cvc_confirmation; - $this->fingerprint = $fingerprint; - $this->payment_method_stripe_id = $payment_method_stripe_id; - $this->customer_id = $customer_id; + $this->payment_method = $payment_method; + $this->order = $order; + $this->token = $token; + $this->payment_initiated_by = $payment_initiated_by ?? Payment_Initiated_By::CUSTOMER(); + $this->manual_capture = $manual_capture ?? Payment_Capture_Type::AUTOMATIC(); + $this->payment_type = $payment_type ?? Payment_Type::SINGLE(); + $this->cvc_confirmation = $cvc_confirmation; + $this->fingerprint = $fingerprint; + $this->payment_method_stripe_id = $payment_method_stripe_id; + $this->customer_id = $customer_id; + $this->is_agentic_commerce_request = $is_agentic_commerce_request; } /** @@ -266,7 +275,15 @@ public static function from_payment_request( ?Payment_Capture_Type $manual_capture = null, ?string $payment_method_stripe_id = null ): Payment_Information { - $payment_method = self::get_payment_method_from_request( $request ); + /** + * Psalm note: Class exists as a util in Woo core. + * + * @psalm-suppress UndefinedClass + */ + $is_agentic_commerce_request = method_exists( '\\Automattic\\WooCommerce\\StoreApi\\Utilities\\AgenticCheckoutUtils', 'is_agentic_commerce_session' ) + && \Automattic\WooCommerce\StoreApi\Utilities\AgenticCheckoutUtils::is_agentic_commerce_session(); + + $payment_method = self::get_payment_method_from_request( $request, $is_agentic_commerce_request ); $token = self::get_token_from_request( $request ); $cvc_confirmation = self::get_cvc_confirmation_from_request( $request ); $fingerprint = self::get_fingerprint_from_request( $request ); @@ -275,7 +292,7 @@ public static function from_payment_request( $order->add_meta_data( 'is_woopay', true, true ); $order->save_meta_data(); } - $payment_information = new Payment_Information( $payment_method, $order, $payment_type, $token, $payment_initiated_by, $manual_capture, $cvc_confirmation, $fingerprint, $payment_method_stripe_id ); + $payment_information = new Payment_Information( $payment_method, $order, $payment_type, $token, $payment_initiated_by, $manual_capture, $cvc_confirmation, $fingerprint, $payment_method_stripe_id, null, $is_agentic_commerce_request ); if ( self::PAYMENT_METHOD_ERROR === $payment_method ) { $error_message = $request['wcpay-payment-method-error-message'] ?? __( "We're not able to process this payment. Please try again later.", 'woocommerce-payments' ); @@ -291,11 +308,16 @@ public static function from_payment_request( * Extracts the payment method from the provided request. * * @param array $request Associative array containing payment request information. + * @param bool $is_agentic_commerce_request Whether the current order is processed by Agentic Commerce. * * @return string */ - public static function get_payment_method_from_request( array $request ): string { - foreach ( [ 'wcpay-payment-method', 'wcpay-payment-method-sepa' ] as $key ) { + public static function get_payment_method_from_request( array $request, bool $is_agentic_commerce_request = false ): string { + $keys_to_check = $is_agentic_commerce_request + ? [ 'wc-agentic_commerce-token' ] + : [ 'wcpay-payment-method', 'wcpay-payment-method-sepa' ]; + + foreach ( $keys_to_check as $key ) { if ( ! empty( $request[ $key ] ) ) { $normalized = wc_clean( $request[ $key ] ); return is_string( $normalized ) ? $normalized : ''; @@ -304,6 +326,15 @@ public static function get_payment_method_from_request( array $request ): string return ''; } + /** + * Whether this payment is processed by Agentic Commerce. + * + * @return bool + */ + public function is_agentic_commerce_request(): bool { + return $this->is_agentic_commerce_request; + } + /** * Extract the payment token from the provided request. * diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index 78780e977c0..26b9b46e59e 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -325,6 +325,7 @@ public function __construct( $this->supports = [ 'products', 'refunds', + 'agentic_commerce', ]; if ( 'card' !== $this->stripe_id ) { @@ -753,7 +754,9 @@ private function get_request_payment_data( \WP_REST_Request $request ) { } if ( ! empty( $request['payment_data'] ) ) { foreach ( $request['payment_data'] as $data ) { - $payment_data[ sanitize_key( $data['key'] ) ] = wc_clean( $data['value'] ); + if ( isset( $data['key'], $data['value'] ) ) { + $payment_data[ sanitize_key( $data['key'] ) ] = wc_clean( $data['value'] ); + } } } @@ -864,6 +867,24 @@ public function needs_setup() { return parent::needs_setup() || ! empty( $account_status['error'] ) || ! $account_status['paymentsEnabled']; } + /** + * Get the agentic commerce payment provider identifier. + * + * @return string Payment provider identifier. + */ + public function get_agentic_commerce_provider() { + return 'stripe'; + } + + /** + * Get the supported payment methods for agentic commerce. + * + * @return array Array of supported payment methods. + */ + public function get_agentic_commerce_payment_methods() { + return [ 'card' ]; + } + /** * Returns whether a store that is not in test mode needs to set https * in the checkout @@ -1127,8 +1148,14 @@ public function process_payment( $order_id ) { 'invalid_phone_number' ); } + + $payment_information = $this->prepare_payment_information( $order ); + // Check if session exists and we're currently not processing a WooPay request before instantiating `Fraud_Prevention_Service`. - if ( WC()->session && ! apply_filters( 'wcpay_is_woopay_store_api_request', false ) ) { + if ( WC()->session + && ! apply_filters( 'wcpay_is_woopay_store_api_request', false ) + && ! $payment_information->is_agentic_commerce_request() + ) { $fraud_prevention_service = Fraud_Prevention_Service::get_instance(); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized if ( $fraud_prevention_service->is_enabled() && ! $fraud_prevention_service->verify_token( $_POST['wcpay-fraud-prevention-token'] ?? null ) ) { @@ -1171,7 +1198,6 @@ public function process_payment( $order_id ) { return $check_existing_intention; } - $payment_information = $this->prepare_payment_information( $order ); return $this->process_payment_for_order( WC()->cart, $payment_information ); } catch ( Exception $e ) { // We set this variable to be used in following checks. @@ -1194,6 +1220,24 @@ public function process_payment( $order_id ) { if ( $blocked_by_fraud_rules ) { $this->order_service->mark_order_blocked_for_fraud( $order, '', Intent_Status::CANCELED ); + } elseif ( $e instanceof Process_Payment_Exception && 'rate_limiter_enabled' === $e->get_error_code() ) { + /** + * TODO: Move the contents of this into the Order_Service. + */ + $note = sprintf( + WC_Payments_Utils::esc_interpolated_html( + /* translators: %1: the failed payment amount */ + __( + 'A payment of %1$s failed to complete because of too many failed transactions. A rate limiter was enabled for the user to prevent more attempts temporarily.', + 'woocommerce-payments' + ), + [ + 'strong' => '', + ] + ), + WC_Payments_Explicit_Price_Formatter::get_explicit_price( wc_price( $order->get_total(), [ 'currency' => $order->get_currency() ] ), $order ) + ); + $order->add_order_note( $note ); } elseif ( ! empty( $payment_information ) ) { /** * TODO: Move the contents of this else into the Order_Service. @@ -1243,26 +1287,6 @@ public function process_payment( $order_id ) { $order->add_order_note( $note ); } - if ( $e instanceof Process_Payment_Exception && 'rate_limiter_enabled' === $e->get_error_code() ) { - /** - * TODO: Move the contents of this into the Order_Service. - */ - $note = sprintf( - WC_Payments_Utils::esc_interpolated_html( - /* translators: %1: the failed payment amount */ - __( - 'A payment of %1$s failed to complete because of too many failed transactions. A rate limiter was enabled for the user to prevent more attempts temporarily.', - 'woocommerce-payments' - ), - [ - 'strong' => '', - ] - ), - WC_Payments_Explicit_Price_Formatter::get_explicit_price( wc_price( $order->get_total(), [ 'currency' => $order->get_currency() ] ), $order ) - ); - $order->add_order_note( $note ); - } - // This allows WC to check if WP_DEBUG mode is enabled before returning previous Exception and expose Exception class name to frontend. add_filter( 'woocommerce_return_previous_exceptions', '__return_true' ); wc_add_notice( wp_strip_all_tags( WC_Payments_Utils::get_filtered_error_message( $e, $blocked_by_fraud_rules ) ), 'error' ); @@ -2117,6 +2141,8 @@ public function get_payment_method_types( $payment_information ): array { $order = $payment_information->get_order(); $order_id = $order instanceof WC_Order ? $order->get_id() : null; $payment_methods = $this->get_payment_methods_from_gateway_id( $token->get_gateway_id(), $order_id ); + } elseif ( $payment_information->is_agentic_commerce_request() ) { + $payment_methods = $this->get_agentic_commerce_payment_methods(); } return $payment_methods; diff --git a/includes/core/server/request/class-create-intention.php b/includes/core/server/request/class-create-intention.php index 917089eb5d7..64e9b9d2807 100644 --- a/includes/core/server/request/class-create-intention.php +++ b/includes/core/server/request/class-create-intention.php @@ -55,7 +55,7 @@ public function get_method(): string { */ public function set_payment_method( string $payment_method_id ) { // Including the 'card' prefix to support subscription renewals using legacy payment method IDs. - $this->validate_stripe_id( $payment_method_id, [ 'pm', 'src', 'card' ] ); + $this->validate_stripe_id( $payment_method_id, [ 'pm', 'src', 'card', 'spt' ] ); $this->set_param( 'payment_method', $payment_method_id ); } diff --git a/tests/unit/test-class-wc-payment-gateway-wcpay.php b/tests/unit/test-class-wc-payment-gateway-wcpay.php index 95a27f48e4c..d901bde4ffb 100644 --- a/tests/unit/test-class-wc-payment-gateway-wcpay.php +++ b/tests/unit/test-class-wc-payment-gateway-wcpay.php @@ -962,6 +962,9 @@ public function test_exception_will_be_thrown_if_phone_number_is_invalid() { $order = WC_Helper_Order::create_order(); $order->set_billing_phone( '+1123456789123456789123' ); $order->save(); + + $_POST['wcpay-payment-method'] = 'pm_mock'; + $result = $this->card_gateway->process_payment( $order->get_id() ); $this->assertEquals( 'fail', $result['result'] ); $error_notices = WC()->session->get( 'wc_notices' ); @@ -3381,6 +3384,8 @@ public function test_process_payment_caches_mimimum_amount_and_displays_error_up public function test_process_payment_rejects_if_missing_fraud_prevention_token() { $order = WC_Helper_Order::create_order(); + $_POST['wcpay-payment-method'] = 'pm_mock'; + $fraud_prevention_service_mock = $this->get_fraud_prevention_service_mock(); $fraud_prevention_service_mock @@ -3400,6 +3405,8 @@ public function test_process_payment_rejects_if_missing_fraud_prevention_token() public function test_process_payment_rejects_if_invalid_fraud_prevention_token() { $order = WC_Helper_Order::create_order(); + $_POST['wcpay-payment-method'] = 'pm_mock'; + $fraud_prevention_service_mock = $this->get_fraud_prevention_service_mock(); $fraud_prevention_service_mock @@ -3467,9 +3474,13 @@ public function test_process_payment_marks_order_as_blocked_for_fraud() { $error_message = "There's a problem with this payment. Please try again or use a different payment method."; + $mock_payment_information = $this->createMock( Payment_Information::class ); + $mock_payment_information->method( 'is_agentic_commerce_request' )->willReturn( false ); + $mock_wcpay_gateway ->expects( $this->once() ) - ->method( 'prepare_payment_information' ); + ->method( 'prepare_payment_information' ) + ->willReturn( $mock_payment_information ); $mock_wcpay_gateway ->expects( $this->once() ) ->method( 'process_payment_for_order' ) @@ -3538,9 +3549,13 @@ public function test_process_payment_marks_order_as_blocked_for_fraud_avs_mismat $error_message = "There's a problem with this payment. Please try again or use a different payment method."; + $mock_payment_information = $this->createMock( Payment_Information::class ); + $mock_payment_information->method( 'is_agentic_commerce_request' )->willReturn( false ); + $mock_wcpay_gateway ->expects( $this->once() ) - ->method( 'prepare_payment_information' ); + ->method( 'prepare_payment_information' ) + ->willReturn( $mock_payment_information ); $mock_wcpay_gateway ->expects( $this->once() ) ->method( 'process_payment_for_order' ) @@ -3611,9 +3626,13 @@ public function test_process_payment_marks_order_as_blocked_for_postal_code_mism $error_message = 'We couldn’t verify the postal code in your billing address. Make sure the information is current with your card issuing bank and try again.'; + $mock_payment_information = $this->createMock( Payment_Information::class ); + $mock_payment_information->method( 'is_agentic_commerce_request' )->willReturn( false ); + $mock_wcpay_gateway ->expects( $this->once() ) - ->method( 'prepare_payment_information' ); + ->method( 'prepare_payment_information' ) + ->willReturn( $mock_payment_information ); $mock_wcpay_gateway ->expects( $this->once() ) ->method( 'process_payment_for_order' ) @@ -3652,9 +3671,15 @@ public function test_process_payment_continues_if_valid_fraud_prevention_token() ->willReturn( false ); $mock_wcpay_gateway = $this->get_partial_mock_for_gateway( [ 'prepare_payment_information', 'process_payment_for_order' ] ); + + $mock_payment_information = $this->createMock( Payment_Information::class ); + $mock_payment_information->method( 'is_agentic_commerce_request' )->willReturn( false ); + $mock_wcpay_gateway ->expects( $this->once() ) - ->method( 'prepare_payment_information' ); + ->method( 'prepare_payment_information' ) + ->willReturn( $mock_payment_information ); + $mock_wcpay_gateway ->expects( $this->once() ) ->method( 'process_payment_for_order' ); @@ -3843,6 +3868,7 @@ private function get_partial_mock_for_gateway( array $methods = [], array $const */ public function test_no_payment_is_processed_for_woopay_preflight_check_request() { $_POST['is-woopay-preflight-check'] = true; + $_POST['wcpay-payment-method'] = 'pm_mock'; // Arrange: Create an order to test with. $order_data = [ @@ -3866,6 +3892,8 @@ public function test_no_payment_is_processed_for_woopay_preflight_check_request( public function test_process_payment_rate_limiter_enabled_throw_exception() { $order = WC_Helper_Order::create_order(); + $_POST['wcpay-payment-method'] = 'pm_mock'; + $this->mock_rate_limiter ->expects( $this->once() ) ->method( 'is_limited' )