diff --git a/app/services/doc_auth/error_generator.rb b/app/services/doc_auth/error_generator.rb index 8fc3522de07..7349c394027 100644 --- a/app/services/doc_auth/error_generator.rb +++ b/app/services/doc_auth/error_generator.rb @@ -1,140 +1,22 @@ # frozen_string_literal: true module DocAuth - class ErrorGenerator - include SelfieConcern - attr_reader :config - - def initialize(config) - @config = config + # Non document authentication related error + class ErrorHandler + def handle(response_info) + raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'" end + end - # These constants are the key names for the TrueID errors hash that is returned - ID = :id - FRONT = :front - BACK = :back - SELFIE = :selfie - GENERAL = :general - + class IdTypeErrorHandler < ErrorHandler + SUPPORTED_ID_CLASSNAME = ['Identification Card', 'Drivers License'].freeze ACCEPTED_ISSUER_TYPES = [DocAuth::LexisNexis::IssuerTypes::STATE_OR_PROVINCE.name, DocAuth::LexisNexis::IssuerTypes::UNKNOWN.name] - - ERROR_KEYS = [ - ID, - FRONT, - BACK, - SELFIE, - GENERAL, - ].to_set.freeze - - ALERT_MESSAGES = { - '1D Control Number Valid': { type: BACK, msg_key: Errors::REF_CONTROL_NUMBER_CHECK }, - '2D Barcode Content': { type: BACK, msg_key: Errors::BARCODE_CONTENT_CHECK }, - '2D Barcode Read': { type: BACK, msg_key: Errors::BARCODE_READ_CHECK }, - 'Birth Date Crosscheck': { type: ID, msg_key: Errors::BIRTH_DATE_CHECKS }, - 'Birth Date Valid': { type: ID, msg_key: Errors::BIRTH_DATE_CHECKS }, - 'Control Number Crosscheck': { type: BACK, msg_key: Errors::CONTROL_NUMBER_CHECK }, - 'Document Classification': { type: ID, msg_key: Errors::ID_NOT_RECOGNIZED }, - 'Document Crosscheck Aggregation': { type: ID, msg_key: Errors::DOC_CROSSCHECK }, - 'Document Expired': { type: ID, msg_key: Errors::DOCUMENT_EXPIRED_CHECK }, - 'Document Number Crosscheck': { type: ID, msg_key: Errors::DOC_NUMBER_CHECKS }, - 'Expiration Date Crosscheck': { type: ID, msg_key: Errors::EXPIRATION_CHECKS }, - 'Expiration Date Valid': { type: ID, msg_key: Errors::EXPIRATION_CHECKS }, - 'Full Name Crosscheck': { type: ID, msg_key: Errors::FULL_NAME_CHECK }, - 'Issue Date Crosscheck': { type: ID, msg_key: Errors::ISSUE_DATE_CHECKS }, - 'Issue Date Valid': { type: ID, msg_key: Errors::ISSUE_DATE_CHECKS }, - 'Layout Valid': { type: ID, msg_key: Errors::ID_NOT_VERIFIED }, - 'Near-Infrared Response': { type: ID, msg_key: Errors::ID_NOT_VERIFIED }, - 'Photo Printing': { type: FRONT, msg_key: Errors::VISIBLE_PHOTO_CHECK }, - 'Physical Document Presence': { type: ID, msg_key: Errors::ID_NOT_VERIFIED }, - 'Sex Crosscheck': { type: ID, msg_key: Errors::SEX_CHECK }, - 'Visible Color Response': { type: ID, msg_key: Errors::VISIBLE_COLOR_CHECK }, - 'Visible Pattern': { type: ID, msg_key: Errors::ID_NOT_VERIFIED }, - 'Visible Photo Characteristics': { type: FRONT, msg_key: Errors::VISIBLE_PHOTO_CHECK }, - }.freeze - - SUPPORTED_ID_CLASSNAME = ['Identification Card', 'Drivers License'].freeze - - def generate_doc_auth_errors(response_info) - alert_error_count = response_info[:doc_auth_result] == 'Passed' ? - 0 : response_info[:alert_failure_count] - - unknown_fail_count = scan_for_unknown_alerts(response_info) - alert_error_count -= unknown_fail_count - - # If we have document type errors (Ex: passport was uploaded) return only - # document type errors for both the "FRONT" and "BACK" fields (but not "SELFIE") - # this return will never include any selfie errors at the moment. - doc_type_errors = get_id_type_errors(response_info[:classification_info]) - return doc_type_errors.to_h unless doc_type_errors.nil? || doc_type_errors.empty? - - # If we have image metric errors (Ex: DPI too low) return only - # image metric errors for both the "FRONT" and "BACK" fields (but not "SELFIE") - # this return will never include any selfie errors at the moment. - image_metric_errors = get_image_metric_errors(response_info[:image_metrics]) - return image_metric_errors.to_h unless image_metric_errors.empty? - - # If we have a selfie failure (selfie does not match id), then all three fields: - # front, back, and selfie should fail with specific in line errors. - liveness_enabled = response_info[:liveness_enabled] - selfie_error = get_selfie_error(liveness_enabled, response_info) - if selfie_error == Errors::SELFIE_FAILURE - # This returns the same sort of object that ErrorResult.to_h returns - # but we need to do something more complex than that's set up to handle - return { - general: [Errors::SELFIE_NOT_LIVE], - front: [Errors::MULTIPLE_FRONT_ID_FAILURES], - back: [Errors::MULTIPLE_BACK_ID_FAILURES], - selfie: [Errors::SELFIE_FAILURE], - hints: false, - } - end - - # This block can also return errors for front, back, and/or selfie - # But we only get here if none of the returns above are triggered - alert_errors = get_error_messages(liveness_enabled, response_info) - alert_error_count += 1 if alert_errors.include?(SELFIE) - - error = '' - side = nil - - # If we don't have document type or image metric errors then sort out which - # errors to return. Note that there's a :general error added in the - # `to_h` method of error_result - if alert_error_count < 1 - config.warn_notifier&.call( - message: 'DocAuth failure escaped without useful errors', - response_info: response_info, - ) - - error = Errors::GENERAL_ERROR - side = ID - elsif alert_error_count == 1 - error = alert_errors.values[0].to_a.pop - side = alert_errors.keys[0] - elsif alert_error_count > 1 - # Simplify multiple errors into a single error for the user - error_fields = alert_errors.keys - if error_fields.length == 1 - side = error_fields.first - case side - when ID - error = Errors::GENERAL_ERROR - when FRONT - error = Errors::MULTIPLE_FRONT_ID_FAILURES - when BACK - error = Errors::MULTIPLE_BACK_ID_FAILURES - end - elsif error_fields.length > 1 - error = Errors::GENERAL_ERROR - side = ID - end - end - - ErrorResult.new(error, side).to_h + def handle(response_info) + get_id_type_errors(response_info[:classification_info]) end - # private + private def get_id_type_errors(classification_info) return unless classification_info.present? @@ -160,10 +42,26 @@ def get_id_type_errors(classification_info) error_result end + def supported_country_codes + IdentityConfig.store.doc_auth_supported_country_codes + end + end + + class ImageMetricsErrorHandler < ErrorHandler + def initialize(config) + @config = config + end + + def handle(response_info) + get_image_metric_errors(response_info[:image_metrics]) + end + + private + def get_image_metric_errors(processed_image_metrics) - dpi_threshold = config&.dpi_threshold&.to_i || 290 - sharpness_threshold = config&.sharpness_threshold&.to_i || 40 - glare_threshold = config&.glare_threshold&.to_i || 40 + dpi_threshold = @config&.dpi_threshold&.to_i || 290 + sharpness_threshold = @config&.sharpness_threshold&.to_i || 40 + glare_threshold = @config&.glare_threshold&.to_i || 40 dpi_metrics, sharp_metrics, glare_metrics = {}, {}, {} error_result = ErrorResult.new @@ -203,28 +101,29 @@ def get_image_metric_errors(processed_image_metrics) error_result end + end - def get_error_messages(liveness_enabled, response_info) - errors = Hash.new { |hash, key| hash[key] = Set.new } - - if response_info[:doc_auth_result] != 'Passed' - response_info[:processed_alerts][:failed]&.each do |alert| - alert_msg_hash = ALERT_MESSAGES[alert[:name].to_sym] + class SelfieErrorHandler < ErrorHandler + include SelfieConcern + def handle(response_info) + liveness_enabled = response_info[:liveness_enabled] + get_selfie_error(liveness_enabled, response_info) + end - if alert_msg_hash.present? - field_type = alert[:side] || alert_msg_hash[:type] - errors[field_type.to_sym] << alert_msg_hash[:msg_key] - end - end - end + def is_generic_selfie_error?(error) + error == Errors::SELFIE_FAILURE + end - selfie_error = get_selfie_error(liveness_enabled, response_info) - if liveness_enabled && !!selfie_error - errors[SELFIE] << selfie_error - end + SELFIE_GENERAL_FAILURE_ERROR = + { + general: [Errors::SELFIE_NOT_LIVE], + front: [Errors::MULTIPLE_FRONT_ID_FAILURES], + back: [Errors::MULTIPLE_BACK_ID_FAILURES], + selfie: [Errors::SELFIE_FAILURE], + hints: false, + } - errors - end + private def get_selfie_error(liveness_enabled, response_info) # The part of the response that contains information about the selfie @@ -252,7 +151,187 @@ def get_selfie_error(liveness_enabled, response_info) return Errors::SELFIE_NOT_LIVE end # Fallback, we don't expect this to happen - return Errors::SELFIE_FAILURE + Errors::SELFIE_FAILURE + end + end + + class AlertErrorHandler < ErrorHandler + def initialize(config:, liveness_enabled:) + @config = config + @liveness_enabled = liveness_enabled + end + + def handle(known_alert_error_count, response_info, selfie_error) + alert_errors = get_error_messages(response_info) + # take non general selfie error into account + if @liveness_enabled && !!selfie_error + alert_errors[ErrorGenerator::SELFIE] << selfie_error + end + known_alert_error_count += 1 if alert_errors.include?(ErrorGenerator::SELFIE) + + # consolidate error based on count + if known_alert_error_count < 1 + process_unknown_alert_error(response_info) + elsif known_alert_error_count == 1 + process_single_alert_error(alert_errors) + elsif known_alert_error_count > 1 + # Simplify multiple errors into a single error for the user + consolidate_multiple_alert_errors(alert_errors) + else + # default fall back + ErrorResult.new(error, side) + end + end + + private + + def get_error_messages(response_info) + errors = Hash.new { |hash, key| hash[key] = Set.new } + + if response_info[:doc_auth_result] != 'Passed' + response_info[:processed_alerts][:failed]&.each do |alert| + alert_msg_hash = ErrorGenerator::ALERT_MESSAGES[alert[:name].to_sym] + + if alert_msg_hash.present? + field_type = alert[:side] || alert_msg_hash[:type] + errors[field_type.to_sym] << alert_msg_hash[:msg_key] + end + end + end + errors + end + + ## + # Return ErrorResult as hash, there is error but known_error_count = 0 + ## + def process_unknown_alert_error(response_info) + @config.warn_notifier&.call( + message: 'DocAuth failure escaped without useful errors', + response_info: response_info, + ) + + error = Errors::GENERAL_ERROR + side = ErrorGenerator::ID + ErrorResult.new(error, side) + end + + def process_single_alert_error(alert_errors) + error = alert_errors.values[0].to_a.pop + side = alert_errors.keys[0] + ErrorResult.new(error, side) + end + + def consolidate_multiple_alert_errors(alert_errors) + error_fields = alert_errors.keys + if error_fields.length == 1 + side = error_fields.first + case side + when ErrorGenerator::ID + error = Errors::GENERAL_ERROR + when ErrorGenerator::FRONT + error = Errors::MULTIPLE_FRONT_ID_FAILURES + when ErrorGenerator::BACK + error = Errors::MULTIPLE_BACK_ID_FAILURES + end + elsif error_fields.length > 1 + error = Errors::GENERAL_ERROR + side = ErrorGenerator::ID + end + ErrorResult.new(error, side) + end + end + + class ErrorGenerator + attr_reader :config + + # These constants are the key names for the TrueID errors hash that is returned + ID = :id + FRONT = :front + BACK = :back + SELFIE = :selfie + GENERAL = :general + + ACCEPTED_ISSUER_TYPES = [DocAuth::LexisNexis::IssuerTypes::STATE_OR_PROVINCE.name, + DocAuth::LexisNexis::IssuerTypes::UNKNOWN.name] + + ERROR_KEYS = [ + ID, + FRONT, + BACK, + SELFIE, + GENERAL, + ].to_set.freeze + + ALERT_MESSAGES = { + '1D Control Number Valid': { type: BACK, msg_key: Errors::REF_CONTROL_NUMBER_CHECK }, + '2D Barcode Content': { type: BACK, msg_key: Errors::BARCODE_CONTENT_CHECK }, + '2D Barcode Read': { type: BACK, msg_key: Errors::BARCODE_READ_CHECK }, + 'Birth Date Crosscheck': { type: ID, msg_key: Errors::BIRTH_DATE_CHECKS }, + 'Birth Date Valid': { type: ID, msg_key: Errors::BIRTH_DATE_CHECKS }, + 'Control Number Crosscheck': { type: BACK, msg_key: Errors::CONTROL_NUMBER_CHECK }, + 'Document Classification': { type: ID, msg_key: Errors::ID_NOT_RECOGNIZED }, + 'Document Crosscheck Aggregation': { type: ID, msg_key: Errors::DOC_CROSSCHECK }, + 'Document Expired': { type: ID, msg_key: Errors::DOCUMENT_EXPIRED_CHECK }, + 'Document Number Crosscheck': { type: ID, msg_key: Errors::DOC_NUMBER_CHECKS }, + 'Expiration Date Crosscheck': { type: ID, msg_key: Errors::EXPIRATION_CHECKS }, + 'Expiration Date Valid': { type: ID, msg_key: Errors::EXPIRATION_CHECKS }, + 'Full Name Crosscheck': { type: ID, msg_key: Errors::FULL_NAME_CHECK }, + 'Issue Date Crosscheck': { type: ID, msg_key: Errors::ISSUE_DATE_CHECKS }, + 'Issue Date Valid': { type: ID, msg_key: Errors::ISSUE_DATE_CHECKS }, + 'Layout Valid': { type: ID, msg_key: Errors::ID_NOT_VERIFIED }, + 'Near-Infrared Response': { type: ID, msg_key: Errors::ID_NOT_VERIFIED }, + 'Photo Printing': { type: FRONT, msg_key: Errors::VISIBLE_PHOTO_CHECK }, + 'Physical Document Presence': { type: ID, msg_key: Errors::ID_NOT_VERIFIED }, + 'Sex Crosscheck': { type: ID, msg_key: Errors::SEX_CHECK }, + 'Visible Color Response': { type: ID, msg_key: Errors::VISIBLE_COLOR_CHECK }, + 'Visible Pattern': { type: ID, msg_key: Errors::ID_NOT_VERIFIED }, + 'Visible Photo Characteristics': { type: FRONT, msg_key: Errors::VISIBLE_PHOTO_CHECK }, + }.freeze + + SUPPORTED_ID_CLASSNAME = ['Identification Card', 'Drivers License'].freeze + + def initialize(config) + @config = config + end + + def generate_doc_auth_errors(response_info) + # when entered here, it's decided the doc auth is not successful + + # scan unknown(handled) error, make sure `warn_notify` it + # if unhandled error found + unknown_fail_count = scan_for_unknown_alerts(response_info) + + # check whether ID type supported + id_type_error_handler = IdTypeErrorHandler.new + id_type_error = id_type_error_handler.handle(response_info) + return id_type_error.to_h if id_type_error.present? && !id_type_error.empty? + + # check Image metrics error + metrics_error_handler = ImageMetricsErrorHandler.new(config) + metrics_error = metrics_error_handler.handle(response_info) + return metrics_error.to_h if metrics_error.present? && !metrics_error.empty? + + # check selfie error + selfie_error_handler = SelfieErrorHandler.new + selfie_error = selfie_error_handler.handle(response_info) + + # if selfie itself is ok, but we have selfie related error + if selfie_error_handler.is_generic_selfie_error?(selfie_error) + return SelfieErrorHandler::SELFIE_GENERAL_FAILURE_ERROR + end + + # other vendor response detail error + liveness_enabled = response_info[:liveness_enabled] + alert_error_count = response_info[:doc_auth_result] == 'Passed' ? + 0 : response_info[:alert_failure_count] + + known_alert_error_count = alert_error_count - unknown_fail_count + alert_error_handler = AlertErrorHandler.new( + config: config, + liveness_enabled: liveness_enabled, + ) + alert_error = alert_error_handler.handle(known_alert_error_count, response_info, selfie_error) + alert_error.to_h end def scan_for_unknown_alerts(response_info) @@ -264,7 +343,7 @@ def scan_for_unknown_alerts(response_info) unknown_alerts = [] all_alerts.each do |alert| - if ALERT_MESSAGES[alert[:name].to_sym].blank? + if ErrorGenerator::ALERT_MESSAGES[alert[:name].to_sym].blank? unknown_alerts.push(alert[:name]) unknown_fail_count += 1 if alert[:result] != 'Passed' @@ -289,9 +368,5 @@ def self.general_error(_liveness_enabled) def self.wrapped_general_error(liveness_enabled) { general: [ErrorGenerator.general_error(liveness_enabled)], hints: true } end - - def supported_country_codes - IdentityConfig.store.doc_auth_supported_country_codes - end end end