Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/flex_commerce.rb
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,5 @@ module V2
autoload :ParamToShql, File.join(gem_root, "app", "services", "param_to_shql")
autoload :SurrogateKeys, File.join(gem_root, "app", "services", "surrogate_keys")
autoload :PaypalExpress, File.join(gem_root, "lib", "paypal_express")
autoload :Retry, File.join(gem_root, "lib", "retry")
end
2 changes: 1 addition & 1 deletion lib/flex_commerce_api/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class Config
# section of the admin panel
# @!attribute order_test_mode
# The order test mode.This config determines if orders are processed as test or real orders
attr_accessor :flex_root_url, :flex_api_key, :flex_account, :logger, :adapter, :order_test_mode, :http_cache, :open_timeout, :timeout, :paypal_login, :paypal_password, :paypal_signature, :order_test_mode
attr_accessor :flex_root_url, :flex_api_key, :flex_account, :logger, :adapter, :order_test_mode, :http_cache, :open_timeout, :timeout, :paypal_login, :paypal_password, :paypal_signature, :order_test_mode, :paypal_connection_errors_no_of_retries
attr_reader :api_version

def initialize
Expand Down
2 changes: 1 addition & 1 deletion lib/flex_commerce_api/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module FlexCommerceApi
VERSION = '0.6.55'
VERSION = '0.6.56'
end
45 changes: 40 additions & 5 deletions lib/paypal_express/api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,52 @@ module PaypalExpress
module Api
extend ActiveSupport::Concern

private
USER_ERRORS = {
"10410" => {gateway_response: "Invalid token"},
"10411" => {gateway_response: "Checkout session expired"},
"10415" => {gateway_response: "Duplicate transaction"},
"13113" => {gateway_response: "Paypal declined the transaction"},
"10417" => {gateway_response: "Paypal cannot process this transaction. Please use alternative payment method"},
"10419" => {gateway_response: "PayerID Missing"},
"10421" => {gateway_response: "Invalid token"},
"10422" => {gateway_response: "Invalid funding source. Please try again with a different funding source"},
"10424" => {gateway_response: "Invalid shipping address"},
"10474" => {gateway_response: "Invalid shipping country - must be the same as the paypal account"},
"10486" => {gateway_response: "Invalid funding source. Please try again with a different funding source"},
"10736" => {gateway_response: "Invalid shipping address"},
"11084" => {gateway_response: "No funding sources. Please try a different payment method"},
"13122" => {gateway_response: "This transaction cannot be completed because it violates the PayPal User Agreement"},
"10606" => {gateway_response: "Paypal cannot process this transaction using the payment method provided"},
"10626" => {gateway_response: "Paypal declined this transaction due to its risk model"}
}.freeze

def convert_amount(amount)
(amount * 100.0).round.to_i
end
private

def gateway
verify_credentials

@gateway ||= gateway_class.new(
test: test_mode,
login: paypal_login,
password: paypal_password,
signature: paypal_signature)
end

def is_user_error?(response)
(USER_ERRORS.keys & response_error_codes(response)).present?
Copy link
Copy Markdown
Contributor

@RichardWatkins1 RichardWatkins1 Dec 11, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about this conditional. Surely USER_ERRORS.keys will always return an array of keys so there's no point in checking if it's present?. the & will combine the arrays.

end

def mark_transaction_with_errors!(response)
errors = []
response_error_codes(response).each do |error_code|
errors.push(USER_ERRORS[error_code])
end
errors
end

def response_error_codes(response)
response.params["error_codes"].split(",")
end

def verify_credentials
unless paypal_login.present? && paypal_password.present? && paypal_signature.present? then
raise "Please ensure all Paypal Credentails are set in your env file."
Expand All @@ -46,6 +76,11 @@ def paypal_password
def paypal_signature
FlexCommerceApi.config.paypal_signature
end

def convert_amount(amount)
(amount * 100.0).round.to_i
end

