4
votes

I am currently developing a modification for an open source forum software. This modification allows an user to donate through that forum software.

However, recently an user reported an issue which may be caused by my code. I use another open source library to handle the IPN connection - An IPN Listener PHP class.

The user who reported this issue is receiving the following e-mail:

Hello <My Name>,

Please check your server that handles PayPal Instant Payment Notifications (IPN). Instant Payment Notifications sent to the following URL(s) are failing:

http://www.MySite.com/donate/handler.php

If you do not recognize this URL, you may be using a service provider that is using IPN on your behalf. Please contact your service provider with the above information. If this problem continues, IPNs may be disabled for your account.

Thank you for your prompt attention to this issue.

Sincerely, PayPal

I am fearing that the issue comes from my side, therefore I have to look into this and make sure.

I lightly modified the IPN Listener script, which leads me to think that my modification is causing this issue. Paypal also had some changes recently which might have provoked this problem.

This is how the class looks like momentarily:

/**
* PayPal IPN Listener
*
* A class to listen for and handle Instant Payment Notifications (IPN) from 
* the PayPal server.
*
* https://github.com/Quixotix/PHP-PayPal-IPN
*
* @package    PHP-PayPal-IPN
* @author     Micah Carrick
* @copyright  (c) 2011 - Micah Carrick
* @version    2.0.5
* @license    http://opensource.org/licenses/gpl-license.php
*
* This library is originally licensed under GPL v3, but I received
* permission from the author to use it under GPL v2.
*/
class ipn_handler 
{
    /**
     *  If true, the recommended cURL PHP library is used to send the post back 
     *  to PayPal. If flase then fsockopen() is used. Default true.
     *
     *  @var boolean
     */
    public $use_curl = true;     

    /**
     *  If true, explicitly sets cURL to use SSL version 3. Use this if cURL
     *  is compiled with GnuTLS SSL.
     *
     *  @var boolean
     */
    public $force_ssl_v3 = true;     

    /**
     *  If true, cURL will use the CURLOPT_FOLLOWLOCATION to follow any 
     *  "Location: ..." headers in the response.
     *
     *  @var boolean
     */
    public $follow_location = false;     

    /**
     *  If true, an SSL secure connection (port 443) is used for the post back 
     *  as recommended by PayPal. If false, a standard HTTP (port 80) connection
     *  is used. Default true.
     *
     *  @var boolean
     */
    public $use_ssl = true;      

    /**
     *  If true, the paypal sandbox URI www.sandbox.paypal.com is used for the
     *  post back. If false, the live URI www.paypal.com is used. Default false.
     *
     *  @var boolean
     */
    public $use_sandbox = false; 

    /**
     *  The amount of time, in seconds, to wait for the PayPal server to respond
     *  before timing out. Default 30 seconds.
     *
     *  @var int
     */
    public $timeout = 60;       

    private $post_data = array();
    private $post_uri = '';     
    private $response_status = '';
    private $response = '';

    const PAYPAL_HOST = 'www.paypal.com';
    const SANDBOX_HOST = 'www.sandbox.paypal.com';

