Інтегруємо оплату через Paypal в web-додаток

В даній статті розглянуто інтеграція разових платежів, а також оплати за передплатою з допомогою Paypal у веб-додаток. Приклади реалізовані на PHP, але, в принципі, без особливих проблем те ж саме можна зробити за допомогою інших технологій. Даний метод обраний як компроміс між простотою і гнучкістю. Це спроба написати керівництво, яке допоможе швидко розібратися в темі і інтегрувати оплату через Paypal в свій проект.

Стаття орієнтована в основному на тих, хто раніше з цією системою не працював. Знавці Paypal навряд чи знайдуть тут для себе щось нове. Але, можливо, вони вкажуть на недоліки даного методу або порадять, як можна було б по-іншому це реалізувати.

Створення облікового запису
Для реалізації даної схеми нам потрібно business аккаунт. PayPal Payments Standard повинно бути достатньо.
Переходимо посилання і створюємо аккаунт.

Створення sandbox облікового запису
Для тестування нашого додатка будемо використовувати Paypal Sandbox. Нам знадобиться 2 sandbox облікового запису. Аккаунт покупця(buyer) і аккаунт продавця(facilitator). Насамперед потрібно задати пароль для обох sandbox акаунтів. Для цього переходимо на сайт paypal розділ для розробників. Логинимся, потім переходимо в dashboard. В меню зліва знаходимо розділ Sandbox, вкладку рахунки. Тут ми можемо побачити 2 sandbox аккаунта(Buyer і Facilitator).



Натискаємо на profile, у вікні модальному вікні натискаємо change password, потім зберігаємо пароль.
Встановлюємо паролі для обох облікових записів. Після цього можна перейти на сайт Paypal Sandbox і спробувати залогінитися.

Налаштування Paypal
Тепер нам потрібно налаштувати Paypal Facilitator обліковий запис, на який ми будемо отримувати кошти. Переходимо на сайт Sandbox, логинимся з допомогою facilitator облікового запису і переходимо в налаштування профілю. Відкриваємо меню profile, вибираємо пункт my selling tools.



У розділі Selling online вибираємо пункт Website preferences, натискаємо Update. Тут можна включити перенаправлення користувача. Після завершення платежу користувач за замовчуванням буде перенаправлено на вказаний url. Але також є можливість перенаправити користувача на інший url (див. нижче).



Також необхідно активувати Paypal Instant Payment Notifications. Для цього в розділі Getting paid and managing my risk вибираємо пункт Instant payment notifications і також натискаємо Update.



У налаштуваннях IPN вказуємо URL, на якому буде працювати наш IPN Listener. Цей URL обов'язково повинен бути доступний глобально т. к. на нього будуть приходити повідомлення про проведення операцій.



Включаємо Message delivery і сохраняемся. На цьому налаштування облікового запису завершена. Можна приступити до налаштування безпосередньо платежів.

Разові Платежі
Для початку реалізуємо разові платежі. Це, ймовірно, найбільш поширений варіант використання. Користувач просто хоче купити який-небудь товар або разову послугу. Ну і хочеться, щоб нам нічого більше не потрібно було змінювати в налаштуваннях paypal. Список товарів і ціни зберігалися б в базі нашої програми, ми могли б їх змінювати як нам хочеться. Для разових платежів будемо використовувати Payment Buttons (PayPal Payments Standard).

Структура даних
Список товарів зберігатися в базі даних нашого застосування. Ми можемо додавати, видаляти і редагувати товари в будь-який момент. Тут представлена найбільш проста структура, вся інформація зберігатися в одній таблиці.

Але можна і ускладнити завдання. Наприклад, змінювати ціну в залежності від кількості замовлених товарів, або змінювати вартість в залежності від дня тижня і часу.

Або включити в замовлення багато різних товарів.

products — тут будемо зберігати товари:

id name price description
1 Product 1 1.0 ...
2 Product 2 4.0 ...
users — тут будемо зберігати користувачів:

id firstname lastname email password
315 Alan Smith alansmith@example.com $1$2z4.hu5.$E3A3H6csEPDBoH8VYK3AB0
316 Joe Doe joedoe@example.com $1$Kd4.Lf0.$pGc1h7vwmy9N6EJxac953/

products_users — кому ми і що відвантажили:

id user_id product_id items_count created_date
1 315 1 3 2015-09-03 08:23:05
Також будемо зберігати в нашій базі історію транзакцій в таблиці transactions:

