diff --git a/app/controllers/idv/in_person/verify_info_controller.rb b/app/controllers/idv/in_person/verify_info_controller.rb index ff1c146bffe..755484b4720 100644 --- a/app/controllers/idv/in_person/verify_info_controller.rb +++ b/app/controllers/idv/in_person/verify_info_controller.rb @@ -70,6 +70,7 @@ def update idv_session.vendor_phone_confirmation = false idv_session.user_phone_confirmation = false + # todo: calculate whether or not we need to do double address verification Idv::Agent.new(pii).proof_resolution( document_capture_session, should_proof_state_id: should_use_aamva?(pii), diff --git a/app/jobs/resolution_proofing_job.rb b/app/jobs/resolution_proofing_job.rb index 56a84873568..60741be76fd 100644 --- a/app/jobs/resolution_proofing_job.rb +++ b/app/jobs/resolution_proofing_job.rb @@ -18,6 +18,7 @@ def perform( encrypted_arguments:, trace_id:, should_proof_state_id:, + double_address_verification: false, user_id: nil, threatmetrix_session_id: nil, request_ip: nil @@ -42,6 +43,7 @@ def perform( threatmetrix_session_id: threatmetrix_session_id, request_ip: request_ip, should_proof_state_id: should_proof_state_id, + double_address_verification: double_address_verification, ) document_capture_session = DocumentCaptureSession.new(result_id: result_id) @@ -66,82 +68,27 @@ def make_vendor_proofing_requests( applicant_pii:, threatmetrix_session_id:, request_ip:, - should_proof_state_id: + should_proof_state_id:, + double_address_verification: ) - device_profiling_result = proof_with_threatmetrix_if_needed( + result = resolution_proofer.proof( applicant_pii: applicant_pii, - user: user, + user_email: user&.confirmed_email_addresses&.first&.email, threatmetrix_session_id: threatmetrix_session_id, request_ip: request_ip, + should_proof_state_id: should_proof_state_id, + double_address_verification: double_address_verification, timer: timer, ) - add_threatmetrix_proofing_component(user.id, device_profiling_result) if user.present? - - resolution_result = timer.time('resolution') do - resolution_proofer.proof(applicant_pii) - end - - state_id_result = Proofing::StateIdResult.new( - success: true, errors: {}, exception: nil, vendor_name: 'UnsupportedJurisdiction', - ) - if should_proof_state_id && user_can_pass_after_state_id_check?(resolution_result) - timer.time('state_id') do - state_id_result = state_id_proofer.proof(applicant_pii) - end - end - - result = Proofing::ResolutionResultAdjudicator.new( - resolution_result: resolution_result, - state_id_result: state_id_result, - should_proof_state_id: should_proof_state_id, - device_profiling_result: device_profiling_result, - ).adjudicated_result.to_h + log_threatmetrix_info(result.device_profiling_result, user) + add_threatmetrix_proofing_component(user.id, result.device_profiling_result) if user.present? CallbackLogData.new( - result: result, - resolution_success: resolution_result.success?, - state_id_success: state_id_result.success?, - device_profiling_success: device_profiling_result.success?, - ) - end - - def proof_with_threatmetrix_if_needed( - applicant_pii:, - user:, - threatmetrix_session_id:, - request_ip:, - timer: - ) - if !FeatureManagement.proofing_device_profiling_collecting_enabled? - return threatmetrix_disabled_result - end - - # The API call will fail without a session ID, so do not attempt to make - # it to avoid leaking data when not required. - return threatmetrix_disabled_result if threatmetrix_session_id.blank? - - return threatmetrix_disabled_result unless applicant_pii - - ddp_pii = applicant_pii.dup - ddp_pii[:threatmetrix_session_id] = threatmetrix_session_id - ddp_pii[:email] = user&.confirmed_email_addresses&.first&.email - ddp_pii[:request_ip] = request_ip - - result = timer.time('threatmetrix') do - lexisnexis_ddp_proofer.proof(ddp_pii) - end - - log_threatmetrix_info(result, user) - - result - end - - def threatmetrix_disabled_result - Proofing::DdpResult.new( - success: true, - client: 'tmx_disabled', - review_status: 'pass', + device_profiling_success: result.device_profiling_result.success?, + resolution_success: result.resolution_result.success?, + result: result.adjudicated_result.to_h, + state_id_success: result.state_id_result.success?, ) end @@ -158,64 +105,9 @@ def logger_info_hash(hash) logger.info(hash.to_json) end - def user_can_pass_after_state_id_check?(resolution_result) - return true if resolution_result.success? - # For failed IV results, this method validates that the user is eligible to pass if the - # failed attributes are covered by the same attributes in a successful AAMVA response - # aka the Get-to-Yes w/ AAMVA feature. - return false unless resolution_result.failed_result_can_pass_with_additional_verification? - - attributes_aamva_can_pass = [:address, :dob, :state_id_number] - results_that_cannot_pass_aamva = - resolution_result.attributes_requiring_additional_verification - attributes_aamva_can_pass - - results_that_cannot_pass_aamva.blank? - end - def resolution_proofer - @resolution_proofer ||= - if IdentityConfig.store.proofer_mock_fallback - Proofing::Mock::ResolutionMockClient.new - else - Proofing::LexisNexis::InstantVerify::Proofer.new( - instant_verify_workflow: IdentityConfig.store.lexisnexis_instant_verify_workflow, - account_id: IdentityConfig.store.lexisnexis_account_id, - base_url: IdentityConfig.store.lexisnexis_base_url, - username: IdentityConfig.store.lexisnexis_username, - password: IdentityConfig.store.lexisnexis_password, - request_mode: IdentityConfig.store.lexisnexis_request_mode, - ) - end - end - - def lexisnexis_ddp_proofer - @lexisnexis_ddp_proofer ||= - if IdentityConfig.store.lexisnexis_threatmetrix_mock_enabled - Proofing::Mock::DdpMockClient.new - else - Proofing::LexisNexis::Ddp::Proofer.new( - api_key: IdentityConfig.store.lexisnexis_threatmetrix_api_key, - org_id: IdentityConfig.store.lexisnexis_threatmetrix_org_id, - base_url: IdentityConfig.store.lexisnexis_threatmetrix_base_url, - ) - end - end - - def state_id_proofer - @state_id_proofer ||= - if IdentityConfig.store.proofer_mock_fallback - Proofing::Mock::StateIdMockClient.new - else - Proofing::Aamva::Proofer.new( - auth_request_timeout: IdentityConfig.store.aamva_auth_request_timeout, - auth_url: IdentityConfig.store.aamva_auth_url, - cert_enabled: IdentityConfig.store.aamva_cert_enabled, - private_key: IdentityConfig.store.aamva_private_key, - public_key: IdentityConfig.store.aamva_public_key, - verification_request_timeout: IdentityConfig.store.aamva_verification_request_timeout, - verification_url: IdentityConfig.store.aamva_verification_url, - ) - end + @resolution_proofer ||= Proofing::Resolution::ProgressiveProofer.new + @resolution_proofer end def add_threatmetrix_proofing_component(user_id, threatmetrix_result) diff --git a/app/services/idv/agent.rb b/app/services/idv/agent.rb index 983f84e3ab8..3bedd96cecf 100644 --- a/app/services/idv/agent.rb +++ b/app/services/idv/agent.rb @@ -10,7 +10,8 @@ def proof_resolution( trace_id:, user_id:, threatmetrix_session_id:, - request_ip: + request_ip:, + double_address_verification: false ) document_capture_session.create_proofing_session @@ -26,6 +27,7 @@ def proof_resolution( user_id: user_id, threatmetrix_session_id: threatmetrix_session_id, request_ip: request_ip, + double_address_verification: double_address_verification, } if IdentityConfig.store.ruby_workers_idv_enabled diff --git a/app/services/idv/steps/verify_base_step.rb b/app/services/idv/steps/verify_base_step.rb index 902c1cc3eb0..37e12d41b88 100644 --- a/app/services/idv/steps/verify_base_step.rb +++ b/app/services/idv/steps/verify_base_step.rb @@ -199,6 +199,10 @@ def enqueue_job document_capture_session.requested_at = Time.zone.now + double_address_verification = + current_user.establishing_in_person_enrollment&.capture_secondary_id_enabled && + flow_session['pii_from_user']['same_address_as_id'] == 'false' + idv_agent.proof_resolution( document_capture_session, should_proof_state_id: should_use_aamva?(pii), @@ -206,6 +210,7 @@ def enqueue_job user_id: user_id, threatmetrix_session_id: flow_session[:threatmetrix_session_id], request_ip: request.remote_ip, + double_address_verification: double_address_verification, ) end diff --git a/app/services/proofing/lexis_nexis/instant_verify/proofer.rb b/app/services/proofing/lexis_nexis/instant_verify/proofer.rb index 84dfb5acd3c..4c6b9c12bed 100644 --- a/app/services/proofing/lexis_nexis/instant_verify/proofer.rb +++ b/app/services/proofing/lexis_nexis/instant_verify/proofer.rb @@ -15,7 +15,7 @@ def proof(applicant) build_result_from_response(response) rescue => exception NewRelic::Agent.notice_error(exception) - ResolutionResult.new( + Resolution::Result.new( success: false, errors: {}, exception: exception, vendor_name: 'lexisnexis:instant_verify', vendor_workflow: config.instant_verify_workflow @@ -27,7 +27,7 @@ def proof(applicant) def build_result_from_response(verification_response) instant_verify_product = find_instant_verify_product(verification_response) - Proofing::ResolutionResult.new( + Proofing::Resolution::Result.new( success: verification_response.verification_status == 'passed', errors: parse_verification_errors(verification_response), exception: nil, diff --git a/app/services/proofing/mock/resolution_mock_client.rb b/app/services/proofing/mock/resolution_mock_client.rb index 10e2cb71ce5..afbaddafd16 100644 --- a/app/services/proofing/mock/resolution_mock_client.rb +++ b/app/services/proofing/mock/resolution_mock_client.rb @@ -51,7 +51,7 @@ def timeout_result end def resolution_result(success:, errors:, exception:) - ResolutionResult.new( + Resolution::Result.new( success: success, errors: errors, exception: exception, diff --git a/app/services/proofing/resolution/progressive_proofer.rb b/app/services/proofing/resolution/progressive_proofer.rb new file mode 100644 index 00000000000..cfcebd82707 --- /dev/null +++ b/app/services/proofing/resolution/progressive_proofer.rb @@ -0,0 +1,236 @@ +module Proofing + module Resolution + # Uses a combination of LexisNexis InstantVerify and AAMVA checks to verify that + # a user's identity can be resolved against authoritative sources. This includes logic for when: + # 1. The user is or is not within an AAMVA-participating jurisdiction + # 2. The user has only provided one address for their residential and identity document + # address or separate residential and identity document addresses + class ProgressiveProofer + def proof( + applicant_pii:, + double_address_verification:, + request_ip:, + should_proof_state_id:, + threatmetrix_session_id:, + timer:, + user_email: + ) + device_profiling_result = proof_with_threatmetrix_if_needed( + applicant_pii: applicant_pii, + request_ip: request_ip, + threatmetrix_session_id: threatmetrix_session_id, + timer: timer, + user_email: user_email, + ) + + residential_address_result = proof_residential_address_if_needed( + applicant_pii: applicant_pii, + timer: timer, + ) + + resolution_result = proof_resolution_if_needed( + applicant_pii: applicant_pii, + timer: timer, + ) + + state_id_result = proof_state_id_if_needed( + applicant_pii: applicant_pii, + timer: timer, + resolution_result: resolution_result, + should_proof_state_id: should_proof_state_id, + ) + + ResultAdjudicator.new( + device_profiling_result: device_profiling_result, + double_address_verification: double_address_verification, + resolution_result: resolution_result, + should_proof_state_id: should_proof_state_id, + state_id_result: state_id_result, + residential_address_result: residential_address_result, + ) + end + + private + + def proof_with_threatmetrix_if_needed( + applicant_pii:, + user_email:, + threatmetrix_session_id:, + request_ip:, + timer: + ) + if !FeatureManagement.proofing_device_profiling_collecting_enabled? + return threatmetrix_disabled_result + end + + # The API call will fail without a session ID, so do not attempt to make + # it to avoid leaking data when not required. + return threatmetrix_disabled_result if threatmetrix_session_id.blank? + + return threatmetrix_disabled_result unless applicant_pii + + ddp_pii = applicant_pii.dup + ddp_pii[:threatmetrix_session_id] = threatmetrix_session_id + ddp_pii[:email] = user_email + ddp_pii[:request_ip] = request_ip + + timer.time('threatmetrix') do + lexisnexis_ddp_proofer.proof(ddp_pii) + end + end + + def proof_residential_address_if_needed( + applicant_pii:, + timer:, + double_address_verification: + ) + return residential_address_unnecessary unless double_address_verification + + timer.time('residential address') do + resolution_proofer.proofer(applicant_pii) + end + end + + def proof_resolution_if_needed( + applicant_pii:, + timer:, + double_address_verification:, + residential_address_result: + ) + + unless residential_address_can_pass_after_state_id_check?(residential_address_result) + return resolution_unnecessary_result + end + + applicant_pii = with_state_id_address(applicant_pii) if double_address_verification + + timer.time('resolution') do + resolution_proofer.proofer(applicant_pii) + end + end + + def proof_state_id_if_needed( + applicant_pii:, + timer:, + resolution_result:, + should_proof_state_id:, + double_address_verification_enabled:, + residential_address_result: + ) + unless should_proof_state_id && + residential_address_can_pass_after_state_id_check?(residential_address_result) && + resolution_result_can_pass_after_state_id_check?(resolution_result) + return out_of_aamva_jurisdiction_result + end + + applicant_pii = with_state_id_address(applicant_pii) if double_address_verification_enabled + + timer.time('state_id') do + state_id_proofer.proof(applicant_pii) + end + end + + def user_can_pass_after_state_id_check?(resolution_result) + return true if resolution_result.success? + # For failed IV results, this method validates that the user is eligible to pass if the + # failed attributes are covered by the same attributes in a successful AAMVA response + # aka the Get-to-Yes w/ AAMVA feature. + return false unless resolution_result.failed_result_can_pass_with_additional_verification? + + attributes_aamva_can_pass = [:address, :dob, :state_id_number] + results_that_cannot_pass_aamva = + resolution_result.attributes_requiring_additional_verification - attributes_aamva_can_pass + + results_that_cannot_pass_aamva.blank? + end + + def threatmetrix_disabled_result + Proofing::DdpResult.new( + success: true, + client: 'tmx_disabled', + review_status: 'pass', + ) + end + + def residential_address_unnecessary_result + Proofing::AddressResult.new( + success: true, errors: {}, exception: nil, vendor_name: 'ResidentialAddressNotRequired', + ) + end + + def resolution_unnecessary_result + Proofing::AddressResult.new( + success: true, errors: {}, exception: nil, vendor_name: 'ResolutionUnnecessary', + ) + end + + def out_of_aamva_jurisdiction_result + Proofing::StateIdResult.new( + errors: {}, + exception: nil, + success: true, + vendor_name: 'UnsupportedJurisdiction', + ) + end + + def lexisnexis_ddp_proofer + @lexisnexis_ddp_proofer ||= + if IdentityConfig.store.lexisnexis_threatmetrix_mock_enabled + Proofing::Mock::DdpMockClient.new + else + Proofing::LexisNexis::Ddp::Proofer.new( + api_key: IdentityConfig.store.lexisnexis_threatmetrix_api_key, + org_id: IdentityConfig.store.lexisnexis_threatmetrix_org_id, + base_url: IdentityConfig.store.lexisnexis_threatmetrix_base_url, + ) + end + end + + def resolution_proofer + @resolution_proofer ||= + if IdentityConfig.store.proofer_mock_fallback + Proofing::Mock::ResolutionMockClient.new + else + Proofing::LexisNexis::InstantVerify::Proofer.new( + instant_verify_workflow: IdentityConfig.store.lexisnexis_instant_verify_workflow, + account_id: IdentityConfig.store.lexisnexis_account_id, + base_url: IdentityConfig.store.lexisnexis_base_url, + username: IdentityConfig.store.lexisnexis_username, + password: IdentityConfig.store.lexisnexis_password, + request_mode: IdentityConfig.store.lexisnexis_request_mode, + ) + end + end + + def state_id_proofer + @state_id_proofer ||= + if IdentityConfig.store.proofer_mock_fallback + Proofing::Mock::StateIdMockClient.new + else + Proofing::Aamva::Proofer.new( + auth_request_timeout: IdentityConfig.store.aamva_auth_request_timeout, + auth_url: IdentityConfig.store.aamva_auth_url, + cert_enabled: IdentityConfig.store.aamva_cert_enabled, + private_key: IdentityConfig.store.aamva_private_key, + public_key: IdentityConfig.store.aamva_public_key, + verification_request_timeout: IdentityConfig.store.aamva_verification_request_timeout, + verification_url: IdentityConfig.store.aamva_verification_url, + ) + end + end + + # Make a copy of pii with the user's state ID address overwriting the address keys + def with_state_id_address(pii) + pii.transform_keys(SECONDARY_ID_ADDRESS_MAP) + end + + SECONDARY_ID_ADDRESS_MAP = { + state_id_address1: :address1, + state_id_address2: :address2, + state_id_city: :city, + state_id_state: :state, + state_id_zipcode: :zipcode, + }.freeze + end + end +end diff --git a/app/services/proofing/resolution/result.rb b/app/services/proofing/resolution/result.rb new file mode 100644 index 00000000000..137ac2ea9fa --- /dev/null +++ b/app/services/proofing/resolution/result.rb @@ -0,0 +1,77 @@ +module Proofing + module Resolution + class Result + attr_reader :errors, + :exception, + :success, + :vendor_name, + :transaction_id, + :verified_attributes, + :failed_result_can_pass_with_additional_verification, + :attributes_requiring_additional_verification, + :reference, + :vendor_workflow, + :drivers_license_info_matches + + def initialize( + success: nil, + errors: {}, + exception: nil, + vendor_name: nil, + transaction_id: '', + reference: '', + failed_result_can_pass_with_additional_verification: false, + attributes_requiring_additional_verification: [], + vendor_workflow: nil, + drivers_license_info_matches: false + ) + @success = success + @errors = errors + @exception = exception + @vendor_name = vendor_name + @transaction_id = transaction_id + @reference = reference + @failed_result_can_pass_with_additional_verification = + failed_result_can_pass_with_additional_verification + @attributes_requiring_additional_verification = + attributes_requiring_additional_verification + @vendor_workflow = vendor_workflow + @drivers_license_info_matches = drivers_license_info_matches + end + + def success? + success + end + + def timed_out? + exception.is_a?(Proofing::TimeoutError) + end + + def to_h + { + success: success?, + errors: errors, + exception: exception, + timed_out: timed_out?, + transaction_id: transaction_id, + reference: reference, + can_pass_with_additional_verification: + failed_result_can_pass_with_additional_verification, + attributes_requiring_additional_verification: + attributes_requiring_additional_verification, + vendor_name: vendor_name, + vendor_workflow: vendor_workflow, + drivers_license_info_matches: drivers_license_info_matches, + } + end + + def failed_result_can_pass_with_additional_verification? + failed_result_can_pass_with_additional_verification + end + + def drivers_license_info_matches? + drivers_license_info_matches + end + end +end +end diff --git a/app/services/proofing/resolution/result_adjudicator.rb b/app/services/proofing/resolution/result_adjudicator.rb new file mode 100644 index 00000000000..be9204efc72 --- /dev/null +++ b/app/services/proofing/resolution/result_adjudicator.rb @@ -0,0 +1,108 @@ +module Proofing + module Resolution + class ResultAdjudicator + attr_reader :resolution_result, :state_id_result, :device_profiling_result, + :double_address_verification, :residential_address_result + + def initialize( + resolution_result:, + state_id_result:, + should_proof_state_id:, + double_address_verification:, + device_profiling_result:, + residential_address_result: + ) + @resolution_result = resolution_result + @state_id_result = state_id_result + @should_proof_state_id = should_proof_state_id + @double_address_verification = double_address_verification + @device_profiling_result = device_profiling_result + @residential_address_result = residential_address_result + end + + def adjudicated_result + resolution_success, resolution_reason = resolution_result_and_reason + device_profiling_success, device_profiling_reason = device_profiling_result_and_reason + + FormResponse.new( + success: resolution_success && device_profiling_success, + errors: errors, + extra: { + exception: exception, + timed_out: timed_out?, + threatmetrix_review_status: device_profiling_result.review_status, + context: { + device_profiling_adjudication_reason: device_profiling_reason, + resolution_adjudication_reason: resolution_reason, + should_proof_state_id: should_proof_state_id?, + double_address_verification: double_address_verification, + stages: { + resolution: resolution_result.to_h, + state_id: state_id_result.to_h, + threatmetrix: device_profiling_result.to_h, + residential_address: residential_address_result.to_h, + }, + }, + }, + ) + end + + def should_proof_state_id? + @should_proof_state_id + end + + private + + def errors + resolution_result.errors. + merge(state_id_result.errors). + merge(device_profiling_result.errors || {}) + end + + def exception + resolution_result.exception || + state_id_result.exception || + device_profiling_result.exception + end + + def timed_out? + resolution_result.timed_out? || state_id_result.timed_out? || + device_profiling_result.timed_out? + end + + def device_profiling_result_and_reason + if device_profiling_result.exception? + [false, :device_profiling_exception] + elsif device_profiling_result.success? + [true, :device_profiling_result_pass] + else + [true, :device_profiling_result_review_required] + end + end + + def resolution_result_and_reason + # todo: update logic to include residential_address_result in the adjudication + if resolution_result.success? && state_id_result.success? + [true, :pass_resolution_and_state_id] + elsif !state_id_result.success? + [false, :fail_state_id] + elsif !should_proof_state_id? + [false, :fail_resolution_skip_state_id] + elsif state_id_attributes_cover_resolution_failures? + [true, :state_id_covers_failed_resolution] + else + [false, :fail_resolution_without_state_id_coverage] + end + end + + def state_id_attributes_cover_resolution_failures? + return false unless resolution_result.failed_result_can_pass_with_additional_verification? + failed_resolution_attributes = + resolution_result.attributes_requiring_additional_verification + passed_state_id_attributes = state_id_result.verified_attributes + + (failed_resolution_attributes.to_a - passed_state_id_attributes.to_a).empty? + end + end + end +end diff --git a/app/services/proofing/resolution_result.rb b/app/services/proofing/resolution_result.rb deleted file mode 100644 index ff236ffed84..00000000000 --- a/app/services/proofing/resolution_result.rb +++ /dev/null @@ -1,73 +0,0 @@ -module Proofing - class ResolutionResult - attr_reader :errors, - :exception, - :success, - :vendor_name, - :transaction_id, - :verified_attributes, - :failed_result_can_pass_with_additional_verification, - :attributes_requiring_additional_verification, - :reference, - :vendor_workflow, - :drivers_license_info_matches - - def initialize( - success: nil, - errors: {}, - exception: nil, - vendor_name: nil, - transaction_id: '', - reference: '', - failed_result_can_pass_with_additional_verification: false, - attributes_requiring_additional_verification: [], - vendor_workflow: nil, - drivers_license_info_matches: false - ) - @success = success - @errors = errors - @exception = exception - @vendor_name = vendor_name - @transaction_id = transaction_id - @reference = reference - @failed_result_can_pass_with_additional_verification = - failed_result_can_pass_with_additional_verification - @attributes_requiring_additional_verification = - attributes_requiring_additional_verification - @vendor_workflow = vendor_workflow - @drivers_license_info_matches = drivers_license_info_matches - end - - def success? - success - end - - def timed_out? - exception.is_a?(Proofing::TimeoutError) - end - - def to_h - { - success: success?, - errors: errors, - exception: exception, - timed_out: timed_out?, - transaction_id: transaction_id, - reference: reference, - can_pass_with_additional_verification: failed_result_can_pass_with_additional_verification, - attributes_requiring_additional_verification: attributes_requiring_additional_verification, - vendor_name: vendor_name, - vendor_workflow: vendor_workflow, - drivers_license_info_matches: drivers_license_info_matches, - } - end - - def failed_result_can_pass_with_additional_verification? - failed_result_can_pass_with_additional_verification - end - - def drivers_license_info_matches? - drivers_license_info_matches - end - end -end diff --git a/app/services/proofing/resolution_result_adjudicator.rb b/app/services/proofing/resolution_result_adjudicator.rb deleted file mode 100644 index 83a05d876df..00000000000 --- a/app/services/proofing/resolution_result_adjudicator.rb +++ /dev/null @@ -1,95 +0,0 @@ -module Proofing - class ResolutionResultAdjudicator - attr_reader :resolution_result, :state_id_result, :device_profiling_result - - def initialize( - resolution_result:, - state_id_result:, - should_proof_state_id:, - device_profiling_result: - ) - @resolution_result = resolution_result - @state_id_result = state_id_result - @should_proof_state_id = should_proof_state_id - @device_profiling_result = device_profiling_result - end - - def adjudicated_result - resolution_success, resolution_reason = resolution_result_and_reason - device_profiling_success, device_profiling_reason = device_profiling_result_and_reason - - FormResponse.new( - success: resolution_success && device_profiling_success, - errors: errors, - extra: { - exception: exception, - timed_out: timed_out?, - threatmetrix_review_status: device_profiling_result.review_status, - context: { - device_profiling_adjudication_reason: device_profiling_reason, - resolution_adjudication_reason: resolution_reason, - should_proof_state_id: should_proof_state_id?, - stages: { - resolution: resolution_result.to_h, - state_id: state_id_result.to_h, - threatmetrix: device_profiling_result.to_h, - }, - }, - }, - ) - end - - def should_proof_state_id? - @should_proof_state_id - end - - private - - def errors - resolution_result.errors. - merge(state_id_result.errors). - merge(device_profiling_result.errors || {}) - end - - def exception - resolution_result.exception || state_id_result.exception || device_profiling_result.exception - end - - def timed_out? - resolution_result.timed_out? || state_id_result.timed_out? || - device_profiling_result.timed_out? - end - - def device_profiling_result_and_reason - if device_profiling_result.exception? - [false, :device_profiling_exception] - elsif device_profiling_result.success? - [true, :device_profiling_result_pass] - else - [true, :device_profiling_result_review_required] - end - end - - def resolution_result_and_reason - if resolution_result.success? && state_id_result.success? - [true, :pass_resolution_and_state_id] - elsif !state_id_result.success? - [false, :fail_state_id] - elsif !should_proof_state_id? - [false, :fail_resolution_skip_state_id] - elsif state_id_attributes_cover_resolution_failures? - [true, :state_id_covers_failed_resolution] - else - [false, :fail_resolution_without_state_id_coverage] - end - end - - def state_id_attributes_cover_resolution_failures? - return false unless resolution_result.failed_result_can_pass_with_additional_verification? - failed_resolution_attributes = resolution_result.attributes_requiring_additional_verification - passed_state_id_attributes = state_id_result.verified_attributes - - (failed_resolution_attributes.to_a - passed_state_id_attributes.to_a).empty? - end - end -end diff --git a/lib/idp/constants.rb b/lib/idp/constants.rb index 35ccd19e4ae..f2ccf2e9d93 100644 --- a/lib/idp/constants.rb +++ b/lib/idp/constants.rb @@ -84,21 +84,21 @@ module Vendors MOCK_IDV_APPLICANT_STATE_ID_JURISDICTION = 'ND' MOCK_IDV_APPLICANT = { - first_name: 'FAKEY', - middle_name: nil, - last_name: 'MCFAKERSON', address1: '1 FAKE RD', address2: nil, city: 'GREAT FALLS', - state: 'MT', - zipcode: '59010', dob: '1938-10-06', - state_id_number: '1111111111111', - state_id_jurisdiction: MOCK_IDV_APPLICANT_STATE_ID_JURISDICTION, - state_id_type: 'drivers_license', + first_name: 'FAKEY', + last_name: 'MCFAKERSON', + middle_name: nil, + phone: nil, + state: 'MT', state_id_expiration: '2099-12-31', state_id_issued: '2019-12-31', - phone: nil, + state_id_jurisdiction: MOCK_IDV_APPLICANT_STATE_ID_JURISDICTION, + state_id_number: '1111111111111', + state_id_type: 'drivers_license', + zipcode: '59010', }.freeze MOCK_IDV_APPLICANT_STATE_ID_ADDRESS = MOCK_IDV_APPLICANT.merge( @@ -116,5 +116,16 @@ module Vendors MOCK_IDV_APPLICANT_FULL_STATE_ID_JURISDICTION = 'North Dakota' MOCK_IDV_APPLICANT_FULL_STATE = 'Montana' MOCK_IDV_APPLICANT_FULL_STATE_ID_STATE = 'Virginia' + + MOCK_IDV_APPLICANT_WITH_STATE_ID_ADDRESS = MOCK_IDV_APPLICANT_WITH_SSN.merge( + { + same_address_as_id: 'false', + state_id_address1: '73 FAKE CIRCLE', + state_id_address2: '#2', + state_id_city: 'LESSER FALLS', + state_id_state: 'VA', + state_id_zipcode: '59022', + }, + ).freeze end end diff --git a/spec/features/idv/analytics_spec.rb b/spec/features/idv/analytics_spec.rb index f061f8db1af..a68f5ca5af6 100644 --- a/spec/features/idv/analytics_spec.rb +++ b/spec/features/idv/analytics_spec.rb @@ -27,7 +27,7 @@ 'IdV: doc auth ssn submitted' => { success: true, errors: {}, flow_path: 'standard', step: 'ssn', step_count: 1, acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth', irs_reproofing: false }, 'IdV: doc auth verify visited' => { flow_path: 'standard', step: 'verify', step_count: 1, acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth', irs_reproofing: false }, 'IdV: doc auth verify submitted' => { flow_path: 'standard', step: 'verify', step_count: 1, acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth', irs_reproofing: false }, - 'IdV: doc auth verify proofing results' => { success: true, errors: {}, address_edited: false, address_line2_present: false, ssn_is_unique: true, proofing_results: { exception: nil, timed_out: false, threatmetrix_review_status: 'pass', context: { device_profiling_adjudication_reason: 'device_profiling_result_pass', resolution_adjudication_reason: 'pass_resolution_and_state_id', should_proof_state_id: true, stages: { resolution: { success: true, errors: {}, exception: nil, timed_out: false, transaction_id: 'resolution-mock-transaction-id-123', reference: 'aaa-bbb-ccc', can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [], vendor_name: 'ResolutionMock', vendor_workflow: nil, drivers_license_info_matches: false }, state_id: { success: true, errors: {}, exception: nil, timed_out: false, transaction_id: 'state-id-mock-transaction-id-456', vendor_name: 'StateIdMock', verified_attributes: [], state: 'MT', state_id_jurisdiction: 'ND', state_id_number: '#############' }, threatmetrix: { client: 'tmx_disabled', success: true, errors: {}, exception: nil, timed_out: false, transaction_id: nil, review_status: 'pass', response_body: { error: 'TMx response body was empty' } } } } } }, + 'IdV: doc auth verify proofing results' => { success: true, errors: {}, address_edited: false, address_line2_present: false, ssn_is_unique: true, proofing_results: { exception: nil, timed_out: false, threatmetrix_review_status: 'pass', context: { device_profiling_adjudication_reason: 'device_profiling_result_pass', double_address_verification: false, resolution_adjudication_reason: 'pass_resolution_and_state_id', should_proof_state_id: true, stages: { resolution: { success: true, errors: {}, exception: nil, timed_out: false, transaction_id: 'resolution-mock-transaction-id-123', reference: 'aaa-bbb-ccc', can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [], vendor_name: 'ResolutionMock', vendor_workflow: nil, drivers_license_info_matches: false }, state_id: { success: true, errors: {}, exception: nil, timed_out: false, transaction_id: 'state-id-mock-transaction-id-456', vendor_name: 'StateIdMock', verified_attributes: [], state: 'MT', state_id_jurisdiction: 'ND', state_id_number: '#############' }, threatmetrix: { client: 'tmx_disabled', success: true, errors: {}, exception: nil, timed_out: false, transaction_id: nil, review_status: 'pass', response_body: { error: 'TMx response body was empty' } } } } } }, 'IdV: phone of record visited' => { proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', threatmetrix: false, threatmetrix_review_status: 'pass' } }, 'IdV: phone confirmation form' => { success: true, errors: {}, phone_type: :mobile, types: [:fixed_or_mobile], carrier: 'Test Mobile Carrier', country_code: 'US', area_code: '202', proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', threatmetrix: false, threatmetrix_review_status: 'pass' }, otp_delivery_preference: 'sms' }, 'IdV: phone confirmation vendor' => { success: true, errors: {}, vendor: { exception: nil, vendor_name: 'AddressMock', transaction_id: 'address-mock-transaction-id-123', timed_out: false, reference: '' }, new_phone_added: false, proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', threatmetrix: false, threatmetrix_review_status: 'pass', address_check: 'lexis_nexis_address' }, area_code: '202', country_code: 'US', phone_fingerprint: anything }, @@ -62,7 +62,7 @@ 'IdV: doc auth ssn submitted' => { success: true, errors: {}, flow_path: 'standard', step: 'ssn', step_count: 1, acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth', irs_reproofing: false }, 'IdV: doc auth verify visited' => { flow_path: 'standard', step: 'verify', step_count: 1, acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth', irs_reproofing: false }, 'IdV: doc auth verify submitted' => { flow_path: 'standard', step: 'verify', step_count: 1, acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth', irs_reproofing: false }, - 'IdV: doc auth verify proofing results' => { success: true, errors: {}, address_edited: false, address_line2_present: false, ssn_is_unique: true, proofing_results: { exception: nil, timed_out: false, threatmetrix_review_status: 'pass', context: { device_profiling_adjudication_reason: 'device_profiling_result_pass', resolution_adjudication_reason: 'pass_resolution_and_state_id', should_proof_state_id: true, stages: { resolution: { success: true, errors: {}, exception: nil, timed_out: false, transaction_id: 'resolution-mock-transaction-id-123', reference: 'aaa-bbb-ccc', can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [], vendor_name: 'ResolutionMock', vendor_workflow: nil, drivers_license_info_matches: false }, state_id: { success: true, errors: {}, exception: nil, timed_out: false, transaction_id: 'state-id-mock-transaction-id-456', vendor_name: 'StateIdMock', verified_attributes: [], state: 'MT', state_id_jurisdiction: 'ND', state_id_number: '#############' }, threatmetrix: { client: 'tmx_disabled', success: true, errors: {}, exception: nil, timed_out: false, transaction_id: nil, review_status: 'pass', response_body: { error: 'TMx response body was empty' } } } } } }, + 'IdV: doc auth verify proofing results' => { success: true, errors: {}, address_edited: false, address_line2_present: false, ssn_is_unique: true, proofing_results: { exception: nil, timed_out: false, threatmetrix_review_status: 'pass', context: { device_profiling_adjudication_reason: 'device_profiling_result_pass', double_address_verification: false, resolution_adjudication_reason: 'pass_resolution_and_state_id', should_proof_state_id: true, stages: { resolution: { success: true, errors: {}, exception: nil, timed_out: false, transaction_id: 'resolution-mock-transaction-id-123', reference: 'aaa-bbb-ccc', can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [], vendor_name: 'ResolutionMock', vendor_workflow: nil, drivers_license_info_matches: false }, state_id: { success: true, errors: {}, exception: nil, timed_out: false, transaction_id: 'state-id-mock-transaction-id-456', vendor_name: 'StateIdMock', verified_attributes: [], state: 'MT', state_id_jurisdiction: 'ND', state_id_number: '#############' }, threatmetrix: { client: 'tmx_disabled', success: true, errors: {}, exception: nil, timed_out: false, transaction_id: nil, review_status: 'pass', response_body: { error: 'TMx response body was empty' } } } } } }, 'IdV: phone of record visited' => { proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', threatmetrix: false, threatmetrix_review_status: 'pass' } }, 'IdV: USPS address letter requested' => { resend: false, proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', threatmetrix: false, threatmetrix_review_status: 'pass' } }, 'IdV: review info visited' => { address_verification_method: 'gpo', proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', threatmetrix: false, threatmetrix_review_status: 'pass', address_check: 'gpo_letter' } }, diff --git a/spec/jobs/resolution_proofing_job_spec.rb b/spec/jobs/resolution_proofing_job_spec.rb index 6f1e9ddfc3e..07fec8159be 100644 --- a/spec/jobs/resolution_proofing_job_spec.rb +++ b/spec/jobs/resolution_proofing_job_spec.rb @@ -1,22 +1,7 @@ require 'rails_helper' RSpec.describe ResolutionProofingJob, type: :job do - let(:pii) do - { - dob: '01/01/1970', - first_name: Faker::Name.first_name, - last_name: Faker::Name.last_name, - address1: '123 Main St.', - city: 'Milwaukee', - state: 'WI', - ssn: '444-55-8888', - zipcode: Faker::Address.zip_code, - state_id_jurisdiction: Faker::Address.state_abbr, - state_id_number: '123456789', - state_id_type: 'drivers_license', - uuid: SecureRandom.hex, - } - end + let(:pii) { Idp::Constants::MOCK_IDV_APPLICANT_WITH_SSN } let(:encrypted_arguments) do Encryption::Encryptors::BackgroundProofingArgEncryptor.new.encrypt( { applicant_pii: pii }.to_json, @@ -312,6 +297,108 @@ end end + context "when the user's state ID address does not match their residential address" do + let(:pii) { Idp::Constants::MOCK_IDV_APPLICANT_WITH_STATE_ID_ADDRESS } + + let(:state_id_address) do + { + address1: pii[:state_id_address1], + address2: pii[:state_id_address2], + city: pii[:state_id_city], + state: pii[:state_id_state], + state_id_jurisdiction: pii[:state_id_jurisdiction], + zipcode: pii[:state_id_zipcode], + } + end + + subject(:perform) do + instance.perform( + result_id: document_capture_session.result_id, + should_proof_state_id: should_proof_state_id, + encrypted_arguments: encrypted_arguments, + trace_id: trace_id, + user_id: user.id, + threatmetrix_session_id: threatmetrix_session_id, + request_ip: request_ip, + double_address_verification: true, + ) + end + + it 'verifies the state ID address with AAMVA and LexisNexis' do + stub_vendor_requests + + expect_any_instance_of(Proofing::LexisNexis::InstantVerify::Proofer).to receive(:proof). + with(hash_including(state_id_address)).and_call_original + expect_any_instance_of(Proofing::Aamva::Proofer).to receive(:proof).with( + hash_including(state_id_address), + ).and_call_original + + perform + end + + it 'stores a successful result' do + stub_vendor_requests + + perform + + result = document_capture_session.load_proofing_result[:result] + result_context = result[:context] + result_context_stages = result_context[:stages] + result_context_stages_resolution = result_context_stages[:resolution] + result_context_stages_state_id = result_context_stages[:state_id] + result_context_stages_threatmetrix = result_context_stages[:threatmetrix] + + expect(result[:exception]).to be_nil + expect(result[:errors].keys).to eq([:"Execute Instant Verify"]) + expect(result[:success]).to be true + expect(result[:timed_out]).to be false + + # result[:context] + expect(result_context[:should_proof_state_id]) + + # result[:context][:stages][:resolution] + expect(result_context_stages_resolution[:vendor_name]). + to eq('lexisnexis:instant_verify') + expect(result_context_stages_resolution[:errors]).to include(:"Execute Instant Verify") + expect(result_context_stages_resolution[:exception]).to eq(nil) + expect(result_context_stages_resolution[:success]).to eq(true) + expect(result_context_stages_resolution[:timed_out]).to eq(false) + expect(result_context_stages_resolution[:transaction_id]).to eq('123456') + expect(result_context_stages_resolution[:reference]).to eq('Reference1') + expect(result_context_stages_resolution[:can_pass_with_additional_verification]). + to eq(false) + expect(result_context_stages_resolution[:attributes_requiring_additional_verification]). + to eq([]) + + # result[:context][:stages][:state_id] + expect(result_context_stages_state_id[:vendor_name]).to eq('aamva:state_id') + expect(result_context_stages_state_id[:errors]).to eq({}) + expect(result_context_stages_state_id[:exception]).to eq(nil) + expect(result_context_stages_state_id[:success]).to eq(true) + expect(result_context_stages_state_id[:timed_out]).to eq(false) + expect(result_context_stages_state_id[:transaction_id]).to eq('1234-abcd-efgh') + expect(result_context_stages_state_id[:verified_attributes]).to eq( + %w[address state_id_number state_id_type dob last_name first_name], + ) + + # result[:context][:stages][:threatmetrix] + expect(result_context_stages_threatmetrix[:client]).to eq('lexisnexis') + expect(result_context_stages_threatmetrix[:errors]).to eq({}) + expect(result_context_stages_threatmetrix[:exception]).to eq(nil) + expect(result_context_stages_threatmetrix[:success]).to eq(true) + expect(result_context_stages_threatmetrix[:timed_out]).to eq(false) + expect(result_context_stages_threatmetrix[:transaction_id]).to eq('1234') + expect(result_context_stages_threatmetrix[:review_status]).to eq('pass') + expect(result_context_stages_threatmetrix[:response_body]).to eq( + JSON.parse(LexisNexisFixtures.ddp_success_redacted_response_json, symbolize_names: true), + ) + + proofing_component = user.proofing_component + expect(proofing_component.threatmetrix).to equal(true) + expect(proofing_component.threatmetrix_review_status).to eq('pass') + end + end + context 'without a threatmetrix session ID' do let(:threatmetrix_session_id) { nil } diff --git a/spec/services/idv/steps/in_person/verify_step_spec.rb b/spec/services/idv/steps/in_person/verify_step_spec.rb index e21f8070e92..681da95a1c5 100644 --- a/spec/services/idv/steps/in_person/verify_step_spec.rb +++ b/spec/services/idv/steps/in_person/verify_step_spec.rb @@ -70,6 +70,7 @@ threatmetrix_session_id: nil, user_id: anything, request_ip: request.remote_ip, + double_address_verification: nil, ) step.call diff --git a/spec/services/proofing/aamva/proofing_spec.rb b/spec/services/proofing/aamva/proofer_spec.rb similarity index 100% rename from spec/services/proofing/aamva/proofing_spec.rb rename to spec/services/proofing/aamva/proofer_spec.rb diff --git a/spec/services/proofing/resolution/progessive_proofer_spec.rb b/spec/services/proofing/resolution/progessive_proofer_spec.rb new file mode 100644 index 00000000000..e0e980f6a0d --- /dev/null +++ b/spec/services/proofing/resolution/progessive_proofer_spec.rb @@ -0,0 +1,43 @@ +require 'rails_helper' + +RSpec.describe Proofing::Resolution::ProgressiveProofer do + let(:applicant_pii) { Idp::Constants::MOCK_IDV_APPLICANT_WITH_SSN } + let(:should_proof_state_id) { true } + let(:double_address_verification) { false } + let(:request_ip) { Faker::Internet.ip_v4_address } + let(:threatmetrix_session_id) { SecureRandom.uuid } + let(:timer) { JobHelpers::Timer.new } + let(:user) { create(:user, :signed_up) } + + let(:instance) { described_class.new } + + describe '#proof' do + subject(:proof) do + instance.proof( + applicant_pii: applicant_pii, + double_address_verification: double_address_verification, + request_ip: request_ip, + should_proof_state_id: should_proof_state_id, + threatmetrix_session_id: threatmetrix_session_id, + timer: timer, + user_email: user.confirmed_email_addresses.first.email, + ) + end + + it 'returns a ResultAdjudicator' do + expect(proof).to be_an_instance_of(Proofing::Resolution::ResultAdjudicator) + end + + it 'makes a request to Instant Verify' + + context 'user is not in an AAMVA jurisdiction' do + it 'does not make a request to AAMVA' + end + + context 'Instant Verify passes' do + context 'user is in an AAMVA jurisdiction' do + it 'makes a request to AAMVA' + end + end + end +end diff --git a/spec/services/proofing/resolution_result_adjudicator_spec.rb b/spec/services/proofing/resolution/result_adjudicator_spec.rb similarity index 95% rename from spec/services/proofing/resolution_result_adjudicator_spec.rb rename to spec/services/proofing/resolution/result_adjudicator_spec.rb index 21008d295f6..70c9055dbcc 100644 --- a/spec/services/proofing/resolution_result_adjudicator_spec.rb +++ b/spec/services/proofing/resolution/result_adjudicator_spec.rb @@ -1,11 +1,11 @@ require 'rails_helper' -RSpec.describe Proofing::ResolutionResultAdjudicator do +RSpec.describe Proofing::Resolution::ResultAdjudicator do let(:resolution_success) { true } let(:can_pass_with_additional_verification) { false } let(:attributes_requiring_additional_verification) { [] } let(:resolution_result) do - Proofing::ResolutionResult.new( + Proofing::Resolution::Result.new( success: resolution_success, errors: {}, exception: nil, @@ -28,6 +28,7 @@ end let(:should_proof_state_id) { true } + let(:double_address_verification) { false } let(:device_profiling_success) { true } let(:device_profiling_exception) { nil } @@ -46,6 +47,7 @@ resolution_result: resolution_result, state_id_result: state_id_result, should_proof_state_id: should_proof_state_id, + double_address_verification: double_address_verification, device_profiling_result: device_profiling_result, ) end