PayPal Classic API and Rails: как не выстрелить себе в ногу?

Привет. Это очередная статья по интеграции PayPal в Rails приложении. Если вы уже работали с какой-нибудь платежной системой, вам сразу придет на ум ActiveMerchant. Это вариант когда у вас, скажем, простой сценарий оплаты в интернет-магазине или нужно поддерживать несколько платежных систем. Но PayPal предоставляет достаточно богатый API и адаптеры ActiveMerchant для PayPal’а (к Direct Payment и Express Checkout API) поддерживают только базовый функционал - создание заказа, проведение платежа, его отмена и возврат денег.

Если вам нужно более тесно интегрироваться с PayPal то ActiveMerchant не подойдет. Что же остается? Писать клиент на коленке и с нуля? Почему бы и нет. Но не забудьте, что у PayPal есть официальные Ruby SDK для большинства Classic API: Adaptive Payments, Express Checkout, Permissions, Direct Payment (и в том числе для REST API). О этом мы и поговорим.

На Github’е вы найдете краткое описание как настраивать доступ к API и пример какого-нибудь API-вызова. Classic API поддерживает как NVP (name-value pair), так и SOAP-формат. Фактически, Ruby SDK - это тонкая обертка над SOAP API.

Первый шаг - регистрация на портале разработчиков и получение доступа к Sandbox окружению PayPal. Вам надо наштамповать несколько аккаунтов покупателей (Personal аккаунт) и продавцов (Bussines/Premier аккаунт). Не забудьте положить на счет покупателей побольше денег. Если после ручных тестов у вас закончатся тестовые деньги, пополнить опустевший кошелек можно будет только сделав денежный перевод с другого тестового аккаунта. Постарайтесь не удалять сгоряча тестовые аккаунты - их email’ы не получится повторно использовать. Далее вы должны разрешить api доступ к вашему аккаунту продавца и сгенерировать настройки доступа - password и signatura.

Далее вы настраиваете Rails-приложение. В конфиге config/paypal.yml указываете username, password, signature, mode и возможно app_id. Резонно поотлаживать приложение в песочнице, поэтому указываем опцию mode: sandbox.

# config/paypal.yml

default: &default
  # Mode can be 'live' or 'sandbox'
  mode: sandbox

  # Credentials for Classic APIs
  username: username
  password: password
  signature: AQU0e5vuZCvSg-XJploSa.sGUDlpAC6auTE-xLN1ERo8.JTZ4A51PHwu
  app_id: app_id 

Проверим что наши запросы доходят до PayPal. Для теста сделаем запрос к Permissions API - инициируем сценарий получения прав для создания заказа, резервирования и перевода денег и их возврата (RequestPermissions API-вызов).

api_caller = PayPal::SDK::Permissions::API.new
request = api_caller.build_request_permissions(
  :scope => ['EXPRESS_CHECKOUT', 'AUTH_CAPTURE', 'REFUND'],
  :callback => 'http://example.com/redirect-to'
)
response = api_caller.request_permissions(request)
# => Request[post]: https://svcs.sandbox.paypal.com/Permissions/RequestPermissions
# => Response[200]: OK, Duration: 1.296s
response.token
#=> "AAAAAAAfmW8C1Xq0qkpi"

Если запрос прошел успешно и в ответ пришел токен, значит доступ к PayPal настроен.

Рассмотрим простой запрос на резервирование суммы на счету покупателя, который сделал заказ (TransactionID - идентификатор транзакции по созданию заказа, полученный ранее).

api_client = PayPal::SDK::Merchant::API.new
request = api_client.build_do_authorization(
  :TransactionID => 'O-1HX867885F889024F',
  :Amount => {
    :currencyID => 'GBP',
    :value => 150 }
)
response = api_client.do_authorization(request)
response.TransactionID
# => '681296425B698101R'

Можете заметить, что структура и именование как параметров так и возвращаемых данных соответствует один к одному SOAP-формату API вызова DoAuthorization из документации (DoAuthorization API Operation (SOAP))