    /**
     *  Post Back Using cURL
     *
     *  Sends the post back to PayPal using the cURL library. Called by
     *  the processIpn() method if the use_curl property is true. Throws an
     *  exception if the post fails. Populates the response, response_status,
     *  and post_uri properties on success.
     *
     *  @param  string  The post data as a URL encoded string
     */
    protected function curlPost($encoded_data) 
    {
        global $user;

        if ($this->use_ssl) 
        {
            $uri = 'https://' . $this->getPaypalHost() . '/cgi-bin/webscr';
            $this->post_uri = $uri;
        }
        else 
        {
            $uri = 'http://' . $this->getPaypalHost() . '/cgi-bin/webscr';
            $this->post_uri = $uri;
        }

        $ch = curl_init();

        curl_setopt($ch, CURLOPT_URL, $uri);
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $encoded_data);
        curl_setopt($ch, CURLOPT_FOLLOWLOCATION, $this->follow_location);
        curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_HEADER, true);

        if ($this->force_ssl_v3) 
        {
            curl_setopt($ch, CURLOPT_SSLVERSION, 3);
        }

        $this->response = curl_exec($ch);
        $this->response_status = strval(curl_getinfo($ch, CURLINFO_HTTP_CODE));

        if ($this->response === false || $this->response_status == '0') 
        {
            $errno = curl_errno($ch);
            $errstr = curl_error($ch);
            throw new Exception($user->lang['CURL_ERROR'] . "[$errno] $errstr");
        }
    }

    /**
     *  Post Back Using fsockopen()
     *
     *  Sends the post back to PayPal using the fsockopen() function. Called by
     *  the processIpn() method if the use_curl property is false. Throws an
     *  exception if the post fails. Populates the response, response_status,
     *  and post_uri properties on success.
     *
     *  @param  string  The post data as a URL encoded string
     */
    protected function fsockPost($encoded_data) 
    {
        global $user;

        if ($this->use_ssl) 
        {
            $uri = 'ssl://' . $this->getPaypalHost();
            $port = '443';
            $this->post_uri = $uri . '/cgi-bin/webscr';
        } 
        else 
        {
            $uri = $this->getPaypalHost(); // no "http://" in call to fsockopen()
            $port = '80';
            $this->post_uri = 'http://' . $uri . '/cgi-bin/webscr';
        }

        $fp = fsockopen($uri, $port, $errno, $errstr, $this->timeout);

        if (!$fp) 
        { 
            // fsockopen error
            throw new Exception($user->lang['FSOCKOPEN_ERROR'] . "[$errno] $errstr");
        } 

        $header = "POST /cgi-bin/webscr HTTP/1.1\r\n";
        $header .= "Content-Length: " . strlen($encoded_data) . "\r\n";
        $header .= "Content-Type: application/x-www-form-urlencoded\r\n";
        $header .= "Host: " . $this->getPaypalHost() . "\r\n";
        $header .= "Connection: close\r\n\r\n";

        fputs($fp, $header . $encoded_data . "\r\n\r\n");

        while(!feof($fp)) 
        { 
            if (empty($this->response)) 
            {
                // extract HTTP status from first line
                $this->response .= $status = fgets($fp, 1024); 
                $this->response_status = trim(substr($status, 9, 4));
            } 
            else 
            {
                $this->response .= fgets($fp, 1024); 
            }
        } 

        fclose($fp);
    }

    private function getPaypalHost() 
    {
        if ($this->use_sandbox) 
        {
            return ipn_handler::SANDBOX_HOST;
        }
        else
        {
            return ipn_handler::PAYPAL_HOST;
        }
    }

    /**
     *  Get POST URI
     *
     *  Returns the URI that was used to send the post back to PayPal. This can
     *  be useful for troubleshooting connection problems. The default URI
     *  would be "ssl://www.sandbox.paypal.com:443/cgi-bin/webscr"
     *
     *  @return string
     */
    public function getPostUri() 
    {
        return $this->post_uri;
    }

    /**
     *  Get Response
     *
     *  Returns the entire response from PayPal as a string including all the
     *  HTTP headers.
     *
     *  @return string
     */
    public function getResponse() 
    {
        return $this->response;
    }

    /**
     *  Get Response Status
     *
     *  Returns the HTTP response status code from PayPal. This should be "200"
     *  if the post back was successful. 
     *
     *  @return string
     */
    public function getResponseStatus() 
    {
        return $this->response_status;
    }

    /**
     *  Get Text Report
     *
     *  Returns a report of the IPN transaction in plain text format. This is
     *  useful in emails to order processors and system administrators. Override
     *  this method in your own class to customize the report.
     *
     *  @return string
     */
    public function getTextReport() 
    {
        $r = '';

        // date and POST url
        for ($i = 0; $i < 80; $i++) 
        { 
            $r .= '-'; 
        }

        $r .= "\n[" . date('m/d/Y g:i A') . '] - ' . $this->getPostUri();
        if ($this->use_curl) 
        {
            $r .= " (curl)\n";
        }
        else
        {
            $r .= " (fsockopen)\n";
        }

        // HTTP Response
        for ($i = 0; $i < 80; $i++) 
        { 
            $r .= '-'; 
        }

        $r .= "\n{$this->getResponse()}\n";

        // POST vars
        for ($i = 0; $i < 80; $i++) 
        { 
            $r .= '-'; 
        }

        $r .= "\n";

        foreach ($this->post_data as $key => $value) 
        {
            $r .= str_pad($key, 25) . "$value\n";
        }

        $r .= "\n\n";

        return $r;
    }

    /**
     *  Process IPN
     *
     *  Handles the IPN post back to PayPal and parsing the response. Call this
     *  method from your IPN listener script. Returns true if the response came
     *  back as "VERIFIED", false if the response came back "INVALID", and 
     *  throws an exception if there is an error.
     *
     *  @param array
     *
     *  @return boolean
     */    
    public function processIpn($post_data = null) 
    {
        global $user;

        $encoded_data = 'cmd=_notify-validate';

        if ($post_data === null) 
        { 
            // use raw POST data 
            if (!empty($_POST)) 
            {
                $this->post_data = $_POST;
                $encoded_data .= '&' . file_get_contents('php://input');
            } 
            else 
            {
                throw new Exception($user->lang['NO_POST_DATA']);
            }
        } 
        else 
        { 
            // use provided data array
            $this->post_data = $post_data;

            foreach ($this->post_data as $key => $value) 
            {
                $encoded_data .= "&$key=" . urlencode($value);
            }
        }

        if ($this->use_curl) 
        {
            $this->curlPost($encoded_data); 
        }
        else
        {
            $this->fsockPost($encoded_data);
        }

        if (strpos($this->response_status, '200') === false) 
        {
            throw new Exception($user->lang['INVALID_RESPONSE'] . $this->response_status);
        }

        if (strpos(trim($this->response), "VERIFIED") !== false) 
        {
            return true;
        } 
        elseif (trim(strpos($this->response), "INVALID") !== false) 
        {
            return false;
        } 
        else 
        {
            throw new Exception($user->lang['UNEXPECTED_ERROR']);
        }
    }

    /**
     *  Require Post Method
     *
     *  Throws an exception and sets a HTTP 405 response header if the request
     *  method was not POST. 
     */    
    public function requirePostMethod() 
    {
        global $user;

        // require POST requests
        if ($_SERVER['REQUEST_METHOD'] && $_SERVER['REQUEST_METHOD'] != 'POST') 
        {
            header('Allow: POST', true, 405);
            throw new Exception($user->lang['INVALID_REQUEST_METHOD']);
        }
    }
}

