Class: Order::FraudDetector

Inherits:
BaseService show all
Defined in:
app/services/order/fraud_detector.rb

Defined Under Namespace

Classes: Result

Constant Summary collapse

PAYMENT_METHOD_MAP =
{
  Payment::CREDIT_CARD => 'card',
  Payment::CREDIT_CARD_TERMINAL => 'card',
  Payment::PAYPAL => 'digital_wallet',
  Payment::PAYPAL_INVOICE => 'digital_wallet',
  Payment::ECHECK => 'bank_debit',
  Payment::CHECK => 'bank_debit',
  Payment::WIRE => 'bank_transfer',
  Payment::AMAZON_PAY => 'digital_wallet',
}.freeze

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from BaseService

#log_debug, #log_error, #log_info, #log_warning, #logger, #options, #tagged_logger

Constructor Details

#initialize(order, payment, _options = {}) ⇒ FraudDetector

Returns a new instance of FraudDetector.



8
9
10
11
12
13
14
15
16
17
18
19
# File 'app/services/order/fraud_detector.rb', line 8

def initialize(order, payment, _options = {})
  @order = order
  @payment = payment
  @customer = @order.billing_entity
  unless @payment.category.in?(['PayPal', 'eCheck'])
    @billing_address = billing_address
    @shipping_address = shipping_address
    @is_pickup = @order.is_warehouse_pickup?
    @existing_report = @payment.fraud_report
  end
  @fr = FraudReport.new(payment: @payment)
end

Class Method Details

.potential_fraud(fraud_report) ⇒ Object



297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
# File 'app/services/order/fraud_detector.rb', line 297

def self.potential_fraud(fraud_report)
  reasons = []
  if fraud_report.radar_risk_level == 'elevated'
    reasons << 'Stripe reports an elevated risk level'
  end
  if fraud_report.radar_risk_level == 'paypal_high'
    reasons << 'Paypal order needs to be manually checked by Venu and released.'
  end
  if fraud_report.radar_risk_level == 'echeck_high'
    reasons << 'eCheck payments over $2,000 need to be manually checked by Venu and released.'
  end
  if fraud_report.credit_card_country.present? and !Country.find_by(iso: fraud_report.credit_card_country).north_america?
    reasons << "Credit card country is outside North America."
  end
  if fraud_report.minfraud_error.present?
    reasons << "Unable to complete fraud report, error from Minfraud: #{fraud_report.minfraud_error}"
  end
  if fraud_report.minfraud_risk_score.present? && (fraud_report.minfraud_risk_score > 50)
    reasons << "Minfraud reports a high risk score (#{fraud_report.minfraud_risk_score})"
  end
  if fraud_report.billing_address_line1_check == 'fail'
    reasons << 'Billing address street does not match registered credit card address'
  elsif %w[unavailable unchecked].include?(fraud_report.billing_address_line1_check)
    reasons << 'Billing address street check was unavailable'
  end
  if fraud_report.billing_address_zip_check == 'fail'
    reasons << 'Billing address zip does not match registered credit card address'
  elsif %w[unavailable unchecked].include?(fraud_report.billing_address_zip_check)
    reasons << 'Billing address zip check was unavailable'
  end
  if fraud_report.credit_card_cvc_check == 'fail'
    reasons << 'Credit card CVC check failed'
  elsif %w[unavailable unchecked].include?(fraud_report.credit_card_cvc_check)
    reasons << 'Credit card CVC check was unavailable'
  end
  if fraud_report.is_online_order?
    if fraud_report.shipping_state_different_to_billing_state?
      reasons << 'Shipping address state is different to billing address state'
    elsif fraud_report.shipping_zip_different_to_billing_zip?
      reasons << 'Shipping address zip is different to billing address zip'
    end
    if fraud_report.shipping_state_is_qc?
      reasons << 'Order is shipping to a high risk province (Quebec)'
    end
  end
  if fraud_report.billing_and_shipping_address_different? && fraud_report.first_time_order?
    reasons << 'First time order with different billing and shipping address'
  end
  if fraud_report.anonymizer_confidence.present? && fraud_report.anonymizer_confidence > 80
    provider = fraud_report.anonymizer_provider_name.present? ? " (#{fraud_report.anonymizer_provider_name})" : ''
    reasons << "High-confidence VPN/anonymizer detected#{provider} (#{fraud_report.anonymizer_confidence}% confidence)"
  end
  if fraud_report.email_is_disposable?
    reasons << 'Disposable email address detected'
  end
  if fraud_report.email_domain_visit_status.present? && fraud_report.email_domain_visit_status.in?(%w[parked dns_error])
    reasons << "Email domain is #{fraud_report.email_domain_visit_status.humanize.downcase}"
  end

  { result: reasons.any?, reasons: reasons }
