Looking for something?

Magento PayPal Express Checkout: Recoverable Failures

Background

Recently I had to respond to a new situation involving a PayPal Express problem on a Magento 1.7 install involving error code 10486.  Unbeknownst to me at the time, some checkout errors are actually recoverable if the application responds correctly.

After reading through the PayPal documentation regarding the error codes that were occurring, I found that the user could be redirected back to PayPal after getting one of the following error codes:

  • Error 10486 - The transaction could not be completed and the user can be immediately 302'd back to PayPal
  • Error 10417 - Customer must choose another funding source from their wallet - offer them a link to go back to PayPal
  • Error 10422 - Customer must choose new funding sources - offer them a link to go back to PayPal
  • Error 10485 - Payment has not been authorized by the user - offer them a link to go back to PayPal
  • Error 10736 - PayPal has determined that the shipping address does not exist - fix this on the order review page

If error 10486 occurs, then the user can be immediately redirected back to PayPal.  The customer will then be greeted with a verbose message regarding why the payment failed.  The same degree of information is not provided to the merchant's application seemingly due to privacy concerns. If one of the other errors in this list is returned from the NVP gateway, then the application is expected to give the customer a quick link to return to PayPal to edit their funding source (using the same checkout token).  In the event of error 10736, the user is expected to fix their shipping address and is then able to place the order without restarting the checkout operation.

Magento Default Behavior

By default, Magento doesn't support any sort of redirect back to PayPal in the event of these errors.  The behavior does change between version 1.8 and 1.9 however, but still does not follow the PayPal recommended procedure.  In Magento 1.8 and prior, the error received from the NVP gateway is presented to the user.  In Magento 1.9 and beyond, a generic message is shown instead (possibly due to a logic flow oversight made by the Magento Core team).

Code Trace

We'll start out inside the NVP API model found at app/code/core/Mage/Paypal/Model/Api/Nvp.php.  This is called when the user clicks 'Place Order' on the order review page.  It's the last API call made during the Express Checkout process.

    public function callDoExpressCheckoutPayment()
    {
        $this->_prepareExpressCheckoutCallRequest($this->_doExpressCheckoutPaymentRequest);
        $request = $this->_exportToRequest($this->_doExpressCheckoutPaymentRequest);
        $this->_exportLineItems($request);

        $response = $this->call(self::DO_EXPRESS_CHECKOUT_PAYMENT, $request);
        $this->_importFromResponse($this->_paymentInformationResponse, $response);
        $this->_importFromResponse($this->_doExpressCheckoutPaymentResponse, $response);
        $this->_importFromResponse($this->_createBillingAgreementResponse, $response);
    }

This method sets up the request parameters and calls through to the call method in the same class.  This is the method that makes the connection to PayPal, sends the request, and (if everything goes alright) receives a response.

public function call($methodName, array $request)
    {
        $request = $this->_addMethodToRequest($methodName, $request);
        $eachCallRequest = $this->_prepareEachCallRequest($methodName);
        if ($this->getUseCertAuthentication()) {
            if ($key = array_search('SIGNATURE', $eachCallRequest)) {
                unset($eachCallRequest[$key]);
            }
        }
        $request = $this->_exportToRequest($eachCallRequest, $request);
        $debugData = array('url' => $this->getApiEndpoint(), $methodName => $request);

        try {
            $http = new Varien_Http_Adapter_Curl();
            $config = array(
                'timeout'    => 60,
                'verifypeer' => $this->_config->verifyPeer
            );

            if ($this->getUseProxy()) {
                $config['proxy'] = $this->getProxyHost(). ':' . $this->getProxyPort();
            }
            if ($this->getUseCertAuthentication()) {
                $config['ssl_cert'] = $this->getApiCertificate();
            }
            $http->setConfig($config);
            $http->write(
                Zend_Http_Client::POST,
                $this->getApiEndpoint(),
                '1.1',
                $this->_headers,
                $this->_buildQuery($request)
            );
            $response = $http->read();
        } catch (Exception $e) {
            $debugData['http_error'] = array('error' => $e->getMessage(), 'code' => $e->getCode());
            $this->_debug($debugData);
            throw $e;
        }

        $response = preg_split('/^\r?$/m', $response, 2);
        $response = trim($response[1]);
        $response = $this->_deformatNVP($response);

        $debugData['response'] = $response;
        $this->_debug($debugData);
        $response = $this->_postProcessResponse($response);

        // handle transport error
        if ($http->getErrno()) {
            Mage::logException(new Exception(
                sprintf('PayPal NVP CURL connection error #%s: %s', $http->getErrno(), $http->getError())
            ));
            $http->close();

            Mage::throwException(Mage::helper('paypal')->__('Unable to communicate with the PayPal gateway.'));
        }

        // cUrl resource must be closed after checking it for errors
        $http->close();

        if (!$this->_validateResponse($methodName, $response)) {
            Mage::logException(new Exception(
                Mage::helper('paypal')->__("PayPal response hasn't required fields.")
            ));
            Mage::throwException(Mage::helper('paypal')->__('There was an error processing your order. Please contact us or try again later.'));
        }

        $this->_callErrors = array();
        if ($this->_isCallSuccessful($response)) {
            if ($this->_rawResponseNeeded) {
                $this->setRawSuccessResponseData($response);
            }
            return $response;
        }
        $this->_handleCallErrors($response);
        return $response;
    }

