Paypal IPN integration for CakePHP 1.3

Note: This guide applies to CakePHP 1.3 but may be adapted with relative ease to work with CakePHP 2.x. The guide is based on code from an old project and is intended for use as a guide, it is not a comprehensive “How To”. For the purposes of this guide, I have removed a lot of the more complex back office functions which don’t really serve a purpose here.

I am not going to go through showing you how to set up user auth, access control and shopping cart functionality. It has been documented to death, in this guide I am focusing solely on Paypal integration.

So, you have a CakePHP based web application with a functional shopping cart and you want to integrate it with Paypal IPN? Well you are in the right place! Before we get started, you’ll need to understand a little bit about the Paypal IPN flow.

Paypal IPN flow

  1. User on your site submits a Paypal Button (a standard form in this case) to the Paypal servers.
  2. Paypal sends an Instant Payment Notification (IPN) http request to a predefined handler/listener on your site.
  3. Assuming you have defined the correct IPN URL, your IPN handler will return a HTTP 200 OK response code (otherwise Paypal will keep sending IPN notifications for up to four days).
  4. Your IPN handler needs to send the complete unmodified notification (as received from PayPal) back to Paypal to validate the notify request. You can do this by prepending the notification with “cmd=_notify-validate”.
  5. Upon receiving this request, Paypal will reply with a single word – either “VERIFIED” or “INVALID”.
IPN Overview diagram from developer.paypal.com
IPN Overview diagram from developer.paypal.com

As you can see, it is pretty straight forward process. With the flow above in mind, we will need to do a number of things to get our CakePHP based checkout up and running.

  1. Setup a Paypal Website Payments Standard compatible form that submits to paypal.
  2. Create a listener/handler to handle the Paypal IPN and perform some back office tasks on your site.

The Paypal Form

So, first things first, lets create the Paypal form that contains all of the items in our shopping cart. All of the more important form fields are described in the code snippet below.

<form action="https://www.paypal.com/cgi-bin/webscr" method="post">
    <input type="hidden" name="business" value="<?php echo Configure::read('SiteSettings.paypal_business'); ?>"> <!-- pass in the email address of the Paypal merchant account holder -->
    <input type="hidden" name="cmd" value="_cart"> <!-- here we specify the type of Paypal button we are using - in this case it is a shopping cart button (as opposed to buy now, subscribe or donate button - among others available) -->
    <input type="hidden" name="upload" value="1"> <!-- we are going to create a cart containing individual items. We will pass individual items to paypal via the Cart "upload" option -->
    <input type="hidden" name="currency_code" value="EUR"> <!-- specify the transaction currency -->
	
    <!-- the "custom" field below allows us to pass in any custom data from our form right through to our IPN handler. -->
    <input type="hidden" name="custom" value="<?php echo $user_id; ?>">
    <input type="hidden" name="return" value="http://mydomain.ie/thankyou"><!-- specify the url of you want to the user to be forwarded to on return from Paypal after successful payment -->
    <input type="hidden" name="cancel_return" value="http://mydomain.ie/cancel"><!-- specify the url of you want to the user to be forwarded to on return from Paypal after aborted payment -->
    <input type="hidden" name="notify_url" value="http://mydomain.ie/ipn" /><!-- specify the url of your IPN handler -->

	<!-- Customer details -->
    <input type="hidden" name="first_name" value="<?php echo $customerFirstName; ?>">
	<input type="hidden" name="last_name" value="<?php echo $customerLastName; ?>">
    <input type="hidden" name="address1" value="<?php echo $customerAddress1; ?>">
    <input type="hidden" name="address2 " value="<?php echo $customerAddress2; ?>">
    <input type="hidden" name="street" value="<?php echo $customerStreet; ?>">
    <input type="hidden" name="city" value="<?php echo $customerCity; ?>">
    <input type="hidden" name="state" value="<?php echo $customerState; ?>">
    <input type="hidden" name="zip" value="<?php echo $customerPostCode; ?>">
    <input type="hidden" name="country" value="<?php echo $customerCountry; ?>" />
    <input type="hidden" name="email" value="<?php echo $customerEmailAddress; ?>" />
    <input type="hidden" name="contact_phone" value="<?php echo $customerPhone; ?>" />
   
      
	<?php
		/*
		* You can submit multiple items from your shopping cart. Each item can have a group of fields 
		* that can describe the product including the name, price (unit), quantity and an item number 
		* (i.e. a stock code or cart item reference number)
		* Each of these items is diffrentiated by appending a sequential number (for each item) to 
		* each field name in the group.
		* For example:
		* Fields for the first item include: item_name_1, amount_1, quantity_1, item_number_1
		* Fields for the second item include: item_name_2, amount_2, quantity_2, item_number_2
		* Fields for the third item include: item_name_3, amount_3, quantity_3, item_number_3
		* ... and so on
		*
		* So now we will loop through each item in the cart and create a group of fields for each item.
		*
		*/
		
		$itemNumber = 1; 
	
		foreach ($cartitems as $cartItem):
	 
			$price = $cartItem['Product']['price'] * $cartItem['CartItem']['quantity']; //calculate the cart total
			$cartTotal += $price;
	?>

            <input type='hidden' name='item_name_<?php echo $itemNumber; ?>' value='<?php echo $cartItem['Product']['title']; ?>'>
            <input type='hidden' name='amount_<?php echo $itemNumber; ?>' value='<?php echo number_format($cartItem['Product']['price'],2); ?>'>
            <input type='hidden' name='quantity_<?php echo $itemNumber; ?>' value='<?php echo $cartItem['CartItem']['quantity']; ?>'>
            <input type='hidden' name='item_number_<?php echo $itemNumber; ?>' value='<?php echo $cartItem['CartItem']['id']; ?>'> 

	<?php 
		endforeach; 
	?>
            
    <input type='hidden' name='handling_cart' value='<?php echo number_format($shippingPrice, 2) ?>' /> <!-- add your shipping/handling fee -->
    <input type="submit" name="submit" value="Proceed to Payment" />