txn_id txn_type mc_gross mc_currency quantity payment_date payment_status business receiver_email payer_id payer_email relation_id relation_type created_date
Форма оплати
Для початку створимо форму замовлення. Генеруємо форму в нашому додатку, де вказуємо основні параметри замовлення(назва товару, ціна, кількість).

Тут ми можемо вказати будь-яку ціну, назву, кількість і т. д. Полі custom корисно тим, що в ньому можна передавати будь-які дані. Тут ми будемо передавати id товару, id користувача і, можливо, іншу інформацію. Ці дані знадобляться нам для подальшої обробки платежу.
Якщо потрібно передати кілька параметрів, можна використовувати json або серіалізацію. Або можна використати додаткові поля виду on0, on1, os0 and os1. Особисто я не перевіряв, інформацію знайшов здесь.

Нижче наведено приклад форми:

<?php
$payNowButtonUrl = 'https://www.sandbox.paypal.com/cgi-bin/websc';
$userId = 315 // id поточного користувача

$receiverEmail = 'xxx-facilitator@yandex.ru'; //email одержувача платежу(на нього зареєстрований paypal аккаунт) 

$productId = 1;
$ім'я елемента = 'Product 1'; // назва продукту
$amount = '1.0'; // ціна продукту(за 1 шт.)
$quantity = 3; // кількість

$returnUrl = 'http://your-site.com/single_payment?status=paymentSuccess';
$customData = ['user_id' => $userId, 'product_id' => $productId];
?>

<form action="<?php echo $payNowButtonUrl; ?>" method="post">
<input type="hidden" name="cmd" value="_xclick">
<input type="hidden" name="business" value="<?php echo $receiverEmail; ?>">
<input id="paypalItemName" type="hidden" name="item_name" value="<?php echo $ім'я елемента; ?>">
<input id="paypalQuantity" type="hidden" name="quantity" value="<?php echo $quantity; ?>">
<input id="paypalAmmount" type="hidden" name="amount" value="<?php echo $amount; ?>">
<input type="hidden" name="no_shipping" value="1">
<input type="hidden" name="return" value="<?php echo $returnUrl; ?>">

<input type="hidden" name="custom" value="<?php echo json_encode($customData);?>">

<input type="hidden" name="currency_code" value="USD">
<input type="hidden" name="lc" value="US">
<input type="hidden" name="bn" value="PP-BuyNowBF">

<button type="submit">
Pay Now 
</button>
</form>

Насправді, параметрів може бути набагато більше, детальну інформацію можна знайти на документации. Після відправки форми користувач потрапляє на сторінку оплати paypal, де він знову бачить деталі замовлення.



Тут користувач може оплатити замовлення з допомогою paypal облікового запису чи з допомогою банківської картки. Далі користувач переадресовується назад на наш сайт(return), де ми можемо повідомити йому, що його платіж знаходиться в обробці.

Instant Payment Notification(IPN)
Після того, як користувач здійснив платіж, Paypal обробляє його і відправляє підтвердження в наш додаток. Для цього використовується сервіс Instant Payment Notification(IPN).

На початку статті ми налаштовували наш Paypal аккаунт і встановлювали IPN Notification URL. Зараз саме час створити IPN listener, який буде обробляти IPN запити. Paypal надає приклад реалізації IPN listener. Докладне пояснення роботи сервісу можна знайти на тут. У двох словах, як це працює: Paypal обробляє платіж користувача, бачить, що все добре і платіж успішно завершено. Після цього IPN відправляє на наш Notification URL такого виду 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

Наш IPN Listener цей запит повинен обробити. Зокрема:

  • Перевірити тип запиту(разовий платіж або підписка). Залежно від цього по-різному будемо його обробляти. В нашому випадку це буде разовий платіж — web_accept.
  • Вибрати оточення — sandbox або live.
  • Перевірити достовірність запиту. Знаючи як виглядає IPN запит і, знаючи наш IPN Notification URL, будь-який бажаючий може відправити нам підроблений запит. Тому ми обов'язково повинні виконати цю перевірку.
<?php
/**
* Class PaypalIpn
*/
class PaypalIpn{

private $debug = true;
private $service;

/**
* @throws Exception
*/
public function createIpnListener(){
$postData = file_get_contents('php://input');
$transactionType = $this->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 without 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;
}
} 
?>

Після цього, якщо Paypal підтвердив справжність запиту, можемо приступити до його подальшої обробки.

Обробка платежу
В першу чергу нам потрібно отримати значення поля custom, де ми передавали id замовлення, id користувача або ще щось(залежить від логіки нашого додатка). Відповідно ми зможемо отримати з нашої бази даних інформацію про користувача/замовленні. Також потрібно отримати id транзакції.

