diff --git a/setup/shopinvader_api_payment_provider_worldline/odoo/addons/shopinvader_api_payment_provider_worldline b/setup/shopinvader_api_payment_provider_worldline/odoo/addons/shopinvader_api_payment_provider_worldline new file mode 120000 index 00000000..c6c21ec2 --- /dev/null +++ b/setup/shopinvader_api_payment_provider_worldline/odoo/addons/shopinvader_api_payment_provider_worldline @@ -0,0 +1 @@ +../../../../shopinvader_api_payment_provider_worldline \ No newline at end of file diff --git a/setup/shopinvader_api_payment_provider_worldline/setup.py b/setup/shopinvader_api_payment_provider_worldline/setup.py new file mode 100644 index 00000000..28c57bb6 --- /dev/null +++ b/setup/shopinvader_api_payment_provider_worldline/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/shopinvader_api_payment_provider_worldline/README.rst b/shopinvader_api_payment_provider_worldline/README.rst new file mode 100644 index 00000000..e69de29b diff --git a/shopinvader_api_payment_provider_worldline/__init__.py b/shopinvader_api_payment_provider_worldline/__init__.py new file mode 100644 index 00000000..9ef81445 --- /dev/null +++ b/shopinvader_api_payment_provider_worldline/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import routers diff --git a/shopinvader_api_payment_provider_worldline/__manifest__.py b/shopinvader_api_payment_provider_worldline/__manifest__.py new file mode 100644 index 00000000..803ccf9b --- /dev/null +++ b/shopinvader_api_payment_provider_worldline/__manifest__.py @@ -0,0 +1,19 @@ +# Copyright 2025 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Shopinvader Api Payment Provider Wordline", + "summary": """ + Specific routes for Worldline payments from Shopinvader""", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "ACSONE SA/NV,Shopinvader", + "website": "https://github.com/shopinvader/odoo-shopinvader-payment", + "depends": [ + "fastapi", + "shopinvader_api_payment", + "payment_worldline", + ], + "data": [], + "demo": [], +} diff --git a/shopinvader_api_payment_provider_worldline/models/__init__.py b/shopinvader_api_payment_provider_worldline/models/__init__.py new file mode 100644 index 00000000..cb741e90 --- /dev/null +++ b/shopinvader_api_payment_provider_worldline/models/__init__.py @@ -0,0 +1 @@ +from . import payment_transaction diff --git a/shopinvader_api_payment_provider_worldline/models/payment_transaction.py b/shopinvader_api_payment_provider_worldline/models/payment_transaction.py new file mode 100644 index 00000000..ae17a463 --- /dev/null +++ b/shopinvader_api_payment_provider_worldline/models/payment_transaction.py @@ -0,0 +1,28 @@ +# Copyright Odoo SA (https://odoo.com) +# Copyright 2025 ACSONE SA (https://acsone.eu). +# License LGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from urllib.parse import urljoin + +from werkzeug import urls + +from odoo import models + + +class PaymentTransaction(models.Model): + _inherit = "payment.transaction" + + def _worldline_create_checkout_session_get_payload(self): + self.ensure_one() + payload = super()._worldline_create_checkout_session_get_payload() + if not self.env.context.get("shopinvader_api_payment"): + return payload + shopinvader_api_base_url = self.shopinvader_frontend_redirect_url + return_url = urljoin( + shopinvader_api_base_url, "/shopinvader/payment/providers/worldline/return" + ) + return_url_params = urls.url_encode({"provider_id": str(self.provider_id.id)}) + payload["hostedCheckoutSpecificInput"][ + "returnUrl" + ] = f"{return_url}?{return_url_params}" + return payload diff --git a/shopinvader_api_payment_provider_worldline/readme/CONTRIBUTORS.rst b/shopinvader_api_payment_provider_worldline/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000..eea6cac0 --- /dev/null +++ b/shopinvader_api_payment_provider_worldline/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Benjamin Willig diff --git a/shopinvader_api_payment_provider_worldline/readme/USAGE.rst b/shopinvader_api_payment_provider_worldline/readme/USAGE.rst new file mode 100644 index 00000000..f4cbd863 --- /dev/null +++ b/shopinvader_api_payment_provider_worldline/readme/USAGE.rst @@ -0,0 +1,6 @@ +**Specific procedure for Worldline** + +First call the POST `/payment/transactions` as described in the core addon `shopinvader_api_payment`. +This method will return a `TransactionProcessingValues` schema in which you will find a `redirect_form_html` HTML form. +This HTML form must just be submitted by the front to call the Worldline services. +After worldline has finished, the frontend redirect url will be called back. diff --git a/shopinvader_api_payment_provider_worldline/routers/__init__.py b/shopinvader_api_payment_provider_worldline/routers/__init__.py new file mode 100644 index 00000000..4ece639e --- /dev/null +++ b/shopinvader_api_payment_provider_worldline/routers/__init__.py @@ -0,0 +1 @@ +from . import payment_worldline diff --git a/shopinvader_api_payment_provider_worldline/routers/payment_worldline.py b/shopinvader_api_payment_provider_worldline/routers/payment_worldline.py new file mode 100644 index 00000000..597f0b05 --- /dev/null +++ b/shopinvader_api_payment_provider_worldline/routers/payment_worldline.py @@ -0,0 +1,129 @@ +# Copyright Odoo SA (https://odoo.com) +# Copyright 2025 ACSONE SA (https://acsone.eu). +# License LGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +import pprint +from typing import Annotated +from urllib.parse import quote_plus + +from fastapi import Depends, Request +from fastapi.responses import RedirectResponse +from werkzeug.exceptions import Forbidden + +from odoo import _, api, models +from odoo.exceptions import ValidationError + +from odoo.addons.fastapi.dependencies import odoo_env +from odoo.addons.payment_worldline.controllers.main import WorldlineController +from odoo.addons.shopinvader_api_payment.routers import payment_router +from odoo.addons.shopinvader_api_payment.routers.utils import ( + add_query_params_in_url, + tx_state_to_redirect_status, +) + +_logger = logging.getLogger(__name__) + + +@payment_router.get("/payment/providers/worldline/return") +async def worldline_return( + request: Request, + odoo_env: Annotated[api.Environment, Depends(odoo_env)], +) -> RedirectResponse: + """Handle SIPS return. + + After the payment, the user is redirected with a POST to this endpoint. We handle + the notification data to update the transaction status. We then redirect the browser + with a GET to the frontend_return_url, with the transaction reference as parameter. + + Future: we could also return a unguessable transaction uuid that the front could the + use to consult /payment/transactions/{uuid} and obtain the transaction status. + """ + data = await request.form() + _logger.info( + "return notification received from Worldline with data:\n%s", + pprint.pformat(data), + ) + params = request.query_params + hosted_checkout_id = params.get("hostedCheckoutId") + provider_id = int(params.get("provider_id", 0)) + + provider = odoo_env["payment.provider"].sudo().browse(provider_id).exists() + if not provider or provider.code != "worldline": + _logger.warning("Received payment data with invalid provider id.") + raise Forbidden() + + checkout_session_data = provider._worldline_make_request( + f"hostedcheckouts/{hosted_checkout_id}", method="GET" + ) + _logger.info( + "Response of '/hostedcheckouts/' request:\n%s", + pprint.pformat(checkout_session_data), + ) + notification_data = checkout_session_data.get("createdPaymentOutput", {}) + + tx_sudo = ( + odoo_env["payment.transaction"] + .sudo() + ._get_tx_from_notification_data("worldline", notification_data) + ) + reference = tx_sudo.display_name + frontend_redirect_url = tx_sudo.shopinvader_frontend_redirect_url + try: + tx_sudo._handle_notification_data("worldline", notification_data) + status = tx_state_to_redirect_status(tx_sudo.state) + except Exception: + _logger.exception("unable to handle worldline notification data", exc_info=True) + status = "error" + return RedirectResponse( + url=add_query_params_in_url( + frontend_redirect_url, + {"status": status, "reference": quote_plus(reference)}, + ), + status_code=303, + ) + + +@payment_router.post("/payment/providers/worldline/webhook") +async def worldline_webhook( + request: Request, + odoo_env: Annotated[api.Environment, Depends(odoo_env)], +): + """Handle Wordline webhook.""" + data = await request.json() + _logger.info( + "webhook notification received from SIPS with data:\n%s", pprint.pformat(data) + ) + try: + tx_sudo = ( + odoo_env["payment.transaction"] + .sudo() + ._get_tx_from_notification_data( + "worldline", + data, + ) + ) + received_signature = request.headers.get("X-GCS-Signature") + body = await request.body() + odoo_env[ + "shopinvader_provider_worldline.payment_worldline_router.helper" + ]._verify_worldline_signature(tx_sudo, received_signature, body) + tx_sudo._handle_notification_data("worldline", data) + except Exception: + _logger.exception("unable to handle worldline notification data", exc_info=True) + return "" + + +class ShopinvaderApiPaymentProviderWordlineRouterHelper(models.AbstractModel): + _name = "shopinvader_provider_worldline.payment_worldline_router.helper" + _description = "ShopInvader API Payment Provider Worldline Router Helper" + + def _verify_worldline_signature(self, tx_sudo, received_signature, data): + """Verify the Worldline signature.""" + try: + WorldlineController._verify_notification_signature( + data, received_signature, tx_sudo + ) + except Forbidden as ex: + _logger.exception(ex) + raise ValidationError(_("Unable to verify worldline signature")) from ex