Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions addons/payment/controllers/portal.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ def _prepare_payment_form_values(
force_tokenization=force_tokenization,
is_express_checkout=is_express_checkout,
report=availability_report,
amount=amount,
**kwargs,
)._deduplicate_by_code(report=availability_report)

Expand Down
2 changes: 2 additions & 0 deletions addons/payment/models/payment_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -808,11 +808,13 @@ def _find_available_payment_methods(
force_tokenization=False,
is_express_checkout=False,
report=None,
amount=0.0, # noqa: ARG002
**_kwargs,
):
"""Find the providers' payment methods available for the given payment context.

:param int partner_id: The partner making the payment, as a `res.partner` id
:param float amount: The amount to pay (`0` for validation transactions)
:param int currency_id: The payment currency, as a `res.currency` id
:param bool force_tokenization: Whether payment methods must support tokenization
:param bool is_express_checkout: Whether payment methods must support express checkout
Expand Down
Binary file modified addons/payment/static/img/p24.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added addons/payment/static/img/paypal_paylater.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 0 additions & 1 deletion addons/payment_paypal/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
"views/payment_form_templates.xml",
"views/payment_paypal_templates.xml",
"views/payment_provider_views.xml",
"views/payment_transaction_views.xml",
"data/payment_method_data.xml",
"data/payment_provider_data.xml",
],
Expand Down
29 changes: 27 additions & 2 deletions addons/payment_paypal/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,25 @@
# The codes of the default primary payment methods to activate
DEFAULT_PAYMENT_METHOD_CODES = {"paypal"}

# Mapping of Odoo's local payment method codes to the payment source keys returned by PayPal's
# `find-eligible-methods` endpoint. Local codes that have no entry in this mapping are not
# affected by the eligibility check, e.g., because PayPal doesn't assess their eligibility.
# See https://docs.paypal.ai/api-reference/payments_payment_v2/find-eligible-methods.
PAYMENT_METHODS_MAPPING = {
"paypal": "paypal",
"venmo": "venmo",
"paypal_paylater": "paypal_pay_later",
"ideal": "ideal",
"card": "advanced_cards", # The `card` payment method is processed through ACDC.
"blik": "blik",
"p24": "p24",
"eps": "eps",
"bancontact": "bancontact",
"trustly": "trustly",
"mybank": "mybank",
"mulitbanco": "multibanco",
}

# Mapping of transaction states to PayPal payment statuses.
# See https://developer.paypal.com/docs/api/orders/v2/#definition-capture_status.
# See https://developer.paypal.com/api/rest/webhooks/event-names/#orders.
Expand All @@ -43,22 +62,28 @@
"PENDING",
"CREATED",
"APPROVED", # The buyer approved a checkout order.
"PAYER_ACTION_REQUIRED",
),
"done": ("COMPLETED", "CAPTURED"),
"cancel": ("DECLINED", "DENIED", "VOIDED"),
"error": ("FAILED",),
"cancel": ("CANCELED", "VOIDED"),
"error": ("FAILED", "DECLINED"),
}

# Events which are handled by the webhook.
# See https://developer.paypal.com/api/rest/webhooks/event-names/
CHECKOUT_WEBHOOK_EVENTS = [
"CHECKOUT.ORDER.COMPLETED",
"CHECKOUT.ORDER.APPROVED",
"CHECKOUT.ORDER.DECLINED",
"CHECKOUT.PAYMENT-APPROVAL.REVERSED",
]

SELLER_EMAIL_CONFIRMED_WEBHOOK = "CUSTOMER.MERCHANT-INTEGRATION.SELLER-EMAIL-CONFIRMED"

CAPTURE_WEBHOOK_EVENTS = ["PAYMENT.CAPTURE.COMPLETED", "PAYMENT.CAPTURE.DENIED"]

MERCHANT_WEBHOOK_EVENTS = ["CUSTOMER.MERCHANT-INTEGRATION.SELLER-EMAIL-CONFIRMED"]

# Odoo's public identifiers as a PayPal Partner used to offer merchant onboarding via Odoo.
ONBOARDING_REFERENCE = {
"partner_id": "QHZVTLZNWGSEW",
Expand Down
2 changes: 1 addition & 1 deletion addons/payment_paypal/controllers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.

from . import main, onboarding
from . import main, onboarding, portal
193 changes: 152 additions & 41 deletions addons/payment_paypal/controllers/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@

class PaypalController(http.Controller):
_complete_url = "/payment/paypal/complete_order"
_return_url = "/payment/paypal/return"
_cancel_url = "/payment/paypal/cancel"
_webhook_url = "/payment/paypal/webhook/"

@http.route(_complete_url, type="jsonrpc", auth="public", methods=["POST"])
Expand All @@ -35,21 +37,80 @@ def paypal_complete_order(self, order_id, reference):
._search_by_reference("paypal", {"reference_id": reference})
)
if tx_sudo:
idempotency_key = payment_utils.generate_idempotency_key(
tx_sudo, scope="payment_request_controller"
)
response = tx_sudo._send_api_request(
"POST", f"/v2/checkout/orders/{order_id}/capture", idempotency_key=idempotency_key
)
normalized_response = self._normalize_paypal_data(response)
tx_sudo = (
self
.env["payment.transaction"]
.sudo()
._search_by_reference("paypal", normalized_response)
)
if tx_sudo:
tx_sudo._record(normalized_response)
self._paypal_capture_order(tx_sudo, order_id)

@http.route(
_return_url,
type="http",
auth="public",
methods=["GET", "POST"],
csrf=False,
save_session=False,
)
def paypal_return_from_checkout(self, **data):
"""Process the payment data sent by PayPal after redirection from an alternative payment
method checkout.