end

Instance Method Details

#billing_addressObject



408
409
410
# File 'app/services/order/fraud_detector.rb', line 408

def billing_address
  @payment.billing_address
end

#delete_old_fraud_reportObject



82
83
84
85
86
87
88
# File 'app/services/order/fraud_detector.rb', line 82

def delete_old_fraud_report
  old_reports = FraudReport.where(payment_id: @payment.id)
  return nil if old_reports.empty?

  @fr.previous_minfraud_id = old_reports.collect(&:minfraud_id).join(',')
  old_reports.destroy_all
end

#determine_avs_resultObject



370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
# File 'app/services/order/fraud_detector.rb', line 370

def determine_avs_result
  if @payment.card_country == 'US'
    if (@payment.address_line1_check == 'pass') && (@payment.address_zip_check == 'pass')
      'Y' # Street address and 5-digit zip code match.
    elsif (@payment.address_line1_check == 'pass') && (@payment.address_zip_check == 'fail')
      'A' # Street address matches, but 5-digit and 9-digit zip code do not match.
    elsif (@payment.address_line1_check == 'fail') && (@payment.address_zip_check == 'pass')
      'Z' # Street address does not match, but 5-digit zip code matches.
    elsif (@payment.address_line1_check == 'fail') && (@payment.address_zip_check == 'fail')
      'N' # Street address and postal code do not match.
    elsif (@payment.address_line1_check == 'unavailable') || (@payment.address_zip_check == 'unavailable')
      'S' # U.S. Bank does not support AVS.
    end
  else
    if (@payment.address_line1_check == 'pass') && (@payment.address_zip_check == 'pass')
      'M' # Street address and postal code match.
    elsif (@payment.address_line1_check == 'pass') && (@payment.address_zip_check == 'fail')
      'B' # Street address matches, but postal code not verified.
    elsif (@payment.address_line1_check == 'fail') && (@payment.address_zip_check == 'pass')
      'P' # Postal code matches, but street address not verified.
    elsif (@payment.address_line1_check == 'fail') && (@payment.address_zip_check == 'fail')
      'C' # Street address and postal code do not match.
    elsif (@payment.address_line1_check == 'unavailable') || (@payment.address_zip_check == 'unavailable')
      'G' # Non-U.S. issuing bank does not support AVS.
    end
  end
end

#determine_cvv_resultObject



359
360
361
362
363
364
365
366
367
368
# File 'app/services/order/fraud_detector.rb', line 359

def determine_cvv_result
  case @payment.cvc_check
  when 'pass'
    'M'
  when 'fail'
    'N'
  when 'unavailable'
    'P'
  end
end

#distance_between(address1, address2) ⇒ Object



398
399
400
401
402
403
404
405
406
# File 'app/services/order/fraud_detector.rb', line 398

def distance_between(address1, address2)
  res = nil
  geo1 = Geocoder.search(address1.address_for_geocoder)&.first
  geo2 = Geocoder.search(address2.address_for_geocoder)&.first
  if geo1.present? && geo2.present?
    res = Geocoder::Calculations.distance_between([geo1.latitude, geo1.longitude], [geo2.latitude, geo2.longitude])
  end
  res
end

#do_minfraud_assessmentObject



90
91
92
93
94
95
96
97
98
99
# File 'app/services/order/fraud_detector.rb', line 90

def do_minfraud_assessment
  assessment = prepare_minfraud_assessment
  begin
    insights = assessment.insights
    store_minfraud_insights(insights.body)
  rescue StandardError => e
    @fr.minfraud_error = e.message
    ErrorReporting.error e
  end
end

#minfraud_payment_methodObject



