
Интернет-эквайринг на примере. Оплата товаров и услуг на своем сайте через Сбербанк
Подробнее о интернет-эквайринге Сбербанка
Для возможности оплаты товаров или услуг на сайте с помощью Сбербанка, необходимо на сервере иметь открытый исходящий доступ и TLS 1.2 (протокол обеспечивающий защищенную передачу данных). Вызывающая сторона, т.е. сайт или магазин, должен дожидаться получения ответа для продолжения работы, а также взаимодействие всегда одностороннее - сайт обращается к платежному шлюзу, но не наоборот.
Система предоставляет два способа взаимодействия:
- SOAP (web-сервисы)
- REST (http запросы)
Примерный принцип работы, схожий с любой другой платежной системой:
- Пользователь заполняет форму на сайте и нажимает кнопку оплатить
- Сайт отправляет запрос на регистрацию заказа на платежный шлюз, шлюз возвращает ссылку на страницу оплаты
- Сайт перенаправляет пользователя на страницу оплаты, которая уже находится на стороне сервера (страница может быть стандартным шаблоном или вы можете её сверстать, следуя рекомендациям Сбера и передав в дальнейшем архив их специалистам)
- Пользователь заполняет информацию о своей карте и нажимает кнопку оплатить
- Платежный шлюз обрабатывает информацию, завершает оплату и перенаправляет пользователя на страницу сайта, указанную при регистрации заказа
- Сайт получает от платежного шлюза информацию по результатам оплаты и отображает пользователю
- Сайт отправляет смс пользователю об успешной оплате (как бонус к статье, это не относится к Сбербанку)
Примечание: пользователь должен совершить оплату в течение 20 минут, иначе оплата считается неудачной; статус оплаты можно изменить также через непосредственный запрос владельца сайта к сотрудникам банка.
В документации, которую предоставляет Сбербанк и которая содержит в себе 150 страниц, в принципе все описано понятно и доходчиво. Итак, попробуем создать инструмент, который позволит нам реализовать весь принцип работы. Все работы будут в рамках php и Yii2.
Подготовка почвы
Для начала создадим таблицу в базе для хранения информации о платеже, т.к. для запроса на регистрацию заказа необходимо передавать уникальный id заказа, к тому же в эту таблицу будет вносить информацию не только о статусе платежа, но и так же о статусе отправки сообщения.
Создаем миграцию:
$ ./yii migrate/create create_payment_table
У меня по задаче посетитель сайта покупает карту в фитнес клуб, поэтому поля получились такими:
class m170420_071317_create_payment_table extends Migration
{
/**
* @inheritdoc
*/
public function up()
{
$this->createTable('payment', [
'id' => $this->primaryKey(),
'name' => $this->string(255)->notNull() . ' COMMENT "Имя"',
'surname' => $this->string(255)->notNull() . ' COMMENT "Фамилия"',
'sex' => $this->integer()->notNull() . ' COMMENT "Пол"',
'dob' => $this->date()->notNull() . ' COMMENT "Дата рождения"',
'phone' => $this->string(12)->notNull() . ' COMMENT "Мобильный телефон"',
'dateCreate' => $this->dateTime()->notNull() . ' COMMENT "Дата создания"',
'paymentStatus' => $this->integer() . ' COMMENT "Статус оплаты в системе оплаты"',
'paymentOrderId' => $this->string(36) . ' COMMENT "Id в системе оплаты"',
'paymentErrorCode' => $this->integer() . ' COMMENT "Ошибка системы оплаты"',
'paymentErrorMessage' => $this->string(512) . ' COMMENT "Описание ошибки системы оплаты"',
'isSmsSend' => $this->integer()->defaultValue(0)->notNull() . ' COMMENT "Отправлено смс"',
'description' => $this->text . ' COMMENT "Описание"',
'amount' => $this->integer() . ' COMMENT "Стоимость"',
]);
}
/**
* @inheritdoc
*/
public function down()
{
$this->dropTable('payment');
}
}
Применяем миграцию:
$ ./yii migrate/up
Создаем модель через gii и сразу добавляем небольшой бихейвор:
public function behaviors()
{
return [
[
'class' => CreateDateBehavior::className(),
'createdAtAttribute' => 'dateCreate',
'value' => \Yii::$app->formatter->asDatetime('now', "php:Y-m-d H:i:s"),
],
];
}
Я создала класс PaymentGateClient, в котором определила единственный метод get, который будет возвращать нам объект класса, в зависимости от определенного типа, будь то soap или rest. Если же выбрали только единственный подходящий вам, то вы можете перепрыгнуть сразу к реализации класса уже нужного метода. У нас же будет возможность переключаться.
class PaymentGateClient
{
const SOAP = 'soap';
const REST = 'rest';
/**
* Получаем экземпляр класса в зависимости от типа
*
* @param string $type
*
* @return RESTClient|SOAPClient
*/
public static function get(string $type)
{
switch ($type){
case self::SOAP:
return new SOAPClient();
break;
case self::REST:
return new RESTClient();
break;
default:
throw new RuntimeException('Calling undefined type of client');
break;
}
}
}
Создадим абстрактный класс Client, который будет общим для SOAPClient и RESTClient, в этом классе укажем общие атрибуты и методы. Пока там будет присутствовать два метода обязательных для любого клиента.
abstract class Client
{
/**
* Логин в системе платежного шлюза
* @var string
*/
protected $login = '{login}';
/**
* Пароль в системе платежного шлюза
* @var string
*/
protected $password = '{password}';
/**
* Регистрируем заказ на шлюзе
* @param $orderId int
* @param $description string
* @param $amount int
*
* @return mixed
*/
abstract public function registerOrder(int $orderId, string $description, int $amount);
/**
* Получаем информацию о заказе
* @param $orderId string
*
* @return mixed
*/
abstract public function getStatusOrder(string $orderId);
}
На этом наши приготовления заканчиваются, приступаем к следующему этапу.
SOAP запросы
Займемся классом SOAPClient. После создания класса и наследования от класса Client, PhpStrom заботливо предлагает переопределить методы, и потому он выглядит следующим образом:
class SOAPClient extends Client
{
/**
* @inheritdoc
*/
public function registerOrder(int $orderId, string $description, int $amount)
{
// TODO: Implement registerOrder() method.
}
/**
* @inheritdoc
*/
public function getStatusOrder(string $orderId)
{
// TODO: Implement getStatusOrder() method.
}
}
Теперь, чтобы двигаться дальше смотрим на пример запроса, указанного в документации, а еще примечаем, что при каждом запросе необходимо указывать имя и пароль, описанные в рамках спецификации WS-Security. Что такое WS-Security? Это спецификация, которая описывает усовершенствованный обмен SOAP-сообщениями, обеспечивающий целостность и конфиденциальность таких сообщений. Подробнее можно почитать в документации на английском.
Итак, нам необходимо в заголовке запроса отправлять следующую конструкцию, где {login} и {password} - логин и пароль предоставленный банком.
<wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-%20wssecurity-utility-1.0.xsd">
<wsse:UsernameToken wsu:Id="UsernameToken-87">
<wsse:Username>{login}</wsse:Username>
<wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText">{password}</wsse:Password>
</wsse:UsernameToken>
</wsse:Security>
Реализуем метод, который будет нам составлять такой заголовок.
/**
* Составляем header xml запроса
*
* @return SoapHeader
*/
private function getSoapHeaderWSSecurity()
{
//namespaces
$nsWsse = 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd';
$nsWsu = 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd';
$passwordType = 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText';
//формируем xml
$root = new SimpleXMLElement('<root/>');
$root->registerXPathNamespace('wsse', $nsWsse);
$security = $root->addChild('wsse:Security', null, $nsWsse);
$security->registerXPathNamespace('wsu', $nsWsu);
$usernameToken = $security->addChild('wsse:UsernameToken', null, $nsWsse);
$usernameToken->addChild('wsse:Username', $this->login, $nsWsse);
$usernameToken->addChild('wsse:Password', $this->password, $nsWsse)->addAttribute('Type', $passwordType);
//берем из xml только security
$securityXml = $root->xpath('/root/wsse:Security');
//формируем заголовок
return new SoapHeader($nsWsse, 'Security', new SoapVar($securityXml[0]->asXML(), XSD_ANYXML), true);
}
После чего приступаем к реализации метода получения информации о заказе, где $this->url - это координаты до wsdl, указанный в документации. CLog - мой класс по логированию.
public function getStatusOrder(string $orderId)
{
//создаем клиента для подключения к wsdl
$client = new \SoapClient($this->url, ["cache_wsdl" => 0, "trace" => 1, "exceptions" => 0]);
//задаем заголовок
$client->__setSoapHeaders($this->getSoapHeaderWSSecurity());
//формируем xml
$root = new SimpleXMLElement('<root/>');
//параметры, которые необходимо указывать, берем из документации
$order = $root->addChild('order', null, null);
$order->addAttribute('orderId', $orderId);
$order->addAttribute('language', 'ru');
$orderXml = $root->xpath('/root/order');
$orderVar = new SoapVar($orderXml[0]->asXML(), XSD_ANYXML);
try{
//отправляем запрос, getOrderStatus - метод, который предоставляет wsdl
$result = $client->getOrderStatus($orderVar);
return $result;
} catch (\SoapFault $ex){
CLog::err([$ex->getCode(), $ex->getMessage()]);
}
return false;
}
Пока у нас нет созданных заказов, поэтому пропустим реализацию обработки ответа. Теперь приступим к методу, который будет отвечать за регистрацию заказа на шлюзе. Обязательными параметрами являются merchantOrderNumber - номер заказа на нашем сайте, amount - сумма платежа, returnUrl - адрес, на который требуется перенаправить пользователя в случае успешной оплаты.
public function registerOrder(integer $orderId, string $description, integer $amount)
{
//создаем клиента для подключения к wsdl
$client = new \SoapClient($this->url, ["cache_wsdl" => 0, "trace" => 1, "exceptions" => 0]);
//задаем заголовок
$client->__setSoapHeaders($this->getSoapHeaderWSSecurity());
//формируем xml
$root = new SimpleXMLElement('<root/>');
//параметры берутся из документации
$order = $root->addChild('order', null, null);
$order->addAttribute('merchantOrderNumber', $orderId);
$order->addAttribute('description', $description);
$order->addAttribute('amount', $amount);
$order->addChild('returnUrl', 'http://site.loc/payment-done');
$orderXml = $root->xpath('/root/order');
$orderVar = new SoapVar($orderXml[0]->asXML(), XSD_ANYXML);
try{
//отправляем запрос, registerOrder - метод, который предоставляет wsdl
$result = $client->registerOrder($orderVar);
CLog::err([$result]);
if($result->errorCode == 0){
return $result;
} else {
CLog::err(['Система вернула ошибку', $result]);
}
} catch (\SoapFault $ex){
CLog::err([$ex->getCode(), $ex->getMessage()]);
}
return false;
}
Шлюз нам возвращает id заказа в их системе и url-ссылку для оплаты для покупателя, на которую его необходимо редиректить. Создание заказа в модели Payment у меня выглядит так:
public function create(){
if(!$this->isNewRecord)
return false;
//сохраняем в базе
$this->isSmsSend = 0;
if($this->save()){
$this->refresh();
//Регистрируем заказ в системе
$client = PaymentGateClient::get(PaymentGateClient::SOAP);
$response = $client->registerOrder($this->id, $this->description, $this->amount);
if($response !== false) {
//Обновляем модель
$this->paymentOrderId = $response->orderdId;
$this->update();
//Возвращаем url
return $response->formUrl;
}
return false;
} else {
CLog::err(['Не получилось сохранить модель', $this->errors]);
return false;
}
}
Этот метод вызывается в action контроллера, который отвечает за обработку данных с формы, и в случае успешной обработки и успешного создания заказа, делаю редирект на возвращаемый url.
В ответ получаем что-то типа того ({orderId} - id заказа в системе шлюза):
stdClass Object
(
[errorCode] => 0
[errorMessage] => Успешно
[formUrl] => https://domen.ru/directory/payment.html?mdOrder={orderId}&language=ru
[orderId] => {orderId}
)
После всех манипуляций пользователя на странице оплаты платежного шлюза, система редиректит его на адрес который был указан в параметре returnUrl в запросе регистрации оплаты на шлюзе. Адрес будет выглядеть так: http://site.loc/payment-done?orderId={orderId}, т.е. в query параметре будет указан {orderId} - номер заказа в системе шлюза.
Дальше, нам необходимо получить статус оплаты, чтобы понять какую информацию выводить пользователю и заодно обновить запись в базе.
В SiteController у меня создан action:
public function actionPaymentDone($orderId){
//получаем информацию о заказе от платежного шлюза
$client = PaymentGateClient::get(PaymentGateClient::SOAP);
$result = $client->getStatusOrder($orderId);
//ищем наш заказ в базе
/** @var Payment $payment */
$payment = Payment::find()->where(['paymentOrderId' => $orderId, 'id' => (int)$result->orderNumber])->one();
//если заказа нет, то выбрасываем 404
if($payment === null){
throw new NotFoundHttpException();
}
//меняем информацию в базе
$payment->changeStatus($result);
//передаем инфу на страницу и выводим как это нам надо
return $this->render('payment-done', [
'payment' => $payment
]);
}
В методе changeStatus мы изменяем данные модели и отправляем смс.
public function changeStatus(stdClass $result){
if($this->isNewRecord || empty($result))
return false;
//изменяем данные
$this->paymentStatus = $result->orderStatus;
$this->paymentErrorCode = $result->errorCode;
$this->paymentErrorMessage = !empty($result->errorMessage) ? $result->errorMessage : '';
//если платеж уже прошел, сразу кидаем смс
if($this->paymentStatus == 2) {
//отправляем сообщение
$smsSender = new SMSSender();
//проверяем возможность подключения, через авторизацию
if ($smsSender->isAuth()) {
$isSend = $smsSender->sendMessage($this->phone, 'Ваша оплата на сумму ' . $this->amount . ' на сайте site.loc прошла успешно.');
$this->isSmsSend = $isSend;
}
}
//сохраняем в базу
if($this->save()) {
$this->refresh();
return true;
} else {
CLog::err(['Не получилось сохранить модель', $this->errors]);
return false;
}
}
Класс по отправке sms будет чуть позже. В принципе для моей задачи двух этих запросов достаточно. Все платежи, которые получили иные статусы кроме "2 - Проведена полная авторизация суммы заказа", считаются неудачными и никак не обрабатываются, пользователю только предоставляется информация. Сам Cбербанк предлагает ещё различные методы: отмена оплаты заказа, возврат средств, расширенный запрос состояния заказа, проверка вовлеченности карты в 3DS, добавление дополнительных параметров к заказу, получение статистики по платежам за период, добавление карты в список SSL-карт.
REST запросы
Теперь рассмотрим как это реализовать через REST. Взаимодействие происходит как HTTP обращения методами GET или POST на определенные URL, для каждого типа - свой. Результат обработки запроса возвращается в виде JSON объекта.
Для отправки запросов будем использовать Curl, но не кустарным способом, а с помощью библиотеки "php-curl-class/php-curl-class". Добавляем его в композер: "php-curl-class/php-curl-class" : "*" и делаем composer update в терминале/консоли/командной строке.
Переходим к нашему классу RESTClient. Добавим конструктор (кстати в SOAPClient можно также вытащить инициализацию клиента в конструктор):
public function __construct()
{
$this->curl = new Curl();
//настраиваем curl
$this->curl->setTimeout(10);
$this->curl->setConnectTimeout(10);
$this->curl->setOpt(CURLOPT_FOLLOWLOCATION, true);
$this->curl->setOpt(CURLOPT_RETURNTRANSFER, true);
$this->curl->setOpt(CURLOPT_SSL_VERIFYPEER, false);
$this->curl->setOpt(CURLOPT_SSL_VERIFYHOST, false);
$this->curl->setOpt(CURLOPT_NOSIGNAL, 1);
}
Метод registerOrder в классе RESTClient будет выглядеть так:
public function registerOrder(int $orderId, string $description, int $amount)
{
$requestUrl = 'https://3dsec.sberbank.ru/payment/rest/register.do';
$this->curl->get($requestUrl, [
'userName' => $this->login,
'password' => $this->password,
'amount' => $amount,
'orderNumber' => $orderId,
'returnUrl' => 'http://site.loc/payment-done',
'description' => $description
]);
if($this->curl->error){
CLog::err(['Ошибка отправки запроса', $this->curl->url, $this->curl->errorCode, $this->curl->errorMessage]);
} else{
$response = json_decode($this->curl->response);
return $response;
}
return false;
}
Метод для проверки статуса будет следующим:
public function getStatusOrder(string $orderId)
{
$requestUrl = 'https://3dsec.sberbank.ru/payment/rest/getOrderStatus.do';
$this->curl->get($requestUrl, [
'userName' => $this->login,
'password' => $this->password,
'orderId' => $orderId,
]);
if($this->curl->error){
CLog::err(['Ошибка отправки запроса', $this->curl->url, $this->curl->errorCode, $this->curl->errorMessage]);
} else{
$response = json_decode($this->curl->response);
return $response;
}
return false;
}
Оба метода возвращают полученный $response. Как и при SOAP, ответ при REST запросе будет экземпляром stdClass с некоторым набором атрибутов (одинаковых в SOAP и REST), поэтому методы в модели Payment create() и changeStatus(stdClass $result) будут также работать.
Класс для отправки СМС
Сам класс SMSSender выглядит следующим образом. Для отправки смс использовался сервис sms.ru и библиотека zelenin/smsru. Установка: "zelenin/smsru": "~4" в composer.json и делаем composer update.
class SMSSender
{
/**
* @var string
*/
private $appId = '{appId}';
/**
* @var string
*/
private $login = '{login}';
/**
* @var string
*/
private $password = '{password}';
/**
* @var Api
*/
private $client;
/**
* SMSSender constructor.
*/
public function __construct()
{
$this->client = new Api(new LoginPasswordSecureAuth($this->login, $this->password, $this->appId));
}
/**
* Отправка сообщения
*
* @param string $to
* @param string $mess
*
* @return bool
*/
public function sendMessage(string $to, string $mess){
$sms = new Sms($to, $mess);
$sms->from = 'Riofit';
if(YII_ENV_DEV)
$sms->test = 1;
$response = $this->client->smsSend($sms);
CLog::err($response);
//$response->ids[0] - беру потому что в ответ приходит массив статусов, потому что можно делать массовую рассылку, а у нас всегда одно сообщение для одного адресата
return (int)$response->code == 100 ? $this->checkSms($response->ids[0]) : false;
}
/**
* Проверка статуса сообщения
*
* @param string $id
*
* @return bool
*/
public function checkSms(string $id){
$response = $this->client->smsStatus($id);
CLog::err($response);
return (int)$response->code == 100 ? true : false;
}
/**
* Проверка авторизаций
*
* @return bool
*/
public function isAuth(){
$response = $this->client->authCheck();
CLog::err($response);
return (int)$response->code == 100;
}
}
На этом можно закончить. Было рассмотрено два способа интеграции api Сбербанка для осуществления оплаты через интернет эквайринг. Были небольшие сложности, которые не были отображены в статье, но решения уже включены в представленный код. Так же я предполагала, что при работе с запросами через SOAP можно было бы хранить готовый xml с готовым телом шаблона где-нибудь в определенной директории, а затем при отправке запроса считывать этот файл с помощью simplexml_load_file(), менять необходимые параметры, а затем отправлять, но потом отказалась от этой идеи, как не очень удачной в плане реализации, да и simplexml_load_file не получилось настроить на парсинг xml с MS-Security и SOAP Envelope, даже явно указывав неймспейсы.