Приведенный выше API-вызов превращается в обмен XML-документами:

Запрос, отправляемый PayPal’у

<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:cc="urn:ebay:apis:CoreComponentTypes" xmlns:ebl="urn:ebay:apis:eBLBaseComponents" xmlns:ed="urn:ebay:apis:EnhancedDataTypes" xmlns:ns="urn:ebay:api:PayPalAPI">
   <soapenv:Header>
      <ns:RequesterCredentials>
         <ebl:Credentials>
            <ebl:Username>username</ebl:Username>
            <ebl:Password>password</ebl:Password>
            <ebl:Signature>AQU0e5vuZCvSg-XJploSa.sGUDlpAC6auTE-xLN1ERo8.JTZ4A51PHwu</ebl:Signature>
            <ebl:Subject>UAVRQCSDMQCKY</ebl:Subject>
         </ebl:Credentials>
      </ns:RequesterCredentials>
   </soapenv:Header>
   <soapenv:Body>
      <ns:DoAuthorizationReq>
         <ns:DoAuthorizationRequest>
            <ebl:Version>106.0</ebl:Version>
            <ns:TransactionID>O-1HX867885F889024F</ns:TransactionID>
            <ns:Amount currencyID="GBP">150.0</ns:Amount>
         </ns:DoAuthorizationRequest>
      </ns:DoAuthorizationReq>
   </soapenv:Body>
</soapenv:Envelope>

Успешный ответ:

<?xml version="1.0" encoding="UTF-8"?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/" xmlns:cc="urn:ebay:apis:CoreComponentTypes" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:ebl="urn:ebay:apis:eBLBaseComponents" xmlns:ed="urn:ebay:apis:EnhancedDataTypes" xmlns:ns="urn:ebay:api:PayPalAPI" xmlns:saml="urn:oasis:names:tc:SAML:1.0:assertion" xmlns:wsse="http://schemas.xmlsoap.org/ws/2002/12/secext" xmlns:wsu="http://schemas.xmlsoap.org/ws/2002/07/utility" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
   <SOAP-ENV:Header>
      <Security xmlns="http://schemas.xmlsoap.org/ws/2002/12/secext" xsi:type="wsse:SecurityType" />
      <RequesterCredentials xmlns="urn:ebay:api:PayPalAPI" xsi:type="ebl:CustomSecurityHeaderType">
         <Credentials xmlns="urn:ebay:apis:eBLBaseComponents" xsi:type="ebl:UserIdPasswordType">
            <Username xsi:type="xs:string" />
            <Password xsi:type="xs:string" />
            <Signature xsi:type="xs:string" />
            <Subject xsi:type="xs:string" />
         </Credentials>
      </RequesterCredentials>
   </SOAP-ENV:Header>
   <SOAP-ENV:Body id="_0">
      <DoAuthorizationResponse xmlns="urn:ebay:api:PayPalAPI">
         <Timestamp xmlns="urn:ebay:apis:eBLBaseComponents">2015-10-07T08:58:31Z</Timestamp>
         <Ack xmlns="urn:ebay:apis:eBLBaseComponents">Success</Ack>
         <CorrelationID xmlns="urn:ebay:apis:eBLBaseComponents">7e427a34b9612</CorrelationID>
         <Version xmlns="urn:ebay:apis:eBLBaseComponents">106.0</Version>
         <Build xmlns="urn:ebay:apis:eBLBaseComponents">000000</Build>
         <TransactionID>681296425B698101R</TransactionID>
         <Amount xsi:type="cc:BasicAmountType" currencyID="GBP">150.00</Amount>
         <AuthorizationInfo xmlns="urn:ebay:apis:eBLBaseComponents" xsi:type="ebl:AuthorizationInfoType">
            <PaymentStatus xsi:type="ebl:PaymentStatusCodeType">Pending</PaymentStatus>
            <PendingReason xsi:type="ebl:PendingStatusCodeType">authorization</PendingReason>
            <ProtectionEligibility xsi:type="xs:string">Eligible</ProtectionEligibility>
            <ProtectionEligibilityType xsi:type="xs:string">ItemNotReceivedEligible,UnauthorizedPaymentEligible</ProtectionEligibilityType>
         </AuthorizationInfo>
      </DoAuthorizationResponse>
   </SOAP-ENV:Body>
