На днях я помогал студенческой команде Craft Academy внедрять платежи Stripe в приложение проекта. Однажды я реализовал Stripe в приложении Rails, когда мы создавали сайт электронной коммерции с использованием гема Shoppe. Но я никогда не реализовывал это напрямую - просто вставляя расширение в платформу Shoppe. Так что я был на том же уровне, что и моя студентка Эмбер Уилки, когда мы начали реализацию.
Особенность была проста:
Как владелец ресторана
чтобы заработать
мне нужно, чтобы клиенты платили при оформлении заказа.
Довольно прямолинейно и определенно критично для бизнеса. Заработок имеет решающее значение для любого рынка.
Что касается критериев приемлемости функции, мы стремились к как можно более простому решению:
- Используйте общую форму, которую предоставляет Stripe
- Обработайте платеж и отметьте заказ как оплаченный в случае успеха.
- Предоставить пользователю сообщение об ошибке, если Stripe отклонит платеж
Эмбер возглавила первую часть реализации, просмотрела документацию по Stripe API и нашла для нас хорошую библиотеку - stripe-rails. У нас были некоторые начальные проблемы с изучением всего потока платежа, но мы обсудили это и выяснили, что должно происходить, когда платеж обрабатывается Stripe. Все идет нормально.
Мы также создали запрос на извлечение WIP на GitHub, чтобы нам было проще сотрудничать, позволяя нам обоим совершать коммиты.
Мы используем Огурец для приемочных испытаний, и у нас уже был cart_page.feature
, который тестировал процесс добавления продуктов в заказ. Теперь мы создали новый файл функций, в котором намеревались написать сценарии для последнего шага процесса. В недавно созданный checkout.feature
мы добавили следующий сценарий, который мы планировали расширять по мере продвижения:
Scenario: I check out and pay with my credit card Given I am logged in as a user And there is one dish in my cart And I am on the "cart" page When I click the "Pay with Card" stripe button And I fill in appropriate card details
Мы быстро заметили, что шаг о there is one dish in my cart
сформулирован не лучшим образом, но пока мы его упустили. Он состоит из вложенных шагов, которые делают то, что мы ожидаем, - добавляем пиццу в наш заказ.
Given(/^there is one dish in my cart$/) do steps %q{Given the following dish exist | name | description | price | | Pizza | Delicious pizza | 70 | And I am on the dish page for "Pizza" Then I click the link "Add to cart"} end
Мы упираемся в стену со сценарием And I fill in the appropriate card details
, не имея возможности понять, как мы заставим Капибару взаимодействовать с формой Stripe. Наш шаблон HAML включал такую форму:
= form_tag charges_path do = hidden_field_tag :cart_id, @cart.id %script.stripe-button{ src: 'https://checkout.stripe.com/checkout.js', data: {amount: @cart.total*100, description: 'Whatever', key: ENV['STRIPE_PUBLISHABLE_KEY'], currency: 'sek'}}
В этот момент я вспомнил, что Сэм Джозеф (tansaku) недавно внедрил платежи Stripe в проект WebsiteOne - основной сайт сообщества Agile Ventures. Раньше я был руководителем проекта на WebsiteOne, и, поскольку я долгое время был поклонником Cucumber, этот проект также использует эту конкретную структуру для приемочных испытаний.
Найти файл нужной функции было достаточно просто, как и определения шагов, использованные Cucumber для перевода языка Gherkin (DSL Cucumber) в исполняемый код Ruby. Команда AV, работающая над этим проектом, проделала отличную работу по группировке соответствующих шагов в хорошо названные файлы.
Итак, я и Эмбер нашли это определение шага, которое указывало нам в правильном направлении:
Given(/^I fill in appropriate card details for premium(?: for user with email "([^"]*)")?$/) do |email| stripe_iframe = all('iframe[name=stripe_checkout_app]').last email = email.present? ? email : '[email protected]' Capybara.within_frame stripe_iframe do fill_in 'Email', with: email fill_in 'Card number', with: '4242 4242 4242 4242' fill_in 'CVC', with: '123' fill_in 'cc-exp', with: "12/2019" click_button "Pay £10.00" end sleep(3) end
Итак, в WebsiteOne они взимают плату за членство, в нашем проекте мы имеем дело с заказом еды, поэтому язык домена нужно было настроить, но решение выглядело надежным, поэтому мы двинулись вперед и просто «позаимствовали» определение шага.
Мы используем драйвер Poltergeist для тестов, требующих выполнения javascript, что значительно замедляет работу набора тестов. Среди прочего, немного расстраивает тот факт, что, когда драйвер не находит подходящий селектор, он просто ждет (надеясь, что селектор каким-то образом появится). Это приводит к длительным срокам выполнения заказа, и мы, разработчики, сидим и ждем, пока драйвер истечет. Нам удалось немного сократить это время, изменив настройки, но все же ...
После некоторого рефакторинга проб и ошибок мы остановились на следующем сценарии, используя несколько измененные определения шагов по сравнению с решением WebsiteOne:
Scenario: I check out and pay with my credit card Given I am logged in as a customer And there is one dish in my cart And I am on the "cart" page When I click the "Pay with Card" stripe button And I fill in my card details on the stripe form And I submit the stripe form Then I should see: | content | | Your food is on its way! | | Pizza | And my order should be registered in the system
И в cart_steps.rb
And(/^I fill in my card details on the stripe form$/) do sleep(0.1) until page.evaluate_script('$.active') == 0 @stripe_iframe = all('iframe[name=stripe_checkout_app]').last within_frame @stripe_iframe do fill_in 'Email', with: '[email protected]' fill_in 'Card number', with: '4242 4242 4242 4242' fill_in 'CVC', with: '123' fill_in 'cc-exp', with: '12/2021' end end And(/^I submit the stripe form$/) do cart = ShoppingCart.last within_frame @stripe_iframe do click_button "Pay SEK#{cart.total.to_i}" end sleep(1) end
Оглядываясь назад, это выглядит довольно просто, но будьте уверены, что решение всех мелких проблем, которые возникли до того, как мы добрались до этого решения, проверило наше (в основном мое) терпение. Кроме того, приближается обеденный перерыв и уровень сахара в крови значительно упал, что сделало «битву» с полтергейстом, полосатой, капибарой и т. Д. Еще более невыносимой.
После долгой отладки мы обнаружили двусмысленность в поведении Stripe с точки зрения генерации формы по умолчанию. Взгляните на ту же форму, созданную в нашей тестовой среде, и на то, что мы видим, когда получаем доступ к той же форме в Chrome.
Чтобы Capybara нашла эту кнопку, нам нужно было изменить определение шага, а также убедиться, что сумма заказа ВСЕГДА имеет два десятичных знака. В итоге мы получили вот что:
within_frame stripe_iframe do click_button "Pay kr#{sprintf('%.2f', cart.total.to_i)}" end
Еще одна проблема, которую мы решили, - это обратный вызов. По умолчанию Stripe вызывает URL-адрес /charges
после обработки формы, и я заметил, что во многих реализациях (в том числе на WebsiteOne) есть отдельный контроллер для обработки этой формы. Мне было неудобно добавлять новый контроллер, так как он у нас уже есть для оформления заказа. Вместо этого мы создали собственный маршрут для этого URL и указали его на действие create в ранее созданном CartsController
.
post '/charges', controller: :carts, action: :create
Наша стратегия заключалась в том, что, как только мы получим ответ от Stripe, для выполнения всех необходимых шагов нам потребуется обработать платеж и пометить заказ как оплаченный.
- Используйте токен для создания клиента и списания в Stripe
- Назначьте заказ пользователю (в нашей системе посетители могут создавать заказы, но должны войти в систему или зарегистрироваться, чтобы завершить их)
- Установите для атрибута заказа
paid
значениеtrue
.
Это много действий для одного метода, поэтому мы добавили заботу об обработке входящих данных из формы и создании фактического списания:
module StripePayment extend ActiveSupport::Concern def self.perform_payment(params, cart) customer = Stripe::Customer.create( email: params[:stripeEmail], source: params[:stripeToken] ) charge = Stripe::Charge.create( customer: customer.id, amount: (cart.total * 100).to_i, description: 'Best Slow Food order', currency: 'sek' ) return charge end end
На этом этапе наши шаги прошли, и мы пообедали, закончили сеанс парного программирования и договорились, что мы продолжим асинхронно, с Эмбер, выполняющей необходимые действия в действии create
и модуле StripePayment
, а я в основном просто проверяю и оказываю ей поддержку, используя функция проверки кода на GitHub.
Когда я вернулся к этой функции позже в тот же день, я был рад увидеть, что Amber добилась значительного прогресса в том, чтобы все происходило в контроллере и проблемах - почти немного или много, если честно, некоторые модификации экземпляра ShoppingCart
могли быть добавленными как методы экземпляра к модели, но это что-то для рутинной работы по рефакторингу.
В целом мы остались довольны решением, за исключением того факта, что наши тесты действительно попадали в Stripe API и создавали расходы на учетную запись песочницы Ambers. Во время разработки это было нормально, но мы знали, что нам нужно заглушить эти вызовы, прежде чем мы сможем объединить эту функцию.
В WebsiteOne в tansaku есть настройки VCR и Puffing Billy для записи сетевых вызовов и ответов (на стороне сервера и в браузере соответственно). Я не хотел идти по этому пути. Мы только хотели иметь дело с объектами Stripe, и я чувствовал, что введение этих двух зависимостей было немного излишним. Несомненно, должен быть лучший способ.
Эмбер нашла фиктивную библиотеку для тестирования Stripe под названием Stripe Ruby Mock, и когда я загрузил ее изменения на свой локальный хост, я заметил, что она уже добавила его в Gemfile
.
group :development, :test do gem 'stripe-ruby-mock', '~> 2.3.1', require: 'stripe_mock'
Гем предоставляет множество помощников по тестированию, которые мы могли бы использовать. В качестве первого шага нам нужно было убедиться, что Cucumber на самом деле не запрашивает Stripe API. Мы сделали это, добавив хук @stripe
в наши сценарии и настроив его в нашем features/support/hooks.rb
.
Before '@stripe' do StripeMock.start end After '@stripe' do StripeMock.stop end
Мы приближаемся к завершению этой функции, но нам нужен был печальный сценарий, чтобы убедиться, что мы справляемся с любыми ошибками.
Scenario: Redirects to carts page on error Given I register as a user with username "Amber" and email "[email protected]" And I check out but my card is declined Then I should be on the "cart" page Then I should see "The card was declined Please try again"
Мы использовали следующее определение шага для имитации ошибки отклонения карты:
And(/^I check out but my card is declined$/) do StripeMock.prepare_card_error(:card_declined) steps %q{ And I am on the "cart" page When I click the "Pay with Card" stripe button And I fill in appropriate card details And I submit the stripe form } end
Я также изменил нашу озабоченность до конечного состояния:
module StripePayment extend ActiveSupport::Concern def self.perform_payment(params, cart) customer = Stripe::Customer.create( email: params[:stripeEmail], source: stripe_token(params) ) charge = Stripe::Charge.create( customer: customer.id, amount: (cart.total * 100).to_i, description: 'Best Slow Food order', currency: 'sek' ) return charge rescue => e [e.message, 'Please try again'].join(' ') end private def self.stripe_token(params) Rails.env.test? ? generate_test_token : params[:stripeToken] end def self.generate_test_token StripeMock.create_test_helper.generate_card_token end end
И добавил для него спецификацию:
describe 'StripePayment' do before do class FakeController < ApplicationController include StripePayment end StripeMock.start end after do Object.send :remove_const, :FakeController StripeMock.stop end let(:controller) { FakeController.new } let(:cart) { create(:shopping_cart)} let(:params) { {} } describe '#perform_payment' do it 'on success' do allow(cart).to receive(:total).and_return 100 expect(StripePayment.perform_payment(params, cart).class).to eq Stripe::Charge end it 'on error' do allow(cart).to receive(:total).and_return 0 expect(StripePayment.perform_payment(params, cart)).to eq 'Invalid positive integer Please try again' end end end
На данный момент у нас было работающее, проверенное решение для наших платежей. В образовательных целях я остался доволен тем, что мы сделали вместе. Из-за приближающегося крайнего срока и отставания, полного других неотложных функций, мы сократили фазу рефакторинга, и код впоследствии был объединен с базой кода и развернут на сервере разработки на Heroku.
Я многому научился в процессе, и думаю, Эмбер тоже остался доволен сеансом пары. По большому счету, мы мало что сделали, но именно в этом и заключается вся суть создания программного обеспечения - небольшой, но стабильный прогресс постепенно. И особенности доставки.
Уроки выучены
- "Держать его просто глупо!" это отличный принцип. Если бы мы стремились к высокому уровню, мы бы никогда не осуществили выплаты в течение нескольких часов.
- Парное программирование - это здорово!
- Создание функции, использующей сторонние сервисы (= код, которым вы не владеете), сложно и приводит к непредвиденным последствиям.
- Вы никогда не прекращаете учиться и работать с открытым исходным кодом, и быть частью сообщества означает, что вы изучаете материал и можете вернуться к предыдущей работе своих коллег, если это необходимо.
- Правильное питание имеет решающее значение при подготовке к длительному сеансу парного программирования.
Спасибо за прочтение.
Редактировать
После запроса от tansaku о геме StripeMock и сетевом трафике я еще раз взглянул на эти тесты.
Я не был уверен, сможем ли мы запустить тест Stripe без каких-либо сетевых вызовов. Одна из проблем заключалась в том, что я не мог просто отключить доступ к сети, файл stripe.js
загружается с помощью cdn, чтобы не слетало.
Что я сделал в отдельной ветке, так это включил stripe.js
в свою воронку активов. Тест прошел успешно, но получил: Похоже, Stripe.js не загружается с https://js.stripe.com. Stripe не поддерживает обслуживание Stripe.js из вашего собственного домена.
Хорошо, введите WebMock - библиотеку для заглушки и установки ожиданий для HTTP-запросов в Ruby. Установка и настройка довольно просты. После добавления драгоценного камня в ваш Gemfile и запуска bundle
вам просто потребуются модули WebMock Cucumber в ваш support/env.rb
файл, и вы готовы к работе.
require 'webmock/cucumber'
Я хотел убедиться, что никакие сетевые вызовы не будут разрешены во время моих тестов, принимаемых на localhost. Я все еще хотел иметь возможность загружать файл js из Stripe. Я изменил свои хуки и провел тестовый запуск:
Before '@stripe' do WebMock.disable_net_connect!(allow_localhost: true) StripeMock.start end After '@stripe' do WebMock.allow_net_connect! StripeMock.stop end
Похоже, я все еще в порядке с загрузкой Stripe.js из CDN, и мои тесты зеленые. Итак, я предполагаю, что StripeMock работает полностью, заглушая оба вызова Stripe.
Редактировать 2
В другом студенческом проекте я столкнулся с самим собой, думая об этом посте, и вспомнил комментарий, который я получил от одного читателя, о том, что я был неправ насчет того, что StripeMock
заглушил оба вызова Stripe. После некоторого исследования я указал своим студентам на fake_stripe - библиотеку, которая позволяет нам тестировать код Stripe, не затрагивая серверы Stripe. Он использует Capybara :: Server и Webmock для перехвата всех вызовов из библиотеки Stripe Ruby и возвращает JSON, который библиотека Stripe может проанализировать.
Посмотрим, что они придумают. Я попрошу их высказать свое мнение, когда они завершат реализацию своей функции.