We integrate payment via Paypal in a web application

This article discusses the integration of one-time payments, as well as subscription payments using Paypal in a web application. The examples are implemented in PHP, but, in principle, without any problems the same can be done using other technologies. This method is chosen as a compromise between simplicity and flexibility. This is an attempt to write a guide that will help you quickly understand the topic and integrate payment via Paypal into your project.

The article is focused mainly on those who have not worked with this system before. Paypal experts are unlikely to find anything new for themselves here. But, perhaps, they will point out the shortcomings of this method or advise how it could be implemented differently.

Account creation


To implement this scheme, we need a business account. PayPal Payments Standard should be enough.
Follow the link and create an account.

Creating a sandbox account


We will use Paypal Sandbox to test our application. We need 2 sandbox accounts. Buyer account (buyer) and seller account (facilitator). First of all, you need to set a password for both sandbox accounts. To do this, go to the paypal website in the developer section . Log in, then go to the dashboard . In the menu on the left we find the Sandbox section, the accounts tab. Here we can see 2 sandbox accounts (Buyer and Facilitator).



We click on profile, in the appeared modal window we click change password, then we save the password.
We set passwords for both accounts. After that, you can go to the Paypal Sandbox website and try to log in.

Paypal setup


Now we need to set up a Paypal Facilitator account, to which we will receive funds. We go to the Sandbox website, log in using the facilitator account and go to the profile settings. Open the profile menu, select the item my selling tools.



In the Selling online section, select Website preferences, click Update. Here you can enable user redirection. After the payment is completed, the user will be redirected to the specified url by default. But it is also possible to redirect the user to another url (see below).



You must also activate Paypal Instant Payment Notifications. To do this, in the section Getting paid and managing my risk, select the item Instant payment notifications and also click Update.



In the IPN settings, specify the URL on which our IPN Listener will work. This URL must be accessible globally since it will receive notifications of operations.



Turn on Message delivery and save. This completes the account setup. You can start setting up payments directly.

One-time Payments


First, we make one-time payments. This is probably the most common use case. The user just wants to buy some product or one-time service. Well, I want us to no longer need to change anything in the paypal settings. The list of goods and prices would be stored in the database of our application, we could change them as we want. For one-time payments, we will use Payment Buttons (PayPal Payments Standard) .

Data structure


The list of goods is stored in the database of our application. We can add delete and edit products at any time. The simplest structure is presented here; all information is stored in one table.

But you can complicate the task. For example, change the price depending on the quantity of goods ordered, or change the price depending on the day of the week and time.

Or include in the order a lot of different products.

products - here we will store the goods:

idnamepricedescription
1Product 11.0...
2Product 24.0...

users - here we will store users:

idfirstnamelastnameemailpassword
315AlanSmithalansmith@example.com$ 1 $ 2z4.hu5. $ E3A3H6csEPDBoH8VYK3AB0
316JoeDoejoedoe@example.com$ 1 $ Kd4.Lf0. $ PGc1h7vwmy9N6EJxac953 /

products_users - to whom we shipped:

iduser_idproduct_iditems_countcreated_date
1315132015-09-03 08:23:05

We will also store transaction history in our database in the transactions table:

txn_idtxn_typemc_grossmc_currencyquantitypayment_datepayment_statusbusinessreceiver_emailpayer_idpayer_emailrelation_idrelation_typecreated_date

Form of payment


First, create an order form. We generate a form in our application, where we indicate the main parameters of the order (product name, price, quantity).

Here we can indicate any price, name, quantity, etc. The custom field is useful in that you can transfer any data in it. Here we will pass the product id, user id, and possibly other information. We will need these data for further payment processing.
If you need to pass multiple parameters, you can use json or serialization. Or you can use additional fields of the form on0, on1, os0 and os1. Personally, I did not check this, I found the information here .

The following is an example form:

 $userId, 'product_id' => $productId];
?>

In fact, the parameters can be much larger, detailed information can be found in the documentation . After submitting the form, the user gets to the paypal payment page, where he again sees the details of the order.



Here the user can pay for the order using a paypal account or using a bank card. Then the user is redirected back to our website (return parameter), where we can inform him that his payment is being processed.

