IPN для Qiwi на Node.js

Что реализовать возможность оплаты через шлюз оплаты 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, там все номера ошибок, расписана бизнес логика и др. Кто прочёл спасибо за прочтения. Буду рад любым комментариям. Я критику люблю.
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 16

    +5

    Ну начнем с того что ваш код не читаемый абсолютно. Понять алгоритм в такой адовой вложенности легко только тому кто это пишет.

      –2
      Согласен, что непривычно читать, но только с первого раза и тому, кто не знаком с промисами — такова цена асинхронности.
        +2

        Нет, это вина ни разу не асинхронности и промисов, а только вашего плохого стиля кода.

          0
          Готов выслушать конкретные советы
            0

            Ну как минимум поменять анонимки для промисов на конкретные вспомогательные методы или функции.


            Вынести шаблоны ответов так же во вспомогательные функции, ну и вообще разбить код методов.


            Код вида:


            if (condition) {
                //...
                //много логики
                //...
            } else {
               // какая-то команда выхода например return;
            }

            сменить на


            if (! condition) {
              //команда выхода
            }
            
            //дальше логика

            Ну и т.п.

              0
              Мне не всегда легче читать чужой код, через вспомогательные методы и функции, тем более если они повторяются одинажды. Легче читать всё за один раз сверху вниз, чем клацать — переходить в объявления методов, потом обратно возвращаться. По мне — удобнее прочитать комментарий о смылсе анонимки в самом начале. К совету о выходе из метода при не соблюдении условия прислушаюсь, спасибо!
                0
                Так же, если вникнуть в код, можно заметить, что есть переменные из локального scope. Если вынести анонимные функции в вспомогательные функции — нужно будет постоянно передавать несколько объектов в параметрах (в некоторых случаях — некоторые будут лишними), ждать результатов ответов.
        +2
        А почему используете Node.js — 0.12.4. На дворе уже Node v6 в lts с нативными Promise.
        Ну и с кодом надо что-то делать, соглашусь что его читать невозможно.
          0
          Проекту больше 2х лет. Переводить на 6 версию ноды нет времени, но не мешало бы, тем более es6 — поприятнее.
            0
            У вас в проекте какие-то специфичные вещи старой ноды используются? Если нет — поднимайте до 7 вместе с async/await, как упомянули ниже, либо на 6 с транспайлингом того же async/await в генераторы.
              0
              Попробую, спасибо
            0

            и седьмая с async/await...

            0
            Макконнелл, Макконнелл и еще раз Макконнелл.

            Стыдно должно быть такое в паблик выкладывать.
              0
              У меня ощущение что качество статей про программирование на хабре упало.
                +1
                Node.js — 0.12.4

                Вы точно уверены, что вам в продакшне не нужны исправления безопасности Node.js и OpenSSL?

                  0
                  ну время на фикс багов из-за несовместимости никто не выделял. Проект работает и зарабатывает деньги заказчику, если я сейчас начну переходить на Node v6 — начнут теряться деньги. Заказчику нужна стабильная работа проекта. Как будет первый прецедент по взлому через существующие дыры 0.12.4 — будем общаться насчёт перевода проекта.

                Only users with full accounts can post comments. Log in, please.