[IMP] payment_payulatam: support webhook notifications
task-2701097 opw-2627875 closes odoo/odoo#84581 X-original-commit: b82e6788921fc1f3b1ebeeb13eb79619416932f8 Signed-off-by: Antoine Vandevenne (anv) <anv@odoo.com>
This commit is contained in:
committed by
Antoine Vandevenne (anv)
parent
90cf777a4d
commit
b9d3527c46
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<input type="hidden" name="buyerFullName" t-att-value="buyerFullName"/>
|
||||
<input type="hidden" name="buyerEmail" t-att-value="buyerEmail"/>
|
||||
<input type="hidden" name="responseUrl" t-att-value="responseUrl"/>
|
||||
<input type="hidden" name="confirmationUrl" t-att-value="confirmationUrl"/>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user