В приложении, которое я разрабатываю на Ruby on Rails, мне нужно было подключить платежную систему. Заказчик заключил договор с Деньги Online, и первым делом я, конечно, проверил список поддерживаемых систем в ActiveMerchant от Shopify — там этого сервиса не оказалось, также еще поискал готовые решения, которые смогли бы упростить интеграцию, но ничего полезного под RoR не нашлось. В итоге было решено форкнуть ActiveMerchant и разработать под него интеграцию для этого сервиса, а в последствии использовать наработки в проекте.
В этой статье хочу рассказать о процессе подключения платежной системы, в надежде что кому-нибудь это пригодится, потому что мне не хватало подобной информации. А также возможно кому-нибудь пригодится информация, конкретно по этой платежной системе.
До этого у меня уже было знакомство с ActiveMerchant: в одном из проектов интегрировал Robokassa —, тогда во много помогла эта статья, и ни с какими проблемами я не столкнулся, но в код реализации модуля пришлось заглянуть лишь несколько раз, что бы проверить или понять работу каких-то конкретных методов — на этом все ограничилось. А при интеграции своего модуля мне пришлось изучить достаточно большую часть ActiveMerchant.
Структура ActiveMerchant
Для помощи в интеграции подобных сервисов, в проекте существует 3 базовых класса:
- Helper — на основе него происходит построение формы при помощи метода payment_service_for, этот метод сам генерирует форму, со всеми необходимыми параметрами, разработчику остается лишь правильно создать Helper, для конкретной платежной системы
- Notification — в данном классе содержатся методы для проверки валидности информации пришедшей от сервера платежной системы, например при подтверждении завершения операции
- Return — используется для обработки колбеков, но зачастую удобнее работать со встроенными в рельсы хелперами, например для обработки пришедших параметров, чем с этим классом. И в реализациях этого класса в различных системах обычно нет каких-то специфичных функций.
Каждая реализация платежной системы содержит несколько классов унаследованных от базовых, в некоторых модулях имеются дополнительные классы, реализующие дополнительные возможности протокола. Так же для каждой платежной системы в ActiveMerchant есть модуль, в котором происходит загрузка классов из файлов, и в которых содержатся методы для создания конкретного класса, включенного в этот модуль.
Интеграция Деньги Online в ActiveMerchant
Решение этой задачи началось с изучения протокола Деньги Online, и там все оказалось не так прозрачно как в случае с Robokassa: фоновая проверка правильности информации, подтверждение и колбеки, по сути все просто, но в большей степени меня смущали параметры передаваемые при запросах и куча различных оговорок.
Общая схема запросов
Вкратце: в общем случае по позитивному сценарию все происходит так: сайт отправляет системе форму со всеми необходимыми параметрами, при этом пользователь переходит на сайт платежной системы, далее платежная система в фоне запрашивает подтверждение правильности данных у нашего сайта. Если данные верны, то пользователь будет перенаправлен туда, где увидит необходимые инструкции для совержения платежа. После нажатия кнопки «Оплатить», платежная система перед принятием платежа, снова проверит правильность данных, потом платеж будет принят и система отправит на сайт запрос о подтверждении оплаты, и в случае положительного ответа, пользователь будет перенаправлен на колбек.
Основные особенности протокола, которые меня смутили:
- При выставлении счета отправляются одни параметры, при проверках эти параметры возвращаются уже под другими именами
- Изначально в протоколе предполагается, что проверка осуществляется только по идентификатору пользователя, сделавшего заказ, а зачастую гораздо удобнее проверять данные по внутреннему идентификатору платежа
- Не смотря на то, что можно передать системе внутренний идентификатор платежа, при проверке и в некоторых случаях при подтверждении он не будет возвращаться, и является необязательным параметром
- В колбеки вообще не передаются какие-либо параметры
- При выставлении счета есть много дополнительных возможностей обеспечения удобства для пользователей, но все эти возможности в протоколе реализованы по разному
Из-за этих особенностей мне пришлось несколько раз написать в их поддержку (хотя сначала я связался с ними через модуль голосовой связи на сайте, но девушка выслушав мою проблему, перенаправила меня на автоответчик), и в течение этого же дня получал адекватные ответы.
Из плюсов заметил, что если сделать запрос точно по описанному протоколу, то в ответ получишь ожидаемый результат, хотя в некоторых случаях проверка данных не осуществляется, но видимо из-за особенностей шлюзов.
Также мне понравилась возможность в фоновом режиме выписать счет в некоторых случаях. И я решил сделать максимально полную поддержку протокола выставления счета, но у меня не было возможности протестить абсолютно все, так что возможно могут наблюдаться какие-то проблемы.
Еще удалось выявить одну недокументированную возможность: в ответ на фоновый запрос создания счета для оплаты через QIWI, приходит вместе с описанными параметрами, параметр iframeSRC с непонятным содержанием, недолго думая, декодировал данные из этого параметр при помощи base64 и получил ссылку, которая ведет на форму оплаты через QIWI, и видимо предполагается, что ссылку следует использовать для создания iframe.
Если посмотреть на различные реализации интеграций платежных систем в ActiveMerchant, то можно увидеть как один и тот же код может перетекать во множество модулей, в каких-то файлах кодом может стать объединение кода из нескольких модулей и так далее, но нигде нет каких-то сложных сценариев. Мне пришлось написать много своего, в частности для реализации фоновых запросов, поэтому боюсь что пул реквест не одобрят, хотя по сути все возможности и идеи, что несут в себе базовые классы в своей реализации я сохранил, просто немного добавил новых.
Что получилось в итоге можно посмотреть здесь. Единственное что у меня выходит из рамок общей концепции для хелпера, так это валидация данных, но в общем случае она совершенно не нужна, ей стоит пользоваться только при фоновых запросах, что бы избежать возможных ошибок еще перед запросом (не смотря на это в моем случае при реализации системы оплаты на сайте, мне пришлось грязно-хаково обойти это), и так же автоматически выставляются некоторые параметры запросов в особых случаях, что бы разработчику не пришлось долго разбираться с протоколом. Еще у моего хелпера есть возможность отправить фоновый запрос, если это делается для валидных mode_type, то ответ автоматически спарсится и с этим ответом можно будет удобно работать.
Не могу сказать что код получился хорошим, потому что с руби я знаком от силы месяца три, поэтому буду рад любой помощи или советам, также сам готов помочь тем, кто захочет интегрировать какую-либо платежную систему в ActiveMerchant.
Пример создания системы оплаты на сайте
Первым делом необходимо включить ActiveMerchant в проект, для этого добавляем его в Gemfile
gem 'activemerchant', :git => "https://github.com/ovcharik/active_merchant.git", :branch => "dengionline"
Пока можно добавлять так, но я отправил пул реквест в официальный репозиторий, и возможно он скоро будет одобрен.
Далее создаем необходимые роутеры
scope 'top_up' do
post 'create' => 'top_up#create', :as => 'top_up_create'
post 'notify' => 'top_up#notify', :as => 'top_up_notify'
post 'check' => 'top_up#check', :as => 'top_up_check'
match 'success' => 'top_up#success', :as => 'top_up_success'
match 'fail' => 'top_up#fail', :as => 'top_up_fail'
end
- create используется для создания объекта платежа и выписки счета, в принципе он не обязателен или его логику можно разместить в другом месте, но мне было удобнее сделать именно так.
- success и fail — это колбеки, на которые пользователь будет перенаправлен после завершения транзакции, кстати именно в эти колбеки от системы не возвращаются какие-либо параметры.
- notify и check нужны для подтверждения оплаты и проверки корректности данных системой оплаты на сайте соответсвенно, по этим адресам система оплаты будет совершать push-запросы, с определенными параметрами.
Последние 4 адреса, указываются платежной системе при регистрации в ней: в процессе необходимо будет заполнить анкету, в которой есть пункты по этим адресам. Сам сайт не предоставляет возможности изменения адресов, а делается это только через дополнительные запросы в поддержку, поэтому мне пришлось многое тестировать прямо в продакшене (не смотря на то, что проект запущен он пока еще в стадии разработки и для пользователей это никак не сказалось).
В проекте который я делаю, происходит такой сценарий при оплате услуг: пользователь нажав кнопку «Оплатить» посылает ajax-запрос на /top_up/create, там проверяется правильность данных, потом отправляется фоновый запрос в платежную систему, успешный ответ выводится пользователю, ошибки обрабатываются отдельно. В моем случае, в качестве успешного ответа, приходит форма со скрытыми полями и сразу после нее скрипт который выполнят отправку этой формы, то есть если вывести этот ответ где-то на странице, то пользователь будет неявно перенаправлен на страницу шлюза, где он может завершить оплату.
Во время фонового запроса происходит следующее: я делаю фоновый запрос, система проверяет все данные и если они верны то она обращается к методу /top_up/check, где должна получить положительный ответ, после проверки система возвращает необходимые данные.
На сайте шлюза пользователь видит форму, где ему необходимо указать реквизиты, и после нажатия кнопки «Оплатить», шлюз проверяет данные в системе Деньги Online, которая в свою очередь проверяет данные в проекте (опять по адресу /top_up/check), получает ответ и передает его платежному шлюзу. Он подтверждает платеж и уведомляет об этом платежную систему, а она уже отправляет на сайт уведомление (/top_up/notify), и в это же время пользователя возвращают на колбек.
Started POST "/ru/top_up/create" for user_ip at 2013-10-05 14:45:00 +0400
Processing by TopUpController#create as */*
Parameters: {"amount"=>"1.0", "authenticity_token"=>"token", "currency"=>"RUB", "order"=>"78", "lang"=>"ru"}
Started POST "/top_up/check" for deingionline_ip at 2013-10-05 13:45:00 +0400
Processing by TopUpController#check as */*
Parameters: {"amount"=>"0", "userid"=>"example@mail.com", "userid_extra"=>"58", "paymentid"=>"0", "key"=>"key1"}
Rendered text template (0.0ms)
Completed 200 OK in 16ms (Views: 1.1ms | ActiveRecord: 2.5ms)
Completed 200 OK in 557ms (Views: 0.5ms | ActiveRecord: 11.1ms)
Started POST "/top_up/check" for deingionline_ip at 2013-10-05 14:46:20 +0400
Processing by TopUpController#check as */*
Parameters: {"amount"=>"0", "userid"=>"example@mail.com", "userid_extra"=>"58", "paymentid"=>"0", "key"=>"key1"}
Rendered text template (0.0ms)
Completed 200 OK in 19ms (Views: 1.0ms | ActiveRecord: 1.9ms)
Started POST "/top_up/notify" for deingionline_ip at 2013-10-05 14:46:20 +0400
Processing by TopUpController#notify as */*
Parameters: {"amount"=>"1.00", "userid"=>"example@mail.com", "userid_extra"=>"58", "paymentid"=>"123456789", "paymode"=>"mode_type", "orderid"=>"58", "key"=>"key2"}
Rendered text template (0.0ms)
Completed 200 OK in 127ms (Views: 0.9ms | ActiveRecord: 15.2ms)
Started GET "/top_up/success" for user_ip at 2013-10-05 14:46:30 +0400
Processing by TopUpController#success as HTML
Rendered ...
Completed 200 OK in 21ms (Views: 16.6ms | ActiveRecord: 0.7ms)
В логах можно увидеть какие параметры передаются во всех случаях, и что при обращении к /top_up/check платежная система не передает orderid (внутренний идентификатор платежа в проекте, paymentid — идентификатор в платежной системе), поэтому для идентификации платежа было решено использовать параметр userid_extra, но здесь тоже нужно быть осторожным, так как в некоторых случаях он не возвращается, можно указывать внутренний номер платежа в userid, но в личном кабинете в платежной системы, есть возможность посмотреть все транзакции, а так же осуществить выборку по этому параметру, и если использовать уникальный идентификатор в качестве это параметра, то полезность возможности выборки будет нулевой.
class TopUpController < ApplicationController
include ActiveMerchant::Billing::Integrations
# аутенфикация пользователя для create
skip_before_filter :verify_authenticity_token, :except => [:create]
# создание нотификатора вкюченного в ActiveMerchant
before_filter :create_notification, :only => [:check, :notify]
# поиск объекта платежа
before_filter :find_payment, :only => [:check, :notify]
# создание платежа
def create
authorize! :create, Payment
r = {
:success => false,
:errors => {:base => []}
}
errors = false
# создаем объект модели платежа, эта модель используется только внутри
# и к activemerchant никакого отношения не имеет
@payment = Payment.new({
:user => current_user,
:amount => params[:amount]
})
# валидация данных
unless errors or @payment.save
r[:errors].merge! @payment.errors.messages
errors = true
end
unless errors
# формируем хелпер из ActiveMerchant
helper=Dengionline.helper @payment.id, CONFIG["dengionline"]["project"], {
:amount => @payment.amount,
:nickname => @payment.user.email,
# идентификатор внутреннего объекта платежа
:nick_extra => @payment.id,
# в проекте используется только одно значение этого параметра
:transaction_type => CONFIG["dengionline"]["mode_type"],
# нужен что бы пользователь в последствии был редирекчен на колбеки
:source => CONFIG["dengionline"]["source"],
# секретный код, задается при регистрации в платежной системе
:secret => CONFIG["dengionline"]["secret"],
# указывам метод и режим, что бы хелпер правильно провалидировал данные,
:method => :credit_card,
:mode => :background
}
begin
# здесь применен грязный хак, так как этот mode_type не поддерживает
# фоновые запросы, но в случае ошибки, пользователь вместо каких-то
# разнеснений почему так и что делать, увидит лишь чистый xml,
# что не очень дружелюбно
# смотрим, если единственная ошибка при валидации это mode_type
# тогда можно считать что данные верны
if (helper.valid?
or helper.errors.size > 1
or helper.errors[0] != "mode_type")
r[:errors][:base] << I18n.t("views.top_up.create.technical_error")
break
end
# совершаем фоновый запрос, восклицательный знак вконце стоит
# потому что предположительно данные не валидны
response = helper.background_request!
# если не возникло ошибок у парсера, значит вернулся xml
# это означает ошибку
if response.errors.empty? and response.fail?
r[:errors][:base] << response.comment
break
end
# если ошибка не parse_error - это означает, что запрос не прошел
# и вернулся код отличный от 200
if (response.success?
or response.errors.empty?
or response.errors.size > 1
or response.errors[0] != "parse_error")
r[:errors][:base] << I18n.t("views.top_up.create.gateway_error")
break
end
# если ошибка parse_error, значит либо все прошло успешно
# либо поменялся протокол у платежной системы, но в любом случае
# пользователю будет выведенно то, что было получено
r[:success] = true
r[:body] = response.body
end until true
end
render :json => r
end
# проверка данных
def check
if (@notification.acknowledged?
and @payment
and @payment.wait?
and @payment.valid?)
render :text => @notification.generate_response("YES")
else
render :text => @notification.generate_response("NO")
end
end
# подтверждение платежа
def notify
if (@notification.acknowledged?
and @payment
and @payment.amount == @notification.amount
and @payment.amount > 0)
# этот запрос может посылаться несколько раз
# и нужно возвращать актуальный статус
# в моем случае у платежа может не пройти валидация и что бы отдать
# правильный ответ необходим этот флаг
saved = true
if @payment.wait?
@payment.do_payment_id = @notification.payment_id
@payment.paid!
saved = @payment.save
Notifier.payment_paid(@payment).deliver if saved
end
if @payment.paid? and saved
render :text => @notification.generate_response("YES")
else
render :text => @notification.generate_response("NO")
end
else
render :text => @notification.generate_response("NO")
end
end
# в колбеках просто рендерятся страницы
def success
end
def fail
end
private
def create_notification
@notification = Dengionline.notification(request.raw_post, {
:secret => CONFIG["dengionline"]["secret"]
})
end
def find_payment
# ищу так, потому что удобнее обработать nil, чем исключение
@payment = Payment.where(:id => @notification.nick_extra).first
end
end
У меня все данные посылаются в фоне за-за особенностей ответов, но можно и использовать способ описанный здесь.
Еще приведу пример использования класса для получения удаленной информации о платеже, думаю ни у кого проблем с применением этого не возникнет.
$ rails c
irb(main):001:0> Dengionline = ActiveMerchant::Billing::Integrations::Dengionline
=> ActiveMerchant::Billing::Integrations::Dengionline
irb(main):002:0> status = Dengionline.status 69, 1234, :secret => "secret"
=> #<ActiveMerchant::Billing::Integrations::Dengionline::Status:0xb805bb4>
irb(main):003:0> status.to_hash
=> {
"id"=>123456789,
"amount_rub"=>"1.00",
"status"=>9,
"status_description"=>"Success",
"order"=>"69",
"nick"=>"example@mail.com",
"date_payment"=>"2013-10-05T13:00:00+04:00",
"paymode"=>"mode_type",
"currency_project"=>"RUB",
"amount_project"=>"1.00",
"currency_paymode"=>"RUB"
}
В данном примере в качестве основного параметра передается внутренний идентификатор платежа, если вы его не использовали при выписке счета, то можно передавать идентификатор платежа платежной системы, для этого последним параметром укажите :payment => payment_id.
Автор: movl