226 lines
10 KiB
Python
226 lines
10 KiB
Python
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||
|
|
||
|
import logging
|
||
|
import pprint
|
||
|
|
||
|
import requests
|
||
|
from werkzeug import urls
|
||
|
from werkzeug.exceptions import Forbidden
|
||
|
|
||
|
from odoo import _, http
|
||
|
from odoo.exceptions import ValidationError
|
||
|
from odoo.http import request
|
||
|
from odoo.tools import html_escape
|
||
|
|
||
|
from odoo.addons.payment import utils as payment_utils
|
||
|
|
||
|
|
||
|
_logger = logging.getLogger(__name__)
|
||
|
|
||
|
|
||
|
class PaypalController(http.Controller):
|
||
|
_return_url = '/payment/paypal/return/'
|
||
|
_cancel_url = '/payment/paypal/cancel/'
|
||
|
_webhook_url = '/payment/paypal/webhook/'
|
||
|
|
||
|
@http.route(
|
||
|
_return_url, type='http', auth='public', methods=['GET', 'POST'], csrf=False,
|
||
|
save_session=False
|
||
|
)
|
||
|
def paypal_return_from_checkout(self, **pdt_data):
|
||
|
""" Process the PDT notification sent by PayPal after redirection from checkout.
|
||
|
|
||
|
The PDT (Payment Data Transfer) notification contains the parameters necessary to verify the
|
||
|
origin of the notification and retrieve the actual notification data, if PDT is enabled on
|
||
|
the account. See https://developer.paypal.com/api/nvp-soap/payment-data-transfer/.
|
||
|
|
||
|
The route accepts both GET and POST requests because PayPal seems to switch between the two
|
||
|
depending on whether PDT is enabled, whether the customer pays anonymously (without logging
|
||
|
in on PayPal), whether they click on "Return to Merchant" after paying, etc.
|
||
|
|
||
|
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
|
||
|
cookie is created without a `SameSite` attribute, some browsers that don't implement the
|
||
|
recommended default `SameSite=Lax` behavior will not include the cookie in the redirection
|
||
|
request from the payment provider to Odoo. As the redirection to the '/payment/status' page
|
||
|
will satisfy any specification of the `SameSite` attribute, the session of the user will be
|
||
|
retrieved and with it the transaction which will be immediately post-processed.
|
||
|
|
||
|
:param dict pdt_data: The PDT notification data send by PayPal.
|
||
|
"""
|
||
|
_logger.info("Handling redirection from PayPal with data:\n%s", pprint.pformat(pdt_data))
|
||
|
|
||
|
tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_notification_data(
|
||
|
'paypal', pdt_data
|
||
|
)
|
||
|
try:
|
||
|
notification_data = self._verify_pdt_notification_origin(pdt_data, tx_sudo)
|
||
|
except Forbidden:
|
||
|
_logger.exception("Could not verify the origin of the PDT; discarding it.")
|
||
|
else:
|
||
|
tx_sudo._handle_notification_data('paypal', notification_data)
|
||
|
|
||
|
return request.redirect('/payment/status')
|
||
|
|
||
|
@http.route(
|
||
|
_cancel_url, type='http', auth='public', methods=['GET'], csrf=False, save_session=False
|
||
|
)
|
||
|
def paypal_return_from_canceled_checkout(self, tx_ref, return_access_tkn):
|
||
|
""" Process the transaction after the customer has canceled the payment.
|
||
|
|
||
|
:param str tx_ref: The reference of the transaction having been canceled.
|
||
|
:param str return_access_tkn: The access token to verify the authenticity of the request.
|
||
|
PayPal forbids any parameter with the name "token" inside.
|
||
|
"""
|
||
|
_logger.info(
|
||
|
"Handling redirection from Paypal for cancellation of transaction with reference %s",
|
||
|
tx_ref,
|
||
|
)
|
||
|
|
||
|
tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_notification_data(
|
||
|
'paypal', {'item_number': tx_ref}
|
||
|
)
|
||
|
if not payment_utils.check_access_token(return_access_tkn, tx_ref):
|
||
|
raise Forbidden()
|
||
|
tx_sudo._handle_notification_data('paypal', {})
|
||
|
|
||
|
return request.redirect('/payment/status')
|
||
|
|
||
|
def _verify_pdt_notification_origin(self, pdt_data, tx_sudo):
|
||
|
""" Validate the authenticity of a PDT and return the retrieved notification data.
|
||
|
|
||
|
The validation is done in four steps:
|
||
|
|
||
|
1. Make a POST request to Paypal with `tx`, the GET param received with the PDT data, and
|
||
|
with the two other required params `cmd` and `at`.
|
||
|
2. PayPal sends back a response text starting with either 'SUCCESS' or 'FAIL'. If the
|
||
|
validation was a success, the notification data are appended to the response text as a
|
||
|
string formatted as follows: 'SUCCESS\nparam1=value1\nparam2=value2\n...'
|
||
|
3. Extract the notification data and process these instead of the PDT.
|
||
|
4. Return an empty HTTP 200 response (done at the end of the route controller).
|
||
|
|
||
|
See https://developer.paypal.com/docs/api-basics/notifications/payment-data-transfer/.
|
||
|
|
||
|
:param dict pdt_data: The PDT data whose authenticity must be checked.
|
||
|
:param recordset tx_sudo: The sudoed transaction referenced in the PDT, as a
|
||
|
`payment.transaction` record
|
||
|
:return: The retrieved notification data
|
||
|
:raise :class:`werkzeug.exceptions.Forbidden`: if the notification origin can't be verified
|
||
|
"""
|
||
|
if 'tx' not in pdt_data: # PDT is not enabled; PayPal directly sent the notification data.
|
||
|
tx_sudo._log_message_on_linked_documents(_(
|
||
|
"The status of transaction with reference %(ref)s was not synchronized because the "
|
||
|
"'Payment data transfer' option is not enabled on the PayPal dashboard.",
|
||
|
ref=tx_sudo.reference,
|
||
|
))
|
||
|
raise Forbidden("PayPal: PDT are not enabled; cannot verify data origin")
|
||
|
else: # The PayPal account is configured to send PDT data.
|
||
|
# Request a PDT data authenticity check and the notification data to PayPal.
|
||
|
provider_sudo = tx_sudo.provider_id
|
||
|
url = provider_sudo._paypal_get_api_url()
|
||
|
payload = {
|
||
|
'cmd': '_notify-synch',
|
||
|
'tx': pdt_data['tx'],
|
||
|
'at': tx_sudo.provider_id.paypal_pdt_token,
|
||
|
}
|
||
|
try:
|
||
|
response = requests.post(url, data=payload, timeout=10)
|
||
|
response.raise_for_status()
|
||
|
except (requests.exceptions.ConnectionError, requests.exceptions.HTTPError):
|
||
|
raise Forbidden("PayPal: Encountered an error when verifying PDT origin")
|
||
|
else:
|
||
|
notification_data = self._parse_pdt_validation_response(response.text)
|
||
|
if notification_data is None:
|
||
|
raise Forbidden("PayPal: The PDT origin was not verified by PayPal")
|
||
|
|
||
|
return notification_data
|
||
|
|
||
|
@staticmethod
|
||
|
def _parse_pdt_validation_response(response_content):
|
||
|
""" Parse the PDT validation request response and return the parsed notification data.
|
||
|
|
||
|
:param str response_content: The PDT validation request response
|
||
|
:return: The parsed notification data
|
||
|
:rtype: dict
|
||
|
"""
|
||
|
response_items = response_content.splitlines()
|
||
|
if response_items[0] == 'SUCCESS':
|
||
|
notification_data = {}
|
||
|
for notification_data_param in response_items[1:]:
|
||
|
key, raw_value = notification_data_param.split('=', 1)
|
||
|
notification_data[key] = urls.url_unquote_plus(raw_value)
|
||
|
return notification_data
|
||
|
return None
|
||
|
|
||
|
@http.route(_webhook_url, type='http', auth='public', methods=['GET', 'POST'], csrf=False)
|
||
|
def paypal_webhook(self, **data):
|
||
|
""" Process the notification data (IPN) sent by PayPal to the webhook.
|
||
|
|
||
|
The "Instant Payment Notification" is a classical webhook notification.
|
||
|
See https://developer.paypal.com/api/nvp-soap/ipn/.
|
||
|
|
||
|
:param dict data: The notification data
|
||
|
:return: An empty string to acknowledge the notification
|
||
|
:rtype: str
|
||
|
"""
|
||
|
_logger.info("notification received from PayPal with data:\n%s", pprint.pformat(data))
|
||
|
try:
|
||
|
# Check the origin and integrity of the notification
|
||
|
tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_notification_data(
|
||
|
'paypal', data
|
||
|
)
|
||
|
self._verify_webhook_notification_origin(data, tx_sudo)
|
||
|
|
||
|
# Handle the notification data
|
||
|
tx_sudo._handle_notification_data('paypal', data)
|
||
|
except ValidationError: # Acknowledge the notification to avoid getting spammed
|
||
|
_logger.warning(
|
||
|
"unable to handle the notification data; skipping to acknowledge", exc_info=True
|
||
|
)
|
||
|
return ''
|
||
|
|
||
|
@staticmethod
|
||
|
def _verify_webhook_notification_origin(notification_data, tx_sudo):
|
||
|
""" Check that the notification was sent by PayPal.
|
||
|
|
||
|
The verification is done in three steps:
|
||
|
|
||
|
1. POST the complete message back to Paypal with the additional param
|
||
|
`cmd=_notify-validate`, in the same encoding.
|
||
|
2. PayPal sends back either 'VERIFIED' or 'INVALID'.
|
||
|
3. Return an empty HTTP 200 response if the notification origin is verified by PayPal, raise
|
||
|
an HTTP 403 otherwise.
|
||
|
|
||
|
See https://developer.paypal.com/docs/api-basics/notifications/ipn/IPNIntro/.
|
||
|
|
||
|
:param dict notification_data: The notification data
|
||
|
:param recordset tx_sudo: The sudoed transaction referenced in the notification data, as a
|
||
|
`payment.transaction` record
|
||
|
:return: None
|
||
|
:raise: :class:`werkzeug.exceptions.Forbidden` if the notification origin can't be verified
|
||
|
"""
|
||
|
# Request PayPal for an authenticity check
|
||
|
url = tx_sudo.provider_id._paypal_get_api_url()
|
||
|
payload = dict(notification_data, cmd='_notify-validate')
|
||
|
try:
|
||
|
response = requests.post(url, payload, timeout=60)
|
||
|
response.raise_for_status()
|
||
|
except (requests.exceptions.ConnectionError, requests.exceptions.HTTPError) as error:
|
||
|
_logger.exception(
|
||
|
"could not verify notification origin at %(url)s with data: %(data)s:\n%(error)s",
|
||
|
{
|
||
|
'url': url,
|
||
|
'data': pprint.pformat(notification_data),
|
||
|
'error': pprint.pformat(error.response.text),
|
||
|
},
|
||
|
)
|
||
|
raise Forbidden()
|
||
|
else:
|
||
|
response_content = response.text
|
||
|
if response_content != 'VERIFIED':
|
||
|
_logger.warning(
|
||
|
"PayPal did not confirm the origin of the notification with data:\n%s",
|
||
|
pprint.pformat(notification_data),
|
||
|
)
|
||
|
raise Forbidden()
|