18F/identity-idp

View on GitHub
app/services/doc_auth/lexis_nexis/responses/true_id_response.rb

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
# frozen_string_literal: true

module DocAuth
  module LexisNexis
    module Responses
      class TrueIdResponse < DocAuth::Response
        include ImageMetricsReader
        include DocPiiReader
        include ClassificationConcern
        include SelfieConcern

        attr_reader :config, :http_response

        def initialize(http_response, config, liveness_checking_enabled = false,
                       request_context = {})
          @config = config
          @http_response = http_response
          @request_context = request_context
          @liveness_checking_enabled = liveness_checking_enabled
          @pii_from_doc = read_pii(true_id_product)
          super(
            success: successful_result?,
            errors: error_messages,
            extra: extra_attributes,
            pii_from_doc: @pii_from_doc,
          )
        rescue StandardError => e
          NewRelic::Agent.notice_error(e)
          super(
            success: false,
            errors: { network: true },
            exception: e,
            extra: {
              backtrace: e.backtrace,
              reference: reference,
            },
          )
        end

        ## returns full check success status, considering all checks:
        #    vendor (document and selfie if requested)
        #    document type
        #    bar code attention
        def successful_result?
          doc_auth_success? &&
            (@liveness_checking_enabled ? selfie_passed? : true)
        end

        # all checks from document perspectives, without considering selfie:
        #  vendor (document only)
        #  document_type
        #  bar code attention
        def doc_auth_success?
          # really it's everything else excluding selfie
          ((transaction_status_passed? &&
            true_id_product.present? &&
            product_status_passed? &&
            doc_auth_result_passed?
           ) ||
            attention_with_barcode?
          ) && id_type_supported?
        end

        def error_messages
          return {} if successful_result?

          if with_authentication_result?
            ErrorGenerator.new(config).generate_doc_auth_errors(response_info)
          elsif true_id_product.present?
            ErrorGenerator.wrapped_general_error
          else
            { network: true } # return a generic technical difficulties error to user
          end
        end

        def extra_attributes
          if with_authentication_result?
            attrs = response_info.merge(true_id_product[:AUTHENTICATION_RESULT])
            attrs.reject! do |k, _v|
              PII_EXCLUDES.include?(k) || k.start_with?('Alert_')
            end
          else
            attrs = {
              lexis_nexis_status: parsed_response_body[:Status],
              lexis_nexis_info: parsed_response_body.dig(:Information),
              exception: 'LexisNexis Response Unexpected: TrueID response details not found.',
            }
          end

          basic_logging_info.merge(attrs)
        end

        def attention_with_barcode?
          return false unless doc_auth_result_attention?

          parsed_alerts[:failed]&.count.to_i == 1 &&
            parsed_alerts.dig(:failed, 0, :name) == '2D Barcode Read' &&
            parsed_alerts.dig(:failed, 0, :result) == 'Attention'
        end

        def billed?
          !!doc_auth_result
        end

        # @return [:success, :fail, :not_processed]
        # When selfie result is missing or not requested:
        #   return :not_processed
        # Otherwise:
        #   return :success if selfie check result == 'Pass'
        #   return :fail
        def selfie_status
          return :not_processed if selfie_result.nil? || !@liveness_checking_enabled
          selfie_result == 'Pass' ? :success : :fail
        end

        def selfie_passed?
          selfie_status == :success
        end

        private

        def conversation_id
          @conversation_id ||= parsed_response_body.dig(:Status, :ConversationId)
        end

        def request_id
          @request_id ||= parsed_response_body.dig(:Status, :RequestId)
        end

        def parsed_response_body
          @parsed_response_body ||= JSON.parse(http_response.body).with_indifferent_access
        end

        def transaction_status
          parsed_response_body.dig(:Status, :TransactionStatus)
        end

        def transaction_status_passed?
          transaction_status == 'passed'
        end

        def transaction_reason_code
          @transaction_reason_code ||=
            parsed_response_body.dig(:Status, :TransactionReasonCode, :Code)
        end

        def reference
          @reference ||= parsed_response_body.dig(:Status, :Reference)
        end

        def products
          @products ||=
            parsed_response_body.dig(:Products)&.each_with_object({}) do |product, product_list|
              extract_details(product)
              product_list[product[:ProductType]] = product
            end&.with_indifferent_access
        end

        def extract_details(product)
          return unless product[:ParameterDetails]

          product[:ParameterDetails].each do |detail|
            group = detail[:Group]
            detail_name = detail[:Name]
            is_region = detail_name.end_with?('Regions', 'Regions_Reference')
            value = is_region ? detail[:Values].map { |v| v[:Value] } :
                      detail.dig(:Values, 0, :Value)
            product[group] ||= {}

            product[group][detail_name] = value
          end
        end

        def response_info
          @response_info ||= create_response_info
        end

        def create_response_info
          alerts = parsed_alerts
          log_alert_formatter = DocAuth::ProcessedAlertToLogAlertFormatter.new
          {
            transaction_status: transaction_status,
            transaction_reason_code: transaction_reason_code,
            product_status: product_status,
            decision_product_status: decision_product_status,
            doc_auth_result: doc_auth_result,
            processed_alerts: alerts,
            alert_failure_count: alerts[:failed]&.count.to_i,
            log_alert_results: log_alert_formatter.log_alerts(alerts),
            portrait_match_results: portrait_match_results,
            image_metrics: read_image_metrics(true_id_product),
            address_line2_present: !pii_from_doc&.address2.blank?,
            classification_info: classification_info,
            liveness_enabled: @liveness_checking_enabled,
          }
        end

        def basic_logging_info
          {
            conversation_id: conversation_id,
            request_id: request_id,
            reference: reference,
            vendor: 'TrueID',
            billed: billed?,
            workflow: @request_context&.dig(:workflow),
          }
        end

        def selfie_result
          portrait_match_results&.dig(:FaceMatchResult)
        end

        def product_status_passed?
          product_status == 'pass'
        end

        def doc_auth_result_passed?
          doc_auth_result == 'Passed'
        end

        def doc_auth_result_attention?
          doc_auth_result == 'Attention'
        end

        def doc_class_name
          true_id_product&.dig(:AUTHENTICATION_RESULT, :DocClassName)
        end

        def doc_issuer_type
          true_id_product&.dig(:AUTHENTICATION_RESULT, :DocIssuerType)
        end

        def classification_info
          # Acuant response has both sides info, here simulate that
          doc_class = doc_class_name
          issuing_country = pii_from_doc&.issuing_country_code
          {
            Front: {
              ClassName: doc_class,
              IssuerType: doc_issuer_type,
              CountryCode: issuing_country,
            },
            Back: {
              ClassName: doc_class,
              IssuerType: doc_issuer_type,
              CountryCode: issuing_country,
            },
          }
        end

        def portrait_match_results
          true_id_product&.dig(:PORTRAIT_MATCH_RESULT)
        end

        def doc_auth_result
          true_id_product&.dig(:AUTHENTICATION_RESULT, :DocAuthResult)
        end

        def product_status
          true_id_product&.dig(:ProductStatus)
        end

        def decision_product_status
          true_id_product_decision&.dig(:ProductStatus)
        end

        def true_id_product
          products[:TrueID] if products.present?
        end

        def true_id_product_decision
          products[:TrueID_Decision] if products.present?
        end

        def parsed_alerts
          return @new_alerts if defined?(@new_alerts)

          @new_alerts = { passed: [], failed: [] }
          return @new_alerts unless with_authentication_result?
          all_alerts = true_id_product[:AUTHENTICATION_RESULT].select do |key|
            key.start_with?('Alert_')
          end

          region_details = parse_document_region
          alert_names = all_alerts.select { |key| key.end_with?('_AlertName') }
          alert_names.each do |alert_name, _v|
            alert_prefix = alert_name.scan(/Alert_\d{1,2}_/).first
            alert = combine_alert_data(all_alerts, alert_prefix, region_details)
            if alert[:result] == 'Passed'
              @new_alerts[:passed].push(alert)
            else
              @new_alerts[:failed].push(alert)
            end
          end
          @new_alerts
        end

        def combine_alert_data(all_alerts, alert_name, region_details)
          new_alert_data = {}
          # Get the set of Alerts that are all the same number (e.g. Alert_11)
          alert_set = all_alerts.select { |key| key.match?(alert_name) }

          alert_set.each do |key, value|
            new_alert_data[:alert] = alert_name.delete_suffix('_')
            new_alert_data[:name] = value if key.end_with?('_AlertName')
            new_alert_data[:result] = value if key.end_with?('_AuthenticationResult')
            new_alert_data[:region] = value if key.end_with?('_Regions')
            new_alert_data[:disposition] = value if key.end_with?('_Disposition')
            new_alert_data[:model] = value if key.end_with?('_Model')
            if key.end_with?('Regions_Reference')
              new_alert_data[:region_ref] = value.map { |v| region_details[v] }
            end
          end

          new_alert_data
        end

        # Generate a hash for image references information that can be linked to Alert
        # @return A hash with region_id => {:key : 'What region', :side: 'Front|Back'}
        def parse_document_region
          region_details = {}
          image_sides = {}
          true_id_product[:ParameterDetails].each do |detail|
            next unless detail[:Group] == 'DOCUMENT_REGION' ||
                        (detail[:Group] == 'IMAGE_METRICS_RESULT' &&
                          %w[ImageMetrics_Id Side].include?(detail[:Name]))
            inner_val = detail[:Values].map { |value| value[:Value] }
            if detail[:Group] == 'DOCUMENT_REGION'
              region_details[detail[:Name]] = inner_val
            else
              image_sides[detail[:Name]] = inner_val
            end
          end
          transform_document_region(region_details, image_sides)
        end

        def transform_document_region(region_details, image_sides)
          new_region_details = {}
          new_image_sides = {}
          image_sides['ImageMetrics_Id']&.each_with_index do |id, i|
            new_image_sides[id] = image_sides.transform_values { |v| v[i] }
          end
          region_details['DocumentRegion_Id']&.each_with_index do |region_id, i|
            new_region_details[region_id] = region_details.transform_values { |v| v[i] }
            new_region_details[region_id].delete('DocumentRegion_Id')
          end
          new_region_details.deep_transform_values! do |v|
            if new_image_sides[v]
              new_image_sides[v]['Side']
            else
              v
            end
          end
          new_region_details.deep_transform_keys! do |k|
            if k.start_with?('DocumentRegion_')
              new_key = k.sub(/DocumentRegion_/, '').downcase
              new_key = new_key == 'imagereference' ? 'side' : new_key
              new_key.to_sym
            else
              k
            end
          end
        end

        def with_authentication_result?
          true_id_product&.dig(:AUTHENTICATION_RESULT).present?
        end
      end
    end
  end
end