</SOAP-ENV:Envelope>

Мы видим, что PayPal вернул идентификатор новой транзакции резервирования. Заказ перешел в новое состояние “Pended" и причина этого (поле pended_reason) - “authorization".

PayPal позволяет выполнять запросы от имени другого аккаунта. Допустим, ваш партнер предоставляет/делегирует вам права на проведение платежей (это можно автоматизировать через PayPal Permissions API). Вы должны указывать PayPal аккаунт вашего партнера как subject поле в конфигурации. Предположим, у вас несколько партнеров с разными subject. Выход - нужно динамически задавать конфигурацию PayPal клиента. Это делается так:

def self.api(credentials)
  PayPal::SDK::Merchant::API.new.tap do |a|
    a.set_config(
      Rails.env,
      credentials.dup.extract!(:username, :password, :signature, :app_id, :subject))
  end
end

Обработка ошибок это отдельная интересная тема. Как минимум они должны логироваться. Часть из них связана с проблемами в аккаунтах покупателей (например не хватает средств на свету), другие - с аккаунтом продавца. В последнем случае вы должны реагировать мгновенно.

Вот так вы можете получить список ошибок при неуспешном вызове (да-да - их может быть несколько)

def errors
  @response.error.map { |er| er.message }
end

HTTPS запрос к PayPal может закончиться 500-й ошибкой. Серьезно, я видел это своими глазами. При этом HTTP клиент просто бросает исключение. Поэтому, для каждого API-вызова неплохо бы их перехватывать. Не забудьте - правильно перехватывать только наследников класса StandardError, например так:

module Payment::PayPal
  class BaseError < RuntimeError
  end
end

module Payment::PayPal
  class BadRequestError < BaseError
    attr_reader :code

    def initialize(message, code)
      super(message)
      @code = code
    end
  end
end

def self.with_error_handling
  yield.tap do |response|
    unless response.success?
      raise BadRequestError.new(
        "#{ response.errors.join('.') }", response.error_codes[0])
    end
  end
rescue StandardError
  raise Payment::PayPal::BaseError
end

Вы умеете отправлять запросы, создавать заказы, выполнять авторизацию и “завершение” (capturing) платежа. Что еще осталось? PayPal рекомендует использовать IPN. IPN это HTTP запрос, который выполняет PayPal и информирует вас о каком-либо событии, например об изменении статуса заказа или о жалобе PayPal’у покупателя на ваш магазин. В сценарии заказа после каждого API-вызова вы должны (согласно документации) дожидаться IPN с новым статусом заказа. PayPal может вернуть новое состояние заказа при API вызове, но опираться вы должны только на IPN. Наверное в большинстве случаем IPN можно игнорировать, но иногда без него не обойтись. Например по некоторым причинам, операцию (скажем, резервирование) могут посчитать подозрительной и проводить в ручном режиме. Обычная задержка - до суток, и IPN - это единственный способ узнать о том, что операция завершилась успешно (или не успешно).

Теоретически, IPN может приходить с большой задержкой, но по факту уведомления приходят мгновенно. Обычно API-запрос занимает 2-3 секунды и IPN (вызванный этим API запросом) может прийти до момента завершения API-запроса. Ваша система еще не сохранила данные о начале транзакции, а вам уже надо сохранить факт ее успешного (или не успешного) завершения. Вам нужно учитывать эту асинхронность, иначе рискуете на ровном месте потерять заказ.

