kount-ris 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +58 -0
- data/Rakefile +6 -0
- data/app/models/kount.rb +28 -0
- data/app/models/kount/configuration.rb +13 -0
- data/app/models/kount/khash.rb +44 -0
- data/app/models/kount/ris.rb +5 -0
- data/app/models/kount/ris/address.rb +31 -0
- data/app/models/kount/ris/base.rb +17 -0
- data/app/models/kount/ris/device.rb +122 -0
- data/app/models/kount/ris/inquiry.rb +440 -0
- data/app/models/kount/ris/location.rb +28 -0
- data/app/models/kount/ris/persona.rb +26 -0
- data/app/models/kount/ris/product.rb +30 -0
- data/app/models/kount/ris/response.rb +389 -0
- data/app/models/kount/ris/trigger/counter.rb +19 -0
- data/app/models/kount/ris/trigger/error.rb +17 -0
- data/app/models/kount/ris/trigger/rule.rb +19 -0
- data/app/models/kount/ris/trigger/warning.rb +17 -0
- data/config/initializers/configuration.rb +39 -0
- data/lib/kount/ris.rb +10 -0
- data/lib/kount/ris/engine.rb +21 -0
- data/lib/kount/ris/version.rb +5 -0
- metadata +107 -0
@@ -0,0 +1,28 @@
|
|
1
|
+
module Kount
|
2
|
+
module RIS
|
3
|
+
class Location < Base
|
4
|
+
attr_accessor :ip_address, :country_code, :latitude, :longitude, :city, :region, :owner
|
5
|
+
|
6
|
+
validates :ip_address, length: { maximum: 16 }
|
7
|
+
validates :country_code, length: { maximum: 16 }
|
8
|
+
validates :latitude, length: { maximum: 16 }
|
9
|
+
validates :longitude, length: { maximum: 16 }
|
10
|
+
validates :city, length: { maximum: 255 }
|
11
|
+
validates :region, length: { maximum: 255 }
|
12
|
+
validates :owner, length: { maximum: 64 } # owner of IP address or address block
|
13
|
+
|
14
|
+
def to_h
|
15
|
+
{
|
16
|
+
ip_address: ip_address,
|
17
|
+
country_code: country_code,
|
18
|
+
latitude: latitude,
|
19
|
+
longitude: longitude,
|
20
|
+
city: city,
|
21
|
+
region: region,
|
22
|
+
owner: owner,
|
23
|
+
}
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module Kount
|
2
|
+
module RIS
|
3
|
+
class Persona < Base
|
4
|
+
attr_accessor :card_count, :device_count, :email_count, :country_code, :order_count, :active_order_count
|
5
|
+
|
6
|
+
validates :card_count, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true # number of cards kount has associated to persona
|
7
|
+
validates :device_count, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true # number of devices kount has associated to persona
|
8
|
+
validates :email_count, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true # number of emails associated to persona
|
9
|
+
validates :country_code, length: { maximum: 2 } # contry related to the persona
|
10
|
+
validates :order_count, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true # number of orders from persona
|
11
|
+
validates :active_order_count, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true # count orders from persona within the most active 6 hour window in last 14 days
|
12
|
+
|
13
|
+
def to_h
|
14
|
+
{
|
15
|
+
card_count: card_count,
|
16
|
+
device_count: device_count,
|
17
|
+
email_count: email_count,
|
18
|
+
country_code: country_code,
|
19
|
+
order_count: order_count,
|
20
|
+
active_order_count: active_order_count,
|
21
|
+
}
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Kount
|
2
|
+
module RIS
|
3
|
+
class Product < Base
|
4
|
+
attr_accessor :description, :id, :price, :quantity, :category
|
5
|
+
|
6
|
+
validates :description, presence: true, length: { maximum: 256 } # product description
|
7
|
+
validates :id, presence: true, length: { maximum: 256 } # product identifier or SKU
|
8
|
+
validates :price, presence: true, numericality: { greater_than_or_equal_to: 0 } # product price
|
9
|
+
validates :quantity, presence: true, numericality: { greater_than: 0 } # quantity being purchased
|
10
|
+
validates :category, presence: true, length: { maximum: 256 } # the product category
|
11
|
+
|
12
|
+
def initialize(attrs)
|
13
|
+
attrs.symbolize_keys!
|
14
|
+
attrs.reverse_merge!({ quantity: 1 })
|
15
|
+
super(attrs)
|
16
|
+
end
|
17
|
+
|
18
|
+
def to_h
|
19
|
+
{
|
20
|
+
description: description,
|
21
|
+
id: id,
|
22
|
+
price: price,
|
23
|
+
quantity: quantity,
|
24
|
+
category: category,
|
25
|
+
}
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
@@ -0,0 +1,389 @@
|
|
1
|
+
module Kount
|
2
|
+
module RIS
|
3
|
+
class Response < Base
|
4
|
+
attr_accessor :score, :reason_text, :merchant_id, :mode, :order_number, :session_id, :site_id,
|
5
|
+
:api_version, :kount_id, :counters, :rules, :warnings, :errors, :persona, :device, :proxy_location,
|
6
|
+
:pierced_location, :error_code
|
7
|
+
|
8
|
+
DECISIONS = {
|
9
|
+
'a' => 'Approve',
|
10
|
+
'd' => 'Decline',
|
11
|
+
'r' => 'Review',
|
12
|
+
'e' => 'Esclate',
|
13
|
+
}.freeze
|
14
|
+
CARD_TYPES = {
|
15
|
+
'disc' => 'Discover',
|
16
|
+
'visa' => 'Visa',
|
17
|
+
# # FILL
|
18
|
+
}.freeze
|
19
|
+
REASON_CODES = {
|
20
|
+
'amnt' => 'Amount',
|
21
|
+
'geox' => 'Persona related country with highest probability of fraud',
|
22
|
+
'list' => 'VIP List (EMail, Card, Address, Gift Card, Device ID)',
|
23
|
+
'netw' => 'Network Type',
|
24
|
+
'scor' => 'Score',
|
25
|
+
'velo' => 'Sales velocity in a fourteen day time frame',
|
26
|
+
'vmax' => 'Sales velocity in a six hour time frame',
|
27
|
+
}.freeze
|
28
|
+
STATUSES = {
|
29
|
+
'a' => 'Approve',
|
30
|
+
'd' => 'Decline',
|
31
|
+
'r' => 'Review',
|
32
|
+
'e' => 'Esclate',
|
33
|
+
'c' => 'Original transaction was approved but due to dynamic scoring the transaction now has an elevated score and may require reevaluation',
|
34
|
+
'x' => 'Transaction was flagged for review but never acted upon and has timed out',
|
35
|
+
'y' => 'Original transaction was approved but updated with AUTH=D and then timed out',
|
36
|
+
}.freeze
|
37
|
+
|
38
|
+
class << self
|
39
|
+
def decisions
|
40
|
+
DECISIONS
|
41
|
+
end
|
42
|
+
|
43
|
+
def card_types
|
44
|
+
CARD_TYPES
|
45
|
+
end
|
46
|
+
|
47
|
+
def reason_codes
|
48
|
+
REASON_CODES
|
49
|
+
end
|
50
|
+
|
51
|
+
def statuses
|
52
|
+
STATUSES
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def decisions
|
57
|
+
self.class.decisions
|
58
|
+
end
|
59
|
+
|
60
|
+
def card_types
|
61
|
+
self.class.card_types
|
62
|
+
end
|
63
|
+
|
64
|
+
def reason_codes
|
65
|
+
self.class.reason_codes
|
66
|
+
end
|
67
|
+
|
68
|
+
def statuses
|
69
|
+
self.class.statuses
|
70
|
+
end
|
71
|
+
|
72
|
+
def decision=(value)
|
73
|
+
return @decision = nil if value.nil?
|
74
|
+
|
75
|
+
|
76
|
+
decision_value = decisions.detect { |k,v| k if v.downcase == value.to_s.downcase }&.first || value.to_s.downcase
|
77
|
+
raise ArgumentError, "'#{value}' is not a valid decision" unless decisions.include?(decision_value)
|
78
|
+
@decision = decision_value
|
79
|
+
end
|
80
|
+
|
81
|
+
def decision
|
82
|
+
decisions[@decision]
|
83
|
+
end
|
84
|
+
|
85
|
+
def card_type=(value)
|
86
|
+
return @card_type = nil if value.nil?
|
87
|
+
|
88
|
+
card_type_value = card_types.detect { |k,v| k if v.downcase == value.to_s.downcase }&.first || value.to_s.downcase
|
89
|
+
raise ArgumentError, "'#{value}' is not a valid card type" unless card_types.include?(card_type_value)
|
90
|
+
@card_type = card_type_value
|
91
|
+
end
|
92
|
+
|
93
|
+
def card_type
|
94
|
+
card_types[@card_type]
|
95
|
+
end
|
96
|
+
|
97
|
+
def reason_code=(value)
|
98
|
+
return @reason_code = nil if value.nil?
|
99
|
+
|
100
|
+
reason_code_value = reason_codes.detect { |k,v| k if v.downcase == value.to_s.downcase }&.first || value.to_s.downcase
|
101
|
+
raise ArgumentError, "'#{value}' is not a valid reason code" unless reason_codes.include?(reason_code_value)
|
102
|
+
@reason_code = reason_code_value
|
103
|
+
end
|
104
|
+
|
105
|
+
def reason_code
|
106
|
+
reason_codes[@reason_code]
|
107
|
+
end
|
108
|
+
|
109
|
+
def status=(value)
|
110
|
+
return @status = nil if value.nil?
|
111
|
+
|
112
|
+
status_value = statuses.detect { |k,v| k if v.downcase == value.to_s.downcase }&.first || value.to_s.downcase
|
113
|
+
raise ArgumentError, "'#{value}' is not a valid status" unless statuses.include?(status_value)
|
114
|
+
@status = status_value
|
115
|
+
end
|
116
|
+
|
117
|
+
def status
|
118
|
+
statuses[@status]
|
119
|
+
end
|
120
|
+
|
121
|
+
validates :score, numericality: { in: 0..100 } # score 0-99 ?
|
122
|
+
validates :reason_text, length: { maximum: 16 } # reason for score, custom text
|
123
|
+
validates :merchant_id, length: { maximum: 6 } # (matches inquiry)
|
124
|
+
validates :mode, length: { maximum: 1 } # (matches inquiry)
|
125
|
+
validates :order_number, length: { maximum: 32 } # transaction order number
|
126
|
+
validates :session_id, length: { maximum: 32 } # (matches inquiry)
|
127
|
+
validates :site_id, length: { maximum: 8 } # (matches inquiry)
|
128
|
+
validates :api_version, length: { maximum: 4 } # (matches inquiry)
|
129
|
+
validates :kount_id, length: { maximum: 12 } # Kount transaction ID number
|
130
|
+
validates :error_code, length: { is: 3 }, allow_nil: true # error code, also in errors
|
131
|
+
|
132
|
+
validates :decision, inclusion: { in: decisions.values }, allow_nil: true # decision from Count for transaction
|
133
|
+
validates :card_type, inclusion: { in: card_types.values }, allow_nil: true # credit card type
|
134
|
+
validates :reason_code, inclusion: { in: reason_codes.values }, allow_nil: true # reason for score, if 'None' use #reason_text
|
135
|
+
validates :status, inclusion: { in: statuses.values }, allow_nil: true # extended decision details -> STAT
|
136
|
+
|
137
|
+
validate :counters_are_valid # array of Trigger::Counter objects -> {count} COUNTERS_TRIGGERED, COUNTER_NAME_X, COUNTER_VALUE_X
|
138
|
+
validate :rules_are_valid # array of Trigger::Rule objects -> {count} RULES_TRIGGERED / RULE_DESCRIPTION_X / RULE_ID_X
|
139
|
+
validate :warnings_are_valid # array of Trigger::Warning objects -> {count} WARNING_COUNT / WARNING_N
|
140
|
+
validate :errors_are_valid # array of Trigger::Error objects -> {count} ERROR_COUNT / ERROR_N
|
141
|
+
validate :persona_is_valid # Persona object
|
142
|
+
validate :device_is_valid # Device object
|
143
|
+
validate :proxy_location_is_valid # Location object -> {ALSO} PROXY derived from #proxy_location
|
144
|
+
validate :pierced_location_is_valid # Location object
|
145
|
+
|
146
|
+
def initialize(attrs = {})
|
147
|
+
self.score = attrs['SCOR'].to_i if attrs.include?('SCOR')
|
148
|
+
self.reason_text = attrs['REASON_CODE']
|
149
|
+
self.merchant_id = attrs['MERC']
|
150
|
+
self.mode = attrs['MODE']
|
151
|
+
self.order_number = attrs['ORDER']
|
152
|
+
self.session_id = attrs['SESS']
|
153
|
+
self.site_id = attrs['SITE']
|
154
|
+
self.api_version = attrs['VERS']
|
155
|
+
self.kount_id = attrs['TRAN']
|
156
|
+
self.error_code = attrs['ERRO']
|
157
|
+
|
158
|
+
self.decision = attrs['AUTO']
|
159
|
+
self.card_type = attrs['BRND']
|
160
|
+
self.reason_code = attrs['REAS']
|
161
|
+
self.status = attrs['STAT']
|
162
|
+
|
163
|
+
device_attributes = {
|
164
|
+
browser: attrs['BROWSER'],
|
165
|
+
cookies_enabled: ((attrs['COOKIES'].downcase == 'y' ? true : false) if attrs.include?('COOKIES')),
|
166
|
+
first_seen_at: ((Date.strptime(attrs['DDFS'], '%Y-%m-%d') rescue nil) if attrs.include?('DDFS')),
|
167
|
+
layers: attrs['DEVICE_LAYERS'],
|
168
|
+
screen_resolution: attrs['DSR'],
|
169
|
+
fingerprint: attrs['FINGERPRINT'],
|
170
|
+
flash_enabled: ((attrs['FLASH'].downcase == 'y' ? true : false) if attrs.include?('FLASH')),
|
171
|
+
configured_country_code: attrs['HTTP_COUNTRY'],
|
172
|
+
javascript_enabled: ((attrs['JAVASCRIPT'].downcase == 'y' ? true : false) if attrs.include?('JAVASCRIPT')),
|
173
|
+
captured: ((attrs['KAPT'].downcase == 'y' ? true : false) if attrs.include?('KAPT')),
|
174
|
+
language_country_code: attrs['LANGUAGE'],
|
175
|
+
localtime: ((Date.strptime(attrs['LOCALTIME'], '%Y-%m-%d %H:%M:%S') rescue nil) if attrs.include?('LOCALTIME')),
|
176
|
+
using_forwarder: ((attrs['MOBILE_FORWARDER'].downcase == 'y' ? true : false) if attrs.include?('MOBILE_FORWARDER')),
|
177
|
+
mobile_type: attrs['MOBILE_TYPE'],
|
178
|
+
network_type: attrs['NETW'],
|
179
|
+
operating_system: attrs['OS'],
|
180
|
+
remote_access: ((attrs['PC_REMOTE'].downcase == 'y' ? true : false) if attrs.include?('PC_REMOTE')),
|
181
|
+
configured_region: attrs['REGN'],
|
182
|
+
timezone: attrs['TIMEZONE'],
|
183
|
+
user_agent: attrs['UAS'],
|
184
|
+
voice_enabled: ((attrs['VOICE_DEVICE'].downcase == 'y' ? true : false) if attrs.include?('VOICE_DEVICE')),
|
185
|
+
region: attrs['REGION'],
|
186
|
+
country_code: attrs['COUNTRY'],
|
187
|
+
}.compact
|
188
|
+
self.device = Device.new(device_attributes)
|
189
|
+
|
190
|
+
persona_attributes = {
|
191
|
+
card_count: attrs['CARDS'],
|
192
|
+
device_count: attrs['DEVICES'],
|
193
|
+
email_count: attrs['EMAILS'],
|
194
|
+
country_code: attrs['GEOX'],
|
195
|
+
order_count: attrs['VELO'],
|
196
|
+
active_order_count: attrs['VMAX'],
|
197
|
+
}.compact
|
198
|
+
self.persona = Persona.new(persona_attributes) if persona_attributes.any?
|
199
|
+
|
200
|
+
proxy_location_attributes = {
|
201
|
+
ip_address: attrs['IP_IPAD'],
|
202
|
+
country_code: attrs['IP_COUNTRY'],
|
203
|
+
latitude: attrs['IP_LAT'],
|
204
|
+
longitude: attrs['IP_LON'],
|
205
|
+
city: attrs['IP_CITY'],
|
206
|
+
region: attrs['IP_REGION'],
|
207
|
+
owner: attrs['IP_ORG'],
|
208
|
+
}.compact
|
209
|
+
self.proxy_location = Location.new(proxy_location_attributes) if proxy_location_attributes.any?
|
210
|
+
|
211
|
+
pierced_location_attributes = {
|
212
|
+
ip_address: attrs['PIP_IPAD'],
|
213
|
+
country_code: attrs['PIP_COUNTRY'],
|
214
|
+
latitude: attrs['PIP_LAT'],
|
215
|
+
longitude: attrs['PIP_LON'],
|
216
|
+
city: attrs['PIP_CITY'],
|
217
|
+
region: attrs['PIP_REGION'],
|
218
|
+
owner: attrs['PIP_ORG'],
|
219
|
+
}.compact
|
220
|
+
self.pierced_location = Location.new(pierced_location_attributes) if pierced_location_attributes.any?
|
221
|
+
|
222
|
+
self.counters = attrs['COUNTERS_TRIGGERED'].to_i.times.collect.with_index(1) do |_,i|
|
223
|
+
Trigger::Counter.new(
|
224
|
+
name: attrs["COUNTER_NAME_#{i}"],
|
225
|
+
count: attrs["COUNTER_VALUE_#{i}"].to_i,
|
226
|
+
)
|
227
|
+
end
|
228
|
+
|
229
|
+
self.errors = attrs['ERROR_COUNT'].to_i.times.collect.with_index(1) do |_,i|
|
230
|
+
Trigger::Error.new(
|
231
|
+
message: attrs["ERROR_#{i}"],
|
232
|
+
)
|
233
|
+
end
|
234
|
+
|
235
|
+
self.rules = attrs['RULES_TRIGGERED'].to_i.times.collect.with_index(1) do |_,i|
|
236
|
+
Trigger::Rule.new(
|
237
|
+
description: attrs["RULE_DESCRIPTION_#{i}"],
|
238
|
+
id: attrs["RULE_ID_#{i}"].to_i,
|
239
|
+
)
|
240
|
+
end
|
241
|
+
|
242
|
+
self.warnings = attrs['WARNING_COUNT'].to_i.times.collect.with_index(1) do |_,i|
|
243
|
+
Trigger::Warning.new(
|
244
|
+
message: attrs["WARNING_#{i}"],
|
245
|
+
)
|
246
|
+
end
|
247
|
+
end
|
248
|
+
|
249
|
+
def to_h
|
250
|
+
{
|
251
|
+
score: score,
|
252
|
+
reason_text: reason_text,
|
253
|
+
merchant_id: merchant_id,
|
254
|
+
mode: mode,
|
255
|
+
order_number: order_number,
|
256
|
+
session_id: session_id,
|
257
|
+
site_id: site_id
|
258
|
+
api_version: api_version,
|
259
|
+
kount_id: kount_id,
|
260
|
+
counters: counters.collect(&:to_h),
|
261
|
+
rules: rules.collect(&:to_h),
|
262
|
+
warnings: warnings.collect(&:to_h),
|
263
|
+
errors: errors.collect(&:to_h),
|
264
|
+
persona: persona.to_h,
|
265
|
+
device: device.to_h,
|
266
|
+
proxy_location: proxy_location.to_h,
|
267
|
+
pierced_location: pierced_location.to_h,
|
268
|
+
error_code: error_code,
|
269
|
+
}
|
270
|
+
end
|
271
|
+
|
272
|
+
private
|
273
|
+
|
274
|
+
def counters_are_valid
|
275
|
+
unless counters.is_a?(Array)
|
276
|
+
errors.add(:counters, 'must be an Array of Counter objects')
|
277
|
+
return false
|
278
|
+
end
|
279
|
+
|
280
|
+
counters.each.with_index(1) do |counter, i|
|
281
|
+
unless counter.valid?
|
282
|
+
counter.errors.full_messages.each do |error|
|
283
|
+
errors.add(:counters, "element #{i} #{error}")
|
284
|
+
end
|
285
|
+
end
|
286
|
+
end
|
287
|
+
end
|
288
|
+
|
289
|
+
def rules_are_valid
|
290
|
+
unless rules.is_a?(Array)
|
291
|
+
errors.add(:rules, 'must be an Array of Rule objects')
|
292
|
+
return false
|
293
|
+
end
|
294
|
+
|
295
|
+
rules.each.with_index(1) do |rule, i|
|
296
|
+
unless rule.valid?
|
297
|
+
rule.errors.full_messages.each do |error|
|
298
|
+
errors.add(:rules, "element #{i} #{error}")
|
299
|
+
end
|
300
|
+
end
|
301
|
+
end
|
302
|
+
end
|
303
|
+
|
304
|
+
def warnings_are_valid
|
305
|
+
unless warnings.is_a?(Array)
|
306
|
+
errors.add(:warnings, 'must be an Array of Warning objects')
|
307
|
+
return false
|
308
|
+
end
|
309
|
+
|
310
|
+
warnings.each.with_index(1) do |warning, i|
|
311
|
+
unless warning.valid?
|
312
|
+
warning.errors.full_messages.each do |error|
|
313
|
+
errors.add(:warnings, "element #{i} #{error}")
|
314
|
+
end
|
315
|
+
end
|
316
|
+
end
|
317
|
+
end
|
318
|
+
|
319
|
+
def errors_are_valid
|
320
|
+
unless errors.is_a?(Array)
|
321
|
+
errors.add(:errors, 'must be an Array of Error objects')
|
322
|
+
return false
|
323
|
+
end
|
324
|
+
|
325
|
+
errors.each.with_index(1) do |error, i|
|
326
|
+
unless error.valid?
|
327
|
+
error.errors.full_messages.each do |error|
|
328
|
+
errors.add(:errors, "element #{i} #{error}")
|
329
|
+
end
|
330
|
+
end
|
331
|
+
end
|
332
|
+
end
|
333
|
+
|
334
|
+
def persona_is_valid
|
335
|
+
unless persona.is_a?(Persona)
|
336
|
+
errors.add(:persona, 'must be an Persona object')
|
337
|
+
return false
|
338
|
+
end
|
339
|
+
|
340
|
+
unless persona.valid?
|
341
|
+
persona.errors.full_messages.each do |error|
|
342
|
+
errors.add(:persona, error)
|
343
|
+
end
|
344
|
+
end
|
345
|
+
end
|
346
|
+
|
347
|
+
def device_is_valid
|
348
|
+
unless device.is_a?(Device)
|
349
|
+
errors.add(:device, 'must be an Device object')
|
350
|
+
return false
|
351
|
+
end
|
352
|
+
|
353
|
+
unless device.valid?
|
354
|
+
device.errors.full_messages.each do |error|
|
355
|
+
errors.add(:device, error)
|
356
|
+
end
|
357
|
+
end
|
358
|
+
end
|
359
|
+
|
360
|
+
def proxy_location_is_valid
|
361
|
+
unless proxy_location.is_a?(Location)
|
362
|
+
errors.add(:proxy_location, 'must be an Location object')
|
363
|
+
return false
|
364
|
+
end
|
365
|
+
|
366
|
+
unless proxy_location.valid?
|
367
|
+
proxy_location.errors.full_messages.each do |error|
|
368
|
+
errors.add(:proxy_location, error)
|
369
|
+
end
|
370
|
+
end
|
371
|
+
end
|
372
|
+
|
373
|
+
def pierced_location_is_valid
|
374
|
+
unless pierced_location.is_a?(Location)
|
375
|
+
errors.add(:pierced_location, 'must be an Location object')
|
376
|
+
return false
|
377
|
+
end
|
378
|
+
|
379
|
+
unless pierced_location.valid?
|
380
|
+
pierced_location.errors.full_messages.each do |error|
|
381
|
+
errors.add(:pierced_location, error)
|
382
|
+
end
|
383
|
+
end
|
384
|
+
end
|
385
|
+
|
386
|
+
end
|
387
|
+
end
|
388
|
+
end
|
389
|
+
|