Implement a transaction via PayPal Payment Standard and PayPal IPN Message

Introduction

If you’ve once tried PayPal Payment service, you should know that PayPal provides several ways for users to pay and receive payment
Those are included in this article:

  1. How to pay via PayPal Payment Standard
  2. How to validate the payment result via PayPal IPN Message
  3. With PayPal Payment Standard, how to submit several items with their names, unit prices, and quantities.

In this article, the language I use is Laravel, the framework of PHP.
Since this article basically is a record of a project I was working, there may be some parts that don’t have so much to do with payment service.
You could skip those parts and start from ‘Make a payment order’

Validation

It’s part of the whole process, and doesn’t have anything to do with payment service. You could just skip it.

$toBeValidatedCondition = [
'order_id' => 'required|array',
];
$failMessage = Helpers::validation($toBeValidatedCondition, $request);
if ($failMessage)
return Helpers::result(false, $failMessage, 400);

if (!Helpers::checkIfIDExists($request, new Order(), 'order_id'))
return Helpers::result(false, 'The orders doesn\'t exist', 400);

if (!Helpers::checkIfBelongToTheUser($request, new Order(), 'order_id'))
return Helpers::result(false, 'The order doesn\'t belong to this user', 400);


$orders = Order::whereIn('id', $request->order_id)->get();

if (Order::checkIfOrderPaid($orders))
return Helpers::result(false, 'The order has already been paid', 400);

if (Order::checkIfOrderExpired($orders))
return Helpers::result(false, 'The order has expired', 400);

if ($recipient->user_id !== User::getUserID($request))
return Helpers::result(false, 'The recipient doesn\'t belong to the user', 400);

Collect required information.

When I was working, it seemed that I had more time than front end, lol, so I decided to take care as mush information as possible, managing to let front end do more things with the least information.

$toBeSavedInfo = [
'total_amount' => Order::getTotalAmountForPayments($orders),
'orders_name' => Order::getOrdersNameForPayments($orders),
'merchant_trade_no' => time() . Helpers::createAUniqueNumber(),
'merchant_trade_date' => date('Y/m/d H:i:s'),
'trade_desc' => 'BuyBuyGo',
'quantity' => 1,
'user_id' => User::getUserID($request),
'payment_service' => $thirdPartyPaymentService,
'expiry_time' => (new Carbon())->now()->addDay(1)->toDateTimeString(),
'orders' => $orders,
'mc_currency' => 'TWD',
'ClintBackURL' => $request->ClintBackURL
];

Splitting point

Since I use two payment services in this project, so I need a place to determine where the request is from and should go

switch ($thirdPartyPaymentService->id)
{
case 1:
$error = (new AllPay)->make($toBeSavedInfo, $request, $recipient);
if($error)
return Helpers::result(false, $error,400);

return (new AllPay())->send($toBeSavedInfo, $request);
break;

case 2:
$error = (new PayPal)->make($toBeSavedInfo, $request, $recipient);
if($error)
return Helpers::result(false, $error, 400);

$url = (new PayPal)->send($toBeSavedInfo, $request, $recipient);
return Helpers::result(true, $url, 200);
break;
}

Make a payment order

Whenever the user call PAY API, a temporary order will be built, which is only between the seller and PayPal.
Because I’m going to insert data into two tables at a time, we I am going to use Laravel’s Transaction to implement the data inserting work. If you are interested in Transaction of Laravel, you could refer to a short section in my another article, which I mentioned a bit.

public function make(Array $toBeSavedInfo, Request $request, Recipient $recipient)
{
DB::beginTransaction();
try
{
$PayPal = new self();

$PayPal->user_id = $toBeSavedInfo['user_id'];
$PayPal->payment_service_id = $toBeSavedInfo['payment_service']->id;
$PayPal->expiry_time = $toBeSavedInfo['expiry_time'];
$PayPal->merchant_trade_no = $toBeSavedInfo['merchant_trade_no'];
$PayPal->total_amount = $toBeSavedInfo['total_amount'];
$PayPal->trade_desc = $toBeSavedInfo['trade_desc'];
$PayPal->item_name = $toBeSavedInfo['orders_name'];
$PayPal->mc_currency = $toBeSavedInfo['mc_currency'];
$PayPal->recipient_id = $recipient->id;
$PayPal->save();

foreach ($toBeSavedInfo['orders'] as $order)
{
$order_relations = new OrderRelations();
$order_relations->payment_service_id = $toBeSavedInfo['payment_service']->id;
$order_relations->payment_service_order_id = $PayPal->id;
$order_relations->order_id = $order->id;
$order_relations->save();
}
} catch (Exception $e)
{
DB::rollBack();

return 'something went wrong with DB';
}
DB::commit();
}