433
434
435
436
437
# File 'app/services/order/fraud_detector.rb', line 433

def minfraud_payment_method
  return 'digital_wallet' if @payment.is_www_apple_pay?

  PAYMENT_METHOD_MAP[@payment.category]
end

#prepare_minfraud_assessmentObject



124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
# File 'app/services/order/fraud_detector.rb', line 124

def prepare_minfraud_assessment
  require 'minfraud'
  Minfraud.configure do |c|
    c.license_key = Heatwave::Configuration.fetch(:minfraud, :license_key)
    c.     = Heatwave::Configuration.fetch(:minfraud, :user_id)
  end
  Minfraud::Assessments.new(
    device: {
      ip_address: @payment.remote_ip_address,
      user_agent: @payment.http_user_agent,
      accept_language: @payment.http_accept_language
    },
    event: {
      transaction_id: "ORD REF: #{@order.reference_number} / ORD ID: #{@order.id} / AUTH ID: #{@payment.id}",
      time: @payment.created_at.to_datetime.rfc3339,
      type: 'purchase',
      party: 'customer'
    },
    account: {
      user_id: @payment.,
      username_md5: @payment..present? ? Digest::MD5.hexdigest(@payment..email) : nil
    },
    email: {
      address: @payment..present? ? @payment..email : nil,
      domain: @payment..present? ? @payment..email.split('@')[1] : nil
    },
    billing: {
      # first_name: @customer.is_homeowner? ? @customer.name1 : @customer.primary_contact.try(:name1),
      # last_name: @customer.is_homeowner? ? @customer.name3 : @customer.primary_contact.try(:name3),
      company: @payment.name,
      address: @payment.billing_address_line1,
      address_2: @payment.billing_address_line2,
      city: @payment.billing_address_city,
      region: State.code_for_string(@payment.billing_address_state),
      country: Country.iso_for_string(@payment.billing_address_country),
      postal: @payment.billing_address_zip
      # phone_number: @customer.first_contact_point_by_category(ContactPoint::PHONE).try(:dial_string),
      # phone_country_code: @customer.first_contact_point_by_category(ContactPoint::PHONE).try(:country_code)
    },
    shipping: if @is_pickup
                {}
              else
                {
                  # first_name: @customer.is_homeowner? ? (@shipping_address.person_name.split(" ").first rescue nil) : nil,
                  # last_name: @customer.is_homeowner? ? (@shipping_address.person_name.split(" ").last rescue nil) : nil,
                  company: @payment.shipping_address_name,
                  address: @payment.shipping_address_line1,
                  address_2: @payment.shipping_address_line2,
                  city: @payment.shipping_address_city,
                  region: State.code_for_string(@payment.shipping_address_state),
                  country: Country.iso_for_string(@payment.shipping_address_country),
                  postal: @payment.shipping_address_zip
                  # phone_number: "203-000-0000",
                  # phone_country_code: "1",
                  # delivery_speed: "same_day"
                }
end,
    payment: {
      processor: :stripe,
      was_authorized: true,
      method: minfraud_payment_method
    },
    credit_card: {
      issuer_id_number: @payment.issuer_number,
      last_digits: @payment.last4,
      token: @payment.card_identifier,
      country: @payment.card_country,
      # bank_name: "Bank of America",
      # bank_phone_country_code: "1",
      # bank_phone_number: "800-342-1232",
      avs_result: determine_avs_result,
      cvv_result: determine_cvv_result
    },
    order: {
      amount: @order.total.to_f,
      currency: @order.currency,
      discount_code: @order.discounts.nil? ? nil : @order.discounts.collect { |d| d.coupon.code }.uniq.join(','),
      # affiliate_id: "af12",
      # subaffiliate_id: "saf42",
      # referrer_uri: "http://www.google.com/",
      is_gift: false,
      has_gift_message: false
    } # ,
    # shopping_cart: [
    #   {
    #     category: "pets",
    #     item_id: "ad23232",
    #     quantity: 2,
    #     price: 20.43
    #   },
    #   {
    #     category: "beauty",
    #     item_id: "bst112",
    #     quantity: 1,
    #     price: 100.00
    #   }
    # ]
  )
end

#process(force_new_report = false) ⇒ Object



