-
Notifications
You must be signed in to change notification settings - Fork 166
LG-13706: Socure KYC Proofer #11093
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
LG-13706: Socure KYC Proofer #11093
Changes from all commits
f561963
44fd914
bd9e8b4
dce1b7c
61315d3
332d626
15d27c4
7d2ee0f
e4bf6ef
06f4bf6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| module Proofing | ||
| module Socure | ||
| module IdPlus | ||
| Config = RedactedStruct.new( | ||
| :api_key, | ||
| :base_url, | ||
| :timeout, | ||
| keyword_init: true, | ||
| allowed_members: [ | ||
| :base_url, | ||
| :timeout, | ||
| ], | ||
| ).freeze | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| module Proofing | ||
| module Socure | ||
| module IdPlus | ||
| Input = RedactedStruct.new( | ||
| :address1, | ||
| :address2, | ||
| :city, | ||
| :dob, | ||
| :first_name, | ||
| :last_name, | ||
| :middle_name, | ||
| :state, | ||
| :zipcode, | ||
| :phone, | ||
| :email, | ||
| :ssn, | ||
| keyword_init: true, | ||
| ).freeze | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| module Proofing | ||
| module Socure | ||
| module IdPlus | ||
| class Proofer | ||
| attr_reader :config | ||
|
|
||
| VENDOR_NAME = 'socure_kyc' | ||
|
|
||
| VERIFIED_ATTRIBUTE_MAP = { | ||
| address: %i[streetAddress city state zip].freeze, | ||
| first_name: :firstName, | ||
| last_name: :surName, | ||
| phone: :mobileNumber, | ||
| ssn: :ssn, | ||
| dob: :dob, | ||
| }.freeze | ||
|
|
||
| REQUIRED_ATTRIBUTES = %i[ | ||
| first_name | ||
| last_name | ||
| address | ||
| dob | ||
| ssn | ||
| ].to_set.freeze | ||
|
|
||
| # @param [Proofing::Socure::IdPlus::Config] config | ||
| def initialize(config) | ||
| @config = config | ||
| end | ||
|
|
||
| # @param [Hash] applicant | ||
| # @return [Proofing::Resolution::Result] | ||
| def proof(applicant) | ||
| input = Input.new(applicant) | ||
|
|
||
| request = Request.new(config:, input:) | ||
|
|
||
| response = request.send_request | ||
|
|
||
| build_result_from_response(response) | ||
| rescue Proofing::TimeoutError, RequestError => err | ||
| build_result_from_error(err) | ||
| end | ||
|
|
||
| private | ||
|
|
||
| # @param [Proofing::Socure::IdPlus::Response] response | ||
| def all_required_attributes_verified?(response) | ||
| (REQUIRED_ATTRIBUTES - verified_attributes(response)).empty? | ||
| end | ||
|
|
||
| def build_result_from_error(err) | ||
| Proofing::Resolution::Result.new( | ||
| success: false, | ||
| errors: {}, | ||
| exception: err, | ||
| vendor_name: VENDOR_NAME, | ||
| transaction_id: err.respond_to?(:reference_id) ? err.reference_id : nil, | ||
matthinz marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ) | ||
| end | ||
|
|
||
| # @param [Proofing::Socure::IdPlus::Response] response | ||
| # @return [Proofing::Resolution::Result] | ||
| def build_result_from_response(response) | ||
| Proofing::Resolution::Result.new( | ||
| success: all_required_attributes_verified?(response), | ||
| errors: reason_codes_as_errors(response), | ||
| exception: nil, | ||
| vendor_name: VENDOR_NAME, | ||
| verified_attributes: verified_attributes(response), | ||
| transaction_id: response.reference_id, | ||
| ) | ||
| end | ||
|
|
||
| # @param [Proofing::Socure::IdPlus::Response] response | ||
| # @return [Hash] | ||
| def reason_codes_as_errors(response) | ||
| { | ||
| reason_codes: response.kyc_reason_codes.sort, | ||
| } | ||
| end | ||
|
|
||
| # @param [Proofing::Socure::IdPlus::Response] response | ||
| def verified_attributes(response) | ||
| VERIFIED_ATTRIBUTE_MAP.each_with_object([]) do |(attr_name, field_names), result| | ||
| if Array(field_names).all? { |f| response.kyc_field_validations[f] } | ||
| result << attr_name | ||
| end | ||
| end.to_set | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,152 @@ | ||||||||
| # frozen_string_literal: true | ||||||||
|
|
||||||||
| module Proofing | ||||||||
| module Socure | ||||||||
| module IdPlus | ||||||||
| class RequestError < StandardError | ||||||||
| def initialize(wrapped) | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it wasn't clear to me just from the name
Suggested change
|
||||||||
| @wrapped = wrapped | ||||||||
| super(build_message) | ||||||||
| end | ||||||||
|
|
||||||||
| def reference_id | ||||||||
| return @reference_id if defined?(@reference_id) | ||||||||
| @reference_id = response_body.is_a?(Hash) ? | ||||||||
| response_body['referenceId'] : | ||||||||
| nil | ||||||||
| end | ||||||||
|
|
||||||||
| def response_body | ||||||||
| return @response_body if defined?(@response_body) | ||||||||
| @response_body = wrapped.try(:response_body) | ||||||||
| end | ||||||||
|
|
||||||||
| def response_status | ||||||||
| return @response_status if defined?(@response_status) | ||||||||
| @response_status = wrapped.try(:response_status) | ||||||||
| end | ||||||||
|
|
||||||||
| private | ||||||||
|
|
||||||||
| attr_reader :wrapped | ||||||||
|
|
||||||||
| def build_message | ||||||||
| message = response_body.is_a?(Hash) ? response_body['msg'] : nil | ||||||||
| message ||= wrapped.message | ||||||||
| status = response_status ? " (#{response_status})" : '' | ||||||||
| [message, status].join('') | ||||||||
| end | ||||||||
| end | ||||||||
|
|
||||||||
| class Request | ||||||||
| attr_reader :config, :input | ||||||||
|
|
||||||||
| SERVICE_NAME = 'socure_id_plus' | ||||||||
|
|
||||||||
| # @param [Proofing::Socure::IdPlus::Config] config | ||||||||
| # @param [Proofing::Socure::IdPlus::Input] input | ||||||||
| def initialize(config:, input:) | ||||||||
| @config = config | ||||||||
| @input = input | ||||||||
| end | ||||||||
|
|
||||||||
| def send_request | ||||||||
| conn = Faraday.new do |f| | ||||||||
| f.request :instrumentation, name: 'request_metric.faraday' | ||||||||
| f.response :raise_error | ||||||||
| f.response :json | ||||||||
| f.options.timeout = config.timeout | ||||||||
| f.options.read_timeout = config.timeout | ||||||||
| f.options.open_timeout = config.timeout | ||||||||
| f.options.write_timeout = config.timeout | ||||||||
| end | ||||||||
|
|
||||||||
| Response.new( | ||||||||
| conn.post(url, body, headers) do |req| | ||||||||
| req.options.context = { service_name: SERVICE_NAME } | ||||||||
| end, | ||||||||
| ) | ||||||||
| rescue Faraday::BadRequestError, | ||||||||
| Faraday::ConnectionFailed, | ||||||||
| Faraday::ServerError, | ||||||||
| Faraday::SSLError, | ||||||||
| Faraday::TimeoutError, | ||||||||
| Faraday::UnauthorizedError => e | ||||||||
|
|
||||||||
| if timeout_error?(e) | ||||||||
| raise ::Proofing::TimeoutError, | ||||||||
| 'Timed out waiting for verification response' | ||||||||
| end | ||||||||
|
|
||||||||
| raise RequestError, e | ||||||||
| end | ||||||||
|
|
||||||||
| def body | ||||||||
| @body ||= { | ||||||||
| modules: ['kyc'], | ||||||||
| firstName: input.first_name, | ||||||||
| surName: input.last_name, | ||||||||
| country: 'US', | ||||||||
|
|
||||||||
| physicalAddress: input.address1, | ||||||||
| physicalAddress2: input.address2, | ||||||||
| city: input.city, | ||||||||
| state: input.state, | ||||||||
| zip: input.zipcode, | ||||||||
|
|
||||||||
| nationalId: input.ssn, | ||||||||
| dob: input.dob&.to_date&.to_s, | ||||||||
|
|
||||||||
| userConsent: true, | ||||||||
| consentTimestamp: 5.minutes.ago.iso8601, | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This seems weird? Do they actually require a timestamp here?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. They do, and it needs to be recent enough or they will return an error |
||||||||
|
|
||||||||
| email: input.email, | ||||||||
| mobileNumber: input.phone, | ||||||||
|
|
||||||||
| # > The country or jurisdiction from where the transaction originates, | ||||||||
| # > specified in ISO-2 country codes format | ||||||||
| countryOfOrigin: 'US', | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Idle curiosity: does the transaction "originate" from our servers, or the end user? I.e., we know CBP has a lot of users from Mexico; do we need to try to make this dynamic, or is it where we are?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is where we are--on Socure's end they are using it for compliance stuff (like, "does GDPR apply to this transaction?") |
||||||||
| }.to_json | ||||||||
| end | ||||||||
|
|
||||||||
| def headers | ||||||||
| @headers ||= { | ||||||||
| 'Content-Type' => 'application/json', | ||||||||
| 'Authorization' => "SocureApiKey #{config.api_key}", | ||||||||
| } | ||||||||
| end | ||||||||
|
|
||||||||
| def url | ||||||||
| @url ||= URI.join( | ||||||||
| config.base_url, | ||||||||
| '/api/3.0/EmailAuthScore', | ||||||||
| ).to_s | ||||||||
| end | ||||||||
|
|
||||||||
| private | ||||||||
|
|
||||||||
| # @param [Faraday::Error] err | ||||||||
| def faraday_error_message(err) | ||||||||
| message = begin | ||||||||
| err.response[:body].dig('msg') | ||||||||
| rescue | ||||||||
| 'HTTP request failed' | ||||||||
| end | ||||||||
|
|
||||||||
| status = begin | ||||||||
| err.response[:status] | ||||||||
| rescue | ||||||||
| 'unknown status' | ||||||||
| end | ||||||||
|
|
||||||||
| "#{message} (#{status})" | ||||||||
| end | ||||||||
|
|
||||||||
| def timeout_error?(err) | ||||||||
| err.is_a?(Faraday::TimeoutError) || | ||||||||
| (err.is_a?(Faraday::ConnectionFailed) && err.wrapped_exception.is_a?(Net::OpenTimeout)) | ||||||||
| end | ||||||||
| end | ||||||||
| end | ||||||||
| end | ||||||||
| end | ||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| module Proofing | ||
| module Socure | ||
| module IdPlus | ||
| class Response | ||
| # @param [Faraday::Response] http_response | ||
| def initialize(http_response) | ||
| @http_response = http_response | ||
| end | ||
|
|
||
| # @return [Hash<Symbol,Boolean>] | ||
| def kyc_field_validations | ||
| @kyc_field_validations ||= kyc('fieldValidations'). | ||
| each_with_object({}) do |(field, valid), obj| | ||
| obj[field.to_sym] = valid.round == 1 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. would we ever want this logic to change per-field? or are we set with anything above
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. They will literally only send either |
||
| end.freeze | ||
| end | ||
|
|
||
| # @return [Set<String>] | ||
| def kyc_reason_codes | ||
| @kyc_reason_codes ||= kyc('reasonCodes').to_set.freeze | ||
| end | ||
|
|
||
| def reference_id | ||
| http_response.body['referenceId'] | ||
| end | ||
|
|
||
| private | ||
|
|
||
| attr_reader :http_response | ||
|
|
||
| def kyc(*fields) | ||
| kyc_object = http_response.body['kyc'] | ||
| raise 'No kyc section on response' unless kyc_object | ||
| kyc_object.dig(*fields) | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| require 'rails_helper' | ||
|
|
||
| RSpec.describe Proofing::Socure::IdPlus::Input do | ||
| let(:user) { build(:user) } | ||
|
|
||
| let(:state_id) do | ||
| Idp::Constants::MOCK_IDV_APPLICANT_WITH_PHONE | ||
| end | ||
|
|
||
| subject do | ||
| described_class.new( | ||
| **state_id.to_h.slice(*described_class.members), | ||
| email: user.email, | ||
| ) | ||
| end | ||
|
|
||
| it 'creates an appropriate instance' do | ||
| expect(subject.to_h).to eql( | ||
| { | ||
| address1: '1 FAKE RD', | ||
| address2: nil, | ||
| city: 'GREAT FALLS', | ||
| state: 'MT', | ||
| zipcode: '59010-1234', | ||
|
|
||
| first_name: 'FAKEY', | ||
| last_name: 'MCFAKERSON', | ||
| middle_name: nil, | ||
|
|
||
| dob: '1938-10-06', | ||
|
|
||
| phone: '12025551212', | ||
| ssn: '900-66-1234', | ||
| email: user.email, | ||
matthinz marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }, | ||
| ) | ||
| end | ||
| end | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is offtopic and unimportant, but is camelcase
surNameour pattern or Socure's? My brain keeps either reading it assurfName, or as two words (since camelCase) and wondering what a sur name is, like it's a foreign word. (I mean, it is, but who knows what a "south name" is?)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is Socure's field naming. My thinking is that request/response class speak in terms of Socure's API and their naming, and the proofer class translates that to our conventions / case style