142 lines
6.0 KiB
Python
142 lines
6.0 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import json
|
|
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.addons.payment import utils as payment_utils
|
|
from odoo.addons.payment_paypal import const
|
|
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class PaypalController(http.Controller):
|
|
_complete_url = '/payment/paypal/complete_order'
|
|
_webhook_url = '/payment/paypal/webhook/'
|
|
|
|
@http.route(_complete_url, type='json', auth='public', methods=['POST'])
|
|
def paypal_complete_order(self, provider_id, order_id, reference=None):
|
|
""" Make a capture request and handle the notification data.
|
|
|
|
:param int provider_id: The provider handling the transaction, as a `payment.provider` id.
|
|
:param string order_id: The order id provided by PayPal to identify the order.
|
|
:param str reference: The reference of the transaction used to generate idempotency key.
|
|
:return: None
|
|
"""
|
|
provider_sudo = request.env['payment.provider'].browse(provider_id).sudo()
|
|
idempotency_key = None
|
|
if reference:
|
|
tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_notification_data(
|
|
'paypal', {'reference_id': reference}
|
|
)
|
|
idempotency_key = payment_utils.generate_idempotency_key(
|
|
tx_sudo, scope='payment_request_controller'
|
|
)
|
|
response = provider_sudo._paypal_make_request(
|
|
f'/v2/checkout/orders/{order_id}/capture', idempotency_key=idempotency_key
|
|
)
|
|
normalized_response = self._normalize_paypal_data(response)
|
|
tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_notification_data(
|
|
'paypal', normalized_response
|
|
)
|
|
tx_sudo._handle_notification_data('paypal', normalized_response)
|
|
|
|
@http.route(_webhook_url, type='http', auth='public', methods=['POST'], csrf=False)
|
|
def paypal_webhook(self):
|
|
""" Process the notification data sent by PayPal to the webhook.
|
|
|
|
See https://developer.paypal.com/docs/api/webhooks/v1/.
|
|
|
|
:return: An empty string to acknowledge the notification.
|
|
:rtype: str
|
|
"""
|
|
data = request.get_json_data()
|
|
if data.get('event_type') in const.HANDLED_WEBHOOK_EVENTS:
|
|
normalized_data = self._normalize_paypal_data(
|
|
data.get('resource'), from_webhook=True
|
|
)
|
|
_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', normalized_data
|
|
)
|
|
self._verify_notification_origin(data, tx_sudo)
|
|
|
|
# Handle the notification data.
|
|
tx_sudo._handle_notification_data('paypal', normalized_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 request.make_json_response('')
|
|
|
|
def _normalize_paypal_data(self, data, from_webhook=False):
|
|
""" Normalize the payment data received from PayPal.
|
|
|
|
The payment data received from PayPal has a different format depending on whether the data
|
|
come from the payment request response, or from the webhook.
|
|
|
|
:param dict data: The data to normalize.
|
|
:param bool from_webhook: Whether the data come from the webhook.
|
|
:return: The normalized data.
|
|
:rtype: dict
|
|
"""
|
|
purchase_unit = data['purchase_units'][0]
|
|
result = {
|
|
'payment_source': data['payment_source'].keys(),
|
|
'reference_id': purchase_unit.get('reference_id')
|
|
}
|
|
if from_webhook:
|
|
result.update({
|
|
**purchase_unit,
|
|
'txn_type': data.get('intent'),
|
|
'id': data.get('id'),
|
|
'status': data.get('status'),
|
|
})
|
|
else:
|
|
if captured := purchase_unit.get('payments', {}).get('captures'):
|
|
result.update({
|
|
**captured[0],
|
|
'txn_type': 'CAPTURE',
|
|
})
|
|
else:
|
|
raise ValidationError("PayPal: " + _("Invalid response format, can't normalize."))
|
|
return result
|
|
|
|
def _verify_notification_origin(self, notification_data, tx_sudo):
|
|
""" Check that the notification was sent by PayPal.
|
|
|
|
See https://developer.paypal.com/docs/api/webhooks/v1/#verify-webhook-signature_post.
|
|
|
|
: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 Forbidden: If the notification origin can't be verified.
|
|
"""
|
|
headers = request.httprequest.headers
|
|
data = json.dumps({
|
|
'transmission_id': headers.get('PAYPAL-TRANSMISSION-ID'),
|
|
'transmission_time': headers.get('PAYPAL-TRANSMISSION-TIME'),
|
|
'cert_url': headers.get('PAYPAL-CERT-URL'),
|
|
'auth_algo': headers.get('PAYPAL-AUTH-ALGO'),
|
|
'transmission_sig': headers.get('PAYPAL-TRANSMISSION-SIG'),
|
|
'webhook_id': tx_sudo.provider_id.paypal_webhook_id,
|
|
'webhook_event': notification_data,
|
|
})
|
|
verification = tx_sudo.provider_id._paypal_make_request(
|
|
'/v1/notifications/verify-webhook-signature', data=data
|
|
)
|
|
if verification.get('verification_status') != 'SUCCESS':
|
|
_logger.warning("Received notification that was not verified by PayPal.")
|
|
raise Forbidden()
|