21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# File 'app/services/order/fraud_detector.rb', line 21

def process(force_new_report = false)
  # if we've already done a fraud report for this auth just return those results
  # unless a new report has been requested
  if @payment.category == 'PayPal'
    #First time order or lifetime revenue should be at least 50% of the order in question
    # details = Paypal.get_transaction_details(@payment)
    # details['transaction_details'][]
    invoiced_orders = @customer.orders.invoiced
    if invoiced_orders.empty? || invoiced_orders.sum(&:total) > (@payment.amount * 0.5)
      @fr.radar_risk_level = 'paypal_high'
    end
  elsif @payment.category == 'eCheck'
    @fr.radar_risk_level = 'echeck_high' if @payment.amount > Payment::ECHECK_MIN_AMOUNT_WITHOUT_SUPERVISION
  else
    if @existing_report && (force_new_report == false)
      returned_report = @existing_report
    else
      delete_old_fraud_report
      @fr.is_online_order = @order.online_order?
      @fr.first_time_order = @customer.orders.invoiced.empty?
      @fr.shipping_state_is_qc = @shipping_address.state_code == 'QC'
      # Here we implement billing and shipping address comparisons unless auth billing address is nil AND the auth is either a www apple_pay auth which hides the billing address, or crm auth linked to a legacy crm that did not store the billing address, at which point we skip the comparisons
      unless @payment.billing_address_line1.nil? && (@payment.is_www_apple_pay? || @payment.is_crm_legacy_vault?)
        @fr.shipping_state_different_to_billing_state = @is_pickup ? false : @billing_address.state_code != @shipping_address.state_code
        @fr.shipping_zip_different_to_billing_zip = @is_pickup ? false : zip_codes_different?(@billing_address, @shipping_address)
        @fr.billing_and_shipping_address_different = false
        unless @shipping_address.is_warehouse
          # here we test for non warehouse pickups
          # if the billing address and shipping address can geocode and are less than ~50 feet from each other, consider this OK
          if (d = distance_between(@billing_address, @shipping_address)).present? && d < 0.01 # miles
          else
            # otherwise use the address same_as method to see if the billing address and shipping address are different, this method sometimes gives false positives based on weird characters or trivial differences
            unless @billing_address.same_as(@shipping_address)
              @fr.billing_and_shipping_address_different = true
            end
          end
        end
      end

      @fr.radar_risk_level = @payment.radar_risk_level
      @fr.radar_reason = @payment.radar_reason

      @fr.billing_address_line1_check = @payment.address_line1_check
      @fr.billing_address_zip_check = @payment.address_zip_check
      @fr.credit_card_cvc_check = @payment.cvc_check
      @fr.credit_card_country = @payment.card_country

      if @order.online_order? && !@payment.skip_minfraud? && Rails.env.production?
        do_minfraud_assessment
      end
    end
  end
  @fr.save!
  returned_report = @fr

  Result.new(
    fraud_report: returned_report,
    potential_fraud: Order::FraudDetector.potential_fraud(@fr)[:result]
  )
end

#shipping_addressObject



412
413
414
415
416
# File 'app/services/order/fraud_detector.rb', line 412

def shipping_address
  address = @payment.shipping_address
  address.is_warehouse = warehouse_pickup?
  address
end

#store_minfraud_insights(insights) ⇒ Object



224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
# File 'app/services/order/fraud_detector.rb', line 224

