diff --git a/website_sale_signifyd/migrations/15.0.2.0.0/pre-migrate.py b/website_sale_signifyd/migrations/15.0.2.0.0/pre-migrate.py index ae09eb52..8dc4530a 100644 --- a/website_sale_signifyd/migrations/15.0.2.0.0/pre-migrate.py +++ b/website_sale_signifyd/migrations/15.0.2.0.0/pre-migrate.py @@ -11,3 +11,8 @@ def migrate(cr, version): WHERE signifyd_case_type IS NOT NULL SET signifyd_case_required = TRUE; ''') + + cr.execute(''' + ALTER TABLE signifyd_case + RENAME COLUMN uuid TO ref; + ''') diff --git a/website_sale_signifyd/models/__init__.py b/website_sale_signifyd/models/__init__.py index d59fa371..d323fcad 100644 --- a/website_sale_signifyd/models/__init__.py +++ b/website_sale_signifyd/models/__init__.py @@ -6,6 +6,7 @@ from . import payment_acquirer from . import product_template from . import sale_order from . import signifyd_case +from . import signifyd_case_coverage from . import signifyd_coverage from . import signifyd_connector from . import stock_picking diff --git a/website_sale_signifyd/models/sale_order.py b/website_sale_signifyd/models/sale_order.py index 42716e4f..d61cee52 100644 --- a/website_sale_signifyd/models/sale_order.py +++ b/website_sale_signifyd/models/sale_order.py @@ -3,12 +3,13 @@ from odoo import api, fields, models from odoo.exceptions import UserError from odoo.http import request - +import logging +_logger = logging.getLogger(__name__) class SaleOrder(models.Model): _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): if request: return request.httprequest.environ['REMOTE_ADDR'] @@ -49,36 +50,46 @@ class SaleOrder(models.Model): acquirers = self.transaction_ids.acquirer_id if acquirers and not any(acquirers.mapped('signifyd_case_required')): 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 def post_signifyd_case(self): self.ensure_one() 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: checkout_token = request.session.session_token order_session_id = checkout_token else: checkout_token = '' - - # Session values for Signifyd post sig_vals = self._prepare_signifyd_case_values(order_session_id, checkout_token, self.source_ip) response = signifyd_api.post_case(sig_vals) - success_response = response.get('signifydId') - if success_response: - new_case = self.env['signifyd.case'].create({ - 'order_id': self.id, - 'case_id': success_response, - 'name': success_response, - 'partner_id': self.partner_id.id, - }) - self.write({'signifyd_case_id': new_case.id}) - return new_case - # TODO do we need to raise an exception? - return None + case_id = response.get('signifydId') + + if not case_id: + _logger.warning(f'{self.name}: Signifyd Case creation failed') + return None + new_case = self.env['signifyd.case'].create({ + 'connector_id': self.website_id.signifyd_connector_id.id, + 'order_id': self.id, + 'partner_id': self.partner_id.id, + 'name': f'{self.name} ({case_id})', + '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): coverage_none = self.env.ref('website_sale_signifyd.signifyd_coverage_none') coverage_all = self.env.ref('website_sale_signifyd.signifyd_coverage_all') @@ -87,7 +98,7 @@ class SaleOrder(models.Model): if coverage_all in acquirer_coverage_types: return coverage_all # '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 # Specific acquirer-level coverage types if acquirer_coverage_types - coverage_none: @@ -123,8 +134,7 @@ class SaleOrder(models.Model): # API v3 WIP new_case_vals = { - # FIXME: UUID? - 'orderId': self.id, + 'orderId': self.name, 'purchase': { 'createdAt': self.date_order.isoformat(timespec='seconds') + '+00:00', 'orderChannel': 'WEB', @@ -147,12 +157,24 @@ class SaleOrder(models.Model): 'itemWeight': line.product_id.weight, } for line in self.order_line if line.product_id ], - 'shipments': [ - { - 'carrier': carrier.name, - 'fulfillmentMethod': carrier.signifyd_fulfillment_method, - } for carrier in self.carrier_id - ], + 'shipments': [{ + 'destination': { + 'fullName': self.partner_shipping_id.name, + 'organization': self.partner_shipping_id.commercial_partner_id.name or '', + # '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, 'transactions': [ diff --git a/website_sale_signifyd/models/signifyd_api.py b/website_sale_signifyd/models/signifyd_api.py index ee21e773..fa50b727 100644 --- a/website_sale_signifyd/models/signifyd_api.py +++ b/website_sale_signifyd/models/signifyd_api.py @@ -17,11 +17,14 @@ class SignifydAPI: 'Authorization': 'Basic ' + self._key, 'Content-Type': 'application/json', } + return headers def _request(self, method, path, headers=None, json=None): - headers = headers or {} - headers.update(self._get_headers()) - request = requests.request(method, API_URL + path, headers=headers, json=json) + request_headers = { + **(headers or {}), + **self._get_headers() + } + request = requests.request(method, API_URL + path, headers=request_headers, json=json) return request def get(self, path, headers=None): @@ -30,7 +33,17 @@ class SignifydAPI: def post(self, path, headers=None, json=None): 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) - r = self._post('/orders/events/sales', json=values) + r = self.post('/orders/events/sales', json=values) 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}) \ No newline at end of file diff --git a/website_sale_signifyd/models/signifyd_case.py b/website_sale_signifyd/models/signifyd_case.py index b3045061..2de01c23 100644 --- a/website_sale_signifyd/models/signifyd_case.py +++ b/website_sale_signifyd/models/signifyd_case.py @@ -11,12 +11,11 @@ class SignifydCase(models.Model): _name = 'signifyd.case' _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) partner_id = fields.Many2one('res.partner') 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([ ('OPEN', 'Open'), ('DISMISSED', 'Dismissed'), @@ -54,12 +53,8 @@ class SignifydCase(models.Model): ('HOLD', 'Hold'), ('REJECT', 'Reject'), ], string='Checkpoint Action') - - coverage_ids = fields.Many2many('signifyd.coverage', string='Requested Coverage Types') - # TODO add to view - - def _get_connector(self): - return self.order_id.website_id.signifyd_connector_id + coverage_ids = fields.Many2many('signifyd.coverage', string='Coverage', help='Accepted Coverage Types') + coverage_line_ids = fields.One2many('signifyd.case.coverage', 'case_id') @api.model def _compute_signifyd_url(self): @@ -87,17 +82,62 @@ class SignifydCase(models.Model): # r = requests.post(url=url, headers=headers, json=values) # 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() - 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() + api = self.connector_id.get_connection() + response = api.get_decision(self.ref) + response.raise_for_status() + data = response.json() + + decision = data['decision'] + case_vals = { + 'last_update': decision.get('createdAt'), + '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): for record in self: @@ -108,42 +148,42 @@ class SignifydCase(models.Model): if not self.case_id: raise UserError(_('Case not represented in Signifyd.')) if not vals: - case = self.get_case() - case_id = case.get('caseId') - if not case_id: - raise ValueError(_('Signifyd Case has no ID?')) - team_id = case.get('teamId', self.team_id) - team_name = case.get('teamName', self.team_name) - uuid = case.get('uuid', self.uuid) - status = case.get('status', self.status) - review_disposition = case.get('reviewDisposition', self.review_disposition) - order_outcome = case.get('orderOutcome', self.order_outcome) - guarantee_disposition = case.get('guaranteeDisposition', self.guarantee_disposition) - adjusted_score = case.get('adjustedScore', self.adjusted_score) - score = case.get('score', self.score) - checkpoint_action = case.get('checkpointAction', self.checkpoint_action) - if not checkpoint_action and guarantee_disposition: - if guarantee_disposition == 'APPROVED': - checkpoint_action = 'ACCEPT' - elif guarantee_disposition == 'DECLINED': - checkpoint_action = 'REJECT' - else: - checkpoint_action = 'HOLD' + vals = self.get_decision_vals() + # case_id = case.get('caseId') + # if not case_id: + # raise ValueError(_('Signifyd Case has no ID?')) + # team_id = case.get('teamId', self.team_id) + # team_name = case.get('teamName', self.team_name) + # ref = case.get('ref', self.ref) + # status = case.get('status', self.status) + # review_disposition = case.get('reviewDisposition', self.review_disposition) + # order_outcome = case.get('orderOutcome', self.order_outcome) + # guarantee_disposition = case.get('guaranteeDisposition', self.guarantee_disposition) + # adjusted_score = case.get('adjustedScore', self.adjusted_score) + # score = case.get('score', self.score) + # checkpoint_action = case.get('checkpointAction', self.checkpoint_action) + # if not checkpoint_action and guarantee_disposition: + # if guarantee_disposition == 'APPROVED': + # checkpoint_action = 'ACCEPT' + # elif guarantee_disposition == 'DECLINED': + # checkpoint_action = 'REJECT' + # else: + # checkpoint_action = 'HOLD' - 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, - 'last_update': dt.now(), # why not just use - 'checkpoint_action': checkpoint_action, - } + # vals = { + # 'case_id': case_id, + # 'team_id': team_id, + # 'team_name': team_name, + # 'ref': ref, + # 'status': status, + # 'review_disposition': review_disposition, + # 'order_outcome': order_outcome, + # 'adjusted_score': adjusted_score, + # 'guarantee_disposition': guarantee_disposition, + # 'score': score, + # 'last_update': dt.now(), # why not just use + # 'checkpoint_action': checkpoint_action, + # } outcome = vals.get('guarantee_disposition') checkpoint_action = vals.get('checkpoint_action') @@ -152,7 +192,7 @@ class SignifydCase(models.Model): for user in connector.notify_user_ids: self.create_notification(user, outcome or checkpoint_action) - self.write(vals) + self.sudo().write(vals) def create_notification(self, user, outcome): self.ensure_one() diff --git a/website_sale_signifyd/models/signifyd_case_coverage.py b/website_sale_signifyd/models/signifyd_case_coverage.py new file mode 100644 index 00000000..ad31ca94 --- /dev/null +++ b/website_sale_signifyd/models/signifyd_case_coverage.py @@ -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') diff --git a/website_sale_signifyd/models/signifyd_connector.py b/website_sale_signifyd/models/signifyd_connector.py index ffaa2610..0a1a26e8 100644 --- a/website_sale_signifyd/models/signifyd_connector.py +++ b/website_sale_signifyd/models/signifyd_connector.py @@ -40,12 +40,10 @@ class SignifydConnector(models.Model): def get_connection(self): if not self: return - return SignifydAPI(self.name, self.secret_key, self.teamid) + return SignifydAPI(self.secret_key, self.teamid) def register_webhooks(self): 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. base_url = None 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 if not base_url: base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') - values = { - 'webhooks': [ - # Given we are creating the cases, we do not need to know about it - # { - # "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() + api = self.get_connection() + r = api.register_webhook(base_url + '/signifyd/cases/update') + r.raise_for_status() return r def action_register_webhooks(self): diff --git a/website_sale_signifyd/security/ir.model.access.csv b/website_sale_signifyd/security/ir.model.access.csv index b8723341..4d93e912 100644 --- a/website_sale_signifyd/security/ir.model.access.csv +++ b/website_sale_signifyd/security/ir.model.access.csv @@ -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 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 +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 diff --git a/website_sale_signifyd/views/signifyd_views.xml b/website_sale_signifyd/views/signifyd_views.xml index 558fd1f8..0d7f1175 100644 --- a/website_sale_signifyd/views/signifyd_views.xml +++ b/website_sale_signifyd/views/signifyd_views.xml @@ -32,23 +32,34 @@

- - - + - + + + + + + + + + + + + + + @@ -104,6 +115,7 @@ +