Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
Copy link
Copy Markdown
Contributor

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 surName our pattern or Socure's? My brain keeps either reading it as surfName, 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?)

Copy link
Copy Markdown
Contributor Author

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

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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it wasn't clear to me just from the name wrapped what this was wrapping, some ideas:

  1. call it error or wrapped_error
  2. type annotation?
Suggested change
def initialize(wrapped)
# @param [StandardError] wrapped
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,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems weird? Do they actually require a timestamp here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 0.5 is a pass

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They will literally only send either 0.99 or 0.01, I just figured .round would avoid any weirdness with == and floating points

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