Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog/add-support-agentic_commerce-in-core
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: add

Support Agentic Commerce Protocol
83 changes: 57 additions & 26 deletions includes/class-payment-information.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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.
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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 );
Expand All @@ -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' );
Expand All @@ -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 : '';
Expand All @@ -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.
*
Expand Down
72 changes: 49 additions & 23 deletions includes/class-wc-payment-gateway-wcpay.php
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ public function __construct(
$this->supports = [
'products',
'refunds',
'agentic_commerce',
];

if ( 'card' !== $this->stripe_id ) {
Expand Down Expand Up @@ -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'] );
}
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ) ) {
Expand Down Expand Up @@ -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.
Expand All @@ -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() ) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I needed to move this specific handling of rate_limiter_enabled exception because of:

  1. the move of $payment_information = $this->prepare_payment_information( $order ); to the initial phase of process_payment.
  2. since this handling is more specific than the next one ! empty( $payment_information ), I would like to move it here, otherwise, two failure notes will be added.

/**
* 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 <strong>failed</strong> to complete because of too many failed transactions. A rate limiter was enabled for the user to prevent more attempts temporarily.',
'woocommerce-payments'
),
[
'strong' => '<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.
Expand Down Expand Up @@ -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 <strong>failed</strong> to complete because of too many failed transactions. A rate limiter was enabled for the user to prevent more attempts temporarily.',
'woocommerce-payments'
),
[
'strong' => '<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' );
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion includes/core/server/request/class-create-intention.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}

Expand Down
36 changes: 32 additions & 4 deletions tests/unit/test-class-wc-payment-gateway-wcpay.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' );
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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' )
Expand Down Expand Up @@ -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' )
Expand Down Expand Up @@ -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' )
Expand Down Expand Up @@ -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' );
Expand Down Expand Up @@ -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 = [
Expand All @@ -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' )
Expand Down
Loading