Shopify/active_merchant

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

Summary

Maintainability
B
4 hrs
Test Coverage
module ActiveMerchant #:nodoc:
  module Billing #:nodoc:
    class OppGateway < Gateway
      # = Open Payment Platform
      #
      #  The Open Payment Platform includes a powerful omni-channel transaction processing API,
      #   enabling you to quickly and flexibly build new applications and services on the platform.
      #
      #   This plugin enables connectivity to the Open Payment Platform for activemerchant.
      #
      # For any questions or comments please contact support@payon.com
      #
      # == Usage
      #
      #   gateway = ActiveMerchant::Billing::OppGateway.new(
      #      access_token: 'access_token',
      #      entity_id: 'entity id',
      #   )
      #
      #   # set up credit card object as in main ActiveMerchant example
      #   creditcard = ActiveMerchant::Billing::CreditCard.new(
      #     :type       => 'visa',
      #     :number     => '4242424242424242',
      #     :month      => 8,
      #     :year       => 2009,
      #     :first_name => 'Bob',
      #     :last_name  => 'Bobsen'
      #     :verification_value: '123')
      #
      #   # Request: complete example, including address, billing address, shipping address
      #    complete_request_options = {
      #      order_id: "your merchant/shop order id", # alternative is to set merchantInvoiceId
      #      merchant_transaction_id: "your merchant/shop transaction id",
      #      address: address,
      #      description: 'Store Purchase - Books',
      #      risk_workflow: false,
      #      test_mode: 'EXTERNAL' # or 'INTERNAL', valid only for test system
      #      create_registration: false, # payment details will be stored on the server an latter can be referenced
      #
      #     billing_address: {
      #        address1: '123 Test Street',
      #        city:     'Test',
      #        state:    'TE',
      #        zip:      'AB12CD',
      #        country:  'GB',
      #      },
      #      shipping_address: {
      #        name:     'Muton DeMicelis',
      #        address1: 'My Street On Upiter, Apt 3.14/2.78',
      #        city:     'Munich',
      #        state:    'Bov',
      #        zip:      '81675',
      #        country:  'DE',
      #      },
      #      customer: {
      #        merchant_customer_id:  "your merchant/customer id",
      #        givenname:  'Jane',
      #        surname:  'Jones',
      #        birth_date:  '1965-05-01',
      #        phone:  '(?!?)555-5555',
      #        mobile:  '(?!?)234-23423',
      #        email:  'jane@jones.com',
      #        company_name:  'JJ Ltd.',
      #        identification_doctype:  'PASSPORT',
      #        identification_docid:  'FakeID2342431234123',
      #        ip:  101.102.103.104,
      #      },
      #    }
      #
      #    # Request: minimal example
      #    minimal_request_options = {
      #      order_id: "your merchant/shop order id", # alternative is to set merchantInvoiceId
      #      description: 'Store Purchase - Books',
      #    }
      #
      #   options =
      #   # run request
      #   response = gateway.purchase(754, creditcard, options) # charge 7,54 EUR
      #
      #   response.success?                   # Check whether the transaction was successful
      #   response.error_code                 # Retrieve the error message - it's mapped to Gateway::STANDARD_ERROR_CODE
      #   response.message                    # Retrieve the message returned by opp
      #   response.authorization              # Retrieve the unique transaction ID returned by opp
      #   response.params['result']['code']   # Retrieve original return code returned by opp server
      #
      # == Errors
      #   If transaction is not successful, response.error_code contains mapped to Gateway::STANDARD_ERROR_CODE error message.
      #   Complete list of opp error codes can be viewed on https://docs.oppwa.com/
      #   Because this list is much bigger than Gateway::STANDARD_ERROR_CODE, only fraction is mapped to Gateway::STANDARD_ERROR_CODE.
      #   All other codes are mapped as Gateway::STANDARD_ERROR_CODE[:processing_error], so if this is the case,
      #   you may check the original result code from OPP that can be found in response.params['result']['code']
      #
      # == Special features
      #   For purchase method risk check can be forced when options[:risk_workflow] = true
      #   This will split (on OPP server side) the transaction into two separate transactions: authorize and capture,
      #   but capture will be executed only if risk checks are successful.
      #
      #   For testing you may use the test account details listed fixtures.yml under opp. It is important to note that there are two test modes available:
      #     options[:test_mode]='EXTERNAL' causes test transactions to be forwarded to the processor's test system for 'end-to-end' testing
      #     options[:test_mode]='INTERNAL' causes transactions to be sent to opp simulators, which is useful when switching to the live endpoint for connectivity testing.
      #   If no test_mode parameter is sent, test_mode=INTERNAL is the default behaviour.
      #
      #   Billing Address, Shipping Address, Custom Parameters are supported as described under https://docs.oppwa.com/parameters
      #   See complete example above for details.
      #
      #   == Tokenization
      #  When create_registration is set to true, the payment details will be stored and a token will be returned in registrationId response field,
      #  which can subsequently be used to reference the stored payment.

      self.test_url = 'https://test.oppwa.com/v1/payments'
      self.live_url = 'https://oppwa.com/v1/payments'

      self.supported_countries = %w(AD AI AG AR AU AT BS BB BE BZ BM BR BN BG CA HR CY CZ DK DM EE FI FR DE GR GD GY HK HU IS IN IL IT JP LV LI LT LU MY MT MX MC MS NL PA PL PT KN LC MF VC SM SG SK SI ZA ES SR SE CH TR GB US UY)
      self.default_currency = 'EUR'
      self.supported_cardtypes = %i[visa master american_express diners_club discover jcb maestro dankort]

      self.homepage_url = 'https://docs.oppwa.com'
      self.display_name = 'Open Payment Platform'

      def initialize(options = {})
        requires!(options, :access_token, :entity_id)
        super
      end

      def purchase(money, payment, options = {})
        # debit
        options[:registrationId] = payment if payment.is_a?(String)
        execute_dbpa(options[:risk_workflow] ? 'PA.CP' : 'DB', money, payment, options)
      end

      def authorize(money, payment, options = {})
        # preauthorization PA
        execute_dbpa('PA', money, payment, options)
      end

      def capture(money, authorization, options = {})
        # capture CP
        execute_referencing('CP', money, authorization, options)
      end

      def refund(money, authorization, options = {})
        # refund RF
        execute_referencing('RF', money, authorization, options)
      end

      def void(authorization, options = {})
        # reversal RV
        execute_referencing('RV', nil, authorization, options)
      end

      def verify(credit_card, options = {})
        MultiResponse.run(:use_first_response) do |r|
          r.process { authorize(100, credit_card, options) }
          r.process(:ignore_result) { void(r.authorization, options) }
        end
      end

      def store(credit_card, options = {})
        execute_store(credit_card, options.merge(store: true))
      end

      def supports_scrubbing?
        true
      end

      def scrub(transcript)
        transcript.
          gsub(%r((Authorization: Bearer )\w+)i, '\1[FILTERED]').
          gsub(%r((card\.number=)\d+), '\1[FILTERED]').
          gsub(%r((card\.cvv=)\d+), '\1[FILTERED]')
      end

      private

      def execute_store(payment, options)
        post = {}
        add_payment_method(post, payment, options)
        add_address(post, options)
        add_options(post, options)
        add_3d_secure(post, options)
        commit(post, nil, options)
      end

      def execute_dbpa(txtype, money, payment, options)
        post = {}
        post[:paymentType] = txtype
        add_invoice(post, money, options)
        add_payment_method(post, payment, options)
        add_address(post, options)
        add_customer_data(post, payment, options)
        add_options(post, options)
        add_3d_secure(post, options)
        commit(post, nil, options)
      end

      def execute_referencing(txtype, money, authorization, options)
        post = {}
        post[:paymentType] = txtype
        add_invoice(post, money, options)
        commit(post, authorization, options)
      end

      def add_authentication(post)
        post[:authentication] = { entityId: @options[:entity_id] }
      end

      def add_customer_data(post, payment, options)
        if options[:customer]
          post[:customer] = {
            merchantCustomerId:  options[:customer][:merchant_customer_id],
            givenName:  options[:customer][:givenname] || payment.first_name,
            surname:  options[:customer][:surname] || payment.last_name,
            birthDate:  options[:customer][:birth_date],
            phone:  options[:customer][:phone],
            mobile:  options[:customer][:mobile],
            email:  options[:customer][:email] || options[:email],
            companyName:  options[:customer][:company_name],
            identificationDocType:  options[:customer][:identification_doctype],
            identificationDocId:  options[:customer][:identification_docid],
            ip:  options[:customer][:ip] || options[:ip]
          }
        end
      end

      def add_address(post, options)
        if billing_address = options[:billing_address] || options[:address]
          address(post, billing_address, 'billing')
        end
        if shipping_address = options[:shipping_address]
          address(post, shipping_address, 'shipping')
          if shipping_address[:name]
            firstname, lastname = shipping_address[:name].split(' ')
            post[:shipping] = { givenName: firstname, surname: lastname }
          end
        end
      end

      def address(post, address, prefix)
        post[prefix] = {
          street1: address[:address1],
          street2: address[:address2],
          city: address[:city],
          state: address[:state],
          postcode: address[:zip],
          country: address[:country]
        }
      end

      def add_invoice(post, money, options)
        post[:amount] = amount(money)
        post[:currency] = options[:currency] || currency(money) unless post[:paymentType] == 'RV'
        post[:descriptor] = options[:description] || options[:descriptor]
        post[:merchantInvoiceId] = options[:merchantInvoiceId] || options[:order_id]
        post[:merchantTransactionId] = options[:merchant_transaction_id] || generate_unique_id
      end

      def add_payment_method(post, payment, options)
        return if payment.is_a?(String)

        if options[:registrationId]
          post[:card] = {
            cvv: payment.verification_value
          }
        else
          post[:paymentBrand] = payment.brand.upcase
          post[:card] = {
            holder: payment.name,
            number: payment.number,
            expiryMonth: '%02d' % payment.month,
            expiryYear: payment.year,
            cvv: payment.verification_value
          }
        end
      end

      def add_3d_secure(post, options)
        return unless options[:eci] && options[:cavv] && options[:xid]

        post[:threeDSecure] = {
          eci: options[:eci],
          verificationId: options[:cavv],
          xid: options[:xid]
        }
      end

      def add_options(post, options)
        post[:createRegistration] = options[:create_registration] if options[:create_registration] && !options[:registrationId]
        post[:testMode] = options[:test_mode] if test? && options[:test_mode]
        options.each { |key, value| post[key] = value if key.to_s =~ /'customParameters\[[a-zA-Z0-9\._]{3,64}\]'/ }
        post['customParameters[SHOPPER_pluginId]'] = 'activemerchant'
        post['customParameters[custom_disable3DSecure]'] = options[:disable_3d_secure] if options[:disable_3d_secure]
      end

      def build_url(url, authorization, options)
        if options[:store]
          url.gsub(/payments/, 'registrations')
        elsif options[:registrationId]
          "#{url.gsub(/payments/, 'registrations')}/#{options[:registrationId]}/payments"
        elsif authorization
          "#{url}/#{authorization}"
        else
          url
        end
      end

      def commit(post, authorization, options)
        url = build_url(test? ? test_url : live_url, authorization, options)
        add_authentication(post)
        post = flatten_hash(post)

        response =
          begin
            parse(
              ssl_post(
                url,
                post.collect { |key, value| "#{key}=#{CGI.escape(value.to_s)}" }.join('&'),
                headers
              )
            )
          rescue ResponseError => e
            parse(e.response.body)
          end

        success = success_from(response)

        Response.new(
          success,
          message_from(response),
          response,
          authorization: authorization_from(response),
          test: test?,
          error_code: success ? nil : error_code_from(response)
        )
      end

      def headers
        {
          'Content-Type' => 'application/x-www-form-urlencoded;charset=UTF-8',
          'Authorization' => "Bearer #{@options[:access_token]}"
        }
      end

      def parse(body)
        JSON.parse(body)
      rescue JSON::ParserError
        json_error(body)
      end

      def json_error(body)
        message = "Invalid response received #{body.inspect}"
        { 'result' => { 'description' => message, 'code' => 'unknown' } }
      end

      def success_from(response)
        return false unless response['result']

        success_regex = /^(000\.000\.|000\.100\.1|000\.[36])/

        if success_regex.match?(response['result']['code'])
          true
        else
          false
        end
      end

      def message_from(response)
        return 'Failed' unless response['result']

        response['result']['description']
      end

      def authorization_from(response)
        response['id']
      end

      def error_code_from(response)
        response['result']['code']
      end

      def flatten_hash(hash)
        hash.each_with_object({}) do |(k, v), h|
          if v.is_a? Hash
            flatten_hash(v).map do |h_k, h_v|
              h["#{k}.#{h_k}".to_sym] = h_v
            end
          else
            h[k] = v
          end
        end
      end
    end
  end
end