From 54fa480c2e07922d2de4c07a09d4de9872bacdc5 Mon Sep 17 00:00:00 2001 From: "Antoine Vandevenne (anv)" Date: Mon, 17 Jan 2022 16:13:27 +0000 Subject: [PATCH] [IMP] payment_buckaroo: accept webhook notifications Before this commit, it was not possible for a Buckaroo acquirer to accept asynchronous notifications for payment updates. This is important because, without them, an acquirer can only rely on synchronous notifications (i.e. redirect requests from the provider's checkout page) which can be unreliable and do not allow receiving status updates, should payments take a bit longer to be confirmed by the provider. This commit adds a webhook controller whose route can be configured in Buckaroo Plaza (backend) to send asynchronous notifications. task-2687586 closes odoo/odoo#82922 Signed-off-by: Antoine Vandevenne (anv) Co-authored-by: Lucie Van Nieuwenhuyze --- addons/payment_buckaroo/controllers/main.py | 30 ++++++++++++++++++- addons/payment_buckaroo/tests/common.py | 13 ++++++++ .../payment_buckaroo/tests/test_buckaroo.py | 27 +++++++++++++---- 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/addons/payment_buckaroo/controllers/main.py b/addons/payment_buckaroo/controllers/main.py index 9bde082258f..069416a7e9e 100644 --- a/addons/payment_buckaroo/controllers/main.py +++ b/addons/payment_buckaroo/controllers/main.py @@ -7,6 +7,7 @@ import pprint from werkzeug.exceptions import Forbidden from odoo import http +from odoo.exceptions import ValidationError from odoo.http import request _logger = logging.getLogger(__name__) @@ -14,12 +15,13 @@ _logger = logging.getLogger(__name__) class BuckarooController(http.Controller): _return_url = '/payment/buckaroo/return' + _webhook_url = '/payment/buckaroo/webhook' @http.route( _return_url, type='http', auth='public', methods=['POST'], csrf=False, save_session=False ) def buckaroo_return_from_checkout(self, **raw_data): - """ Process the notification data returned by Buckaroo after redirection from checkout. + """ Process the notification data sent by Buckaroo after redirection from checkout. The route is flagged with `save_session=False` to prevent Odoo from assigning a new session to the user if they are redirected to this route with a POST request. Indeed, as the session @@ -45,6 +47,32 @@ class BuckarooController(http.Controller): request.env['payment.transaction'].sudo()._handle_feedback_data('buckaroo', data) return request.redirect('/payment/status') + @http.route(_webhook_url, type='http', auth='public', methods=['POST'], csrf=False) + def buckaroo_webhook(self, **raw_data): + """ Process the notification data sent by Buckaroo to the webhook. + + See https://www.pronamic.nl/wp-content/uploads/2013/04/BPE-3.0-Gateway-HTML.1.02.pdf. + + :param dict raw_data: The un-formatted notification data + :return: An empty string to acknowledge the notification + :rtype: str + """ + _logger.info("notification received from Buckaroo with data:\n%s", pprint.pformat(raw_data)) + data = self._normalize_data_keys(raw_data) + try: + # Check the integrity of the notification + received_signature = data.get('brq_signature') + tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_feedback_data( + 'buckaroo', data + ) + self._verify_notification_signature(raw_data, received_signature, tx_sudo) + + # Handle the notification data + request.env['payment.transaction'].sudo()._handle_feedback_data('buckaroo', data) + except ValidationError: # Acknowledge the notification to avoid getting spammed + _logger.exception("unable to handle the notification data; skipping to acknowledge") + return '' + @staticmethod def _normalize_data_keys(data): """ Set all keys of a dictionary to lower-case. diff --git a/addons/payment_buckaroo/tests/common.py b/addons/payment_buckaroo/tests/common.py index a0020e7ae53..a50147dd2f2 100644 --- a/addons/payment_buckaroo/tests/common.py +++ b/addons/payment_buckaroo/tests/common.py @@ -18,6 +18,19 @@ class BuckarooCommon(PaymentCommon): 'brq_signature': '5d389aa4f563cd99666a2e6bef79da3d4a32eb50', } + ASYNC_NOTIFICATION_DATA = { + 'brq_transactions': '0123456789ABCDEF0123456789ABCDEF', + 'brq_transaction_method': 'paypal', + 'brq_statuscode': '190', # confirmed + 'brq_statusmessage': 'Transaction successfully processed', + 'brq_invoicenumber': 'Test Transaction', # Shamefully copy-pasted from payment + 'brq_amount': '1111.11', + 'brq_currency': 'USD', + 'brq_timestamp': '2022-01-01 12:00:00', + 'brq_transaction_type': 'V010', + 'brq_signature': '9ba976c3a6a3d2d1b5b58d3aa8c2c6fe269a9c27', + } + @classmethod def setUpClass(cls, chart_template_ref=None): super().setUpClass(chart_template_ref=chart_template_ref) diff --git a/addons/payment_buckaroo/tests/test_buckaroo.py b/addons/payment_buckaroo/tests/test_buckaroo.py index 62e1ce93b1d..ff6b1fd978c 100644 --- a/addons/payment_buckaroo/tests/test_buckaroo.py +++ b/addons/payment_buckaroo/tests/test_buckaroo.py @@ -1,16 +1,19 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. +from unittest.mock import patch + from werkzeug.exceptions import Forbidden from odoo.tests import tagged from odoo.tools import mute_logger +from odoo.addons.payment.tests.http_common import PaymentHttpCommon from odoo.addons.payment_buckaroo.controllers.main import BuckarooController from odoo.addons.payment_buckaroo.tests.common import BuckarooCommon @tagged('post_install', '-at_install') -class BuckarooTest(BuckarooCommon): +class BuckarooTest(BuckarooCommon, PaymentHttpCommon): def test_redirect_form_values(self): return_url = self._build_url(BuckarooController._return_url) @@ -19,7 +22,7 @@ class BuckarooTest(BuckarooCommon): 'Brq_amount': str(self.amount), 'Brq_currency': self.currency.name, 'Brq_invoicenumber': self.reference, - 'Brq_signature': '669d4f64ea9cbb58cfefba9b802389667e4eef39', + 'Brq_signature': 'dacc220c3087edcc1200a38a6db0191c823e7f69', 'Brq_return': return_url, 'Brq_returncancel': return_url, 'Brq_returnerror': return_url, @@ -58,14 +61,26 @@ class BuckarooTest(BuckarooCommon): self.env['payment.transaction']._handle_feedback_data('buckaroo', notification_data) self.assertEqual(tx.state, 'error') + @mute_logger('odoo.addons.payment_buckaroo.controllers.main') + def test_webhook_notification_confirms_transaction(self): + """ Test the processing of a webhook notification. """ + tx = self.create_transaction('redirect') + url = self._build_url(BuckarooController._webhook_url) + with patch( + 'odoo.addons.payment_buckaroo.controllers.main.BuckarooController' + '._verify_notification_signature' + ): + self._make_http_post_request(url, data=self.ASYNC_NOTIFICATION_DATA) + self.assertEqual(tx.state, 'done') + def test_accept_notification_with_valid_signature(self): """ Test the verification of a notification with a valid signature. """ tx = self.create_transaction('redirect') self._assert_does_not_raise( Forbidden, BuckarooController._verify_notification_signature, - self.SYNC_NOTIFICATION_DATA, - self.SYNC_NOTIFICATION_DATA['brq_signature'], + self.ASYNC_NOTIFICATION_DATA, + self.ASYNC_NOTIFICATION_DATA['brq_signature'], tx, ) @@ -76,7 +91,7 @@ class BuckarooTest(BuckarooCommon): self.assertRaises( Forbidden, BuckarooController._verify_notification_signature, - self.SYNC_NOTIFICATION_DATA, + self.ASYNC_NOTIFICATION_DATA, None, tx, ) @@ -88,7 +103,7 @@ class BuckarooTest(BuckarooCommon): self.assertRaises( Forbidden, BuckarooController._verify_notification_signature, - self.SYNC_NOTIFICATION_DATA, + self.ASYNC_NOTIFICATION_DATA, 'dummy', tx, )