instant_quote 1.0.5
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +12 -0
- data/.rspec +3 -0
- data/.rubocop.yml +17 -0
- data/.ruby-version +1 -0
- data/.travis.yml +7 -0
- data/Gemfile +11 -0
- data/Gemfile.lock +110 -0
- data/README.md +31 -0
- data/Rakefile +8 -0
- data/bin/console +30 -0
- data/bin/setup +9 -0
- data/instant_quote.gemspec +41 -0
- data/lib/instant_quote/adapter.rb +43 -0
- data/lib/instant_quote/adapter_finder.rb +75 -0
- data/lib/instant_quote/adapters/capital_on_tap.rb +80 -0
- data/lib/instant_quote/adapters/iwoca.rb +104 -0
- data/lib/instant_quote/api_error.rb +18 -0
- data/lib/instant_quote/connection_translator.rb +20 -0
- data/lib/instant_quote/connection_translators/capital_on_tap.rb +79 -0
- data/lib/instant_quote/connection_translators/iwoca.rb +151 -0
- data/lib/instant_quote/decision_parser.rb +44 -0
- data/lib/instant_quote/decision_parsers/capital_on_tap.rb +65 -0
- data/lib/instant_quote/decision_parsers/iwoca.rb +112 -0
- data/lib/instant_quote/version.rb +5 -0
- data/lib/instant_quote/webhooks/iwoca.rb +77 -0
- data/lib/instant_quote.rb +37 -0
- metadata +223 -0
@@ -0,0 +1,104 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'iwoca'
|
4
|
+
require 'instant_quote/decision_parsers/iwoca'
|
5
|
+
|
6
|
+
module InstantQuote
|
7
|
+
module Adapters
|
8
|
+
class Iwoca < Adapter
|
9
|
+
additional_fields [
|
10
|
+
{
|
11
|
+
name: 'last_full_year_turnover',
|
12
|
+
label: 'Last full year turnover',
|
13
|
+
required: true,
|
14
|
+
type: 'money'
|
15
|
+
},
|
16
|
+
{
|
17
|
+
name: 'applicant_date_of_birth',
|
18
|
+
label: 'Applicant Date of Birth',
|
19
|
+
required: true,
|
20
|
+
type: 'date'
|
21
|
+
},
|
22
|
+
{
|
23
|
+
name: 'applicant_house_number',
|
24
|
+
label: 'Applicant house number',
|
25
|
+
required: true
|
26
|
+
},
|
27
|
+
{
|
28
|
+
name: 'applicant_address_line_1',
|
29
|
+
label: 'Applicant home address',
|
30
|
+
required: true
|
31
|
+
},
|
32
|
+
{
|
33
|
+
name: 'applicant_town',
|
34
|
+
label: 'Town',
|
35
|
+
required: true
|
36
|
+
},
|
37
|
+
{
|
38
|
+
name: 'applicant_postcode',
|
39
|
+
label: 'Postcode',
|
40
|
+
required: true
|
41
|
+
},
|
42
|
+
{
|
43
|
+
name: 'applicant_country',
|
44
|
+
label: 'Country',
|
45
|
+
type: 'country'
|
46
|
+
},
|
47
|
+
{
|
48
|
+
name: 'applicant_phone_number',
|
49
|
+
label: 'Applicant mobile number',
|
50
|
+
required: true
|
51
|
+
},
|
52
|
+
{
|
53
|
+
name: 'applicant_consent',
|
54
|
+
label: "I agree to iwoca running soft credit checks on my personal information with \
|
55
|
+
credit reference agencies. <b>This will not affect my credit score</b> - I understand \
|
56
|
+
that iwoca will do a full credit search if I take funding. I also agree to iwoca’s \
|
57
|
+
<a href='https://www.iwoca.co.uk/terms-of-use/' target='_blank'> \
|
58
|
+
Terms and Conditions</a> and \
|
59
|
+
<a href='https://www.iwoca.co.uk/privacy-policy/' target='_blank'>Privacy Policy</a>.",
|
60
|
+
required: true,
|
61
|
+
type: 'checkbox'
|
62
|
+
}
|
63
|
+
]
|
64
|
+
|
65
|
+
# Creates the iwoca customer and returns the state key from the response.
|
66
|
+
def get_quote(params)
|
67
|
+
response = ::Iwoca::Quote.create_customer(params)
|
68
|
+
|
69
|
+
return response.data[:state_key] if response.success?
|
70
|
+
|
71
|
+
raise_error(response)
|
72
|
+
end
|
73
|
+
|
74
|
+
# Get the current status for the created customer.
|
75
|
+
def get_status(state_key)
|
76
|
+
response = ::Iwoca::Quote.credit_facility_status(state_key)
|
77
|
+
|
78
|
+
return response.data if response.success?
|
79
|
+
|
80
|
+
raise_error(response)
|
81
|
+
end
|
82
|
+
|
83
|
+
# Gets the URL for the given state key.
|
84
|
+
def get_link(state_key)
|
85
|
+
response = ::Iwoca::Quote.login_link(state_key)
|
86
|
+
|
87
|
+
return response.data[:login_link] if response.success?
|
88
|
+
|
89
|
+
raise_error(response)
|
90
|
+
end
|
91
|
+
|
92
|
+
def get_approval(state_key)
|
93
|
+
response = ::Iwoca::Quote.approval(state_key)
|
94
|
+
response.success?
|
95
|
+
end
|
96
|
+
|
97
|
+
private
|
98
|
+
|
99
|
+
def raise_error(response)
|
100
|
+
raise ApiError.new(data: response.data, error: response.errors.first[:detail])
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module InstantQuote
|
4
|
+
class ApiError < StandardError
|
5
|
+
attr_accessor :error, :data
|
6
|
+
|
7
|
+
alias message error
|
8
|
+
alias to_s error
|
9
|
+
|
10
|
+
def initialize(params = {})
|
11
|
+
@error = params[:error]
|
12
|
+
@data = params[:data] || {}
|
13
|
+
|
14
|
+
# Delete the metaData key, because it's irrelevant and has long strings
|
15
|
+
@data.delete(:metaData) if @data.key?(:metaData)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module InstantQuote
|
4
|
+
class ConnectionTranslator
|
5
|
+
attr_accessor :application, :extra_info
|
6
|
+
|
7
|
+
def self.translate(connection)
|
8
|
+
new(connection).translate
|
9
|
+
end
|
10
|
+
|
11
|
+
def initialize(connection)
|
12
|
+
@application = connection.finance_application
|
13
|
+
@extra_info = connection.extra_info
|
14
|
+
end
|
15
|
+
|
16
|
+
def translate
|
17
|
+
raise NotImplementedError, "You must implement '#{self.class.name}#translate'"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module InstantQuote
|
4
|
+
module ConnectionTranslators
|
5
|
+
class CapitalOnTap < ConnectionTranslator
|
6
|
+
DEFAULT_SALUTATION = 'Mr'
|
7
|
+
|
8
|
+
# rubocop:disable Metrics/AbcSize
|
9
|
+
def translate
|
10
|
+
{
|
11
|
+
Salutation: app_user.salutation || DEFAULT_SALUTATION,
|
12
|
+
FirstName: app_user.first_name,
|
13
|
+
LastName: app_user.last_name,
|
14
|
+
DateOfBirth: extra_info['date_of_birth'],
|
15
|
+
MobilePhone: extra_info['applicant_phone_number'],
|
16
|
+
EmailAddress: app_user.email,
|
17
|
+
PersonalAddress: translate_address,
|
18
|
+
TradingName: app_org.name,
|
19
|
+
BusinessLegalName: app_org.name,
|
20
|
+
BusinessLandline: extra_info['business_landline'],
|
21
|
+
YearsTrading: app_org.years_in_business,
|
22
|
+
MonthlyTurnOver: translate_turnover,
|
23
|
+
BusinessType: translate_business_type,
|
24
|
+
BusinessAddress: translate_address,
|
25
|
+
RegistrationNumber: app_org.company_number
|
26
|
+
}
|
27
|
+
end
|
28
|
+
# rubocop:enable Metrics/AbcSize
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def app_user
|
33
|
+
application.primary_user
|
34
|
+
end
|
35
|
+
|
36
|
+
def app_org
|
37
|
+
application.borrower_organisation
|
38
|
+
end
|
39
|
+
|
40
|
+
def translate_address
|
41
|
+
addr = app_org.address
|
42
|
+
|
43
|
+
return '' unless addr.present?
|
44
|
+
|
45
|
+
street_parts = [addr.line_1, addr.line_2, addr.line_3, addr.line_4]
|
46
|
+
|
47
|
+
{
|
48
|
+
CountryCode: 'UK',
|
49
|
+
Street: street_parts.compact.reject(&:empty?).join(', '),
|
50
|
+
PostCode: addr.postcode,
|
51
|
+
City: addr.town
|
52
|
+
}
|
53
|
+
end
|
54
|
+
|
55
|
+
# Since in Finpoint the user selects an intervel (i.e. "500,000 - 999,999"). We get the avg
|
56
|
+
# on this interval and use that number as the monthly turnover.
|
57
|
+
def translate_turnover
|
58
|
+
turnover = app_org.turnover_range
|
59
|
+
|
60
|
+
((turnover.from + turnover.to) / 2 / 100).ceil
|
61
|
+
end
|
62
|
+
|
63
|
+
def translate_business_type
|
64
|
+
name = app_org.company_type.name
|
65
|
+
|
66
|
+
case name
|
67
|
+
when /Sole/
|
68
|
+
'SoleTrader'
|
69
|
+
when /(LLP)/
|
70
|
+
'LimitedLiabilityPartnership'
|
71
|
+
when /(LTD)/
|
72
|
+
'LimitedCompany'
|
73
|
+
else
|
74
|
+
'Partnership'
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,151 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module InstantQuote
|
4
|
+
module ConnectionTranslators
|
5
|
+
class Iwoca < ConnectionTranslator
|
6
|
+
def translate
|
7
|
+
{
|
8
|
+
data: {
|
9
|
+
application: {
|
10
|
+
company: company_information,
|
11
|
+
people: [person_information],
|
12
|
+
requested_products: {
|
13
|
+
credit_facility: {
|
14
|
+
approval: {
|
15
|
+
amount: (application.amount_pennies / 100),
|
16
|
+
duration: 12,
|
17
|
+
detailed_purpose: application&.proceeds_purpose&.name
|
18
|
+
}
|
19
|
+
}
|
20
|
+
}
|
21
|
+
}
|
22
|
+
}
|
23
|
+
}
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
# rubocop:disable Metrics/AbcSize
|
29
|
+
def company_information
|
30
|
+
information = {
|
31
|
+
registered_company_name: company.name,
|
32
|
+
industry: company.industry.name,
|
33
|
+
company_number: company.company_number.to_s,
|
34
|
+
type: company_type_translated,
|
35
|
+
trading_from_date: company.business_starting_date,
|
36
|
+
registered_address: company_address,
|
37
|
+
vat_status: {
|
38
|
+
is_vat_registered: company.vat_registered
|
39
|
+
}
|
40
|
+
}
|
41
|
+
|
42
|
+
if company_type_translated == 'sole_trader'
|
43
|
+
information.merge(
|
44
|
+
last_12_months_profit: {
|
45
|
+
amount: extra_info['last_full_year_turnover'].to_f
|
46
|
+
}
|
47
|
+
)
|
48
|
+
else
|
49
|
+
information.merge(
|
50
|
+
last_12_months_turnover: {
|
51
|
+
amount: extra_info['last_full_year_turnover'].to_f
|
52
|
+
}
|
53
|
+
)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
# rubocop:enable Metrics/AbcSize
|
57
|
+
|
58
|
+
def company_type_translated
|
59
|
+
case company.company_type.name
|
60
|
+
when 'Limited Company (LTD)'
|
61
|
+
'limited_liability_company'
|
62
|
+
when 'Limited Partnership and Limited Liability Partnership (LLP)'
|
63
|
+
'limited_liability_partnership'
|
64
|
+
when 'Public Limited Company (PLC)'
|
65
|
+
'public_limited_company'
|
66
|
+
when 'Sole Trader'
|
67
|
+
'sole_trader'
|
68
|
+
else
|
69
|
+
'other'
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def company
|
74
|
+
application.borrower_organisation
|
75
|
+
end
|
76
|
+
|
77
|
+
# rubocop:disable Metrics/AbcSize
|
78
|
+
def company_address
|
79
|
+
{
|
80
|
+
postcode: company.address.postcode,
|
81
|
+
street_line_1: company.address.line_1,
|
82
|
+
street_line_2: company.address.line_2 || '',
|
83
|
+
town: company.address.town,
|
84
|
+
country: company.address.country
|
85
|
+
}
|
86
|
+
end
|
87
|
+
# rubocop:enable Metrics/AbcSize
|
88
|
+
|
89
|
+
def person_information
|
90
|
+
{
|
91
|
+
uid: SecureRandom.uuid,
|
92
|
+
first_name: application.primary_user.first_name,
|
93
|
+
last_name: application.primary_user.last_name,
|
94
|
+
date_of_birth: extra_info['applicant_date_of_birth'],
|
95
|
+
roles: %w[applicant shareholder guarantor director],
|
96
|
+
phones: [person_phone],
|
97
|
+
emails: [person_email],
|
98
|
+
residential_addresses: [person_residential_address],
|
99
|
+
privacy_policy: {
|
100
|
+
agreed: true,
|
101
|
+
datetime: DateTime.current
|
102
|
+
}
|
103
|
+
}
|
104
|
+
end
|
105
|
+
|
106
|
+
def person_phone
|
107
|
+
{
|
108
|
+
uid: SecureRandom.uuid,
|
109
|
+
number: extra_info['applicant_phone_number'],
|
110
|
+
type: 'primary'
|
111
|
+
}
|
112
|
+
end
|
113
|
+
|
114
|
+
def person_email
|
115
|
+
{
|
116
|
+
uid: SecureRandom.uuid,
|
117
|
+
email: application&.primary_user&.email,
|
118
|
+
type: 'primary'
|
119
|
+
}
|
120
|
+
end
|
121
|
+
|
122
|
+
def person_residential_address
|
123
|
+
{
|
124
|
+
uid: SecureRandom.uuid,
|
125
|
+
town: extra_info['applicant_town'],
|
126
|
+
street_line_1: extra_info['applicant_address_line_1'],
|
127
|
+
street_line_2: extra_info['applicant_address_line_2'] || '',
|
128
|
+
country: extra_info['applicant_country'],
|
129
|
+
postcode: extra_info['applicant_postcode'],
|
130
|
+
house_number: extra_info['applicant_house_number'],
|
131
|
+
residential_status: home_owning_translated
|
132
|
+
}
|
133
|
+
end
|
134
|
+
|
135
|
+
def home_owning_translated
|
136
|
+
case company.directors_home_owning.name
|
137
|
+
when 'Home owner without mortgage'
|
138
|
+
'owner_no_mortgage'
|
139
|
+
when 'Tenant'
|
140
|
+
'tenant'
|
141
|
+
when 'Home owner with mortgage'
|
142
|
+
'owner_with_mortgage'
|
143
|
+
when 'Living rent free with family'
|
144
|
+
'rent_free'
|
145
|
+
else
|
146
|
+
''
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/core_ext/hash/indifferent_access'
|
4
|
+
|
5
|
+
module InstantQuote
|
6
|
+
# This is the class responsible for parsing the decision that is provided by every different
|
7
|
+
# provider. You should inherit from this class in every specific parser (for the different
|
8
|
+
# providers).
|
9
|
+
class DecisionParser
|
10
|
+
attr_reader :data
|
11
|
+
|
12
|
+
def initialize(data = {})
|
13
|
+
@data = ActiveSupport::HashWithIndifferentAccess.new(data)
|
14
|
+
end
|
15
|
+
|
16
|
+
def pending?
|
17
|
+
false
|
18
|
+
end
|
19
|
+
|
20
|
+
def approved?
|
21
|
+
false
|
22
|
+
end
|
23
|
+
|
24
|
+
def declined?
|
25
|
+
false
|
26
|
+
end
|
27
|
+
|
28
|
+
def manual_review?
|
29
|
+
false
|
30
|
+
end
|
31
|
+
|
32
|
+
def no_decision_possible?
|
33
|
+
false
|
34
|
+
end
|
35
|
+
|
36
|
+
def loan_started?
|
37
|
+
false
|
38
|
+
end
|
39
|
+
|
40
|
+
def status
|
41
|
+
raise NotImplementedError, "You must implement '#{self.class}.status'"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'money'
|
4
|
+
|
5
|
+
module InstantQuote
|
6
|
+
module DecisionParsers
|
7
|
+
class CapitalOnTap < DecisionParser
|
8
|
+
STATUSES = {
|
9
|
+
pending: 'Pending',
|
10
|
+
approved: 'Approved',
|
11
|
+
declined: 'Declined',
|
12
|
+
could_not_submit: 'CouldNotSubmit' # this is our own status
|
13
|
+
}.freeze
|
14
|
+
|
15
|
+
def initialize(data = {})
|
16
|
+
@data = super
|
17
|
+
@data.delete(:metaData) # remove useless information
|
18
|
+
end
|
19
|
+
|
20
|
+
def pending?
|
21
|
+
status == STATUSES[:pending]
|
22
|
+
end
|
23
|
+
|
24
|
+
def approved?
|
25
|
+
status == STATUSES[:approved]
|
26
|
+
end
|
27
|
+
|
28
|
+
def declined?
|
29
|
+
status == STATUSES[:declined]
|
30
|
+
end
|
31
|
+
|
32
|
+
def amount
|
33
|
+
Money.new(credit_decision[:approvalAmount].to_f * 1000).format
|
34
|
+
end
|
35
|
+
|
36
|
+
def monthly_interest_rate
|
37
|
+
credit_decision[:monthlyInterestRate] || 0.0
|
38
|
+
end
|
39
|
+
|
40
|
+
def monthly_card_interest_rate
|
41
|
+
credit_decision[:monthlyCardInterestRate] || 0.0
|
42
|
+
end
|
43
|
+
|
44
|
+
def status
|
45
|
+
credit_decision.dig(:status) || STATUSES[:could_not_submit]
|
46
|
+
end
|
47
|
+
|
48
|
+
# The applicationStage can be one of the following:
|
49
|
+
#
|
50
|
+
# Undetermined, AppSubmitted, Referred, KybSoleTrader, KybPartner, IncompleteInfo,
|
51
|
+
# FinancialsOverdue, Declined, Duplicate, IdVerificationRequired, ReferredAfterUpdate,
|
52
|
+
# ApprovedCredit, ApprovedPrepaid, DdSetup, AddOnSelection, DoneHavFailed,
|
53
|
+
# DoneIdVerificationRequired, Done, Portal
|
54
|
+
def stage
|
55
|
+
data.dig(:applicationStage) || {}
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
def credit_decision
|
61
|
+
data.dig(:creditDecision) || {}
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|