Paypal може кілька разів надсилати підтвердження однієї і тієї ж операції. Тому потрібно перевірити і, якщо транзакція не оброблялася, обробляємо її. Якщо транзакція вже оброблялася, тоді нічого не робимо.

Проводимо валідацію платежу. Якщо все нормально, тоді можна зберегти інформацію про платіж в базу і виконати подальші дії (присвоїти користувачу статус «premium», замовлення статус «сплачено» і т. д.). Якщо платіж не пройшов валідацію, необхідно встановити причину і зв'язатися з користувачем. Подальші операції, зокрема, скасування платежу, проводяться вручну.

<?php
function processPayment($myPost){

$customData = json_decode($myPost['custom'],true);
$userId = $customData['user_id'];
$productId = $customData['product_id'];

//
$userService = new UserService();
$userInfo = $userService->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{
//дублікат, цю транзакцію ми вже опрацювали. нічого не робимо
}
}
?> 

Валідація платежу
Валідація платежу сильно залежить від бізнес-логіки застосунку. Можуть бути додані специфічні умови. Наприклад, користувач сплатив 15 одиниць товару, а в наявності є лише 10. Не можна пропустити таке замовлення.

Втім, такі речі має сенс перевіряти ще на етапі генерації форми. Валідація платежу потрібна швидше для запобігання шахрайства(наприклад, користувач у формі оплати вручну збільшив кількість товару, а ціну залишив незмінною).

Є кілька речей, які в будь-якому випадку варто перевірити:
  • Перевірити відповідність ціни в платежі і в нашій базі даних
  • Перевірити що підсумкова вартість не дорівнює 0(параноя т. к. попередній пункт покриває цей випадок)
  • Перевірити, що вказано правильну одержувач платежу
  • Перевірити статус платежу
  • Перевірити валюту платежу
