Shopify/active_merchant

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

Summary

Maintainability
C
1 day
Test Coverage
module ActiveMerchant #:nodoc:
  module Billing #:nodoc:
    # The Mercury gateway integration by default requires that the Mercury
    # account being used has tokenization turned. This enables the use of
    # capture/refund/void without having to pass the credit card back in each
    # time. Only the "OneTime" tokenization is used; there is no use of
    # "Recurring" tokenization.
    #
    # If you don't wish to enable Mercury tokenization, you can pass
    # <code>:tokenization => false</code> as an option when creating the
    # gateway. If you do so, then passing a +:credit_card+ option to +capture+
    # and +refund+ will become mandatory.
    class MercuryGateway < Gateway
      URLS = {
        test: 'https://w1.mercurycert.net/ws/ws.asmx',
        live: 'https://w1.mercurypay.com/ws/ws.asmx'
      }

      self.homepage_url = 'http://www.mercurypay.com'
      self.display_name = 'Mercury'
      self.supported_countries = %w[US CA]
      self.supported_cardtypes = %i[visa master american_express discover diners_club jcb]
      self.default_currency = 'USD'

      STANDARD_ERROR_CODE_MAPPING = {
        '100204' => STANDARD_ERROR_CODE[:invalid_number],
        '100205' => STANDARD_ERROR_CODE[:invalid_expiry_date],
        '000000' => STANDARD_ERROR_CODE[:card_declined]
      }

      def initialize(options = {})
        requires!(options, :login, :password)
        @use_tokenization = (!options.has_key?(:tokenization) || options[:tokenization])
        super
      end

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

        request = build_non_authorized_request('Sale', money, credit_card, options)
        commit('Sale', request)
      end

      def credit(money, credit_card, options = {})
        requires!(options, :order_id)

        request = build_non_authorized_request('Return', money, credit_card, options)
        commit('Return', request)
      end

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

        request = build_non_authorized_request('PreAuth', money, credit_card, options.merge(authorized: money))
        commit('PreAuth', request)
      end

      def capture(money, authorization, options = {})
        requires!(options, :credit_card) unless @use_tokenization

        request = build_authorized_request('PreAuthCapture', money, authorization, options[:credit_card], options.merge(authorized: money))
        commit('PreAuthCapture', request)
      end

      def refund(money, authorization, options = {})
        requires!(options, :credit_card) unless @use_tokenization

        request = build_authorized_request('Return', money, authorization, options[:credit_card], options)
        commit('Return', request)
      end

      def void(authorization, options = {})
        requires!(options, :credit_card) unless @use_tokenization

        request = build_authorized_request('VoidSale', nil, authorization, options[:credit_card], options)
        commit('VoidSale', request)
      end

      def store(credit_card, options = {})
        request = build_card_lookup_request(credit_card, options)
        commit('CardLookup', request)
      end

      def supports_scrubbing?
        true
      end

      def scrub(transcript)
        transcript.
          gsub(%r(&lt;), '<').
          gsub(%r(&gt;), '>').
          gsub(%r((<pw>).*(</pw>))i, '\1[FILTERED]\2').
          gsub(%r((<AcctNo>)(\d|x)*(</AcctNo>))i, '\1[FILTERED]\3').
          gsub(%r((<CVVData>)\d*(</CVVData>))i, '\1[FILTERED]\2')
      end

      private

      def build_non_authorized_request(action, money, credit_card, options)
        xml = Builder::XmlMarkup.new

        xml.tag! 'TStream' do
          xml.tag! 'Transaction' do
            xml.tag! 'TranType', 'Credit'
            xml.tag! 'TranCode', action
            xml.tag! 'PartialAuth', 'Allow' if options[:allow_partial_auth] && %w[PreAuth Sale].include?(action)
            add_invoice(xml, options[:order_id], nil, options)
            add_reference(xml, 'RecordNumberRequested')
            add_customer_data(xml, options)
            add_amount(xml, money, options)
            add_credit_card(xml, credit_card, action)
            add_address(xml, options) unless credit_card.track_data.present?
          end
        end
        xml = xml.target!
      end

      def build_authorized_request(action, money, authorization, credit_card, options)
        xml = Builder::XmlMarkup.new

        invoice_no, ref_no, auth_code, acq_ref_data, process_data, record_no, amount = split_authorization(authorization)
        ref_no = '1' if ref_no.blank?

        xml.tag! 'TStream' do
          xml.tag! 'Transaction' do
            xml.tag! 'TranType', 'Credit'
            xml.tag! 'PartialAuth', 'Allow' if options[:allow_partial_auth] && (action == 'PreAuthCapture')
            xml.tag! 'TranCode', (@use_tokenization ? (action + 'ByRecordNo') : action)
            add_invoice(xml, invoice_no, ref_no, options)
            add_reference(xml, record_no)
            add_customer_data(xml, options)
            add_amount(xml, (money || amount.to_i), options)
            add_credit_card(xml, credit_card, action) if credit_card
            add_address(xml, options)
            xml.tag! 'TranInfo' do
              xml.tag! 'AuthCode', auth_code
              xml.tag! 'AcqRefData', acq_ref_data
              xml.tag! 'ProcessData', process_data
            end
          end
        end
        xml = xml.target!
      end

      def build_card_lookup_request(credit_card, options)
        xml = Builder::XmlMarkup.new

        xml.tag! 'TStream' do
          xml.tag! 'Transaction' do
            xml.tag! 'TranType', 'CardLookup'
            xml.tag! 'RecordNo', 'RecordNumberRequested'
            xml.tag! 'Frequency', 'OneTime'

            xml.tag! 'Memo', options[:description]
            add_customer_data(xml, options)
            add_credit_card(xml, credit_card, options)
          end
        end
        xml.target!
      end

      def add_invoice(xml, invoice_no, ref_no, options)
        xml.tag! 'InvoiceNo', invoice_no
        xml.tag! 'RefNo', (ref_no || invoice_no)
        xml.tag! 'OperatorID', options[:merchant] if options[:merchant]
        xml.tag! 'Memo', options[:description] if options[:description]
      end

      def add_reference(xml, record_no)
        if @use_tokenization
          xml.tag! 'Frequency', 'OneTime'
          xml.tag! 'RecordNo', record_no
        end
      end

      def add_customer_data(xml, options)
        xml.tag! 'IpAddress', options[:ip] if options[:ip]
        if options[:customer]
          xml.tag! 'TranInfo' do
            xml.tag! 'CustomerCode', options[:customer]
          end
        end
        xml.tag! 'MerchantID', @options[:login]
      end

      def add_amount(xml, money, options = {})
        xml.tag! 'Amount' do
          xml.tag! 'Purchase', amount(money)
          xml.tag! 'Tax', options[:tax] if options[:tax]
          xml.tag! 'Authorize', amount(options[:authorized]) if options[:authorized]
          xml.tag! 'Gratuity', amount(options[:tip]) if options[:tip]
        end
      end

      CARD_CODES = {
        'visa' => 'VISA',
        'master' => 'M/C',
        'american_express' => 'AMEX',
        'discover' => 'DCVR',
        'diners_club' => 'DCLB',
        'jcb' => 'JCB'
      }

      def add_credit_card(xml, credit_card, action)
        xml.tag! 'Account' do
          if credit_card.track_data.present?
            # Track 1 has a start sentinel (STX) of '%' and track 2 is ';'
            # Track 1 and 2 have identical end sentinels (ETX) of '?'
            # Tracks may or may not have checksum (LRC) after the ETX
            # If the track has no STX or is corrupt, we send it as track 1, to let Mercury
            # handle with the validation error as it sees fit.
            # Track 2 requires having the STX and ETX stripped. Track 1 does not.
            # Max-length track 1s require having the STX and ETX stripped. Max is 79 bytes including LRC.
            is_track2 = credit_card.track_data[0] == ';'
            etx_index = credit_card.track_data.rindex('?') || credit_card.track_data.length
            is_max_track1 = etx_index >= 77

            if is_track2
              xml.tag! 'Track2', credit_card.track_data[1...etx_index]
            elsif is_max_track1
              xml.tag! 'Track1', credit_card.track_data[1...etx_index]
            else
              xml.tag! 'Track1', credit_card.track_data
            end
          else
            xml.tag! 'AcctNo', credit_card.number
            xml.tag! 'ExpDate', expdate(credit_card)
          end
        end
        xml.tag! 'CardType', CARD_CODES[credit_card.brand] if credit_card.brand

        include_cvv = !%w(Return PreAuthCapture).include?(action) && !credit_card.track_data.present?
        xml.tag! 'CVVData', credit_card.verification_value if include_cvv && credit_card.verification_value
      end

      def add_address(xml, options)
        if billing_address = options[:billing_address] || options[:address]
          xml.tag! 'AVS' do
            xml.tag! 'Address', billing_address[:address1]
            xml.tag! 'Zip', billing_address[:zip]
          end
        end
      end

      def parse(action, body)
        response = {}
        hashify_xml!(unescape_xml(body), response)
        response
      end

      def hashify_xml!(xml, response)
        xml = REXML::Document.new(xml)

        xml.elements.each('//CmdResponse/*') do |node|
          response[node.name.underscore.to_sym] = node.text
        end

        xml.elements.each('//TranResponse/*') do |node|
          if node.name.to_s == 'Amount'
            node.elements.each do |amt|
              response[amt.name.underscore.to_sym] = amt.text
            end
          else
            response[node.name.underscore.to_sym] = node.text
          end
        end
      end

      def endpoint_url
        URLS[test? ? :test : :live]
      end

      def build_soap_request(body)
        xml = Builder::XmlMarkup.new

        xml.instruct!
        xml.tag! 'soap:Envelope', ENVELOPE_NAMESPACES do
          xml.tag! 'soap:Body' do
            xml.tag! 'CreditTransaction', 'xmlns' => homepage_url do
              xml.tag! 'tran' do
                xml << escape_xml(body)
              end
              xml.tag! 'pw', @options[:password]
            end
          end
        end
        xml.target!
      end

      def build_header
        {
          'SOAPAction' => 'http://www.mercurypay.com/CreditTransaction',
          'Content-Type' => 'text/xml; charset=utf-8'
        }
      end

      SUCCESS_CODES = %w[Approved Success]

      def commit(action, request)
        response = parse(action, ssl_post(endpoint_url, build_soap_request(request), build_header))

        success = SUCCESS_CODES.include?(response[:cmd_status])
        message = success ? 'Success' : message_from(response)

        Response.new(
          success,
          message,
          response,
          test: test?,
          authorization: authorization_from(response),
          avs_result: { code: response[:avs_result] },
          cvv_result: response[:cvv_result],
          error_code: success ? nil : STANDARD_ERROR_CODE_MAPPING[response[:dsix_return_code]]
        )
      end

      def message_from(response)
        response[:text_response]
      end

      def authorization_from(response)
        dollars, cents = (response[:purchase] || '').split('.').collect(&:to_i)
        dollars ||= 0
        cents ||= 0
        [
          response[:invoice_no],
          response[:ref_no],
          response[:auth_code],
          response[:acq_ref_data],
          response[:process_data],
          response[:record_no],
          ((dollars * 100) + cents).to_s
        ].join(';')
      end

      def split_authorization(authorization)
        invoice_no, ref_no, auth_code, acq_ref_data, process_data, record_no, amount = authorization.split(';')
        [invoice_no, ref_no, auth_code, acq_ref_data, process_data, record_no, amount]
      end

      ENVELOPE_NAMESPACES = {
        'xmlns:xsd' => 'http://www.w3.org/2001/XMLSchema',
        'xmlns:soap' => 'http://schemas.xmlsoap.org/soap/envelope/',
        'xmlns:xsi' => 'http://www.w3.org/2001/XMLSchema-instance'
      }

      def escape_xml(xml)
        "\n<![CDATA[\n#{xml}\n]]>\n"
      end

      def unescape_xml(escaped_xml)
        escaped_xml.gsub(/\&gt;/, '>').gsub(/\&lt;/, '<')
      end
    end
  end
end