Instant Payment Notification (IPN)


After the user has made the payment, Paypal processes it and sends a confirmation to our application. To do this, use the Instant Payment Notification (IPN) service .

At the beginning of the article, we set up our Paypal account and set the IPN Notification URL. Now is the time to create an IPN listener that will handle IPN requests. Paypal provides an example implementation of an IPN listener . A detailed explanation of the service can be found here . In a nutshell, how it works: Paypal processes the user's payment, sees that everything is fine and the payment is successfully completed. After that, the IPN sends a request to our Notification URL of the form Post:
mc_gross=37.50&protection_eligibility=Ineligible&payer_id=J86MHHMUDEHZU&tax=0.00&payment_date=07%3A04%3A48+Mar+30%2C+2015+PDT&payment_status=Completed&charset=windows-1252&first_name=test&mc_fee=1.39¬ify_version=3.8&custom=%7B%22user_id%22%3A314%2C%22service_provider%22%3A%22twilio%22%2C%22service_name%22%3A%22textMessages%22%7D&payer_status=verified&business=antonshel-facilitator%40gmail.com&quantity=150&verify_sign=AR-ITpb83c-ktcbmApqG4jM17OeQAx2RSvfYZo4XU8YFZrTSeF.iYsSx&payer_email=antonshel-buyer%40gmail.com&txn_id=30R69966SH780054J&payment_type=instant&last_name=buyer&receiver_email=antonshel-facilitator%40gmail.com&payment_fee=1.39&receiver_id=VM2QHCE6FBR3N&txn_type=web_accept&item_name=GetScorecard+Text+Messages&mc_currency=USD&item_number=&residence_country=US&test_ipn=1&handling_amount=0.00&transaction_subject=%7B%22user_id%22%3A314%2C%22service_provider%22%3A%22twilio%22%2C%22service_name%22%3A%22textMessages%22%7D&payment_gross=37.50&shipping=0.00&ipn_track_id=6b01a2c76197

Our IPN Listener should process this request. In particular:

  • Check the type of request (one-time payment or subscription). Depending on this, we will process it differently. In our case, it will be a one-time payment - web_accept.
  • Choose environment - sandbox or live.
  • Check the validity of the request. Knowing what the IPN request looks like and knowing our IPN Notification URL, anyone can send us a fake request. Therefore, we must do this check.

