diff --git a/.psalm/stubs.php b/.psalm/stubs.php index 0ac41fcd3..27484ec28 100644 --- a/.psalm/stubs.php +++ b/.psalm/stubs.php @@ -153,26 +153,3 @@ class WP_HTML_Tag_Processor { return ''; } } - -/** - * WooCommerce Session stubs for Psalm - */ -class WC_Session { - /** - * Get session cookie. - * - * @return string|false - */ - public function get_session_cookie() { - return ''; - } - - /** - * Set customer session cookie. - * - * @param bool $set Whether to set the cookie. - * @return void - */ - public function set_customer_session_cookie( $set ) { - } -} diff --git a/modules/ppcp-axo/src/Gateway/AxoGateway.php b/modules/ppcp-axo/src/Gateway/AxoGateway.php index b3b40d8e0..a522028e8 100644 --- a/modules/ppcp-axo/src/Gateway/AxoGateway.php +++ b/modules/ppcp-axo/src/Gateway/AxoGateway.php @@ -245,59 +245,16 @@ class AxoGateway extends WC_Payment_Gateway { ); } - // Check for stored 3DS errors. - $stored_error = $this->get_and_clear_stored_error(); - if ( $stored_error ) { - return array( - 'result' => 'failure', - 'message' => $stored_error['message'], - ); + // Check for tokens to determine if this is a 3DS return or initial payment. + // phpcs:ignore WordPress.Security.NonceVerification.Missing + $axo_nonce = wc_clean( wp_unslash( $_POST['axo_nonce'] ?? '' ) ); + // phpcs:ignore WordPress.Security.NonceVerification.Missing + $token_param = wc_clean( wp_unslash( $_GET['token'] ?? '' ) ); + + if ( empty( $axo_nonce ) && ! empty( $token_param ) ) { + return $this->process_3ds_return( $wc_order, $token_param ); } - $existing_order = $this->session_handler->order(); - - if ( $existing_order ) { - // Check if this session order belongs to current WC order and we're in 3DS context. - $session_order_belongs_to_current_wc_order = $this->session_order_matches_wc_order( $existing_order, $wc_order ); - $is_3ds_context = $this->is_3ds_context(); - - if ( $session_order_belongs_to_current_wc_order && $is_3ds_context ) { - // This is a legitimate 3DS return for the current order. - try { - /** - * This filter controls if the method 'process()' from OrderProcessor will be called. - */ - $process = apply_filters( 'woocommerce_paypal_payments_before_order_process', true, $this, $wc_order ); - if ( $process ) { - $this->order_processor->process_captured_and_authorized( $wc_order, $existing_order ); - } - - // Clear session after successful processing. - $this->session_handler->destroy_session_data(); - WC()->cart->empty_cart(); - - return array( - 'result' => 'success', - 'redirect' => $this->get_return_url( $wc_order ), - ); - - } catch ( Exception $exception ) { - // Handle 3DS processing failures with universal error approach. - // Clear session data since payment failed. - $this->session_handler->destroy_session_data(); - - return array( - 'result' => 'failure', - 'message' => $this->get_user_friendly_error_message( $exception ), - ); - } - } else { - // Session order doesn't belong to current WC order OR not 3DS context. - $this->session_handler->destroy_session_data(); - } - } - - // No existing order or cleared session - this is an initial payment. try { // phpcs:ignore WordPress.Security.NonceVerification.Missing $fastlane_member = wc_clean( wp_unslash( $_POST['fastlane_member'] ?? '' ) ); @@ -308,31 +265,25 @@ class AxoGateway extends WC_Payment_Gateway { } // The `axo_nonce` is not a WP nonce, but a card-token generated by the JS SDK. - // phpcs:ignore WordPress.Security.NonceVerification.Missing - $token = wc_clean( wp_unslash( $_POST['axo_nonce'] ?? '' ) ); - - // Enhanced token validation with universal error handling. - if ( empty( $token ) ) { - // Universal error return. + if ( empty( $axo_nonce ) ) { return array( 'result' => 'failure', - 'message' => $this->is_3ds_context() - ? __( 'Payment session expired. Please try your payment again.', 'woocommerce-paypal-payments' ) - : __( 'No payment token provided. Please try again.', 'woocommerce-paypal-payments' ), + 'message' => __( 'No payment token provided. Please try again.', 'woocommerce-paypal-payments' ), ); } - $order = $this->create_paypal_order( $wc_order, $token ); + $order = $this->create_paypal_order( $wc_order, $axo_nonce ); // Check if 3DS verification is required. $payer_action = $this->get_payer_action_url( $order ); - // If 3DS verification is required, store order and redirect. + // If 3DS verification is required, redirect with token in return URL. if ( $payer_action ) { - // Store the order in session before 3DS redirect. - $this->session_handler->replace_order( $order ); - - $return_url = home_url( WC_AJAX::get_endpoint( ReturnUrlEndpoint::ENDPOINT ) ); + $return_url = add_query_arg( + 'token', + $order->id(), + home_url( WC_AJAX::get_endpoint( ReturnUrlEndpoint::ENDPOINT ) ) + ); $redirect_url = add_query_arg( 'redirect_uri', @@ -359,7 +310,8 @@ class AxoGateway extends WC_Payment_Gateway { $this->order_processor->process_captured_and_authorized( $wc_order, $order ); } } catch ( Exception $exception ) { - // Error handling for initial payment failures. + // Error handling for payment failures. + $this->logger->error( '[AXO] Payment processing failed: ' . $exception->getMessage() ); return array( 'result' => 'failure', 'message' => $this->get_user_friendly_error_message( $exception ), @@ -375,20 +327,51 @@ class AxoGateway extends WC_Payment_Gateway { } /** - * Get and clear stored payment errors. + * Process 3DS return scenario. + * + * @param WC_Order $wc_order The WooCommerce order. + * @param string $token The PayPal order token. + * + * @return array */ - private function get_and_clear_stored_error() { - if ( ! WC()->session ) { - return null; + protected function process_3ds_return( WC_Order $wc_order, string $token ) : array { + try { + $paypal_order = $this->order_endpoint->order( $token ); + + if ( ! $paypal_order->status()->is( OrderStatus::COMPLETED ) ) { + return array( + 'result' => 'failure', + 'message' => __( '3D Secure authentication was not completed successfully. Please try again.', 'woocommerce-paypal-payments' ), + ); + } + + /** + * This filter controls if the method 'process()' from OrderProcessor will be called. + * So you can implement your own for example on subscriptions + * + * - true bool controls execution of 'OrderProcessor::process()' + * - $this \WC_Payment_Gateway + * - $wc_order \WC_Order + */ + $process = apply_filters( 'woocommerce_paypal_payments_before_order_process', true, $this, $wc_order ); + if ( $process ) { + $this->order_processor->process_captured_and_authorized( $wc_order, $paypal_order ); + } + + } catch ( Exception $exception ) { + $this->logger->error( '[AXO] 3DS return processing failed: ' . $exception->getMessage() ); + return array( + 'result' => 'failure', + 'message' => $this->get_user_friendly_error_message( $exception ), + ); } - $stored_error = WC()->session->get( 'ppcp_payment_error' ); - if ( $stored_error ) { - WC()->session->__unset( 'ppcp_payment_error' ); - return $stored_error; - } + WC()->cart->empty_cart(); - return null; + return array( + 'result' => 'success', + 'redirect' => $this->get_return_url( $wc_order ), + ); } /** @@ -423,56 +406,21 @@ class AxoGateway extends WC_Payment_Gateway { * Extract payer action URL from PayPal order. */ private function get_payer_action_url( Order $order ) { - foreach ( $order->links() as $link ) { - if ( $link->rel === 'payer-action' ) { - return $link->href; + $links = $order->links(); + + if ( ! $links ) { + return ''; + } + + foreach ( $links as $link ) { + if ( isset( $link->rel ) && $link->rel === 'payer-action' ) { + return $link->href ?? ''; } } + return ''; } - /** - * Check if session order belongs to current WC order. - * - * @param Order $paypal_order The PayPal order from session. - * @param WC_Order $wc_order The current WooCommerce order. - * @return bool - */ - private function session_order_matches_wc_order( Order $paypal_order, WC_Order $wc_order ): bool { - $paypal_custom_id = $paypal_order->purchase_units()[0]->custom_id() ?? ''; - $wc_order_id = (string) $wc_order->get_id(); - - return $paypal_custom_id === $wc_order_id; - } - - /** - * Check if we're in a 3DS return context. - * - * @return bool - */ - private function is_3ds_context(): bool { - // Check referer for PayPal. - $referer = $_SERVER['HTTP_REFERER'] ?? ''; - if ( strpos( $referer, 'paypal.com' ) !== false ) { - return true; - } - - // Check for 3DS-specific parameters. - $three_ds_params = array( 'liability_shift', 'state', 'code', 'authentication_state' ); - foreach ( $three_ds_params as $param ) { - if ( isset( $_GET[ $param ] ) ) { - return true; - } - } - - // Check if we're being called by ReturnUrlEndpoint. - if ( isset( $_GET['wc-ajax'] ) && $_GET['wc-ajax'] === 'ppc-return-url' ) { - return true; - } - - return false; - } - /** * Create a new PayPal order from the existing WC_Order instance. * @@ -505,7 +453,8 @@ class AxoGateway extends WC_Payment_Gateway { null, self::ID, array(), - $payment_source + $payment_source, + $wc_order ); } diff --git a/modules/ppcp-wc-gateway/src/Endpoint/ReturnUrlEndpoint.php b/modules/ppcp-wc-gateway/src/Endpoint/ReturnUrlEndpoint.php index 96d389613..1275622ab 100644 --- a/modules/ppcp-wc-gateway/src/Endpoint/ReturnUrlEndpoint.php +++ b/modules/ppcp-wc-gateway/src/Endpoint/ReturnUrlEndpoint.php @@ -88,71 +88,37 @@ class ReturnUrlEndpoint { WC()->session->set_customer_session_cookie( true ); } - // Check if we have a PayPal order in session (3DS return). - $order = $this->session_handler->order(); - - if ( $order ) { - try { - // Handle 3DS capture before processing. - $order = $this->handle_3ds_return( $order ); - - $wc_order_id = (int) $order->purchase_units()[0]->custom_id(); - $wc_order = wc_get_order( $wc_order_id ); - - if ( ! $wc_order || ! is_a( $wc_order, \WC_Order::class ) ) { - wc_add_notice( __( 'Order information is missing. Please try placing your order again.', 'woocommerce-paypal-payments' ), 'error' ); - wp_safe_redirect( wc_get_checkout_url() ); - exit(); - } - - $payment_gateway = $this->get_payment_gateway( $wc_order->get_payment_method() ); - if ( ! $payment_gateway ) { - wc_add_notice( __( 'Payment gateway is unavailable. Please try again or contact support.', 'woocommerce-paypal-payments' ), 'error' ); - wp_safe_redirect( wc_get_checkout_url() ); - exit(); - } - - $result = $payment_gateway->process_payment( $wc_order_id ); - - if ( isset( $result['result'] ) && $result['result'] === 'success' ) { - wp_safe_redirect( $result['redirect'] ); - exit(); - } - - wc_add_notice( __( 'Payment processing failed. Please try again or contact support.', 'woocommerce-paypal-payments' ), 'error' ); - wp_safe_redirect( wc_get_checkout_url() ); - exit(); - - } catch ( Exception $e ) { - wc_add_notice( __( 'There was an error processing your payment. Please try again or contact support.', 'woocommerce-paypal-payments' ), 'error' ); - wp_safe_redirect( wc_get_checkout_url() ); - exit(); - } - } - - // No order in session - handle regular PayPal returns. + // Check for token parameter - required for all returns. if ( ! isset( $_GET['token'] ) ) { wc_add_notice( __( 'Payment session expired. Please try placing your order again.', 'woocommerce-paypal-payments' ), 'error' ); wp_safe_redirect( wc_get_checkout_url() ); exit(); } - // Handle regular PayPal returns (non-3DS). - // phpcs:enable WordPress.Security.NonceVerification.Recommended $token = sanitize_text_field( wp_unslash( $_GET['token'] ) ); try { $order = $this->order_endpoint->order( $token ); } catch ( Exception $exception ) { + $this->logger->warning( "Return URL endpoint failed to fetch order $token: " . $exception->getMessage() ); wc_add_notice( __( 'Could not retrieve payment information. Please try again.', 'woocommerce-paypal-payments' ), 'error' ); wp_safe_redirect( wc_get_checkout_url() ); exit(); } - if ( $order->status()->is( OrderStatus::APPROVED ) || $order->status()->is( OrderStatus::COMPLETED ) ) { - $this->session_handler->replace_order( $order ); + // Handle 3DS completion if needed. + if ( $this->needs_3ds_completion( $order ) ) { + try { + $order = $this->complete_3ds_verification( $order ); + } catch ( Exception $e ) { + $this->logger->warning( "3DS completion failed for order $token: " . $e->getMessage() ); + wc_add_notice( $this->get_3ds_error_message( $e ), 'error' ); + wp_safe_redirect( wc_get_checkout_url() ); + exit(); + } } + // Get WooCommerce order ID. $wc_order_id = (int) $order->purchase_units()[0]->custom_id(); if ( ! $wc_order_id ) { // We cannot finish processing here without WC order, but at least go into the continuation mode. @@ -192,7 +158,9 @@ class ReturnUrlEndpoint { exit(); } + $this->logger->info( 'ReturnUrlEndpoint calling process_payment for gateway: ' . get_class( $payment_gateway ) ); $success = $payment_gateway->process_payment( $wc_order_id ); + $this->logger->info( 'ReturnUrlEndpoint process_payment result: ' . wp_json_encode( $success ) ); if ( isset( $success['result'] ) && 'success' === $success['result'] ) { add_filter( @@ -212,62 +180,70 @@ class ReturnUrlEndpoint { exit(); } + /** + * Check if order needs 3DS completion. + * + * @param mixed $order The PayPal order. + * @return bool + */ + private function needs_3ds_completion( $order ): bool { + // If order is still CREATED after 3DS redirect, it needs to be captured. + return $order->status()->is( OrderStatus::CREATED ); + } /** - * Handle 3DS return and capture order if needed. + * Complete 3DS verification by capturing the order. * * @param mixed $order The PayPal order. * @return mixed The processed order. + * @throws Exception When 3DS completion fails. */ - private function handle_3ds_return( $order ) { - // If order is still CREATED after 3DS, it needs to be captured. - if ( $order->status()->is( OrderStatus::CREATED ) ) { - try { - // Capture the order. - $captured_order = $this->order_endpoint->capture( $order ); + private function complete_3ds_verification( $order ) { + try { + // Capture the order. + $captured_order = $this->order_endpoint->capture( $order ); - // Check if capture actually succeeded vs. payment declined. - if ( $captured_order->status()->is( OrderStatus::COMPLETED ) ) { - // Update session with captured order. - $this->session_handler->replace_order( $captured_order ); - return $captured_order; - } else { - // Capture API succeeded but payment was declined. - throw new Exception( __( 'Payment was declined by the payment provider. Please try a different payment method.', 'woocommerce-paypal-payments' ) ); - } - } catch ( DomainException $e ) { - // Handle 3DS authentication failures (Test Case 4: Unavailable). - // Clear session data since authentication failed. - $this->session_handler->destroy_session_data(); - - // Use native WooCommerce error handling. - wc_add_notice( __( '3D Secure authentication was unavailable or failed. Please try a different payment method or contact your bank.', 'woocommerce-paypal-payments' ), 'error' ); - wp_safe_redirect( wc_get_checkout_url() ); - exit(); - - } catch ( RuntimeException $e ) { - if ( strpos( $e->getMessage(), 'declined' ) !== false || - strpos( $e->getMessage(), 'PAYMENT_DENIED' ) !== false || - strpos( $e->getMessage(), 'INSTRUMENT_DECLINED' ) !== false || - strpos( $e->getMessage(), 'Payment provider declined' ) !== false ) { - - $this->session_handler->destroy_session_data(); - - wc_add_notice( __( 'Your payment was declined after 3D Secure verification. Please try a different payment method or contact your bank.', 'woocommerce-paypal-payments' ), 'error' ); - wp_safe_redirect( wc_get_checkout_url() ); - exit(); - } - throw $e; - } catch ( Exception $e ) { - $this->session_handler->destroy_session_data(); - wc_add_notice( __( 'There was an error processing your payment. Please try again or contact support.', 'woocommerce-paypal-payments' ), 'error' ); - wp_safe_redirect( wc_get_checkout_url() ); - exit(); + // Check if capture actually succeeded vs. payment declined. + if ( $captured_order->status()->is( OrderStatus::COMPLETED ) ) { + return $captured_order; + } else { + // Capture API succeeded but payment was declined. + throw new Exception( __( 'Payment was declined by the payment provider. Please try a different payment method.', 'woocommerce-paypal-payments' ) ); } + } catch ( DomainException $e ) { + // Handle 3DS authentication failures. + throw new Exception( __( '3D Secure authentication was unavailable or failed. Please try a different payment method or contact your bank.', 'woocommerce-paypal-payments' ) ); + } catch ( RuntimeException $e ) { + if ( strpos( $e->getMessage(), 'declined' ) !== false || + strpos( $e->getMessage(), 'PAYMENT_DENIED' ) !== false || + strpos( $e->getMessage(), 'INSTRUMENT_DECLINED' ) !== false || + strpos( $e->getMessage(), 'Payment provider declined' ) !== false ) { + throw new Exception( __( 'Your payment was declined after 3D Secure verification. Please try a different payment method or contact your bank.', 'woocommerce-paypal-payments' ) ); + } + throw $e; + } + } + + /** + * Get user-friendly error message for 3DS failures. + * + * @param Exception $exception The exception. + * @return string + */ + private function get_3ds_error_message( Exception $exception ): string { + $error_message = $exception->getMessage(); + + if ( strpos( $error_message, '3D Secure' ) !== false ) { + return $error_message; } - return $order; + if ( strpos( $error_message, 'declined' ) !== false ) { + return __( 'Your payment was declined after 3D Secure verification. Please try a different payment method or contact your bank.', 'woocommerce-paypal-payments' ); + } + + return __( 'There was an error processing your payment. Please try again or contact support.', 'woocommerce-paypal-payments' ); } + /** * Gets the appropriate payment gateway for the given payment method. *