Pull to refresh

IPN для Qiwi на Node.js

Reading time6 min
Views5.3K
Что реализовать возможность оплаты через шлюз оплаты Qiwi достаточно прочитать руководство для разработчика, которое, кстати, на русском. Но для тех, у кого горят сроки и не хочется тратить много времени на разработку, я попробую облегчить процесс разработки своими выкладками с кодом.

Исходные данные — 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, там все номера ошибок, расписана бизнес логика и др. Кто прочёл спасибо за прочтения. Буду рад любым комментариям. Я критику люблю.
Tags:
Hubs:
+11
Comments16

Articles