getPaymentType($postData);
        $config = Config::get();
		// в зависимости от типа платежа выбираем клас
        if($transactionType == PaypalTransactionType::TRANSACTION_TYPE_SINGLE_PAY){
            $this->service = new PaypalSinglePayment();
        }
        elseif($transactionType == PaypalTransactionType::TRANSACTION_TYPE_SUBSCRIPTION){
            $this->service = new PaypalSubscription($config);
        }
        else{
            throw new Exception('Wrong payment type');
        }
        $raw_post_data = file_get_contents('php://input');
        $raw_post_array = explode('&', $raw_post_data);
        $myPost = array();
        foreach ($raw_post_array as $keyval) {
            $keyval = explode ('=', $keyval);
            if (count($keyval) == 2)
                $myPost[$keyval[0]] = urldecode($keyval[1]);
        }
        $customData = $customData = json_decode($myPost['custom'],true);
        $userId = $customData['user_id'];
        // read the post from PayPal system and add 'cmd'
        $req = 'cmd=_notify-validate';
        if(function_exists('get_magic_quotes_gpc')) {
            $get_magic_quotes_exists = true;
        }
        else{
            $get_magic_quotes_exists = false;
        }
        foreach ($myPost as $key => $value) {
            if($get_magic_quotes_exists == true && get_magic_quotes_gpc() == 1) {
                $value = urlencode(stripslashes($value));
            } else {
                $value = urlencode($value);
            }
            $req .= "&$key=$value";
        }
        $myPost['customData'] = $customData;
        $paypal_url = 'https://www.sandbox.paypal.com/cgi-bin/websc';
        //$paypal_url = 'https://www.paypal.com/cgi-bin/websc';
		// проверка подлинности IPN запроса
        $res = $this->sendRequest($paypal_url,$req);
        // Inspect IPN validation result and act accordingly
        // Split response headers and payload, a better way for strcmp
        $tokens = explode("\r\n\r\n", trim($res));
        $res = trim(end($tokens));
        /**/
        if (strcmp ($res, "VERIFIED") == 0) {
			// продолжаем обраюотку запроса
            $this->service->processPayment($myPost);
        } else if (strcmp ($res, "INVALID") == 0) {
            // запрос не прощел проверку
            self::log([
                'message' => "Invalid IPN: $req" . PHP_EOL,
                'level' => self::LOG_LEVEL_ERROR
            ], $myPost);
        }
        /**/
    }
    private function sendRequest($paypal_url,$req){
        $debug = $this->debug;
        $ch = curl_init($paypal_url);
        if ($ch == FALSE) {
            return FALSE;
        }
        curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
        curl_setopt($ch, CURLOPT_POST, 1);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER,1);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $req);
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 1);
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
        curl_setopt($ch, CURLOPT_FORBID_REUSE, 1);
        if($debug == true) {
            curl_setopt($ch, CURLOPT_HEADER, 1);
            curl_setopt($ch, CURLINFO_HEADER_OUT, 1);
        }
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 30);
		//передаем заголовок, указываем User-Agent - название нашего приложения. Необходимо для работы в live режиме
        curl_setopt($ch, CURLOPT_HTTPHEADER, array('Connection: Close', 'User-Agent: ' . $this->projectName));
        $res = curl_exec($ch);
        curl_close($ch);
        return $res;
    }
	public function getPaymentType($rawPostData){
        $post = $this->getPostFromRawData($rawPostData);
        if(isset($post['subscr_id'])){
            return "subscr_payment";
        }
        else{
            return "web_accept";
        }
    }
    /**
     * @param $raw_post_data
     * @return array
     */
    public function getPostFromRawData($raw_post_data){
        $raw_post_array = explode('&', $raw_post_data);
        $myPost = array();
        foreach ($raw_post_array as $keyval) {
            $keyval = explode ('=', $keyval);
            if(count($keyval) == 2)
                $myPost[$keyval[0]] = urldecode($keyval[1]);
        }
        return $myPost;
    }
}   
?>

After that, if Paypal has confirmed the authenticity of the request, we can proceed to its further processing.

Payment processing


First of all, we need to get the value of the custom field, where we passed the order id, user id or something else (depends on the logic of our application). Accordingly, we will be able to obtain user / order information from our database. You also need to get the transaction id.

Paypal can send confirmation of the same transaction several times. Therefore, you need to check and if the transaction was not processed, we process it. If the transaction has already been processed, then we do nothing.

We validate the payment. If everything is fine, then you can save the payment information to the database and perform further actions (assign the user “premium” status, order status “paid”, etc.). If the payment has not been validated, it is necessary to establish the reason and contact the user. Further operations, in particular, the cancellation of the payment, are carried out manually.

getUserData($userId);
		//получаем информацию о транзакции из базы данных
        $transactionService = new TransactionService();
        $transaction = $transactionService->getTransactionById($myPost['txn_id']);
        if($transaction === null){
			//получаем информацию о продукте из бд
			$productService = new ProductService();
            $product = $productService->getProductById($productId);
			// проводим валидацию транзакции
            if($this->validateTransaction($myPost,$product)){
				// оплата прошла успешно. сохраняем транзакцию в базу данных. 
                $transactionService->createTransaction($myPost);
				// Выполняем какие-либо другие действия
            }
			else{
				// платеж не прошел валидацию. Необходимо проверить вручную
			}
        }
        else{
			//дубликат, эту транзакцию мы уже обработали. ничего не делаем
        }
    }
?>	

Payment Validation


Payment validation is highly dependent on the business logic of your application. Specific conditions may be added. For example, the user paid 15 units of goods, and there are only 10 available. You can’t miss such an order.

However, it makes sense to check such things at the stage of form generation. Validation of the payment is needed rather to prevent fraud (for example, the user in the form of payment manually increased the amount of goods, but left the price unchanged).

