[WIP] signifyd v3

This commit is contained in:
Milan
2024-12-09 23:59:04 +00:00
parent 10a9a2eaac
commit 35f29af2ac
9 changed files with 200 additions and 126 deletions

View File

@@ -11,3 +11,8 @@ def migrate(cr, version):
WHERE signifyd_case_type IS NOT NULL WHERE signifyd_case_type IS NOT NULL
SET signifyd_case_required = TRUE; SET signifyd_case_required = TRUE;
''') ''')
cr.execute('''
ALTER TABLE signifyd_case
RENAME COLUMN uuid TO ref;
''')

View File

@@ -6,6 +6,7 @@ from . import payment_acquirer
from . import product_template from . import product_template
from . import sale_order from . import sale_order
from . import signifyd_case from . import signifyd_case
from . import signifyd_case_coverage
from . import signifyd_coverage from . import signifyd_coverage
from . import signifyd_connector from . import signifyd_connector
from . import stock_picking from . import stock_picking

View File

@@ -3,12 +3,13 @@
from odoo import api, fields, models from odoo import api, fields, models
from odoo.exceptions import UserError from odoo.exceptions import UserError
from odoo.http import request from odoo.http import request
import logging
_logger = logging.getLogger(__name__)
class SaleOrder(models.Model): class SaleOrder(models.Model):
_inherit = 'sale.order' _inherit = 'sale.order'
# Source IP for case creation - determination attempted at order creation and if necessary at confirmation # Source IP for case creation - determination attempted at order creation and, if necessary, at confirmation
def _get_source_ip(self): def _get_source_ip(self):
if request: if request:
return request.httprequest.environ['REMOTE_ADDR'] return request.httprequest.environ['REMOTE_ADDR']
@@ -49,35 +50,45 @@ class SaleOrder(models.Model):
acquirers = self.transaction_ids.acquirer_id acquirers = self.transaction_ids.acquirer_id
if acquirers and not any(acquirers.mapped('signifyd_case_required')): if acquirers and not any(acquirers.mapped('signifyd_case_required')):
return False return False
if not self.website_id.signifyd_connector_id:
return False
if not self.source_ip:
_logger.warning(f'{self.name}: no source IP for Signifyd Case creation')
return False
return True return True
def post_signifyd_case(self): def post_signifyd_case(self):
self.ensure_one() self.ensure_one()
signifyd_api = self.website_id.signifyd_connector_id.get_connection() signifyd_api = self.website_id.signifyd_connector_id.get_connection()
if not signifyd_api or not self.source_ip:
return # Session values for Signifyd post
if request and request.session: if request and request.session:
checkout_token = request.session.session_token checkout_token = request.session.session_token
order_session_id = checkout_token order_session_id = checkout_token
else: else:
checkout_token = '' checkout_token = ''
# Session values for Signifyd post
sig_vals = self._prepare_signifyd_case_values(order_session_id, checkout_token, self.source_ip) sig_vals = self._prepare_signifyd_case_values(order_session_id, checkout_token, self.source_ip)
response = signifyd_api.post_case(sig_vals) response = signifyd_api.post_case(sig_vals)
success_response = response.get('signifydId') case_id = response.get('signifydId')
if success_response:
new_case = self.env['signifyd.case'].create({ if not case_id:
'order_id': self.id, _logger.warning(f'{self.name}: Signifyd Case creation failed')
'case_id': success_response, return None
'name': success_response,
'partner_id': self.partner_id.id, new_case = self.env['signifyd.case'].create({
}) 'connector_id': self.website_id.signifyd_connector_id.id,
self.write({'signifyd_case_id': new_case.id}) 'order_id': self.id,
return new_case 'partner_id': self.partner_id.id,
# TODO do we need to raise an exception? 'name': f'{self.name} ({case_id})',
return None 'case_id': case_id,
'ref': response.get('orderId'),
# Unused response fields in async mode:
# decision
# coverage
})
self.write({'signifyd_case_id': new_case.id})
return new_case
def _get_coverage_types(self): def _get_coverage_types(self):
coverage_none = self.env.ref('website_sale_signifyd.signifyd_coverage_none') coverage_none = self.env.ref('website_sale_signifyd.signifyd_coverage_none')
@@ -87,7 +98,7 @@ class SaleOrder(models.Model):
if coverage_all in acquirer_coverage_types: if coverage_all in acquirer_coverage_types:
return coverage_all return coverage_all
# 'NONE' if specified by all acquirers # 'NONE' if specified by all acquirers
if all(self.transaction_ids.acquirer_id.mapped(lambda a: a.signifyd_coverage_ids == coverage_none)): if acquirer_coverage_types and all(self.transaction_ids.acquirer_id.mapped(lambda a: a.signifyd_coverage_ids == coverage_none)):
return coverage_none return coverage_none
# Specific acquirer-level coverage types # Specific acquirer-level coverage types
if acquirer_coverage_types - coverage_none: if acquirer_coverage_types - coverage_none:
@@ -123,8 +134,7 @@ class SaleOrder(models.Model):
# API v3 WIP # API v3 WIP
new_case_vals = { new_case_vals = {
# FIXME: UUID? 'orderId': self.name,
'orderId': self.id,
'purchase': { 'purchase': {
'createdAt': self.date_order.isoformat(timespec='seconds') + '+00:00', 'createdAt': self.date_order.isoformat(timespec='seconds') + '+00:00',
'orderChannel': 'WEB', 'orderChannel': 'WEB',
@@ -147,12 +157,24 @@ class SaleOrder(models.Model):
'itemWeight': line.product_id.weight, 'itemWeight': line.product_id.weight,
} for line in self.order_line if line.product_id } for line in self.order_line if line.product_id
], ],
'shipments': [ 'shipments': [{
{ 'destination': {
'carrier': carrier.name, 'fullName': self.partner_shipping_id.name,
'fulfillmentMethod': carrier.signifyd_fulfillment_method, 'organization': self.partner_shipping_id.commercial_partner_id.name or '',
} for carrier in self.carrier_id # 'email': self.partner_shipping_id.email,
], # 'phone': self.partner_shipping_id.phone,
'address': {
'streetAddress': self.partner_shipping_id.street or '',
'unit': self.partner_shipping_id.street2 or '',
'postalCode': self.partner_shipping_id.zip or '',
'city': self.partner_shipping_id.city or '',
'provinceCode': self.partner_shipping_id.state_id.code or '',
'countryCode': self.partner_shipping_id.country_id.code or ''
}
},
'carrier': self.carrier_id.name or '',
'fulfillmentMethod': self.carrier_id.signifyd_fulfillment_method,
}],
}, },
'coverageRequests': coverage_codes, 'coverageRequests': coverage_codes,
'transactions': [ 'transactions': [

View File

@@ -17,11 +17,14 @@ class SignifydAPI:
'Authorization': 'Basic ' + self._key, 'Authorization': 'Basic ' + self._key,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
} }
return headers
def _request(self, method, path, headers=None, json=None): def _request(self, method, path, headers=None, json=None):
headers = headers or {} request_headers = {
headers.update(self._get_headers()) **(headers or {}),
request = requests.request(method, API_URL + path, headers=headers, json=json) **self._get_headers()
}
request = requests.request(method, API_URL + path, headers=request_headers, json=json)
return request return request
def get(self, path, headers=None): def get(self, path, headers=None):
@@ -30,7 +33,17 @@ class SignifydAPI:
def post(self, path, headers=None, json=None): def post(self, path, headers=None, json=None):
return self._request('POST', path, headers=headers, json=json) return self._request('POST', path, headers=headers, json=json)
def post_case(self, connector, values): def post_case(self, values):
# data = json.dumps(values, indent=4, sort_keys=True, default=str) # data = json.dumps(values, indent=4, sort_keys=True, default=str)
r = self._post('/orders/events/sales', json=values) r = self.post('/orders/events/sales', json=values)
return r.json() return r.json()
def get_decision(self, case_id):
return self.get(f'/orders/{case_id}/decision')
def register_webhook(self, url):
return self.post(f"/teams/{self._teamid}/webhooks", json={'url': url})
def test_webhook(self, url):
# https://api.signifyd.com/v3/webhooks/tests
return self.post('/webhooks/tests', json={'url': url})