<?php
function validateTransaction($myPost,$product){
$valid = true;

/*
* Перевірка відповідності цін
*/
if($product->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;
}
?> 

Ну і, звичайно, додавайте свої перевірки.

В результаті у вас повинні працювати разові платежі. На етапі створення форми платежу ми можемо вказувати будь-які параметри. Наприклад, можна гнучко управляти ціною товару(2 за ціною 3, кожному 101 покупцю знижка 30% тощо). Нам для цього не потрібно нічого міняти в Paypal.

Підписки
Тепер розглянемо реалізацію підписок. Принцип той же, що і з разовими платежами. Тільки платежі повторюються. Тому їх реалізація дещо складніше.

Доступно кілька тарифних планів, наприклад Free — безкоштовно, Pro — 5$ на користувача в місяць, Premium — 10$ на користувача в місяць.
Користувач може скасувати підписку з поверненням грошей за невикористаний період. Також користувач може змінювати умови підписки, наприклад, перейти на інший тарифний план, або змінити кількість користувачів.

Зрозуміло, що для Free підписки paypal взагалі не потрібен. Можливо, цей тарифний план повинен бути придатним автоматично, відразу при реєстрації користувача в нашому додатку. Дана схема хороша тим, що показує типове використання для якої-небудь SaaS системи. І з ходу не дуже зрозуміло, як реалізувати це з використанням Paypal.

Для роботи з підписками знадобляться додаткові таблиці:

subscription_plans — для зберігання тарифних планів:

id service_provider service_name price price_type period
1 Service pro 5.00 user month
2 Service enterprise 10.00 user month
3 Service free 0.00 user month
subscriptions — для зберігання підписок:

id user_id plan_id subscription_id created_date updated_date payment_date items_count status
Форма оформлення передплати
Форма оформлення підписки дуже схожа на форму створення разового платежу.

<?php
$payNowButtonUrl = 'https://www.sandbox.paypal.com/cgi-bin/websc';
$userId = 1 // id поточного користувача

$receiverEmail = 'xxx-facilitator@gmail.com'; //email одержувача платежу(на нього зареєстрований paypal аккаунт) 

$serviceId = 1;
$serviceName = 'Service Pro'; // назва підписки(тарифний план)
$servicePrice = '5.00'; // вартість сервісу - 5$ за 1 користувача за місяць
$quantity = 3; // кількість користувачів

$amount = $servicePrice * $quantity; // вартість передплати - 15$ у місяць

$returnUrl = 'http://your-site.com/subscription?status=paymentSuccess';
$customData = ['user_id' => $userId, 'service_id' => $serviceId ];
?>

<form id="createSubscription" action="<?php echo $payNowButtonUrl; ?>" method="post" target="_top">
<input type="hidden" name="cmd" value="_xclick-subscriptions">
<input type="hidden" name="business" value="<?php echo $receiverEmail; ?>">
<input type="hidden" name="lc" value="GB">

<input type="hidden" name="item_name" value="<?php echo $serviceName; ?>">
<input type="hidden" name="no_note" value="1">
<input type="hidden" name="no_shipping" value="1">

<input type="hidden" name="return" value="<?php echo $returnUrl; ?>">

<input type="hidden" name="src" value="1">
<input type="hidden" name="a3" value="<?php echo $amount; ?>">

<input type="hidden" name="p3" value="1">
<input type="hidden" name="t3" value="M">

<input id="customData" type="hidden" name="custom" value="<?php echo json_encode($customData); ?>">
<input type="hidden" name="currency_code" value="USD">

<button type="submit">Subscribe</button>
</form>

Вартість передплати задається параметром a3. Період підписки задається за допомогою параметрів p3 і t3(в даному прикладі платежі відбуваються кожен місяць).

Докладний опис цих і інших параметрів можна подивитися в документации.

IPN
З IPN принципово все те ж саме, що і з разовими платежами. Правда, ми будемо отримувати більше запитів т. к. потрібно обробляти більше подій: створення підписки, платіж за передплатою, скасування підписки і т. д. Як і раніше, потрібно перевіряти достовірність для кожного запиту і тільки після цього обробляти.

Валідація підписки
Тут все трохи складніше, ніж з разовими платежами. Нам потрібно валідувати не тільки платіж, але і створення підписки, скасування підписки, можливо, зміна підписки. Можливо, щось ще, в залежності від логіки роботи програми. Наприклад, ми хочемо, щоб на тарифному плані Pro можна було створити не більше 100 користувачів. Або ще що-небудь в цьому роді. Знову ж таки все це можна спробувати врахувати на етапі створення форми.

Що точно необхідно перевіряти в даному випадку:

  • У разі скасування підписки потрібно перевірити, що підписка існує
  • Для платежу по підписці необхідно перевірити, що
    • ціна не дорівнює 0
    • розмір платежу дорівнює розміру підписки

    • одержувач вказаний правильно
    • статус передплати «Completed»
    • валюта USD
  • У разі повернення платежу потрібно перевірити, що платіж існує і сума повернення не більше суми платежу(сума повернення може бути менше платежу, у випадку, якщо ми проводимо частковий повернення)
  • У разі створення підписки потрібно перевірити тарифний план існує і ціни збігаються
<?php

function validateSubscription($subscriptionPlan,$myPost){
$userId = $myPost['customData']['user_id'];
$userService = new UserService();
$userInfo = $userService->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;
}
?>

Обробка платежу
Після успішної валідації можна продовжити обробку платежу. Тут у нас можливі кілька станів підписки:
  • передплата не існує
  • підписка активна
  • підписка скасована

Залежно від стану підписки запити будуть оброблятися по-різному.

<?php
function processPayment($myPost){
$customData = $this->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{
// підписка не пройшла валідацію
}
}
?>

Скасування підписки
Реалізуємо скасування підписки, на випадок якщо набридне користуватися нашим додатком. В такому випадку скористаємося Paypal Classic Api для скасування підписки.

Для роботи з API нам знадобляться Username, Password і Signature. Їх можна знайти у налаштуваннях профілю.



Скасування підписки здійснюється з допомогою методу ManageRecurringPaymentsProfileStatus

<?php
// $profile_id - id підписки (параметр $myPost['subscr_id'])
// $action - 'Cancel'

public function changeSubscriptionStatus($profile_id, $action, $apiCredentials){
$api_request = 'USER=' . urlencode( $apiCredentials['username'] )
. '&PWD=' . urlencode( $apiCredentials['password'] )
. '&SIGNATURE=' . urlencode( $apiCredentials['signature'] )
. '&VERSION=76.0'
. '&METHOD=ManageRecurringPaymentsProfileStatus'
. '&PROFILEID=' . urlencode( $profile_id )
. '&ACTION=' . urlencode( $action )
. '&NOTE=' . urlencode( 'Profile cancelled at store' );

$ch = curl_init();

curl_setopt( $ch, CURLOPT_URL, 'https://api-3t.sandbox.paypal.com/nvp' ); // For live transactions, change to 'https://api-3t.paypal.com/nvp'

curl_setopt( $ch, CURLOPT_VERBOSE, 1 );

curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 1);

curl_setopt( $ch, CURLOPT_RETURNTRANSFER, 1 );
curl_setopt( $ch, CURLOPT_POST, 1 );

// Set the API parameters for this transaction
curl_setopt( $ch, CURLOPT_POSTFIELDS, $api_request );

// Request response from PayPal
$response = curl_exec( $ch );

// If no response was received from PayPal there is no point parsing the response
if( ! $response ){
return false;
}

curl_close( $ch );

// An associative array is more usable than a parameter string
parse_str( $response, $parsed_response );

return $parsed_response;
}
?>