There are a few things worth checking out anyway:
  • Check the correspondence of the price in the payment and in our database
  • Check that the total cost is not equal to 0 (paranoia because the previous paragraph covers this case)
  • Check that the correct payee is specified
  • Check payment status
  • Check payment currency

getTotalPrice($myPost['quantity']) != $myPost['payment_gross']){
            $valid = false;
        }
		/*
		 * Проверка на нулевую цену
		 */
        elseif($myPost['payment_gross'] == 0){
            $valid = false;
        }
		/*
		 * Проверка статуса платежа
		 */
        elseif($myPost['payment_status'] !== 'Completed'){
            $valid = false;
        }
		/*
		 * Проверка получателя платежа
		 */
        elseif($myPost['receiver_email'] != 'YOUR PAYPAL ACCOUNT'){
            $valid = false;
        }
		/*
		 * Проверка валюты
		 */
        elseif($myPost['mc_currency'] != 'USD'){
            $valid = false;
        }
        return $valid;
    }
?>	

Well and, of course, add your checks.

As a result, one-time payments should work for you. At the stage of creating a payment form, we can specify any parameters. For example, you can flexibly control the price of a product (2 for the price of 3, for every 101 customers a 30% discount, etc.). We do not need to change anything in Paypal.

Subscriptions


Now consider the implementation of subscriptions. The principle is the same as with one-time payments. Only recurring payments. Therefore, their implementation is somewhat more complicated.

Several tariff plans are available, for example, Free - for free, Pro - $ 5 per user per month, Premium - $ 10 per user per month.
The user can unsubscribe with a refund for an unused period. Also, the user can change the subscription conditions, for example, switch to another tariff plan, or change the number of users.

It is clear that paypal is not needed at all for a Free subscription. Perhaps this tariff plan should be activated automatically, immediately upon user registration in our application. This scheme is good in that it shows a typical use for some SaaS system. And on the go it’s not very clear how to implement this using Paypal.

To work with subscriptions, you will need additional tables:

subscription_plans - to store tariff plans:

idservice_providerservice_namepriceprice_typeperiod
1Servicepro5.00usermonth
2Serviceenterprise10.00usermonth
3Servicefree0.00usermonth

subscriptions - to store subscriptions:

iduser_idplan_idsubscription_idcreated_dateupdated_datepayment_dateitems_countstatus

Subscription Form


The subscription form is very similar to the one-time payment form.

 $userId, 'service_id' => $serviceId ];
?>

Subscription price is set by parameter a3. The subscription period is set using parameters p3 and t3 (in this example, payments occur every month).

A detailed description of these and other parameters can be found in the documentation .

IPN


With IPN, everything is basically the same as with one-time payments. True, we will receive more requests since more events need to be handled: subscription creation, subscription payment, unsubscription, etc. As before, you need to check the reliability for each request and only then process it.

Subscription Validation


Everything here is a little more complicated than with one-time payments. We need to validate not only the payment, but also the creation of the subscription, the cancellation of the subscription, possibly a change in the subscription. Perhaps something else, depending on the logic of the application. For example, we want to make it possible to create no more than 100 users on the Pro tariff plan. Or something else like that. Again, you can try to take all this into account at the stage of creating the form.

What exactly needs to be checked in this case:

  • In case of cancellation, check that the subscription exists
  • To pay by subscription, check that
    • the price is not 0
    • payment amount is equal to the size of the subscription
    • the recipient is specified correctly
    • Completed Subscription Status
    • USD currency

  • In the case of a refund, you need to check that the payment exists and the refund amount is not more than the payment amount (the refund amount may be less than the payment if we are conducting a partial refund)
  • If you create a subscription, you need to check that the tariff plan exists and the prices match