</form>

The form is pretty ordinary as far as HTML forms go. You need to make sure that all of your URLs are correct and that you are populating the Cart items correctly in the form.

 

The IPN handler

Now that we have our form submitting all of our cart details to Paypal, we need to setup our IPN handler that will validate the IPN notification, save the order details and associated items and remove items from the customers cart.

Our controller looks like this.

<?php
class InstantPaymentNotificationsController extends AppController {
    /**
     * InstantPaymentNotificationsController
     * 
     * A custom controller to handle Paypal Instant Payment Notifications. 

     */
    var $name = 'InstantPaymentNotifications';
    
	var $uses = array('InstantPaymentNotification', 'CartItem', 'Product', 'User', 'Order', 'OrderItems');

	
	/*
	* process_ipn() is our IPN request handler. Traffic is routed here via /ipn as defined app/config/routes.php
	*/
	
    public function process_ipn(){

		// Paypal (we are assuming) has sent an IPN message to our IPN handler. Ensure that the POST is not empty.
        if(!empty($_POST)){
			
            //NOTE: at this point you could add optional validation here to verify that
            // 1. the cart contents match what was submitted to Paypal
            // 2. the cart value matches the Paypal transaction total
            
			//Now we need to validate the notification sent by Paypal. You do this by returning the notification 
            //back to paypal with an additional variable (cmd=_notify-validate) in front of the POST string.
            //add 'cmd' 'notify-validate' to a transaction variable
            $postback = 'cmd=_notify-validate';
            //and add each individual parameter that Paypal sent to the transaction
            foreach ($_POST as $key => $value) {
                $value = urlencode(stripslashes($value));
                $postback .= "&$key=$value";
            }
            //create headers for the post back
            $header = "POST /cgi-bin/webscr HTTP/1.0\r\n";
            $header .= "Content-Type: application/x-www-form-urlencoded\r\n";
            $header .= "Content-Length: " . strlen($postback) . "\r\n\r\n";
			
			//Check to see if we are working with the Paypal sandbox or the production server - a handy switch for developers to ensure you are working with the correct gateway
            //If this is a sandbox transaction then 'test_ipn' will be set to '1'
           	if(isset($_POST['test_ipn'])){
                $server = 'www.sandbox.paypal.com';
            }else{
                $server = 'www.paypal.com';
            }

            //create a socket connection to the relevant Paypal over SSL
            $socket = fsockopen ('ssl://'.$server, 443, $errno, $errstr, 30);

            //If we can't open a socket - log an error.
            if (!$socket) {
                $this->log('HTTP Error in InstantPaymentNotifications::process while posting back to PayPal: Transaction='.$transaction);
            }else{ 
				// we opened the socket successfully. Write the request to the open socket.
                fwrite($socket, $header.$postback);
				
                while (!feof($socket)) {
                    $response = fgets($socket, 1024);//get the response from paypal
					
					
                    if (strcmp ($response, "VERIFIED") == 0) { //paypal transaction verified.

						//save the IPN details to the IPN table - optional, but I like to maintain a record of all of my paypal transactions.
                        $notification = array();
                        $notification['InstantPaymentNotification']=$_POST;
                        $this->InstantPaymentNotification->save($notification);
                        
						//go ahead and process the order (i.e. perform your back office logic.
						$this->processOrder($_POST);
						
						
                    }else if (strcmp($response, "INVALID") == 0) {
                        //The paypal transaction has not been verified, so the order is invalid. Log the details.
                        $this->log('Found Invalid:'.$transaction);
                    }
                }
                fclose ($socket); //close your socket to the paypal server.
            }
        }

    }
	