View File

@@ -11,12 +11,11 @@ class SignifydCase(models.Model):
_name = 'signifyd.case' _name = 'signifyd.case'
_description = 'Stores Signifyd case information on orders.' _description = 'Stores Signifyd case information on orders.'
# flow_type = fields.Selection([('pre', 'PreAuth'), ('post', 'PostAuth')], default='post', required=True) connector_id = fields.Many2one('signifyd.connector', string='Signifyd Connector')
order_id = fields.Many2one('sale.order', required=True) order_id = fields.Many2one('sale.order', required=True)
partner_id = fields.Many2one('res.partner') partner_id = fields.Many2one('res.partner')
case_id = fields.Char(string='Case ID') case_id = fields.Char(string='Case ID')
uuid = fields.Char(string='Unique ID') ref = fields.Char(string='Reference', help="Signifyd Order ID, set to the sale order's name for new cases. Previously Unique ID.")
status = fields.Selection([ status = fields.Selection([
('OPEN', 'Open'), ('OPEN', 'Open'),
('DISMISSED', 'Dismissed'), ('DISMISSED', 'Dismissed'),
@@ -54,12 +53,8 @@ class SignifydCase(models.Model):
('HOLD', 'Hold'), ('HOLD', 'Hold'),
('REJECT', 'Reject'), ('REJECT', 'Reject'),
], string='Checkpoint Action') ], string='Checkpoint Action')
coverage_ids = fields.Many2many('signifyd.coverage', string='Coverage', help='Accepted Coverage Types')
coverage_ids = fields.Many2many('signifyd.coverage', string='Requested Coverage Types') coverage_line_ids = fields.One2many('signifyd.case.coverage', 'case_id')
# TODO add to view
def _get_connector(self):
return self.order_id.website_id.signifyd_connector_id
@api.model @api.model
def _compute_signifyd_url(self): def _compute_signifyd_url(self):
@@ -87,17 +82,62 @@ class SignifydCase(models.Model):
# r = requests.post(url=url, headers=headers, json=values) # r = requests.post(url=url, headers=headers, json=values)
# return r.json() # return r.json()
def get_case(self): # def get_case(self):
# self.ensure_one()
# if not self.case_id:
# raise UserError(_('Case not represented in Signifyd.'))
# connector = self._get_connector()
# headers = connector.get_headers()
# r = requests.get(
# connector.API_URL + '/cases/' + str(self.case_id),
# headers=headers
# )
# return r.json()
def get_decision_vals(self):
self.ensure_one() self.ensure_one()
if not self.case_id: api = self.connector_id.get_connection()
raise UserError(_('Case not represented in Signifyd.')) response = api.get_decision(self.ref)
connector = self._get_connector() response.raise_for_status()
headers = connector.get_headers() data = response.json()
r = requests.get(
connector.API_URL + '/cases/' + str(self.case_id), decision = data['decision']
headers=headers case_vals = {
) 'last_update': decision.get('createdAt'),
return r.json() 'checkpoint_action': decision.get('checkpointAction'),
'score': decision.get('score'),
'coverage_line_ids': []
}
coverage = data.get('coverage')
if coverage:
coverage_map = {
'inrChargebacks': self.env.ref('website_sale_signifyd.signifyd_coverage_inr').id,
'fraudChargebacks': self.env.ref('website_sale_signifyd.signifyd_coverage_fraud').id,
'snadChargebacks': self.env.ref('website_sale_signifyd.signifyd_coverage_snad').id,
'allChargebacks': self.env.ref('website_sale_signifyd.signifyd_coverage_all').id
}
# coverage_ids = []
# if coverage.get('inrChargebacks'):
# coverage_ids += self.env.ref('website_sale_signifyd.signifyd_coverage_inr').ids
# if coverage.get('fraudChargebacks'):
# coverage_ids += self.env.ref('website_sale_signifyd.signifyd_coverage_fraud').ids
# if coverage.get('snadChargebacks'):
# coverage_ids += self.env.ref('website_sale_signifyd.signifyd_coverage_snad').ids
# if coverage.get('allChargebacks'):
# coverage_ids = self.env.ref('website_sale_signifyd.signifyd_coverage_all').ids
# vals['coverage_ids'] = coverage_ids
for name, vals in coverage.items():
if not vals:
continue
case_vals['coverage_line_ids'] += [(0, 0, {
# 'case_id': self.id,
'coverage_type_id': coverage_map[name],
'amount': vals.get('amount'),
'currency_id': self.env['res.currency'].search(
[('name', '=ilike', vals.get('currency'))], limit=1).id,
})]
return {k: v for k, v in case_vals.items() if v}
def action_force_update_case(self): def action_force_update_case(self):
for record in self: for record in self:
@@ -108,42 +148,42 @@ class SignifydCase(models.Model):
if not self.case_id: if not self.case_id:
raise UserError(_('Case not represented in Signifyd.')) raise UserError(_('Case not represented in Signifyd.'))
if not vals: if not vals:
case = self.get_case() vals = self.get_decision_vals()
case_id = case.get('caseId') # case_id = case.get('caseId')
if not case_id: # if not case_id:
raise ValueError(_('Signifyd Case has no ID?')) # raise ValueError(_('Signifyd Case has no ID?'))
team_id = case.get('teamId', self.team_id) # team_id = case.get('teamId', self.team_id)
team_name = case.get('teamName', self.team_name) # team_name = case.get('teamName', self.team_name)
uuid = case.get('uuid', self.uuid) # ref = case.get('ref', self.ref)
status = case.get('status', self.status) # status = case.get('status', self.status)
review_disposition = case.get('reviewDisposition', self.review_disposition) # review_disposition = case.get('reviewDisposition', self.review_disposition)
order_outcome = case.get('orderOutcome', self.order_outcome) # order_outcome = case.get('orderOutcome', self.order_outcome)
guarantee_disposition = case.get('guaranteeDisposition', self.guarantee_disposition) # guarantee_disposition = case.get('guaranteeDisposition', self.guarantee_disposition)
adjusted_score = case.get('adjustedScore', self.adjusted_score) # adjusted_score = case.get('adjustedScore', self.adjusted_score)
score = case.get('score', self.score) # score = case.get('score', self.score)
checkpoint_action = case.get('checkpointAction', self.checkpoint_action) # checkpoint_action = case.get('checkpointAction', self.checkpoint_action)
if not checkpoint_action and guarantee_disposition: # if not checkpoint_action and guarantee_disposition:
if guarantee_disposition == 'APPROVED': # if guarantee_disposition == 'APPROVED':
checkpoint_action = 'ACCEPT' # checkpoint_action = 'ACCEPT'
elif guarantee_disposition == 'DECLINED': # elif guarantee_disposition == 'DECLINED':
checkpoint_action = 'REJECT' # checkpoint_action = 'REJECT'
else: # else:
checkpoint_action = 'HOLD' # checkpoint_action = 'HOLD'
vals = { # vals = {
'case_id': case_id, # 'case_id': case_id,
'team_id': team_id, # 'team_id': team_id,
'team_name': team_name, # 'team_name': team_name,
'uuid': uuid, # 'ref': ref,
'status': status, # 'status': status,
'review_disposition': review_disposition, # 'review_disposition': review_disposition,
'order_outcome': order_outcome, # 'order_outcome': order_outcome,
'adjusted_score': adjusted_score, # 'adjusted_score': adjusted_score,
'guarantee_disposition': guarantee_disposition, # 'guarantee_disposition': guarantee_disposition,
'score': score, # 'score': score,
'last_update': dt.now(), # why not just use # 'last_update': dt.now(), # why not just use
'checkpoint_action': checkpoint_action, # 'checkpoint_action': checkpoint_action,
} # }
outcome = vals.get('guarantee_disposition') outcome = vals.get('guarantee_disposition')
checkpoint_action = vals.get('checkpoint_action') checkpoint_action = vals.get('checkpoint_action')
@@ -152,7 +192,7 @@ class SignifydCase(models.Model):
for user in connector.notify_user_ids: for user in connector.notify_user_ids:
self.create_notification(user, outcome or checkpoint_action) self.create_notification(user, outcome or checkpoint_action)
self.write(vals) self.sudo().write(vals)
def create_notification(self, user, outcome): def create_notification(self, user, outcome):
self.ensure_one() self.ensure_one()