Make a URL for submitting the payment request to PayPal

Here we will use a lot of variables of PayPal Payment Standard. You could refer to the usage of every of them from this article
By the way, because when Ray doing this project, the front end’s schedule was a bit tighter than Ray, so Ray decided to handle as much information as possible. The backend will collect all those information from orders that have been built, and feed PayPal with those information. So basically the front end only has to let me know what orders the user want to pay.

public function send(Array $toBeSavedInfo, Request $request, Recipient $recipient)
{
$enableSandbox = env('PAYPAL_SANDBOX_ENABLESANDBOX');

$paypalUrl = $enableSandbox ? 'https://www.sandbox.paypal.com/cgi-bin/webscr' : 'https://www.paypal.com/cgi-bin/webscr';

$data = [];

// Set PayPal account. You might need to apply test account of either seller or buyer via the link below. We will need to enter seller's account here, so whenever an order is paid, the payment will be automatically transferred into this account.
// https://developer.paypal.com/developer/accounts/
$data['business'] = env('PAYPAL_SANDBOX_MAIL');

// Set the PayPal return addresses, after the transaction is completed, the user could be back via this URL.
$data['return'] = $toBeSavedInfo['ClintBackURL'];

// During the transaction process on PayPal's site, the user could cancel the transaction and go back via this URL.
$data['cancel_return'] = env('PAYPAL_SANDBOX_CANCEL_URL');

// After the transaction is completed, PayPal will send IPN message to this URL.
$data['notify_url'] = env('PAYPAL_SANDBOX_NOFITY_URL');

// Set the details about the products being purchased, including the price for every individual
// and currency so that these aren't overridden by the form data.
$i = 1;
foreach ($toBeSavedInfo['orders'] as $order)
{
$data["item_name_$i"] = $order->item_name;
$data["item_number_$i"] = $order->quantity;
$data["amount_$i"] = $order->total_amount;
$i++;
}

// Here we specify the currency, you could refer to the supported currency from the link below
// https://developer.paypal.com/docs/classic/api/currency_codes/
$data['currency_code'] = $toBeSavedInfo['mc_currency'];

// Add any custom fields for the query string. We will pass this value to PayPal, and it will return along with IPN Message from PayPal. In this case, I pass the order number of payment order.
$data['custom'] = $toBeSavedInfo['merchant_trade_no'];

// Add recipient's information, and show it on paying process
$data['address_override'] = 1;
$data['country'] = $recipient->country_code;
$data['city'] = $recipient->city;
$data['address1'] = $recipient->others;
$data['zip'] = $recipient->postcode;
$data['first_name'] = $recipient->name;

// This setting allow to add multiple items with IPN method
$data['upload'] = '1';
$data['cmd'] = "_cart";

// Add charset
$data['charset'] = 'utf-8';

// Build the query string from the data.
$queryString = http_build_query($data);

// Build the URL to PayPal
$url = $paypalUrl . '?' . $queryString;

return $url;
}

Pay the payment

  1. The user reach the PayPal payment page via the URL we produced above. Please register test account via link here

  1. Reach payment page

  2. Here we could see the items in detail, including price and quantity. Click continue to go on

  3. Here we could see the shipping address we designated

  4. Transaction completed, and here we could see all the details

Verify payment state