getUserData($userId);
        $customData = $this->getCustomData($myPost);
		//валидация для отмены подписки
        if($myPost['txn_type'] == 'subscr_cancel'){
            $subscriptionService = new SubscriptionService();
            $subscription = $subscriptionService->loadBySubscriptionId($myPost['subscr_id']);
            if(!$subscription->id){
				//подписка не существует
                return false;
            }
        }
		//валидация для платежа
        elseif($myPost['txn_type'] == 'subscr_payment'){
            // проверяем правильность цены
			if($subscriptionPlan->price * $myPost['customData']['items_count'] != $myPost['mc_gross']){
                return false;
            }
            // проверяем, что цена не равна 0
			if($myPost['mc_gross'] == 0){
                return false;
            }
			//проверяем получателя платежа
            if($myPost['receiver_email'] != 'xxx-facilitator@yandex.ru'){
                return false;
            }
			//проверяем валюту
            if($myPost['mc_currency'] != 'USD'){
                return false;
            }
			//проверяем статус платежа
            if($myPost['payment_status'] != 'Completed'){
                return false;
            }
        }
		//проверяем возврат платежа
        elseif($myPost['reason_code'] == 'refund' && $myPost['payment_status'] == 'Refunded'){
			$transactionService = new TransactionService();
            $lastTransaction = $transactionService->getLastActiveTransactionBySubscription($myPost['subscr_id']);
			//проверяем, что платеж существует
            if(!$lastTransaction){
                return false;
            }
			//проверяем, что сумма возврата не больше суммы платежа
            if(abs($myPost['mc_gross']) > $lastTransaction['mc_gross']){
                return false;
            }
        }
        return true;
    }
?>

Payment processing


After successful validation, you can continue processing the payment. Here we have several possible subscription states:
  • subscription does not exist
  • subscription active
  • unsubscribed

Depending on the status of the subscription, requests will be handled differently.

getCustomData($myPost);
        $userId = $customData['user_id'];
        $userService = new UserService();
        $userInfo = $userService->getUserData($userId);
		$subscriptionPlanService = new SubscriptionPlanService();
        $subscriptionPlan = $subscriptionPlanService->getSubscriptionPlan($myPost);
		$transactionService = new TransactionService();
		$subscriptionService = new SubscriptionService();
        if(validateSubscription($subscriptionPlan,$myPost)){
            $subscription = $subscriptionService->loadBySubscriptionId($myPost['subscr_id']);
            $transaction = $transactionService->getTransactionById($myPost['txn_id']);
			//подписка существует
            if($subscription->id){
				// платеж по подписке
                if($myPost['txn_type'] == 'subscr_payment'){
					// транзакция еще не обрабатывалась
                    if(!$transaction){
						// обновляем подписку
                        $subscription->status = 'active';
                        $subscription->payment_date = $myPost['payment_date'];
                        $subscription->updated_date = date('Y-m-d H:i:s');
                        $subscription->save();
						// сохраняем транзакцию
						$myPost['relation_id'] = $subscription->id;
                        $myPost['relation_type'] = 'transaction';
                        $transactionService->createTransaction($myPost);
                    }
                    else{
                        //транзакция уже обрабатывалась. ничего не нужно делать
                    }
                }
				// отмена подписки
                if($myPost['txn_type'] == 'subscr_cancel'){
                    $subscription->status = 'cancelled';
                    $subscription->updated_date = date('Y-m-d H:i:s');
                    $subscription->save();
                }
				// подписка истекла
                if($myPost['txn_type'] == 'subscr_eot'){
                    $subscription->status = 'expired';
                    $subscription->updated_date = date('Y-m-d H:i:s');
                    $subscription->save();
                }
				// подписка уже существует
                if($myPost['txn_type'] == 'subscr_signup'){
                }
				// пользователь изменил условия подписки в одностороннем порядке. отменяем подписку. Нужно связаться с пользователем
                if($myPost['txn_type'] == 'subscr_modify'){
                    $subscription->status = 'modified';
                    $subscription->updated_date = date('Y-m-d H:i:s');
                    $subscription->save();
                }
				// возврат платежа
                if($myPost['payment_status'] == 'Refunded' && $myPost['reason_code'] == 'refund'){
					// обновляем транзакцию в нашей базе
                    $transactionService->updateTransactionStatus($myPost['parent_txn_id'],'Refunded');
					//сохраняем обратную транзакцию (возврат)
                    $myPost['txn_type'] = 'refund';
                    $myPost['relation_id'] = $subscription->id;
                    $myPost['relation_type'] = 'subscription';
                    $transactionService->createTransaction($myPost);
                }
            }
			// подписка не существует
            else{
				// первый платеж по подписке
                if($myPost['txn_type'] == 'subscr_payment'){
                    $activeSubscriptions = $subscriptionService->getActiveSubscriptions($userId);
                    // проверяем, что у пользователя нет активной подписки.
                    if(count($activeSubscriptions) > 0){
                        // ошибка, пользователь не может иметь больше одной подписки
                    }
                    elseif(!$transaction){
						// создаем подписку
                        $subscription = new Subscription();
                        $subscription->user_id = $userId;
                        $subscription->plan_id = $subscriptionPlan->id;
                        $subscription->subscription_id = $myPost['subscr_id'];
                        $subscription->created_date = date("Y-m-d H:i:s");
                        $subscription->updated_date = date('Y-m-d H:i:s');
                        $subscription->payment_date = $myPost['payment_date'];
                        $subscription->items_count = $customData['items_count'];
                        $subscription->status = 'active';
                        $subscriptionId = $subscription->save();
						// сохраняем транзакцию
                        $myPost['relation_id'] = $subscriptionId;
                        $myPost['relation_type'] = PaypalTransaction::TRANSACTION_RELATION_SUBSCRIPTION;
                        $transactionService = new PaypalTransaction();
                        $transactionService->createTransaction($myPost);
                    }
                    else{
                        // платеж уже обработан
                    }
                }
				// создание подписки. можно было бы создавать подписку здесь, но мы создаем ее при обработке первого платежа
                if($myPost['txn_type'] == 'subscr_signup'){
                }
				// изменение подписки. Такого быть не должно т.к. подписка еще не существует
                if($myPost['txn_type'] == 'subscr_modify'){
                }
            }
        }
        else{
            // подписка не прошла валидацию
        }
    }
