На днях я помогал студенческой команде 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 может проанализировать.

Посмотрим, что они придумают. Я попрошу их высказать свое мнение, когда они завершат реализацию своей функции.