Advertisement

How to Sell Digital Goods with CodeIgniter

by
Student iconAre you a student? Get a yearly Tuts+ subscription for $45 →

In today's tutorial, you'll learn how to create a small web app to sell digital items (eg. eBooks) securely and accept payments from PayPal.
Please note that this tutorial requires PHP 5. Please upgrade your PHP installation if you're still running version 4.


Getting Started

Download the latest CodeIgniter release to your server, and set up your MySQL database (I named it digitalgoods) with the following SQL queries:

 
CREATE TABLE `ci_sessions` ( 
  `session_id` varchar(40) NOT NULL DEFAULT '0', 
  `ip_address` varchar(16) NOT NULL DEFAULT '0', 
  `user_agent` varchar(50) NOT NULL, 
  `last_activity` int(10) unsigned NOT NULL DEFAULT '0', 
  `user_data` text NOT NULL, 
  PRIMARY KEY (`session_id`) 
) ENGINE=MyISAM DEFAULT CHARSET=utf8; 
 
CREATE TABLE `downloads` ( 
  `id` int(11) NOT NULL AUTO_INCREMENT, 
  `item_id` int(11) DEFAULT NULL, 
  `purchase_id` int(11) DEFAULT NULL, 
  `download_at` int(11) DEFAULT NULL, 
  `ip_address` varchar(15) DEFAULT NULL, 
  `user_agent` varchar(255) DEFAULT NULL, 
  PRIMARY KEY (`id`) 
) ENGINE=MyISAM DEFAULT CHARSET=utf8; 
 
CREATE TABLE `items` ( 
  `id` int(11) NOT NULL AUTO_INCREMENT, 
  `name` varchar(255) DEFAULT NULL, 
  `description` text, 
  `price` decimal(10,2) DEFAULT NULL, 
  `file_name` varchar(255) DEFAULT NULL, 
  PRIMARY KEY (`id`) 
) ENGINE=MyISAM DEFAULT CHARSET=utf8; 
 
CREATE TABLE `purchases` ( 
  `id` int(11) NOT NULL AUTO_INCREMENT, 
  `item_id` int(11) DEFAULT NULL, 
  `key` varchar(255) DEFAULT NULL, 
  `email` varchar(127) DEFAULT NULL, 
  `active` tinyint(1) DEFAULT NULL, 
  `purchased_at` int(11) DEFAULT NULL, 
  `paypal_email` varchar(127) DEFAULT NULL, 
  `paypal_txn_id` varchar(255) DEFAULT NULL, 
  PRIMARY KEY (`id`) 
) ENGINE=MyISAM DEFAULT CHARSET=utf8; 
 
INSERT INTO `items` (`id`,`name`,`description`,`price`,`file_name`) 
VALUES 
  (1, 'Unix and CHMOD', 'Nulla fringilla, orci ac euismod semper, magna diam porttitor mauris, quis sollicitudin sapien justo in libero. Vestibulum mollis mauris enim. Morbi euismod magna ac lorem rutrum elementum.\n\nIn condimentum facilisis porta. Sed nec diam eu diam mattis viverra. Nulla fringilla, orci ac euismod semper, magna diam porttitor mauris, quis sollicitudin sapien justo in libero. Vestibulum mollis mauris enim. Morbi euismod magna ac lorem rutrum elementum. Donec viverra auctor lobortis. Pellentesque eu est a nulla placerat dignissim. Morbi a enim in magna semper bibendum. Etiam scelerisque, nunc ac egestas consequat, odio nibh euismod nulla, eget auctor orci nibh vel nisi.\n\nDonec viverra auctor lobortis. Pellentesque eu est a nulla placerat dignissim. Morbi a enim in magna semper bibendum. Etiam scelerisque, nunc. Morbi malesuada nulla nec purus convallis consequat. Vivamus id mollis quam. Morbi ac commodo nulla.', 19.99, 'UNIX and CHMOD.txt'), 
  (2, 'Intro to 8086 Programming', 'Nulla fringilla, orci ac euismod semper, magna diam porttitor mauris, quis sollicitudin sapien justo in libero. Vestibulum mollis mauris enim. Morbi euismod magna ac lorem rutrum elementum.\n\nMorbi malesuada nulla nec purus convallis consequat. Vivamus id mollis quam. Morbi ac commodo nulla.', 4.95, 'Intro to 8086 Programming.txt');

The first query creates CodeIgniter's default user sessions table. We then create a table to log file downloads, one to store the items and another to store purchase details. Finally, we insert a couple of items into the table.

We've inserted two items into the database, so we have to create those files on the server. In the root directory for your application (the same folder as CodeIgniter's system directory), create a new directory named files:


In that directory, create two text files, named UNIX and CHMOD.txt and Intro to 8086 Programming.txt. The capital letters are important on most web servers. These are the file names we set in the database for our two items. Enter some content in the files so we can be sure the files are being downloaded correctly.

CodeIgniter Configuration

In the system/application/config/database.php file, set your database settings in the fields provided:

 
$db['default']['hostname'] = "localhost"; 
$db['default']['username'] = "root"; 
$db['default']['password'] = ""; 
$db['default']['database'] = "digitalgoods";

In config/autoload.php set the following libraries and helpers:

 
$autoload['libraries'] = array( 'database', 'session', 'form_validation', 'email' ); 
... 
$autoload['helper'] = array( 'url', 'form', 'download', 'file' );

In config/config.php set the base_url:

 
$config['base_url']	= "http://localhost/digitalgoods/";

In the same file, paste the following to create our own configuration settings:

 
/* 
|-------------------------------------------------------------------------- 
| Website Name 
|-------------------------------------------------------------------------- 
| 
| Will be used on page title bars, in emails etc. 
| 
*/ 
$config['site_name'] = "Digital Goods"; 
 
