[Из песочницы] IPN для Qiwi на Node.js
Исходные данные — Node.js — 0.12.4, Sails — v0.12.11.
Для начала разработки нужно зарегистрироваться и дождаться подтверждения аккаунта на https://ishop.qiwi.com. После подтверждения аккаунта, нужно зайти в Настройки → Протоколы → REST-протокол и в табличке «Аутентификационные данные» можно увидеть ID проекта — ID магазина (SHOP_ID) для проверки REST ответов. Дополнительно нужно нажать «Сгенерировать новый ID» — и сгенерировать API_ID для REST запросов к Qiwi API. Хочу обратить внимание, что нужно записать пароль (API_PWD), посмотреть его потом будет негде.
Хотелось бы сначала огорчить программистов и уведомить, что у Qiwi нет песочницы, как например у Paypal, вся работа изначально будет выполняться на лив серверах с реальными деньгами и карточками.
Для начала научимся отправлять запрос на выставление счёта. Коротко: весь процесс оплаты может состоять в выставлении счёта, получение ссылки для оплаты, перехода на сайт, на котором происходит оплата клиентом за услугу и ожидание сервера ответа от Qiwi IPN сервера.
// Payment Controller
...
// action for payment request
qw_activate: function (req, res) {
...
var user_id = user.id;
var bill_id = user_id +'_'+ Date.now(),
order_lifetime_days = 1,
successUrl = req.param('success_return_url'), // redirect URL in case of success payment
failUrl = req.param('fail_return_url'); // redirect URL in case of payment is failed
var url = sails.config.custom_config.QIWI.API_URL+sails.config.custom_config.QIWI.SHOP_ID+'/bills/'+bill_id,
request = require('request'),
querystring = require('querystring');
var request_data = {headers: {
"Accept": "text/json",
"Authorization": 'Basic '+new Buffer( sails.config.custom_config.QIWI.API_ID +':'+ sails.config.custom_config.QIWI.API_PWD ).toString('base64'),
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8"
}};
request_data.url = url;
var lifetime = new Date();
lifetime.setHours(lifetime.getHours() + 24 * order_lifetime_days);
request_data.body = querystring.stringify({
user: 'tel:'+req.param('phone').replace(/[\(\)]/g, ""),
amount: sails.config.custom_config.QIWI.member_pro_membership_cost,
ccy: sails.config.custom_config.QIWI.CURRENCY, // RUB || USD
comment: "Payment for service by "+user.email,
lifetime: lifetime.toISOString(),
pay_source: 'qw', // 'mobile'
prv_name: 'email@mail.ru'
});
request.put(request_data, function (err, data) {
if(err) return res.badRequest(err); // q
if(JSON.parse(data.body).response.result_code == 0) {
return res.ok({ url: 'https://qiwi.com/order/external/main.action?shop='+sails.config.custom_config.QIWI.SHOP_ID+'&transaction='+bill_id+'&successUrl='+successUrl+'&failUrl='+failUrl+'&iframe=false' });
}
res.badRequest({ message: JSON.parse(data.body).response.description });
});
...
},
...
После того как отправили клиент оплатил запрос на сайте Qiwi в зависимости от исхода оплаты, клиента перебрасывает на ту или иную страницу, которая была указана в ссылке на оплату. Вне зависимости от исхода оплаты (отмена, успех, просрочка, ошибка и др.) — наш сервер открыт для получения ответов от Qiwi IPN сервера. Ответы могут быть как по https так и по http. Если вы можете перевести свой API сервер на https — советую использовать этот протокол — он безопаснее.
В коде у меня есть кусок кода, который отвечает за проверку ответов по https, но он не проверенный, этот код можно взять за основу своего. Для получения ответов об состоянии оплаты по http или https на свой сервер необходимо настроить раздел «Настройки Pull (REST) протокола» в настройках личного кабинета Qiwi. Необходимо включить уведомления и указать URL для уведомлений. Порт для http — только 80, для https — 443. Указать другие порты у вас не получится. Нужно сгенерировать пароль для оповещений, нажав на «Сменить пароль оповещения». После этого можно приступить к написанию кода:
// Payment controller
...
// ipn action
qw_ipn: function (req, res) {
var Q = require('q'),
THIS = this;
(function (req, res) {
var deferred = Q.defer();
var reqParams = req.allParams();
var UID = reqParams.bill_id ? reqParams.bill_id.split('_')[0] : null,
payment_date = reqParams.bill_id ? new Date(parseInt(reqParams.bill_id.split('_')[1])) : null,
txn_id = "txn_" + reqParams.bill_id,
txn_status = reqParams.status;
(function() {
var deferred2 = Q.defer();
if (typeof req.headers.authorization !== 'undefined') { // Basic authorization
if (req.headers.authorization == 'Basic ' + new Buffer(sails.config.custom_config.QIWI.SHOP_ID + ':' + sails.config.custom_config.QIWI.NOTIFICATION_PWD).toString('base64')) {
deferred2.resolve();
} else {
deferred2.reject(150); // Error in password verification
}
} else if (typeof req.headers['x-api-signature'] !== 'undefined') { // digital sign
// TODO: code not verified
var crypto = require('crypto'),
hexHash,
signature = req.headers['x-api-signature'],
encoded_signature,
reqString = "";
var sortedIndexes = Object.keys(reqParams).sort(); // sort keys
// generate string from values of sorted request
for (var i in sortedIndexes) {
reqString += "|" + reqParams[sortedIndexes[i]];
}
reqString = THIS._convertUTF16ToUTF8ToByteStr(reqString.substring(1)); // convert UTF16 string to UTF8 and then to string of bytes
hexHash = crypto.createHmac('sha1', THIS._convertUTF16ToUTF8ToByteStr(sails.config.custom_config.QIWI.SHOP_ID)).update(reqString).digest('hex'); // hashed string hexadecimal
encoded_signature = new Buffer(THIS._convertUTF16ToUTF8ToByteStr(hexHash)).toString('base64'); // base64 encoded
if (encoded_signature == signature) { // compare encoded signature with signature from header
deferred2.resolve();
} else {
deferred2.reject(151); // Error in sign verification
}
}
return deferred2.promise;
})().then(function() {
if(parseFloat(reqParams.amount) !== sails.config.custom_config.QIWI.member_pro_membership_cost) return deferred.resolve(0); // ignore creating transactions for commission
Transaction.findOne({txn_id: txn_id, payment_status: txn_status}).exec(function (err, found) {
if (err) return deferred.reject('Invalid updating payment status. Error: ' + err);
(function() {
var deferred3 = Q.defer();
if (!found) {
var params = {
txn_id: txn_id,
txn_type: reqParams.command, // "bill"
mc_gross: reqParams.amount,
mc_currency: reqParams.ccy,
payment_date: payment_date,
payment_status: reqParams.status,
business: reqParams.prv_name,
receiver_email: reqParams.prv_name,
payer_id: UID,
payer_email: reqParams.user,
custom: JSON.stringify({error: reqParams.error}),
gateway_type: Transaction.attributes.gateway_type.in[1] // qiwi gateway
};
// first payment
Transaction.create(params).then(function (created) {
if (created) {
deferred3.resolve();
}
}).catch(function (err) {
if (err) deferred3.reject('Invalid transaction creation. Error: ' + err);
});
} else {
// already exists
deferred.resolve(0);
}
return deferred3.promise;
})().then(function() {
if (parseFloat(reqParams.amount) == sails.config.custom_config.QIWI.member_pro_membership_cost && reqParams.ccy == sails.config.custom_config.QIWI.CURRENCY) {
Model.findOne({id: UID}).then(function (found_user) {
if(found_user) {
switch(reqParams.status) {
case 'paid':
// mark user as paid
...
break;
case 'rejected':
// mark user as unpaid if he was rejected payment
...
break;
}
} else {
if (err) return deferred.reject('User not found. Error: ' + err);
}
}).catch(function (err) {
if (err) return deferred.reject('Error while searching user. Error: ' + err);
});
} else {
deferred.reject('Not valid currency or payment amount.');
}
}, function(err) {
deferred.reject('Error while transaction creation. Error: ' + err);
});
});
}, function(err) {
deferred.reject(err);
});
return deferred.promise;
})(req, res).then(function (result_code) {
res.setHeader("Content-type", "text/xml");
var xml = '\
\
' + result_code + ' \
';
return res.send(xml);
}, function (error) {
console.log(error);
res.setHeader("Content-type", "text/xml");
var errNum = typeof error == 'number' ? error : 13;
var xml = '\
\
' + errNum + ' \
';
return res.send(xml);
});
},
/**
* Convert UTF16 string to UTF8 and then to bytes
* @param str
* @returns {string}
* @private
*/
_convertUTF16ToUTF8ToByteStr: function (str) {
var utf8 = unescape(encodeURIComponent(str));
var byteString = "";
for (var i = 0; i < utf8.length; i++) {
byteString += utf8.charCodeAt(i);
}
return byteString;
},
Код не сложный, но могут возникнуть некоторые вопросы, которые я постараюсь предугадать и дать на них ответы ниже.
Сервер Qiwi IPN повторяет запрос с нарастающим интервалом в течение суток (всего 50 попыток) до получения в ответе кода результата 0 и кода состояния HTTP 200. Для исключения дублирования оплаты — я при получении первого уведомления создаю транзакцию с номером счёта, в дальнейшем, если транзакция с таким счётом существует — я запросы отбрасываю. Так же меня интересует оплата и возврат оплаты, то есть «paid» и «rejected» статусы оплаты.
Для понимания типов запроса выкладываю роуты к экшенам.
// routes.js
...
'POST /qw_ipn': 'AccountController.qw_ipn',
'POST /qw_activate': 'AccountController.qw_activate'
...
На этом свой короткий и первый пост я завершаю. Код лучше читать с документацией к Qiwi API, там все номера ошибок, расписана бизнес логика и др. Кто прочёл спасибо за прочтения. Буду рад любым комментариям. Я критику люблю.