Skip to content

Commit

Permalink
LG-13706: Socure KYC Proofer (#11093)
Browse files Browse the repository at this point in the history
* 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
matthinz authored Aug 21, 2024
1 parent 25c3436 commit e863fe9
Show file tree
Hide file tree
Showing 10 changed files with 995 additions and 1 deletion.
4 changes: 3 additions & 1 deletion app/services/proofing/resolution/result.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ def initialize(
reference: '',
failed_result_can_pass_with_additional_verification: false,
attributes_requiring_additional_verification: [],
vendor_workflow: nil
vendor_workflow: nil,
verified_attributes: nil
)
@success = success
@errors = errors
Expand All @@ -35,6 +36,7 @@ def initialize(
@attributes_requiring_additional_verification =
attributes_requiring_additional_verification
@vendor_workflow = vendor_workflow
@verified_attributes = verified_attributes
end

def success?
Expand Down
18 changes: 18 additions & 0 deletions app/services/proofing/socure/id_plus/config.rb
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
23 changes: 23 additions & 0 deletions app/services/proofing/socure/id_plus/input.rb
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
96 changes: 96 additions & 0 deletions app/services/proofing/socure/id_plus/proofer.rb
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
152 changes: 152 additions & 0 deletions app/services/proofing/socure/id_plus/request.rb
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
41 changes: 41 additions & 0 deletions app/services/proofing/socure/id_plus/response.rb
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
38 changes: 38 additions & 0 deletions spec/services/proofing/socure/id_plus/input_spec.rb
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
Loading

0 comments on commit e863fe9

Please sign in to comment.