:param dict data: The transaction reference embedded in the return URL, together
with the data appended by PayPal (e.g. `token`, `PayerID`).
"""
_logger.info("Handling redirection from PayPal with data:\n%s", pprint.pformat(data))
tx_sudo = (
self
.env["payment.transaction"]
.sudo()
._search_by_reference("paypal", {"reference_id": data.get("reference")})
)
if tx_sudo:
order_id = tx_sudo.provider_reference
try:
if tx_sudo.payment_method_code in {"paypal", "card"}:
self._paypal_capture_order(tx_sudo, order_id)
else:
order_details = tx_sudo._send_api_request(
"GET", f"/v2/checkout/orders/{order_id}"
)
normalized_data = self._normalize_paypal_data(order_details)
tx_sudo._record(normalized_data)
except ValidationError as e:
_logger.warning("Unable to complete the order with PayPal: %s", e)
tx_sudo.with_context(payment_safe_write=True)._set_error(str(e))
return request.redirect("/payment/status")

@http.route(
_cancel_url,
type="http",
auth="public",
methods=["GET", "POST"],
csrf=False,
save_session=False,
)
def paypal_cancel_payment(self, **data):
"""Process the payment cancellation initated by the customer sent by PayPal after
redirection from an alternative payment method checkout.

:param dict data: The transaction reference embedded in the return URL, together
with the data appended by PayPal (e.g. `token`, `PayerID`).
"""
_logger.info("Handling redirection from PayPal with data:\n%s", pprint.pformat(data))
tx_sudo = (
self
.env["payment.transaction"]
.sudo()
._search_by_reference("paypal", {"reference_id": data.get("reference")})
)
if tx_sudo:
try:
order_details = tx_sudo._send_api_request(
"GET", f"/v2/checkout/orders/{tx_sudo.provider_reference}"
)
except ValidationError:
_logger.warning("Unable to fetch the order details from PayPal.")
else:
normalized_data = self._normalize_paypal_data(order_details)
normalized_data["status"] = "CANCELED"
tx_sudo._record(normalized_data)
return request.redirect("/payment/status")

@http.route(_webhook_url, type="http", auth="public", methods=["POST"], csrf=False)
def paypal_webhook(self):
Expand All @@ -65,6 +126,8 @@ def paypal_webhook(self):
if event_type := data.get("event_type"):
if event_type in const.CHECKOUT_WEBHOOK_EVENTS:
self._handle_checkout_notification(data)
elif event_type in const.CAPTURE_WEBHOOK_EVENTS:
self._handle_capture_notification(data)
elif event_type == const.SELLER_EMAIL_CONFIRMED_WEBHOOK:
self._handle_merchant_notification(data)
return request.make_json_response("")
Expand All @@ -75,23 +138,46 @@ def _handle_checkout_notification(self, data):
:param dict data: The notification data sent by PayPal.
:return: None
"""
normalized_data = self._normalize_paypal_data(data.get("resource"), from_webhook=True)
normalized_data = self._normalize_paypal_data(data.get("resource"))
tx_sudo = (
self.env["payment.transaction"].sudo()._search_by_reference("paypal", normalized_data)
)
if not tx_sudo:
return

# Check the origin and integrity of the notification.
try:
self._verify_notification_origin(data, tx_sudo=tx_sudo)
except ValidationError:
tx_sudo.with_context(
# The verification request is idempotent; the handler is safe to replay.
payment_safe_write=True
)._set_error(self.env._("Unable to verify the payment data"))
else:
tx_sudo._record(normalized_data)
self._verify_notification_origin(data, tx_sudo=tx_sudo)
Comment thread
lkerroum marked this conversation as resolved.
Outdated
if data.get("event_type") == "CHECKOUT.ORDER.DECLINED":
normalized_data["status"] = "DECLINED"
if errors := normalized_data.get("most_recent_errors"):
normalized_data["state_message"] = errors[0].get("description")
tx_sudo._record(normalized_data)

