activemerchant/active_merchant

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

Summary

Maintainability
C
1 day
Test Coverage
module ActiveMerchant #:nodoc:
  module Billing #:nodoc:
    class Shift4Gateway < Gateway
      self.test_url = 'https://utgapi.shift4test.com/api/rest/v1/'
      self.live_url = 'https://utg.shift4api.net/api/rest/v1/'

      self.supported_countries = %w(US CA CU HT DO PR JM TT GP MQ BS BB LC CW AW VC VI GD AG DM KY KN SX TC MF VG BQ AI BL MS)
      self.default_currency = 'USD'
      self.supported_cardtypes = %i[visa master american_express discover]

      self.homepage_url = 'https://shift4.com'
      self.display_name = 'Shift4'

      RECURRING_TYPE_TRANSACTIONS = %w(recurring installment)
      TRANSACTIONS_WITHOUT_RESPONSE_CODE = %w(accesstoken add)
      SUCCESS_TRANSACTION_STATUS = %w(A)
      DISALLOWED_ENTRY_MODE_ACTIONS = %w(capture refund add verify)
      URL_POSTFIX_MAPPING = {
        'accesstoken' => 'credentials',
        'add' => 'tokens',
        'verify' => 'cards'
      }

      def initialize(options = {})
        requires!(options, :client_guid, :auth_token)
        @client_guid = options[:client_guid]
        @auth_token = options[:auth_token]
        @access_token = options[:access_token]
        super
      end

      def purchase(money, payment_method, options = {})
        post = {}
        action = 'sale'

        payment_method = get_card_token(payment_method) if payment_method.is_a?(String)
        add_datetime(post, options)
        add_invoice(post, money, options)
        add_clerk(post, options)
        add_transaction(post, options)
        add_card(action, post, payment_method, options)
        add_card_present(post, options)
        add_customer(post, payment_method, options)

        commit(action, post, options)
      end

      def authorize(money, payment_method, options = {})
        post = {}
        action = 'authorization'

        payment_method = get_card_token(payment_method) if payment_method.is_a?(String)
        add_datetime(post, options)
        add_invoice(post, money, options)
        add_clerk(post, options)
        add_transaction(post, options)
        add_card(action, post, payment_method, options)
        add_card_present(post, options)
        add_customer(post, payment_method, options)

        commit(action, post, options)
      end

      def capture(money, authorization, options = {})
        post = {}
        action = 'capture'
        options[:invoice] = get_invoice(authorization)

        add_datetime(post, options)
        add_invoice(post, money, options)
        add_clerk(post, options)
        add_transaction(post, options)
        add_card(action, post, get_card_token(authorization), options)

        commit(action, post, options)
      end

      def refund(money, payment_method, options = {})
        post = {}
        action = 'refund'

        add_datetime(post, options)
        add_invoice(post, money, options)
        add_clerk(post, options)
        add_transaction(post, options)
        card_token = payment_method.is_a?(CreditCard) ? get_card_token(payment_method) : payment_method
        add_card(action, post, card_token, options)
        add_card_present(post, options)

        commit(action, post, options)
      end

      alias credit refund

      def void(authorization, options = {})
        options[:invoice] = get_invoice(authorization)
        commit('invoice', {}, options)
      end

      def verify(credit_card, options = {})
        post = {}
        action = 'verify'
        post[:transaction] = {}

        add_datetime(post, options)
        add_card(action, post, credit_card, options)
        add_customer(post, credit_card, options)
        add_card_on_file(post[:transaction], options)

        commit(action, post, options)
      end

      def store(credit_card, options = {})
        post = {}
        action = 'add'

        add_datetime(post, options)
        add_card(action, post, credit_card, options)
        add_customer(post, credit_card, options)

        commit(action, post, options)
      end

      def supports_scrubbing?
        true
      end

      def scrub(transcript)
        transcript.
          gsub(%r(("Number\\?"\s*:\s*\\?")[^"]*)i, '\1[FILTERED]').
          gsub(%r(("expirationDate\\?"\s*:\s*\\?")[^"]*)i, '\1[FILTERED]').
          gsub(%r(("FirstName\\?"\s*:\s*\\?")[^"]*)i, '\1[FILTERED]').
          gsub(%r(("LastName\\?"\s*:\s*\\?")[^"]*)i, '\1[FILTERED]').
          gsub(%r(("securityCode\\?":{\\?"\w+\\?":\d+,\\?"value\\?":\\?")\d*)i, '\1[FILTERED]')
      end

      def setup_access_token
        post = {}
        add_credentials(post, options)
        add_datetime(post, options)

        response = commit('accesstoken', post, request_headers('accesstoken', options))
        raise OAuthResponseError.new(response, response.params.fetch('result', [{}]).first.dig('error', 'longText')) unless response.success?

        response.params['result'].first['credential']['accessToken']
      end

      private

      def add_credentials(post, options)
        post[:credential] = {}
        post[:credential][:clientGuid] = @client_guid
        post[:credential][:authToken] = @auth_token
      end

      def add_clerk(post, options)
        post[:clerk] = {}
        post[:clerk][:numericId] = options[:clerk_id] || '1'
      end

      def add_invoice(post, money, options)
        post[:amount] = {}
        post[:amount][:total] = amount(money.to_f)
        post[:amount][:tax] = options[:tax].to_f || 0.0
      end

      def add_datetime(post, options)
        post[:dateTime] = options[:date_time] || current_date_time(options)
      end

      def add_transaction(post, options)
        post[:transaction] = {}
        post[:transaction][:invoice] = options[:invoice] || Time.new.to_i.to_s[1..3] + rand.to_s[2..7]
        post[:transaction][:notes] = options[:notes] if options[:notes].present?
        post[:transaction][:vendorReference] = options[:order_id]

        add_purchase_card(post[:transaction], options)
        add_card_on_file(post[:transaction], options)
      end

      def add_card(action, post, payment_method, options)
        post[:card] = {}
        post[:card][:entryMode] = options[:entry_mode] || 'M' unless DISALLOWED_ENTRY_MODE_ACTIONS.include?(action)
        if payment_method.is_a?(CreditCard)
          post[:card][:expirationDate] = "#{format(payment_method.month, :two_digits)}#{format(payment_method.year, :two_digits)}"
          post[:card][:number] = payment_method.number
          post[:card][:securityCode] = {}
          post[:card][:securityCode][:indicator] = 1
          post[:card][:securityCode][:value] = payment_method.verification_value
        else
          post[:card] = {} if post[:card].nil?
          post[:card][:token] = {}
          post[:card][:token][:value] = payment_method
          post[:card][:expirationDate] = options[:expiration_date] if options[:expiration_date]
        end
      end

      def add_card_present(post, options)
        post[:card] = {} unless post[:card].present?

        post[:card][:present] = options[:card_present] || 'N'
      end

      def add_customer(post, card, options)
        address = options[:billing_address] || {}

        post[:customer] = {}
        post[:customer][:addressLine1] = address[:address1] if address[:address1]
        post[:customer][:postalCode] = address[:zip] if address[:zip] && !address[:zip]&.to_s&.empty?
        post[:customer][:firstName] = card.first_name if card.is_a?(CreditCard) && card.first_name
        post[:customer][:lastName] = card.last_name if card.is_a?(CreditCard) && card.last_name
        post[:customer][:emailAddress] = options[:email] if options[:email]
        post[:customer][:ipAddress] = options[:ip] if options[:ip]
      end

      def add_purchase_card(post, options)
        return unless options[:customer_reference] || options[:destination_postal_code] || options[:product_descriptors]

        post[:purchaseCard] = {}
        post[:purchaseCard][:customerReference] = options[:customer_reference] if options[:customer_reference]
        post[:purchaseCard][:destinationPostalCode] = options[:destination_postal_code] if options[:destination_postal_code]
        post[:purchaseCard][:productDescriptors] = options[:product_descriptors] if options[:product_descriptors]
      end

      def add_card_on_file(post, options)
        return unless options[:stored_credential] || options[:usage_indicator] || options[:indicator] || options[:scheduled_indicator] || options[:transaction_id]

        stored_credential = options[:stored_credential] || {}
        post[:cardOnFile] = {}
        post[:cardOnFile][:usageIndicator] = options[:usage_indicator] || (stored_credential[:initial_transaction] ? '01' : '02')
        post[:cardOnFile][:indicator] = options[:indicator] || '01'
        post[:cardOnFile][:scheduledIndicator] = options[:scheduled_indicator] || (RECURRING_TYPE_TRANSACTIONS.include?(stored_credential[:reason_type]) ? '01' : '02')
        post[:cardOnFile][:transactionId] = options[:transaction_id] || stored_credential[:network_transaction_id] if options[:transaction_id] || stored_credential[:network_transaction_id]
      end

      def commit(action, parameters, option)
        url_postfix = URL_POSTFIX_MAPPING[action] || 'transactions'
        url = (test? ? "#{test_url}#{url_postfix}/#{action}" : "#{live_url}#{url_postfix}/#{action}")
        if action == 'invoice'
          response = parse(ssl_request(:delete, url, parameters.to_json, request_headers(action, option)))
        else
          response = parse(ssl_post(url, parameters.to_json, request_headers(action, option)))
        end

        Response.new(
          success_from(action, response),
          message_from(action, response),
          response,
          authorization: authorization_from(action, response),
          avs_result: avs_result_from(response),
          test: test?,
          error_code: error_code_from(action, response)
        )
      end

      def handle_response(response)
        case response.code.to_i
        when 200...300, 400, 401, 500
          response.body
        else
          raise ResponseError.new(response)
        end
      end

      def parse(body)
        return {} if body == ''

        JSON.parse(body)
      end

      def message_from(action, response)
        success_from(action, response) ? 'Transaction successful' : (error(response)&.dig('longText') || response['result'].first&.dig('transaction', 'hostResponse', 'reasonDescription') || 'Transaction declined')
      end

      def error_code_from(action, response)
        code = response['result'].first&.dig('transaction', 'responseCode')
        primary_code = response['result'].first['error'].present?
        return unless code == 'D' || primary_code == true || success_from(action, response)

        if response['result'].first&.dig('transaction', 'hostResponse')
          response['result'].first&.dig('transaction', 'hostResponse', 'reasonCode')
        elsif response['result'].first['error']
          response['result'].first&.dig('error', 'primaryCode')
        else
          response['result'].first&.dig('transaction', 'responseCode')
        end
      end

      def avs_result_from(response)
        AVSResult.new(code: response['result'].first&.dig('transaction', 'avs', 'result')) if response['result'].first&.dig('transaction', 'avs')
      end

      def authorization_from(action, response)
        return unless success_from(action, response)

        authorization = response.dig('result', 0, 'card', 'token', 'value').to_s
        invoice = response.dig('result', 0, 'transaction', 'invoice')
        authorization += "|#{invoice}" if invoice
        authorization
      end

      def get_card_token(authorization)
        authorization.is_a?(CreditCard) ? authorization : authorization.split('|')[0]
      end

      def get_invoice(authorization)
        authorization.is_a?(CreditCard) ? authorization : authorization.split('|')[1]
      end

      def request_headers(action, options)
        headers = {
          'Content-Type' => 'application/json'
        }
        headers['AccessToken'] = @access_token
        headers['Invoice'] = options[:invoice] if action != 'capture' && options[:invoice].present?
        headers['InterfaceVersion'] = '1'
        headers['InterfaceName'] = 'Spreedly'
        headers['CompanyName'] = 'Spreedly'
        headers
      end

      def success_from(action, response)
        success = error(response).nil?
        success &&= SUCCESS_TRANSACTION_STATUS.include?(response['result'].first['transaction']['responseCode']) unless TRANSACTIONS_WITHOUT_RESPONSE_CODE.include?(action)
        success
      end

      def error(response)
        server_error = { 'longText' => response['error'] } if response['error']
        server_error || response['result'].first['error']
      end

      def current_date_time(options = {})
        time_zone = options[:merchant_time_zone] || 'Pacific Time (US & Canada)'
        time = Time.now.in_time_zone(time_zone)
        offset = Time.now.in_time_zone(time_zone).formatted_offset

        time.strftime('%Y-%m-%dT%H:%M:%S.%3N') + offset
      end
    end
  end
end