end
end
end
83 changes: 83 additions & 0 deletions lib/paypal_express/auth.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# frozen_string_literal: true
require_relative 'api'
require 'retry'

# @module FlexCommerce::PaypalExpress
module FlexCommerce
module PaypalExpress
# @class Setup
#
# This service authorises the payment via the Paypal gateway
class Auth
include ::Retry
include ::FlexCommerce::PaypalExpress::Api

DEFAULT_CURRENCY = "GBP"

# @initialize
#
# @param {String} token - Paypal token
# @param {String} payer_id - Paypal user id
def initialize(cart:, token:, payer_id:, payment_transaction:, gateway_class: ::ActiveMerchant::Billing::PaypalExpressGateway)
self.cart = cart
self.token = token
self.payer_id = payer_id
self.payment_transaction = payment_transaction
self.gateway_class = gateway_class
end

def call
process_with_gateway
end

private

attr_accessor :cart, :token, :payer_id, :payment_transaction, :gateway_class

def process_with_gateway
# Fetch Order details from Paypal
response = do_express_checkout_payment
unless response.success?
unless is_user_error?(response)
raise ::FlexCommerce::PaypalExpress::Exception::NotAuthorized.new("Payment not authorised - #{response.message}", response: response)
end
return mark_transaction_with_errors!(response)
end

# Authorizing transaction
auth_response = do_authorization(response)
unless auth_response.success?
unless is_user_error?(auth_response)
raise ::FlexCommerce::PaypalExpress::Exception::NotAuthorized.new("Failed authorising transaction - #{auth_response.message}", response: auth_response)
end
return mark_transaction_with_errors!(auth_response)
end

payment_transaction.attributes = { gateway_response: { payer_id: payer_id, token: token, transaction_id: response.params["transaction_id"], authorization_id: auth_response.params["transaction_id"]} }
payment_transaction.save
payment_transaction
rescue ::ActiveMerchant::ConnectionError => ex
raise ::FlexCommerce::PaypalExpress::Exception::ConnectionError.new("Failed authorising transaction due to a connection error. Original message was #{ex.message}")
end

def do_express_checkout_payment
Retry.call(no_of_retries: no_of_retires, rescue_errors: ::ActiveMerchant::ConnectionError) {
::NewRelic::Agent.increment_metric('Custom/Paypal/Do_Express_Checkout_Payment') if defined?(NewRelic::Agent)
gateway.order(convert_amount(cart.total), token: token, payer_id: payer_id, currency: DEFAULT_CURRENCY)
}
end


def do_authorization(response)
Retry.call(no_of_retries: no_of_retires, rescue_errors: ::ActiveMerchant::ConnectionError) {
::NewRelic::Agent.increment_metric('Custom/Paypal/Do_Auhtorization') if defined?(NewRelic::Agent)
gateway.authorize_transaction(response.params["transaction_id"], convert_amount(cart.total), transaction_entity: "Order", currency: DEFAULT_CURRENCY, payer_id: payer_id)
}
end

def no_of_retires
FlexCommerceApi.config.paypal_connection_errors_no_of_retries
end
end
end
end
10 changes: 10 additions & 0 deletions lib/paypal_express/exception/connection_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
require_relative 'transaction'

module FlexCommerce
module PaypalExpress
module Exception
class ConnectionError < Transaction
end
end
end
end
10 changes: 10 additions & 0 deletions lib/paypal_express/exception/not_authorized.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
require_relative 'transaction'

module FlexCommerce
module PaypalExpress
module Exception
class NotAuthorized < Transaction
end
end
end
end
20 changes: 20 additions & 0 deletions lib/retry.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true
module Retry

DEFAULT_MAX_NO_OF_RETRIES = 2
DEFAULT_RESCUE_ERRORS = StandardError