    private function processOrder($ipnFields){
		
		$orderError = false;
		
        //get the user id from the "custom" passthrough variable (as added to the Paypal form.
		$user_id = $ipnFields['custom'];
		
		//setup your order data
		$this->data['Order']['user_id'] = $user_id;
		$this->data['Order']['address1'] = $ipnFields['address1'];
		$this->data['Order']['address2'] = $ipnFields['address2'];
    	$this->data['Order']['street'] = $ipnFields['street'];
        $this->data['Order']['city'] = $ipnFields['city'];
        $this->data['Order']['state'] = $ipnFields['state'];
        $this->data['Order']['zip'] = $ipnFields['zip'];
        $this->data['Order']['country'] = $ipnFields['country'];
        $this->data['Order']['email'] = $ipnFields['email'];
        $this->data['Order']['contact_phone'] = $ipnFields['contact_phone'];
		$this->data['Order']['txn_id'] = $ipnFields['txn_id'];
	    $this->data['Order']['status'] = $ipnFields['payment_status';
        
		//if the payment status is pending create an order from the values passed in
		if($ipnFields['payment_status'] == 'Pending') // Pending status is ok.
		{
			$this->Order->create();
			$this->Order->save($this->data['Order']);
		}
		else //if the order is complete or failed, update the order (as previously inserted).
		{
			//perform a lookup based on the txn_id/txnid
			$id = $this->Order->find('first', array('fields' => array('Order.id'),'conditions' => array('Order.txnid' => $fields['txn_id']))); 
			
			//perform an update
			$this->Order->id =$id; //load the existing order record
			$this->Order->save($this->data['Order']); //save the updated order details in the same record to avoid duplicates in the DB
            
            if($fields['payment_status'] != 'Completed') //anything other than completed should be investigated.
    		{
                $orderError = true;
			}
        }
		//get the id of the order
		$order_id = $this->Order->find('first', array('fields' => array('Order.id'),'conditions' => array('Order.txnid' => $fields['txn_id']))); 
		
		
		//Loop through all items passed in from the Paypal notification.
		$itemNumber = 1; // set the initial item number
        while(isset($fields['item_name_'.$itemNumber])){
			
			//get the product id of the current item from the cart based on the unique cart id passed in the 'item_number_X' field.
			$product_id = $this->CartItem->find('first', array('fields' => array('CartItem.product_id'),'conditions' => array('CartItem.id' => $fields['item_number_'.$itemNumber],'CartItem.user_id' => $user_id))); 
			
			//add the product details to the OrderItems table.
			$this->data['OrderItem']['product_id'] = $product_id;
			$this->data['OrderItem']['quantity'] = $fields['quantity_'.$itemNumber];
			$this->data['OrderItem']['order_id'] = $order_id;
			
			$this->OrderItem->create();
			if ($this->OrderItem->save($this->data['OrderItem'])) {
				//remove the corresponding item from the users shopping cart
				$this->CartItem->delete($fields['item_number_'.$itemNumber]);
			}
			else
			{
				$this->log('Couldn\'t save order item. Product ID: '.$fields['item_number_'.$itemNumber].' Order ID: '.$order_id);
				$orderError = true;
			}
            $itemNumber += 1;
        }
		

		
		
		if($orderError) //if there hasn't been an error with the order - send out mail to the site admin and the user
		{
			//send the site owner details about the problem order so that they can follow up with the customer.			
		}
		else
		{
			//perform other logic here such as send confirmation emails		
            //also be sure to add a check to see if the payment status is Pending or Complete.
		}
    }
	
	
}
?>



The critical part here is the “process_ipn” function. This is the function that validates the IPN notification. The “processOrder” function is not complete and is purely for demonstrative purposes and to give you an idea of how you might parse the the IPN details and start your back office workflow.

Routes to the handler

You may have noticed in the form code above, that I am pointing Paypal to some nice clean URLs, but can’t figure out how these line up with my IPN controller. I use CakePHP routes for nicer URLs.

See app/config/routes.php

<?php
    /* Paypal IPN Routes */
    Router::connect('/ipn', array('controller' => 'instant_payment_notifications', 'action' => 'process_ipn'));
    Router::connect('/thanks', array('controller' => 'orders', 'action' => 'thanks'));
    Router::connect('/cancel', array('controller' => 'pages', 'action' => 'cancel'));
?>

Validation Methods

This guide uses postbacks to validate your transaction. The preferred way to validate your transaction is via “shared secret” because it ensures the validity of the data while decreasing traffic two/from your site. However to implement this approach, you need dedicated hosting (not shared), SSL and you need to be using Encrypted Website Payments – making the postback method much cheaper to implement.

 Further Security for the postback method

Worried about the potential for fraud in the postback method? Don’t be! The great thing about the postback method is that you can compare the items in the IPN against customers cart contents (and transaction value) before returning the IPN back to Paypal (see comments in the controller code above) to check that the initial post variables have not been modified.

You can also perform the same (or a similar) check after you have received the “Verified”/”Invalid” message if you need to automate some back office tasks such as locking a user account or mailing an in-house fraud team.

 

Leave a Comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.