Портфолио
Разработка сайтов Продвижение сайтов Маркетинг Дизайн
Контакты

вернуться

Интернет-эквайринг на примере. Оплата товаров и услуг на своем сайте через Сбербанк

Подробнее о интернет-эквайринге Сбербанка

Для возможности оплаты товаров или услуг на сайте с помощью Сбербанка, необходимо на сервере иметь открытый исходящий доступ и TLS 1.2 (протокол обеспечивающий защищенную передачу данных). Вызывающая сторона, т.е. сайт или магазин, должен дожидаться получения ответа для продолжения работы, а также взаимодействие всегда одностороннее - сайт обращается к платежному шлюзу, но не наоборот.

Система предоставляет два способа взаимодействия:

  1. SOAP (web-сервисы)
  2. REST (http запросы)

Примерный принцип работы, схожий с любой другой платежной системой:

  1. Пользователь заполняет форму на сайте и нажимает кнопку оплатить
  2. Сайт отправляет запрос на регистрацию заказа на платежный шлюз, шлюз возвращает ссылку на страницу оплаты
  3. Сайт перенаправляет пользователя на страницу оплаты, которая уже находится на стороне сервера (страница может быть стандартным шаблоном или вы можете её сверстать, следуя рекомендациям Сбера и передав в дальнейшем архив их специалистам)
  4. Пользователь заполняет информацию о своей карте и нажимает кнопку оплатить
  5. Платежный шлюз обрабатывает информацию, завершает оплату и перенаправляет пользователя на страницу сайта, указанную при регистрации заказа
  6. Сайт получает от платежного шлюза информацию по результатам оплаты и отображает пользователю
  7. Сайт отправляет смс пользователю об успешной оплате (как бонус к статье, это не относится к Сбербанку)

Примечание: пользователь должен совершить оплату в течение 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, даже явно указывав неймспейсы.