def self.call(no_of_retries: DEFAULT_MAX_NO_OF_RETRIES, rescue_errors: DEFAULT_RESCUE_ERRORS, &blk)
total_attempts = 0
begin
blk.call
rescue rescue_errors => ex
total_attempts += 1
retry if total_attempts < no_of_retries
ensure
if total_attempts == no_of_retries
return
end
end
end
end
138 changes: 138 additions & 0 deletions spec/lib/paypal_express/auth_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
require "e2e_spec_helper"

RSpec.describe FlexCommerce::PaypalExpress::Auth, vcr: true, paypal: true do
include ActiveSupport::NumberHelper
include_context "context store"
include_context "housekeeping"

let(:token) { "fake-token" }
let(:payer_id) { "fake-payer-id" }
let(:cart) { build_stubbed(:cart, total: 100) }
let(:transaction) {
to_clean.transaction = FlexCommerce::PaymentTransaction.create(
cart_id: cart.id,
gateway_response: {
token: token,
payer_id: payer_id
},
amount: cart.total,
status: "success",
transaction_type: 'authorisation',
currency: "GBP",
payment_gateway_reference: "paypal_reference"
)
}
before(:each) do
# Ensure the service doesnt touch the cart or its addresses
cart.shipping_address.freeze
cart.billing_address.freeze
cart.freeze
end

subject { described_class.new(cart: cart, token: token, payer_id: payer_id, payment_transaction: transaction) }

shared_context "mocked active merchant" do |expect_login: true, test_mode: true|
# Mock active merchant
let(:active_merchant_gateway_class) { class_double("ActiveMerchant::Billing::PaypalExpressGateway", new: active_merchant_gateway).as_stubbed_const }
let(:active_merchant_gateway) { instance_spy("ActiveMerchant::Billing::PaypalExpressGateway") }
let!(:active_connection_error) { class_double("ActiveMerchant::ConnectionError").as_stubbed_const }

before(:each) do
expect(active_merchant_gateway_class).to receive(:new).with(test: true, login: ENV['PAYPAL_LOGIN'], password: ENV['PAYPAL_PASSWORD'], signature: ENV['PAYPAL_SIGNATURE']).and_return active_merchant_gateway if expect_login
end
let(:order_response) do
instance_double "ActiveMerchant::Billing::PaypalExpressResponse", "order_response", params: order_response_params, success?: true
end
let(:authorize_order_response) do
instance_double "ActiveMerchant::Billing::PaypalExpressResponse", "authorize_order_response", params: authorize_order_response_params, success?: true
end
let(:order_response_params) do
{
"token" => token,
"Token" => token,
"transaction_id" => transaction_id,
"transaction_type" => "express-checkout",
}
end
let(:authorize_order_response_params) do
{
"token" => token,
"Token" => token,
"transaction_id" => auth_transaction_id,
"transaction_type" => "express-checkout",
}
end

let(:transaction_id) { "fake-transaction-id" }
let(:auth_transaction_id) { "fake-auth-transaction-id" }
end

context "#call" do
context "happy path" do
include_context "mocked active merchant"

before(:each) do
expect(active_merchant_gateway).to receive(:order).with(convert_amount(cart.total), token: token, payer_id: payer_id, currency: "GBP").and_return order_response
expect(active_merchant_gateway).to receive(:authorize_transaction).with(transaction_id, convert_amount(cart.total), transaction_entity: "Order", payer_id: payer_id, currency: "GBP").and_return authorize_order_response
end

it "should set the gateway response" do
response = subject.call
expect(response.gateway_response).to include(transaction_id: transaction_id, authorization_id: auth_transaction_id)
end

end

context "happy path in production" do
include_context "mocked active merchant", test_mode: false

before(:each) do
expect(active_merchant_gateway).to receive(:order).with(convert_amount(cart.total), token: token, payer_id: payer_id, currency: "GBP").and_return order_response
expect(active_merchant_gateway).to receive(:authorize_transaction).with(transaction_id, convert_amount(cart.total), transaction_entity: "Order", payer_id: payer_id, currency: "GBP").and_return authorize_order_response
end

