[ADD] connector_signifyd: Automate fraud detection on orders with the Signifyd API.

This commit is contained in:
Brett Spaulding
2020-11-18 22:11:04 -05:00
parent 4ac984c852
commit 05574fc1fb
19 changed files with 877 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
from . import company
from . import partner
from . import sale_order
from . import signifyd
from . import signifyd_connector
from . import stock

View File

@@ -0,0 +1,7 @@
from odoo import fields, models
class ResCompany(models.Model):
_inherit = 'res.company'
signifyd_connector_id = fields.Many2one('signifyd.connector')

View File

@@ -0,0 +1,19 @@
from odoo import api, fields, models
class ResPartner(models.Model):
_inherit = 'res.partner'
signifyd_case_ids = fields.One2many('signifyd.case', 'partner_id', string='Signifyd Cases')
signifyd_case_count = fields.Integer(compute='_compute_signifyd_stats', string='Signifyd Cases')
signifyd_average_score = fields.Float(compute='_compute_signifyd_stats', string='Signifyd Score')
def _compute_signifyd_stats(self):
for record in self:
cases = record.signifyd_case_ids
if cases:
record.signifyd_case_count = len(cases)
record.signifyd_average_score = sum(cases.mapped('score')) / record.signifyd_case_count
else:
record.signifyd_case_count = 0
record.signifyd_average_score = 0

View File

@@ -0,0 +1,149 @@
from odoo import api, fields, models
class SaleOrder(models.Model):
_inherit = 'sale.order'
signifyd_case_id = fields.Many2one('signifyd.case', readonly=1)
singifyd_score = fields.Float(related='signifyd_case_id.score', readonly=1)
signifyd_disposition_status = fields.Selection(related='signifyd_case_id.guarantee_disposition', tracking=True)
def action_view_signifyd_case(self):
self.ensure_one()
form_id = self.env.ref('gcl_signifyd_connector.signifyd_case_form_view').id
context = {'create': False, 'delete': False, 'id': self.signifyd_case_id.id}
return {
'type': 'ir.actions.act_window',
'name': 'Signifyd Case',
'view_mode': 'form',
'views': [(form_id, 'form')],
'res_model': 'signifyd.case',
'res_id': self.signifyd_case_id.id,
'context': context,
}
def post_signifyd_case(self, order_session_id, checkout_token, browser_ip_address):
# Session values for Signifyd post
data = {
'order_session_id': order_session_id,
'checkout_token': checkout_token,
'browser_ip_address': browser_ip_address,
}
sig_vals = self.prepare_signifyd_case_values(data)
case_res = self.env['signifyd.case'].post_case(sig_vals)
success_response = case_res.get('investigationId')
if success_response:
new_case = self.env['signifyd.case'].create({
'order_id': self.id,
'case_id': success_response,
'name': success_response,
})
self.write({'signifyd_case_id': new_case.id})
self.partner_id.write({
'signifyd_case_ids': [(4, new_case.id)],
})
return new_case
@api.model
def prepare_signifyd_case_values(self, data):
order_session_id = data.get('order_session_id')
checkout_token = data.get('checkout_token')
browser_ip_address = data.get('browser_ip_address')
new_case_vals = {}
new_case_vals['purchase'] = {
"orderSessionId": order_session_id,
"orderId": self.id,
"checkoutToken": checkout_token,
"browserIpAddress": browser_ip_address,
"currency": self.partner_id.currency_id.name,
"orderChannel": "WEB",
"totalPrice": self.amount_total,
}
new_case_vals['purchase']['products'] = []
for line in self.order_line:
product = line.product_id
vals = {
"itemId": product.id,
"itemName": product.name,
"itemIsDigital": False,
"itemCategory": product.categ_id.name,
"itemUrl": product.website_url,
"itemQuantity": line.product_uom_qty,
"itemPrice": line.price_unit,
"itemWeight": product.weight,
}
new_case_vals['purchase']['products'].append(vals)
new_case_vals['purchase']['shipments'] = []
if self.carrier_id:
vals = {
"shipper": self.carrier_id.name,
"shippingMethod": "ground",
"shippingPrice": self.amount_delivery,
}
new_case_vals['purchase']['shipments'].append(vals)
new_case_vals['recipients'] = []
recipients = [self.partner_invoice_id, self.partner_shipping_id]
for partner in recipients:
vals = {
"fullName": partner.name,
"confirmationEmail": partner.email,
"confirmationPhone": partner.phone,
"organization": partner.company_id.name,
"deliveryAddress": {
"streetAddress": partner.street,
"unit": partner.street2,
"city": partner.city,
"provinceCode": partner.state_id.code,
"postalCode": partner.zip,
"countryCode": partner.country_id.code,
}
}
new_case_vals['recipients'].append(vals)
new_case_vals['transactions'] = []
# payment.transaction
for tx in self.transaction_ids:
tx_status_type = {
'draft': 'FAILURE',
'pending': 'PENDING',
'authorized': 'SUCCESS',
'done': 'SUCCESS',
'cancel': 'FAILURE',
'error': 'ERROR',
}
tx_status = tx_status_type[tx.state]
vals = {
"parentTransactionId": None,
"transactionId": tx.id,
"gateway": tx.acquirer_id.name,
"paymentMethod": "CREDIT_CARD",
"gatewayStatusCode": tx_status,
"type": "AUTHORIZATION",
"currency": self.partner_id.currency_id.name,
"amount": tx.amount,
"avsResponseCode": "Y",
"cvvResponseCode": "N",
"checkoutPaymentDetails": {
"holderName": tx.partner_id.name,
"billingAddress": {
"streetAddress": tx.partner_id.street,
"unit": tx.partner_id.street2,
"city": tx.partner_id.city,
"provinceCode": tx.partner_id.state_id.code,
"postalCode": tx.partner_id.zip,
"countryCode": tx.partner_id.country_id.code,
}
}
}
new_case_vals['transactions'].append(vals)
return new_case_vals

