activemerchant/active_merchant

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

Summary

Maintainability
C
1 day
Test Coverage
module ActiveMerchant #:nodoc:
  module Billing #:nodoc:
    class AirwallexGateway < Gateway
      self.test_url = 'https://api-demo.airwallex.com/api/v1'
      self.live_url = 'https://pci-api.airwallex.com/api/v1'

      # per https://www.airwallex.com/docs/online-payments__overview, cards are accepted in all EU countries
      self.supported_countries = %w[AT AU BE BG CY CZ DE DK EE GR ES FI FR GB HK HR HU IE IT LT LU LV MT NL PL PT RO SE SG SI SK]
      self.default_currency = 'AUD'
      self.supported_cardtypes = %i[visa master]

      self.homepage_url = 'https://airwallex.com/'
      self.display_name = 'Airwallex'

      ENDPOINTS = {
        login: '/authentication/login',
        setup: '/pa/payment_intents/create',
        sale: '/pa/payment_intents/%{id}/confirm',
        capture: '/pa/payment_intents/%{id}/capture',
        refund: '/pa/refunds/create',
        void: '/pa/payment_intents/%{id}/cancel'
      }

      # Provided by Airwallex for testing purposes
      TEST_NETWORK_TRANSACTION_IDS = {
        visa: '123456789012345',
        master: 'MCC123ABC0101'
      }

      def initialize(options = {})
        requires!(options, :client_id, :client_api_key)
        @client_id = options[:client_id]
        @client_api_key = options[:client_api_key]
        super
        @access_token = options[:access_token] || setup_access_token
      end

      def purchase(money, card, options = {})
        payment_intent_id = create_payment_intent(money, options)
        post = {
          'request_id' => request_id(options),
          'merchant_order_id' => merchant_order_id(options)
        }
        add_card(post, card, options)
        add_descriptor(post, options)
        add_stored_credential(post, options)
        add_return_url(post, options)
        post['payment_method_options'] = { 'card' => { 'auto_capture' => false } } if authorization_only?(options)

        add_three_ds(post, options)
        commit(:sale, post, payment_intent_id)
      end

      def authorize(money, payment, options = {})
        # authorize is just a purchase w/o an auto capture
        purchase(money, payment, options.merge({ auto_capture: false }))
      end

      def capture(money, authorization, options = {})
        raise ArgumentError, 'An authorization value must be provided.' if authorization.blank?

        post = {
          'request_id' => request_id(options),
          'merchant_order_id' => merchant_order_id(options),
          'amount' => amount(money)
        }
        add_descriptor(post, options)

        commit(:capture, post, authorization)
      end

      def refund(money, authorization, options = {})
        raise ArgumentError, 'An authorization value must be provided.' if authorization.blank?

        post = {}
        post[:amount] = amount(money)
        post[:payment_intent_id] = authorization
        post[:request_id] = request_id(options)
        post[:merchant_order_id] = merchant_order_id(options)

        commit(:refund, post)
      end

      def void(authorization, options = {})
        raise ArgumentError, 'An authorization value must be provided.' if authorization.blank?

        post = {}
        post[:request_id] = request_id(options)
        post[:merchant_order_id] = merchant_order_id(options)
        add_descriptor(post, options)

        commit(:void, post, authorization)
      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 supports_scrubbing?
        true
      end

      def scrub(transcript)
        transcript.
          gsub(/(\\\"number\\\":\\\")\d+/, '\1[REDACTED]').
          gsub(/(\\\"cvc\\\":\\\")\d+/, '\1[REDACTED]')
      end

      private

      def request_id(options)
        options[:request_id] || generate_uuid
      end

      def merchant_order_id(options)
        options[:merchant_order_id] || options[:order_id] || generate_uuid
      end

      def add_return_url(post, options)
        post[:return_url] = options[:return_url] if options[:return_url]
      end

      def generate_uuid
        SecureRandom.uuid
      end

      def setup_access_token
        token_headers = {
          'Content-Type' => 'application/json',
          'x-client-id' => @client_id,
          'x-api-key' => @client_api_key
        }

        begin
          raw_response = ssl_post(build_request_url(:login), nil, token_headers)
        rescue ResponseError => e
          raise OAuthResponseError.new(e)
        else
          response = JSON.parse(raw_response)
          if (token = response['token'])
            token
          else
            oauth_response = Response.new(false, response['message'])
            raise OAuthResponseError.new(oauth_response)
          end
        end
      end

      def build_request_url(action, id = nil)
        base_url = (test? ? test_url : live_url)
        endpoint = ENDPOINTS[action].to_s
        endpoint = id.present? ? endpoint % { id: id } : endpoint
        base_url + endpoint
      end

      def add_referrer_data(post)
        post[:referrer_data] = { type: 'spreedly' }
      end

      def create_payment_intent(money, options = {})
        post = {}
        add_invoice(post, money, options)
        add_order(post, options)
        post[:request_id] = "#{request_id(options)}_setup"
        post[:merchant_order_id] = merchant_order_id(options)
        add_referrer_data(post)
        add_descriptor(post, options)
        post['payment_method_options'] = { 'card' => { 'risk_control' => { 'three_ds_action' => 'SKIP_3DS' } } } if options[:skip_3ds]

        response = commit(:setup, post)
        raise ArgumentError.new(response.message) unless response.success?

        response.params['id']
      end

      def add_billing(post, card, options = {})
        return unless has_name_info?(card)

        billing = post['payment_method']['card']['billing'] || {}
        billing['email'] = options[:email] if options[:email]
        billing['phone'] = options[:phone] if options[:phone]
        billing['first_name'] = card.first_name
        billing['last_name'] = card.last_name
        billing_address = options[:billing_address]
        billing['address'] = build_address(billing_address) if has_required_address_info?(billing_address)

        post['payment_method']['card']['billing'] = billing
      end

      def has_name_info?(card)
        # These fields are required if billing data is sent.
        card.first_name && card.last_name
      end

      def has_required_address_info?(address)
        # These fields are required if address data is sent.
        return unless address

        address[:address1] && address[:country]
      end

      def build_address(address)
        return unless address

        address_data = {} # names r hard
        address_data[:country_code] = address[:country]
        address_data[:street] = address[:address1]
        address_data[:city] = address[:city] if address[:city] # required per doc, not in practice
        address_data[:postcode] = address[:zip] if address[:zip]
        address_data[:state] = address[:state] if address[:state]
        address_data
      end

      def add_invoice(post, money, options)
        post[:amount] = amount(money)
        post[:currency] = (options[:currency] || currency(money))
      end

      def add_card(post, card, options = {})
        post['payment_method'] = {
          'type' => 'card',
          'card' => {
            'expiry_month' => format(card.month, :two_digits),
            'expiry_year' => card.year.to_s,
            'number' => card.number.to_s,
            'name' => card.name,
            'cvc' => card.verification_value,
            'brand' => card.brand
          }
        }
        add_billing(post, card, options)
      end

      def add_order(post, options)
        return unless shipping_address = options[:shipping_address]

        physical_address = build_shipping_address(shipping_address)
        first_name, last_name = split_names(shipping_address[:name])
        shipping = {}
        shipping[:first_name] = first_name if first_name
        shipping[:last_name] = last_name if last_name
        shipping[:phone_number] = shipping_address[:phone_number] if shipping_address[:phone_number]
        shipping[:address] = physical_address
        post[:order] = { shipping: shipping }
      end

      def build_shipping_address(shipping_address)
        address = {}
        address[:city] = shipping_address[:city]
        address[:country_code] = shipping_address[:country]
        address[:postcode] = shipping_address[:zip]
        address[:state] = shipping_address[:state]
        address[:street] = shipping_address[:address1]
        address
      end

      def add_stored_credential(post, options)
        return unless stored_credential = options[:stored_credential]

        external_recurring_data = post[:external_recurring_data] = {}

        case stored_credential.dig(:reason_type)
        when 'recurring', 'installment'
          external_recurring_data[:merchant_trigger_reason] = 'scheduled'
        when 'unscheduled'
          external_recurring_data[:merchant_trigger_reason] = 'unscheduled'
        end

        external_recurring_data[:original_transaction_id] = test_mit?(options) ? test_network_transaction_id(post) : stored_credential.dig(:network_transaction_id)
        external_recurring_data[:triggered_by] = stored_credential.dig(:initiator) == 'cardholder' ? 'customer' : 'merchant'
      end

      def test_network_transaction_id(post)
        case post['payment_method']['card']['brand']
        when 'visa'
          TEST_NETWORK_TRANSACTION_IDS[:visa]
        when 'master'
          TEST_NETWORK_TRANSACTION_IDS[:master]
        end
      end

      def test_mit?(options)
        test? && options.dig(:stored_credential, :initiator) == 'merchant'
      end

      def add_three_ds(post, options)
        return unless three_d_secure = options[:three_d_secure]

        pm_options = post.dig('payment_method_options', 'card')

        external_three_ds = {
          version: format_three_ds_version(three_d_secure),
          eci: three_d_secure[:eci]
        }.merge(three_ds_version_specific_fields(three_d_secure))

        pm_options ? pm_options.merge!(external_three_ds: external_three_ds) : post['payment_method_options'] = { card: { external_three_ds: external_three_ds } }
      end

      def format_three_ds_version(three_d_secure)
        version = three_d_secure[:version].split('.')

        version.push('0') until version.length == 3
        version.join('.')
      end

      def three_ds_version_specific_fields(three_d_secure)
        if three_d_secure[:version].to_f >= 2
          {
            authentication_value: three_d_secure[:cavv],
            ds_transaction_id: three_d_secure[:ds_transaction_id],
            three_ds_server_transaction_id: three_d_secure[:three_ds_server_trans_id]
          }
        else
          {
            cavv: three_d_secure[:cavv],
            xid: three_d_secure[:xid]
          }
        end
      end

      def authorization_only?(options = {})
        options.include?(:auto_capture) && options[:auto_capture] == false
      end

      def add_descriptor(post, options)
        post[:descriptor] = options[:description] if options[:description]
      end

      def parse(body)
        JSON.parse(body)
      end

      def commit(action, post, id = nil)
        url = build_request_url(action, id)

        post_headers = { 'Authorization' => "Bearer #{@access_token}", 'Content-Type' => 'application/json' }
        response = parse(ssl_post(url, post_data(post), post_headers))

        Response.new(
          success_from(response),
          message_from(response),
          response,
          authorization: authorization_from(response),
          avs_result: AVSResult.new(code: response.dig('latest_payment_attempt', 'authentication_data', 'avs_result')),
          cvv_result: CVVResult.new(response.dig('latest_payment_attempt', 'authentication_data', 'cvc_code')),
          test: test?,
          error_code: error_code_from(response)
        )
      end

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

      def post_data(post)
        post.to_json
      end

      def success_from(response)
        %w(REQUIRES_PAYMENT_METHOD SUCCEEDED RECEIVED REQUIRES_CAPTURE CANCELLED).include?(response['status'])
      end

      def message_from(response)
        response.dig('latest_payment_attempt', 'status') || response['status'] || response['message']
      end

      def authorization_from(response)
        response.dig('latest_payment_attempt', 'payment_intent_id')
      end

      def error_code_from(response)
        response['provider_original_response_code'] || response['code'] unless success_from(response)
      end
    end
  end
end