Что реализовать возможность оплаты через шлюз оплаты 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, там все номера ошибок, расписана бизнес логика и др. Кто прочёл спасибо за прочтения. Буду рад любым комментариям. Я критику люблю.
Поделиться с друзьями
-->

Комментарии (16)


  1. ellrion
    07.03.2017 11:59
    +5

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


    1. Ixtinkt
      07.03.2017 17:35
      -2

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


      1. ellrion
        07.03.2017 17:46
        +2

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


        1. Ixtinkt
          07.03.2017 18:00

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


          1. ellrion
            07.03.2017 18:18

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


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


            Код вида:


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

            сменить на


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

            Ну и т.п.


            1. Ixtinkt
              07.03.2017 18:35

              Мне не всегда легче читать чужой код, через вспомогательные методы и функции, тем более если они повторяются одинажды. Легче читать всё за один раз сверху вниз, чем клацать — переходить в объявления методов, потом обратно возвращаться. По мне — удобнее прочитать комментарий о смылсе анонимки в самом начале. К совету о выходе из метода при не соблюдении условия прислушаюсь, спасибо!


            1. Ixtinkt
              09.03.2017 12:44

              Так же, если вникнуть в код, можно заметить, что есть переменные из локального scope. Если вынести анонимные функции в вспомогательные функции — нужно будет постоянно передавать несколько объектов в параметрах (в некоторых случаях — некоторые будут лишними), ждать результатов ответов.


  1. crash_nsk
    07.03.2017 12:10
    +2

    А почему используете Node.js — 0.12.4. На дворе уже Node v6 в lts с нативными Promise.
    Ну и с кодом надо что-то делать, соглашусь что его читать невозможно.


    1. Ixtinkt
      07.03.2017 17:36

      Проекту больше 2х лет. Переводить на 6 версию ноды нет времени, но не мешало бы, тем более es6 — поприятнее.


      1. raveclassic
        07.03.2017 22:16

        У вас в проекте какие-то специфичные вещи старой ноды используются? Если нет — поднимайте до 7 вместе с async/await, как упомянули ниже, либо на 6 с транспайлингом того же async/await в генераторы.


        1. Ixtinkt
          09.03.2017 10:33

          Попробую, спасибо


    1. Jabher
      07.03.2017 18:40

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


  1. dezconnect
    07.03.2017 12:51

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

    Стыдно должно быть такое в паблик выкладывать.


  1. some_x
    07.03.2017 14:28

    У меня ощущение что качество статей про программирование на хабре упало.


  1. ChALkeRx
    07.03.2017 20:38
    +1

    Node.js — 0.12.4

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


    1. Ixtinkt
      09.03.2017 10:35

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