?>

Unsubscribe


We realize the cancellation of the subscription, in case the user is tired of using our application. In this case, we will use Paypal Classic Api to cancel the subscription.

To work with the API, we need Username, Password and Signature. They can be found in the profile settings. Unsubscribing is



done using the ManageRecurringPaymentsProfileStatus method


There is some problem with this method, as we cannot cancel the subscription if it is already canceled. But we can’t check the status of the subscription either. Therefore, we have to cancel the subscription all the time (in a normal situation, we do not have to cancel the subscription twice). This problem is described in this post .

Refund (full / partial)


Perhaps, in addition to canceling the subscription, the user would like to return the money for an unused period (note: subscribed for a month, canceled after a week - you need to return 75% of the cost).

You can also use Paypal Classic Api, the RefundTransaction method .


To calculate the amount of return, you can use the following code. The code is designed to calculate the monthly subscription return.

diff($currentDate);
    $days =  $dDiff->days;
    $daysInMonth = cal_days_in_month(CAL_GREGORIAN,$currentDate->format('m'),$currentDate->format('Y'));
    $amount = $transaction['mc_gross'] - $transaction['mc_gross'] * $days / $daysInMonth;
    $amount = round($amount, 2, PHP_ROUND_HALF_DOWN);
    $amount = str_replace(',','.',$amount);
    return $amount;
}
?>

Change subscription


Now add the ability to change the conditions of the subscription. This will be needed if the user wants to change the tariff plan, or the number of users. Unfortunately, paypal imposes certain restrictions on changing a subscription.

This problem is discussed here

. I do not want to check this information myself. At the same time, a situation is possible when the user wants to change the tariff plan, and the cost of the subscription will change significantly. In this case, you can first cancel the current subscription and conduct a partial refund. Then create a new subscription with other parameters.

Perhaps not very beautiful, but it works flawlessly. As a result, I settled on this option. although, in principle, the link above contains information on how to make a change in the subscription more correct.

Conclusion


As a result, we get the opportunity to work with one-time payments and Paypal subscriptions. The logic for working with one-time subscription payments is located in our web application.

Over time, we can add new tariff plans and change old ones (you need to do this carefully, check validation, etc.).

This concludes the story. Thank you all for your attention. Hope the article has been helpful. I will be glad to answer questions in the comments.

Upd : Thank you Daniyar94 . You can use PDT in addition to IPN. This will help to immediately display a message about a successful payment. Details here habrahabr.ru/post/266091/#comment_8560801

Also popular now: