diff --git a/addons/payment/controllers/portal.py b/addons/payment/controllers/portal.py index c270578e6b205b..1f5a22b4f37ae4 100644 --- a/addons/payment/controllers/portal.py +++ b/addons/payment/controllers/portal.py @@ -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) diff --git a/addons/payment/models/payment_provider.py b/addons/payment/models/payment_provider.py index e58d533c1af85d..3037ae1ea88961 100644 --- a/addons/payment/models/payment_provider.py +++ b/addons/payment/models/payment_provider.py @@ -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 diff --git a/addons/payment/static/img/p24.png b/addons/payment/static/img/p24.png index 0bc2550223d1b4..f25e2a2d0e0a9a 100644 Binary files a/addons/payment/static/img/p24.png and b/addons/payment/static/img/p24.png differ diff --git a/addons/payment/static/img/paypal_paylater.png b/addons/payment/static/img/paypal_paylater.png new file mode 100644 index 00000000000000..73e9cb20643f74 Binary files /dev/null and b/addons/payment/static/img/paypal_paylater.png differ diff --git a/addons/payment_paypal/__manifest__.py b/addons/payment_paypal/__manifest__.py index 939db27f6c24f5..bd400b304ac7f8 100644 --- a/addons/payment_paypal/__manifest__.py +++ b/addons/payment_paypal/__manifest__.py @@ -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", ], diff --git a/addons/payment_paypal/const.py b/addons/payment_paypal/const.py index 559e5457e0c4aa..1b2a5ddd76b64c 100644 --- a/addons/payment_paypal/const.py +++ b/addons/payment_paypal/const.py @@ -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. @@ -43,10 +62,11 @@ "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. @@ -54,11 +74,16 @@ 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", diff --git a/addons/payment_paypal/controllers/__init__.py b/addons/payment_paypal/controllers/__init__.py index c2fd602de6ec09..9dcd77cf60ba8f 100644 --- a/addons/payment_paypal/controllers/__init__.py +++ b/addons/payment_paypal/controllers/__init__.py @@ -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 diff --git a/addons/payment_paypal/controllers/main.py b/addons/payment_paypal/controllers/main.py index ab88021b24ba4b..ab8c955f15d5e9 100644 --- a/addons/payment_paypal/controllers/main.py +++ b/addons/payment_paypal/controllers/main.py @@ -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"]) @@ -35,21 +37,62 @@ 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"], 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 + 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) + return request.redirect("/payment/status") + + @http.route(_cancel_url, type="http", auth="public", methods=["GET"], 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: + order_id = tx_sudo.provider_reference + 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) + 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): @@ -65,6 +108,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("") @@ -75,13 +120,12 @@ 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) @@ -91,6 +135,44 @@ def _handle_checkout_notification(self, data): payment_safe_write=True )._set_error(self.env._("Unable to verify the payment data")) else: + 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 + 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: + 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): @@ -110,38 +192,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): @@ -176,3 +252,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) diff --git a/addons/payment_paypal/controllers/portal.py b/addons/payment_paypal/controllers/portal.py new file mode 100644 index 00000000000000..f55669d7a7fdae --- /dev/null +++ b/addons/payment_paypal/controllers/portal.py @@ -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) diff --git a/addons/payment_paypal/data/payment_method_data.xml b/addons/payment_paypal/data/payment_method_data.xml index 37837a76299071..4a21e32e229424 100644 --- a/addons/payment_paypal/data/payment_method_data.xml +++ b/addons/payment_paypal/data/payment_method_data.xml @@ -4,10 +4,226 @@ - Paypal + PayPal paypal + + Bancontact + bancontact + + + + + + + + BLIK + blik + + + + + + + + Card + card + + + True + + + + + + EPS + eps + + + + + + + + iDEAL + ideal + + + + + + + + Multibanco + multibanco + + + + + + + + MyBank + mybank + + + + + + + + Pay Later by PayPal + paypal_paylater + + + + + + + + P24 + p24 + + + + + + + + Trustly + trustly + + + + + + + + Venmo + venmo + + + + + + diff --git a/addons/payment_paypal/data/payment_provider_data.xml b/addons/payment_paypal/data/payment_provider_data.xml index 75626ec67ab741..e4f781eef693a3 100644 --- a/addons/payment_paypal/data/payment_provider_data.xml +++ b/addons/payment_paypal/data/payment_provider_data.xml @@ -3,6 +3,8 @@ paypal + + diff --git a/addons/payment_paypal/models/payment_provider.py b/addons/payment_paypal/models/payment_provider.py index e72b74240d2a76..33c856dadc22be 100644 --- a/addons/payment_paypal/models/payment_provider.py +++ b/addons/payment_paypal/models/payment_provider.py @@ -8,6 +8,7 @@ from odoo.exceptions import UserError, ValidationError from odoo.tools import urls +from odoo.addons.payment import utils as payment_utils from odoo.addons.payment.logging import get_payment_logger from odoo.addons.payment_paypal import const from odoo.addons.payment_paypal.controllers.main import PaypalController @@ -104,7 +105,11 @@ def action_paypal_create_webhook(self): :raise UserError: If the base URL is not in HTTPS. """ base_url = self._paypal_get_base_url() - webhook_events = const.CHECKOUT_WEBHOOK_EVENTS + [const.SELLER_EMAIL_CONFIRMED_WEBHOOK] + webhook_events = ( + const.CHECKOUT_WEBHOOK_EVENTS + + const.CAPTURE_WEBHOOK_EVENTS + + [const.SELLER_EMAIL_CONFIRMED_WEBHOOK] + ) data = { "url": urls.urljoin(base_url, PaypalController._webhook_url), "event_types": [{"name": event_type} for event_type in webhook_events], @@ -148,19 +153,123 @@ def action_reset_credentials(self): # === BUSINESS METHODS === # - def _paypal_get_inline_form_values(self, currency=None): + def _find_available_payment_methods( + self, + partner_id, + *, + currency_id=None, + force_tokenization=False, + is_express_checkout=False, + report=None, + amount=0.0, + **kwargs, + ): + """Override of `payment` to filter out payment methods that PayPal deems ineligible. + + PayPal's own eligibility rules (based on the customer's country, the seller account, etc.) + are not necessarily reflected in the local configuration of the payment methods. The + `find-eligible-methods` endpoint is called to refine the availability of PayPal's payment + methods for the given payment context. + + :param float amount: The amount to pay (`0` for validation transactions) + + """ + payment_methods = super()._find_available_payment_methods( + partner_id, + currency_id=currency_id, + force_tokenization=force_tokenization, + is_express_checkout=is_express_checkout, + report=report, + amount=amount, + **kwargs, + ) + for provider in self.filtered(lambda p: p.code == "paypal"): + if not provider.paypal_client_id or not provider.paypal_client_secret: + continue + eligible_method_keys = provider._paypal_get_eligible_payment_method_keys( + partner_id, + amount, + currency_id=currency_id, + user_agent=kwargs.get("paypal_customer_user_agent"), + ) + if eligible_method_keys is None: + continue + ineligible_pms = payment_methods.filtered( + lambda pm: ( + pm.code in const.PAYMENT_METHODS_MAPPING + and const.PAYMENT_METHODS_MAPPING[pm.code] not in eligible_method_keys + ) + ) + payment_utils.add_to_report( + report, + ineligible_pms, + available=False, + reason=self.env._("Not eligible according to PayPal"), + ) + payment_methods -= ineligible_pms + return payment_methods + + def _paypal_get_eligible_payment_method_keys( + self, partner_id, amount, currency_id=None, user_agent=None + ): + """Return the PayPal payment source keys that are eligible for the given context. + + Note: `self.ensure_one()` + + :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 str user_agent: The customer's browser user agent string, forwarded to PayPal to + derive the browser, OS, and device type for eligibility assessment. + :return: The eligible PayPal payment source keys (e.g., `{'paypal', 'venmo'}`), or `None` + if the eligibility could not be determined. + :rtype: set|None + """ + self.ensure_one() + + partner = self.env["res.partner"].browse(partner_id) + currency = self.env["res.currency"].browse(currency_id) + payload = { + "customer": {"country_code": partner.country_code, "email": partner.email}, + "purchase_units": [ + { + "amount": {"currency_code": currency.name, "value": amount}, + "payee": { + "email_address": self.paypal_email_account, + "merchant_id": self.paypal_account_id, + }, + } + ], + "preferences": {"intent": "CAPTURE"}, + } + try: + response_content = self._send_api_request( + "POST", + "/v2/payments/find-eligible-methods", + json=payload, + paypal_customer_user_agent=user_agent, + ) + except ValidationError: + _logger.warning("Could not fetch eligible payment methods from PayPal.") + return None + return set(response_content.get("eligible_methods", {})) + + def _paypal_get_inline_form_values(self, currency=None, partner_id=None): """Return a serialized JSON of the required values to render the inline form. Note: `self.ensure_one()` :param res.currency currency: The transaction currency. + :param int partner_id: The partner of the transaction, as a `res.partner` id. :return: The JSON serial of the required values to render the inline form. :rtype: str """ + partner = self.env["res.partner"].browse(partner_id).exists() inline_form_values = { "provider_id": self.id, "client_id": self.paypal_client_id, "currency_code": currency and currency.name, + "country_code": partner and partner.country_code, } return json.dumps(inline_form_values) @@ -246,35 +355,29 @@ def _build_request_headers( is_refresh_token_request=False, paypal_onboarding_shared_id=None, paypal_onboarding_access_token=None, + paypal_customer_user_agent=None, **kwargs, ): """Override of `payment` to build the request headers.""" if self.code != "paypal": - return super()._build_request_headers( - *args, - idempotency_key=idempotency_key, - is_refresh_token_request=is_refresh_token_request, - paypal_onboarding_shared_id=paypal_onboarding_shared_id, - paypal_onboarding_access_token=paypal_onboarding_access_token, - **kwargs, - ) - + return super()._build_request_headers(*args, idempotency_key=idempotency_key, **kwargs) + is_onboarding_request = paypal_onboarding_shared_id or paypal_onboarding_access_token headers = { - "Content-Type": "application/json", # PayPal requires a reference specific to Odoo to be able to track Odoo customers. - "PayPal-Partner-Attribution-Id": "ODOO_SP_DIRECT", + "PayPal-Partner-Attribution-Id": "ODOO_SP_DIRECT" } - if paypal_onboarding_shared_id or paypal_onboarding_access_token: + if is_onboarding_request: headers["Content-Type"] = "application/x-www-form-urlencoded" + else: + headers["Content-Type"] = "application/json" + + if paypal_customer_user_agent: + headers["User-Agent"] = paypal_customer_user_agent if paypal_onboarding_access_token: headers["Authorization"] = f"Bearer {paypal_onboarding_access_token}" if idempotency_key: headers["PayPal-Request-Id"] = idempotency_key - if ( - not is_refresh_token_request - and not paypal_onboarding_shared_id - and not paypal_onboarding_access_token - ): + if not (is_refresh_token_request or is_onboarding_request): headers["Authorization"] = f"Bearer {self._paypal_fetch_access_token()}" return headers @@ -309,7 +412,13 @@ def _parse_response_error(self, response): """Override of `payment` to parse the error message.""" if self.code != "paypal": return super()._parse_response_error(response) - return response.json().get("message", "") + response_content = response.json() + descriptions = [ + detail["description"] + for detail in response_content.get("details", []) + if detail.get("description") + ] + return "\n".join(descriptions) or response_content.get("message", "") def _build_request_auth( self, *, is_refresh_token_request=False, paypal_onboarding_shared_id=None, **kwargs diff --git a/addons/payment_paypal/models/payment_transaction.py b/addons/payment_paypal/models/payment_transaction.py index ce28ec346feb31..57dde421b9d62b 100644 --- a/addons/payment_paypal/models/payment_transaction.py +++ b/addons/payment_paypal/models/payment_transaction.py @@ -1,12 +1,16 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models +from urllib.parse import urlencode + +from odoo import api, models from odoo.exceptions import ValidationError +from odoo.tools import urls from odoo.addons.payment import utils as payment_utils from odoo.addons.payment.logging import get_payment_logger from odoo.addons.payment_paypal import utils as paypal_utils from odoo.addons.payment_paypal.const import PAYMENT_STATUS_MAPPING +from odoo.addons.payment_paypal.controllers.main import PaypalController _logger = get_payment_logger(__name__) @@ -14,10 +18,6 @@ class PaymentTransaction(models.Model): _inherit = "payment.transaction" - # See https://developer.paypal.com/docs/api-basics/notifications/ipn/IPNandPDTVariables/ - # this field has no use in Odoo except for debugging - paypal_type = fields.Char(string="PayPal Transaction Type") - def _get_specific_processing_values(self, processing_values): """Override of `payment` to return the Paypal-specific processing values. @@ -28,23 +28,63 @@ def _get_specific_processing_values(self, processing_values): :return: The dict of provider-specific processing values :rtype: dict """ - if self.provider_code != "paypal": + if self.provider_code != "paypal" or self.operation != "online_direct": return super()._get_specific_processing_values(processing_values) - payload = self._paypal_prepare_order_payload() + try: + order_data = self._paypal_create_order() + self.provider_reference = order_data["id"] + except ValidationError as e: + self._set_error(str(e)) + return {} + + return {"order_id": order_data["id"]} - idempotency_key = payment_utils.generate_idempotency_key( - self, scope="payment_request_order" + def _get_specific_rendering_values(self, processing_values): + """Override of `payment` to return the PayPal-specific rendering values. + + Note: self.ensure_one() from `_get_processing_values`. + + :param dict processing_values: The generic and specific processing values of the + transaction. + :return: The dict of provider-specific rendering values. + :rtype: dict + """ + if self.provider_code != "paypal": + return super()._get_specific_rendering_values(processing_values) + + payload = ( + self._paypal_prepare_order_payload() + if self.payment_method_code == "paypal" + else self._paypal_prepare_apm_order_payload() ) try: - order_data = self._send_api_request( - "POST", "/v2/checkout/orders", json=payload, idempotency_key=idempotency_key - ) + order_data = self._paypal_create_order(payload=payload) except ValidationError as e: self._set_error(str(e)) return {} - return {"order_id": order_data["id"]} + self.provider_reference = order_data["id"] + payer_action_url = next( + link["href"] for link in order_data["links"] if link["rel"] == "payer-action" + ) + return { + "api_url": payer_action_url, + "http_method": "get", + "url_params": payment_utils.extract_url_params(payer_action_url), + } + + def _paypal_create_order(self, payload=None): + """Create a PayPal order for the transaction and return the API response.""" + idempotency_key = payment_utils.generate_idempotency_key( + self, scope="payment_request_order" + ) + return self._send_api_request( + "POST", + "/v2/checkout/orders", + json=payload if payload else self._paypal_prepare_order_payload(), + idempotency_key=idempotency_key, + ) def _paypal_prepare_order_payload(self): """Prepare the payload for the Paypal create order request. @@ -52,44 +92,100 @@ def _paypal_prepare_order_payload(self): :return: The requested payload to create a Paypal order. :rtype: dict """ - partner_first_name, partner_last_name = payment_utils.split_partner_name(self.partner_name) if self.partner_id.is_public: invoice_address_vals = {"address": {"country_code": self.company_id.country_code}} shipping_address_vals = {} else: invoice_address_vals = paypal_utils.format_partner_address(self.partner_id) shipping_address_vals = paypal_utils.format_shipping_address(self) - shipping_preference = "SET_PROVIDED_ADDRESS" if shipping_address_vals else "NO_SHIPPING" # See https://developer.paypal.com/docs/api/orders/v2/#orders_create!ct=application/json - payload = { + return { + "intent": "CAPTURE", + "purchase_units": [self._paypal_get_purchase_unit(shipping_address_vals)], + "payment_source": self._paypal_get_payment_source( + invoice_address_vals, has_shipping=bool(shipping_address_vals) + ), + } + + def _paypal_get_purchase_unit(self, shipping_address_vals): + payee_data = { + "display_data": {"brand_name": self.provider_id.company_id.name}, + "email_address": self.provider_id.paypal_email_account, + } + + if company_email := self.provider_id.company_id.email: + payee_data["display_data"]["business_email"] = company_email + + return { + "reference_id": self.reference, + "description": f"{self.company_id.name}: {self.reference}", + "amount": {"currency_code": self.currency_id.name, "value": str(self.amount)}, + "payee": payee_data, + **shipping_address_vals, + } + + def _paypal_get_payment_source(self, invoice_address_vals, has_shipping): + return_url, cancel_url = self._paypal_get_return_urls(self.reference) + if self.payment_method_code == "card": + return { + "card": { + "name": self.partner_name, + "billing_address": invoice_address_vals.get("address", {}), + "attributes": {"verification": {"method": "SCA_WHEN_REQUIRED"}}, + "experience_context": {"return_url": return_url, "cancel_url": cancel_url}, + } + } + partner_first_name, partner_last_name = payment_utils.split_partner_name(self.partner_name) + return { + "paypal": { + "experience_context": { + "payment_method_preference": "IMMEDIATE_PAYMENT_REQUIRED", + "landing_page": "LOGIN", + "shipping_preference": ( + "SET_PROVIDED_ADDRESS" if has_shipping else "NO_SHIPPING" + ), + "user_action": "PAY_NOW", + "return_url": return_url, + "cancel_url": cancel_url, + }, + "name": {"given_name": partner_first_name, "surname": partner_last_name}, + **invoice_address_vals, + } + } + + def _paypal_prepare_apm_order_payload(self): + """Prepare the payload of the create order request for an alternative payment method. + + :return: The payload of the create order request for the alternative payment method. + :rtype: dict + """ + return_url, cancel_url = self._paypal_get_return_urls(self.reference) + locale = (self.partner_id.lang or self.env.user.lang or "en_US").replace("_", "-") + return { "intent": "CAPTURE", + "processing_instruction": "ORDER_COMPLETE_ON_PAYMENT_APPROVAL", "purchase_units": [ { "reference_id": self.reference, + "custom_id": self.reference, "description": f"{self.company_id.name}: {self.reference}", - "amount": {"currency_code": self.currency_id.name, "value": self.amount}, - "payee": { - "display_data": {"brand_name": self.provider_id.company_id.name}, - "email_address": self.provider_id.paypal_email_account, - }, - **shipping_address_vals, + "amount": {"currency_code": self.currency_id.name, "value": str(self.amount)}, } ], "payment_source": { - "paypal": { - "experience_context": {"shipping_preference": shipping_preference}, - "name": {"given_name": partner_first_name, "surname": partner_last_name}, - **invoice_address_vals, + self.payment_method_code: { + "country_code": self.partner_id.country_code or self.company_id.country_code, + "name": self.partner_name, + "email": self.partner_email, } }, + "application_context": { + "locale": locale, + "return_url": return_url, + "cancel_url": cancel_url, + }, } - # PayPal does not accept None set to fields and to avoid users getting errors when email - # is not set on company we will add it conditionally since its not a required field. - if company_email := self.provider_id.company_id.email: - payload["purchase_units"][0]["payee"]["display_data"]["business_email"] = company_email - - return payload @api.model def _extract_reference(self, provider_code, payment_data): @@ -110,23 +206,15 @@ def _apply_updates(self, payment_data): # Update the provider reference. txn_id = payment_data.get("id") - txn_type = payment_data.get("txn_type") - if not all((txn_id, txn_type)): - self._set_error( - self.env._( - "Missing value for txn_id (%(txn_id)s) or txn_type (%(txn_type)s).", - txn_id=txn_id, - txn_type=txn_type, - ) - ) + if not all(txn_id): + self._set_error(self.env._("Missing value for txn_id (%(txn_id)s).", txn_id=txn_id)) return self.provider_reference = txn_id - self.paypal_type = txn_type # Force PayPal as the payment method if it exists. self.payment_method_id = ( - self.provider_id._get_pm_from_code("paypal") or self.payment_method_id + self.payment_method_id or self.provider_id._get_pm_from_code("paypal") ) # Update the payment state. @@ -138,6 +226,11 @@ def _apply_updates(self, payment_data): self._set_done() elif payment_status in PAYMENT_STATUS_MAPPING["cancel"]: self._set_canceled() + elif payment_status in PAYMENT_STATUS_MAPPING["error"]: + self._set_error( + payment_data.get("state_message") + or self.env._("The payment was declined by PayPal.") + ) else: _logger.info( "Received data with invalid payment status (%s) for transaction %s.", @@ -157,3 +250,10 @@ def _extract_amount_data(self, payment_data): amount = amount_data.get("value") currency_code = amount_data.get("currency_code") return {"amount": float(amount), "currency_code": currency_code} + + def _paypal_get_return_urls(self, ref): + base_url = self.provider_id._paypal_get_base_url() + params = urlencode({"reference": ref}) + return_url = f"{urls.urljoin(base_url, PaypalController._return_url)}?{params}" + cancel_url = f"{urls.urljoin(base_url, PaypalController._cancel_url)}?{params}" + return return_url, cancel_url diff --git a/addons/payment_paypal/static/src/interactions/payment_form.js b/addons/payment_paypal/static/src/interactions/payment_form.js index 78ce8e0c191f4d..03fa2b66912eee 100644 --- a/addons/payment_paypal/static/src/interactions/payment_form.js +++ b/addons/payment_paypal/static/src/interactions/payment_form.js @@ -7,6 +7,41 @@ import { patch } from '@web/core/utils/patch'; import { PaymentForm } from '@payment/interactions/payment_form'; +const PAYPAL_SDK_METHODS = ['venmo', 'paypal_paylater', 'card']; +const CARD_FIELDS_STYLE = { + "body": { + "padding": "0", + "border-radius": "0.4rem" + }, + "input": { + "font-family": '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"', + "font-size": "1rem", + "height": "38px", + "font-weight": "400", + "line-height": "1.5", + "color": "#212529", + "background": "#FFFFFF", + + "appearance": "none", + "-webkit-appearance": "none", + "-moz-appearance": "none", + + "border": "1px solid color-mix(in srgb, currentcolor 15%, transparent)", + "border-radius": "0.4rem", + "transition": "background-color 0.05s ease-in-out, border-color 0.05s ease-in-out, box-shadow 0.05s ease-in-out" + }, + ":focus": { + "color": "#212529", + "background": "#FFFFFF", + "border": "2px solid #b8a5b3", + "outline": "0", + "box-shadow": "0 0 0 0.1rem rgba(113, 75, 103, 0.25)" + }, + ".invalid": { + "color": "#dc3545" + } +}; + patch(PaymentForm.prototype, { setup() { @@ -75,87 +110,158 @@ patch(PaymentForm.prototype, { return; } - this._hideInputs(); + // If the selected payment method isn't handled by the Paypal SDK, hide the PayPal button so + // the default redirect flow applies instead. + if (!PAYPAL_SDK_METHODS.includes(paymentMethodCode)) { + for (const buttonContainer of document.querySelectorAll('#o_paypal_button_container')) { + buttonContainer.classList.add('d-none'); + } + this.selectedOptionId = paymentOptionId; + return; + } + this._setPaymentFlow('direct'); + const isCard = paymentMethodCode === 'card'; const paypalLoadingList = document.querySelectorAll('#o_paypal_loading'); - for (const paypalLoading of paypalLoadingList) { - paypalLoading.classList.remove('d-none'); + if (!isCard) { + this._hideInputs(); + for (const paypalLoading of paypalLoadingList) { + paypalLoading.classList.remove('d-none'); + } } // Check if instantiation of the component is needed. if (this.selectedOptionId && this.selectedOptionId !== paymentOptionId) { Object.entries(this.paypalData).forEach(([_key, value]) => { - value.enabledButtons.forEach(btn => btn.hide()); - value.disabledButtons.forEach(btn => btn.hide()); + value.enabledButtons?.forEach(btn => btn.hide()); + value.disabledButtons?.forEach(btn => btn.hide()); }); } const currentPayPalData = this.paypalData[paymentOptionId]; if (currentPayPalData && this.selectedOptionId !== paymentOptionId) { - const paypalSDKURL = this.paypalData[paymentOptionId]['sdkURL'] + const paypalSDKURL = this.paypalData[paymentOptionId]['sdkURL']; await this.waitFor(this._paypalLoadSDK(paypalSDKURL)); - this.paypalData[this.selectedOptionId]['enabledButtons'].forEach(btn => btn.show()); - this.paypalData[this.selectedOptionId]['disabledButtons'].forEach(btn => btn.show()); + this.paypalData[paymentOptionId]['enabledButtons']?.forEach(btn => btn.show()); + this.paypalData[paymentOptionId]['disabledButtons']?.forEach(btn => btn.show()); } else if (!currentPayPalData) { this.paypalData[paymentOptionId] = {}; const radio = document.querySelector('input[name="o_payment_radio"]:checked'); let inlineFormValues; - let paypalColor = 'blue'; + let paypalColor = 'default'; if (radio) { inlineFormValues = JSON.parse(radio.dataset['paypalInlineFormValues']); paypalColor = radio.dataset['paypalColor']; } // https://developer.paypal.com/sdk/js/configuration/#link-queryparameters - const { client_id, currency_code } = inlineFormValues; - const paypalSDKURL = `https://www.paypal.com/sdk/js?client-id=${ - client_id}&components=buttons¤cy=${currency_code}&intent=capture`; + const { client_id, currency_code, country_code } = inlineFormValues; + const paypalSDKParams = new URLSearchParams({ + "client-id": client_id, + "components": "buttons,card-fields,payment-fields,funding-eligibility", + "buyer-country": country_code, + "currency": currency_code, + "enable-funding": "paypal,paylater,venmo", + "intent": "capture", + }); + const paypalSDKURL = `https://www.paypal.com/sdk/js?${paypalSDKParams}`; this.paypalData[paymentOptionId]['sdkURL'] = paypalSDKURL; await this.waitFor(this._paypalLoadSDK(paypalSDKURL)); - // Create the two sets of PayPal buttons. - // See https://developer.paypal.com/sdk/js/reference. - this.paypalData[paymentOptionId]['enabledButtons'] = []; - document.querySelectorAll('[id^="o_paypal_enabled_button"]').forEach(domButton => { - const enabledButton = paypal.Buttons({ - fundingSource: paypal.FUNDING.PAYPAL, - style: { // https://developer.paypal.com/sdk/js/reference/#link-style - color: paypalColor, - label: 'paypal', - disableMaxWidth: true, - borderRadius: 6, + if (isCard && paypal.CardFields !== undefined) { + const cardFields = paypal.CardFields({ + style: CARD_FIELDS_STYLE, + createOrder: () => { + return this.paypalData[paymentOptionId].paypalOrderId; }, - createOrder: this._paypalOnClick.bind(this), onApprove: this._paypalOnApprove.bind(this), - onCancel: this._paypalOnCancel.bind(this), - onError: this._paypalOnError.bind(this), }); - enabledButton.render(`#${domButton.id}`); - this.paypalData[paymentOptionId]['enabledButtons'].push(enabledButton); - }); - this.paypalData[paymentOptionId]['disabledButtons'] = []; - document.querySelectorAll('[id^="o_paypal_disabled_button"]').forEach(domButton => { - const disabledButton = paypal.Buttons({ - fundingSource: paypal.FUNDING.PAYPAL, - style: { // https://developer.paypal.com/sdk/js/reference/#link-style - color: 'silver', + this.paypalData[paymentOptionId].cardFields = cardFields; + + const radio = document.querySelector('input[name="o_payment_radio"]:checked'); + const inlineForm = this._getInlineForm(radio); + const paypalInlineForm = inlineForm.querySelector('[name="o_paypal_form"]'); + this.paypalData[paymentOptionId].inlineForm = paypalInlineForm; + + cardFields + .NameField({ placeholder: "" }) + .render(inlineForm.querySelector(".paypal-card-name-field")); + cardFields + .NumberField({ placeholder: "" }) + .render(inlineForm.querySelector(".paypal-card-number-field")); + cardFields + .ExpiryField({ placeholder: "" }) + .render(inlineForm.querySelector(".paypal-card-expiry-field")); + cardFields + .CVVField({ placeholder: "" }) + .render(inlineForm.querySelector(".paypal-card-cvv-field")); + } else { + // Create the two sets of standard PayPal buttons. + // See https://developer.paypal.com/sdk/js/reference. + const METHOD_CONFIG = { + 'paypal': { + fundingSource: paypal.FUNDING.PAYPAL, + label: 'paypal', + color: paypalColor + }, + 'paypal_paylater': { + fundingSource: paypal.FUNDING.PAYLATER, + label: 'pay', + color: 'gold' + }, + 'venmo': { + fundingSource: paypal.FUNDING.VENMO, label: 'paypal', - disableMaxWidth: true, - borderRadius: 6, + color: 'blue' }, - onInit: (data, actions) => actions.disable(), // Permanently disable the button. + }; + const activeConfig = METHOD_CONFIG[paymentMethodCode] || METHOD_CONFIG['paypal']; + + this.paypalData[paymentOptionId]['enabledButtons'] = []; + document.querySelectorAll('[id^="o_paypal_enabled_button"]').forEach(domButton => { + const enabledButton = paypal.Buttons({ + fundingSource: activeConfig.fundingSource, + style: { // https://developer.paypal.com/sdk/js/reference/#link-style + layout: 'vertical', + label: activeConfig.label, + color: activeConfig.color, + disableMaxWidth: true, + borderRadius: 6, + }, + createOrder: this._paypalOnClick.bind(this), + onApprove: this._paypalOnApprove.bind(this), + onCancel: this._paypalOnCancel.bind(this), + onError: this._paypalOnError.bind(this), + }); + enabledButton.render(`#${domButton.id}`); + this.paypalData[paymentOptionId]['enabledButtons'].push(enabledButton); }); - disabledButton.render(`#${domButton.id}`); - this.paypalData[paymentOptionId]['disabledButtons'].push(disabledButton); - }); - } + this.paypalData[paymentOptionId]['disabledButtons'] = []; + document.querySelectorAll('[id^="o_paypal_disabled_button"]').forEach(domButton => { + const disabledButton = paypal.Buttons({ + fundingSource: activeConfig.fundingSource, + style: { + // https://developer.paypal.com/sdk/js/reference/#link-style + layout: "vertical", + color: "white", + label: activeConfig.label, + disableMaxWidth: true, + borderRadius: 6, + }, + onInit: (data, actions) => actions.disable(), // Permanently disable the button. + }); + disabledButton.render(`#${domButton.id}`); + this.paypalData[paymentOptionId]['disabledButtons'].push(disabledButton); + }); + } + } for (const paypalLoading of paypalLoadingList) { paypalLoading.classList.add('d-none'); } for (const buttonContainer of document.querySelectorAll('#o_paypal_button_container')) { - buttonContainer.classList.remove('d-none'); + buttonContainer.classList.toggle('d-none', isCard); } this.selectedOptionId = paymentOptionId; }, @@ -196,6 +302,16 @@ patch(PaymentForm.prototype, { } this.paypalData[paymentOptionId].paypalOrderId = processingValues['order_id']; this.paypalData[paymentOptionId].paypalTxRef = processingValues['reference']; + + if (paymentMethodCode === 'card') { + const currentPayPalData = this.paypalData[paymentOptionId]; + if (currentPayPalData && currentPayPalData.cardFields) { + currentPayPalData.cardFields.submit().catch((error) => { + this._displayErrorDialog("Validation Error", error.message); + this._enableButton(); + }); + } + } }, /** @@ -213,8 +329,12 @@ patch(PaymentForm.prototype, { 'reference': this.paypalData[this.selectedOptionId].paypalTxRef, })); // Close the PayPal buttons that were rendered - for (const enabledButton of this.paypalData[this.selectedOptionId]['enabledButtons']) { - enabledButton.close(); + const enabledButtons = this.paypalData[this.selectedOptionId]['enabledButtons']; + if (enabledButtons) { + for (const enabledButton of enabledButtons) { + enabledButton.close(); + } + } window.location = '/payment/status'; } catch (error) { diff --git a/addons/payment_paypal/static/src/scss/payment_paypal.scss b/addons/payment_paypal/static/src/scss/payment_paypal.scss new file mode 100644 index 00000000000000..81c09a2330972c --- /dev/null +++ b/addons/payment_paypal/static/src/scss/payment_paypal.scss @@ -0,0 +1,14 @@ +/* + The PayPal SDK renders each card field in an iframe whose height it computes asynchronously, + after the inline form has already been made visible. Reserving that height (the one the SDK + settles on for the 38px input configured in `payment_form.js`) keeps the form from growing and + pushing the submit button down. +*/ +[name="o_paypal_form"] { + .paypal-card-name-field, + .paypal-card-number-field, + .paypal-card-expiry-field, + .paypal-card-cvv-field { + min-height: 60px; + } +} diff --git a/addons/payment_paypal/tests/common.py b/addons/payment_paypal/tests/common.py index e9fecbfad03d56..6142fb0848ff18 100644 --- a/addons/payment_paypal/tests/common.py +++ b/addons/payment_paypal/tests/common.py @@ -59,3 +59,53 @@ def setUpClass(cls): } ], } + + # The `payer-action` URL the customer is redirected to for alternative payment methods. + cls.payer_action_url = ( + f"https://www.sandbox.paypal.com/payment/bancontact?token={cls.order_id}" + ) + cls.apm_order_data = { + "id": cls.order_id, + "status": "PAYER_ACTION_REQUIRED", + "links": [ + { + "href": f"https://api-m.sandbox.paypal.com/v2/checkout/orders/{cls.order_id}", + "rel": "self", + "method": "GET", + }, + {"href": cls.payer_action_url, "rel": "payer-action", "method": "GET"}, + ], + } + + cls.capture_notification = { + "event_type": "PAYMENT.CAPTURE.COMPLETED", + "resource": { + "id": "8SS60826HT082593F", + "status": "COMPLETED", + "custom_id": cls.reference, + "amount": {"currency_code": cls.currency.name, "value": str(cls.amount)}, + "supplementary_data": {"related_ids": {"order_id": cls.order_id}}, + }, + } + + cls.declined_notification = { + "event_type": "CHECKOUT.ORDER.DECLINED", + "resource": { + "id": cls.order_id, + "intent": "CAPTURE", + "status": "PAYER_ACTION_REQUIRED", + "payment_source": {"bancontact": {"name": "John Doe", "country_code": "BE"}}, + "purchase_units": [ + { + "reference_id": cls.reference, + "amount": {"currency_code": cls.currency.name, "value": str(cls.amount)}, + "most_recent_errors": [ + { + "issue": "PAYMENT_SOURCE_CANNOT_BE_USED", + "description": "The provided payment source cannot be used.", + } + ], + } + ], + }, + } diff --git a/addons/payment_paypal/tests/test_paypal.py b/addons/payment_paypal/tests/test_paypal.py index a0d6662fceac65..476e5bb0cf5755 100644 --- a/addons/payment_paypal/tests/test_paypal.py +++ b/addons/payment_paypal/tests/test_paypal.py @@ -23,10 +23,27 @@ def test_processing_values(self): processing_values = tx._get_processing_values() self.assertEqual(processing_values["order_id"], self.order_id) + def test_apm_rendering_values(self): + """Test that an alternative payment method redirects the customer to PayPal.""" + bancontact_pm = self.env.ref("payment_paypal.payment_method_bancontact").id + tx = self._create_transaction(flow="redirect", payment_method_id=bancontact_pm) + with patch( + "odoo.addons.payment.models.payment_provider.PaymentProvider._send_api_request", + return_value=self.apm_order_data, + ): + processing_values = tx._get_processing_values() + form_info = self._extract_values_from_html_form(processing_values["redirect_form_html"]) + self.assertEqual(form_info["action"], self.payer_action_url) + self.assertEqual(form_info["method"], "get") + self.assertEqual(tx.provider_reference, self.order_id) + def test_order_payload_values_for_public_user(self): """If a payment is made with the public user we need to make sure that the email address is not sent to PayPal and that we provide the country code of the company instead.""" - tx = self._create_transaction(flow="direct", partner_id=self.public_user.partner_id.id) + paypal_pm = self.env.ref("payment_paypal.payment_method_paypal").id + tx = self._create_transaction( + flow="direct", partner_id=self.public_user.partner_id.id, payment_method_id=paypal_pm + ) payload = tx._paypal_prepare_order_payload() customer_payload = payload["payment_source"]["paypal"] self.assertTrue("email_address" not in customer_payload) @@ -36,14 +53,16 @@ def test_order_payload_values_for_public_user(self): def test_complete_order_confirms_transaction(self): """Test the processing of a webhook notification.""" tx = self._create_transaction("direct") - normalized_data = PaypalController._normalize_paypal_data(self, self.completed_order) + normalized_data = PaypalController._normalize_paypal_data( + self, self.completed_order, is_capture_request=True + ) tx.with_context(payment_safe_write=True)._process(normalized_data) self.assertEqual(tx.state, "done") self.assertEqual(tx.provider_reference, normalized_data["id"]) def test_feedback_processing(self): normalized_data = PaypalController._normalize_paypal_data( - self, self.payment_data.get("resource"), from_webhook=True + self, self.payment_data.get("resource") ) # Confirmed transaction @@ -78,6 +97,47 @@ def test_webhook_notification_confirms_transaction(self): self._run_processing() self.assertEqual(tx.state, "done") + @mute_logger("odoo.addons.payment_paypal.controllers.main") + def test_order_declined_webhook_errors_transaction(self): + """Test that a `CHECKOUT.ORDER.DECLINED` webhook notification errors the transaction.""" + tx = self._create_transaction("redirect") + url = self._build_url(PaypalController._webhook_url) + with patch( + "odoo.addons.payment_paypal.controllers.main.PaypalController" + "._verify_notification_origin" + ): + self._make_json_request(url, data=self.declined_notification) + self._run_processing() + self.assertEqual(tx.state, "error") + self.assertEqual(tx.state_message, "The provided payment source cannot be used.") + + @mute_logger("odoo.addons.payment_paypal.controllers.main") + def test_capture_denied_webhook_updates_tx_error_status(self): + """Test that a `PAYMENT.CAPTURE.DENIED` webhook notification cancels the transaction. + + The transaction is matched through the order id (`provider_reference`) as the denied capture + resource does not echo back the shared reference_id. + """ + tx = self._create_transaction("redirect") + self._update_transaction(tx, provider_reference=self.order_id) + denied_notification = { + "event_type": "PAYMENT.CAPTURE.DENIED", + "resource": { + "id": "8SS60826HT082593F", + "status": "DECLINED", + "amount": {"currency_code": self.currency.name, "value": str(self.amount)}, + "supplementary_data": {"related_ids": {"order_id": self.order_id}}, + }, + } + url = self._build_url(PaypalController._webhook_url) + with patch( + "odoo.addons.payment_paypal.controllers.main.PaypalController" + "._verify_notification_origin" + ): + self._make_json_request(url, data=denied_notification) + self._run_processing() + self.assertEqual(tx.state, "error") + @mute_logger("odoo.addons.payment_paypal.controllers.main") def test_webhook_notification_triggers_origin_check(self): """Test that receiving a webhook notification triggers an origin check.""" @@ -98,7 +158,8 @@ def test_webhook_notification_skips_processing_for_errored_txs(self): with ( patch.object( PaymentTransaction, "_send_api_request", side_effect=ValidationError("Test error") - ), patch.object(PaymentTransaction, "_record") as record_mock + ), + patch.object(PaymentTransaction, "_record") as record_mock, ): self._make_json_request(url, data=self.payment_data) self.assertEqual(record_mock.call_count, 0) @@ -112,7 +173,10 @@ def test_provide_shipping_address(self): "partner_id": self.partner.id, "order_line": [Command.create({"product_id": product.id})], }) - tx = self._create_transaction(flow="direct", sale_order_ids=[Command.set(order.ids)]) + paypal_pm = self.env.ref("payment_paypal.payment_method_paypal").id + tx = self._create_transaction( + flow="direct", sale_order_ids=[Command.set(order.ids)], payment_method_id=paypal_pm + ) payload = tx._paypal_prepare_order_payload() self.assertEqual( diff --git a/addons/payment_paypal/utils.py b/addons/payment_paypal/utils.py index 16836e4e60a497..3b981068c3cc12 100644 --- a/addons/payment_paypal/utils.py +++ b/addons/payment_paypal/utils.py @@ -1,5 +1,9 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.addons.payment.logging import get_payment_logger + +_logger = get_payment_logger(__name__) + def format_partner_address(partner): """Format the partner address values to PayPal address values. When provided, PayPal requires diff --git a/addons/payment_paypal/views/payment_form_templates.xml b/addons/payment_paypal/views/payment_form_templates.xml index 4cafa66ff46ba5..5254e7aa5a4939 100644 --- a/addons/payment_paypal/views/payment_form_templates.xml +++ b/addons/payment_paypal/views/payment_form_templates.xml @@ -4,7 +4,7 @@ - provider_sudo._paypal_get_inline_form_values(currency) + provider_sudo._paypal_get_inline_form_values(currency, partner_id) "blue" diff --git a/addons/payment_paypal/views/payment_paypal_templates.xml b/addons/payment_paypal/views/payment_paypal_templates.xml index 148682f690f08c..2c06c5c882e456 100644 --- a/addons/payment_paypal/views/payment_paypal_templates.xml +++ b/addons/payment_paypal/views/payment_paypal_templates.xml @@ -20,4 +20,40 @@ + + + + + + + + Cardholder Name + + + + + + Card Number + + + + + + + + + Expiration (MM/YY) + + + + + + Card Code (CVV) + + + + + + + diff --git a/addons/payment_paypal/views/payment_provider_views.xml b/addons/payment_paypal/views/payment_provider_views.xml index 1c7cdaca3d08ea..dac5b52d07ceec 100644 --- a/addons/payment_paypal/views/payment_provider_views.xml +++ b/addons/payment_paypal/views/payment_provider_views.xml @@ -36,17 +36,17 @@ - + Attention: Please confirm your email address on PayPal in order to receive payments. - + Attention: You currently cannot receive payments due to a possible restriction on your PayPal account. Please reach out to PayPal Customer Support. - Recheck Status + Recheck Status diff --git a/addons/payment_paypal/views/payment_transaction_views.xml b/addons/payment_paypal/views/payment_transaction_views.xml deleted file mode 100644 index 70f1d8219e3fe8..00000000000000 --- a/addons/payment_paypal/views/payment_transaction_views.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - PayPal Transaction Form - payment.transaction - - - - - - - - - -