From 134ca8b1c936030f8dbfcd03737db6cf83feb84b Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 22 Dec 2025 18:25:32 -0700 Subject: [PATCH] Fix PayPal checkout loop and improve error handling (#193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem When PayPal returns an error (e.g., error 10002 "Security header is not valid" due to invalid API credentials), the checkout would silently loop back to the /register page instead of showing an error message to the user. ## Root Causes Fixed 1. **get_checkout_details() not checking ACK=Failure**: The function only checked HTTP status code (200 OK) but not the PayPal ACK field. When API credentials were invalid, PayPal would return HTTP 200 with ACK=Failure, which was being treated as a valid response. 2. **confirmation_form() returned errors instead of displaying them**: When an error occurred, the function returned an error string but the calling code used output buffering and ignored return values, so errors were never shown. 3. **Missing validation for required data**: No validation for pending_payment, customer, or membership objects before trying to render the confirmation form. ## Changes ### get_checkout_details() - Added ACK=Failure check to detect PayPal API errors - Returns WP_Error on API failures instead of partial data - Added comprehensive logging for all error conditions ### confirmation_form() - Changed return type from string to void - Uses wp_die() to display errors instead of returning strings - Added validation for pending_payment, customer, and membership - Added error logging ### process_confirmation() - Added proper WP_Error handling for get_checkout_details() response - Improved all error messages with better context - Added logging for all error conditions - Uses proper wp_die() calls with back_link option ### create_recurring_profile() & complete_single_payment() - Added logging for API call start, success, and failure - Added ACK=FailureWithWarning check (not just Failure) - Improved error messages to include PayPal error code and message - Fixed HTTP response code from 401 to 200 for proper back links ## Result Users now see clear error messages instead of being silently redirected. All PayPal API errors are logged for debugging. Fixes #193 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- inc/gateways/class-paypal-gateway.php | 214 +++++++++++++++++++++++--- 1 file changed, 192 insertions(+), 22 deletions(-) diff --git a/inc/gateways/class-paypal-gateway.php b/inc/gateways/class-paypal-gateway.php index 3953ab01..6ae7d335 100644 --- a/inc/gateways/class-paypal-gateway.php +++ b/inc/gateways/class-paypal-gateway.php @@ -830,8 +830,39 @@ public function process_confirmation(): void { */ $details = $this->get_checkout_details(wu_request('token')); - if (empty($details)) { - wp_die(esc_html__('PayPal token no longer valid.', 'ultimate-multisite')); + /* + * Check if we got a WP_Error back from PayPal (e.g., invalid credentials, expired token). + */ + if (is_wp_error($details)) { + wu_log_add('paypal', 'PayPal confirmation failed: ' . $details->get_error_message(), LogLevel::ERROR); + + wp_die( + esc_html( + sprintf( + // translators: %s is the PayPal error message. + __('PayPal Error: %s', 'ultimate-multisite'), + $details->get_error_message() + ) + ), + esc_html__('PayPal Error', 'ultimate-multisite'), + [ + 'back_link' => true, + 'response' => '200', + ] + ); + } + + if (empty($details) || ! is_array($details)) { + wu_log_add('paypal', 'PayPal confirmation failed: token no longer valid or empty response', LogLevel::ERROR); + + wp_die( + esc_html__('PayPal token no longer valid. Please try again.', 'ultimate-multisite'), + esc_html__('PayPal Error', 'ultimate-multisite'), + [ + 'back_link' => true, + 'response' => '200', + ] + ); } /* @@ -845,7 +876,16 @@ public function process_confirmation(): void { * Bail. */ if (empty($payment)) { - wp_die(esc_html__('Pending payment does not exist.', 'ultimate-multisite')); + wu_log_add('paypal', 'PayPal confirmation failed: pending payment not found', LogLevel::ERROR); + + wp_die( + esc_html__('Pending payment does not exist. Please try again or contact support.', 'ultimate-multisite'), + esc_html__('Payment Error', 'ultimate-multisite'), + [ + 'back_link' => true, + 'response' => '200', + ] + ); } /* @@ -857,7 +897,16 @@ public function process_confirmation(): void { $original_cart = $payment->get_meta('wu_original_cart'); if (empty($original_cart)) { - wp_die(esc_html__('Original cart does not exist.', 'ultimate-multisite')); + wu_log_add('paypal', 'PayPal confirmation failed: original cart not found for payment ' . $payment->get_id(), LogLevel::ERROR); + + wp_die( + esc_html__('Original cart does not exist. Please try again or contact support.', 'ultimate-multisite'), + esc_html__('Cart Error', 'ultimate-multisite'), + [ + 'back_link' => true, + 'response' => '200', + ] + ); } /* @@ -869,9 +918,16 @@ public function process_confirmation(): void { $is_recurring = $original_cart->has_recurring(); if (empty($membership) || empty($customer)) { - $error = new \WP_Error('no-membership', esc_html__('Missing membership or customer data.', 'ultimate-multisite')); - - wp_die($error); // phpcs:ignore WordPress.Security.EscapeOutput + wu_log_add('paypal', 'PayPal confirmation failed: missing membership or customer for payment ' . $payment->get_id(), LogLevel::ERROR); + + wp_die( + esc_html__('Missing membership or customer data. Please try again or contact support.', 'ultimate-multisite'), + esc_html__('Data Error', 'ultimate-multisite'), + [ + 'back_link' => true, + 'response' => '200', + ] + ); } if ($should_auto_renew && $is_recurring) { @@ -1274,6 +1330,8 @@ protected function create_recurring_profile($details, $cart, $payment, $membersh $args['TOTALBILLINGCYCLES'] = $membership->get_billing_cycles() - $membership->get_times_billed(); } + wu_log_add('paypal', sprintf('Creating recurring payment profile for payment #%d', $payment->get_id())); + $request = wp_remote_post( $this->api_endpoint, [ @@ -1284,7 +1342,16 @@ protected function create_recurring_profile($details, $cart, $payment, $membersh ); if (is_wp_error($request)) { - wp_die(esc_html($request->get_error_message())); + wu_log_add('paypal', 'CreateRecurringPaymentsProfile request failed: ' . $request->get_error_message(), LogLevel::ERROR); + + wp_die( + esc_html($request->get_error_message()), + esc_html__('PayPal Error', 'ultimate-multisite'), + [ + 'back_link' => true, + 'response' => '200', + ] + ); } $body = wp_remote_retrieve_body($request); @@ -1300,8 +1367,21 @@ protected function create_recurring_profile($details, $cart, $payment, $membersh wp_parse_str($body, $body); } - if ('failure' === strtolower((string) $body['ACK'])) { - wp_die(esc_html($body['L_LONGMESSAGE0']), esc_html($body['L_ERRORCODE0'])); + if ('failure' === strtolower((string) $body['ACK']) || 'failurewithwarning' === strtolower((string) $body['ACK'])) { + $error_code = $body['L_ERRORCODE0'] ?? 'unknown'; + $error_message = $body['L_LONGMESSAGE0'] ?? __('Unknown PayPal error', 'ultimate-multisite'); + + wu_log_add('paypal', sprintf('CreateRecurringPaymentsProfile failed with error %s: %s', $error_code, $error_message), LogLevel::ERROR); + + wp_die( + // translators: %1$s is the PayPal error code, %2$s is the error message. + esc_html(sprintf(__('PayPal Error (%1$s): %2$s', 'ultimate-multisite'), $error_code, $error_message)), + esc_html__('PayPal Error', 'ultimate-multisite'), + [ + 'back_link' => true, + 'response' => '200', + ] + ); } else { /* * We were successful, let's update @@ -1313,6 +1393,8 @@ protected function create_recurring_profile($details, $cart, $payment, $membersh $transaction_id = $body['TRANSACTIONID'] ?? ''; $profile_status = $body['PROFILESTATUS'] ?? ''; + wu_log_add('paypal', sprintf('CreateRecurringPaymentsProfile successful. Profile ID: %s, Transaction ID: %s, Status: %s', $body['PROFILEID'] ?? 'N/A', $transaction_id ?: 'N/A', $profile_status ?: 'N/A')); + // If TRANSACTIONID is not passed we need to wait for webhook $payment_status = Payment_Status::PENDING; @@ -1385,12 +1467,14 @@ protected function create_recurring_profile($details, $cart, $payment, $membersh exit; } } else { + wu_log_add('paypal', sprintf('CreateRecurringPaymentsProfile received unexpected response: HTTP %d %s', $code, $message), LogLevel::ERROR); + wp_die( esc_html__('Something has gone wrong, please try again', 'ultimate-multisite'), esc_html__('Error', 'ultimate-multisite'), [ 'back_link' => true, - 'response' => '401', + 'response' => '200', ] ); } @@ -1445,6 +1529,8 @@ protected function complete_single_payment($details, $cart, $payment, $membershi 'BUTTONSOURCE' => 'WP_Ultimo', ]; + wu_log_add('paypal', sprintf('Processing single payment for payment #%d', $payment->get_id())); + $request = wp_remote_post( $this->api_endpoint, [ @@ -1463,7 +1549,16 @@ protected function complete_single_payment($details, $cart, $payment, $membershi $message = wp_remote_retrieve_response_message($request); if (is_wp_error($request)) { - wp_die(esc_html($request->get_error_message())); + wu_log_add('paypal', 'DoExpressCheckoutPayment request failed: ' . $request->get_error_message(), LogLevel::ERROR); + + wp_die( + esc_html($request->get_error_message()), + esc_html__('PayPal Error', 'ultimate-multisite'), + [ + 'back_link' => true, + 'response' => '200', + ] + ); } if (200 === absint($code) && 'OK' === $message) { @@ -1471,8 +1566,21 @@ protected function complete_single_payment($details, $cart, $payment, $membershi wp_parse_str($body, $body); } - if ('failure' === strtolower((string) $body['ACK'])) { - wp_die(esc_html($body['L_LONGMESSAGE0']), esc_html($body['L_ERRORCODE0'])); + if ('failure' === strtolower((string) $body['ACK']) || 'failurewithwarning' === strtolower((string) $body['ACK'])) { + $error_code = $body['L_ERRORCODE0'] ?? 'unknown'; + $error_message = $body['L_LONGMESSAGE0'] ?? __('Unknown PayPal error', 'ultimate-multisite'); + + wu_log_add('paypal', sprintf('DoExpressCheckoutPayment failed with error %s: %s', $error_code, $error_message), LogLevel::ERROR); + + wp_die( + // translators: %1$s is the PayPal error code, %2$s is the error message. + esc_html(sprintf(__('PayPal Error (%1$s): %2$s', 'ultimate-multisite'), $error_code, $error_message)), + esc_html__('PayPal Error', 'ultimate-multisite'), + [ + 'back_link' => true, + 'response' => '200', + ] + ); } else { /* * We were successful, let's update @@ -1530,6 +1638,8 @@ protected function complete_single_payment($details, $cart, $payment, $membershi $membership->save(); } + wu_log_add('paypal', sprintf('DoExpressCheckoutPayment successful. Transaction ID: %s', $transaction_id)); + $this->payment = $payment; $redirect_url = $this->get_return_url(); @@ -1538,12 +1648,14 @@ protected function complete_single_payment($details, $cart, $payment, $membershi exit; } } else { + wu_log_add('paypal', sprintf('DoExpressCheckoutPayment received unexpected response: HTTP %d %s', $code, $message), LogLevel::ERROR); + wp_die( esc_html__('Something has gone wrong, please try again', 'ultimate-multisite'), esc_html__('Error', 'ultimate-multisite'), [ 'back_link' => true, - 'response' => '401', + 'response' => '200', ] ); } @@ -1553,9 +1665,9 @@ protected function complete_single_payment($details, $cart, $payment, $membershi * Display the confirmation form. * * @since 2.1 - * @return string + * @return void */ - public function confirmation_form() { + public function confirmation_form(): void { $token = sanitize_text_field(wu_request('token')); @@ -1565,21 +1677,62 @@ public function confirmation_form() { $error = is_wp_error($checkout_details) ? $checkout_details->get_error_message() : __('Invalid response code from PayPal', 'ultimate-multisite'); // translators: %s is the paypal error message. - return '

' . sprintf(__('An unexpected PayPal error occurred. Error message: %s.', 'ultimate-multisite'), $error) . '

'; + $error_message = sprintf(__('An unexpected PayPal error occurred. Error message: %s.', 'ultimate-multisite'), esc_html($error)); + + wp_die( + esc_html($error_message), + esc_html__('PayPal Error', 'ultimate-multisite'), + [ + 'back_link' => true, + 'response' => '200', + ] + ); + } + + /* + * Validate that the pending payment exists. + */ + $pending_payment = $checkout_details['pending_payment'] ?? null; + + if (empty($pending_payment)) { + wu_log_add('paypal', 'PayPal confirmation failed: pending payment not found', LogLevel::ERROR); + + wp_die( + esc_html__('Unable to locate the pending payment. Please try again or contact support.', 'ultimate-multisite'), + esc_html__('Payment Not Found', 'ultimate-multisite'), + [ + 'back_link' => true, + 'response' => '200', + ] + ); } /* * Compiles the necessary elements. */ - $customer = $checkout_details['pending_payment']->get_customer(); // current customer + $customer = $pending_payment->get_customer(); + $membership = $pending_payment->get_membership(); + + if (empty($customer) || empty($membership)) { + wu_log_add('paypal', 'PayPal confirmation failed: customer or membership not found', LogLevel::ERROR); + + wp_die( + esc_html__('Unable to locate customer or membership data. Please try again or contact support.', 'ultimate-multisite'), + esc_html__('Data Error', 'ultimate-multisite'), + [ + 'back_link' => true, + 'response' => '200', + ] + ); + } wu_get_template( 'checkout/paypal/confirm', [ 'checkout_details' => $checkout_details, 'customer' => $customer, - 'payment' => $checkout_details['pending_payment'], - 'membership' => $checkout_details['pending_payment']->get_membership(), + 'payment' => $pending_payment, + 'membership' => $membership, ] ); } @@ -1615,12 +1768,27 @@ public function get_checkout_details($token = '') { $message = wp_remote_retrieve_response_message($request); if (is_wp_error($request)) { + wu_log_add('paypal', 'GetExpressCheckoutDetails request failed: ' . $request->get_error_message(), LogLevel::ERROR); + return $request; } elseif (200 === absint($code) && 'OK' === $message) { if (is_string($body)) { wp_parse_str($body, $body); } + /* + * Check for PayPal API errors. + * ACK=Failure means the API call failed (e.g., invalid credentials, expired token). + */ + if (isset($body['ACK']) && ('failure' === strtolower((string) $body['ACK']) || 'failurewithwarning' === strtolower((string) $body['ACK']))) { + $error_code = $body['L_ERRORCODE0'] ?? 'unknown'; + $error_message = $body['L_LONGMESSAGE0'] ?? __('Unknown PayPal error', 'ultimate-multisite'); + + wu_log_add('paypal', sprintf('GetExpressCheckoutDetails failed with error %s: %s', $error_code, $error_message), LogLevel::ERROR); + + return new \WP_Error($error_code, $error_message); + } + $payment_id = absint(wu_request('payment-id')); $pending_payment = $payment_id ? wu_get_payment($payment_id) : wu_get_payment_by_hash(wu_request('payment')); @@ -1631,11 +1799,13 @@ public function get_checkout_details($token = '') { $body['pending_payment'] = $pending_payment; - $custom = explode('|', (string) $body['PAYMENTREQUEST_0_CUSTOM']); + $custom = explode('|', (string) wu_get_isset($body, 'PAYMENTREQUEST_0_CUSTOM', '')); return $body; } + wu_log_add('paypal', sprintf('GetExpressCheckoutDetails received unexpected response: HTTP %d %s', $code, $message), LogLevel::ERROR); + return false; }