def _handle_capture_notification(self, data):
"""Process a payment capture notification and record the payment on the transaction.

:param dict data: The notification data sent by PayPal.
:return: None
"""
resource = data.get("resource", {})
tx_sudo = self.env["payment.transaction"].sudo()
provider_reference = (
resource.get("supplementary_data", {}).get("related_ids", {}).get("order_id")
)
if provider_reference:
tx_sudo = tx_sudo.search(
[("provider_code", "=", "paypal"), ("provider_reference", "=", provider_reference)],
limit=1,
) # Instead of searching with provider reference possible to get order from PayPal.
if not tx_sudo:
return
self._verify_notification_origin(data, tx_sudo=tx_sudo)
normalized_data = {
"reference_id": tx_sudo.reference,
"id": resource.get("id"),
"status": resource.get("status"),
"amount": resource.get("amount"),
}
tx_sudo._record(normalized_data)

def _handle_merchant_notification(self, data):
"""Process a merchant onboarding notification and update the provider accordingly.
Expand All @@ -110,38 +196,32 @@ def _handle_merchant_notification(self, data):
)
if not provider_sudo:
return

self._verify_notification_origin(data, provider_sudo=provider_sudo)

provider_sudo.paypal_email_confirmed = True

def _normalize_paypal_data(self, data, from_webhook=False):
def _normalize_paypal_data(self, data, is_capture_request=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.
come from the payment request response (order creation or capture), or from the webhook.

:param dict data: The data to normalize.
:param bool from_webhook: Whether the data come from the webhook.
:param bool is_capture_request: Whether the data came from the capture api call.
:return: The normalized data.
:rtype: dict
"""
purchase_unit = data["purchase_units"][0]
result = {
"payment_source": data["payment_source"].keys(),
"payment_source": data.get("payment_source"),
"reference_id": purchase_unit.get("reference_id"),
"purchase_units": data.get("purchase_units"),
}
if from_webhook:
result.update({
**purchase_unit,
"txn_type": data.get("intent"),
"id": data.get("id"),
"status": data.get("status"),
})
if not is_capture_request:
result.update({**purchase_unit, "id": data.get("id"), "status": data.get("status")})
elif captured := purchase_unit.get("payments", {}).get("captures"):
result.update({**captured[0], "txn_type": "CAPTURE"})
result.update(captured[0])
else:
_logger.warning(self.env._("Invalid response format, can't normalize."))
_logger.warning("Invalid PayPal response format, can't normalize.")
return result

def _verify_notification_origin(self, payment_data, tx_sudo=None, provider_sudo=None):
Expand Down Expand Up @@ -176,3 +256,34 @@ def _verify_notification_origin(self, payment_data, tx_sudo=None, provider_sudo=
if verification.get("verification_status") != "SUCCESS":
_logger.warning("Received payment data that was not verified by PayPal.")
raise Forbidden

def _paypal_capture_order(self, tx_sudo, order_id):
"""Capture the order and record the resulting payment data on the transaction.

:param payment.transaction tx_sudo: The sudoed transaction to capture the order for.
:param str order_id: The order id provided by PayPal to identify the order.
:return: None
:raise ValidationError: If the 3D Secure authentication failed.
"""
if tx_sudo.payment_method_code == "card":
order_details = tx_sudo._send_api_request("GET", f"/v2/checkout/orders/{order_id}")
card_info = order_details.get("payment_source", {}).get("card", {})
auth_result = card_info.get("authentication_result", {})
if auth_result and auth_result.get("liability_shift") != "POSSIBLE":
raise ValidationError(self.env._("3D Secure authentication failed."))

idempotency_key = payment_utils.generate_idempotency_key(
tx_sudo, scope="payment_request_controller"
)
response = tx_sudo._send_api_request(
"POST", f"/v2/checkout/orders/{order_id}/capture", idempotency_key=idempotency_key
)
normalized_response = self._normalize_paypal_data(response, is_capture_request=True)
tx_sudo = (
self
.env["payment.transaction"]
.sudo()
._search_by_reference("paypal", normalized_response)
)
if tx_sudo:
tx_sudo._record(normalized_response)
18 changes: 18 additions & 0 deletions addons/payment_paypal/controllers/portal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.

from odoo.http import request

from odoo.addons.payment.controllers import portal


class PaymentPaypalPortal(portal.PaymentPortal):
def _prepare_payment_form_values(self, *args, **kwargs):
"""Override of `payment` to forward the customer's user agent to PayPal.

PayPal's `find-eligible-methods` endpoint derives the customer's browser, OS, and device
type from the `User-Agent` header to refine the eligibility of its payment methods. The raw
user agent string is forwarded so that PayPal parses it, rather than parsing it locally.
"""
if request:
kwargs.setdefault("paypal_customer_user_agent", request.httprequest.user_agent.string)
return super()._prepare_payment_form_values(*args, **kwargs)
Loading