View File

@@ -0,0 +1,163 @@
import requests
import json
from datetime import datetime as dt
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class SignifydCase(models.Model):
_name = 'signifyd.case'
_description = 'Stores Signifyd case information on orders.'
order_id = fields.Many2one('sale.order')
partner_id = fields.Many2one('res.partner')
case_id = fields.Char(string='Case ID')
uuid = fields.Char(string='Unique ID')
status = fields.Selection([
('OPEN', 'Open'),
('DISMISSED', 'Dismissed'),
], string='Case Status')
name = fields.Char(string='Headline')
team_name = fields.Char(string='Team Name')
team_id = fields.Char(string='Team ID')
last_update = fields.Date('Last Update')
review_disposition = fields.Selection([
('UNSET', 'Pending'),
('FRAUD', 'Fraudulent'),
('GOOD', 'Good'),
], string='Review Status')
order_outcome = fields.Selection([
('PENDING', 'pending'),
('SUCCESSFUL', 'Successful'),
])
guarantee_disposition = fields.Selection([
('IN_REVIEW', 'Reviewing'),
('PENDING', 'Pending'),
('APPROVED', 'Approved'),
('DECLINED', 'Declined'),
('CANCELED', 'Canceled'),
], string='Guarantee Status')
disposition_reason = fields.Char('Disposition Reason')
guarantee_eligible = fields.Boolean('Eligible for Guarantee')
guarantee_requested = fields.Boolean('Requested Guarantee')
score = fields.Float(string='Transaction Score')
adjusted_score = fields.Float(string='Adjusted Score')
signifyd_url = fields.Char('Signifyd.com', compute='_compute_signifyd_url')
@api.model
def _compute_signifyd_url(self):
for record in self:
if record.case_id:
self.signifyd_url = 'https://app.signifyd.com/cases/%s' % record.case_id
else:
self.signifyd_url = ''
def write(self, vals):
res = super(SignifydCase, self).write(vals)
disposition = vals.get('guarantee_disposition')
if disposition:
self.order_id.message_post(body=_('Signifyd Updated Record to %s' % vals['guarantee_disposition']),
subtype='gcl_signifyd_connector.disposition_change')
return res
@api.model
def post_case(self, values):
signifyd = self.env['signifyd.connector']
headers = signifyd.get_headers()
data = json.dumps(values, indent=4, sort_keys=True, default=str)
r = requests.post(
signifyd.API_URL + '/cases',
headers=headers,
data=data,
)
return r.json()
@api.model
def get_case(self):
signifyd = self.env['signifyd.connector']
headers = signifyd.get_headers()
r = requests.get(
signifyd.API_URL + '/cases/' + str(self.case_id),
headers=headers
)
return r.json()
@api.model
def request_guarantee(self, *args):
signifyd = self.env['signifyd.connector']
headers = signifyd.get_headers()
values = json.dumps({"caseId": self.case_id})
r = requests.post(
signifyd.API_URL + '/async/guarantees',
headers=headers,
data=values,
)
if 200 <= r.status_code < 300:
self.write({'guarantee_requested': True})
else:
msg = r.content.decode("utf-8")
raise UserError(_(msg))
def action_request_guarantee(self):
for record in self:
record.request_guarantee()
def action_force_update_case(self):
for record in self:
record.update_case_info()
@api.model
def update_case_info(self, vals=None):
if not vals:
case = self.get_case()
case_id = case.get('caseId')
team_id = case.get('teamId')
team_name = case.get('teamName')
uuid = case.get('uuid')
status = case.get('status')
review_disposition = case.get('reviewDisposition')
order_outcome = case.get('orderOutcome')
guarantee_disposition = case.get('guaranteeDisposition')
adjusted_score = case.get('adjustedScore')
score = case.get('score')
guarantee_eligible = case.get('guaranteeEligible')
# order_id = case.get('orderId')
vals = {
'case_id': case_id,
'team_id': team_id,
'team_name': team_name,
'uuid': uuid,
'status': status,
'review_disposition': review_disposition,
'order_outcome': order_outcome,
'adjusted_score': adjusted_score,
'guarantee_disposition': guarantee_disposition,
'score': score,
'guarantee_eligible': guarantee_eligible,
'last_update': dt.now(),
}
outcome = vals.get('guarantee_disposition')
if outcome == 'DECLINED':
for user in self.env.company.signifyd_connector_id.notify_user_ids:
self.create_notification(user, outcome)
self.write(vals)
def create_notification(self, user, outcome):
self.ensure_one()
vals = {
'summary': 'Signifyd Case %s %s' % (self.case_id, outcome),
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
'user_id': user.id,
'res_id': self.order_id.id,
'res_model_id': self.env['ir.model']._get('sale.order').id,
}
self.env['mail.activity'].create(vals)