/* 
|-------------------------------------------------------------------------- 
| Admin Email 
|-------------------------------------------------------------------------- 
| 
| Used to send confirmations of purchases 
| 
*/ 
$config['admin_email'] = "dan@example.com"; 
 
/* 
|-------------------------------------------------------------------------- 
| No-Reply Email 
|-------------------------------------------------------------------------- 
| 
| The 'No-Reply' address used to send out file downloads 
| 
*/ 
$config['no_reply_email'] = "noreply@example.com"; 
 
/* 
|-------------------------------------------------------------------------- 
| PayPal Account 
|-------------------------------------------------------------------------- 
| 
| The email address PayPal payments should be made to 
| 
*/ 
$config['paypal_email'] = "paypal@example.com"; 
 
/* 
|-------------------------------------------------------------------------- 
| Download Limit 
|-------------------------------------------------------------------------- 
| 
| How many times can an item be downloaded within a certain time frame? 
|   eg. 4 downloads within 7 days 
| 
*/ 
$config['download_limit'] = array( 
	'enable'	=> false, 
	'downloads'	=> '4', 
	'days'		=> '7' 
);

Set each of the new config options to your desired settings, but keep 'Download Limit' disabled for now.

Finally, under config/routes.php set the default controller:

 
$route['default_controller'] = "items";

View All Items

Now to create the main controller for the site, create a file in the controllers directory named items.php, and inside enter:

 
<?php  if (!defined('BASEPATH')) exit('No direct script access allowed'); 
 
class Items extends Controller { 
 
    function Items() { 
        parent::Controller(); 
        $this->load->model( 'items_model', 'Item' ); 
        $data['site_name'] = $this->config->item( 'site_name' ); 
        $this->load->vars( $data ); 
    } 
 
    function index() { 
        echo 'Hello, World!'; 
    } 
 
}

In the constructor we've loaded in the 'items_model' model (which we've named 'Item') which we'll create next, and placed the 'site_name' configuration setting into a variable which we can access in the views. For the index method, we've just set a simple 'Hello, World!' message for now.

Also, create a new model by creating new file in the models directory named items_model.php and inside enter:

 
<?php  if (!defined('BASEPATH')) exit('No direct script access allowed'); 
 
class Items_model extends Model { 
 
    function Items_model() { 
        parent::Model(); 
    } 
 
}

If you take a look at the site in your browser now, you should see a 'Hello, World!' message which is being served from the index() method in the controller.

Now that we have no errors, let's get all the items from the database and display them.

In your Items controller, edit the index() function to use the following lines:

 
$data['page_title'] = 'All Items'; 
$data['items'] = $this->Item->get_all(); 
$this->load->view( 'header', $data ); 
$this->load->view( 'items/list', $data ); 
$this->load->view( 'footer', $data );

On the first line, we set a the title for the page to 'All Items'. As usual, everything in the $data array will be extracted when it's passed to the view - so $data['page_title'] can be accessed simply as $page_title.

