Merge pull request #3163 from woocommerce/PCP-4156-implement-3ds-for-google-pay

Implement 3D secure check for Google Pay (4156)
This commit is contained in:
Emili Castells 2025-05-05 15:55:39 +02:00 committed by GitHub
commit 89e847bc52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 336 additions and 173 deletions

View file

@ -1,3 +1,18 @@
const initiateRedirect = ( successUrl ) => {
/**
* Notice how this step initiates a redirect to a new page using a plain
* URL as new location. This process does not send any details about the
* approved order or billed customer.
*
* The redirect will start after a short delay, giving the calling method
* time to process the return value of the `await onApprove()` call.
*/
setTimeout( () => {
window.location.href = successUrl;
}, 200 );
};
const onApprove = ( context, errorHandler ) => {
return ( data, actions ) => {
const canCreateOrder =
@ -28,24 +43,13 @@ const onApprove = ( context, errorHandler ) => {
.then( ( approveData ) => {
if ( ! approveData.success ) {
errorHandler.genericError();
return actions.restart().catch( ( err ) => {
return actions.restart().catch( () => {
errorHandler.genericError();
} );
}
const orderReceivedUrl = approveData.data?.order_received_url;
/**
* Notice how this step initiates a redirect to a new page using a plain
* URL as new location. This process does not send any details about the
* approved order or billed customer.
* Also, due to the redirect starting _instantly_ there should be no other
* logic scheduled after calling `await onApprove()`;
*/
window.location.href = orderReceivedUrl
? orderReceivedUrl
: context.config.redirect;
initiateRedirect( orderReceivedUrl || context.config.redirect );
} );
};
};

View file

@ -6,13 +6,14 @@
* @package WooCommerce\PayPalCommerce\Button\Endpoint
*/
declare(strict_types=1);
declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\Button\Endpoint;
use Exception;
use Psr\Log\LoggerInterface;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Order;
use WooCommerce\PayPalCommerce\ApiClient\Entity\OrderStatus;
use WooCommerce\PayPalCommerce\ApiClient\Exception\PayPalApiException;
use WooCommerce\PayPalCommerce\ApiClient\Helper\DccApplies;
@ -24,7 +25,6 @@ use WooCommerce\PayPalCommerce\Button\Helper\WooCommerceOrderCreator;
use WooCommerce\PayPalCommerce\Session\SessionHandler;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings;
use WooCommerce\PayPalCommerce\WcSubscriptions\Helper\SubscriptionHelper;
/**
* Class ApproveOrderEndpoint
@ -115,17 +115,17 @@ class ApproveOrderEndpoint implements EndpointInterface {
/**
* ApproveOrderEndpoint constructor.
*
* @param RequestData $request_data The request data helper.
* @param OrderEndpoint $order_endpoint The order endpoint.
* @param SessionHandler $session_handler The session handler.
* @param ThreeDSecure $three_d_secure The 3d secure helper object.
* @param Settings $settings The settings.
* @param DccApplies $dcc_applies The DCC applies object.
* @param OrderHelper $order_helper The order helper.
* @param RequestData $request_data The request data helper.
* @param OrderEndpoint $order_endpoint The order endpoint.
* @param SessionHandler $session_handler The session handler.
* @param ThreeDSecure $three_d_secure The 3d secure helper object.
* @param Settings $settings The settings.
* @param DccApplies $dcc_applies The DCC applies object.
* @param OrderHelper $order_helper The order helper.
* @param bool $final_review_enabled Whether the final review is enabled.
* @param PayPalGateway $gateway The WC gateway.
* @param WooCommerceOrderCreator $wc_order_creator The WooCommerce order creator.
* @param LoggerInterface $logger The logger.
* @param PayPalGateway $gateway The WC gateway.
* @param WooCommerceOrderCreator $wc_order_creator The WooCommerce order creator.
* @param LoggerInterface $logger The logger.
*/
public function __construct(
RequestData $request_data,
@ -159,7 +159,7 @@ class ApproveOrderEndpoint implements EndpointInterface {
*
* @return string
*/
public static function nonce(): string {
public static function nonce() : string {
return self::ENDPOINT;
}
@ -169,9 +169,9 @@ class ApproveOrderEndpoint implements EndpointInterface {
* @return bool
* @throws RuntimeException When order not found or handling failed.
*/
public function handle_request(): bool {
public function handle_request() : bool {
try {
$data = $this->request_data->read_request( $this->nonce() );
$data = $this->request_data->read_request( self::nonce() );
if ( ! isset( $data['order_id'] ) ) {
throw new RuntimeException(
__( 'No order id given', 'woocommerce-paypal-payments' )
@ -181,6 +181,7 @@ class ApproveOrderEndpoint implements EndpointInterface {
$order = $this->api_endpoint->order( $data['order_id'] );
$payment_source = $order->payment_source();
if ( $payment_source && $payment_source->name() === 'card' ) {
if ( $this->settings->has( 'disable_cards' ) ) {
$disabled_cards = (array) $this->settings->get( 'disable_cards' );
@ -199,29 +200,23 @@ class ApproveOrderEndpoint implements EndpointInterface {
);
}
}
$proceed = $this->threed_secure->proceed_with_order( $order );
if ( ThreeDSecure::RETRY === $proceed ) {
throw new RuntimeException(
__(
'Something went wrong. Please try again.',
'woocommerce-paypal-payments'
)
);
}
if ( ThreeDSecure::REJECT === $proceed ) {
throw new RuntimeException(
__(
'Unfortunately, we can\'t accept your card. Please choose a different payment method.',
'woocommerce-paypal-payments'
)
);
}
// This check will either pass, or throw an exception.
$this->verify_three_d_secure( $order );
$this->session_handler->replace_order( $order );
// Exit the request early.
wp_send_json_success();
}
if ( $this->order_helper->contains_physical_goods( $order ) && ! $order->status()->is( OrderStatus::APPROVED ) && ! $order->status()->is( OrderStatus::CREATED ) ) {
// Verify 3DS details. Throws an error when security check fails.
$this->verify_three_d_secure( $order );
$is_ready = $order->status()->is( OrderStatus::APPROVED )
|| $order->status()->is( OrderStatus::CREATED );
if ( ! $is_ready && $this->order_helper->contains_physical_goods( $order ) ) {
$message = sprintf(
// translators: %s is the id of the order.
__( 'Order %s is not ready for processing yet.', 'woocommerce-paypal-payments' ),
@ -250,6 +245,7 @@ class ApproveOrderEndpoint implements EndpointInterface {
wp_send_json_success( array( 'order_received_url' => $order_received_url ) );
}
wp_send_json_success();
return true;
} catch ( Exception $error ) {
$this->logger->error( 'Order approve failed: ' . $error->getMessage() );
@ -262,6 +258,7 @@ class ApproveOrderEndpoint implements EndpointInterface {
'details' => is_a( $error, PayPalApiException::class ) ? $error->details() : array(),
)
);
return false;
}
}
@ -271,10 +268,79 @@ class ApproveOrderEndpoint implements EndpointInterface {
*
* @return void
*/
protected function toggle_final_review_enabled_setting(): void {
protected function toggle_final_review_enabled_setting() : void {
// TODO new-ux: This flag must also be updated in the new settings.
$final_review_enabled_setting = $this->settings->has( 'blocks_final_review_enabled' ) && $this->settings->get( 'blocks_final_review_enabled' );
$this->settings->set( 'blocks_final_review_enabled', ! $final_review_enabled_setting );
$this->settings->persist();
}
/**
* Performs a 3DS check to verify the payment is not rejected from PayPal side.
*
* This method only checks, if the payment was rejected:
*
* - No 3DS details are present: The payment can proceed.
* - 3DS details present but no rejected: Payment can proceed.
* - 3DS details with a clear rejected: Payment fails.
*
* @param Order $order The PayPal order to inspect.
* @throws RuntimeException When the 3DS check was rejected.
*/
protected function verify_three_d_secure( Order $order ) : void {
$payment_source = $order->payment_source();
if ( ! $payment_source ) {
// Missing 3DS details.
return;
}
$proceed = ThreeDSecure::NO_DECISION;
$order_status = $order->status();
$source_name = $payment_source->name();
/**
* For GooglePay (and possibly other payment sources) we check the order
* status, as it will clearly indicate if verification is needed.
*
* Note: PayPal is currently investigating this case.
* Maybe the order status is wrong and should be ACCEPTED, in that case,
* we could drop the condition and always run proceed_with_order().
*/
if ( $order_status->is( OrderStatus::PAYER_ACTION_REQUIRED ) ) {
$proceed = $this->threed_secure->proceed_with_order( $order );
} elseif ( 'card' === $source_name ) {
// For credit cards, we also check the 3DS response.
$proceed = $this->threed_secure->proceed_with_order( $order );
}
// Handle the verification result based on the proceed value.
switch ( $proceed ) {
case ThreeDSecure::PROCEED:
// Check was successful.
return;
case ThreeDSecure::NO_DECISION:
// No rejection. Let's proceed with the payment.
return;
case ThreeDSecure::RETRY:
// Rejection case 1, verification can be retried.
throw new RuntimeException(
__(
'Something went wrong. Please try again.',
'woocommerce-paypal-payments'
)
);
case ThreeDSecure::REJECT:
// Rejection case 2, payment was rejected.
throw new RuntimeException(
__(
'Unfortunately, we can\'t accept your card. Please choose a different payment method.',
'woocommerce-paypal-payments'
)
);
}
}
}

View file

@ -5,7 +5,7 @@
* @package WooCommerce\PayPalCommerce\Button\Helper
*/
declare(strict_types=1);
declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\Button\Helper;
@ -19,49 +19,49 @@ use WooCommerce\PayPalCommerce\ApiClient\Factory\CardAuthenticationResultFactory
*/
class ThreeDSecure {
const NO_DECISION = 0;
const PROCEED = 1;
const REJECT = 2;
const RETRY = 3;
public const NO_DECISION = 0;
public const PROCEED = 1;
public const REJECT = 2;
public const RETRY = 3;
/**
* Card authentication result factory.
*
* @var CardAuthenticationResultFactory
*/
private $card_authentication_result_factory;
private CardAuthenticationResultFactory $authentication_result;
/**
* The logger.
*
* @var LoggerInterface
*/
protected $logger;
protected LoggerInterface $logger;
/**
* ThreeDSecure constructor.
*
* @param CardAuthenticationResultFactory $card_authentication_result_factory Card authentication result factory.
* @param LoggerInterface $logger The logger.
* @param CardAuthenticationResultFactory $authentication_factory Card authentication result factory.
* @param LoggerInterface $logger The logger.
*/
public function __construct(
CardAuthenticationResultFactory $card_authentication_result_factory,
CardAuthenticationResultFactory $authentication_factory,
LoggerInterface $logger
) {
$this->logger = $logger;
$this->card_authentication_result_factory = $card_authentication_result_factory;
$this->logger = $logger;
$this->authentication_result = $authentication_factory;
}
/**
* Determine, how we proceed with a given order.
*
* @link https://developer.paypal.com/docs/business/checkout/add-capabilities/3d-secure/#authenticationresult
* @link https://developer.paypal.com/docs/checkout/advanced/customize/3d-secure/response-parameters/
*
* @param Order $order The order for which the decision is needed.
*
* @return int
*/
public function proceed_with_order( Order $order ): int {
public function proceed_with_order( Order $order ) : int {
do_action( 'woocommerce_paypal_payments_three_d_secure_before_check', $order );
@ -70,29 +70,44 @@ class ThreeDSecure {
return $this->return_decision( self::NO_DECISION, $order );
}
if ( ! ( $payment_source->properties()->brand ?? '' ) ) {
return $this->return_decision( self::NO_DECISION, $order );
if ( isset( $payment_source->properties()->card ) ) {
/**
* GooglePay provides the credit-card details and authentication-result
* via the "cards" attribute. We assume, that this structure is also
* used for other payment methods that support 3DS.
*/
$card_properties = $payment_source->properties()->card;
} else {
/**
* For regular credit card payments (via PayPal) we get all details
* directly in the payment_source properties.
*/
$card_properties = $payment_source->properties();
}
if ( ! ( $payment_source->properties()->authentication_result ?? '' ) ) {
if ( empty( $card_properties->brand ) ) {
return $this->return_decision( self::NO_DECISION, $order );
}
$authentication_result = $payment_source->properties()->authentication_result ?? null;
if ( $authentication_result ) {
$result = $this->card_authentication_result_factory->from_paypal_response( $authentication_result );
if ( empty( $card_properties->authentication_result ) ) {
return $this->return_decision( self::NO_DECISION, $order );
}
$this->logger->info( '3DS Authentication Result: ' . wc_print_r( $result->to_array(), true ) );
$result = $this->authentication_result->from_paypal_response( $card_properties->authentication_result );
$liability = $result->liability_shift();
if ( $result->liability_shift() === AuthResult::LIABILITY_SHIFT_POSSIBLE ) {
return $this->return_decision( self::PROCEED, $order );
}
$this->logger->info( '3DS Authentication Result: ' . wc_print_r( $result->to_array(), true ) );
if ( $result->liability_shift() === AuthResult::LIABILITY_SHIFT_UNKNOWN ) {
return $this->return_decision( self::RETRY, $order );
}
if ( $result->liability_shift() === AuthResult::LIABILITY_SHIFT_NO ) {
return $this->return_decision( $this->no_liability_shift( $result ), $order );
}
if ( $liability === AuthResult::LIABILITY_SHIFT_POSSIBLE ) {
return $this->return_decision( self::PROCEED, $order );
}
if ( $liability === AuthResult::LIABILITY_SHIFT_UNKNOWN ) {
return $this->return_decision( self::RETRY, $order );
}
if ( $liability === AuthResult::LIABILITY_SHIFT_NO ) {
return $this->return_decision( $this->no_liability_shift( $result ), $order );
}
return $this->return_decision( self::NO_DECISION, $order );
@ -102,12 +117,13 @@ class ThreeDSecure {
* Processes and returns a ThreeD secure decision.
*
* @param int $decision The ThreeD secure decision.
* @param Order $order The PayPal Order object.
* @param Order $order The PayPal Order object.
* @return int
*/
public function return_decision( int $decision, Order $order ) {
public function return_decision( int $decision, Order $order ) : int {
$decision = apply_filters( 'woocommerce_paypal_payments_three_d_secure_decision', $decision, $order );
do_action( 'woocommerce_paypal_payments_three_d_secure_after_check', $order, $decision );
return $decision;
}
@ -118,42 +134,40 @@ class ThreeDSecure {
*
* @return int
*/
private function no_liability_shift( AuthResult $result ): int {
private function no_liability_shift( AuthResult $result ) : int {
$enrollment = $result->enrollment_status();
$authentication = $result->authentication_result();
if (
$result->enrollment_status() === AuthResult::ENROLLMENT_STATUS_BYPASS
&& ! $result->authentication_result()
) {
return self::PROCEED;
}
if (
$result->enrollment_status() === AuthResult::ENROLLMENT_STATUS_UNAVAILABLE
&& ! $result->authentication_result()
) {
return self::PROCEED;
}
if (
$result->enrollment_status() === AuthResult::ENROLLMENT_STATUS_NO
&& ! $result->authentication_result()
) {
return self::PROCEED;
if ( ! $authentication ) {
if ( $enrollment === AuthResult::ENROLLMENT_STATUS_BYPASS ) {
return self::PROCEED;
}
if ( $enrollment === AuthResult::ENROLLMENT_STATUS_UNAVAILABLE ) {
return self::PROCEED;
}
if ( $enrollment === AuthResult::ENROLLMENT_STATUS_NO ) {
return self::PROCEED;
}
}
if ( $result->authentication_result() === AuthResult::AUTHENTICATION_RESULT_REJECTED ) {
if ( $authentication === AuthResult::AUTHENTICATION_RESULT_REJECTED ) {
return self::REJECT;
}
if ( $result->authentication_result() === AuthResult::AUTHENTICATION_RESULT_NO ) {
if ( $authentication === AuthResult::AUTHENTICATION_RESULT_NO ) {
return self::REJECT;
}
if ( $result->authentication_result() === AuthResult::AUTHENTICATION_RESULT_UNABLE ) {
if ( $authentication === AuthResult::AUTHENTICATION_RESULT_UNABLE ) {
return self::RETRY;
}
if ( ! $result->authentication_result() ) {
if ( ! $authentication ) {
return self::RETRY;
}
return self::NO_DECISION;
}
}