View File

@@ -0,0 +1,10 @@
from odoo import fields, models
class SignifydCaseCoverage(models.Model):
_name = 'signifyd.case.coverage'
case_id = fields.Many2one('signifyd.case', required=True)
coverage_type_id = fields.Many2one('signifyd.coverage', required=True)
amount = fields.Float()
currency_id = fields.Many2one('res.currency')

View File

@@ -40,12 +40,10 @@ class SignifydConnector(models.Model):
def get_connection(self): def get_connection(self):
if not self: if not self:
return return
return SignifydAPI(self.name, self.secret_key, self.teamid) return SignifydAPI(self.secret_key, self.teamid)
def register_webhooks(self): def register_webhooks(self):
self.ensure_one() self.ensure_one()
headers = self.get_headers()
# This should come from the website...
# we may need a better way to link the connector to the website. # we may need a better way to link the connector to the website.
base_url = None base_url = None
website = self.env['website'].search([('signifyd_connector_id', '=', self.id)], limit=1) website = self.env['website'].search([('signifyd_connector_id', '=', self.id)], limit=1)
@@ -55,38 +53,9 @@ class SignifydConnector(models.Model):
base_url = 'http://' + base_url base_url = 'http://' + base_url
if not base_url: if not base_url:
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
values = { api = self.get_connection()
'webhooks': [ r = api.register_webhook(base_url + '/signifyd/cases/update')
# Given we are creating the cases, we do not need to know about it r.raise_for_status()
# {
# "event": "CASE_CREATION",
# "url": base_url + "/signifyd/cases/update"
# },
{
'event': 'CASE_RESCORE',
'url': base_url + '/signifyd/cases/update'
},
{
'event': 'CASE_REVIEW',
'url': base_url + '/signifyd/cases/update'
},
{
'event': 'GUARANTEE_COMPLETION',
'url': base_url + '/signifyd/cases/update'
},
{
'event': 'DECISION_MADE',
'url': base_url + '/signifyd/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 return r
def action_register_webhooks(self): def action_register_webhooks(self):

View File

@@ -8,3 +8,5 @@ access_signifyd_case,access_signifyd_case,model_signifyd_case,base.group_user,1,
public_signifyd_case,public_signifyd_case,model_signifyd_case,base.group_public,1,0,0,0 public_signifyd_case,public_signifyd_case,model_signifyd_case,base.group_public,1,0,0,0
portal_signifyd_case,portal_signifyd_case,model_signifyd_case,base.group_portal,1,0,0,0 portal_signifyd_case,portal_signifyd_case,model_signifyd_case,base.group_portal,1,0,0,0
access_signifyd_coverage,access_signifyd_coverage,model_signifyd_coverage,base.group_user,1,0,0,0 access_signifyd_coverage,access_signifyd_coverage,model_signifyd_coverage,base.group_user,1,0,0,0
manage_signifyd_case_coverage,manage_signifyd_case,model_signifyd_case_coverage,base.group_erp_manager,1,1,1,1
access_signifyd_case_coverage,access_signifyd_case_coverage,model_signifyd_case_coverage,base.group_user,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
8 public_signifyd_case public_signifyd_case model_signifyd_case base.group_public 1 0 0 0
9 portal_signifyd_case portal_signifyd_case model_signifyd_case base.group_portal 1 0 0 0
10 access_signifyd_coverage access_signifyd_coverage model_signifyd_coverage base.group_user 1 0 0 0
11 manage_signifyd_case_coverage manage_signifyd_case model_signifyd_case_coverage base.group_erp_manager 1 1 1 1
12 access_signifyd_case_coverage access_signifyd_case_coverage model_signifyd_case_coverage base.group_user 1 0 0 0

View File

@@ -32,23 +32,34 @@
<h1> <h1>
<field name="name" readonly="1"/> <field name="name" readonly="1"/>
</h1> </h1>
<field name="signifyd_url" widget="url"/>
</div> </div>
<group> <group>
<group> <group>
<field name="checkpoint_action"/> <field name="checkpoint_action"/>
<field name="last_update"/> <field name="last_update"/>
<field name="uuid"/>
<field name="case_id"/>
<field name="status"/> <field name="status"/>
<field name="order_outcome"/> <field name="order_outcome"/>
<field name="review_disposition"/> <field name="review_disposition"/>
<field name="guarantee_disposition"/> <field name="guarantee_disposition"/>
<field name="coverage_ids" widget="many2many_tags" readonly="1"/>
<field name="disposition_reason"/> <field name="disposition_reason"/>
</group> </group>
<group>
<field name="connector_id"/>
<field name="ref" attrs="{'readonly': [('ref', '=', False)]}"/>
<field name="case_id"/>
<field name="signifyd_url" widget="url"/>
<field name="order_id" />
</group>
</group> </group>
<field name="coverage_line_ids" readonly="1">
<tree>
<field name="coverage_type_id"/>
<field name="amount" widget="monetary" options="{'currency_field': 'currency_id'}"/>
<field name="currency_id" invisible="1"/>
</tree>
</field>
</sheet> </sheet>
</form> </form>
</field> </field>
@@ -104,6 +115,7 @@
<group> <group>
<group> <group>
<field name="teamid" required="1"/>
<field name="secret_key" attrs="{'invisible': [('test_mode', '=', True)]}"/> <field name="secret_key" attrs="{'invisible': [('test_mode', '=', True)]}"/>
<field name="secret_key_test" attrs="{'invisible': [('test_mode', '!=', True)]}"/> <field name="secret_key_test" attrs="{'invisible': [('test_mode', '!=', True)]}"/>
<!-- <field name="signifyd_case_type" /> --> <!-- <field name="signifyd_case_type" /> -->