After the payment is completed, PayPal will send an IPN message to us. You could check the IPN in detail through the official document

  1. Firstly, let’s install PayPal’s official IPN CODE SAMPLES

    git clone https://github.com/paypal/ipn-code-samples
  2. In php directory

    cd ipn-code-samples/php
  3. And then, let’s copy PaypalIPN.php into our project, Ray personally put it under app

  4. Now, put the folder cert that is under ipn-code-samples also into app as image below:

  5. In composer.json file, search files under autoload-dev, and add PaypalIPN.php. If there is no files in your composer.json, you might need to create one yourself

    "autoload-dev": {
    "psr-4": {
    "Tests\\": "tests/"
    },
    "files": [
    "app/Helpers.php",
    "app/AllPay.Payment.Integration.php",
    "app/PaypalIPN.php"
    ]
    },
  6. On terminal window, in our project folder, execute composer command

    composer install
  7. Now basically we have everything except for the one last step. Copy the content of example_usage_advanced.php into wherever you want. It could be one of your controllers, or a function under certain class as follows:
    The code below is a bit long. It should be almost the same as the original sample code except for some I put comment to, so you don’t have to go through every of them.

        public function listen(Request $request)
    {
    // Since the offical sample is to catch $_POST, so I convert the request of Laravel into POST. You could modify it on your own.
    $_POST = $request->post();

    // It shows if the payment is cleared.
    $payment_status = $_POST['payment_status'];

    // Remember the payment service order we passed to PayPal that I mentioned earlier
    $merchant_trade_no = $_POST['custom'];

    // Very important. We will use it later
    $txn_id = $_POST['txn_id'];

    $txn_type = $_POST['txn_type'];

    // When the payment is paid
    $payment_date = Carbon::parse($_POST['payment_date'])->setTimezone('UTC');

    // The total amount
    $mc_gross = $_POST['mc_gross'];

    $mc_currency = $_POST['mc_currency'];

    $enable_sandbox = env('PAYPAL_SANDBOX_ENABLESANDBOX');

    // Use this to specify all of the email addresses that you have attached to paypal:
    $my_email_addresses = array(env('PAYPAL_SANDBOX_MAIL'));

    // Set this to true to send a confirmation email:
    $send_confirmation_email = env('PAYPAL_SANDBOX_SEND_CONFIRMATION_EMAIL');
    $confirmation_email_address = "buybuybuygogo@gmail.com";
    $from_email_address = "test@gmail.com";

    // Set this to true to save a log file:
    $save_log_file = env('PAYPAL_SANDBOX_SAVE_LOG_FILE');
    $log_file_dir = storage_path() . "/app/payment_logs";

    // Here is some information on how to configure sendmail:
    // http://php.net/manual/en/function.mail.php#118210

    // Here is the function verifying the IPN message.
    $ipn = new PaypalIPN();
    if ($enable_sandbox)
    {
    $ipn->useSandbox();
    }
    $verified = $ipn->verifyIPN();

    $data_text = "";
    foreach ($_POST as $key => $value)
    {
    $data_text .= $key . " = " . $value . "\r\n";
    }

    $test_text = "";
    if ($_POST["test_ipn"] == 1)
    {
    $test_text = "Test ";
    }

    // Check the receiver email to see if it matches your list of paypal email addresses
    $receiver_email_found = false;
    foreach ($my_email_addresses as $a)
    {
    if (strtolower($_POST["receiver_email"]) == strtolower($a))
    {
    $receiver_email_found = true;
    break;
    }
    }

    date_default_timezone_set("America/Los_Angeles");
    list($year, $month, $day, $hour, $minute, $second, $timezone) = explode(":", date("Y:m:d:H:i:s:T"));
    $date = $year . "-" . $month . "-" . $day;
    $timestamp = $date . " " . $hour . ":" . $minute . ":" . $second . " " . $timezone;
    $dated_log_file_dir = $log_file_dir . "/" . $year . "/" . $month;

    $paypal_ipn_status = "VERIFICATION FAILED";
    if ($verified)
    {

    // When it passes the if below, it means that the IPN is verified, so we could do something after that.
    $paypal_ipn_status = "RECEIVER EMAIL MISMATCH";
    if ($receiver_email_found)
    {
    $paypal_ipn_status = "Completed Successfully";

    $PayPal = (new PayPal())->where('merchant_trade_no', $merchant_trade_no)->first();

    // Here we check a few things as follows:
    // 1. Check txd_id to prevent double processing some transaction that was previously processed. If this txd_id already exists in our database, we should ignore it.
    // 2. Check mc_gross, which should be exactly the same as the total amount in our payment service order.
    // 3. Check mc_currency to make sure it's the same as the one in our payment service order.
    // 4. Check payment_status to make sure that the transaction is completed.

    if ((!PayPal::checkIfTxnIdExists($txn_id)) && ($mc_gross == $PayPal->total_amount) && ($mc_currency == $PayPal->mc_currency) && ($payment_status == 'Completed'))
    {
    // Insert txd_id into the payment service order and update some values, which shows if this order is cleared or not.
    $PayPal->update(['txn_id' => $txn_id, 'txn_type' => $txn_type, 'payment_date' => $payment_date, 'status' => 1, 'expiry_time' => null]);
    $recipient = $PayPal->recipient;

    $orderRelations = $PayPal->orderRelations->where('payment_service_id', 2);

    // After updating the payment service order, we update the user order accordingly.
    Order::updateStatus($orderRelations, $recipient);

    // After everything is perfectly done, we send a notification mail to the buyer and seller
    Helpers::mailWhenPaid($PayPal, $orderRelations);
    }
    }
    } elseif ($enable_sandbox)
    {
    if ($_POST["test_ipn"] != 1)
    {
    $paypal_ipn_status = "RECEIVED FROM LIVE WHILE SANDBOXED";
    }
    } elseif ($_POST["test_ipn"] == 1)
    {
    $paypal_ipn_status = "RECEIVED FROM SANDBOX WHILE LIVE";
    }

    if ($save_log_file)
    {
    // Create log file directory
    if (!is_dir($dated_log_file_dir))
    {
    if (!file_exists($dated_log_file_dir))
    {
    mkdir($dated_log_file_dir, 0777, true);
    if (!is_dir($dated_log_file_dir))
    {
    $save_log_file = false;
    }
    } else
    {
    $save_log_file = false;
    }
    }
    // Restrict web access to files in the log file directory
    $htaccess_body = "RewriteEngine On" . "\r\n" . "RewriteRule .* - [L,R=404]";
    if ($save_log_file && (!is_file($log_file_dir . "/.htaccess") || file_get_contents($log_file_dir . "/.htaccess") !== $htaccess_body))
    {
    if (!is_dir($log_file_dir . "/.htaccess"))
    {
    file_put_contents($log_file_dir . "/.htaccess", $htaccess_body);
    if (!is_file($log_file_dir . "/.htaccess") || file_get_contents($log_file_dir . "/.htaccess") !== $htaccess_body)
    {
    $save_log_file = false;
    }
    } else
    {
    $save_log_file = false;
    }
    }
    if ($save_log_file)
    {
    // Save data to text file
    file_put_contents($dated_log_file_dir . "/" . $test_text . "paypal_ipn_" . $date . ".txt", "paypal_ipn_status = " . $paypal_ipn_status . "\r\n" . "paypal_ipn_date = " . $timestamp . "\r\n" . $data_text . "\r\n", FILE_APPEND);
    }
    }

    if ($send_confirmation_email)
    {
    // Send confirmation email
    mail($confirmation_email_address, $test_text . "PayPal IPN : " . $paypal_ipn_status, "paypal_ipn_status = " . $paypal_ipn_status . "\r\n" . "paypal_ipn_date = " . $timestamp . "\r\n" . $data_text, "From: " . $from_email_address);
    }

    // Reply with an empty 200 response to indicate to paypal the IPN was received correctly
    header("HTTP/1.1 200 OK");

    }

Conclusion

Above mentioned is the whole process of using PayPal Payment Standard and PayPal IPN Message to complete a transaction.
Here is breakdown of every individual step:

  1. A user clicks a PayPal button to kick off a checkout flow; your web application makes an API call; your back-office system makes an API call; or PayPal observes an event.
  2. PayPal HTTPS POSTs your listener an IPN message that notifies you of this event.
  3. Your listener returns an empty HTTP 200 response.
  4. Your listener HTTPS POSTs the complete, unaltered message back to PayPal.
  5. PayPal sends a single word back - either VERIFIED (if the message matches the original) or INVALID (if the message does not match the original).
Easy Payment Gateway with PayPal REST API Add multiple items on PayPal IPN method

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×