activemerchant/active_merchant

View on GitHub
lib/active_merchant/billing/gateways/barclaycard_smartpay.rb

Summary

Maintainability
D
2 days
Test Coverage
module ActiveMerchant #:nodoc:
  module Billing #:nodoc:
    class BarclaycardSmartpayGateway < Gateway
      self.test_url = 'https://pal-test.barclaycardsmartpay.com/pal/servlet'
      self.live_url = 'https://pal-live.barclaycardsmartpay.com/pal/servlet'

      self.supported_countries = %w[AL AD AM AT AZ BY BE BA BG HR CY CZ DK EE FI FR DE GR HU IS IE IT KZ LV LI LT LU MK MT MD MC ME NL NO PL PT RO RU SM RS SK SI ES SE CH TR UA GB VA]
      self.default_currency = 'EUR'
      self.currencies_with_three_decimal_places = %w(BHD KWD OMR RSD TND IQD JOD LYD)
      self.money_format = :cents
      self.supported_cardtypes = %i[visa master american_express discover diners_club jcb dankort maestro]
      self.currencies_without_fractions = %w(CVE DJF GNF IDR JPY KMF KRW PYG RWF UGX VND VUV XAF XOF XPF)

      self.homepage_url = 'https://www.barclaycardsmartpay.com/'
      self.display_name = 'Barclaycard Smartpay'

      API_VERSION = 'v40'

      def initialize(options = {})
        requires!(options, :company, :merchant, :password)
        super
      end

      def purchase(money, creditcard, options = {})
        requires!(options, :order_id)

        MultiResponse.run do |r|
          r.process { authorize(money, creditcard, options) }
          r.process { capture(money, r.authorization, options) }
        end
      end

      def authorize(money, creditcard, options = {})
        requires!(options, :order_id)

        post = payment_request(money, options)
        post[:amount] = amount_hash(money, options[:currency])
        post[:card] = credit_card_hash(creditcard)
        post[:billingAddress] = billing_address_hash(options) if options[:billing_address]
        post[:deliveryAddress] = shipping_address_hash(options) if options[:shipping_address]
        post[:shopperStatement] = options[:shopper_statement] if options[:shopper_statement]

        add_3ds(post, options)
        commit('authorise', post)
      end

      def capture(money, authorization, options = {})
        requires!(options, :order_id)

        post = modification_request(authorization, options)
        post[:modificationAmount] = amount_hash(money, options[:currency])

        commit('capture', post)
      end

      def refund(money, authorization, options = {})
        requires!(options, :order_id)

        post = modification_request(authorization, options)
        post[:modificationAmount] = amount_hash(money, options[:currency])

        commit('refund', post)
      end

      def credit(money, creditcard, options = {})
        post = payment_request(money, options)
        post[:amount] = amount_hash(money, options[:currency])
        post[:card] = credit_card_hash(creditcard)
        post[:dateOfBirth] = options[:date_of_birth] if options[:date_of_birth]
        post[:entityType]  = options[:entity_type] if options[:entity_type]
        post[:nationality] = options[:nationality] if options[:nationality]
        post[:shopperName] = options[:shopper_name] if options[:shopper_name]

        if options[:third_party_payout]
          post[:recurring] = options[:recurring_contract] || { contract: 'PAYOUT' }
          MultiResponse.run do |r|
            r.process {
              commit(
                'storeDetailAndSubmitThirdParty',
                post,
                @options[:store_payout_account],
                @options[:store_payout_password]
              )
            }
            r.process {
              commit(
                'confirmThirdParty',
                modification_request(r.authorization, @options),
                @options[:review_payout_account],
                @options[:review_payout_password]
              )
            }
          end
        else
          commit('refundWithData', post)
        end
      end

      def void(identification, options = {})
        requires!(options, :order_id)

        post = modification_request(identification, options)

        commit('cancel', post)
      end

      def verify(creditcard, options = {})
        authorize(0, creditcard, options)
      end

      def store(creditcard, options = {})
        post = store_request(options)
        post[:card] = credit_card_hash(creditcard)
        post[:recurring] = { contract: 'RECURRING' }

        commit('store', post)
      end

      def supports_scrubbing?
        true
      end

      def scrub(transcript)
        transcript.
          gsub(%r(((?:\r\n)?Authorization: Basic )[^\r\n]+(\r\n)?), '\1[FILTERED]').
          gsub(%r((card.number=)\d+), '\1[FILTERED]').
          gsub(%r((card.cvc=)\d+), '\1[FILTERED]')
      end

      private

      # Smartpay may return AVS codes not covered by standard AVSResult codes.
      # Smartpay's descriptions noted below.
      AVS_MAPPING = {
        '0'  => 'R', # Unknown
        '1'  => 'A', # Address matches, postal code doesn't
        '2'  => 'N', # Neither postal code nor address match
        '3'  => 'R', # AVS unavailable
        '4'  => 'E', # AVS not supported for this card type
        '5'  => 'U', # No AVS data provided
        '6'  => 'Z', # Postal code matches, address doesn't match
        '7'  => 'D', # Both postal code and address match
        '8'  => 'U', # Address not checked, postal code unknown
        '9'  => 'B', # Address matches, postal code unknown
        '10' => 'N', # Address doesn't match, postal code unknown
        '11' => 'U', # Postal code not checked, address unknown
        '12' => 'B', # Address matches, postal code not checked
        '13' => 'U', # Address doesn't match, postal code not checked
        '14' => 'P', # Postal code matches, address unknown
        '15' => 'P', # Postal code matches, address not checked
        '16' => 'N', # Postal code doesn't match, address unknown
        '17' => 'U', # Postal code doesn't match, address not checked
        '18' => 'I'     # Neither postal code nor address were checked
      }

      def commit(action, post, account = 'ws', password = @options[:password])
        request = post_data(flatten_hash(post))
        request_headers = headers(account, password)
        raw_response = ssl_post(build_url(action), request, request_headers)
        response = parse(raw_response)

        Response.new(
          success_from(response),
          message_from(response),
          response,
          test: test?,
          avs_result: AVSResult.new(code: parse_avs_code(response)),
          authorization: response['recurringDetailReference'] || authorization_from(post, response)
        )
      rescue ResponseError => e
        case e.response.code
        when '401'
          return Response.new(false, 'Invalid credentials', {}, test: test?)
        when '403'
          return Response.new(false, 'Not allowed', {}, test: test?)
        when '422', '500'
          if e.response.body.split(/\W+/).any? { |word| %w(validation configuration security).include?(word) }
            error_message = e.response.body[/#{Regexp.escape('message=')}(.*?)#{Regexp.escape('&')}/m, 1].tr('+', ' ')
            error_code = e.response.body[/#{Regexp.escape('errorCode=')}(.*?)#{Regexp.escape('&')}/m, 1]
            return Response.new(false, error_code + ': ' + error_message, {}, test: test?)
          end
        end
        raise
      end

      def authorization_from(parameters, response)
        authorization = [parameters[:originalReference], response['pspReference']].compact

        return nil if authorization.empty?

        return authorization.join('#')
      end

      def parse_avs_code(response)
        AVS_MAPPING[response['additionalData']['avsResult'][0..1].strip] if response.dig('additionalData', 'avsResult')
      end

      def flatten_hash(hash, prefix = nil)
        flat_hash = {}
        hash.each_pair do |key, val|
          conc_key = prefix.nil? ? key : "#{prefix}.#{key}"
          if val.is_a?(Hash)
            flat_hash.merge!(flatten_hash(val, conc_key))
          else
            flat_hash[conc_key] = val
          end
        end
        flat_hash
      end

      def headers(account, password)
        {
          'Content-Type' => 'application/x-www-form-urlencoded; charset=utf-8',
          'Authorization' => 'Basic ' + Base64.strict_encode64("#{account}@Company.#{@options[:company]}:#{password}").strip
        }
      end

      def parse(response)
        parsed_response = {}
        params = CGI.parse(response)
        params.each do |key, value|
          parsed_key = key.split('.', 2)
          if parsed_key.size > 1
            parsed_response[parsed_key[0]] ||= {}
            parsed_response[parsed_key[0]][parsed_key[1]] = value[0]
          else
            parsed_response[parsed_key[0]] = value[0]
          end
        end
        parsed_response
      end

      def post_data(data)
        data.map do |key, val|
          "#{key}=#{CGI.escape(val.to_s)}"
        end.reduce do |x, y|
          "#{x}&#{y}"
        end
      end

      def message_from(response)
        return response['resultCode'] if response.has_key?('resultCode') # Payment request
        return response['response'] if response['response'] # Modification request
        return response['result'] if response.has_key?('result') # Store/Recurring request

        'Failure' # Negative fallback in case of error
      end

      def success_from(response)
        return true if response['result'] == 'Success'

        successful_results = %w(Authorised Received [payout-submit-received])
        successful_responses = %w([capture-received] [cancel-received] [refund-received] [payout-confirm-received])
        successful_results.include?(response['resultCode']) || successful_responses.include?(response['response'])
      end

      def build_url(action)
        case action
        when 'store'
          "#{test? ? self.test_url : self.live_url}/Recurring/#{API_VERSION}/storeToken"
        when 'finalize3ds'
          "#{test? ? self.test_url : self.live_url}/Payment/#{API_VERSION}/authorise3d"
        when 'storeDetailAndSubmitThirdParty', 'confirmThirdParty'
          "#{test? ? self.test_url : self.live_url}/Payout/#{API_VERSION}/#{action}"
        else
          "#{test? ? self.test_url : self.live_url}/Payment/#{API_VERSION}/#{action}"
        end
      end

      def billing_address_hash(options)
        address = options[:address] || options[:billing_address] if options[:address] || options[:billing_address]
        street = options[:street] || parse_street(address)
        house = options[:house_number] || parse_house_number(address)

        create_address_hash(address, house, street)
      end

      def shipping_address_hash(options)
        address = options[:shipping_address]
        street = options[:shipping_street] || parse_street(address)
        house = options[:shipping_house_number] || parse_house_number(address)

        create_address_hash(address, house, street)
      end

      def parse_street(address)
        address_to_parse = "#{address[:address1]} #{address[:address2]}"
        street = address[:street] || address_to_parse.split(/\s+/).keep_if { |x| x !~ /\d/ }.join(' ')
        street.empty? ? 'Not Provided' : street
      end

      def parse_house_number(address)
        address_to_parse = "#{address[:address1]} #{address[:address2]}"
        house = address[:houseNumberOrName] || address_to_parse.split(/\s+/).keep_if { |x| x =~ /\d/ }.join(' ')
        house.empty? ? 'Not Provided' : house
      end

      def create_address_hash(address, house, street)
        hash = {}
        hash[:houseNumberOrName] = house
        hash[:street]            = street
        hash[:city]              = address[:city]
        hash[:stateOrProvince]   = address[:state]
        hash[:postalCode]        = address[:zip]
        hash[:country]           = address[:country]
        hash.keep_if { |_, v| v }
      end

      def amount_hash(money, currency)
        currency = currency || currency(money)
        hash = {}
        hash[:currency] = currency
        hash[:value]    = localized_amount(money, currency) if money
        hash
      end

      def credit_card_hash(creditcard)
        hash = {}
        hash[:cvc]         = creditcard.verification_value if creditcard.verification_value
        hash[:expiryMonth] = format(creditcard.month, :two_digits) if creditcard.month
        hash[:expiryYear]  = format(creditcard.year, :four_digits) if creditcard.year
        hash[:holderName]  = creditcard.name if creditcard.name
        hash[:number]      = creditcard.number if creditcard.number
        hash
      end

      def modification_request(reference, options)
        hash = {}
        hash[:merchantAccount]    = @options[:merchant]
        hash[:originalReference]  = psp_reference_from(reference)
        hash.keep_if { |_, v| v }
      end

      def psp_reference_from(authorization)
        authorization.nil? ? nil : authorization.split('#').first
      end

      def payment_request(money, options)
        hash = {}
        hash[:merchantAccount]    = @options[:merchant]
        hash[:reference]          = options[:order_id]
        hash[:shopperEmail]       = options[:email]
        hash[:shopperIP]          = options[:ip]
        hash[:shopperReference]   = options[:customer]
        hash[:shopperInteraction] = options[:shopper_interaction]
        hash[:deviceFingerprint]  = options[:device_fingerprint]
        hash.keep_if { |_, v| v }
      end

      def store_request(options)
        hash = {}
        hash[:merchantAccount]  = @options[:merchant]
        hash[:shopperEmail]     = options[:email]
        hash[:shopperReference] = options[:customer] if options[:customer]
        hash.keep_if { |_, v| v }
      end

      def add_3ds(post, options)
        if three_ds_2_options = options[:three_ds_2]
          device_channel = three_ds_2_options[:channel]
          if device_channel == 'app'
            post[:threeDS2RequestData] = { deviceChannel: device_channel }
          else
            add_browser_info(three_ds_2_options[:browser_info], post)
            post[:threeDS2RequestData] = { deviceChannel: device_channel, notificationURL: three_ds_2_options[:notification_url] }
          end

          if options.has_key?(:execute_threed)
            post[:additionalData] ||= {}
            post[:additionalData][:executeThreeD] = options[:execute_threed]
            post[:additionalData][:scaExemption] = options[:sca_exemption] if options[:sca_exemption]
          end
        else
          return unless options[:execute_threed] || options[:threed_dynamic]

          post[:browserInfo] = { userAgent: options[:user_agent], acceptHeader: options[:accept_header] }
          post[:additionalData] = { executeThreeD: 'true' } if options[:execute_threed]
        end
      end

      def add_browser_info(browser_info, post)
        return unless browser_info

        post[:browserInfo] = {
          acceptHeader: browser_info[:accept_header],
          colorDepth: browser_info[:depth],
          javaEnabled: browser_info[:java],
          language: browser_info[:language],
          screenHeight: browser_info[:height],
          screenWidth: browser_info[:width],
          timeZoneOffset: browser_info[:timezone],
          userAgent: browser_info[:user_agent]
        }
      end
    end
  end
end