-
Notifications
You must be signed in to change notification settings - Fork 112
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
LG-13706: Socure KYC Proofer (#11093)
* Socure KYC Proofer - Basic Proofer implementation for Socure KYC. - Request / Response classes, error handling, etc. changelog: Upcoming Features, Identity verification, Implement proofer for Socure KYC * Updates after manual integration test - Lint fixes - Move temporary consent timestamp back a little bit * In ruby we just say @return * Array.wrap -> Array * Clean up accessors with .try * Remove pointless begin / end * Use named subject in request spec * Remove empty lines * Improve test names "it does the thing" is not good enough
- Loading branch information
Showing
10 changed files
with
995 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
) | ||
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
@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, | ||
|
||
email: input.email, | ||
mobileNumber: input.phone, | ||
|
||
# > The country or jurisdiction from where the transaction originates, | ||
# > specified in ISO-2 country codes format | ||
countryOfOrigin: 'US', | ||
}.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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}, | ||
) | ||
end | ||
end |
Oops, something went wrong.