diff --git a/addons/payment_buckaroo/tests/test_buckaroo.py b/addons/payment_buckaroo/tests/test_buckaroo.py
index b105a54b2d2..b4dec515497 100644
--- a/addons/payment_buckaroo/tests/test_buckaroo.py
+++ b/addons/payment_buckaroo/tests/test_buckaroo.py
@@ -73,6 +73,21 @@ class BuckarooTest(BuckarooCommon, PaymentHttpCommon):
self._make_http_post_request(url, data=self.async_notification_data)
self.assertEqual(tx.state, 'done')
+ @mute_logger('odoo.addons.payment_buckaroo.controllers.main')
+ def test_webhook_notification_triggers_signature_check(self):
+ """ Test that receiving a webhook notification triggers a signature check. """
+ self.create_transaction('redirect')
+ url = self._build_url(BuckarooController._return_url)
+ with patch(
+ 'odoo.addons.payment_buckaroo.controllers.main.BuckarooController'
+ '._verify_notification_signature'
+ ) as signature_check_mock, patch(
+ 'odoo.addons.payment.models.payment_transaction.PaymentTransaction'
+ '._handle_notification_data'
+ ):
+ self._make_http_post_request(url, data=self.async_notification_data)
+ self.assertEqual(signature_check_mock.call_count, 1)
+
def test_accept_notification_with_valid_signature(self):
""" Test the verification of a notification with a valid signature. """
tx = self.create_transaction('redirect')
diff --git a/addons/payment_payulatam/controllers/main.py b/addons/payment_payulatam/controllers/main.py
index fce44ee736a..59bf31b781e 100644
--- a/addons/payment_payulatam/controllers/main.py
+++ b/addons/payment_payulatam/controllers/main.py
@@ -3,17 +3,115 @@
import logging
import pprint
+from werkzeug.exceptions import Forbidden
+
from odoo import http
+from odoo.exceptions import ValidationError
from odoo.http import request
+from odoo.tools import consteq
_logger = logging.getLogger(__name__)
class PayuLatamController(http.Controller):
_return_url = '/payment/payulatam/return'
+ _webhook_url = '/payment/payulatam/webhook'
@http.route(_return_url, type='http', auth='public', methods=['GET'])
- def payulatam_return(self, **data):
+ def payulatam_return_from_checkout(self, **data):
+ """ Process the notification data sent by PayU Latam after redirection from checkout.
+
+ See http://developers.payulatam.com/latam/en/docs/integrations/webcheckout-integration/response-page.html.
+
+ :param dict data: The notification data
+ """
_logger.info("handling redirection from PayU Latam with data:\n%s", pprint.pformat(data))
- request.env['payment.transaction'].sudo()._handle_notification_data('payulatam', data)
+
+ # Check the integrity of the notification
+ tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_notification_data(
+ 'payulatam', data
+ )
+ self._verify_notification_signature(data, tx_sudo)
+
+ # Handle the notification data
+ tx_sudo._handle_notification_data('payulatam', data)
return request.redirect('/payment/status')
+
+ @http.route(_webhook_url, type='http', auth='public', methods=['POST'], csrf=False)
+ def payulatam_webhook(self, **raw_data):
+ """ Process the notification data sent by PayU Latam to the webhook.
+
+ See http://developers.payulatam.com/latam/en/docs/integrations/webcheckout-integration/confirmation-page.html.
+
+ :param dict raw_data: The un-formatted notification data
+ :return: An empty string to acknowledge the notification
+ :rtype: str
+ """
+ _logger.info(
+ "notification received from PayU Latam with data:\n%s", pprint.pformat(raw_data)
+ )
+ data = self._normalize_data_keys(raw_data)
+
+ try:
+ # Check the origin and integrity of the notification
+ tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_notification_data(
+ 'payulatam', data
+ )
+ self._verify_notification_signature(data, tx_sudo) # Use the normalized data.
+
+ # Handle the notification data
+ tx_sudo._handle_notification_data('payulatam', 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(webhook_notification_data):
+ """ Reshape the webhook notification data to process them as redirect notification data.
+
+ :param dict webhook_notification_data: The webhook notification data
+ :return: The normalized notification data
+ :rtype: dict
+ """
+ state_pol = webhook_notification_data.get('state_pol')
+ if state_pol == '4':
+ lap_transaction_state = 'APPROVED'
+ elif state_pol == '6':
+ lap_transaction_state = 'DECLINED'
+ elif state_pol == '5':
+ lap_transaction_state = 'EXPIRED'
+ else:
+ lap_transaction_state = f'INVALID state_pol {state_pol}'
+ return {
+ 'lapTransactionState': lap_transaction_state,
+ 'transactionState': webhook_notification_data.get('state_pol'),
+ 'TX_VALUE': webhook_notification_data.get('value'),
+ 'currency': webhook_notification_data.get('currency'),
+ 'referenceCode': webhook_notification_data.get('reference_sale'),
+ 'transactionId': webhook_notification_data.get('transaction_id'),
+ 'message': webhook_notification_data.get('response_message_pol'),
+ 'signature': webhook_notification_data.get('sign'),
+ }
+
+ @staticmethod
+ def _verify_notification_signature(notification_data, tx_sudo):
+ """ Check that the received signature matches the expected one.
+
+ :param dict notification_data: The notification data
+ :param recordset tx_sudo: The sudoed transaction referenced by the notification data, as a
+ `payment.transaction` record
+ :return: None
+ :raise: :class:`werkzeug.exceptions.Forbidden` if the signatures don't match
+ """
+ # Retrieve the received signature from the data
+ received_signature = notification_data.get('signature')
+ if not received_signature:
+ _logger.warning("received notification with missing signature")
+ raise Forbidden()
+
+ # Compare the received signature with the expected signature computed from the data
+ expected_signature = tx_sudo.acquirer_id._payulatam_generate_sign(notification_data)
+ if not consteq(received_signature, expected_signature):
+ _logger.warning("received notification with invalid signature")
+ raise Forbidden()
diff --git a/addons/payment_payulatam/models/payment_transaction.py b/addons/payment_payulatam/models/payment_transaction.py
index b0193b2ca07..d857ce8a14c 100644
--- a/addons/payment_payulatam/models/payment_transaction.py
+++ b/addons/payment_payulatam/models/payment_transaction.py
@@ -63,6 +63,7 @@ class PaymentTransaction(models.Model):
api_url = 'https://checkout.payulatam.com/ppp-web-gateway-payu/' \
if self.acquirer_id.state == 'enabled' \
else 'https://sandbox.checkout.payulatam.com/ppp-web-gateway-payu/'
+ base_url = self.get_base_url()
payulatam_values = {
'merchantId': self.acquirer_id.payulatam_merchant_id,
'referenceCode': self.reference,
@@ -74,7 +75,8 @@ class PaymentTransaction(models.Model):
'accountId': self.acquirer_id.payulatam_account_id,
'buyerFullName': self.partner_name,
'buyerEmail': self.partner_email,
- 'responseUrl': urls.url_join(self.get_base_url(), PayuLatamController._return_url),
+ 'responseUrl': urls.url_join(base_url, PayuLatamController._return_url),
+ 'confirmationUrl': urls.url_join(base_url, PayuLatamController._webhook_url),
'api_url': api_url,
}
if self.acquirer_id.state != 'enabled':
@@ -91,40 +93,19 @@ class PaymentTransaction(models.Model):
:param dict notification_data: The notification data sent by the provider
:return: The transaction if found
:rtype: recordset of `payment.transaction`
- :raise: ValidationError if inconsistent data were received
:raise: ValidationError if the data match no transaction
- :raise: ValidationError if the signature can not be verified
"""
tx = super()._get_tx_from_notification_data(provider, notification_data)
if provider != 'payulatam' or len(tx) == 1:
return tx
reference = notification_data.get('referenceCode')
- sign = notification_data.get('signature')
- if not reference or not sign:
- raise ValidationError(
- "PayU Latam: " + _(
- "Received data with missing reference (%(ref)s) or sign (%(sign)s).",
- ref=reference, sign=sign
- )
- )
-
tx = self.search([('reference', '=', reference), ('provider', '=', 'payulatam')])
if not tx:
raise ValidationError(
"PayU Latam: " + _("No transaction found matching reference %s.", reference)
)
- # Verify signature
- sign_check = tx.acquirer_id._payulatam_generate_sign(notification_data, incoming=True)
- if not hmac.compare_digest(sign_check, sign):
- raise ValidationError(
- "PayU Latam: " + _(
- "Invalid sign: received %(sign)s, computed %(check)s.",
- sign=sign, check=sign_check
- )
- )
-
return tx
def _process_notification_data(self, notification_data):
diff --git a/addons/payment_payulatam/tests/common.py b/addons/payment_payulatam/tests/common.py
index b26a2507ad2..c06eef0e0e0 100644
--- a/addons/payment_payulatam/tests/common.py
+++ b/addons/payment_payulatam/tests/common.py
@@ -18,3 +18,13 @@ class PayULatamCommon(PaymentCommon):
# Override default values
cls.acquirer = cls.payulatam
cls.currency = cls.currency_euro
+
+ cls.async_notification_data = {
+ 'currency': cls.currency.name,
+ 'reference_sale': cls.reference,
+ 'response_message_pol': 'APPROVED',
+ 'sign': '6b4728ddb01317af58f92b8accdb4a42',
+ 'state_pol': '4',
+ 'transaction_id': '7008bc34-8258-4857-b866-7d4d7982bd73',
+ 'value': str(cls.amount)
+ }
diff --git a/addons/payment_payulatam/tests/test_payulatam.py b/addons/payment_payulatam/tests/test_payulatam.py
index b8449f9062c..7d4cfe1a7d5 100644
--- a/addons/payment_payulatam/tests/test_payulatam.py
+++ b/addons/payment_payulatam/tests/test_payulatam.py
@@ -1,19 +1,23 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from unittest.mock import patch
+
from freezegun import freeze_time
+from werkzeug.exceptions import Forbidden
from odoo.exceptions import ValidationError
from odoo.fields import Command
from odoo.tests import tagged
from odoo.tools import mute_logger
-from .common import PayULatamCommon
-from ..controllers.main import PayuLatamController
-from ..models.payment_acquirer import SUPPORTED_CURRENCIES
+from odoo.addons.payment.tests.http_common import PaymentHttpCommon
+from odoo.addons.payment_payulatam.controllers.main import PayuLatamController
+from odoo.addons.payment_payulatam.models.payment_acquirer import SUPPORTED_CURRENCIES
+from odoo.addons.payment_payulatam.tests.common import PayULatamCommon
@tagged('post_install', '-at_install')
-class PayULatamTest(PayULatamCommon):
+class PayULatamTest(PayULatamCommon, PaymentHttpCommon):
def test_compatibility_with_supported_currencies(self):
""" Test that the PayULatam acquirer is compatible with all supported currencies. """
@@ -67,6 +71,7 @@ class PayULatamTest(PayULatamCommon):
'buyerEmail': self.partner.email,
'buyerFullName': self.partner.name,
'responseUrl': self._build_url(PayuLatamController._return_url),
+ 'confirmationUrl': self._build_url(PayuLatamController._webhook_url),
'test': str(1), # testing is always performed in test mode
}
expected_values['signature'] = self.payulatam._payulatam_generate_sign(
@@ -147,6 +152,63 @@ class PayULatamTest(PayULatamCommon):
self.assertEqual(tx.state, 'done', 'Payulatam: wrong state after receiving a valid pending notification')
self.assertEqual(tx.acquirer_reference, 'b232989a-4aa8-42d1-bace-153236eee791', 'Payulatam: wrong txn_id after receiving a valid pending notification')
+ @mute_logger('odoo.addons.payment_payulatam.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(PayuLatamController._webhook_url)
+ with patch(
+ 'odoo.addons.payment_payulatam.controllers.main.PayuLatamController'
+ '._verify_notification_signature'
+ ):
+ self._make_http_post_request(url, data=self.async_notification_data)
+ self.assertEqual(tx.state, 'done')
+
+ @mute_logger('odoo.addons.payment_payulatam.controllers.main')
+ def test_webhook_notification_triggers_signature_check(self):
+ """ Test that receiving a webhook notification triggers a signature check. """
+ self.create_transaction('redirect')
+ url = self._build_url(PayuLatamController._webhook_url)
+ with patch(
+ 'odoo.addons.payment_payulatam.controllers.main.PayuLatamController'
+ '._verify_notification_signature'
+ ) as signature_check_mock, patch(
+ 'odoo.addons.payment.models.payment_transaction.PaymentTransaction'
+ '._handle_notification_data'
+ ):
+ self._make_http_post_request(url, data=self.async_notification_data)
+ self.assertEqual(signature_check_mock.call_count, 1)
+
+ def test_accept_notification_with_valid_signature(self):
+ """ Test the verification of a notification with a valid signature. """
+ tx = self.create_transaction('redirect')
+ payload = PayuLatamController._normalize_data_keys(self.async_notification_data)
+ self._assert_does_not_raise(
+ Forbidden, PayuLatamController._verify_notification_signature, payload, tx
+ )
+
+ @mute_logger('odoo.addons.payment_payulatam.controllers.main')
+ def test_reject_notification_with_missing_signature(self):
+ """ Test the verification of a notification with a missing signature. """
+ tx = self.create_transaction('redirect')
+ payload = PayuLatamController._normalize_data_keys(
+ dict(self.async_notification_data, sign=None)
+ )
+ self.assertRaises(
+ Forbidden, PayuLatamController._verify_notification_signature, payload, tx
+ )
+
+ @mute_logger('odoo.addons.payment_payulatam.controllers.main')
+ def test_reject_notification_with_invalid_signature(self):
+ """ Test the verification of a notification with an invalid signature. """
+ tx = self.create_transaction('redirect')
+ payload = PayuLatamController._normalize_data_keys(
+ dict(self.async_notification_data, sign='dummy')
+ )
+ self.assertRaises(
+ Forbidden, PayuLatamController._verify_notification_signature, payload, tx
+ )
+
def test_payulatam_neutralize(self):
self.env['payment.acquirer']._neutralize()
diff --git a/addons/payment_payulatam/views/payment_payulatam_templates.xml b/addons/payment_payulatam/views/payment_payulatam_templates.xml
index 2a34ae4520e..070fb945490 100644
--- a/addons/payment_payulatam/views/payment_payulatam_templates.xml
+++ b/addons/payment_payulatam/views/payment_payulatam_templates.xml
@@ -18,6 +18,7 @@
+