def store_minfraud_insights(insights)
  @fr.minfraud_id = insights.id
  @fr.minfraud_risk_score = insights.risk_score

  if sa = insights.shipping_address
    @fr.shipping_address_is_postal_in_city = sa.is_postal_in_city
    @fr.shipping_address_is_in_ip_country = sa.is_in_ip_country
    @fr.shipping_address_is_high_risk = sa.is_high_risk
    @fr.shipping_address_distance_to_ip_location = sa.distance_to_ip_location
    @fr.shipping_address_distance_to_billing_address = sa.distance_to_billing_address
    @fr.shipping_address_longitude = sa.longitude
    @fr.shipping_address_latitude = sa.latitude
  end

  if ba = insights.billing_address
    @fr.billing_address_is_postal_in_city = ba.is_postal_in_city
    @fr.billing_address_is_in_ip_country = ba.is_in_ip_country
    @fr.billing_address_distance_to_ip_location = ba.distance_to_ip_location
    @fr.billing_address_longitude = ba.longitude
    @fr.billing_address_latitude = ba.latitude
  end

  if cc = insights.credit_card
    @fr.credit_card_brand = cc.brand
    @fr.credit_card_type = cc.type
    @fr.credit_card_country = cc.country
    @fr.credit_card_issuer_name = cc.issuer.try(:name)
    @fr.credit_card_issuer_phone_number = cc.issuer.try(:phone_number)
    @fr.credit_card_is_issued_in_billing_address_country = cc.is_issued_in_billing_address_country
    @fr.credit_card_is_prepaid = cc.is_prepaid
  end

  if ip = insights.ip_address
    @fr.ip_address_risk_score = ip.risk
    @fr.ip_risk_snapshot = ip.traits.try(:ip_risk_snapshot)
    @fr.ip_address_continent = ip.continent.try(:names).try(:[], 'en')
    @fr.ip_address_city = ip.city.try(:names).try(:[], 'en')
    @fr.ip_address_postal_code = ip.postal.try(:code)
    @fr.ip_address_country = ip.country.try(:names).try(:[], 'en')
    @fr.ip_address_registered_country = ip.registered_country.try(:names).try(:[], 'en')
    @fr.ip_address_longitude = ip.location.try(:longitude)
    @fr.ip_address_latitude = ip.location.try(:latitude)
    @fr.ip_address_isp = ip.traits.try(:isp)
    @fr.mobile_country_code = ip.traits.try(:mobile_country_code)
    @fr.mobile_network_code = ip.traits.try(:mobile_network_code)

    if anon = ip.try(:anonymizer)
      @fr.anonymizer_confidence = anon.confidence
      @fr.anonymizer_provider_name = anon.provider_name
      @fr.anonymizer_network_last_seen = anon.network_last_seen
      @fr.anonymizer_is_vpn = anon.anonymous_vpn?
      @fr.anonymizer_is_tor = anon.tor_exit_node?
      @fr.anonymizer_is_hosting_provider = anon.hosting_provider?
    end
  end

  if email = insights.email
    @fr.email_is_free = email.is_free
    @fr.email_is_high_risk = email.is_high_risk
    @fr.email_is_disposable = email.is_disposable

    if domain = email.domain
      @fr.email_domain_classification = domain.classification
      @fr.email_domain_risk = domain.risk
      @fr.email_domain_volume = domain.volume
      @fr.email_domain_first_seen = domain.first_seen
      @fr.email_domain_visit_status = domain.visit.try(:status)
    end
  end

  @fr.minfraud_params = insights.as_json
end

#warehouse_pickup?Boolean

Returns:

  • (Boolean)


418
419
420
# File 'app/services/order/fraud_detector.rb', line 418

def warehouse_pickup?
  @payment.shipping_address_line1.try(:downcase)&.include?('590 telser') || @payment.shipping_address_line1.try(:downcase)&.include?('300 granton')
end

#zip_codes_different?(billing_address, shipping_address) ⇒ Boolean

Returns:

  • (Boolean)


101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'app/services/order/fraud_detector.rb', line 101

def zip_codes_different?(billing_address, shipping_address)
  if billing_address.zip.present? && shipping_address.zip.present?
    b_zip = billing_address.zip.upcase.delete(' ')
    s_zip = shipping_address.zip.upcase.delete(' ')
    if (billing_address.country_iso3 == 'USA') && (shipping_address.country_iso3 == 'USA')
      # both USA addresses, so check if we should only compare the first 5 digits
      if ((b_zip.length == 5) && (s_zip.length > 5)) || ((b_zip.length > 5) && (s_zip.length == 5))
        # b_zip is 5 digits and s_zip is more than 5, or vice versa, so just compare the first 5
        b_zip[0..4] != s_zip[0..4]
      else
        # they're both the same length, so do a full comparison
        b_zip != s_zip
      end
    else
      # one or both are non-USA zips, so compare complete zip codes
      b_zip != s_zip
    end
  else
    # If zip is not present, then we say zip codes are different to force an error.
    true
  end
end