View File

@@ -0,0 +1,134 @@
import requests
from datetime import datetime as dt
from base64 import b64encode
import json
from odoo import api, fields, models
class SignifydConnector(models.Model):
_name = 'signifyd.connector'
_description = 'Interact with Signifyd API'
name = fields.Char(string='Connector Name')
test_mode = fields.Boolean(string='Test Mode')
user_key = fields.Char(string='Username')
secret_key = fields.Char(string='API Key')
user_key_test = fields.Char(string='TEST Username')
secret_key_test = fields.Char(string='TEST API Key')
open_so_cap = fields.Integer(string='Cap requests at:')
webhooks_registered = fields.Boolean(string='Successfully Registered Webhooks')
notify_user_ids = fields.Many2many('res.users', string='Receive event notifications')
API_URL = 'https://api.signifyd.com/v2'
def get_headers(self):
# Check for prod or test mode
signifyd = self.env.company.signifyd_connector_id
if not signifyd:
return False
if signifyd.test_mode:
api_key = signifyd.secret_key_test
else:
api_key = signifyd.secret_key
b64_auth_key = b64encode(api_key.encode('utf-8'))
headers = {
'Authorization': 'Basic ' + str(b64_auth_key, 'utf-8').replace('=', ''),
'Content-Type': 'application/json',
}
return headers
def register_webhooks(self):
headers = self.get_headers()
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
values = {
"webhooks": [
{
"event": "CASE_CREATION",
"url": base_url + "/cases/creation"
},
{
"event": "CASE_RESCORE",
"url": base_url + "/cases/update"
},
{
"event": "CASE_REVIEW",
"url": base_url + "/cases/update"
},
{
"event": "GUARANTEE_COMPLETION",
"url": base_url + "/cases/update"
},
]
}
data = json.dumps(values, indent=4)
r = requests.post(
self.API_URL + '/teams/webhooks',
headers=headers,
data=data,
)
# r.raise_for_status()
return r
def action_register_webhooks(self):
notification = {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': ('Signifyd Connector'),
'sticky': True,
},
}
res = self.register_webhooks()
if 200 <= res.status_code < 300:
notification['params']['type'] = 'success'
notification['params']['message'] = 'Successfully registered webhooks with Signifyd.'
self.webhooks_registered = True
return notification
else:
notification['params']['type'] = 'danger'
notification['params']['message'] = res.content.decode('utf-8')
return notification
def process_post_values(self, post):
# Construct dict from request data for endpoints
guarantee_eligible = post.get('guaranteeEligible')
uuid = post.get('uuid')
case_id = post.get('caseId')
team_name = post.get('teamName')
team_id = post.get('teamId')
review_disposition = post.get('reviewDisposition')
guarantee_disposition = post.get('guaranteeDisposition')
order_outcome = post.get('orderOutcome')
status = post.get('status')
score = post.get('score')
disposition_reason = post.get('dispositionReason')
disposition = post.get('disposition')
last_update = str(dt.now())
values = {}
# Validate that the order and case match the request
values.update({'guarantee_eligible': guarantee_eligible}) if guarantee_eligible else ''
values.update({'uuid': uuid}) if uuid else ''
values.update({'team_name': team_name}) if team_name else ''
values.update({'team_id': team_id}) if team_id else ''
values.update({'review_disposition': review_disposition}) if review_disposition else ''
values.update({'guarantee_disposition': guarantee_disposition}) if guarantee_disposition else ''
values.update({'order_outcome': order_outcome}) if order_outcome else ''
values.update({'status': status}) if status else ''
values.update({'score': score}) if score else ''
values.update({'case_id': case_id}) if case_id else ''
values.update({'disposition_reason': disposition_reason}) if disposition_reason else ''
values.update({'disposition': disposition}) if disposition else ''
values.update({'last_update': last_update})
return values

View File

@@ -0,0 +1,22 @@
from odoo import fields, models
class StockPicking(models.Model):
_inherit = 'stock.picking'
singifyd_case_id = fields.Many2one(related='sale_id.signifyd_case_id')
signifyd_hold = fields.Selection(related='sale_id.signifyd_disposition_status')
def action_view_signifyd_case(self):
self.ensure_one()
form_id = self.env.ref('gcl_signifyd_connector.signifyd_case_form_view').id
context = {'create': False, 'delete': False, 'id': self.sale_id.signifyd_case_id.id}
return {
'type': 'ir.actions.act_window',
'name': 'Signifyd Case',
'view_mode': 'form',
'views': [(form_id, 'form')],
'res_model': 'signifyd.case',
'res_id': self.singifyd_case_id.id,
'context': context,
}