This method is a bit more involved, but essentially, it all boils down to:

  1. Prepare the request
  2. Send the request
  3. Read the response
  4. React to the response

The only lines of interest in this investigation are 62 or 76 depending on the version of Magento that is being worked with.

Version Discrepancy

The path of execution that the logic follows changes between Magento version 1.8 and 1.9 due to the addition of an array element used within the _validateResponse method (shown below).

protected $_requiredResponseParams = array(
    self::DO_DIRECT_PAYMENT             => array('ACK', 'CORRELATIONID', 'AMT'),
    self::DO_EXPRESS_CHECKOUT_PAYMENT   => array('ACK', 'CORRELATIONID', 'AMT'), // Added in Magento 1.9
);

protected function _validateResponse($method, $response) { if (isset($this->_requiredResponseParams[$method])) { foreach ($this->_requiredResponseParams[$method] as $param) { if (!isset($response[$param])) { return false; } } } return true; }

Due to the addition of DoExpressCheckoutPayment to the _requiredResponseParams member property in Magento 1.9, the call method can throw an exception before execution reaches the _handleCallErrors method.  So in other words, any unsuccessful response from PayPal will result in a generic error message being presented in Magento 1.9 whereas in Magento 1.8 and prior, the actual error received from the NVP gateway will be presented to the user.

How do we fix this?

Essentially, we have to hijack the core logic in order to conditionally redirect the user back to PayPal depending on the error code that the original DoExpressCheckoutPayment call returns.  In order to future-proof the solution in my situation, the resulting module should work across Magento CE 1.7+.

We'll start by getting the boilerplate XML out of the way.

Module Registration

<?xml version="1.0" encoding="utf-8"?>
<config>
    <modules>
        <Stabilis_PaypalExpressRedirect>
            <active>true</active>
            <codePool>community</codePool>
            <depends>
                <Mage_Paypal />
<Stabilis_Core /> </depends> </Stabilis_PaypalExpressRedirect> </modules> </config>
 

Module Configuration

<?xml version="1.0" encoding="utf-8"?>
<config>
    <modules>
        <Stabilis_PaypalExpressRedirect>
            <version>1.0.0</version>
        </Stabilis_PaypalExpressRedirect>
    </modules>
    <frontend>
        <translate>
            <modules>
                <stabilis_paypalexpressredirect>
                    <files>
                        <default>Stabilis_PaypalExpressRedirect.csv</default>
                    </files>
                </stabilis_paypalexpressredirect>
            </modules>
        </translate>
    </frontend>
    <global>
        <helpers>
            <stabilis_paypalexpressredirect>
                <class>Stabilis_PaypalExpressRedirect_Helper</class>
            </stabilis_paypalexpressredirect>
        </helpers>
        <models>
            <paypal>
                <rewrite>
                    <api_nvp>Stabilis_PaypalExpressRedirect_Model_Api_Nvp</api_nvp>
                </rewrite>
            </paypal>
        </models>
    </global>
</config>

As can be seen from the module configuration, the class Mage_Paypal_Model_Api_Nvp has been overridden.

NVP Override

<?php

class Stabilis_PaypalExpressRedirect_Model_Api_Nvp extends Mage_Paypal_Model_Api_Nvp {

    /** @var int Symbolic constant for HTTP/302 */
    const HTTP_TEMPORARY_REDIRECT = 302;
    