it "should set the gateway response" do
response = subject.call
expect(response.gateway_response).to include(transaction_id: transaction_id, authorization_id: auth_transaction_id)
end
end

context "with error scenarios" do
include_context "mocked active merchant"

it "should mark the transactions gateway_response as invalid when failure is recoverable in order stage" do
order_response = instance_double "ActiveMerchant::Billing::PaypalExpressGateway", "order_response", params: {"error_codes" => "10410", "message" => "Invalid token", "ack" => "Failure", "Ack" => "Failure"}, success?: false
expect(active_merchant_gateway).to receive(:order).with(convert_amount(cart.total), token: token, payer_id: payer_id, currency: "GBP").and_return order_response
expect(active_merchant_gateway).not_to receive(:authorize_transaction)
response = subject.call
expect(response[0][:gateway_response]).to include "Invalid token"
end

it "should mark the transactions gateway_response as invalid when failure is recoverable in auth stage" do
authorize_order_response = instance_double "ActiveMerchant::Billing::PaypalExpressGateway", "order_response", params: {"error_codes" => "10410", "message" => "Invalid token", "ack" => "Failure", "Ack" => "Failure"}, success?: false
expect(active_merchant_gateway).to receive(:order).with(convert_amount(cart.total), token: token, payer_id: payer_id, currency: "GBP").and_return order_response
expect(active_merchant_gateway).to receive(:authorize_transaction).with(transaction_id, convert_amount(cart.total), transaction_entity: "Order", payer_id: payer_id, currency: "GBP").and_return authorize_order_response
response = subject.call
expect(response[0][:gateway_response]).to include "Invalid token"
end

it "should raise an error when failure is not recoverable in order stage" do
order_response = instance_double "ActiveMerchant::Billing::PaypalExpressGateway", "order_response", params: {"error_codes" => "10002", "message" => "Receiving Limit exceeded", "ack" => "Failure", "Ack" => "Failure"}, message: "Receiving limit exceeded", success?: false
expect(active_merchant_gateway).to receive(:order).with(convert_amount(cart.total), token: token, payer_id: payer_id, currency: "GBP").and_return order_response
expect(active_merchant_gateway).not_to receive(:authorize_transaction)
expect { subject.call }.to raise_error ::FlexCommerce::PaypalExpress::Exception::NotAuthorized
end

it "should raise an error when failure is not recoverable in auth stage" do
authorize_order_response = instance_double "ActiveMerchant::Billing::PaypalExpressGateway", "authorize_order_response", params: {"error_codes" => "10002", "message" => "Receiving Limit exceeded", "ack" => "Failure", "Ack" => "Failure"}, message: "Receiving limit exceeded", success?: false
expect(active_merchant_gateway).to receive(:order).with(convert_amount(cart.total), token: token, payer_id: payer_id, currency: "GBP").and_return order_response
expect(active_merchant_gateway).to receive(:authorize_transaction).with(transaction_id, convert_amount(cart.total), transaction_entity: "Order", payer_id: payer_id, currency: "GBP").and_return authorize_order_response
expect { subject.call }.to raise_error ::FlexCommerce::PaypalExpress::Exception::NotAuthorized
end
end
end

def convert_amount(amount)
(amount * 100.0).to_i
end
end
4 changes: 2 additions & 2 deletions spec/lib/paypal_express/setup_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
context "paypal" do

# Mock active merchant
let(:active_merchant_gateway_class) { class_double("ActiveMerchant::Billing::PaypalExpressGateway").as_stubbed_const }
let(:active_merchant_gateway) { instance_spy("ActiveMerchant::Billing::PaypalExpressGateway") }
let(:active_merchant_gateway_class) { class_double("ActiveMerchant::Billing::PaypalExpressGateway").as_stubbed_const }
let(:active_merchant_gateway) { instance_spy("ActiveMerchant::Billing::PaypalExpressGateway") }

# Inputs to the service
let(:success_url) { "http://success.com" }
Expand Down