Исходные данные — 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 сервера.
// AccountController.js
module.exports = {
// 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 в зависимости от исхода оплаты, клиента перебрасывает на successUrl или failUrl страницу, которые были указаны в ссылке на оплату. Вне зависимости от исхода оплаты (отмена, успех, просрочка, ошибка и др.) — наш сервер открыт для получения ответов от Qiwi IPN сервера. Ответы могут быть как по https так и по http. Если вы можете перевести свой API сервер на https — советую использовать этот протокол — он безопаснее.
В коде у меня есть кусок кода, который отвечает за проверку ответов по https, но он не проверенный, этот код можно взять за основу своего. Для получения ответов об состоянии оплаты по http или https на свой сервер необходимо настроить раздел «Настройки Pull (REST) протокола» в настройках личного кабинета Qiwi. Необходимо включить уведомления и указать URL для уведомлений. Порт для http — только 80, для https — 443. Указать другие порты у вас не получится. Нужно сгенерировать пароль для оповещений, нажав на «Сменить пароль оповещения». После этого можно приступить к написанию кода:
// AccountController.js
module.exports = {
// 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 = '<?xml version="1.0"?> <result> <result_code>' + result_code + '</result_code> </result>';
return res.send(xml);
}, function (error) {
console.log(error);
res.setHeader("Content-type", "text/xml");
var errNum = typeof error == 'number' ? error : 13;
var xml = '<?xml version="1.0"?> <result> <result_code>' + errNum + '</result_code> </result>';
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
module.exports.routes = {
'POST /qw_ipn': 'AccountController.qw_ipn',
'POST /qw_activate': 'AccountController.qw_activate'
}
На этом свой короткий и первый пост я завершаю. Код лучше читать с документацией к Qiwi API, там все номера ошибок, расписана бизнес логика и др. Кто прочёл спасибо за прочтения. Буду рад любым комментариям. Я критику люблю.
Комментарии (16)
crash_nsk
07.03.2017 12:10+2А почему используете Node.js — 0.12.4. На дворе уже Node v6 в lts с нативными Promise.
Ну и с кодом надо что-то делать, соглашусь что его читать невозможно.Ixtinkt
07.03.2017 17:36Проекту больше 2х лет. Переводить на 6 версию ноды нет времени, но не мешало бы, тем более es6 — поприятнее.
raveclassic
07.03.2017 22:16У вас в проекте какие-то специфичные вещи старой ноды используются? Если нет — поднимайте до 7 вместе с async/await, как упомянули ниже, либо на 6 с транспайлингом того же async/await в генераторы.
dezconnect
07.03.2017 12:51Макконнелл, Макконнелл и еще раз Макконнелл.
Стыдно должно быть такое в паблик выкладывать.
ChALkeRx
07.03.2017 20:38+1Node.js — 0.12.4
Вы точно уверены, что вам в продакшне не нужны исправления безопасности Node.js и OpenSSL?
Ixtinkt
09.03.2017 10:35ну время на фикс багов из-за несовместимости никто не выделял. Проект работает и зарабатывает деньги заказчику, если я сейчас начну переходить на Node v6 — начнут теряться деньги. Заказчику нужна стабильная работа проекта. Как будет первый прецедент по взлому через существующие дыры 0.12.4 — будем общаться насчёт перевода проекта.
ellrion
Ну начнем с того что ваш код не читаемый абсолютно. Понять алгоритм в такой адовой вложенности легко только тому кто это пишет.
Ixtinkt
Согласен, что непривычно читать, но только с первого раза и тому, кто не знаком с промисами — такова цена асинхронности.
ellrion
Нет, это вина ни разу не асинхронности и промисов, а только вашего плохого стиля кода.
Ixtinkt
Готов выслушать конкретные советы
ellrion
Ну как минимум поменять анонимки для промисов на конкретные вспомогательные методы или функции.
Вынести шаблоны ответов так же во вспомогательные функции, ну и вообще разбить код методов.
Код вида:
сменить на
Ну и т.п.
Ixtinkt
Мне не всегда легче читать чужой код, через вспомогательные методы и функции, тем более если они повторяются одинажды. Легче читать всё за один раз сверху вниз, чем клацать — переходить в объявления методов, потом обратно возвращаться. По мне — удобнее прочитать комментарий о смылсе анонимки в самом начале. К совету о выходе из метода при не соблюдении условия прислушаюсь, спасибо!
Ixtinkt
Так же, если вникнуть в код, можно заметить, что есть переменные из локального scope. Если вынести анонимные функции в вспомогательные функции — нужно будет постоянно передавать несколько объектов в параметрах (в некоторых случаях — некоторые будут лишними), ждать результатов ответов.