Є деяка проблема з цим методом, оскільки ми не можемо скасувати підписку, якщо вона вже скасовано. Але і перевірити статус підписки ми теж не можемо. Тому доводиться скасовувати підписку вселпую (в нормальній ситуації нам не доведеться скасувати підписку двічі). Дана проблема описана в цьому пості.

Повернення коштів(повний/частковий)
Можливо, крім скасування підписки користувач хотів би повернути гроші за невикористаний період(прим: оформив передплату на місяць, через тиждень скасував — потрібно повернути 75% вартості).

Для цього також можна використовувати Paypal Classic Api, метод RefundTransaction.

<?php 
// $transaction_id - $myPost['txn_id']
// $amount - сума часткового повернення

public function refundTransaction($transaction_id,$apiCredentials$amount = null){

$transaction_id = $transaction['txn_id'];

$refundType = 'Full';

if($amount){
$amount = round($amount, 2, PHP_ROUND_HALF_DOWN);
$amount = str_replace(',','.',$amount);
$refundType = 'Partial';
}

$api_request = 'USER=' . urlencode( $apiCredentials['username'] )
. '&PWD=' . urlencode( $apiCredentials['password'] )
. '&SIGNATURE=' . urlencode( $apiCredentials['signature'] )
. '&VERSION=119'
. '&METHOD=RefundTransaction'
. '&TRANSACTIONID=' . urlencode( $transaction_id )
. '&REFUNDTYPE=' . urlencode( $refundType )
. '&CURRENCYCODE=' . urlencode( 'USD' );

if($amount){
$api_request .= '&AMT=' . urlencode( $amount );
}


$ch = curl_init();

curl_setopt( $ch, CURLOPT_URL, 'https://api-3t.sandbox.paypal.com/nvp' ); // For live transactions, change to 'https://api-3t.paypal.com/nvp'

curl_setopt( $ch, CURLOPT_VERBOSE, 1 );

curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 1);

curl_setopt( $ch, CURLOPT_RETURNTRANSFER, 1 );
curl_setopt( $ch, CURLOPT_POST, 1 );

// Set the API parameters for this transaction
curl_setopt( $ch, CURLOPT_POSTFIELDS, $api_request );

// Request response from PayPal
$response = curl_exec( $ch );

// If no response was received from PayPal there is no point parsing the response
if( ! $response ){
return false;
}

curl_close( $ch );

// An associative array is more usable than a parameter string
parse_str( $response, $parsed_response );

return $parsed_response;
}
?>

Для розрахунку суми повернення можна використовувати наступний код. Код призначений для розрахунку повернення щомісячної підписки.

<?php
public static function getTransactionRefundAmount($transaction){
$paymentDate = date('Y-m-d',strtotime($transaction['payment_date']));
$currentDate = date('Y-m-d');

$paymentDate = new DateTime($paymentDate);
$currentDate = new DateTime($currentDate);

$dDiff = $paymentDate->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;
}
?>

Зміна підписки
Тепер додамо можливість зміни умов підписки. Це знадобиться в разі, якщо користувач захоче змінити тарифний план, або кількість користувачів. На жаль, paypal накладає певні обмеження на зміну підписки.

Ця проблема обговорюється здесь

На собі перевіряти цю інформацію не хочеться. У той же час, можлива ситуація, коли користувач захоче поміняти тарифний план, і вартість передплати сильно зміниться. В такому випадку можна спочатку скасувати поточну підписку і провести часткове повернення коштів. Потім створити нову підписку з іншими параметрами.

Можливо, не дуже гарно, але працює без нарікань. У підсумку зупинився на цьому варіанті. хоча, в принципі, за посиланням вище є інформація, як зробити зміну підписки більш правильно.

Висновок
В результаті отримуємо можливість працювати з разовими платежами і підписками Paypal. Логіка роботи з разовими платежами підписками знаходиться в нашому веб-додатку.

З часом ми можемо додавати нові тарифні плани і змінювати старі (робити це потрібно обережно, перевіряти валідацію і т. д.).

На цьому закінчую розповідь. Всім дякую за увагу. Сподіваюся стаття була корисною. Буду радий відповісти на питання в коментарях.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.