Is there any issue with this script which is causing this problem?

P.S: The URL donate/handler.php is indeed the IPN handler/listener file, so it's a recognized URL.

5
I haven't worked with this stuff, but it seems odd that the user would get the E-Mail, instead of you. Are you sure the addresses are correctly configured? And does PayPal not provide a log with more information about what goes wrong? They should.Pekka
Sorry for not being clear: I released this modification and the user is the owner of his own forum and he is the one who reported this issue. I'm not using my own modification, so I'm not supposed to receive any e-mail.aborted

5 Answers

7
votes

For the debug part

You can also check on Paypal the state of your IPN.

My Account > History > IPN History.

It will list all IPN that were sent to your server. You will see a status for each of them. It might help. But as Andrew Angell says, take a look at your log.

For the PHP part

Paypal provide a lots a goodness stuff on their Github. You should definitively take a closer look.

They have a dead simple IPNLister sample that you should use (instead of a custom one - even if it seems good). It use built-in function from Paypal itself. And I personally use it too. You shouldn't re-invent the wheel :)

<?php
require_once('../PPBootStrap.php');
// first param takes ipn data to be validated. if null, raw POST data is read from input stream
$ipnMessage = new PPIPNMessage(null, Configuration::getConfig());
foreach($ipnMessage->getRawData() as $key => $value) {
    error_log("IPN: $key => $value");
}

if($ipnMessage->validate()) {
    error_log("Success: Got valid IPN data");       
} else {
    error_log("Error: Got invalid IPN data");   
}

As you can see, it's simple.

I use it in a slightly different way:

$rawData    = file_get_contents('php://input');
$ipnMessage = new PPIPNMessage($rawData);

$this->forward404If(!$ipnMessage->validate(), 'IPN not valid.');

$ipnListener = new IPNListener($rawData);
$ipnListener->process();

The IPNListener class is custom to me: it does handle what to do with the IPN. It parse the response and do action depending on the state:

function __construct($rawData)
{
  $rawPostArray = explode('&', $rawData);
  foreach ($rawPostArray as $keyValue)
  {
    $keyValue = explode ('=', $keyValue);
    if (count($keyValue) == 2)
    {
      $this->ipnData[$keyValue[0]] = urldecode($keyValue[1]);
    }
  }

  // log a new IPN and save in case of error in the next process
  $this->ipn = new LogIpn();
  $this->ipn->setContent($rawData);
  $this->ipn->setType(isset($this->ipnData['txn_type']) ? $this->ipnData['txn_type'] : 'Not defined');
  $this->ipn->save();
}

/**
 * Process a new valid IPN
 *
 */
public function process()
{
  if (null === $this->ipnData)
  {
    throw new Exception('ipnData is empty !');
  }

  if (!isset($this->ipnData['txn_type']))
  {
    $this->ipn->setSeemsWrong('No txn_type.');
    $this->ipn->save();

    return;
  }

  switch ($this->ipnData['txn_type'])
  {
    // handle statues
  }
}
3
votes

Check your web server logs. That will show you what result is coming up when the IPN script is hit, and since it's failing you must be getting some sort of 500 internal server error. The logs will give you the error info that you would normally see on screen, like a syntax error, line number, etc.

What I like to do for troubleshooting, too, is create a simulator of my own by building a basic HTML form with the action set to the URL of my IPN listener. Add hidden fields with the names/values you'd expect to get from an IPN and then you can load that in a browser and submit it directly so that you can see the result on screen. You'll probably find that you have an error in your code somewhere causing the script to be unable to complete.

Keep in mind that when testing this way the data isn't coming from PayPal so it will not be verified. You'll need to make sure your code logic is setup to handle that accordingly.

Once you're able to get everything running smoothly testing that way, I'd use the PayPal IPN listener as another confirmation, and then you can rest assured that you've fixed the issue.

3
votes

I highly recommend using the IPN simulator in the Paypal developer site. It can build an IPN request for you and send it to your server and report back what it got.

2
votes

Check your logs under your Paypal account, it will show a list of IPN requests sent and also the results. If you have some major issues you can use the GET string provided to test out use cases.

2
votes

There was indeed an issue with my code. I did some casting mistake and it caused the IPN to fail. This issue has been resolved.

Thanks for the help everyone.