При обработке IPN возникает дилемма: если обработка уведомления завершилась ошибкой, отдавать в ответ PayPal’у HTTP-код успеха или же ошибки? PayPal, получив “неуспешный” код, будет пытаться повторно отправлять уведомление в течение 2-3 дней в надежде что ваш сервер одумается и сможет обработать сообщение. Это удобно - увидели ошибку, поправили ее и при следующем повторном IPN сценарий успешно продолжится. Есть только одна проблема - после определенного количества “неуспешно” обработанных IPN PayPal отключает IPN рассылку для вашего PayPal аккаунта и от этого страдают правильные заказы, для них тоже не приходит IPN. Обычно PayPal присылает письмо и уведомляет о том, что у вас скоро закончится лимит неуспешных IPN и IPN будет отключен.

Так же PayPal рекомендует при обработке IPN проверять, не приходило ли оно раньше (т.е. вам рекомендуют не просто логировать IPN, а хранить их в базе). Как ни странно, дубликаты (успешно обработанные ранее) действительно приходят и действительно надо себя обезопасить еще одной проверкой. Поля уникально идентифицирующие IPN (по крайне мере в контексте Express Checkout) - это txn_id, payment_status и pending_reason. У вас будет что-то похожее:

def duplicate?
  Shop::IPNNotification.where(
    txn_id: txn_id, payment_status: payment_status, pending_reason: pending_reason
  ).any?
end

В заключение я бы отметил что PayPal Classic API оставляет двойственное впечатление. С одной стороны он предоставляет богатые возможности и покрываю наверное 99.9% всех запросов пользователей. С другой стороны в глаза бросается явная нецелостность и несогласованность API. Некоторые очевидные (и нужные нам) возможности не реализованы.

Ruby SDK и PHP SDK для Express Checkout с осени 2014 года официально перестали активно поддерживаться и PayPal рекомендует использовать REST API. REST API радует своей простотой и логичностью, но полноценно заменить Express Checkout пока не может.

С какими трудностями при интеграции PayPal мы столкнулись:

  • Если возникает проблема с API-запросом, скажем, уже production, и вы хотите обратиться в службу поддержки PayPal, вас обязательно спросят внутренний идентификатор API-запроса, или даже сам запрос/ответ в виде NVP или SOAP. Соответственно, возникает необходимость логировать каждый запрос и ответ. В идеале вам нужно сырое (raw) представление текста запроса и ответа. Ruby-клиенты из коробки этого не умеют. Но их можно легко патчить и добавлять нужный функционал.
  • Формат, в котором клиент возвращает ошибки, отличается от гема к гему. Например, для Express Checkout список ошибок доступен как геттер-метод errors, в Permissions - error. Сообщение, длинное сообщение и код ошибки тоже могут называться похожими именами, но не одинаковыми.
  • Часто ни код ошибки, ни её описание не помогает разобраться, в чем же проблема. В документации вы ничего нового не найдете. Нам приходилось периодически обращаться в поддержку чтобы выяснить причины новой ошибки и как это надо исправить.
  • Поддержка это как игра в рулетку, может повезти и вам попадется толкового человека, вы решите проблему быстро. Если не повезет, будете мусолить ее несколько дней и можете так и не добиться внятного решения проблемы или рекомендации. Не раз приходилось ждать ответа день или два. Если выставить высокий приоритет тикету при его создании, этот приоритет могут тихонько уменьшить.
  • Как в Sandbox так и в Production окружениях у PayPal возникают проблемы с доступностью сервиса. PayPal может в течение дня-двух периодически возвращать "Internal Error” ошибку случайным образом для вполне корректных запросов.

Какие мы сделали общие выводы:

  • читайте внимательно документацию. Это достаточно капитанский вывод, но любая мелочь может оказаться важной. Скользкие места или ограничения могут не проговариваются явно, и вы столкнетесь с ними уже после релиза на продакшне
  • логируйте все обращение к API, желательно в оригинальном виде сохраняйте как можно больше данных о транзакциях их идентификаторах, времени событий в базу, это сильно ускорит исследование внезапных косяков и ошибок
  • служба поддержки PayPal - это полезный инструмент для решения проблем. В принципе можно получить помощь и на http://stackoverflow.com/. На адекватные вопросы оперативно отвечает парень с мега высоким рейтингом, вероятно сотрудник PayPal.
Связаться