Following that, we call a get_all() method from the Item model (which we haven't yet created) and place the result in what will become the $items variable.

On the three following lines, we load three view files.

The View

Inside the application/system/views/ directory, create the following file/folder structure: (header.php, footer.php, and an items/ directory with index.php inside).


header.php and footer.php are universal in our app and will be used on every page, and for each controller we will create a separate views folder (items/) and each method will have a view file named after it (index.php).

Inside header.php enter the following HTML5:

 
<!DOCTYPE html> 
<html lang="en">  
<head>  
  <meta charset="UTF-8" /> 
  <link rel="stylesheet" href="<?php echo base_url(); ?>css/style.css" type="text/css" /> 
  <title><?php echo $page_title . ' | ' . $site_name; ?></title> 
</head>  
<body> 
 
<div id="wrap"> 
 
  <header> 
    <h1><?php echo anchor( '', $site_name ); ?></h1> 
  </header> 
 
  <section>

On line 5 we're including a CSS file we'll create later which will be located at the root of our site's file structure.

On line 6 we use both the $page_title and $site_title variables to create a relevant title for the page.

Finally, on line 13 we use CodeIgniter's anchor() method (from the URL Helper) to create a link to the site's homepage.

Inside footer.php enter:

 
  </section> 
   
  <footer> 
    <?php $copyright = ( date( 'Y' > 2010 ) ) ? '2010&ndash;' . date( 'Y' ) : '2010'; ?> 
    <p><small> 
      Copyright &copy; <?php echo anchor( '', $site_name ) . ' ' . $copyright; ?>. 
    </small></p> 
  </footer> 
 
</div><!-- /wrap --> 
</body> 
</html>

Line 4 sets the dates for the copyright notice. If the current year is 2010 then only the year 2010 will display in the notice. If the current year is later than 2010, say 2013, the date in the copyright notice would display as "2010–2013".

Retrieving and Displaying Items

In the controller, you will recall we called a get_all() method from the Items model. So inside models/items_model.php type:

 
function get_all() { 
  return $this->db->get( 'items' )->result(); 
}

This one line of code will retrieve all entries from the 'items' table in the database as PHP objects and return them in an array.

Now, to display the items, enter the following in views/items/index.php:

 
<?php 
if ( ! $items ) : 
  echo '<p>No items found.</p>'; 
 
else : 
  echo '<h2>Items</h2>'; 
  echo '<ul>'; 
 
  foreach ( $items as $item ) { 
    $segments = array( 'item', url_title( $item->name, 'dash', true ), $item->id ); 
    echo '<li>' . anchor( $segments, $item->name ) . ' &ndash; $' . $item->price . '</li>'; 
  } 
 
  echo '</ul>'; 
 
endif;

First, we check whether we retrieved any items from the database and display a message saying so if no items were found.

If items were retrieved from the database, we display a title and open an unordered list. We then loop through each item with foreach().

CodeIgniter returns each entry from the database as an object, so you can access an item's details with $item->name, $item->id, $item->price etc. for each field in the database.

We're going to give each item a SEO-friendly URL, with the item's name in it. For example: http://example.com/item/unix-and-chmod/1/ (the name being in the second segment and the ID in the third).

At line 10 we set the $segments variable to an array containing data which will be converted into a URL (each item in the array will be a segment of the URL). The first segment is simply 'item', then we use the CodeIgniter's url_title() function to make the item's name URL-friendly (removing capital letters and replacing spaces with dashes). And the third segment is the item's ID.

On the next line we create a new list item and pass the $segments to anchor() to create a URL. We also display the item's price. The loop is then closed off, the list closed and the 'if' statement terminated.

Refresh the page in your browser and you should see the two items from the database:



View Single Items

You may be wondering why the URL segments for a single item are item/unix-and-chmod/1 as in CodeIgniter that means we're pointing at a controller named 'item' with a method of 'unix-and-chmod' which doesn't quite make sense as our controller is named 'items' and having a separate method for each item is madness. Well, you're right. We're going to use CodeIgniter's 'Routes' to forward all 'item' requests to a 'details' method in our 'items' controller to help keep our URLs short.

Inside system/application/config/routes.php, after the 'default_controller' and 'scaffolding_trigger' options add:

 
$route['item/:any'] = 'items/details';

What this piece of code does is internally forward all requests to item/ (an 'item' controller) to items/details ('items' controller - 'details' method).

So inside controllers/items.php add the following method:

 
function details() { // ROUTE: item/{name}/{id} 
  echo 'Hello, World!'; 
}

When writing methods which are the result of a route, as this one is, I like to include a comment describing the route's path.

Click the link for either item on the main page and you should see the infamous 'Hello, World!' message.

Now we're sure routes are working fine, replace the 'Hello, World!' echo statement with the following:

 
$id = $this->uri->segment( 3 ); 
$item = $this->Item->get( $id ); 
 
if ( ! $item ) { 
  $this->session->set_flashdata( 'error', 'Item not found.' ); 
  redirect( 'items' ); 
} 
 
$data['page_title'] = $item->name; 
$data['item'] = $item; 
 
$this->load->view( 'header', $data ); 
$this->load->view( 'items/details', $data ); 
$this->load->view( 'footer', $data );

On the first line we grab the ID from the third segment of the URL, then use it to get the item with that ID (we'll create the get() method in the model after).

If the item can't be found in the database, we set an 'Item not found' error message in the user's session and redirect them back to the main page.

We set the page title to the item's name, make the item accessible in the view and load the relevant view files.

Model – Get a Single Item

Inside models/items_model.php add the following method:

 
function get( $id ) { 
  $r = $this->db->where( 'id', $id )->get( 'items' )->result(); 
  if ( $r ) return $r[0]; 
  return false; 
}

On the first line we query the database for an entry with the provided ID in the 'items' table. We get the result and store it in variable $r.

If an entry was found, we return the first array item. Otherwise, we return false.

Displaying Errors

In our controller we set an error message in the user's session using set_flashdata() if an item isn't found. To display that error in the browser, add the following to the bottom of views/header.php:

 
<?php 
if ( $this->session->flashdata( 'success' ) ) 
  echo '<p class="success">' . $this->session->flashdata( 'success' ) . '</p>'; 
 
if ( $this->session->flashdata( 'error' ) ) 
  echo '<p class="error">' . $this->session->flashdata( 'error' ) . '</p>'; 
?>

This simply displays a 'success' or 'error' message if either exist in the user's session.

Note: Anything stored in 'flashdata' (as our success and error messages are) are displayed only on the next page the user loads - CodeIgniter clears them on the next page load so they only display once.

Single Item View

Create a file named views/items/details.php and type the following inside:

 
<h2><?php echo $item->name . '&ndash; $' . $item->price; ?></h2> 
 
<p><?php echo nl2br( $item->description ); ?></p> 
 
<?php $segments = array( 'purchase', url_title( $item->name, 'dash', true ), $item->id ); ?> 
<p class="purchase"><?php echo anchor( $segments, 'Purchase' ); ?></p>

Here, we're simply displaying the item's name and price in the header, we use the nl2br() function to convert SQL-style linebreaks in the item's description into HTML-style <br /> tags. We then create a SEO-friendly link to a purchase page (for example http://example.com/purchase/unix-and-chmod/1).



Purchase Page

First we need to add a new route to direct requests for a 'purchase' link to 'items/purchase' instead - just as we did with the single item pages. Add the following to config/routes.php:

 
$route['purchase/:any'] = 'items/purchase';

Now, inside controllers/items.php add the following 'purchase' method:

 
function purchase() { // ROUTE: purchase/{name}/{id} 
  $item_id = $this->uri->segment( 3 );  
  $item = $this->Item->get( $item_id ); 
   
  if ( ! $item ) { 
    $this->session->set_flashdata( 'error', 'Item not found.' ); 
    redirect( 'items' ); 
  } 
   
  $data['page_title'] = 'Purchase &ldquo;' . $item->name . '&rdquo;'; 
  $data['item'] = $item; 
   
  $this->load->view( 'header', $data ); 
  $this->load->view( 'items/purchase', $data ); 
  $this->load->view( 'footer', $data ); 
}

It's basically the same as the single item view, just with 'Purchase “ ... ” ' in the page title and we're loading the items/purchase view instead.

The View

Create a file at views/items/purchase.php with the following inside:

 
<h2>Purchase</h2> 
 
<?php $segments = array( 'item', url_title( $item->name, 'dash', true ), $item->id ); ?> 
<p>To purchase &ldquo;<?php echo anchor( $segments, $item->name ); ?>&rdquo;, enter your email 
address below and click through to pay with PayPal. Upon confirmation of your payment, we will 
email you your download link to the address you enter below.</p> 
 
<?php 
$url_title = url_title( $item->name, 'dash', true ); 
echo form_open( 'purchase/' . $url_title . '/' . $item->id ); 
echo validation_errors( '<p class="error">', '</p>' ); 
?> 
  <p> 
    <label for="email">Email:</label> 
    <input type="email" name="email" id="email" /> &nbsp; 
    <input type="submit" value="Pay $<?php echo $item->price; ?> via PayPal" /> 
  </p> 
</form>

Here we use CodeIgniter's form helper to create the opening tag for the form which directs back to the current page. In the form we collect the user's email address so we can send them their download link after once we receive confirmation of their purchase from PayPal.


Interfacing with PayPal

To communicate with PayPal, we're going to use a version of the PayPal Lib CodeIgniter library by Ran Aroussi which I modified to include the recommended changes from the Wiki page and add easy support for PayPal's Sandbox for development purposes.

PayPal Lib

Create a new file under application/config/ named paypallib_config.php with the following inside:

 
<?php  if (!defined('BASEPATH')) exit('No direct script access allowed'); 
 
// ------------------------------------------------------------------------ 
// Ppal (Paypal IPN Class) 
// ------------------------------------------------------------------------ 
 
// If (and where) to log ipn to file 
$config['paypal_lib_ipn_log_file'] = BASEPATH . 'logs/paypal_ipn.log'; 
$config['paypal_lib_ipn_log'] = TRUE; 
 
// Where are the buttons located at  
$config['paypal_lib_button_path'] = 'buttons'; 
 
// What is the default currency? 
$config['paypal_lib_currency_code'] = 'USD'; 
 
// Enable Sandbox mode? 
$config['paypal_lib_sandbox_mode'] = TRUE;

And inside application/libraries/ create a file named Paypal_Lib.php (the capital letters are important) with the following inside:

 
<?php if (!defined('BASEPATH')) exit('No direct script access allowed');  
/** 
 * Code Igniter 
 * 
 * An open source application development framework for PHP 4.3.2 or newer 
 * 
 * @package		CodeIgniter 
 * @author		Rick Ellis 
 * @copyright	Copyright (c) 2006, pMachine, Inc. 
 * @license		http://www.codeignitor.com/user_guide/license.html 
 * @link		http://www.codeigniter.com 
 * @since		Version 1.0 
 * @filesource 
 */ 
 
// ------------------------------------------------------------------------ 
 
/** 
 * PayPal_Lib Controller Class (Paypal IPN Class) 
 * 
 * This CI library is based on the Paypal PHP class by Micah Carrick 
 * See www.micahcarrick.com for the most recent version of this class 
 * along with any applicable sample files and other documentaion. 
 * 
 * This file provides a neat and simple method to interface with paypal and 
 * The paypal Instant Payment Notification (IPN) interface.  This file is 
 * NOT intended to make the paypal integration "plug 'n' play". It still 
 * requires the developer (that should be you) to understand the paypal 
 * process and know the variables you want/need to pass to paypal to 
 * achieve what you want.   
 * 
 * This class handles the submission of an order to paypal as well as the 
 * processing an Instant Payment Notification. 
 * This class enables you to mark points and calculate the time difference 
 * between them.  Memory consumption can also be displayed. 
 * 
 * The class requires the use of the PayPal_Lib config file. 
 * 
 * @package     CodeIgniter 
 * @subpackage  Libraries 
 * @category    Commerce 
 * @author      Ran Aroussi <ran@aroussi.com> 
 * @copyright   Copyright (c) 2006, http://aroussi.com/ci/ 
 * 
 */ 
 
// ------------------------------------------------------------------------ 
 
class Paypal_Lib { 
 
	var $last_error;			// holds the last error encountered 
	var $ipn_log;				// bool: log IPN results to text file? 
 
	var $ipn_log_file;			// filename of the IPN log 
	var $ipn_response;			// holds the IPN response from paypal	 
	var $ipn_data = array();	// array contains the POST values for IPN 
	var $fields = array();		// array holds the fields to submit to paypal 
 
	var $submit_btn = '';		// Image/Form button 
	var $button_path = '';		// The path of the buttons 
 
	var $CI; 
 
	function Paypal_Lib() 
	{ 
		$this->CI =& get_instance(); 
		$this->CI->load->helper('url'); 
		$this->CI->load->helper('form'); 
		$this->CI->load->config('paypallib_config'); 
 
		$this->paypal_url = 'https://www.paypal.com/cgi-bin/webscr'; 
		if ( $this->CI->config->item('paypal_lib_sandbox_mode') ) 
			$this->paypal_url = 'https://www.sandbox.paypal.com/cgi-bin/webscr'; 
 
		$this->last_error = ''; 
		$this->ipn_response = ''; 
 
		$this->ipn_log_file = $this->CI->config->item('paypal_lib_ipn_log_file'); 
		$this->ipn_log = $this->CI->config->item('paypal_lib_ipn_log');  
 
		$this->button_path = $this->CI->config->item('paypal_lib_button_path'); 
 
		// populate $fields array with a few default values.  See the paypal 
		// documentation for a list of fields and their data types. These defaul 
		// values can be overwritten by the calling script. 
		$this->add_field('rm','2');			  // Return method = POST 
		$this->add_field('cmd','_xclick'); 
 
		$this->add_field('currency_code', $this->CI->config->item('paypal_lib_currency_code')); 
	    $this->add_field('quantity', '1'); 
		$this->button('Pay Now!'); 
	} 
 
	function button($value) 
	{ 
		// changes the default caption of the submit button 
		$this->submit_btn = form_submit('pp_submit', $value); 
	} 
 
	function image($file) 
	{ 
		$this->submit_btn = '<input type="image" name="add" src="' . base_url() . $this->button_path .'/'. $file . '" border="0" />'; 
	} 
 
 
	function add_field($field, $value)  
	{ 
		// adds a key=>value pair to the fields array, which is what will be  
		// sent to paypal as POST variables.  If the value is already in the  
		// array, it will be overwritten. 
		$this->fields[$field] = $value; 
	} 
 
	function paypal_get_request_link() { 
		$url = $this->paypal_url . '?'; 
 
		foreach ( $this->fields as $name => $value ) 
			$url .= $name . '=' . $value . "&"; 
 
		return $url; 
	} 
 
	function paypal_auto_form()  
	{ 
		// this function actually generates an entire HTML page consisting of 
		// a form with hidden elements which is submitted to paypal via the  
		// BODY element's onLoad attribute.  We do this so that you can validate 
		// any POST vars from you custom form before submitting to paypal.  So  
		// basically, you'll have your own form which is submitted to your script 
		// to validate the data, which in turn calls this function to create 
		// another hidden form and submit to paypal. 
 
		$this->button('Click here if you\'re not automatically redirected...'); 
 
		echo '<html>' . "\n"; 
		echo '<head><title>Processing Payment...</title></head>' . "\n"; 
		echo '<body onLoad="document.forms[\'paypal_auto_form\'].submit();">' . "\n"; 
		echo '<p>Please wait, your order is being processed and you will be redirected to the paypal website.</p>' . "\n"; 
		echo $this->paypal_form('paypal_auto_form'); 
		echo '</body></html>'; 
	} 
 
	function paypal_form($form_name='paypal_form')  
	{ 
		$str = ''; 
		$str .= '<form method="post" action="'.$this->paypal_url.'" name="'.$form_name.'"/>' . "\n"; 
		foreach ($this->fields as $name => $value) 
			$str .= form_hidden($name, $value) . "\n"; 
		$str .= '<p>'. $this->submit_btn . '</p>'; 
		$str .= form_close() . "\n"; 
 
		return $str; 
	} 
 
	function validate_ipn() 
	{ 
		// parse the paypal URL 
		$url_parsed = parse_url($this->paypal_url);		   
 
		// generate the post string from the _POST vars aswell as load the 
		// _POST vars into an arry so we can play with them from the calling 
		// script. 
		$post_string = '';	  
		if (isset($_POST)) 
        { 
            foreach ($_POST as $field=>$value) 
            {       // str_replace("\n", "\r\n", $value) 
                    // put line feeds back to CR+LF as that's how PayPal sends them out 
                    // otherwise multi-line data will be rejected as INVALID 
 
                $value = str_replace("\n", "\r\n", $value); 
                $this->ipn_data[$field] = $value; 
                $post_string .= $field.'='.urlencode(stripslashes($value)).'&'; 
 
            } 
        } 
 
		$post_string.="cmd=_notify-validate"; // append ipn command 
 
		// open the connection to paypal 
		$fp = fsockopen($url_parsed['host'],"80",$err_num,$err_str,30);  
		if(!$fp) 
		{ 
			// could not open the connection.  If loggin is on, the error message 
			// will be in the log. 
			$this->last_error = "fsockopen error no. $errnum: $errstr"; 
			$this->log_ipn_results(false);		  
			return false; 
		}  
		else 
		{  
			// Post the data back to paypal 
			fputs($fp, "POST $url_parsed[path] HTTP/1.1\r\n");  
			fputs($fp, "Host: $url_parsed[host]\r\n");  
			fputs($fp, "Content-type: application/x-www-form-urlencoded\r\n");  
			fputs($fp, "Content-length: ".strlen($post_string)."\r\n");  
			fputs($fp, "Connection: close\r\n\r\n");  
			fputs($fp, $post_string . "\r\n\r\n");  
 
			// loop through the response from the server and append to variable 
			while(!feof($fp)) 
				$this->ipn_response .= fgets($fp, 1024);  
 
			fclose($fp); // close connection 
		} 
 
		if (eregi("VERIFIED",$this->ipn_response)) 
		{ 
			// Valid IPN transaction. 
			$this->log_ipn_results(true); 
			return true;		  
		}  
		else  
		{ 
			// Invalid IPN transaction.  Check the log for details. 
			$this->last_error = 'IPN Validation Failed.'; 
			$this->log_ipn_results(false);	 
			return false; 
		} 
	} 
 
	function log_ipn_results($success)  
	{ 
		if (!$this->ipn_log) return;  // is logging turned off? 
 
		// Timestamp 
		$text = '['.date('m/d/Y g:i A').'] - ';  
 
		// Success or failure being logged? 
		if ($success) $text .= "SUCCESS!\n"; 
		else $text .= 'FAIL: '.$this->last_error."\n"; 
 
		// Log the POST variables 
		$text .= "IPN POST Vars from Paypal:\n"; 
		foreach ($this->ipn_data as $key=>$value) 
			$text .= "$key=$value, "; 
 
		// Log the response from the paypal server 
		$text .= "\nIPN Response from Paypal Server:\n ".$this->ipn_response; 
 
		// Write to log 
		$fp=fopen($this->ipn_log_file,'a'); 
		fwrite($fp, $text . "\n\n");  
 
		fclose($fp);  // close file 
	} 
 
 
	function dump()  
	{ 
		// Used for debugging, this function will output all the field/value pairs 
		// that are currently defined in the instance of the class using the 
		// add_field() function. 
 
		ksort($this->fields); 
		echo '<h2>ppal->dump() Output:</h2>' . "\n"; 
		echo '<code style="font: 12px Monaco, \'Courier New\', Verdana, Sans-serif;  background: #f9f9f9; border: 1px solid #D0D0D0; color: #002166; display: block; margin: 14px 0; padding: 12px 10px;">' . "\n"; 
		foreach ($this->fields as $key => $value) echo '<strong>'. $key .'</strong>:	'. urldecode($value) .'<br/>'; 
		echo "</code>\n"; 
	} 
 
}

Send Details to PayPal

When a user enters their email address into the form on the 'Purchase' page, we want to:

  1. Verify the user has entered an email address;
  2. Add their email address to our database, along with a random key which we'll use to reference their purchase;
  3. Send the item's details, along with the user's random key, to PayPal to process payment.

Inside controllers/items.php, add the following before the $data['page_title'] = ... line inside the purchase() method:

 
$this->form_validation->set_rules( 'email', 'Email', 'required|valid_email|max_length[127]' ); 
 
if ( $this->form_validation->run() ) { 
  $email = $this->input->post( 'email' ); 
   
  $key = md5( $item_id . time() . $email . rand() ); 
  $this->Item->setup_payment( $item->id, $email, $key ); 
   
  $this->load->library( 'Paypal_Lib' ); 
  $this->paypal_lib->add_field( 'business', $this->config->item( 'paypal_email' )); 
  $this->paypal_lib->add_field( 'return', site_url( 'paypal/success' ) ); 
  $this->paypal_lib->add_field( 'cancel_return', site_url( 'paypal/cancel' ) ); 
  $this->paypal_lib->add_field( 'notify_url', site_url( 'paypal/ipn' ) ); // <-- IPN url 
   
  $this->paypal_lib->add_field( 'item_name', $item->name ); 
  $this->paypal_lib->add_field( 'item_number', '1' ); 
  $this->paypal_lib->add_field( 'amount', $item->price ); 
   
  $this->paypal_lib->add_field( 'custom', $key ); 
   
  redirect( $this->paypal_lib->paypal_get_request_link() ); 
}

On the first line we check that the submitted 'email' form field is a valid email address. Everything inside the if loop on line 3 will run if the 'email' field validated.

On line 6 we generate a unique key for the current purchase by hashing the item's ID, the current time, the user's email address and a random number using a MD5 hash.

Line 7 adds the item ID, user's email and the random key to the database via the setup_payment() method we have yet to create.

The rest of the code loads the 'Paypal_Lib' library and adds details about the item and site using the PayPal library's functions. We also pass the random key we generated to PayPal using the 'custom' field. PayPal will send this key back to us upon payment confirmation so we can activate the user's download.

Finally, we redirect the user to the PayPal payment page.

Now we need to create the setup_payment() function in the model. So inside models/items_model.php add the following:

 
function setup_payment( $item_id, $email, $key ) { 
  $data = array( 
    'item_id'  => $item_id, 
    'key'      => $key, 
    'email'    => $email, 
    'active'   => 0 // hasn't been purchased yet 
  ); 
  $this->db->insert( 'purchases', $data ); 
}

This is pretty simple: we create an array containing the provided item ID, user's email address and random key. We also set 'active' to 0 (this gets set to '1' upon payment confirmation). The array is then inserted into the 'purchases' table.

PayPal's Process

With the system we're using to interface with PayPal, once a user has completed payment, PayPal will send the user to a 'success' URL which we provide. This page will simply say "Your payment has been received. We're currently processing it".

Behind-the-scenes, PayPal will send our 'IPN listener' confirmation of the payment and some details about it and the listener sends these details back to PayPal to confirm the message is genuine. It's after our IPN listener receives this second confirmation that we process the purchase and activate the user's download.

We're going to create a new controller to handle these requests from PayPal. So inside controllers/ create a file named paypal.php and enter the following inside:

 
<?php  if (!defined('BASEPATH')) exit('No direct script access allowed'); 
 
class Paypal extends Controller { 
 
  function Paypal() { 
    parent::Controller(); 
    $this->load->model( 'items_model', 'Item' ); 
    $this->load->library( 'Paypal_Lib' ); 
    $data['site_name'] = $this->config->item( 'site_name' ); 
    $this->load->vars( $data ); 
  } 
 
  function index() { 
    redirect( 'items' ); 
  } 
 
  function success() { 
    $this->session->set_flashdata( 'success', 'Your payment is being processed now. Your download link will be emailed to your shortly.' ); 
    redirect( 'items' ); 
  } 
 
  function cancel() { 
    $this->session->set_flashdata( 'success', 'Payment cancelled.' ); 
    redirect( 'items' ); 
    } 
 
}

That's the 'success' and 'cancel' pages (cancel is used with a user doesn't continue with the payment and clicks the cancel button on PayPal instead).

PayPal Developer Tools

PayPal provide a 'Sandbox' for developers to test their code with. You can create your own sandbox PayPal addresses to send pretend payments. Sign up for a free developer account at https://developer.paypal.com/ then go to 'Create a preconfigured buyer or seller account':


Fill out the form to create a new buyer account, enter a balance and click on through. On the 'Test Accounts' page you will find the email address for your new Sandbox buyer email address:


Now go back to the site we're creating and click through to purchase an item. Notice that when you get to PayPal, the address is https://www.sandbox.paypal.com/..... Login with the 'buyer' account you created on the right:


Continue through the payment process and click the 'Return to Merchant' button upon completion. You should be directed back to your homepage, with the "Your payment is being processed now. Your download link will be emailed to your shortly." message below the header.


The main interface of the site is now complete. We just need to add in our IPN listener and email the item to the buyer.


IPN Listener

As mentioned above, once PayPal has confirmed payment, it will send data to our IPN listener, once we validate the data with PayPal (to prevent fraudulent data), we can use the data to activate the buyer's purchase.

The IPN function is a little big, so I'll break it up. Inside the PayPal controller, add the following function:

 
function ipn() { 
  if ( $this->paypal_lib->validate_ipn() ) { 
    $item_name = $this->paypal_lib->ipn_data['item_name']; 
    $price = $this->paypal_lib->ipn_data['mc_gross']; 
    $currency = $this->paypal_lib->ipn_data['mc_currency']; 
    $payer_email = $this->paypal_lib->ipn_data['payer_email']; 
    $txn_id = $this->paypal_lib->ipn_data['txn_id']; 
    $key = $this->paypal_lib->ipn_data['transaction_subject']; 
     
    $this->Item->confirm_payment( $key, $payer_email, $txn_id ); 
    $purchase = $this->Item->get_purchase_by_key( $key ); 
    $item = $this->Item->get( $purchase->item_id ); 
  } 
}

Right at the start we validate the data sent to the listener with PayPal – the library takes care of all this. If the data is valid, we grab a some details (the item name, price, currency, the payer's PayPal email address, the transaction ID and the unique key we sent to PayPal when the payment process began).

We can then use the key to confirm the payment (by setting the 'active' field to '1') and add the payer's PayPal email and transaction ID to the database for future reference.

Using the key we can get the purchase details from the database, along with the item purchased. Before continuing with the IPN function, let's create the confirm_payment() and get_purchase_by_key() model methods.

So inside the model, add the following:

 
function confirm_payment( $key, $paypal_email, $payment_txn_id ) { 
  $data = array( 
    'purchased_at'  => time(), 
    'active'        => 1, 
    'paypal_email'  => $paypal_email, 
    'paypal_txn_id' => $paypal_txn_id 
  ); 
  $this->db->where( 'key', $key ); 
  $this->db->update( 'purchases', $data ); 
} 
 
function get_purchase_by_key( $key ) { 
  $r = $this->db->where( 'key', $key )->get( 'purchases' )->result(); 
  if ( $r ) return $r[0]; 
  return false; 
}

The functions should be pretty self explanatory by now, so now we need to email the customer their download link. This is handled in the IPN listender, so back in the controller add the following to the end of the ipn() function:

 
// Send download link to customer 
$to = $purchase->email; 
$from = $this->config->item( 'no_reply_email' ); 
$name = $this->config->item( 'site_name' ); 
$subject = $item->name . ' Download'; 
 
$segments = array( 'item', url_title( $item->name, 'dash', true ), $item->id ); 
$message = '<p>Thanks for purchasing ' . anchor( $segments, $item->name ) . ' from ' . anchor( '', $name ) . '. Your download link is below.</p>'; 
$message .= '<p>' . anchor( 'download/' . $key ) . '</p>'; 
 
$this->email->from( $from, $name ); 
$this->email->to( $to ); 
$this->email->subject( $subject ); 
$this->email->message( $message ); 
$this->email->send(); 
$this->email->clear();

Here we're using CodeIgniter's Email class to send the email. We start by setting up 'To', 'From', 'Name' and 'Subject' variables with the relevant data.

We then write a short message for the body with a link to the file they purchased, followed by their download link (which will be in the format of: http://example.com/download/{key}). Finally we add the variables into the Email class methods and send it.

The final thing we need in the IPN listener is to send the site's admin an email with the transaction details. Add the following to the end of the ipn() function:

 
// Send confirmation of purchase to admin 
$message = '<p><strong>New Purchase:</strong></p><ul>'; 
$message .= '<li><strong>Item:</strong> ' . anchor( $segments, $item->name ) . '</li>'; 
$message .= '<li><strong>Price:</strong> $' . $item->price . '</li>'; 
$message .= '<li><strong>Email:</strong> ' . $purchase->email . '</li><li></li>'; 
$message .= '<li><strong>PayPal Email:</strong> ' . $payer_email . '</li>'; 
$message .= '<li><strong>PayPal TXN ID:</strong> ' . $txn_id . '</li></ul>'; 
$this->email->from( $from, $name ); 
$this->email->to( $this->config->item( 'admin_email' ) ); 
$this->email->subject( 'A purchase has been made' ); 
$this->email->message( $message ); 
$this->email->send(); 
$this->email->clear();

IMPORTANT! The IPN listener won't work if you're running on a local server ('localhost'). Clearly if PayPal attempted to visit http://localhost/paypal/ipn/ they're not going to arrive at your system. Upload your files to a remote server accessible by a domain name, or external IP address, for this to work.


File Downloads

The final step to getting the site fully working is to get the download links to work. When a customer goes to the download link we email them (eg. http://example.com/download/{key}), we use their key to look up the download. If the purchase associated with the key is set to active (payment fulfilled) and the file exists on the server, the download will start.

First thing we need to do is add another route to set /download/ requests to go to items/download. Add the following to your config/routes.php file:

 
$route['download/:any'] = 'items/download';

Now, inside your items controller, add the following download() method:

 
function download() { // ROUTE: download/{purchase_key} 
  $key = $this->uri->segment( 2 ); 
  $purchase = $this->Item->get_purchase_by_key( $key ); 
   
  // Check purchase was fulfilled 
  if ( ! $purchase ) { 
    $this->session->set_flashdata( 'error', 'Download key not valid.' ); 
    redirect( 'items' ); 
  } 
  if ( $purchase->active == 0 ) { 
    $this->session->set_flashdata( 'error', 'Download not active.' ); 
    redirect( 'items' ); 
  } 
   
  // Get item and initiate download if exists 
  $item = $this->Item->get( $purchase->item_id ); 
   
  $file_name = $item->file_name; 
  $file_data = read_file( 'files/' . $file_name ); 
   
  if ( ! $file_data ) { // file not found on server 
    $this->session->set_flashdata( 'error', 'The requested file was not found. Please contact us to resolve this.' ); 
    redirect( 'items' ); 
  } 
	 
  force_download( $file_name, $file_data ); 
}

In the first couple lines we lookup the purchase using the key in the URL. On line 6, if we can't find a purchase record with that key, we set an error message that the key is invalid and redirect the user to the homepage. Similarly, on line 10, if the purchase was not fulfilled (active is '0'), we display an error.

We then retrieve the actual item from the database, and retrieve the file name. We use the read_file() method from CodeIgniter's File helper to get the contents of the file. If the file can't be found (or is empty), we display an error. Otherwise, we use the force_download() method to initiate a download.

Item Theft

We do have one problem—it's possible to guess the name of a file and download it directly (ie. by visiting http://example.com/files/UNIX and CHMOD.txt). To fix this, simply create a file named .htaccess inside the files/ directory with the following line inside:

 
deny from all

This tells the server to deny any requests for files in this directory – but the server itself can still access the files so buyers can still download using their own unique link.


Stylish

At the root of your app (same location as the files and system directories), create a new directory named css, inside it create a file named style.css and add the following inside to spice things up a little:

 
body { 
  background-color: #f9f9f9; 
  color: #222; 
  font-family: sans-serif; 
} 
 
#wrap { 
  background-color: #fff; 
  border: 1px solid #ddd; 
  border-radius: 5px; 
  -moz-border-radius: 5px; 
  -webkit-border-radius: 5px; 
  box-shadow: #e6e6e6 0 0 15px; 
  -moz-box-shadow: #e6e6e6 0 0 15px; 
  -webkit-box-shadow: #e6e6e6 0 0 15px; 
  margin: 15px auto; 
  padding: 15px; 
  width: 760px; 
} 
 
a { 
  color: #24badb; 
  text-decoration: none; 
} 
 
header h1 { 
  text-align: center; 
} 
 
header a { 
  color: #222; 
  padding: 7px 10px; 
} 
 
header a:hover { 
  background-color: #222; 
  color: #fff; 
} 
 
li { 
  margin-bottom: 10px; 
} 
 
section { 
  line-height: 1.5em; 
} 
 
section a { 
  padding: 3px 4px; 
} 
 
section a:hover { 
  background-color: #24badb; 
  color: #fff; 
} 
 
footer { 
  color: #bbb; 
  text-align: right; 
} 
 
footer a { 
  color: #bbb; 
} 
 
footer a:hover { 
  color: #a0a0a0; 
}


BONUS: Download Limits

You may want to limit how often a user may download their purchase within a certain time period (perhaps to stop them sharing the download link around). Implementing this feature doesn't take much work, so let's add it in now.

We already set up a database table to log file downloads–'downloads'–which we haven't used yet. We also have the 'Download Limit' setting in the config/config.php file, so go ahead and 'enable' it now:

 
/* 
|-------------------------------------------------------------------------- 
| Download Limit 
|-------------------------------------------------------------------------- 
| 
| How many times can an item be downloaded within a certain time frame? 
|   eg. 4 downloads within 7 days 
| 
*/ 
$config['download_limit'] = array( 
	'enable'	=> true, 
	'downloads'	=> '4', 
	'days'		=> '7' 
);

The default setting is to allow up to four file downloads in a seven day period. If, for example, the buyer tries to download five times in seven days, we'll forward them back to the home page and display an error explaining why we can't serve up their download right now.

The first thing we need to do is keep a log every time a download is initiated. To do this, add the following directly before the force_download(...) statement at the end of the download() function in the Items controller:

 
$this->Item->log_download( $item->id, $purchase->id, $this->input->ip_address(), $this->input->user_agent() );

Here we're sending the item id, purchase id and the user's IP address and user agent to a log_download() method in the Items model which we'll create next.

Add the following method to your Items_model:

 
function log_download( $item_id, $purchase_id, $ip_address, $user_agent ) { 
  $data = array( 
    'item_id'      => $item_id, 
    'purchase_id'  => $purchase_id, 
    'download_at'  => time(), 
    'ip_address'   => $ip_address, 
    'user_agent'   => $user_agent 
  ); 
  $this->db->insert( 'downloads', $data ); 
}

This simply adds the data we provided, and the current time, to the 'downloads' table.

We'll also need a method to get the downloads of a purchase, so add the following to the model:

 
function get_purchase_downloads( $purchase_id, $limit ) { 
  return $this->db->where( 'purchase_id', $purchase_id )->limit( $limit )->order_by( 'id', 'desc' )->get( 'downloads' )->result(); 
}

Now, to actually add in the download limit, find the following piece of code in the download() method in the Items controller:

 
// Check purchase was fulfilled 
if ( ! $purchase ) { 
  $this->session->set_flashdata( 'error', 'Download key not valid.' ); 
  redirect( 'items' ); 
} 
if ( $purchase->active == 0 ) { 
  $this->session->set_flashdata( 'error', 'Download not active.' ); 
  redirect( 'items' ); 
}

Directly after this, add the following:

 
// Check download limit 
$download_limit = $this->config->item( 'download_limit' ); 
if ( $download_limit['enable'] ) { 
  $downloads = $this->Item->get_purchase_downloads( $purchase->id, $download_limit['downloads'] ); 
  $count = 0; 
  $time_limit = time() - (86400 * $download_limit['days']); 
  foreach ( $downloads as $download ) { 
    if ( $download->download_at >= $time_limit ) 
      $count++; // download within past x days 
    else 
      break; // later than x days, so can stop foreach 
  } 
 
  // If over download limit, error 
  if ( $count >= $download_limit['downloads'] ) { // can only download x times within y days 
    $this->session->set_flashdata( 'error', 'You can only download a file ' . $download_limit['downloads'] . ' times in a ' . $download_limit['days'] . ' day period. Please try again later.' ); 
    redirect( 'items' ); 
  } 
}

On the third line we check whether the 'Download Limit' functionality is enabled in the config file. If it is, we retrieve the downloads of the current purchase (limited to how many file downloads is permitted).

At line 6, we calculate the furthest time away downloads are limited to (eg. if we have a limit of 4 downloads in 7 days, we find the time 7 days ago) by multiplying the number of days by 86400 (the number of seconds in a day) and subtracting it from the current time.

The loop starting at line 7 checks each download logged to see if it was downloaded within the time limit (eg. 7 days). If it is, we increase $count, otherwise, we break out of the loop as we know if this logged download is older than the limit, all subsequent logs will be to.

At line 15, if the $count is greater than the number of downloads allowed, we display an error message. Otherwise, the rest of the code will be executed and the download will be logged and initiated.


We're done. Try it out!

Note: To disable the PayPal 'sandbox' mode so you can recieve real payments, change the $config['paypal_lib_sandbox_mode'] option in config/paypallib_config.php to false.

Advertisement