Updates
Introduction
This is a quick writing to share one method of managing PayPal Instant Payment Notifications (IPN) through PHP. Looking at several examples found on the web, I was struck by the spaghetti-ness of all the ones I saw - they seemed to be based on PayPal's example code, which was far from comprehensive. Also, they were mostly monolithic, and it was hard to understand all the necessary steps. But thanks to them I was able to figure it out, and the code in this article is the result. Hopefully, by sharing, others will be spared some of the difficulty I went through.
The classes below are also not comprehensive, as they only handle my requirement of single item purchases. A couple other features might also be lacking, but they work for me, and should provide a good beginning for those who wish to go further.
I approached handling IPNs by breaking the process into objects responsible for logical sub-tasks. That makes everything much easier to grok for those who are familiar with object-orientation. (And if you aren't, I hope this helps you see the benefits, and become more versed in the methodology. It will save you lots of work in your future.)
There are a couple other IPN payment approaches on CodeProject you may also want to check out. The ones I found are in C# and ASP.NET. As of July, 2013, AllanPolich, becker666, and Mat Stine's articles seem to be most relevant to this task. Feel free to point out any others I'm missed. (DaSpors linked an impressive PHP/JavaScript/SQL shopping cart solution in the comments that can handle much more than PayPal IPNs if you need additional payment options.)
The Solution
In order to not give away important details of my own setup, I am going to copy/paste my code below and change the appropriate information. This will make for a long article which seems to be a code dump, but I will also place some comments here to make the approach a little easier to understand. I believe the code is self-documenting, which may be some consolation, but I still apologize for the inconvenience, as there are seven files this will require you to copy and paste if you use them for your own project. If there is enough call for it in the future, I will take the time and create a zip of the files from the modified code, but right now I want to concentrate on getting back to other projects as quickly as possible.
My solution breaks the process into five objects:
payPalController
- This class handles the logic of the non-IPN portion of the PayPal process. It checks for duplicate transactions sent by PayPal, and verifies that the product in the IPN transaction is actually one on my site. If so, it initiates adding the purchase to a database and sending an email to the customer. payPalIpn
- This is where the IPN processing occurs. Very important! rmwStoreDB
- This unit handles adding transactions to a database on my end. myMailClass
- A basic mailer which could be vastly extended to handle HTML emails better, but it provides basic functionality. It relies upon Swift Mailer under the hood, to make things much easier. myLoggerClass
- Logs errors and can send you an email when something goes wrong if such an action is specified in its setup.
In addition, there are two more non-class files which hold:
- the database setup information, and
- the email account information
To get this to work, you must perform three steps. First, you must set up a database, which I won't cover here. There is enough online to figure that process out if you haven't yet. Then you must copy the five class files and the two password files to a subdirectory underneath the lowest one visible to the outside world and modify them to your specifications (where 'above' is taken to mean those folders viewable by browsers). And finally, the webpage you specify for PayPal to send notifications to (on a button-by-button basis if you go that route) must look something like this:
<!doctype html public '-//W3C//DTD HTML 4.01//EN' 'http://www.w3.org/TR/html4/strict.dtd'>
<html>
<head>
<title>RandomMonkeyWorks</title>
<meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
<meta name="Generator" content="Notepad++">
<meta name="Description" content="PayPal IPN Processor">
<meta name="Keywords" content="Nothing">
</head>
<body>
<!--
<?php
$base = dirname(dirname(dirname(__FILE__)));
define('BASE_PATH', $base == DIRECTORY_SEPARATOR ? $base : $base . DIRECTORY_SEPARATOR);
require_once (BASE_PATH . "phpPayPalController.php");
$ppc = new payPalController();
$ppc->setTesting(false);
$ppc->setLogging(false);
$ppc->processPayPalIpnPayment();
?>
<!--
<!--
<!--
<!--
</body>
</html>
As you can see, the payPalController
is the only unit the page needs to know about. The controller sets up the other classes itself:
<?php
require_once(BASE_PATH . "phpPayPalIpnClass.php");
require_once(BASE_PATH . "phpLoggerClass.php");
require_once(BASE_PATH . "phpRmwStoreDbActions.php");
require_once(BASE_PATH . "phpMyMailClass.php");
class payPalController {
private $db;
private $logger;
private $testing;
function __construct() {
$this->logger = new myLogger();
$this->logger->setLogFile("log.txt");
$this->logger->setLogging(false);
$this->db = new rmwStoreDB();
$this->db->setLogger($this->logger);
$this->testing = false;
}
function setTesting($state) {
$this->testing = $state;
$this->logger->setLogging($state);
}
function setLogging($state) { $this->logger->setLogging($state); }
function processPayPalIpnPayment() {
$processor = new payPalIpn();
$processor->setTesting($this->testing);
$processor->setLogger($this->logger);
$this->logger->log("Processing a payment." . PHP_EOL);
if (!$this->validatePostData()) return;
if (!$processor->processPost()) return;
if ($this->duplicateTransaction()) return;
if (!$this->verify()) return;
if (!$this->addOrderToDatabase()) return;
$this->sendProduct();
}
function validatePostData() {
$step = 0;
$ret = true;
if(!isset($_POST['txn_id'])) $step = 1;
if(!isset($_POST['shipping'])) $step = 2;
if(!isset($_POST['quantity'])) $step = 3;
if(!isset($_POST['mc_gross'])) $step = 4;
if(!isset($_POST['mc_gross'])) $step = 5;
if(!isset($_POST['last_name'])) $step = 6;
if(!isset($_POST['first_name'])) $step = 7;
if(!isset($_POST['address_zip'])) $step = 8;
if(!isset($_POST['mc_currency'])) $step = 9;
if(!isset($_POST['item_number'])) $step = 10;
if(!isset($_POST['payer_email'])) $step = 11;
if(!isset($_POST['address_city'])) $step = 12;
if(!isset($_POST['address_name'])) $step = 13;
if(!isset($_POST['address_state'])) $step = 14;
if(!isset($_POST['address_street'])) $step = 15;
if(!isset($_POST['receiver_email'])) $step = 16;
if(!isset($_POST['payment_status'])) $step = 17;
if(!isset($_POST['address_country'])) $step = 18;
if ($step != 0) $ret = false;
if ($ret == false) {
$this->logger->log("POST DATA not set: $step" . PHP_EOL);
$response = "";
foreach ($_POST as $key => $value) {
$numPosts += 1;
if($magicQuotesFuncExists == true && get_magic_quotes_gpc() == 1) {
$value = urlencode(stripslashes($value));
}
else {
$value = urlencode($value);
}
$response .= "&$key=$value" . PHP_EOL;
}
$this->logger->log($response);
}
return $ret;
}
private function duplicateTransaction() {
$ret = false;
if ($this->db->itemExists("orders", "payPalTransId", $_POST['txn_id'])) {
$this->logger->log("Transaction: " . $_POST['txn_id'] . " exists" . PHP_EOL);
$ret = true;
}
else {
$this->logger->log("Transaction: " . $_POST['txn_id'] .
" does not exist" . PHP_EOL);
}
return $ret;
}
private function verify() {
$nl = PHP_EOL;
if (!$this->db->itemExists("products", "id", $_POST ['item_number'])) {
$this->logger->log("Item number: " . $_POST['item_number'] .
" doesn't exist in database$nl");
return false;
}
else {
$this->logger->log("Item number: " . $_POST['item_number'] .
" exists in database$nl");
}
$this->dbPrice = $this->db->getCellValue("price", "products", "id",
$_POST['item_number']);
if ($_POST['mc_gross'] < $this->dbPrice) {
$this->logger->log("Payment received (" . $_POST ['mc_gross'] .
") less than item price. (" . $this->dbPrice . PHP_EOL);
return false;
}
else {
$this->logger->log("Adequate payment received (" . $_POST ['mc_gross'] .
").$nl");
}
if ($_POST['mc_currency'] != "USD") {
$this->logger->log("Paid in non-US funds - need to investigate.$nl");
return false;
}
else {
$this->logger->log("US Currency received - OK.$nl");
}
if ($_POST['receiver_email'] != "someone@somewhere.com"
&& $_POST['receiver_email'] != "someone@somewhere.com") {
$this->logger->log("Incorrect receiver email received (" .
$_POST['receiver_email'] . ")$nl");
return false;
}
else {
$this->logger->log("Correct email received (
" . $_POST['receiver_email'] . ")$nl");
}
if ($_POST['payment_status'] != "Completed") {
$this->logger->log("Payment incomplete from PayPal$nl");
return false;
}
return true;
}
private function addOrderToDatabase() {
$this->logger->log("Updating database." . PHP_EOL);
$this->db->addOrUpdateUser();
$this->db->addOrder();
return true;
}
private function sendProduct() {
$nl = PHP_EOL;
$mailHandler = new myMailer();
$mailHandler->setLogger($this->logger);
if ($this->testing) {
$mailTo = 'someone@somewhere.com';
}
else {
$mailTo = $_POST['payer_email'];
}
if ($_POST['item_number'] == "something") {
doSomething();
}
}
}
?>
In the above code block, for prettier formatting, I wrapped some lines which may need to be unwrapped when porting them to your site. They are probably fine as-is, but just in case, this is worth being aware of. Also, remember to change the email addresses used throughout this article.
That brings us to the payPalIpn
class. As stated earlier, this handles the IPN processing, and sending the correct responses back to PayPal.
<?php
require_once(BASE_PATH . "phpLoggerClass.php");
class payPalIpn {
private $logger;
private $ipnVerifiedC;
private $testingC;
function __construct() {
$this->ipnVerifiedC = false;
$this->testingC = false;
}
function ipnVerified() { return $this->ipnVerifiedC; }
function setTesting($state) { $this->testingC = $state; }
function setLogger(myLogger &$logFile) { $this->logger = $logFile; }
function processPost() {
$nl = PHP_EOL;
$this->logger->log("RECEIVED:$nl" . var_export($_POST, true) . PHP_EOL);
$response = 'cmd=_notify-validate';
$magicQuotesFuncExists = false;
if(function_exists('get_magic_quotes_gpc')) {
$magicQuotesFuncExists = true;
}
$numPosts = 0;
foreach ($_POST as $key => $value) {
$numPosts += 1;
if($magicQuotesFuncExists == true && get_magic_quotes_gpc() == 1) {
$value = urlencode(stripslashes($value));
}
else {
$value = urlencode($value);
}
$response .= "&$key=$value";
}
$this->logger->log("AFTER MAGIC QUOTES:$nl".var_export($_POST, true).PHP_EOL);
$header = "POST /cgi-bin/webscr HTTP/1.1$nl";
$header .= "Host: www.sandbox.paypal.com$nl";
$header .= "Content-Type: application/x-www-form-urlencoded$nl";
$header .= "Content-Length: " . strlen($response) . PHP_EOL . PHP_EOL;
if ($this->testingC) {
$socket = fsockopen ('ssl://www.sandbox.paypal.com', 443, $socketErrNum,
$socketErrStr, 30);
}
else {
$socket = fsockopen ('ssl://www.paypal.com', 443, $socketErrNum,
$socketErrStr, 30);
}
if ($socket) $this->logger->log("Socket successful$nl");
else $this->logger->log("Socket failed!$nl");
if (!$socket) {
$mail_Body = "Error from fsockopen:$nl" . $socketErrStr .
"$nl$nlOriginal PayPal Post Data (COULD BE BOGUS!)$nl$nl";
foreach ($_POST as $key => $value) {
$value = urlencode(stripslashes($value));
$mail_Body .= "&$key=$value" . PHP_EOL;
}
mail($myEmail, "IPN Error Noficiation: Failed to connect to PayPal", $mail_Body,
"someone@somewhere.com");
$this->logger->log("Socket error: " . $socketErrStr);
return;
}
$receivedVerification = false;
$this->logger->log("Number of posts: $numPosts$nl");
if ($numPosts > 3) {
$this->logger->log("Sending the post back:$nl");
fputs ($socket, $header . $response);
$this->logger->log("SENT:$nl$nl$header$response$nl$nl");
$receivedVerification = false;
$endOfStreamReached = false;
while (!feof($socket) && !$endOfStreamReached && !$receivedVerification) {
$result = fgets ($socket, 1024);
$this->logger->log("RECEIVED: $result");
if (strncmp ($result, "VERIFIED", 8) == 0) {
$receivedVerification = true;
}
if (strncmp ($result, "0", 1) == 0) $endOfStreamReached = true;
}
}
fclose ($socket);
$result = false;
if ($receivedVerification == false) {
$this->logger->log(
"$nl$nlINVALID TRANSACTION! (Improper PayPal response received)$nl");
}
else {
$this->ipnVerifiedC = true;
$this->logger->log("TRANSACTION VERIFIED!$nl");
$result = true;
}
return $result;
}
}
?>
Next comes the database processing. The db_config.php file in the first line stores the database user and password information. It is critical that db_config.php is not visible to the outside world, which is why you must place it below the topmost public folder on your site.
<?php
require_once(BASE_PATH . "pp_db_config.php");
require_once(BASE_PATH . "phpLoggerClass.php");
class rmwStoreDB {
private $loggerC;
private $lastRowC;
function setlogger(mylogger $logFile) { $this->loggerC = $logFile; }
function tellDb($sql) {
$ret = mysql_query($sql);
if (!$ret) {
$this->loggerC->log("DATABASE ERROR:" . PHP_EOL . mysql_error() . PHP_EOL .
"Query: " . HtmlEntities($sql));
die();
}
return $ret;
}
function itemExists($table, $column, $value) {
$rows = $this->tellDb("Select * from " . $table . " where " . $column . " = '" .
$value . "'");
$lastRowC = mysql_fetch_array($rows);
if ($lastRowC) return true;
return false;
}
function getCellValue($what, $table, $column, $theId) {
$rows = $this->tellDb("Select " . $what . " from " . $table . " where " . $column .
" = " . $theId);
$row = mysql_fetch_array($rows);
if ($row) return $row['price'];
else return 0.00;
}
function addOrUpdateUser() {
$rows = $this->tellDb("Select * from customers where email = '" .
$_POST['payer_email'] . "'");
$row = mysql_fetch_array($rows);
if (!$row) {
$this->loggerC->log("Adding user to database");
$this->addUser();
}
else {
$this->loggerC->log("User already exists in DB." . PHP_EOL);
$this->updateUser($row);
}
}
private function addUser() {
$cmd = "Insert into customers (firstName, lastName, shippingName, email," .
"addressLine1, city, state, zipCode, country) values ('"
. $_POST['first_name'] . "', '" .
$_POST['last_name'] . "', '" .
$_POST['address_name'] ."', '" .
$_POST['payer_email'] . "', '" .
$_POST['address_street'] . "', '" .
$_POST['address_city'] . "', '" .
$_POST['address_state'] . "', '" .
$_POST['address_zip'] . "', '" .
$_POST['address_country'] . "')";
$this->tellDb($cmd);
$this->loggerC->log("Added: '" . $_POST['first_name'] . "', '" .
$_POST['last_name'] . "', '" .
$_POST['address_name'] . "', '" .
$_POST['payer_email'] . "', '" .
$_POST['address_street'] . "', '" .
$_POST['address_city'] . "', '" .
$_POST['address_state'] . "', '" .
$_POST['address_zip'] . "', '" .
$_POST['address_country'] . "')");
}
private function updateUser(array $row) {
if ($row['firstName'] != $_POST['first_name'] ||
$row['lastName'] != $_POST['last_name'] ||
$row['shippingName'] != $_POST['address_name'] ||
$row['email'] != $_POST['payer_email'] ||
$row['addressLine1'] != $_POST['address_street'] ||
$row['city'] != $_POST['address_city'] ||
$row['state'] != $_POST['address_state'] ||
$row['zipCode'] != $_POST['address_zip'] ||
$row['country'] != $_POST['address_country']) {
$cmd = "UPDATE customers SET ";
$cmd .= "firstName = '" . $_POST['first_name'] . "', ";
$cmd .= "lastName = '" . $_POST['last_name'] . "', ";
$cmd .= "shippingName = '" . $_POST['address_name'] . "', ";
$cmd .= "addressLine1 = '" . $_POST['address_street'] . "', ";
$cmd .= "city = '" . $_POST['address_city'] . "', ";
$cmd .= "state = '" . $_POST['address_state'] . "', ";
$cmd .= "zipCode = '" . $_POST['address_zip'] . "', ";
$cmd .= "country = '" . $_POST['address_country'] . "' ";
$cmd .= "WHERE email = '" . $_POST['payer_email'] . "'";
$this->loggerC->log(PHP_EOL . "Changing user with email " .
$_POST['payer_email'] . PHP_EOL);
$old = $row['firstName'] . ", " . $row['lastName'] . ", " .
$row['shippingName'] . ", " . $row['email'] . ", " .
$row['addressLine1'] . ", " . $row['city'] . ", " .
$row['state'] . ", " . $row['zipCode'] . ", " .
$row['country'] . PHP_EOL . PHP_EOL;
$this->loggerC->log($old);
$this->loggerC->log($cmd . PHP_EOL);
$this->tellDb($cmd);
}
}
function addOrder() {
$nl = PHP_EOL;
$cmd = "Select id from customers where email = '" . $_POST['payer_email'] . "'";
$rows = $this->tellDb($cmd);
$row = mysql_fetch_array($rows);
if (!$row) {
$this->loggerC->log("HUGE PROBLEM! CUSTOMER ID NOT FOUND - ABORTING$nl");
die();
}
$id = $row['id'];
$theDate = date('F j, Y, g:i a');
$tz = date('T');
$ppID = $_POST['txn_id'];
$grossPay = $_POST['mc_gross'];
$shipping = $_POST['shipping'];
$cmd = "Insert into orders (customer, date, timeZone, payPalTransId, grossPmt, " .
"shipping) values ('$id', '$theDate', '$tz', '$ppID', '$grossPay', '$shipping')";
$this->tellDb($cmd);
$this->loggerC->log("Inserting order into orders table:$nl$cmd$nl$nl");
$cmd = "Select id from orders where payPalTransId = '$ppID'";
$rows = $this->tellDb($cmd);
$row = mysql_fetch_array($rows);
if (!$row) {
$this->loggerC->log("HUGE PROBLEM! ORDER ID NOT FOUND - ABORTING$nl");
die();
}
$id = $row['id'];
$itemNum = $_POST['item_number'];
$qty = $_POST['quantity'];
$info = $_POST['option_selection1'];
$cmd = "Insert into orderItems (orderNumber, item, quantity, extraInfo) " .
"values('$id', '$itemNum', '$qty', '$info')";
$this->loggerC->log("Inserting into order items:$nl$cmd$nl");
$this->tellDb($cmd);
}
}
?>
Yay! The following classes are much smaller than the previous! The first of them is the mailer unit. Of course, its brevity may have to do with the fact it could use more fleshing out, and I should be sending better-formatted HTML responses to purchases than I currently do. All in good time.
<?php
require_once(BASE_PATH . "pp_hiddenPasswords.php");
require_once(BASE_PATH . "phpLoggerClass.php");
require_once(BASE_PATH . "lib/swift_required.php");
class myMailer {
private $myEmail;
private $logger;
function __construct() {
$this->myEmail = "someone@somewhere.com";
}
function setLogger(myLogger &$logFile) { $this->logger = $logFile; }
function sendEbook($ebookType, $mailTo) {
...
$this->mailWithAttachment($fileName, BASE_PATH, $mailTo, $this->myEmail, $from,
$replyTo, $subject, $msg);
}
private function mailWithAttachment($filename, $path, $mailTo, $from_mail, $from_name,
$replyto, $subject, $message) {
$transport = Swift_SmtpTransport::newInstance('mail.some_server.com', 465, 'ssl')
->setUsername(hiddenEmailAccount())
->setPassword(hiddenEmailPassword())
;
$mailer = Swift_Mailer::newInstance($transport);
$message = Swift_Message::newInstance()
->setSubject($subject)
->setFrom(array($from_mail => $from_name))
->setTo(array($mailTo))
->setBody($message)
->attach(Swift_Attachment::fromPath($path.$filename))
;
$this->logger->forceLog(date('F jS\, Y h:i:s A') . ": Sending " . $filename .
" to " . $mailTo . ": ");
$result = $mailer->send($message);
$this->logger->forceLog("Result = " . $result . PHP_EOL);
}
}
?>
And lastly comes the logger class:
<?php
class myLogger {
private $fileNameC;
private $doLoggingC;
function __construct() {
$this->fileNameC = "log.txt";
$this->doLoggingC = true;
}
function setLogFile($fileName) { $this->fileNameC = $fileName; }
function setLogging($state) { $this->doLoggingC = $state; }
function log($msg) {
if ($this->doLoggingC == true) {
file_put_contents($this->fileNameC, $msg, FILE_APPEND);
}
}
function forceLog($msg) {
file_put_contents($this->fileNameC, $msg, FILE_APPEND);
}
}
?>
But there are still two more files necessary, although you could combine them into one if you wanted, with the appropriate modifications above. They are for storing the database information and the email information. Again, it is imperative these are not exposed to the outside world, and must be placed below the topmost visible folder on your site.
The contents of the db_config.php file are as follows. Except those aren't real users, passwords, and databases!
<?php
$db_con = mysql_connect("localhost", "theUser", "thePassword", true) or die(mysql_error());
$db_selected = mysql_select_db("theDatabase") or die(mysql_error());
?>
And finally, the email password file ("emailPassword.php" in the above code), with the same caveat:
<?php
function hiddenEmailAccount() {
return "someone@somewhere.com";
}
function hiddenEmailPassword() {
return "thePassword";
}
?>
I hope the previous information is useful if you go this route, and maybe even if you don't. Thank you for reading, and I send you wishes for happy coding! If you come up with any improvements, please post them in the comments.