From ee61c993cb4ee22c5c66ee096973ce4e3b0deb05 Mon Sep 17 00:00:00 2001 From: Joren Van Onder Date: Wed, 21 Aug 2019 14:11:42 -0700 Subject: [PATCH] [ADD] pos_adyen: integrate Adyen's Terminal API This allows Adyen's POS terminals to be used in the POS. The architecture is as follows: Odoo <--> Adyen server <--> Adyen payment terminal It means we don't have to communicate directly with the terminal. Instead we send HTTP requests to Adyen's API and they take care of the rest. Therefore this integration doesn't require an IOTBox. Unfortunately this module is more complicated than it needed to be due to Adyen's API not supporting CORS. Because of this we can't directly call their API from JS. Instead we need to proxy requests through the Odoo backend. This complicates things further because this means we can't use their synchronous API. With this API an HTTP request remains open for the entire duration of a transaction. We can't afford to block an HTTP worker this long. So this implements their asynchronous API which calls us back when a transaction completes. In addition to the regular sale requests we also poll the status of the terminal using DiagnosisRequests while transactions are in progress. This is to handle cases where a payment terminal loses connection after a payment transaction is initiated. It also handles cases where a terminal was recently disconnected (<1 min). In this last case Adyen will process the payment request as usual, without notifying us the terminal is offline. It's important to notify the POS user to check the terminal, otherwise he'll have no idea something is wrong. We store the DiagnosisResponse's unique ID and every time the POS requests a status update we launch a new DiagnosisRequest. When we no longer receive DiagnosisResponse ID updates we can notify the user of this in the POS. To test this locally use something like ngrok so Adyen can call you back. API docs: https://docs.adyen.com/point-of-sale https://www.odoo.com/web#id=1981799&model=project.task&view_type=form&menu_id= --- addons/pos_adyen/__init__.py | 5 + addons/pos_adyen/__manifest__.py | 19 ++ addons/pos_adyen/controllers/__init__.py | 2 + addons/pos_adyen/controllers/main.py | 33 ++ addons/pos_adyen/models/__init__.py | 5 + addons/pos_adyen/models/pos_config.py | 26 ++ addons/pos_adyen/models/pos_payment_method.py | 104 ++++++ addons/pos_adyen/static/src/js/models.js | 7 + .../pos_adyen/static/src/js/payment_adyen.js | 308 ++++++++++++++++++ .../pos_adyen/views/point_of_sale_assets.xml | 9 + addons/pos_adyen/views/pos_config_views.xml | 26 ++ .../views/pos_payment_method_views.xml | 18 + 12 files changed, 562 insertions(+) create mode 100644 addons/pos_adyen/__init__.py create mode 100644 addons/pos_adyen/__manifest__.py create mode 100644 addons/pos_adyen/controllers/__init__.py create mode 100644 addons/pos_adyen/controllers/main.py create mode 100644 addons/pos_adyen/models/__init__.py create mode 100644 addons/pos_adyen/models/pos_config.py create mode 100644 addons/pos_adyen/models/pos_payment_method.py create mode 100644 addons/pos_adyen/static/src/js/models.js create mode 100644 addons/pos_adyen/static/src/js/payment_adyen.js create mode 100644 addons/pos_adyen/views/point_of_sale_assets.xml create mode 100644 addons/pos_adyen/views/pos_config_views.xml create mode 100644 addons/pos_adyen/views/pos_payment_method_views.xml diff --git a/addons/pos_adyen/__init__.py b/addons/pos_adyen/__init__.py new file mode 100644 index 00000000000..d26611156a6 --- /dev/null +++ b/addons/pos_adyen/__init__.py @@ -0,0 +1,5 @@ +# coding: utf-8 +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models +from . import controllers diff --git a/addons/pos_adyen/__manifest__.py b/addons/pos_adyen/__manifest__.py new file mode 100644 index 00000000000..e79fb5ca105 --- /dev/null +++ b/addons/pos_adyen/__manifest__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +{ + 'name': 'POS Adyen', + 'version': '1.0', + 'category': 'Point of Sale', + 'sequence': 6, + 'summary': 'Integrate your POS with an Adyen payment terminal', + 'description': '', + 'data': [ + 'views/pos_config_views.xml', + 'views/pos_payment_method_views.xml', + 'views/point_of_sale_assets.xml', + ], + 'depends': ['point_of_sale'], + 'qweb': ['static/src/xml/pos.xml'], + 'installable': True, + 'license': 'OEEL-1', +} diff --git a/addons/pos_adyen/controllers/__init__.py b/addons/pos_adyen/controllers/__init__.py new file mode 100644 index 00000000000..a7ee9d9fbd1 --- /dev/null +++ b/addons/pos_adyen/controllers/__init__.py @@ -0,0 +1,2 @@ +# coding: utf-8 +from . import main diff --git a/addons/pos_adyen/controllers/main.py b/addons/pos_adyen/controllers/main.py new file mode 100644 index 00000000000..ae74c58fba2 --- /dev/null +++ b/addons/pos_adyen/controllers/main.py @@ -0,0 +1,33 @@ +# coding: utf-8 +import logging +import pprint +import json +from odoo import fields, http +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class PosAdyenController(http.Controller): + @http.route('/pos_adyen/notification', type='json', methods=['POST'], auth='none', csrf=False) + def notification(self): + data = json.loads(request.httprequest.data) + _logger.info('notification received from adyen:\n%s', pprint.pformat(data)) + + # ignore if it's not a response to a sales request + if not data.get('SaleToPOIResponse'): + _logger.info('ignoring notification because it\'s not a SaleToPOIResponse') + return + + terminal_identifier = data['SaleToPOIResponse']['MessageHeader']['POIID'] + payment_method = request.env['pos.payment.method'].sudo().search([('adyen_terminal_identifier', '=', terminal_identifier)], limit=1) + + if payment_method: + # These are only used to see if the terminal is reachable, + # store the most recent ID we received. + if data['SaleToPOIResponse'].get('DiagnosisResponse'): + payment_method.adyen_latest_diagnosis = data['SaleToPOIResponse']['MessageHeader']['ServiceID'] + else: + payment_method.adyen_latest_response = json.dumps(data) + else: + _logger.error('received a message for a terminal not registered in Odoo: %s', terminal_identifier) diff --git a/addons/pos_adyen/models/__init__.py b/addons/pos_adyen/models/__init__.py new file mode 100644 index 00000000000..77e4db1fdaf --- /dev/null +++ b/addons/pos_adyen/models/__init__.py @@ -0,0 +1,5 @@ +# coding: utf-8 +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import pos_config +from . import pos_payment_method diff --git a/addons/pos_adyen/models/pos_config.py b/addons/pos_adyen/models/pos_config.py new file mode 100644 index 00000000000..b0d9176c628 --- /dev/null +++ b/addons/pos_adyen/models/pos_config.py @@ -0,0 +1,26 @@ +# coding: utf-8 +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import logging + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class PosConfig(models.Model): + _inherit = 'pos.config' + + adyen_ask_customer_for_tip = fields.Boolean('Ask Customers For Tip', help='Prompt the customer to tip.') + + @api.constrains('adyen_ask_customer_for_tip', 'iface_tipproduct', 'tip_product_id') + def _check_adyen_ask_customer_for_tip(self): + for config in self: + if config.adyen_ask_customer_for_tip and (not config.tip_product_id or not config.iface_tipproduct): + raise ValidationError(_("Please configure a tip product for POS %s to support tipping with Adyen.") % config.name) + + @api.onchange('adyen_ask_customer_for_tip') + def _onchange_adyen_ask_customer_for_tip(self): + for config in self: + if config.adyen_ask_customer_for_tip: + config.iface_tipproduct = True diff --git a/addons/pos_adyen/models/pos_payment_method.py b/addons/pos_adyen/models/pos_payment_method.py new file mode 100644 index 00000000000..3461efbc91a --- /dev/null +++ b/addons/pos_adyen/models/pos_payment_method.py @@ -0,0 +1,104 @@ +# coding: utf-8 +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import json +import logging +import pprint +import random +import requests +import string + +from odoo import fields, models, api, _ +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + +class PosPaymentMethod(models.Model): + _inherit = 'pos.payment.method' + + use_payment_terminal = fields.Selection(selection_add=[('adyen', 'Adyen')]) + adyen_api_key = fields.Char(string="Adyen API key", help='Used when connecting to Adyen: https://docs.adyen.com/user-management/how-to-get-the-api-key/#description', copy=False) + adyen_terminal_identifier = fields.Char(help='[Terminal model]-[Serial number], for example: P400Plus-123456789', copy=False) + adyen_test_mode = fields.Boolean(help='Run transactions in the test environment.') + adyen_latest_response = fields.Char(help='Technical field used to buffer the latest asynchronous notification from Adyen.', copy=False, groups='base.group_erp_manager') + adyen_latest_diagnosis = fields.Char(help='Technical field used to determine if the terminal is still connected.', copy=False, groups='base.group_erp_manager') + + @api.constrains('adyen_terminal_identifier') + def _check_adyen_terminal_identifier(self): + for payment_method in self: + existing_payment_method = self.search([('id', '!=', payment_method.id), + ('adyen_terminal_identifier', '=', payment_method.adyen_terminal_identifier)], + limit=1) + if existing_payment_method: + raise ValidationError(_('Terminal %s is already used on payment method %s.') + % (payment_method.adyen_terminal_identifier, existing_payment_method.display_name)) + + def _is_write_forbidden(self, fields): + whitelisted_fields = set(('adyen_latest_response', 'adyen_latest_diagnosis')) + return super(PosPaymentMethod, self)._is_write_forbidden(fields - whitelisted_fields) + + def _adyen_diagnosis_request_data(self, pos_config_name): + service_id = ''.join(random.choices(string.ascii_letters + string.digits, k=10)) + return { + "SaleToPOIRequest": { + "MessageHeader": { + "ProtocolVersion": "3.0", + "MessageClass": "Service", + "MessageCategory": "Diagnosis", + "MessageType": "Request", + "ServiceID": service_id, + "SaleID": pos_config_name, + "POIID": self.adyen_terminal_identifier + }, + "DiagnosisRequest": { + "HostDiagnosisFlag": False + } + } + } + + def get_latest_adyen_status(self, pos_config_name): + self.ensure_one() + latest_response = self.sudo().adyen_latest_response + latest_response = json.loads(latest_response) if latest_response else False + self.sudo().adyen_latest_response = '' # avoid handling old responses multiple times + + # Poll the status of the terminal if there's no new + # notification we received. This is done so we can quickly + # notify the user if the terminal is no longer reachable due + # to connectivity issues. + if not latest_response: + self.proxy_adyen_request(self._adyen_diagnosis_request_data(pos_config_name)) + + return { + 'latest_response': latest_response, + 'last_received_diagnosis_id': self.adyen_latest_diagnosis, + } + + def proxy_adyen_request(self, data): + ''' Necessary because Adyen's endpoints don't have CORS enabled ''' + self.ensure_one() + TIMEOUT = 10 + endpoint = 'https://terminal-api-live.adyen.com/async' + if self.adyen_test_mode: + endpoint = 'https://terminal-api-test.adyen.com/async' + + _logger.info('request to adyen\n%s', pprint.pformat(data)) + headers = { + 'x-api-key': self.adyen_api_key, + 'Content-Type': 'application/json' + } + req = requests.post(endpoint, data=json.dumps(data), headers=headers, timeout=TIMEOUT) + _logger.info('response from adyen (HTTP status %s):\n%s', req.status_code, req.text) + + # Authentication error doesn't return JSON + if req.status_code == 401: + return { + 'error': { + 'status_code': req.status_code, + 'message': req.text + } + } + + if req.text == 'ok': + return True + + return req.json() diff --git a/addons/pos_adyen/static/src/js/models.js b/addons/pos_adyen/static/src/js/models.js new file mode 100644 index 00000000000..f2734e00a94 --- /dev/null +++ b/addons/pos_adyen/static/src/js/models.js @@ -0,0 +1,7 @@ +odoo.define('pos_adyen.models', function (require) { +var models = require('point_of_sale.models'); +var PaymentAdyen = require('pos_adyen.payment'); + +models.register_payment_method('adyen', PaymentAdyen); +models.load_fields('pos.payment.method', 'adyen_terminal_identifier'); +}); diff --git a/addons/pos_adyen/static/src/js/payment_adyen.js b/addons/pos_adyen/static/src/js/payment_adyen.js new file mode 100644 index 00000000000..fbf4549a5f3 --- /dev/null +++ b/addons/pos_adyen/static/src/js/payment_adyen.js @@ -0,0 +1,308 @@ +odoo.define('pos_adyen.payment', function (require) { +"use strict"; + +var core = require('web.core'); +var rpc = require('web.rpc'); +var PaymentInterface = require('point_of_sale.PaymentInterface'); + +var _t = core._t; + +var PaymentAdyen = PaymentInterface.extend({ + send_payment_request: function (cid) { + this._super.apply(this, arguments); + this._reset_state(); + return this._adyen_pay(); + }, + send_payment_cancel: function (order, cid) { + this._super.apply(this, arguments); + // set only if we are polling + this.was_cancelled = !!this.polling; + return this._adyen_cancel(); + }, + close: function () { + this._super.apply(this, arguments); + }, + + // private methods + _reset_state: function () { + this.was_cancelled = false; + this.last_diagnosis_service_id = false; + this.remaining_polls = 2; + clearTimeout(this.polling); + }, + + _handle_odoo_connection_failure: function (data) { + // handle timeout + var line = this.pos.get_order().selected_paymentline; + if (line) { + line.set_payment_status('retry'); + } + this._show_error(_('Could not connect to the Odoo server, please check your internet connection and try again.')); + + return Promise.reject(data); // prevent subsequent onFullFilled's from being called + }, + + _call_adyen: function (data) { + var self = this; + return rpc.query({ + model: 'pos.payment.method', + method: 'proxy_adyen_request', + args: [[this.payment_method.id], data], + }, { + // When a payment terminal is disconnected it takes Adyen + // a while to return an error (~6s). So wait 10 seconds + // before concluding Odoo is unreachable. + timeout: 10000, + shadow: true, + }).catch(this._handle_odoo_connection_failure.bind(this)); + }, + + _adyen_get_sale_id: function () { + var config = this.pos.config; + return _.str.sprintf('%s (ID: %s)', config.display_name, config.id); + }, + + _adyen_common_message_header: function () { + var config = this.pos.config; + this.most_recent_service_id = Math.floor(Math.random() * Math.pow(2, 64)).toString(); // random ID to identify request/response pairs + this.most_recent_service_id = this.most_recent_service_id.substring(0, 10); // max length is 10 + + return { + 'ProtocolVersion': '3.0', + 'MessageClass': 'Service', + 'MessageType': 'Request', + 'SaleID': this._adyen_get_sale_id(config), + 'ServiceID': this.most_recent_service_id, + 'POIID': this.payment_method.adyen_terminal_identifier + }; + }, + + _adyen_pay_data: function () { + var order = this.pos.get_order(); + var config = this.pos.config; + var line = order.selected_paymentline; + var data = { + 'SaleToPOIRequest': { + 'MessageHeader': _.extend(this._adyen_common_message_header(), { + 'MessageCategory': 'Payment', + }), + 'PaymentRequest': { + 'SaleData': { + 'SaleTransactionID': { + 'TransactionID': order.uid, + 'TimeStamp': moment().format(), // iso format: '2018-01-10T11:30:15+00:00' + } + }, + 'PaymentTransaction': { + 'AmountsReq': { + 'Currency': this.pos.currency.name, + 'RequestedAmount': line.amount, + } + } + } + } + }; + + if (config.adyen_ask_customer_for_tip) { + data.SaleToPOIRequest.PaymentRequest.SaleData.SaleToAcquirerData = "tenderOption=AskGratuity"; + } + + return data; + }, + + _adyen_pay: function () { + var self = this; + var data = this._adyen_pay_data(); + + return this._call_adyen(data).then(function (data) { + return self._adyen_handle_response(data); + }); + }, + + _adyen_cancel: function (ignore_error) { + var previous_service_id = this.most_recent_service_id; + var header = _.extend(this._adyen_common_message_header(), { + 'MessageCategory': 'Abort', + }); + + var data = { + 'SaleToPOIRequest': { + 'MessageHeader': header, + 'AbortRequest': { + 'AbortReason': 'MerchantAbort', + 'MessageReference': { + 'MessageCategory': 'Payment', + 'SaleID': header.SaleID, + 'ServiceID': previous_service_id, + } + }, + } + }; + + return this._call_adyen(data).then(function (data) { + + // Only valid response is a 200 OK HTTP response which is + // represented by true. + if (! ignore_error && data !== true) { + self._show_error(_('Cancelling the payment failed. Please cancel it manually on the payment terminal.')); + } + }); + }, + + _convert_receipt_info: function (output_text) { + return output_text.reduce(function (acc, entry) { + var params = new URLSearchParams(entry.Text); + + if (params.get('name') && !params.get('value')) { + return acc + _.str.sprintf('
%s', params.get('name')); + } else if (params.get('name') && params.get('value')) { + return acc + _.str.sprintf('
%s: %s', params.get('name'), params.get('value')); + } + + return acc; + }, ''); + }, + + _poll_for_response: function (resolve, reject) { + var self = this; + if (this.was_cancelled) { + resolve(false); + return Promise.resolve(); + } + + return rpc.query({ + model: 'pos.payment.method', + method: 'get_latest_adyen_status', + args: [[this.payment_method.id], this._adyen_get_sale_id()], + }, { + timeout: 5000, + shadow: true, + }).catch(function (data) { + reject(); + return self._handle_odoo_connection_failure(data); + }).then(function (status) { + var notification = status.latest_response; + var last_diagnosis_service_id = status.last_received_diagnosis_id; + var order = self.pos.get_order(); + var line = order.selected_paymentline; + + + if (self.last_diagnosis_service_id != last_diagnosis_service_id) { + self.last_diagnosis_service_id = last_diagnosis_service_id; + self.remaining_polls = 2; + } else { + self.remaining_polls--; + } + + if (notification && notification.SaleToPOIResponse.MessageHeader.ServiceID == self.most_recent_service_id) { + var response = notification.SaleToPOIResponse.PaymentResponse.Response; + var additional_response = new URLSearchParams(response.AdditionalResponse); + + if (response.Result == 'Success') { + var config = self.pos.config; + var payment_response = notification.SaleToPOIResponse.PaymentResponse; + var payment_result = payment_response.PaymentResult; + var customer_receipt = payment_response.PaymentReceipt.find(function (receipt) { + return receipt.DocumentQualifier == 'CustomerReceipt'; + }); + + if (customer_receipt) { + line.set_receipt_info(self._convert_receipt_info(customer_receipt.OutputContent.OutputText)); + } + + var tip_amount = payment_result.AmountsResp.TipAmount; + if (config.adyen_ask_customer_for_tip && tip_amount > 0) { + order.set_tip(tip_amount); + line.set_amount(payment_result.AmountsResp.AuthorizedAmount); + } + + line.transaction_id = additional_response.get('pspReference'); + line.card_type = additional_response.get('cardType'); + resolve(true); + } else { + var message = additional_response.get('message'); + self._show_error(_.str.sprintf(_t('Message from Adyen: %s'), message)); + + // this means the transaction was cancelled by pressing the cancel button on the device + if (message.startsWith('108 ')) { + resolve(false); + } else { + line.set_payment_status('force_done'); + reject(); + } + } + } else if (self.remaining_polls <= 0) { + self._show_error(_t('The connection to your payment terminal failed. Please check if it is still connected to the internet.')); + self._adyen_cancel(); + resolve(false); + } + }); + }, + + _adyen_handle_response: function (response) { + var line = this.pos.get_order().selected_paymentline; + + if (response.error && response.error.status_code == 401) { + this._show_error(_t('Authentication failed. Please check your Adyen credentials.')); + line.set_payment_status('force_done'); + return Promise.resolve(); + } + + response = response.SaleToPOIRequest; + if (response && response.EventNotification && response.EventNotification.EventToNotify == 'Reject') { + console.error('error from Adyen', response); + + var msg = ''; + if (response.EventNotification) { + var params = new URLSearchParams(response.EventNotification.EventDetails); + msg = params.get('message'); + } + + this._show_error(_.str.sprintf(_t('An unexpected error occured. Message from Adyen: %s'), msg)); + if (line) { + line.set_payment_status('force_done'); + } + + return Promise.resolve(); + } else { + line.set_payment_status('waitingCard'); + + // This is not great, the payment screen should be + // refactored so it calls render_paymentlines whenever a + // paymentline changes. This way the call to + // set_payment_status would re-render it automatically. + this.pos.chrome.gui.current_screen.render_paymentlines(); + + var self = this; + var res = new Promise(function (resolve, reject) { + // clear previous intervals just in case, otherwise + // it'll run forever + clearTimeout(self.polling); + + self.polling = setInterval(function () { + self._poll_for_response(resolve, reject); + }, 3000); + }); + + // make sure to stop polling when we're done + res.finally(function () { + self._reset_state(); + }); + + return res; + } + }, + + _show_error: function (msg, title) { + if (!title) { + title = _t('Adyen Error'); + } + this.pos.gui.show_popup('error',{ + 'title': title, + 'body': msg, + }); + }, +}); + +return PaymentAdyen; +}); diff --git a/addons/pos_adyen/views/point_of_sale_assets.xml b/addons/pos_adyen/views/point_of_sale_assets.xml new file mode 100644 index 00000000000..bd738dcfccc --- /dev/null +++ b/addons/pos_adyen/views/point_of_sale_assets.xml @@ -0,0 +1,9 @@ + + + + diff --git a/addons/pos_adyen/views/pos_config_views.xml b/addons/pos_adyen/views/pos_config_views.xml new file mode 100644 index 00000000000..72d6b395bda --- /dev/null +++ b/addons/pos_adyen/views/pos_config_views.xml @@ -0,0 +1,26 @@ + + + + pos.config.form.view.inherit.pos_adyen + pos.config + + + +

Adyen

+
+
+
+ +
+
+
+
+
+
+
+
+
diff --git a/addons/pos_adyen/views/pos_payment_method_views.xml b/addons/pos_adyen/views/pos_payment_method_views.xml new file mode 100644 index 00000000000..50aa6b39cb0 --- /dev/null +++ b/addons/pos_adyen/views/pos_payment_method_views.xml @@ -0,0 +1,18 @@ + + + + pos.payment.method.form.inherit.adyen + pos.payment.method + + + + + + + + + + + +