    /** @var int https://developer.paypal.com/docs/classic/express-checkout/ht_ec_fundingfailure10486/ */
    const API_UNABLE_TRANSACTION_COMPLETE       = 10486;

    /** @var int https://www.paypal-knowledge.com/infocenter/index?page=content&id=FAQ1375&actp=LIST */
    const API_UNABLE_PROCESS_PAYMENT_ERROR_CODE = 10417;

    /** @var int https://www.paypal-knowledge.com/infocenter/index?page=content&expand=true&locale=en_US&id=FAQ1850 */
    const API_DO_EXPRESS_CHECKOUT_FAIL          = 10422;

    /** @var int https://www.paypal-knowledge.com/infocenter/index?page=content&id=FAQ2025&actp=LIST */
    const API_BAD_SHIPPING_ADDRESS              = 10736;
    
    /**
     * Internal Constructor
     */
    protected function _construct() {
        parent::_construct();
        
        /// Magento 1.9+ has added the DoExpressCheckoutPayment method to the required response params array.
        /// This array is checked prior to any error checking, therefore an error condition will trigger an 
        /// early exit (even if the error is recoverable).  So we'll remove the 'AMT' field from the required 
        /// params array.
        if (version_compare(Mage::getVersion(), '1.9', '>=')) {
            $this->_requiredResponseParams[static::DO_EXPRESS_CHECKOUT_PAYMENT] = array('ACK', 'CORRELATIONID');
        }
    }

    /**
     * Throws an exception that is dependent upon the version of Magento.
     * 
     * @param Exception $ex the exception to throw in Magento version <= 1.8
     * 
     * @throws Exception
     */
    protected function _rethrow($ex) {
        if (version_compare(Mage::getVersion(), '1.9', '>=')) {

            /// Preserve Magneto 1.9+ Behavior
            Mage::throwException(Mage::helper('paypal')
                    ->__('There was an error processing your order. Please contact us or try again later.'));
        } else {

            /// Preserve Magento <= 1.8 Behavior
            throw $ex;
        }
    }
    
    /**
     * Extends the functionality of the parent method by setting a redirect to 
     * PayPal in the event of certain error conditions.
     * 
     * @param array $response
     * 
     * @throws Exception if an unrecoverable error exists within the response
     */
    protected function _handleCallErrors($response) {
        try {

            /// Let the default functionality take its course
            parent::_handleCallErrors($response);

        } catch (Exception $ex) {
            
            /// If there's more than one error, then there's no silver bullet.
            if(count($this->_callErrors) > 1) {
                $this->_rethrow($ex);
            }
            
            switch($this->_callErrors[0]) {

                /// Redirect the user back to PayPal
                case static::API_UNABLE_TRANSACTION_COMPLETE:
                    Mage::app()->getFrontController()->getResponse()
                        ->setRedirect(Mage::getUrl('paypal/express/edit'), static::HTTP_TEMPORARY_REDIRECT)
                        ->sendResponse();
                    exit;

                /// Give the user an option to click a link to go back and 
                /// select another funding source
                case static::API_UNABLE_PROCESS_PAYMENT_ERROR_CODE:
case static::API_DO_EXPRESS_CHECKOUT_FAIL:
Mage::throwException(Mage::helper('stabilis_paypalexpressredirect') ->__('PayPal could not process your payment at this time. Please <a href="/%s">click here</a> to select a different payment method from within your PayPal account and try again.', Mage::getUrl('paypal/express/edit'))); /// The shipping address isn't right. Fix it on this page. case static::API_BAD_SHIPPING_ADDRESS:
Mage::throwException(Mage::helper('stabilis_paypalexpressredirect') ->__('PayPal has determined that the specified shipping address does not exist. Please double-check your shipping address and try again.')); /// Other error? Let the caller handle it. default: $this->_rethrow($ex); } } } }

The override that we've installed is minimally obtrusive into the existing ecosystem, only changing the behavior when one of the recoverable error codes is returned.  In all other cases, the native behavior for the proper version of Magento is maintained.

 Conclusion & Complete Source Code

I found it quite surprising that the Magento Core team didn't implement this part of PayPal per the official documentation.  Based upon first hand experience (reports of customer confusion / concerns), this addition to a Magento instance can greatly improve the customer experience when checking out with PayPal.

The complete source code can be freely obtained from My GitHub Repository.  If there's anything that I missed or other discrepancies on Magento versions <= 1.7.0.2, feel free to open an issue or make a pull request.