diff --git a/payment_payplug/README.rst b/payment_payplug/README.rst new file mode 100644 index 0000000000..d5cfbb0e5e --- /dev/null +++ b/payment_payplug/README.rst @@ -0,0 +1,372 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +========================= +Payment Provider: Payplug +========================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:8e9ac986b57d3094891d8dc40a31dccf61e1e548b335229055e03dd09c19c5db + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fe--commerce-lightgray.png?logo=github + :target: https://github.com/OCA/e-commerce/tree/19.0/payment_payplug + :alt: OCA/e-commerce +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/e-commerce-19-0/e-commerce-19-0-payment_payplug + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/e-commerce&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Ce module ajoute `Payplug `__ comme +fournisseur de paiement au cadre de paiement d'Odoo (module +``payment``). + +Le client est redirigé vers la page de paiement hébergée par Payplug, +puis revient sur Odoo. Le module gère : + +- le paiement en ligne sur le site web (panier et validation de + commande) ; +- le paiement des factures depuis le portail client et par lien de + paiement ; +- les notifications serveur de Payplug (paiements et remboursements) ; +- les remboursements totaux et partiels depuis Odoo, et ceux faits + depuis le portail Payplug ; +- les annulations, les échecs et les paiements expirés. + +Payplug n'accepte que l'euro : le fournisseur n'est proposé que pour les +paiements en EUR. + +Le module appelle directement l'API REST de Payplug avec ``requests`` ; +il ne demande aucune bibliothèque Python supplémentaire. + +**Avertissement :** ce module est un développement indépendant. Il n'est +ni affilié à Payplug, ni approuvé par Payplug. « Payplug » est une +marque de son propriétaire. + +**Table of contents** + +.. contents:: + :local: + +Installation +============ + +Le module dépend des modules standard ``payment`` et ``account_payment`` +(installés avec la Facturation ou le site e-commerce). Il ne demande +aucune bibliothèque Python supplémentaire. + +1. Placez le dossier ``payment_payplug`` dans un chemin d'extensions + (``addons_path``) d'Odoo 19, ou installez le paquet Python construit + avec ``pyproject.toml``. +2. Redémarrez Odoo, mettez à jour la liste des applications, puis + installez *Payment Provider: Payplug*. + +Le fournisseur *Payplug* est créé désactivé, pour chaque société. + +À la désinstallation, comme pour les fournisseurs standard, Odoo refuse +de continuer si des paiements comptables ont été faits avec Payplug. Un +fournisseur qui n'a que des transactions sans paiement est conservé +(désactivé, sans code) pour garder ces transactions lisibles ; sinon il +est supprimé. + +Configuration +============= + +**Clés Payplug** + +Le module accepte deux méthodes d'authentification, au choix dans le +champ *Authentification* de l'onglet *Identifiants* : + +- **Clé API** (par défaut) : la clé secrète du compte est envoyée à + chaque requête ; +- **OAuth2** : Odoo échange un identifiant client (*client ID*) et un + secret client (*client secret*) contre des jetons d'accès valables 5 + minutes, demandés et renouvelés automatiquement. Le secret ne circule + que vers le serveur d'autorisation de Payplug. + +1. Dans le portail Payplug, à la rubrique des clés d'API, générez une + clé en environnement **Test** et, plus tard, une clé en environnement + **Live** : + + - pour la méthode *Clé API*, une clé de type **API Key** (clé + secrète). Les clés actuelles sont des jetons JWT (``eyJ...``) ; les + anciennes clés ``sk_test_...`` et ``sk_live_...`` sont aussi + acceptées ; + - pour la méthode *OAuth2*, une clé de type **OAuth2**. Le portail + affiche son *client ID* et son *client secret* ; notez le secret + aussitôt, il n'est montré qu'une fois. + + La clé publique n'est pas utilisée : le module ne passe que par la + page de paiement hébergée. + +2. Dans Odoo, ouvrez *Facturation (ou Comptabilité) > Configuration > + Fournisseurs de paiement* et choisissez **Payplug**. + +3. Dans l'onglet *Identifiants*, choisissez la méthode + d'authentification, puis collez la clé secrète (*Clé API*) ou le + *client ID* et le *client secret* (*OAuth2*). Seuls les + administrateurs (groupe *Paramètres*) peuvent les lire ou les + modifier. + +4. Réglez l'état : + + - **Mode test** avec une clé de l'environnement Test : aucun débit + réel ; + - **Activé** avec une clé de l'environnement Live : paiements réels. + + Pour une ancienne clé ``sk_...``, Odoo refuse une clé de production + en mode test et une clé de test sur un fournisseur activé. Pour une + clé JWT, l'accord entre le mode et la clé est vérifié à chaque + paiement : un paiement dont le mode ne correspond pas est refusé. En + OAuth2, l'environnement du jeton (*scope* ``test``) est en plus + vérifié à chaque requête : en mode test, seul un jeton de + l'environnement Test est accepté ; sur un fournisseur activé, un + jeton de test est refusé. + + Pour passer de Test à Live en OAuth2, remplacez le *client ID* et le + *client secret* par ceux de la clé Live en même temps que vous + activez le fournisseur. + +5. Dans l'onglet *Configuration*, vérifiez le journal de paiement et les + moyens de paiement. Par défaut, seul *Carte* (CB, Visa, Mastercard) + est activé. Bancontact, iDEAL et MyBank sont proposés, mais ne les + activez qu'après les avoir fait ouvrir sur votre compte Payplug. + +6. Publiez le fournisseur pour qu'il apparaisse sur le site web. + +**Adresse de notification (webhook)** + +Le module donne à Payplug, pour chaque paiement, l'adresse +``https:///payment/payplug/webhook``. Il n'y a rien à +saisir dans le portail Payplug, mais cette adresse doit être joignable +depuis Internet en HTTPS : + +- ne la protégez pas par une authentification, un filtrage IP ou une + règle de pare-feu (WAF, fail2ban, Cloudflare…) qui bloquerait les + requêtes POST de Payplug ; +- vérifiez le paramètre système ``web.base.url`` (ou le domaine du site + web) : c'est lui qui sert à construire les adresses de retour et de + notification. + +Les retours du client passent par ``/payment/payplug/return`` et +``/payment/payplug/cancel``. + +**Jetons d'accès OAuth2** + +Un jeton dure 5 minutes ; Odoo le garde en mémoire et en demande un +nouveau une minute avant son expiration. Les jetons ne sont jamais +écrits en base ni dans les journaux ; chaque processus (worker) d'Odoo +demande les siens. Si Payplug refuse un jeton (HTTP 401), Odoo l'oublie, +en demande un autre et renvoie la requête une seule fois. Après une +modification des identifiants, chaque processus demande un nouveau jeton +dès sa requête suivante ; après un changement d'état, le jeton en +mémoire est de nouveau confronté au mode du fournisseur. + +**Paramètres système réservés aux tests** + +Ils ne servent qu'à pointer vers un faux serveur de test ; ne les créez +pas en production. + +- ``payment_payplug.api_base_url`` remplace l'adresse de l'API Payplug + (``https://api.payplug.com/v1/`` par défaut) ; +- ``payment_payplug.token_url`` remplace l'adresse du point d'obtention + des jetons OAuth2 (par défaut ``/oauth2/token`` sur l'hôte de l'API, + soit ``https://api.payplug.com/oauth2/token``) ; +- ``payment_payplug.token_audience`` remplace l'audience demandée pour + les jetons (``https://www.payplug.com`` par défaut). + +Usage +===== + +**Paiement sur le site web ou depuis le portail** + +Le client choisit *Carte* (ou un autre moyen activé), est redirigé vers +la page Payplug, paie, puis revient sur la page d'état du paiement +d'Odoo. La commande ou la facture est confirmée dès qu'Odoo a relu le +paiement auprès de Payplug, soit au retour du client, soit à la +réception de la notification. + +Si le client clique sur *Annuler* sur la page Payplug, le paiement est +abandonné chez Payplug et la transaction passe à l'état *Annulée*. Un +paiement refusé passe à l'état *Erreur* avec le motif donné par Payplug +; un paiement jamais terminé est annulé quand Payplug signale son +expiration (environ 15 minutes). + +**Remboursement** + +Depuis le paiement (*Comptabilité > Clients > Paiements*), ou depuis la +facture via son paiement, cliquez sur *Rembourser* et indiquez le +montant (total ou partiel). Odoo demande le remboursement à Payplug, +crée une transaction de remboursement liée à la transaction d'origine, +puis le paiement comptable négatif correspondant. + +Un remboursement fait directement dans le portail Payplug est aussi +enregistré dans Odoo, à la réception de sa notification. + +Payplug n'accepte pas de remboursement inférieur à 0,10 €. + +**Vérifier une transaction** + +Sur une transaction Payplug en attente ou en erreur, le bouton *Vérifier +le statut sur Payplug* relit le paiement auprès de Payplug et met la +transaction à jour (utile si une notification n'a pas pu arriver). + +Development +=========== + +Les tests automatiques (``--test-tags /payment_payplug``) n'appellent +jamais l'API Payplug : ils démarrent un faux serveur Payplug local +(``tests/fake_payplug.py``) et y font pointer le module par le paramètre +système ``payment_payplug.api_base_url``. + +Le même faux serveur sert aux essais manuels de bout en bout, avec une +page de paiement factice (boutons *Payer*, *Refuser*, *Annuler*) et +l'envoi des notifications : + +.. code:: shell + + python3 payment_payplug/tests/fake_payplug.py --host 127.0.0.1 --port 8765 + +Sur une base de test, créez alors le paramètre système +``payment_payplug.api_base_url`` avec la valeur +``http://127.0.0.1:8765/v1/``, et saisissez une clé quelconque +commençant par ``sk_test_``. ``POST /_control/refund/`` +(corps ``{"amount": 1000}``) simule un remboursement fait depuis le +portail Payplug. + +Pour essayer la méthode OAuth2, le faux serveur répond aussi sur +``POST /oauth2/token`` (authentification *Basic*, +``grant_type=client_credentials``, audience ``https://www.payplug.com``) +avec deux clients factices : ``fake_client_test`` / +``fake_client_secret_test`` (jetons de test) et ``fake_client_live`` / +``fake_client_secret_live`` (jetons Live). L'adresse du point de jeton +se déduit de ``payment_payplug.api_base_url`` ; il n'y a rien d'autre à +régler. + +Known issues / Roadmap +====================== + +- Connexion « avec son compte Payplug » (OAuth2 *authorization code* + + PKCE, chemin ``/oauth2/auth``) : hors périmètre. Ce flux demande un + client enregistré par Payplug avec une adresse de rappel ; le module + se limite au flux *client credentials* avec une clé OAuth2 créée dans + le portail. +- Prévu pour une version 3 : paiement en un clic (carte enregistrée), + paiement fractionné, paiement différé, Apple Pay et paiement intégré. +- Paiement en plusieurs fois (Oney 3x/4x) : non pris en charge. Il + demande le panier détaillé (``payment_context``), une autorisation + puis une capture, et des montants entre 100 € et 3 000 €. +- Enregistrement de la carte (jetons) et paiements récurrents : non pris + en charge. +- Capture différée (autorisation puis capture manuelle) : non prise en + charge. +- Apple Pay et le paiement intégré (*Integrated Payment*, champs de + carte dans la page Odoo) : non pris en charge ; seule la page hébergée + est utilisée. +- Les marques de carte renvoyées par Payplug (``card.brand``) ne sont + connues que par la documentation ; à vérifier avec de vraies + transactions de test. +- Satispay, Scalapay et Wero n'ont pas de moyen de paiement + correspondant dans Odoo 19 : ils ne sont pas proposés. + +À vérifier avec de vraies clés de test Payplug (non vérifié, faute de +compte) : + +- le format exact des notifications (paiement et remboursement), en + particulier la présence de ``payment_id`` dans celles des + remboursements ; +- le code HTTP attendu par Payplug en réponse aux notifications, et s'il + les renvoie après une erreur (le module répond 503 quand il ne peut + pas relire le paiement) ; +- l'acceptation des champs ``billing`` / ``shipping`` envoyés (langue, + téléphone, pays) ; +- l'abandon (``aborted``) d'un paiement quand le client clique sur + *Annuler* ; +- les valeurs de ``card.brand`` (Visa, Mastercard, CB…) ; +- Bancontact, iDEAL et MyBank, qui demandent une activation sur le + compte Payplug ; +- en OAuth2 : la valeur du *scope* des jetons de l'environnement Live + (``live`` est supposé ; une autre valeur est tolérée sur un + fournisseur activé, le mode de chaque paiement restant vérifié). + +Changelog +========= + +19.0.1.1.0 (2026-10-24) +----------------------- + +- Authentification OAuth2 (*client credentials*) au choix, à côté de la + clé API : jetons d'accès de 5 minutes gardés en mémoire et renouvelés + automatiquement, nouvelle demande et renvoi unique de la requête si + Payplug refuse le jeton, contrôle de l'environnement (Test ou Live) du + jeton. + +19.0.1.0.0 (2026-10-24) +----------------------- + +- Première version : paiement par la page hébergée Payplug, + notifications, remboursements totaux et partiels, paiement des + factures. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Sébastien Berger (sbinfo67) + +Contributors +------------ + +- Sébastien Berger + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-sbinfo67| image:: https://github.com/sbinfo67.png?size=40px + :target: https://github.com/sbinfo67 + :alt: sbinfo67 + +Current `maintainer `__: + +|maintainer-sbinfo67| + +This module is part of the `OCA/e-commerce `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/payment_payplug/__init__.py b/payment_payplug/__init__.py new file mode 100644 index 0000000000..765031d0e7 --- /dev/null +++ b/payment_payplug/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2026 Sébastien Berger (sbinfo67) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +from . import controllers +from . import models +from .hooks import post_init_hook, uninstall_hook diff --git a/payment_payplug/__manifest__.py b/payment_payplug/__manifest__.py new file mode 100644 index 0000000000..a5df0a7131 --- /dev/null +++ b/payment_payplug/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright 2026 Sébastien Berger (sbinfo67) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +{ + "name": "Payment Provider: Payplug", + "version": "19.0.1.1.0", + "category": "Accounting/Payment Providers", + "summary": "Payplug payment provider: hosted payment page, refunds", + "author": "Sébastien Berger (sbinfo67), Odoo Community Association (OCA)", + "maintainers": ["sbinfo67"], + "website": "https://github.com/OCA/e-commerce", + "license": "LGPL-3", + "development_status": "Beta", + "depends": ["payment", "account_payment"], + "data": [ + "views/payment_payplug_templates.xml", + "views/payment_provider_views.xml", + "views/payment_transaction_views.xml", + "data/payment_provider_data.xml", + ], + "post_init_hook": "post_init_hook", + "uninstall_hook": "uninstall_hook", + "installable": True, +} diff --git a/payment_payplug/const.py b/payment_payplug/const.py new file mode 100644 index 0000000000..ec5f1dcd53 --- /dev/null +++ b/payment_payplug/const.py @@ -0,0 +1,92 @@ +# Copyright 2026 Sébastien Berger (sbinfo67) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +# Base URL of the Payplug REST API. The system parameter below overrides it; it is only +# meant for tests (e.g. a local fake server). The same endpoints serve the live and test +# modes, the secret key alone selecting the mode. +# See https://docs.payplug.com/api/apiref.html +DEFAULT_API_BASE_URL = "https://api.payplug.com/v1/" +API_BASE_URL_PARAM = "payment_payplug.api_base_url" + +# OAuth2 client credentials grant. By default, the token endpoint is `/oauth2/token` on +# the host of the API. The system parameters below override the endpoint and the +# audience; like the base URL, they are only meant for tests. +TOKEN_PATH = "/oauth2/token" +TOKEN_URL_PARAM = "payment_payplug.token_url" +DEFAULT_TOKEN_AUDIENCE = "https://www.payplug.com" +TOKEN_AUDIENCE_PARAM = "payment_payplug.token_audience" +# Seconds before the expiry of an access token at which it is renewed. +TOKEN_RENEWAL_MARGIN = 60 +# Timeout of the token requests, in seconds (the same as the API requests). +TOKEN_REQUEST_TIMEOUT = 10 + +# Version of the API used by this module, sent in the `PayPlug-Version` header. +API_VERSION = "2019-08-06" + +# Prefixes of the secret keys depending on the environment. +SECRET_KEY_PREFIX_TEST = "sk_test_" +SECRET_KEY_PREFIX_LIVE = "sk_live_" + +# Payplug only processes payments in euros. +SUPPORTED_CURRENCIES = ("EUR",) + +# Amount limits in minor units (cents), from the API reference. +MINIMUM_PAYMENT_AMOUNT = 30 +MAXIMUM_PAYMENT_AMOUNT = 2000000 +MINIMUM_REFUND_AMOUNT = 10 + +# The codes of the payment methods to activate when Payplug is activated. +DEFAULT_PAYMENT_METHOD_CODES = { + # Primary payment methods. + "card", + # Brand payment methods. + "visa", + "mastercard", + "cartes_bancaires", +} + +# Mapping of Odoo payment method codes to the `payment_method` values of the Payplug +# API. The `card` method is the default one of the hosted payment page: it is not sent. +PAYMENT_METHODS_MAPPING = { + "bancontact": "bancontact", + "ideal": "ideal", + "mybank": "mybank", +} + +# Mapping of card brands returned by Payplug (lowercased) to Odoo payment method codes. +CARD_BRANDS_MAPPING = { + "visa": "visa", + "mastercard": "mastercard", + "cb": "cartes_bancaires", + "maestro": "maestro", +} + +# Languages supported by the hosted payment page (ISO 639-1). +SUPPORTED_LANGUAGES = ("fr", "en", "it") +DEFAULT_LANGUAGE = "en" + +# Failure codes after which the payment is considered as canceled rather than failed. +CANCEL_FAILURE_CODES = ("aborted", "timeout") + +# Maximum lengths of the billing and shipping fields. +FIELD_MAX_LENGTHS = { + "first_name": 100, + "last_name": 100, + "email": 255, + "address1": 255, + "address2": 255, + "postcode": 16, + "city": 100, + "company_name": 100, +} +DESCRIPTION_MAX_LENGTH = 80 + +# Keys of the payment data that must never be logged. +SENSITIVE_KEYS = { + "secret_key", + "Authorization", + "client_secret", + "access_token", + "payplug_secret_key", + "payplug_client_secret", +} diff --git a/payment_payplug/controllers/__init__.py b/payment_payplug/controllers/__init__.py new file mode 100644 index 0000000000..12a7e529b6 --- /dev/null +++ b/payment_payplug/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/payment_payplug/controllers/main.py b/payment_payplug/controllers/main.py new file mode 100644 index 0000000000..dcb1b0ab1a --- /dev/null +++ b/payment_payplug/controllers/main.py @@ -0,0 +1,122 @@ +# Copyright 2026 Sébastien Berger (sbinfo67) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +import json + +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.logging import get_payment_logger + +from .. import const +from ..models.payment_transaction import CANCEL_ROUTE, RETURN_ROUTE, WEBHOOK_ROUTE + +_logger = get_payment_logger(__name__, sensitive_keys=const.SENSITIVE_KEYS) + + +class PayplugController(http.Controller): + _return_url = RETURN_ROUTE + _cancel_url = CANCEL_ROUTE + _webhook_url = WEBHOOK_ROUTE + + @http.route( + _return_url, type="http", auth="public", methods=["GET"], save_session=False + ) + def payplug_return(self, reference=None, access_token=None, **_kwargs): + """Process the return of the customer from the Payplug payment page. + + The route is flagged with `save_session=False` for the same reason as in the + other redirection-based providers: the session cookie may be missing when coming + back from an external site, and the redirection to `/payment/status` retrieves + it. + + :param str reference: The reference of the transaction, embedded in the return + URL. + :param str access_token: The access token signing the reference. + """ + tx_sudo = self._get_transaction(reference, access_token) + if tx_sudo: + try: + tx_sudo._payplug_fetch_and_process() + except ValidationError: + _logger.exception( + "Unable to fetch the Payplug payment of %s.", reference + ) + return request.redirect("/payment/status") + + @http.route( + _cancel_url, type="http", auth="public", methods=["GET"], save_session=False + ) + def payplug_cancel(self, reference=None, access_token=None, **_kwargs): + """Process the cancellation of the payment by the customer on Payplug. + + :param str reference: The reference of the transaction, embedded in the cancel + URL. + :param str access_token: The access token signing the reference. + """ + tx_sudo = self._get_transaction(reference, access_token) + if tx_sudo: + try: + tx_sudo._payplug_abort_and_process() + except ValidationError: + _logger.exception( + "Unable to cancel the Payplug payment of %s.", reference + ) + return request.redirect("/payment/status") + + @http.route(_webhook_url, type="http", auth="public", methods=["POST"], csrf=False) + def payplug_webhook(self): + """Process a notification sent by Payplug. + + The body is not trusted: only the id of the notified object is used, and the + object is fetched from the API before any update. + + :return: An empty response acknowledging the notification, or an error status + when the object could not be fetched, so that the notification can be sent + again. + """ + try: + notification_data = json.loads( + request.httprequest.get_data(as_text=True) or "{}" + ) + except ValueError: + _logger.warning("Received a Payplug notification with an invalid body.") + return request.make_response("", status=400) + if not isinstance(notification_data, dict): + return request.make_response("", status=400) + _logger.info( + "Notification received from Payplug for %s %s.", + notification_data.get("object"), + notification_data.get("id"), + ) + try: + request.env["payment.transaction"].sudo()._payplug_handle_notification( + notification_data + ) + except ValidationError: + _logger.exception("Unable to process the Payplug notification.") + return request.make_response("", status=503) + return request.make_response("", status=200) + + @staticmethod + def _get_transaction(reference, access_token): + """Return the Payplug transaction matching the signed reference, if any. + + :param str reference: The reference of the transaction. + :param str access_token: The access token signing the reference. + :return: The transaction, in sudo mode, or an empty recordset. + :rtype: payment.transaction + """ + tx_model = request.env["payment.transaction"].sudo() + if not reference or not payment_utils.check_access_token( + access_token, reference + ): + _logger.warning( + "Received a Payplug redirection with an invalid access token." + ) + return tx_model + return tx_model.search( + [("reference", "=", reference), ("provider_code", "=", "payplug")], limit=1 + ) diff --git a/payment_payplug/data/neutralize.sql b/payment_payplug/data/neutralize.sql new file mode 100644 index 0000000000..743bb04310 --- /dev/null +++ b/payment_payplug/data/neutralize.sql @@ -0,0 +1,4 @@ +-- disable the Payplug payment provider +UPDATE payment_provider + SET payplug_secret_key = NULL, + payplug_client_secret = NULL; diff --git a/payment_payplug/data/payment_provider_data.xml b/payment_payplug/data/payment_provider_data.xml new file mode 100644 index 0000000000..bde347e02c --- /dev/null +++ b/payment_payplug/data/payment_provider_data.xml @@ -0,0 +1,25 @@ + + + + + Payplug + payplug + + + + + + diff --git a/payment_payplug/hooks.py b/payment_payplug/hooks.py new file mode 100644 index 0000000000..3ca82859e8 --- /dev/null +++ b/payment_payplug/hooks.py @@ -0,0 +1,39 @@ +# Copyright 2026 Sébastien Berger (sbinfo67) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +from odoo.addons.base.models.ir_model import MODULE_UNINSTALL_FLAG +from odoo.addons.payment import reset_payment_provider, setup_provider + + +def post_init_hook(env): + setup_provider(env, "payplug") + + +def uninstall_hook(env): + """Reset the Payplug providers, like the providers of the standard modules. + + Unlike them, the provider record belongs to this module and would be deleted with + its data. Providers without transactions are deleted now; the others are kept + (with the `none` code) and detached from this module so that their transactions + stay readable. + """ + providers = ( + env["payment.provider"] + .with_context(active_test=False) + .search([("code", "=", "payplug")]) + ) + used_providers = ( + env["payment.transaction"] + .sudo() + .search([("provider_id", "in", providers.ids)]) + .provider_id + ) + reset_payment_provider(env, "payplug") + env["ir.model.data"].search( + [ + ("module", "=", "payment_payplug"), + ("model", "=", "payment.provider"), + ("res_id", "in", used_providers.ids), + ] + ).unlink() + (providers - used_providers).with_context(**{MODULE_UNINSTALL_FLAG: True}).unlink() diff --git a/payment_payplug/i18n/fr.po b/payment_payplug/i18n/fr.po new file mode 100644 index 0000000000..05ec104d17 --- /dev/null +++ b/payment_payplug/i18n/fr.po @@ -0,0 +1,291 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * payment_payplug +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0-20260817\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-10-24 16:51+0000\n" +"PO-Revision-Date: 2026-10-24 16:51+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" + +#. module: payment_payplug +#: model:ir.model.fields.selection,name:payment_payplug.selection__payment_provider__payplug_auth_method__api_key +msgid "API Key" +msgstr "Clé API" + +#. module: payment_payplug +#: model:ir.model.fields,help:payment_payplug.field_payment_provider__payplug_auth_method +msgid "" +"API Key: the secret key of your Payplug account is sent with every request.\n" +"OAuth2: Odoo exchanges a client ID and secret for access tokens valid a few minutes, renewed automatically." +msgstr "" +"Clé API : la clé secrète de votre compte Payplug est envoyée à chaque requête.\n" +"OAuth2 : Odoo échange un client ID et un client secret contre des jetons d'accès valables quelques minutes, renouvelés automatiquement." + +#. module: payment_payplug +#: model_terms:ir.ui.view,arch_db:payment_payplug.payment_provider_form +msgid "Authentication" +msgstr "Authentification" + +#. module: payment_payplug +#: model_terms:ir.ui.view,arch_db:payment_payplug.payment_transaction_form +msgid "Check Status on Payplug" +msgstr "Vérifier le statut sur Payplug" + +#. module: payment_payplug +#: model_terms:ir.ui.view,arch_db:payment_payplug.payment_provider_form +msgid "Client ID" +msgstr "Client ID" + +#. module: payment_payplug +#: model_terms:ir.ui.view,arch_db:payment_payplug.payment_provider_form +msgid "Client Secret" +msgstr "Client secret" + +#. module: payment_payplug +#: model:ir.model.fields,field_description:payment_payplug.field_payment_provider__code +msgid "Code" +msgstr "Code" + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_provider.py:0 +msgid "Could not reach the Payplug authorization server to get an access token. Please try again later." +msgstr "Impossible de joindre le serveur d'autorisation Payplug pour obtenir un jeton d'accès. Veuillez réessayer plus tard." + +#. module: payment_payplug +#: model:ir.model.fields,field_description:payment_payplug.field_payment_provider__display_name +#: model:ir.model.fields,field_description:payment_payplug.field_payment_transaction__display_name +msgid "Display Name" +msgstr "Nom d'affichage" + +#. module: payment_payplug +#: model_terms:ir.ui.view,arch_db:payment_payplug.payment_transaction_form +msgid "Fetch the status of this transaction from Payplug and update it." +msgstr "Relit le statut de cette transaction auprès de Payplug et la met à jour." + +#. module: payment_payplug +#: model:ir.model.fields,field_description:payment_payplug.field_payment_provider__id +#: model:ir.model.fields,field_description:payment_payplug.field_payment_transaction__id +msgid "ID" +msgstr "ID" + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_provider.py:0 +msgid "In test mode, the Payplug OAuth2 key must be a key of the Test environment (access token scope: %s)." +msgstr "En mode test, la clé OAuth2 Payplug doit être une clé de l'environnement Test (portée du jeton d'accès : %s)." + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_provider.py:0 +msgid "In test mode, the Payplug secret key must be a test key (starting with %s)." +msgstr "En mode test, la clé secrète Payplug doit être une clé de test (commençant par %s)." + +#. module: payment_payplug +#: model:ir.model.fields.selection,name:payment_payplug.selection__payment_provider__payplug_auth_method__oauth2 +msgid "OAuth2" +msgstr "OAuth2" + +#. module: payment_payplug +#: model:ir.model,name:payment_payplug.model_payment_provider +msgid "Payment Provider" +msgstr "Fournisseur de paiement" + +#. module: payment_payplug +#: model:ir.model,name:payment_payplug.model_payment_transaction +msgid "Payment Transaction" +msgstr "Transaction de paiement" + +#. module: payment_payplug +#: model:ir.model.fields.selection,name:payment_payplug.selection__payment_provider__code__payplug +#: model:payment.provider,name:payment_payplug.payment_provider_payplug +msgid "Payplug" +msgstr "Payplug" + +#. module: payment_payplug +#: model:ir.model.fields,field_description:payment_payplug.field_payment_provider__payplug_auth_method +msgid "Payplug Authentication" +msgstr "Authentification Payplug" + +#. module: payment_payplug +#: model:ir.model.fields,field_description:payment_payplug.field_payment_provider__payplug_client_id +msgid "Payplug Client ID" +msgstr "Client ID Payplug" + +#. module: payment_payplug +#: model:ir.model.fields,field_description:payment_payplug.field_payment_provider__payplug_client_secret +msgid "Payplug Client Secret" +msgstr "Client secret Payplug" + +#. module: payment_payplug +#: model:ir.model.fields,field_description:payment_payplug.field_payment_provider__payplug_secret_key +msgid "Payplug Secret Key" +msgstr "Clé secrète Payplug" + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_transaction.py:0 +msgid "Payplug did not return any payment page URL." +msgstr "Payplug n'a renvoyé aucune adresse de page de paiement." + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_transaction.py:0 +msgid "Payplug does not accept refunds below %s." +msgstr "Payplug n'accepte pas les remboursements inférieurs à %s." + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_transaction.py:0 +msgid "Payplug only accepts card payments between %(minimum)s and %(maximum)s." +msgstr "Payplug n'accepte les paiements par carte qu'entre %(minimum)s et %(maximum)s." + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_provider.py:0 +msgid "Payplug refused the OAuth2 client ID or secret. Check them, and check that the key belongs to the right environment (Test or Live)." +msgstr "Payplug a refusé le client ID ou le client secret OAuth2. Vérifiez-les, ainsi que l'environnement de la clé (Test ou Live)." + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_provider.py:0 +msgid "Payplug refused the access token request (HTTP %(status)s %(error)s)." +msgstr "Payplug a refusé la demande de jeton d'accès (HTTP %(status)s %(error)s)." + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_provider.py:0 +msgid "Payplug returned an invalid access token response." +msgstr "Payplug a renvoyé une réponse de jeton d'accès invalide." + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_transaction.py:0 +msgid "Payplug returned an object that is not a payment." +msgstr "Payplug a renvoyé un objet qui n'est pas un paiement." + +#. module: payment_payplug +#: model_terms:ir.ui.view,arch_db:payment_payplug.payment_provider_form +msgid "Secret Key" +msgstr "Clé secrète" + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_provider.py:0 +msgid "The Payplug OAuth2 client ID and secret are not set." +msgstr "Le client ID et le client secret OAuth2 Payplug ne sont pas renseignés." + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_provider.py:0 +msgid "The Payplug authorization server is unavailable (HTTP %s). Please try again later." +msgstr "Le serveur d'autorisation Payplug est indisponible (HTTP %s). Veuillez réessayer plus tard." + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_transaction.py:0 +msgid "The Payplug payment does not reference this transaction." +msgstr "Le paiement Payplug ne fait pas référence à cette transaction." + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_transaction.py:0 +msgid "The Payplug payment environment (test or live) does not match the provider." +msgstr "L'environnement du paiement Payplug (test ou production) ne correspond pas à celui du fournisseur." + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_transaction.py:0 +msgid "The Payplug payment id does not match the transaction." +msgstr "L'identifiant du paiement Payplug ne correspond pas à la transaction." + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_transaction.py:0 +msgid "The Payplug refund does not match the transaction." +msgstr "Le remboursement Payplug ne correspond pas à la transaction." + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_transaction.py:0 +msgid "The Payplug refund environment (test or live) does not match the provider." +msgstr "L'environnement du remboursement Payplug (test ou production) ne correspond pas à celui du fournisseur." + +#. module: payment_payplug +#: model:ir.model.fields,help:payment_payplug.field_payment_provider__payplug_client_id +msgid "The client ID of an OAuth2 key of your Payplug account: a key of the Test environment in test mode, a key of the Live environment when the provider is enabled." +msgstr "Le client ID d'une clé OAuth2 de votre compte Payplug : une clé de l'environnement Test en mode test, une clé de l'environnement Live quand le fournisseur est activé." + +#. module: payment_payplug +#: model:ir.model.fields,help:payment_payplug.field_payment_provider__payplug_client_secret +msgid "The client secret of the OAuth2 key, shown once by the Payplug portal when the key is created." +msgstr "Le client secret de la clé OAuth2, affiché une seule fois par le portail Payplug à la création de la clé." + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_provider.py:0 +msgid "The following fields must be filled: %s" +msgstr "Les champs suivants doivent être renseignés : %s" + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_transaction.py:0 +msgid "The payment was canceled on Payplug (%s)." +msgstr "Le paiement a été annulé chez Payplug (%s)." + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_transaction.py:0 +msgid "The payment was refused by Payplug (%(code)s): %(message)s" +msgstr "Le paiement a été refusé par Payplug (%(code)s) : %(message)s" + +#. module: payment_payplug +#: model:ir.model.fields,help:payment_payplug.field_payment_provider__payplug_secret_key +msgid "The secret key of your Payplug account: a key of the Test environment in test mode, a key of the Live environment when the provider is enabled." +msgstr "La clé secrète de votre compte Payplug : une clé de l'environnement Test en mode test, une clé de l'environnement Live quand le fournisseur est activé." + +#. module: payment_payplug +#: model:ir.model.fields,help:payment_payplug.field_payment_provider__code +msgid "The technical code of this payment provider." +msgstr "Le code technique de ce fournisseur de paiement." + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_provider.py:0 +msgid "When the provider is enabled, the Payplug OAuth2 key must be a key of the Live environment (access token scope: %s)." +msgstr "Quand le fournisseur est activé, la clé OAuth2 Payplug doit être une clé de l'environnement Live (portée du jeton d'accès : %s)." + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_provider.py:0 +msgid "When the provider is enabled, the Payplug secret key must be a live key (starting with %s)." +msgstr "Quand le fournisseur est activé, la clé secrète Payplug doit être une clé de production (commençant par %s)." + +#. module: payment_payplug +#: model_terms:payment.provider,auth_msg:payment_payplug.payment_provider_payplug +msgid "Your payment has been authorized." +msgstr "Votre paiement a été autorisé." + +#. module: payment_payplug +#: model_terms:payment.provider,cancel_msg:payment_payplug.payment_provider_payplug +msgid "Your payment has been cancelled." +msgstr "Votre paiement a été annulé." + +#. module: payment_payplug +#: model_terms:payment.provider,pending_msg:payment_payplug.payment_provider_payplug +msgid "Your payment has been processed but is waiting for approval." +msgstr "Votre paiement a été traité mais est en attente d’approbation." + +#. module: payment_payplug +#: model_terms:payment.provider,done_msg:payment_payplug.payment_provider_payplug +msgid "Your payment has been processed." +msgstr "Votre paiement a été traité." diff --git a/payment_payplug/i18n/payment_payplug.pot b/payment_payplug/i18n/payment_payplug.pot new file mode 100644 index 0000000000..a4d29ae412 --- /dev/null +++ b/payment_payplug/i18n/payment_payplug.pot @@ -0,0 +1,311 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * payment_payplug +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0-20260817\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-10-24 16:51+0000\n" +"PO-Revision-Date: 2026-10-24 16:51+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: payment_payplug +#: model:ir.model.fields.selection,name:payment_payplug.selection__payment_provider__payplug_auth_method__api_key +msgid "API Key" +msgstr "" + +#. module: payment_payplug +#: model:ir.model.fields,help:payment_payplug.field_payment_provider__payplug_auth_method +msgid "" +"API Key: the secret key of your Payplug account is sent with every request.\n" +"OAuth2: Odoo exchanges a client ID and secret for access tokens valid a few minutes, renewed automatically." +msgstr "" + +#. module: payment_payplug +#: model_terms:ir.ui.view,arch_db:payment_payplug.payment_provider_form +msgid "Authentication" +msgstr "" + +#. module: payment_payplug +#: model_terms:ir.ui.view,arch_db:payment_payplug.payment_transaction_form +msgid "Check Status on Payplug" +msgstr "" + +#. module: payment_payplug +#: model_terms:ir.ui.view,arch_db:payment_payplug.payment_provider_form +msgid "Client ID" +msgstr "" + +#. module: payment_payplug +#: model_terms:ir.ui.view,arch_db:payment_payplug.payment_provider_form +msgid "Client Secret" +msgstr "" + +#. module: payment_payplug +#: model:ir.model.fields,field_description:payment_payplug.field_payment_provider__code +msgid "Code" +msgstr "" + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_provider.py:0 +msgid "" +"Could not reach the Payplug authorization server to get an access token. " +"Please try again later." +msgstr "" + +#. module: payment_payplug +#: model:ir.model.fields,field_description:payment_payplug.field_payment_provider__display_name +#: model:ir.model.fields,field_description:payment_payplug.field_payment_transaction__display_name +msgid "Display Name" +msgstr "" + +#. module: payment_payplug +#: model_terms:ir.ui.view,arch_db:payment_payplug.payment_transaction_form +msgid "Fetch the status of this transaction from Payplug and update it." +msgstr "" + +#. module: payment_payplug +#: model:ir.model.fields,field_description:payment_payplug.field_payment_provider__id +#: model:ir.model.fields,field_description:payment_payplug.field_payment_transaction__id +msgid "ID" +msgstr "" + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_provider.py:0 +msgid "" +"In test mode, the Payplug OAuth2 key must be a key of the Test environment " +"(access token scope: %s)." +msgstr "" + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_provider.py:0 +msgid "" +"In test mode, the Payplug secret key must be a test key (starting with %s)." +msgstr "" + +#. module: payment_payplug +#: model:ir.model.fields.selection,name:payment_payplug.selection__payment_provider__payplug_auth_method__oauth2 +msgid "OAuth2" +msgstr "" + +#. module: payment_payplug +#: model:ir.model,name:payment_payplug.model_payment_provider +msgid "Payment Provider" +msgstr "" + +#. module: payment_payplug +#: model:ir.model,name:payment_payplug.model_payment_transaction +msgid "Payment Transaction" +msgstr "" + +#. module: payment_payplug +#: model:ir.model.fields.selection,name:payment_payplug.selection__payment_provider__code__payplug +#: model:payment.provider,name:payment_payplug.payment_provider_payplug +msgid "Payplug" +msgstr "" + +#. module: payment_payplug +#: model:ir.model.fields,field_description:payment_payplug.field_payment_provider__payplug_auth_method +msgid "Payplug Authentication" +msgstr "" + +#. module: payment_payplug +#: model:ir.model.fields,field_description:payment_payplug.field_payment_provider__payplug_client_id +msgid "Payplug Client ID" +msgstr "" + +#. module: payment_payplug +#: model:ir.model.fields,field_description:payment_payplug.field_payment_provider__payplug_client_secret +msgid "Payplug Client Secret" +msgstr "" + +#. module: payment_payplug +#: model:ir.model.fields,field_description:payment_payplug.field_payment_provider__payplug_secret_key +msgid "Payplug Secret Key" +msgstr "" + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_transaction.py:0 +msgid "Payplug did not return any payment page URL." +msgstr "" + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_transaction.py:0 +msgid "Payplug does not accept refunds below %s." +msgstr "" + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_transaction.py:0 +msgid "" +"Payplug only accepts card payments between %(minimum)s and %(maximum)s." +msgstr "" + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_provider.py:0 +msgid "" +"Payplug refused the OAuth2 client ID or secret. Check them, and check that " +"the key belongs to the right environment (Test or Live)." +msgstr "" + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_provider.py:0 +msgid "Payplug refused the access token request (HTTP %(status)s %(error)s)." +msgstr "" + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_provider.py:0 +msgid "Payplug returned an invalid access token response." +msgstr "" + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_transaction.py:0 +msgid "Payplug returned an object that is not a payment." +msgstr "" + +#. module: payment_payplug +#: model_terms:ir.ui.view,arch_db:payment_payplug.payment_provider_form +msgid "Secret Key" +msgstr "" + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_provider.py:0 +msgid "The Payplug OAuth2 client ID and secret are not set." +msgstr "" + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_provider.py:0 +msgid "" +"The Payplug authorization server is unavailable (HTTP %s). Please try again " +"later." +msgstr "" + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_transaction.py:0 +msgid "The Payplug payment does not reference this transaction." +msgstr "" + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_transaction.py:0 +msgid "" +"The Payplug payment environment (test or live) does not match the provider." +msgstr "" + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_transaction.py:0 +msgid "The Payplug payment id does not match the transaction." +msgstr "" + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_transaction.py:0 +msgid "The Payplug refund does not match the transaction." +msgstr "" + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_transaction.py:0 +msgid "" +"The Payplug refund environment (test or live) does not match the provider." +msgstr "" + +#. module: payment_payplug +#: model:ir.model.fields,help:payment_payplug.field_payment_provider__payplug_client_id +msgid "" +"The client ID of an OAuth2 key of your Payplug account: a key of the Test " +"environment in test mode, a key of the Live environment when the provider is" +" enabled." +msgstr "" + +#. module: payment_payplug +#: model:ir.model.fields,help:payment_payplug.field_payment_provider__payplug_client_secret +msgid "" +"The client secret of the OAuth2 key, shown once by the Payplug portal when " +"the key is created." +msgstr "" + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_provider.py:0 +msgid "The following fields must be filled: %s" +msgstr "" + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_transaction.py:0 +msgid "The payment was canceled on Payplug (%s)." +msgstr "" + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_transaction.py:0 +msgid "The payment was refused by Payplug (%(code)s): %(message)s" +msgstr "" + +#. module: payment_payplug +#: model:ir.model.fields,help:payment_payplug.field_payment_provider__payplug_secret_key +msgid "" +"The secret key of your Payplug account: a key of the Test environment in " +"test mode, a key of the Live environment when the provider is enabled." +msgstr "" + +#. module: payment_payplug +#: model:ir.model.fields,help:payment_payplug.field_payment_provider__code +msgid "The technical code of this payment provider." +msgstr "" + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_provider.py:0 +msgid "" +"When the provider is enabled, the Payplug OAuth2 key must be a key of the " +"Live environment (access token scope: %s)." +msgstr "" + +#. module: payment_payplug +#. odoo-python +#: code:addons/payment_payplug/models/payment_provider.py:0 +msgid "" +"When the provider is enabled, the Payplug secret key must be a live key " +"(starting with %s)." +msgstr "" + +#. module: payment_payplug +#: model_terms:payment.provider,auth_msg:payment_payplug.payment_provider_payplug +msgid "Your payment has been authorized." +msgstr "" + +#. module: payment_payplug +#: model_terms:payment.provider,cancel_msg:payment_payplug.payment_provider_payplug +msgid "Your payment has been cancelled." +msgstr "" + +#. module: payment_payplug +#: model_terms:payment.provider,pending_msg:payment_payplug.payment_provider_payplug +msgid "Your payment has been processed but is waiting for approval." +msgstr "" + +#. module: payment_payplug +#: model_terms:payment.provider,done_msg:payment_payplug.payment_provider_payplug +msgid "Your payment has been processed." +msgstr "" diff --git a/payment_payplug/models/__init__.py b/payment_payplug/models/__init__.py new file mode 100644 index 0000000000..9afbccd31e --- /dev/null +++ b/payment_payplug/models/__init__.py @@ -0,0 +1,2 @@ +from . import payment_provider +from . import payment_transaction diff --git a/payment_payplug/models/payment_provider.py b/payment_payplug/models/payment_provider.py new file mode 100644 index 0000000000..1ef54f6db9 --- /dev/null +++ b/payment_payplug/models/payment_provider.py @@ -0,0 +1,496 @@ +# Copyright 2026 Sébastien Berger (sbinfo67) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +import re +import threading +from urllib.parse import urlsplit, urlunsplit + +import requests + +from odoo import api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools import urls + +from odoo.addons.payment.logging import get_payment_logger + +from .. import const, oauth + +_logger = get_payment_logger(__name__, sensitive_keys=const.SENSITIVE_KEYS) + +# The fields whose change makes the cached access tokens of a provider obsolete. +OAUTH2_FIELDS = { + "state", + "payplug_auth_method", + "payplug_client_id", + "payplug_client_secret", +} + +# Status code of the last API error response, per thread: the payment framework turns +# the HTTP errors into `ValidationError`, without their status code. +_last_api_error = threading.local() + +OAUTH2_ERROR_CODE_RE = re.compile(r"^[a-z_]{1,64}$") + + +class PaymentProvider(models.Model): + _inherit = "payment.provider" + + code = fields.Selection( + selection_add=[("payplug", "Payplug")], + ondelete={"payplug": "set default"}, + ) + payplug_auth_method = fields.Selection( + string="Payplug Authentication", + selection=[("api_key", "API Key"), ("oauth2", "OAuth2")], + default="api_key", + help="API Key: the secret key of your Payplug account is sent with every " + "request.\n" + "OAuth2: Odoo exchanges a client ID and secret for access tokens valid a few " + "minutes, renewed automatically.", + ) + payplug_secret_key = fields.Char( + help="The secret key of your Payplug account: a key of the Test environment " + "in test mode, a key of the Live environment when the provider is enabled.", + copy=False, + groups="base.group_system", + ) + payplug_client_id = fields.Char( + string="Payplug Client ID", + help="The client ID of an OAuth2 key of your Payplug account: a key of the " + "Test environment in test mode, a key of the Live environment when the " + "provider is enabled.", + copy=False, + groups="base.group_system", + ) + payplug_client_secret = fields.Char( + help="The client secret of the OAuth2 key, shown once by the Payplug portal " + "when the key is created.", + copy=False, + groups="base.group_system", + ) + + # === COMPUTE METHODS === # + + def _compute_feature_support_fields(self): + """Override of `payment` to enable additional features.""" + res = super()._compute_feature_support_fields() + self.filtered(lambda p: p.code == "payplug").update( + { + "support_refund": "partial", + } + ) + return res + + def _get_supported_currencies(self): + """Override of `payment` to return the supported currencies.""" + supported_currencies = super()._get_supported_currencies() + if self.code == "payplug": + supported_currencies = supported_currencies.filtered( + lambda c: c.name in const.SUPPORTED_CURRENCIES + ) + return supported_currencies + + # === CONSTRAINT METHODS === # + + @api.constrains("state", "payplug_auth_method", "payplug_secret_key") + def _check_payplug_secret_key_matches_state(self): + """Prevent using a live key in test mode, and a test key on an enabled provider. + + Payplug serves both modes on the same endpoints; the key alone decides whether + real money is charged. A mismatch would either charge customers during tests or + accept fake payments in production. + """ + for provider in self.sudo().filtered( + lambda p: p.code == "payplug" + and p.state != "disabled" + and p.payplug_auth_method != "oauth2" + and p.payplug_secret_key + ): + key = provider.payplug_secret_key.strip() + if not key.startswith("sk_"): + # Keys issued by the current Payplug portal are JWTs without a mode + # prefix; the mode is then checked on every payment (`is_live`). + continue + if provider.state == "test" and not key.startswith( + const.SECRET_KEY_PREFIX_TEST + ): + raise ValidationError( + self.env._( + "In test mode, the Payplug secret key must be a test key " + "(starting with %s).", + const.SECRET_KEY_PREFIX_TEST, + ) + ) + if provider.state == "enabled" and not key.startswith( + const.SECRET_KEY_PREFIX_LIVE + ): + raise ValidationError( + self.env._( + "When the provider is enabled, the Payplug secret key must be " + "a live key (starting with %s).", + const.SECRET_KEY_PREFIX_LIVE, + ) + ) + + def _check_required_if_provider(self): + """Override of `payment` to require the credentials of the chosen method. + + The secret key is required with the API key method only, the client ID and + secret with the OAuth2 method only. + """ + res = super()._check_required_if_provider() + missing_fields = set() + for provider in self.sudo().filtered( + lambda p: p.code == "payplug" and p.state in ("enabled", "test") + ): + if provider.payplug_auth_method == "oauth2": + required_fields = ("payplug_client_id", "payplug_client_secret") + else: + required_fields = ("payplug_secret_key",) + missing_fields.update( + field for field in required_fields if not provider[field] + ) + if missing_fields: + field_names = [ + self.env["ir.model.fields"]._get(self._name, field).field_description + for field in sorted(missing_fields) + ] + raise ValidationError( + self.env._( + "The following fields must be filled: %s", ", ".join(field_names) + ) + ) + return res + + # === CRUD METHODS === # + + def write(self, vals): + """Override of `payment` to forget the access tokens of changed credentials.""" + res = super().write(vals) + if OAUTH2_FIELDS.intersection(vals): + for provider in self.filtered(lambda p: p.code == "payplug"): + oauth.TOKEN_CACHE.invalidate(self.env.cr.dbname, provider.id) + return res + + def _get_default_payment_method_codes(self): + """Override of `payment` to return the default payment method codes.""" + self.ensure_one() + if self.code != "payplug": + return super()._get_default_payment_method_codes() + return const.DEFAULT_PAYMENT_METHOD_CODES + + def _get_reset_values(self): + """Override of `payment` to also reset the secret key and OAuth2 client.""" + values = super()._get_reset_values() + if self.code == "payplug": + values.update( + payplug_secret_key=False, + payplug_client_id=False, + payplug_client_secret=False, + ) + return values + + # === REQUEST HELPERS === # + + def _payplug_get_api_base_url(self): + """Return the base URL of the Payplug API, ending with a slash. + + The URL can be overridden with a system parameter so that tests target a local + fake server instead of the real API. + """ + base_url = ( + self.env["ir.config_parameter"].sudo().get_param(const.API_BASE_URL_PARAM) + or const.DEFAULT_API_BASE_URL + ) + return base_url.rstrip("/") + "/" + + def _payplug_get_token_url(self): + """Return the URL of the OAuth2 token endpoint. + + By default, `/oauth2/token` on the host of the API; a system parameter overrides + it for tests. + """ + token_url = ( + self.env["ir.config_parameter"].sudo().get_param(const.TOKEN_URL_PARAM) + ) + if token_url: + return token_url + split_url = urlsplit(self._payplug_get_api_base_url()) + return urlunsplit( + (split_url.scheme, split_url.netloc, const.TOKEN_PATH, "", "") + ) + + def _payplug_get_token_audience(self): + """Return the audience requested for the OAuth2 access tokens.""" + return ( + self.env["ir.config_parameter"].sudo().get_param(const.TOKEN_AUDIENCE_PARAM) + or const.DEFAULT_TOKEN_AUDIENCE + ) + + def _payplug_get_authorization_header(self): + """Return the value of the `Authorization` header of the API requests. + + With the API key method, the secret key; with the OAuth2 method, a valid access + token, requested or renewed when needed. + + Note: self.ensure_one() + + :return: The header value. + :rtype: str + :raise ValidationError: If no access token can be obtained. + """ + self.ensure_one() + if self.payplug_auth_method == "oauth2": + return f"Bearer {self._payplug_get_access_token()}" + secret_key = (self.sudo().payplug_secret_key or "").strip() + return f"Bearer {secret_key}" + + def _payplug_get_access_token(self): + """Return a valid OAuth2 access token, from the cache or a new one. + + Note: self.ensure_one() + + :return: The access token. + :rtype: str + :raise ValidationError: If the credentials are missing, if the token endpoint + fails, or if the environment of the token does not match the provider. + """ + self.ensure_one() + client_id = (self.sudo().payplug_client_id or "").strip() + client_secret = (self.sudo().payplug_client_secret or "").strip() + if not client_id or not client_secret: + raise ValidationError( + self.env._("The Payplug OAuth2 client ID and secret are not set.") + ) + token_url = self._payplug_get_token_url() + audience = self._payplug_get_token_audience() + key = oauth.make_cache_key( + self.env.cr.dbname, self.id, client_id, token_url, audience + ) + + def fetch(): + value, expires_in, mode, scope = self._payplug_request_access_token( + client_id, client_secret, token_url, audience + ) + # Never cache a token of the wrong environment. + self._payplug_check_token_mode(mode, scope) + return value, expires_in, mode, scope + + token = oauth.TOKEN_CACHE.get_or_fetch( + key, oauth.TOKEN_CACHE.fingerprint(client_secret), fetch + ) + # The state of the provider may have changed since the token was cached. + self._payplug_check_token_mode(token.mode, token.scope) + return token.value + + def _payplug_invalidate_access_token(self): + """Forget the cached access tokens of the provider.""" + for provider in self: + oauth.TOKEN_CACHE.invalidate(self.env.cr.dbname, provider.id) + + def _payplug_request_access_token( + self, client_id, client_secret, token_url, audience + ): + """Request an access token with the OAuth2 client credentials grant. + + The client authenticates with HTTP Basic (`client_secret_basic`). Neither the + credentials nor the token are ever logged; the response body is not logged + either, as it holds the token. + + :param str client_id: The OAuth2 client ID. + :param str client_secret: The OAuth2 client secret. + :param str token_url: The URL of the token endpoint. + :param str audience: The requested audience. + :return: The token, its lifetime in seconds, its environment and its scope. + :rtype: tuple + :raise ValidationError: If the request fails. + """ + _logger.info("Requesting a Payplug access token for provider %s.", self.id) + try: + response = requests.post( + token_url, + data={"grant_type": "client_credentials", "audience": audience}, + auth=(client_id.encode(), client_secret.encode()), + headers={"Accept": "application/json"}, + timeout=const.TOKEN_REQUEST_TIMEOUT, + allow_redirects=False, + ) + except requests.exceptions.RequestException as error: + _logger.warning( + "Payplug token request of provider %s failed: %s", + self.id, + type(error).__name__, + ) + raise ValidationError( + self.env._( + "Could not reach the Payplug authorization server to get an access " + "token. Please try again later." + ) + ) from None + + try: + content = response.json() + except ValueError: + content = None + if not isinstance(content, dict): + content = {} + + if not response.ok: + error_code = content.get("error") + if not isinstance(error_code, str) or not OAUTH2_ERROR_CODE_RE.match( + error_code + ): + error_code = "" + _logger.warning( + "Payplug refused the token request of provider %s: HTTP %s %s", + self.id, + response.status_code, + error_code, + ) + if response.status_code == 401 or error_code in ( + "invalid_client", + "unauthorized_client", + ): + message = self.env._( + "Payplug refused the OAuth2 client ID or secret. Check them, and " + "check that the key belongs to the right environment (Test or " + "Live)." + ) + elif response.status_code >= 500: + message = self.env._( + "The Payplug authorization server is unavailable (HTTP %s). Please " + "try again later.", + response.status_code, + ) + else: + message = self.env._( + "Payplug refused the access token request (HTTP %(status)s " + "%(error)s).", + status=response.status_code, + error=error_code, + ) + raise ValidationError(message) + + value = content.get("access_token") + token_type = content.get("token_type") + if ( + not isinstance(value, str) + or not value + or any(char.isspace() for char in value) + or (isinstance(token_type, str) and token_type.lower() != "bearer") + ): + _logger.warning( + "Invalid Payplug token response for provider %s (HTTP %s).", + self.id, + response.status_code, + ) + raise ValidationError( + self.env._("Payplug returned an invalid access token response.") + ) + + expires_in = content.get("expires_in") + if not isinstance(expires_in, (int, float)) or isinstance(expires_in, bool): + # Fall back on the lifetime stated in the token; without any, the token is + # used for this request only. + claims = oauth.decode_jwt_claims(value) + exp, iat = claims.get("exp"), claims.get("iat") + expires_in = ( + exp - iat if isinstance(exp, int) and isinstance(iat, int) else 0 + ) + expires_in = max(int(expires_in), 0) + scope = content.get("scope") + scopes = oauth.get_token_scopes(value, scope) + _logger.info( + "Obtained a Payplug access token for provider %s (scope: %s, valid for %s " + "seconds).", + self.id, + " ".join(sorted(scopes)) or "-", + expires_in, + ) + return value, expires_in, oauth.get_token_mode(scopes), " ".join(sorted(scopes)) + + def _payplug_check_token_mode(self, mode, scope): + """Check that the environment of an access token matches the provider state. + + In test mode, the token must be explicitly of the Test environment. When the + provider is enabled, a token of the Test environment is refused; a token whose + environment is not stated is accepted, the environment of every payment being + checked anyway (`is_live`). + + :param str mode: The environment of the token, see `oauth.get_token_mode`. + :param str scope: The scopes of the token, for the error message. + :raise ValidationError: If the environment does not match. + """ + if self.state == "test" and mode != oauth.MODE_TEST: + raise ValidationError( + self.env._( + "In test mode, the Payplug OAuth2 key must be a key of the Test " + "environment (access token scope: %s).", + scope or "-", + ) + ) + if self.state == "enabled" and mode in (oauth.MODE_TEST, oauth.MODE_MIXED): + raise ValidationError( + self.env._( + "When the provider is enabled, the Payplug OAuth2 key must be a " + "key of the Live environment (access token scope: %s).", + scope or "-", + ) + ) + + def _send_api_request(self, method, endpoint, **kwargs): + """Override of `payment` to renew a rejected OAuth2 access token once. + + When the API answers 401 to a request sent with an access token, the token may + have been revoked or have expired early: it is forgotten, a new one is requested + and the request is sent again, once. An unauthenticated request has no effect on + Payplug, so it can safely be sent again. + """ + if self.code != "payplug" or self.payplug_auth_method != "oauth2": + return super()._send_api_request(method, endpoint, **kwargs) + _last_api_error.status_code = None + try: + return super()._send_api_request(method, endpoint, **kwargs) + except ValidationError: + if _last_api_error.status_code != 401: + raise + _logger.info( + "Payplug rejected the access token of provider %s; requesting a new one.", + self.id, + ) + self._payplug_invalidate_access_token() + return super()._send_api_request(method, endpoint, **kwargs) + + def _build_request_url(self, endpoint, **kwargs): + """Override of `payment` to build the request URL.""" + if self.code != "payplug": + return super()._build_request_url(endpoint, **kwargs) + return urls.urljoin(self._payplug_get_api_base_url(), endpoint.strip("/")) + + def _build_request_headers(self, method, endpoint, payload, **kwargs): + """Override of `payment` to build the request headers.""" + if self.code != "payplug": + return super()._build_request_headers(method, endpoint, payload, **kwargs) + return { + "Accept": "application/json", + "Authorization": self._payplug_get_authorization_header(), + "Content-Type": "application/json", + "PayPlug-Version": const.API_VERSION, + } + + def _parse_response_error(self, response): + """Override of `payment` to parse the error message.""" + if self.code != "payplug": + return super()._parse_response_error(response) + _last_api_error.status_code = response.status_code + try: + content = response.json() + except ValueError: + content = None + if not isinstance(content, dict): + return response.text + message = content.get("message") or response.text + details = content.get("details") + if details: + message = f"{message} {details}" + return message diff --git a/payment_payplug/models/payment_transaction.py b/payment_payplug/models/payment_transaction.py new file mode 100644 index 0000000000..2235133fe6 --- /dev/null +++ b/payment_payplug/models/payment_transaction.py @@ -0,0 +1,573 @@ +# Copyright 2026 Sébastien Berger (sbinfo67) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +import re +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +from odoo import api, models +from odoo.exceptions import ValidationError +from odoo.tools import SQL, urls + +from odoo.addons.payment import utils as payment_utils +from odoo.addons.payment.logging import get_payment_logger + +from .. import const + +_logger = get_payment_logger(__name__, sensitive_keys=const.SENSITIVE_KEYS) + +RETURN_ROUTE = "/payment/payplug/return" +CANCEL_ROUTE = "/payment/payplug/cancel" +WEBHOOK_ROUTE = "/payment/payplug/webhook" + +PHONE_CLEANUP_RE = re.compile(r"[\s.\-()/]") +E164_RE = re.compile(r"^\+[1-9]\d{6,14}$") +FRENCH_MOBILE_RE = re.compile(r"^\+33[67]\d{8}$") + + +class PaymentTransaction(models.Model): + _inherit = "payment.transaction" + + # === BUSINESS METHODS - PAYMENT FLOW === # + + def _get_specific_rendering_values(self, processing_values): + """Override of `payment` to create the payment and return the form 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 != "payplug": + return super()._get_specific_rendering_values(processing_values) + + amount_error = self._payplug_check_amount_limits() + if amount_error: + self._set_error(amount_error) + return {} + + payload = self._payplug_prepare_payment_payload() + try: + payment_data = self._send_api_request("POST", "payments", json=payload) + except ValidationError as error: + self._set_error(str(error)) + return {} + + # Store the Payplug id now to fetch the payment after the redirection and to + # match the notifications. + self.provider_reference = payment_data.get("id") + payment_url = (payment_data.get("hosted_payment") or {}).get("payment_url") + if not payment_url: + self._set_error(self.env._("Payplug did not return any payment page URL.")) + return {} + + # Pass the query parameters separately: a GET form drops the query string of its + # action. + split_url = urlsplit(payment_url) + url_params = dict(parse_qsl(split_url.query)) + api_url = urlunsplit(split_url._replace(query="")) + return {"api_url": api_url, "url_params": url_params} + + def _payplug_check_amount_limits(self): + """Return an error message if the amount is outside the Payplug card limits. + + :return: The error message, if any. + :rtype: str|None + """ + if self.payment_method_id.code in const.PAYMENT_METHODS_MAPPING: + # The limits of alternative payment methods differ; let Payplug decide. + return None + amount = payment_utils.to_minor_currency_units(self.amount, self.currency_id) + if ( + amount < const.MINIMUM_PAYMENT_AMOUNT + or amount > const.MAXIMUM_PAYMENT_AMOUNT + ): + return self.env._( + "Payplug only accepts card payments between %(minimum)s and " + "%(maximum)s.", + minimum=self.currency_id.format( + payment_utils.to_major_currency_units( + const.MINIMUM_PAYMENT_AMOUNT, self.currency_id + ) + ), + maximum=self.currency_id.format( + payment_utils.to_major_currency_units( + const.MAXIMUM_PAYMENT_AMOUNT, self.currency_id + ) + ), + ) + return None + + def _payplug_prepare_payment_payload(self): + """Create the payload of the payment creation request. + + See https://docs.payplug.com/api/apiref.html#create-a-payment + + :return: The request payload. + :rtype: dict + """ + self.ensure_one() + base_url = self.provider_id.get_base_url() + url_params = urlencode( + { + "reference": self.reference, + "access_token": payment_utils.generate_access_token( + self.reference, env=self.env + ), + } + ) + billing = self._payplug_prepare_customer_values() + shipping = dict(billing, delivery_type="BILLING") + payload = { + "amount": payment_utils.to_minor_currency_units( + self.amount, self.currency_id + ), + "currency": self.currency_id.name, + "billing": billing, + "shipping": shipping, + "hosted_payment": { + "return_url": f"{urls.urljoin(base_url, RETURN_ROUTE)}?{url_params}", + "cancel_url": f"{urls.urljoin(base_url, CANCEL_ROUTE)}?{url_params}", + }, + "notification_url": urls.urljoin(base_url, WEBHOOK_ROUTE), + "description": self.reference[: const.DESCRIPTION_MAX_LENGTH], + "metadata": {"odoo_reference": self.reference}, + } + payplug_method = const.PAYMENT_METHODS_MAPPING.get(self.payment_method_id.code) + if payplug_method: + payload["payment_method"] = payplug_method + return payload + + def _payplug_prepare_customer_values(self): + """Return the billing values of the customer, without the empty ones. + + :return: The billing values. + :rtype: dict + """ + first_name, last_name = payment_utils.split_partner_name( + self.partner_name or "" + ) + lang = (self.partner_lang or "").split("_")[0] + values = { + "first_name": first_name or last_name, + "last_name": last_name or first_name, + "email": self.partner_email, + "address1": self.partner_id.street or self.partner_address, + "address2": self.partner_id.street2, + "postcode": self.partner_zip, + "city": self.partner_city, + "country": self.partner_country_id.code, + "company_name": self.partner_id.commercial_company_name, + "language": lang + if lang in const.SUPPORTED_LANGUAGES + else const.DEFAULT_LANGUAGE, + } + phone = self._payplug_format_phone(self.partner_phone) + if phone: + key = ( + "mobile_phone_number" + if FRENCH_MOBILE_RE.match(phone) + else "landline_phone_number" + ) + values[key] = phone + cleaned_values = {} + for key, value in values.items(): + if not value: + continue + max_length = const.FIELD_MAX_LENGTHS.get(key) + cleaned_values[key] = value[:max_length] if max_length else value + return cleaned_values + + @api.model + def _payplug_format_phone(self, phone): + """Return the phone number in the E.164 format expected by Payplug, or None. + + Only numbers already in international format (with `+` or `00`) are kept: + guessing the country of a local number could send a wrong number. + """ + if not phone: + return None + phone = PHONE_CLEANUP_RE.sub("", phone) + if phone.startswith("00"): + phone = "+" + phone[2:] + return phone if E164_RE.match(phone) else None + + def _payplug_fetch_and_process(self): + """Fetch the payment from the Payplug API and process it. + + The payment data received from the customer's browser or from a notification are + never trusted: the payment is always read back from the API with the secret key. + + Note: self.ensure_one() + + :return: None + :raise ValidationError: If the API request fails. + """ + self.ensure_one() + if self.operation == "refund": + refund_data = self.source_transaction_id._payplug_get_refund( + self.provider_reference + ) + self._process("payplug", refund_data) + return + if not self.provider_reference: + _logger.warning( + "Transaction %s has no Payplug payment id; nothing to fetch.", + self.reference, + ) + return + payment_data = self._send_api_request( + "GET", f"payments/{self.provider_reference}" + ) + self._process("payplug", payment_data) + + def _payplug_abort_and_process(self): + """Abort the unpaid payment after the customer canceled it, then process it. + + Note: self.ensure_one() + + :return: None + """ + self.ensure_one() + if not self.provider_reference or self.state not in ("draft", "pending"): + return + try: + payment_data = self._send_api_request( + "PATCH", f"payments/{self.provider_reference}", json={"aborted": True} + ) + except ValidationError: + # The payment may have been paid or failed meanwhile: its status decides. + _logger.info( + "Could not abort Payplug payment of transaction %s.", self.reference + ) + self._payplug_fetch_and_process() + else: + self._process("payplug", payment_data) + + # === BUSINESS METHODS - REFUNDS === # + + def _send_refund_request(self): + """Override of `payment` to send a refund request to Payplug. + + Note: self.ensure_one() from `_refund` + """ + if self.provider_code != "payplug": + return super()._send_refund_request() + + amount = payment_utils.to_minor_currency_units(-self.amount, self.currency_id) + if amount < const.MINIMUM_REFUND_AMOUNT: + raise ValidationError( + self.env._( + "Payplug does not accept refunds below %s.", + self.currency_id.format( + payment_utils.to_major_currency_units( + const.MINIMUM_REFUND_AMOUNT, self.currency_id + ) + ), + ) + ) + refund_data = self._send_api_request( + "POST", + f"payments/{self.source_transaction_id.provider_reference}/refunds", + json={"amount": amount, "metadata": {"odoo_reference": self.reference}}, + ) + self._process("payplug", refund_data) + + def _payplug_get_refund(self, refund_id): + """Fetch a refund of the payment of this transaction from the Payplug API. + + Note: self.ensure_one() + + :param str refund_id: The Payplug id of the refund. + :return: The refund data. + :rtype: dict + """ + self.ensure_one() + return self._send_api_request( + "GET", f"payments/{self.provider_reference}/refunds/{refund_id}" + ) + + def _payplug_lock(self): + """Lock the transaction rows to serialize the processing of notifications.""" + self.env.cr.execute( + SQL( + "SELECT id FROM payment_transaction WHERE id IN %s FOR UPDATE", + tuple(self.ids), + ) + ) + + # === BUSINESS METHODS - NOTIFICATIONS === # + + @api.model + def _payplug_handle_notification(self, notification_data): + """Handle a notification sent by Payplug. + + Only the id and the type of the notified object are read from the notification; + the object itself is then fetched from the API. A forged notification can thus + only trigger the re-reading of a genuine object, and a replayed one is processed + idempotently. + + :param dict notification_data: The notification body. + :return: None + :raise ValidationError: If an API request fails. + """ + object_type = notification_data.get("object") + object_id = notification_data.get("id") + if not isinstance(object_id, str) or not object_id: + _logger.warning("Received a Payplug notification without object id.") + return + + if object_type == "payment": + tx = self.search( + [ + ("provider_code", "=", "payplug"), + ("provider_reference", "=", object_id), + ("operation", "!=", "refund"), + ], + limit=1, + ) + if not tx: + _logger.warning( + "No Payplug transaction found for payment %s.", object_id + ) + return + tx._payplug_lock() + tx._payplug_fetch_and_process() + elif object_type == "refund": + self._payplug_handle_refund_notification(object_id, notification_data) + else: + _logger.info("Ignored Payplug notification of type %s.", object_type) + + @api.model + def _payplug_handle_refund_notification(self, refund_id, notification_data): + """Process a refund notification. + + The refund transaction is created when the refund was made outside of Odoo (e.g. + from the Payplug portal). + + :param str refund_id: The Payplug id of the refund. + :param dict notification_data: The notification body. + :return: None + """ + refund_tx = self.search( + [ + ("provider_code", "=", "payplug"), + ("provider_reference", "=", refund_id), + ("operation", "=", "refund"), + ], + limit=1, + ) + if refund_tx: + refund_tx._payplug_lock() + refund_tx._payplug_fetch_and_process() + return + + payment_id = notification_data.get("payment_id") + source_tx = ( + payment_id + and isinstance(payment_id, str) + and self.search( + [ + ("provider_code", "=", "payplug"), + ("provider_reference", "=", payment_id), + ("operation", "!=", "refund"), + ], + limit=1, + ) + ) + if not source_tx: + _logger.warning("No Payplug transaction found for refund %s.", refund_id) + return + + # Serialize with the other notifications of the payment to avoid duplicates. + source_tx._payplug_lock() + refund_data = source_tx._payplug_get_refund(refund_id) + if ( + refund_data.get("id") != refund_id + or refund_data.get("payment_id") != source_tx.provider_reference + ): + _logger.warning("Payplug refund %s does not match its payment.", refund_id) + return + + refund_tx = source_tx.child_transaction_ids.filtered( + lambda t: t.operation == "refund" and t.provider_reference == refund_id + ) + odoo_reference = (refund_data.get("metadata") or {}).get("odoo_reference") + if not refund_tx and odoo_reference: + refund_tx = source_tx.child_transaction_ids.filtered( + lambda t: t.operation == "refund" and t.reference == odoo_reference + ) + if not refund_tx: + # The refund was requested from Odoo and its transaction is not + # committed yet; the request that created it processes the response. + _logger.info( + "Refund %s of transaction %s is being processed by Odoo.", + odoo_reference, + source_tx.reference, + ) + return + if not refund_tx: + if source_tx.state != "done": + _logger.warning( + "Ignored refund %s of unconfirmed transaction %s.", + refund_id, + source_tx.reference, + ) + return + amount = payment_utils.to_major_currency_units( + refund_data.get("amount") or 0, source_tx.currency_id + ) + refund_tx = source_tx._create_child_transaction(amount, is_refund=True) + refund_tx._log_sent_message() + refund_tx[:1]._process("payplug", refund_data) + + # === BUSINESS METHODS - PROCESSING === # + + @api.model + def _extract_reference(self, provider_code, payment_data): + """Override of `payment` to extract the reference from the payment data.""" + if provider_code != "payplug": + return super()._extract_reference(provider_code, payment_data) + return (payment_data.get("metadata") or {}).get("odoo_reference") + + def _extract_amount_data(self, payment_data): + """Override of `payment` to extract the amount and currency of the data.""" + if self.provider_code != "payplug": + return super()._extract_amount_data(payment_data) + amount = payment_data.get("amount") + return { + "amount": payment_utils.to_major_currency_units(amount, self.currency_id) + if isinstance(amount, int) + else None, + "currency_code": payment_data.get("currency"), + } + + def _apply_updates(self, payment_data): + """Override of `payment` to update the transaction based on the payment data.""" + if self.provider_code != "payplug": + return super()._apply_updates(payment_data) + + if self.operation == "refund": + self._payplug_apply_refund_updates(payment_data) + return + + error = self._payplug_check_payment_consistency(payment_data) + if error: + _logger.warning( + "Inconsistent Payplug data for %s: %s", self.reference, error + ) + self._set_error(error) + return + + self._payplug_update_payment_method(payment_data) + + failure = payment_data.get("failure") + if payment_data.get("is_paid"): + self._set_done() + elif failure: + code = failure.get("code") or "" + message = failure.get("message") or code + if code in const.CANCEL_FAILURE_CODES: + self._set_canceled( + state_message=self.env._( + "The payment was canceled on Payplug (%s).", code + ) + ) + else: + self._set_error( + self.env._( + "The payment was refused by Payplug (%(code)s): %(message)s", + code=code, + message=message, + ) + ) + else: + self._set_pending() + + def _payplug_check_payment_consistency(self, payment_data): + """Check that the payment data belong to this transaction. + + :param dict payment_data: The payment data fetched from the API. + :return: The error message, if any. + :rtype: str|None + """ + if payment_data.get("object") != "payment": + return self.env._("Payplug returned an object that is not a payment.") + if ( + self.provider_reference + and payment_data.get("id") != self.provider_reference + ): + return self.env._("The Payplug payment id does not match the transaction.") + if self._extract_reference("payplug", payment_data) != self.reference: + return self.env._( + "The Payplug payment does not reference this transaction." + ) + if bool(payment_data.get("is_live")) != self.is_live: + return self.env._( + "The Payplug payment environment (test or live) does not match the " + "provider." + ) + return None + + def _payplug_update_payment_method(self, payment_data): + """Update the payment method with the card brand used, when known.""" + brand = ((payment_data.get("card") or {}).get("brand") or "").lower() + code = const.CARD_BRANDS_MAPPING.get(brand) + if not code: + return + payment_method = self.env["payment.method"]._get_from_code(code) + if payment_method and self.payment_method_id.code == "card": + self.payment_method_id = payment_method + + def _payplug_apply_refund_updates(self, refund_data): + """Update the refund transaction with the refund data fetched from the API.""" + source_tx = self.source_transaction_id + if ( + refund_data.get("object") != "refund" + or refund_data.get("payment_id") != source_tx.provider_reference + or ( + self.provider_reference + and refund_data.get("id") != self.provider_reference + ) + ): + self._set_error( + self.env._("The Payplug refund does not match the transaction.") + ) + return + if bool(refund_data.get("is_live")) != self.is_live: + self._set_error( + self.env._( + "The Payplug refund environment (test or live) does not match the " + "provider." + ) + ) + return + self.provider_reference = refund_data.get("id") + # A refund exists on Payplug only once accepted: refunds have no status. + if self._set_done(): + # Post-process the refund now: no customer will browse its status page. + self.env.ref("payment.cron_post_process_payment_tx")._trigger() + + # === ACTION METHODS === # + + def action_payplug_refresh_status(self): + """Fetch the status of the transactions from Payplug and update them. + + :return: A client notification. + :rtype: dict + """ + payment_utils.check_rights_on_recordset(self) + for tx in self.sudo().filtered(lambda t: t.provider_code == "payplug"): + try: + tx._payplug_fetch_and_process() + except ValidationError as error: + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": {"type": "danger", "message": str(error)}, + } + return { + "type": "ir.actions.client", + "tag": "soft_reload", + } diff --git a/payment_payplug/oauth.py b/payment_payplug/oauth.py new file mode 100644 index 0000000000..0cc66d0d39 --- /dev/null +++ b/payment_payplug/oauth.py @@ -0,0 +1,168 @@ +# Copyright 2026 Sébastien Berger (sbinfo67) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). +"""In-process cache of the Payplug OAuth2 access tokens, and token helpers. + +The access tokens obtained with the client credentials grant live a few minutes. They +are kept in the memory of the process only (never in the database), keyed by database, +provider and a hash of the client id and token endpoint; the client secret is never part +of the key. Each Odoo worker process thus requests its own tokens. +""" + +import base64 +import binascii +import hashlib +import hmac +import json +import secrets +import threading +import time +from collections import namedtuple + +from . import const + +MODE_TEST = "test" +MODE_LIVE = "live" +MODE_UNKNOWN = "unknown" +MODE_MIXED = "mixed" + +AccessToken = namedtuple( + "AccessToken", ["value", "renew_at", "mode", "scope", "fingerprint"] +) + + +def now(): + """Return the clock used for the token lifetimes. + + A monotonic clock: the lifetimes come from the `expires_in` of the token responses, + so neither the clock of the server nor its adjustments matter. + """ + return time.monotonic() + + +def make_cache_key(dbname, provider_id, client_id, token_url, audience): + """Return the cache key of a token. The client secret is never part of it.""" + digest = hashlib.sha256( + "\0".join((client_id, token_url, audience)).encode() + ).hexdigest() + return (dbname, provider_id, digest) + + +def decode_jwt_claims(token): + """Return the claims of a JWT, without verifying its signature, or {}. + + The token comes straight from the Payplug token endpoint over TLS; its claims are + only read to check that its environment matches the provider, never to grant + anything. + """ + parts = token.split(".") if isinstance(token, str) else [] + if len(parts) != 3: + return {} + payload = parts[1] + "=" * (-len(parts[1]) % 4) + try: + claims = json.loads(base64.urlsafe_b64decode(payload.encode())) + except (binascii.Error, UnicodeDecodeError, ValueError): + return {} + return claims if isinstance(claims, dict) else {} + + +def get_token_scopes(token, scope=None): + """Return the scopes of a token, from its `scp` claim and the response `scope`.""" + scopes = set() + claim = decode_jwt_claims(token).get("scp") + for value in (claim, scope): + if isinstance(value, str): + scopes.update(value.split()) + elif isinstance(value, (list, tuple)): + scopes.update(item for item in value if isinstance(item, str)) + return scopes + + +def get_token_mode(scopes): + """Return the environment of a token given its scopes. + + :param set scopes: The scopes of the token. + :return: `test`, `live`, `mixed` (both) or `unknown` (neither). + :rtype: str + """ + is_test = MODE_TEST in scopes + is_live = MODE_LIVE in scopes + if is_test and is_live: + return MODE_MIXED + if is_test: + return MODE_TEST + if is_live: + return MODE_LIVE + return MODE_UNKNOWN + + +class TokenCache: + """A thread-safe cache of access tokens, renewed before they expire.""" + + def __init__(self, renewal_margin): + self.renewal_margin = renewal_margin + self._lock = threading.Lock() + self._tokens = {} + self._fetch_locks = {} + # Random per process: the fingerprints of the secrets cannot be compared + # outside of it. + self._salt = secrets.token_bytes(32) + + def fingerprint(self, secret): + """Return a fingerprint of the client secret, to detect its changes.""" + return hmac.new(self._salt, secret.encode(), hashlib.sha256).digest() + + def get(self, key, fingerprint): + """Return the cached token if it is still valid for the given secret.""" + with self._lock: + token = self._tokens.get(key) + if token and token.fingerprint == fingerprint and now() < token.renew_at: + return token + return None + + def get_or_fetch(self, key, fingerprint, fetch): + """Return the cached token, or fetch, cache and return a new one. + + Only one thread per key fetches a token; the others wait for it. + + :param tuple key: The cache key, from `make_cache_key`. + :param bytes fingerprint: The fingerprint of the client secret. + :param fetch: A callable returning `(value, expires_in, mode, scope)`. It may + raise; nothing is cached then. + :return: The token. + :rtype: AccessToken + """ + token = self.get(key, fingerprint) + if token: + return token + with self._lock: + fetch_lock = self._fetch_locks.setdefault(key, threading.Lock()) + with fetch_lock: + token = self.get(key, fingerprint) # Fetched by another thread meanwhile. + if token: + return token + requested_at = now() + value, expires_in, mode, scope = fetch() + margin = min(self.renewal_margin, expires_in / 2) + token = AccessToken( + value, requested_at + expires_in - margin, mode, scope, fingerprint + ) + with self._lock: + self._tokens[key] = token + return token + + def invalidate(self, dbname, provider_id): + """Forget the tokens of a provider.""" + with self._lock: + for key in [k for k in self._tokens if k[:2] == (dbname, provider_id)]: + del self._tokens[key] + + def clear(self): + with self._lock: + self._tokens.clear() + + def keys(self): + with self._lock: + return list(self._tokens) + + +TOKEN_CACHE = TokenCache(renewal_margin=const.TOKEN_RENEWAL_MARGIN) diff --git a/payment_payplug/pyproject.toml b/payment_payplug/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/payment_payplug/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/payment_payplug/readme/CONFIGURE.md b/payment_payplug/readme/CONFIGURE.md new file mode 100644 index 0000000000..10bbd031c3 --- /dev/null +++ b/payment_payplug/readme/CONFIGURE.md @@ -0,0 +1,80 @@ +**Clés Payplug** + +Le module accepte deux méthodes d'authentification, au choix dans le champ +*Authentification* de l'onglet *Identifiants* : + +- **Clé API** (par défaut) : la clé secrète du compte est envoyée à chaque requête ; +- **OAuth2** : Odoo échange un identifiant client (*client ID*) et un secret client + (*client secret*) contre des jetons d'accès valables 5 minutes, demandés et renouvelés + automatiquement. Le secret ne circule que vers le serveur d'autorisation de Payplug. + +1. Dans le portail Payplug, à la rubrique des clés d'API, générez une clé en + environnement **Test** et, plus tard, une clé en environnement **Live** : + - pour la méthode *Clé API*, une clé de type **API Key** (clé secrète). Les clés + actuelles sont des jetons JWT (`eyJ...`) ; les anciennes clés `sk_test_...` et + `sk_live_...` sont aussi acceptées ; + - pour la méthode *OAuth2*, une clé de type **OAuth2**. Le portail affiche son + *client ID* et son *client secret* ; notez le secret aussitôt, il n'est montré + qu'une fois. + + La clé publique n'est pas utilisée : le module ne passe que par la page de paiement + hébergée. +2. Dans Odoo, ouvrez *Facturation (ou Comptabilité) > Configuration > Fournisseurs de + paiement* et choisissez **Payplug**. +3. Dans l'onglet *Identifiants*, choisissez la méthode d'authentification, puis collez + la clé secrète (*Clé API*) ou le *client ID* et le *client secret* (*OAuth2*). Seuls + les administrateurs (groupe *Paramètres*) peuvent les lire ou les modifier. +4. Réglez l'état : + - **Mode test** avec une clé de l'environnement Test : aucun débit réel ; + - **Activé** avec une clé de l'environnement Live : paiements réels. + + Pour une ancienne clé `sk_...`, Odoo refuse une clé de production en mode test et + une clé de test sur un fournisseur activé. Pour une clé JWT, l'accord entre le mode + et la clé est vérifié à chaque paiement : un paiement dont le mode ne correspond + pas est refusé. En OAuth2, l'environnement du jeton (*scope* `test`) est en plus + vérifié à chaque requête : en mode test, seul un jeton de l'environnement Test est + accepté ; sur un fournisseur activé, un jeton de test est refusé. + + Pour passer de Test à Live en OAuth2, remplacez le *client ID* et le *client secret* + par ceux de la clé Live en même temps que vous activez le fournisseur. +5. Dans l'onglet *Configuration*, vérifiez le journal de paiement et les moyens de + paiement. Par défaut, seul *Carte* (CB, Visa, Mastercard) est activé. Bancontact, + iDEAL et MyBank sont proposés, mais ne les activez qu'après les avoir fait ouvrir sur + votre compte Payplug. +6. Publiez le fournisseur pour qu'il apparaisse sur le site web. + +**Adresse de notification (webhook)** + +Le module donne à Payplug, pour chaque paiement, l'adresse +`https:///payment/payplug/webhook`. Il n'y a rien à saisir dans le portail +Payplug, mais cette adresse doit être joignable depuis Internet en HTTPS : + +- ne la protégez pas par une authentification, un filtrage IP ou une règle de pare-feu + (WAF, fail2ban, Cloudflare…) qui bloquerait les requêtes POST de Payplug ; +- vérifiez le paramètre système `web.base.url` (ou le domaine du site web) : c'est lui + qui sert à construire les adresses de retour et de notification. + +Les retours du client passent par `/payment/payplug/return` et `/payment/payplug/cancel`. + +**Jetons d'accès OAuth2** + +Un jeton dure 5 minutes ; Odoo le garde en mémoire et en demande un nouveau une minute +avant son expiration. Les jetons ne sont jamais écrits en base ni dans les journaux ; +chaque processus (worker) d'Odoo demande les siens. Si Payplug refuse un jeton (HTTP +401), Odoo l'oublie, en demande un autre et renvoie la requête une seule fois. +Après une modification des identifiants, chaque processus demande un nouveau jeton dès +sa requête suivante ; après un changement d'état, le jeton en mémoire est de nouveau +confronté au mode du fournisseur. + +**Paramètres système réservés aux tests** + +Ils ne servent qu'à pointer vers un faux serveur de test ; ne les créez pas en +production. + +- `payment_payplug.api_base_url` remplace l'adresse de l'API Payplug + (`https://api.payplug.com/v1/` par défaut) ; +- `payment_payplug.token_url` remplace l'adresse du point d'obtention des jetons OAuth2 + (par défaut `/oauth2/token` sur l'hôte de l'API, soit + `https://api.payplug.com/oauth2/token`) ; +- `payment_payplug.token_audience` remplace l'audience demandée pour les jetons + (`https://www.payplug.com` par défaut). diff --git a/payment_payplug/readme/CONTRIBUTORS.md b/payment_payplug/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..9e39002a4d --- /dev/null +++ b/payment_payplug/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Sébastien Berger \<\> diff --git a/payment_payplug/readme/DESCRIPTION.md b/payment_payplug/readme/DESCRIPTION.md new file mode 100644 index 0000000000..73ff28dc6d --- /dev/null +++ b/payment_payplug/readme/DESCRIPTION.md @@ -0,0 +1,20 @@ +Ce module ajoute [Payplug](https://www.payplug.com/) comme fournisseur de paiement au +cadre de paiement d'Odoo (module `payment`). + +Le client est redirigé vers la page de paiement hébergée par Payplug, puis revient sur +Odoo. Le module gère : + +- le paiement en ligne sur le site web (panier et validation de commande) ; +- le paiement des factures depuis le portail client et par lien de paiement ; +- les notifications serveur de Payplug (paiements et remboursements) ; +- les remboursements totaux et partiels depuis Odoo, et ceux faits depuis le portail + Payplug ; +- les annulations, les échecs et les paiements expirés. + +Payplug n'accepte que l'euro : le fournisseur n'est proposé que pour les paiements en EUR. + +Le module appelle directement l'API REST de Payplug avec `requests` ; il ne demande +aucune bibliothèque Python supplémentaire. + +**Avertissement :** ce module est un développement indépendant. Il n'est ni affilié à +Payplug, ni approuvé par Payplug. « Payplug » est une marque de son propriétaire. diff --git a/payment_payplug/readme/DEVELOP.md b/payment_payplug/readme/DEVELOP.md new file mode 100644 index 0000000000..ca1813f76e --- /dev/null +++ b/payment_payplug/readme/DEVELOP.md @@ -0,0 +1,22 @@ +Les tests automatiques (`--test-tags /payment_payplug`) n'appellent jamais l'API Payplug : +ils démarrent un faux serveur Payplug local (`tests/fake_payplug.py`) et y font pointer +le module par le paramètre système `payment_payplug.api_base_url`. + +Le même faux serveur sert aux essais manuels de bout en bout, avec une page de paiement +factice (boutons *Payer*, *Refuser*, *Annuler*) et l'envoi des notifications : + +``` shell +python3 payment_payplug/tests/fake_payplug.py --host 127.0.0.1 --port 8765 +``` + +Sur une base de test, créez alors le paramètre système `payment_payplug.api_base_url` +avec la valeur `http://127.0.0.1:8765/v1/`, et saisissez une clé quelconque commençant +par `sk_test_`. `POST /_control/refund/` (corps `{"amount": 1000}`) +simule un remboursement fait depuis le portail Payplug. + +Pour essayer la méthode OAuth2, le faux serveur répond aussi sur `POST /oauth2/token` +(authentification *Basic*, `grant_type=client_credentials`, audience +`https://www.payplug.com`) avec deux clients factices : `fake_client_test` / +`fake_client_secret_test` (jetons de test) et `fake_client_live` / +`fake_client_secret_live` (jetons Live). L'adresse du point de jeton se déduit de +`payment_payplug.api_base_url` ; il n'y a rien d'autre à régler. diff --git a/payment_payplug/readme/HISTORY.md b/payment_payplug/readme/HISTORY.md new file mode 100644 index 0000000000..15ec8e31b9 --- /dev/null +++ b/payment_payplug/readme/HISTORY.md @@ -0,0 +1,11 @@ +## 19.0.1.1.0 (2026-10-24) + +- Authentification OAuth2 (*client credentials*) au choix, à côté de la clé API : jetons + d'accès de 5 minutes gardés en mémoire et renouvelés automatiquement, nouvelle demande + et renvoi unique de la requête si Payplug refuse le jeton, contrôle de + l'environnement (Test ou Live) du jeton. + +## 19.0.1.0.0 (2026-10-24) + +- Première version : paiement par la page hébergée Payplug, notifications, + remboursements totaux et partiels, paiement des factures. diff --git a/payment_payplug/readme/INSTALL.md b/payment_payplug/readme/INSTALL.md new file mode 100644 index 0000000000..e8b562f8a2 --- /dev/null +++ b/payment_payplug/readme/INSTALL.md @@ -0,0 +1,15 @@ +Le module dépend des modules standard `payment` et `account_payment` (installés avec la +Facturation ou le site e-commerce). Il ne demande aucune bibliothèque Python +supplémentaire. + +1. Placez le dossier `payment_payplug` dans un chemin d'extensions (`addons_path`) + d'Odoo 19, ou installez le paquet Python construit avec `pyproject.toml`. +2. Redémarrez Odoo, mettez à jour la liste des applications, puis installez + *Payment Provider: Payplug*. + +Le fournisseur *Payplug* est créé désactivé, pour chaque société. + +À la désinstallation, comme pour les fournisseurs standard, Odoo refuse de continuer si +des paiements comptables ont été faits avec Payplug. Un fournisseur qui n'a que des +transactions sans paiement est conservé (désactivé, sans code) pour garder ces +transactions lisibles ; sinon il est supprimé. diff --git a/payment_payplug/readme/ROADMAP.md b/payment_payplug/readme/ROADMAP.md new file mode 100644 index 0000000000..14eeb2d007 --- /dev/null +++ b/payment_payplug/readme/ROADMAP.md @@ -0,0 +1,31 @@ +- Connexion « avec son compte Payplug » (OAuth2 *authorization code* + PKCE, chemin + `/oauth2/auth`) : hors périmètre. Ce flux demande un client enregistré par Payplug + avec une adresse de rappel ; le module se limite au flux *client credentials* avec + une clé OAuth2 créée dans le portail. +- Prévu pour une version 3 : paiement en un clic (carte enregistrée), paiement + fractionné, paiement différé, Apple Pay et paiement intégré. +- Paiement en plusieurs fois (Oney 3x/4x) : non pris en charge. Il demande le panier + détaillé (`payment_context`), une autorisation puis une capture, et des montants entre + 100 € et 3 000 €. +- Enregistrement de la carte (jetons) et paiements récurrents : non pris en charge. +- Capture différée (autorisation puis capture manuelle) : non prise en charge. +- Apple Pay et le paiement intégré (*Integrated Payment*, champs de carte dans la page + Odoo) : non pris en charge ; seule la page hébergée est utilisée. +- Les marques de carte renvoyées par Payplug (`card.brand`) ne sont connues que par la + documentation ; à vérifier avec de vraies transactions de test. +- Satispay, Scalapay et Wero n'ont pas de moyen de paiement correspondant dans Odoo 19 : + ils ne sont pas proposés. + +À vérifier avec de vraies clés de test Payplug (non vérifié, faute de compte) : + +- le format exact des notifications (paiement et remboursement), en particulier la + présence de `payment_id` dans celles des remboursements ; +- le code HTTP attendu par Payplug en réponse aux notifications, et s'il les renvoie + après une erreur (le module répond 503 quand il ne peut pas relire le paiement) ; +- l'acceptation des champs `billing` / `shipping` envoyés (langue, téléphone, pays) ; +- l'abandon (`aborted`) d'un paiement quand le client clique sur *Annuler* ; +- les valeurs de `card.brand` (Visa, Mastercard, CB…) ; +- Bancontact, iDEAL et MyBank, qui demandent une activation sur le compte Payplug ; +- en OAuth2 : la valeur du *scope* des jetons de l'environnement Live (`live` est + supposé ; une autre valeur est tolérée sur un fournisseur activé, le mode de chaque + paiement restant vérifié). diff --git a/payment_payplug/readme/USAGE.md b/payment_payplug/readme/USAGE.md new file mode 100644 index 0000000000..4bdee1503f --- /dev/null +++ b/payment_payplug/readme/USAGE.md @@ -0,0 +1,29 @@ +**Paiement sur le site web ou depuis le portail** + +Le client choisit *Carte* (ou un autre moyen activé), est redirigé vers la page Payplug, +paie, puis revient sur la page d'état du paiement d'Odoo. La commande ou la facture est +confirmée dès qu'Odoo a relu le paiement auprès de Payplug, soit au retour du client, +soit à la réception de la notification. + +Si le client clique sur *Annuler* sur la page Payplug, le paiement est abandonné chez +Payplug et la transaction passe à l'état *Annulée*. Un paiement refusé passe à l'état +*Erreur* avec le motif donné par Payplug ; un paiement jamais terminé est annulé quand +Payplug signale son expiration (environ 15 minutes). + +**Remboursement** + +Depuis le paiement (*Comptabilité > Clients > Paiements*), ou depuis la facture via son +paiement, cliquez sur *Rembourser* et indiquez le montant (total ou partiel). Odoo +demande le remboursement à Payplug, crée une transaction de remboursement liée à la +transaction d'origine, puis le paiement comptable négatif correspondant. + +Un remboursement fait directement dans le portail Payplug est aussi enregistré dans +Odoo, à la réception de sa notification. + +Payplug n'accepte pas de remboursement inférieur à 0,10 €. + +**Vérifier une transaction** + +Sur une transaction Payplug en attente ou en erreur, le bouton *Vérifier le statut sur +Payplug* relit le paiement auprès de Payplug et met la transaction à jour (utile si une +notification n'a pas pu arriver). diff --git a/payment_payplug/static/description/icon.png b/payment_payplug/static/description/icon.png new file mode 100644 index 0000000000..117e0eca03 Binary files /dev/null and b/payment_payplug/static/description/icon.png differ diff --git a/payment_payplug/static/description/index.html b/payment_payplug/static/description/index.html new file mode 100644 index 0000000000..187b4f776c --- /dev/null +++ b/payment_payplug/static/description/index.html @@ -0,0 +1,704 @@ + + + + + +Payment Provider: Payplug + + + +
+ + + +Odoo Community Association + +
+

Payment Provider: Payplug

+ +

Beta License: LGPL-3 OCA/e-commerce Translate me on Weblate Try me on Runboat

+

Ce module ajoute Payplug comme +fournisseur de paiement au cadre de paiement d’Odoo (module +payment).

+

Le client est redirigé vers la page de paiement hébergée par Payplug, +puis revient sur Odoo. Le module gère :

+
    +
  • le paiement en ligne sur le site web (panier et validation de +commande) ;
  • +
  • le paiement des factures depuis le portail client et par lien de +paiement ;
  • +
  • les notifications serveur de Payplug (paiements et remboursements) ;
  • +
  • les remboursements totaux et partiels depuis Odoo, et ceux faits +depuis le portail Payplug ;
  • +
  • les annulations, les échecs et les paiements expirés.
  • +
+

Payplug n’accepte que l’euro : le fournisseur n’est proposé que pour les +paiements en EUR.

+

Le module appelle directement l’API REST de Payplug avec requests ; +il ne demande aucune bibliothèque Python supplémentaire.

+

Avertissement : ce module est un développement indépendant. Il n’est +ni affilié à Payplug, ni approuvé par Payplug. « Payplug » est une +marque de son propriétaire.

+

Table of contents

+ +
+

Installation

+

Le module dépend des modules standard payment et account_payment +(installés avec la Facturation ou le site e-commerce). Il ne demande +aucune bibliothèque Python supplémentaire.

+
    +
  1. Placez le dossier payment_payplug dans un chemin d’extensions +(addons_path) d’Odoo 19, ou installez le paquet Python construit +avec pyproject.toml.
  2. +
  3. Redémarrez Odoo, mettez à jour la liste des applications, puis +installez Payment Provider: Payplug.
  4. +
+

Le fournisseur Payplug est créé désactivé, pour chaque société.

+

À la désinstallation, comme pour les fournisseurs standard, Odoo refuse +de continuer si des paiements comptables ont été faits avec Payplug. Un +fournisseur qui n’a que des transactions sans paiement est conservé +(désactivé, sans code) pour garder ces transactions lisibles ; sinon il +est supprimé.

+
+
+

Configuration

+

Clés Payplug

+

Le module accepte deux méthodes d’authentification, au choix dans le +champ Authentification de l’onglet Identifiants :

+
    +
  • Clé API (par défaut) : la clé secrète du compte est envoyée à +chaque requête ;
  • +
  • OAuth2 : Odoo échange un identifiant client (client ID) et un +secret client (client secret) contre des jetons d’accès valables 5 +minutes, demandés et renouvelés automatiquement. Le secret ne circule +que vers le serveur d’autorisation de Payplug.
  • +
+
    +
  1. Dans le portail Payplug, à la rubrique des clés d’API, générez une +clé en environnement Test et, plus tard, une clé en environnement +Live :

    +
      +
    • pour la méthode Clé API, une clé de type API Key (clé +secrète). Les clés actuelles sont des jetons JWT (eyJ...) ; les +anciennes clés sk_test_... et sk_live_... sont aussi +acceptées ;
    • +
    • pour la méthode OAuth2, une clé de type OAuth2. Le portail +affiche son client ID et son client secret ; notez le secret +aussitôt, il n’est montré qu’une fois.
    • +
    +

    La clé publique n’est pas utilisée : le module ne passe que par la +page de paiement hébergée.

    +
  2. +
  3. Dans Odoo, ouvrez Facturation (ou Comptabilité) > Configuration > +Fournisseurs de paiement et choisissez Payplug.

    +
  4. +
  5. Dans l’onglet Identifiants, choisissez la méthode +d’authentification, puis collez la clé secrète (Clé API) ou le +client ID et le client secret (OAuth2). Seuls les +administrateurs (groupe Paramètres) peuvent les lire ou les +modifier.

    +
  6. +
  7. Réglez l’état :

    +
      +
    • Mode test avec une clé de l’environnement Test : aucun débit +réel ;
    • +
    • Activé avec une clé de l’environnement Live : paiements réels.
    • +
    +

    Pour une ancienne clé sk_..., Odoo refuse une clé de production +en mode test et une clé de test sur un fournisseur activé. Pour une +clé JWT, l’accord entre le mode et la clé est vérifié à chaque +paiement : un paiement dont le mode ne correspond pas est refusé. En +OAuth2, l’environnement du jeton (scope test) est en plus +vérifié à chaque requête : en mode test, seul un jeton de +l’environnement Test est accepté ; sur un fournisseur activé, un +jeton de test est refusé.

    +

    Pour passer de Test à Live en OAuth2, remplacez le client ID et le +client secret par ceux de la clé Live en même temps que vous +activez le fournisseur.

    +
  8. +
  9. Dans l’onglet Configuration, vérifiez le journal de paiement et les +moyens de paiement. Par défaut, seul Carte (CB, Visa, Mastercard) +est activé. Bancontact, iDEAL et MyBank sont proposés, mais ne les +activez qu’après les avoir fait ouvrir sur votre compte Payplug.

    +
  10. +
  11. Publiez le fournisseur pour qu’il apparaisse sur le site web.

    +
  12. +
+

Adresse de notification (webhook)

+

Le module donne à Payplug, pour chaque paiement, l’adresse +https://<votre-domaine>/payment/payplug/webhook. Il n’y a rien à +saisir dans le portail Payplug, mais cette adresse doit être joignable +depuis Internet en HTTPS :

+
    +
  • ne la protégez pas par une authentification, un filtrage IP ou une +règle de pare-feu (WAF, fail2ban, Cloudflare…) qui bloquerait les +requêtes POST de Payplug ;
  • +
  • vérifiez le paramètre système web.base.url (ou le domaine du site +web) : c’est lui qui sert à construire les adresses de retour et de +notification.
  • +
+

Les retours du client passent par /payment/payplug/return et +/payment/payplug/cancel.

+

Jetons d’accès OAuth2

+

Un jeton dure 5 minutes ; Odoo le garde en mémoire et en demande un +nouveau une minute avant son expiration. Les jetons ne sont jamais +écrits en base ni dans les journaux ; chaque processus (worker) d’Odoo +demande les siens. Si Payplug refuse un jeton (HTTP 401), Odoo l’oublie, +en demande un autre et renvoie la requête une seule fois. Après une +modification des identifiants, chaque processus demande un nouveau jeton +dès sa requête suivante ; après un changement d’état, le jeton en +mémoire est de nouveau confronté au mode du fournisseur.

+

Paramètres système réservés aux tests

+

Ils ne servent qu’à pointer vers un faux serveur de test ; ne les créez +pas en production.

+
    +
  • payment_payplug.api_base_url remplace l’adresse de l’API Payplug +(https://api.payplug.com/v1/ par défaut) ;
  • +
  • payment_payplug.token_url remplace l’adresse du point d’obtention +des jetons OAuth2 (par défaut /oauth2/token sur l’hôte de l’API, +soit https://api.payplug.com/oauth2/token) ;
  • +
  • payment_payplug.token_audience remplace l’audience demandée pour +les jetons (https://www.payplug.com par défaut).
  • +
+
+
+

Usage

+

Paiement sur le site web ou depuis le portail

+

Le client choisit Carte (ou un autre moyen activé), est redirigé vers +la page Payplug, paie, puis revient sur la page d’état du paiement +d’Odoo. La commande ou la facture est confirmée dès qu’Odoo a relu le +paiement auprès de Payplug, soit au retour du client, soit à la +réception de la notification.

+

Si le client clique sur Annuler sur la page Payplug, le paiement est +abandonné chez Payplug et la transaction passe à l’état Annulée. Un +paiement refusé passe à l’état Erreur avec le motif donné par Payplug +; un paiement jamais terminé est annulé quand Payplug signale son +expiration (environ 15 minutes).

+

Remboursement

+

Depuis le paiement (Comptabilité > Clients > Paiements), ou depuis la +facture via son paiement, cliquez sur Rembourser et indiquez le +montant (total ou partiel). Odoo demande le remboursement à Payplug, +crée une transaction de remboursement liée à la transaction d’origine, +puis le paiement comptable négatif correspondant.

+

Un remboursement fait directement dans le portail Payplug est aussi +enregistré dans Odoo, à la réception de sa notification.

+

Payplug n’accepte pas de remboursement inférieur à 0,10 €.

+

Vérifier une transaction

+

Sur une transaction Payplug en attente ou en erreur, le bouton Vérifier +le statut sur Payplug relit le paiement auprès de Payplug et met la +transaction à jour (utile si une notification n’a pas pu arriver).

+
+
+

Development

+

Les tests automatiques (--test-tags /payment_payplug) n’appellent +jamais l’API Payplug : ils démarrent un faux serveur Payplug local +(tests/fake_payplug.py) et y font pointer le module par le paramètre +système payment_payplug.api_base_url.

+

Le même faux serveur sert aux essais manuels de bout en bout, avec une +page de paiement factice (boutons Payer, Refuser, Annuler) et +l’envoi des notifications :

+
+python3 payment_payplug/tests/fake_payplug.py --host 127.0.0.1 --port 8765
+
+

Sur une base de test, créez alors le paramètre système +payment_payplug.api_base_url avec la valeur +http://127.0.0.1:8765/v1/, et saisissez une clé quelconque +commençant par sk_test_. POST /_control/refund/<id du paiement> +(corps {"amount": 1000}) simule un remboursement fait depuis le +portail Payplug.

+

Pour essayer la méthode OAuth2, le faux serveur répond aussi sur +POST /oauth2/token (authentification Basic, +grant_type=client_credentials, audience https://www.payplug.com) +avec deux clients factices : fake_client_test / +fake_client_secret_test (jetons de test) et fake_client_live / +fake_client_secret_live (jetons Live). L’adresse du point de jeton +se déduit de payment_payplug.api_base_url ; il n’y a rien d’autre à +régler.

+
+
+

Known issues / Roadmap

+
    +
  • Connexion « avec son compte Payplug » (OAuth2 authorization code + +PKCE, chemin /oauth2/auth) : hors périmètre. Ce flux demande un +client enregistré par Payplug avec une adresse de rappel ; le module +se limite au flux client credentials avec une clé OAuth2 créée dans +le portail.
  • +
  • Prévu pour une version 3 : paiement en un clic (carte enregistrée), +paiement fractionné, paiement différé, Apple Pay et paiement intégré.
  • +
  • Paiement en plusieurs fois (Oney 3x/4x) : non pris en charge. Il +demande le panier détaillé (payment_context), une autorisation +puis une capture, et des montants entre 100 € et 3 000 €.
  • +
  • Enregistrement de la carte (jetons) et paiements récurrents : non pris +en charge.
  • +
  • Capture différée (autorisation puis capture manuelle) : non prise en +charge.
  • +
  • Apple Pay et le paiement intégré (Integrated Payment, champs de +carte dans la page Odoo) : non pris en charge ; seule la page hébergée +est utilisée.
  • +
  • Les marques de carte renvoyées par Payplug (card.brand) ne sont +connues que par la documentation ; à vérifier avec de vraies +transactions de test.
  • +
  • Satispay, Scalapay et Wero n’ont pas de moyen de paiement +correspondant dans Odoo 19 : ils ne sont pas proposés.
  • +
+

À vérifier avec de vraies clés de test Payplug (non vérifié, faute de +compte) :

+
    +
  • le format exact des notifications (paiement et remboursement), en +particulier la présence de payment_id dans celles des +remboursements ;
  • +
  • le code HTTP attendu par Payplug en réponse aux notifications, et s’il +les renvoie après une erreur (le module répond 503 quand il ne peut +pas relire le paiement) ;
  • +
  • l’acceptation des champs billing / shipping envoyés (langue, +téléphone, pays) ;
  • +
  • l’abandon (aborted) d’un paiement quand le client clique sur +Annuler ;
  • +
  • les valeurs de card.brand (Visa, Mastercard, CB…) ;
  • +
  • Bancontact, iDEAL et MyBank, qui demandent une activation sur le +compte Payplug ;
  • +
  • en OAuth2 : la valeur du scope des jetons de l’environnement Live +(live est supposé ; une autre valeur est tolérée sur un +fournisseur activé, le mode de chaque paiement restant vérifié).
  • +
+
+
+

Changelog

+
+

19.0.1.1.0 (2026-10-24)

+
    +
  • Authentification OAuth2 (client credentials) au choix, à côté de la +clé API : jetons d’accès de 5 minutes gardés en mémoire et renouvelés +automatiquement, nouvelle demande et renvoi unique de la requête si +Payplug refuse le jeton, contrôle de l’environnement (Test ou Live) du +jeton.
  • +
+
+
+

19.0.1.0.0 (2026-10-24)

+
    +
  • Première version : paiement par la page hébergée Payplug, +notifications, remboursements totaux et partiels, paiement des +factures.
  • +
+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Sébastien Berger (sbinfo67)
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

sbinfo67

+

This module is part of the OCA/e-commerce project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/payment_payplug/tests/__init__.py b/payment_payplug/tests/__init__.py new file mode 100644 index 0000000000..64b9528e45 --- /dev/null +++ b/payment_payplug/tests/__init__.py @@ -0,0 +1,5 @@ +from . import test_payplug +from . import test_payplug_hooks +from . import test_payplug_http +from . import test_payplug_invoice +from . import test_payplug_oauth diff --git a/payment_payplug/tests/common.py b/payment_payplug/tests/common.py new file mode 100644 index 0000000000..acbaf45d4c --- /dev/null +++ b/payment_payplug/tests/common.py @@ -0,0 +1,77 @@ +# Copyright 2026 Sébastien Berger (sbinfo67) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +from odoo.addons.payment.tests.common import PaymentCommon + +from .. import const, oauth +from .fake_payplug import OAUTH2_CLIENTS, FakePayplugServer + +TEST_SECRET_KEY = "sk_test_odoo_payplug_fake_key" # Only valid on the fake server. +# Fake OAuth2 clients, only valid on the fake server. +TEST_CLIENT_ID = "fake_client_test" +TEST_CLIENT_SECRET = OAUTH2_CLIENTS[TEST_CLIENT_ID][0] +LIVE_CLIENT_ID = "fake_client_live" +LIVE_CLIENT_SECRET = OAUTH2_CLIENTS[LIVE_CLIENT_ID][0] + + +class PayplugCommon(PaymentCommon): + """A Payplug provider in test mode talking to a local fake Payplug API.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.fake_payplug = FakePayplugServer().start() + cls.addClassCleanup(cls.fake_payplug.stop) + cls.env["ir.config_parameter"].sudo().set_param( + const.API_BASE_URL_PARAM, cls.fake_payplug.api_url + ) + + cls.payplug = cls._prepare_provider( + "payplug", update_values={"payplug_secret_key": TEST_SECRET_KEY} + ) + cls.provider = cls.payplug + cls.currency = cls.currency_euro + cls.amount = 49.90 + cls.payment_method = cls.env.ref("payment.payment_method_card") + cls.payment_method_id = cls.payment_method.id + cls.payment_method_code = cls.payment_method.code + + def setUp(self): + super().setUp() + self.fake_payplug.state.reset() + oauth.TOKEN_CACHE.clear() + self.addCleanup(oauth.TOKEN_CACHE.clear) + + # === Helpers === # + + def _create_payplug_payment(self, **tx_values): + """Create a transaction and its payment on the fake Payplug, like the checkout. + + :return: The transaction and the processing values. + """ + tx = self._create_transaction("redirect", **tx_values) + processing_values = tx._get_processing_values() + return tx, processing_values + + def _notification(self, obj): + return {key: obj[key] for key in ("id", "object", "is_live") if key in obj} | ( + {"payment_id": obj["payment_id"]} if "payment_id" in obj else {} + ) + + +class PayplugOAuth2Common(PayplugCommon): + """The same Payplug provider, authenticated with an OAuth2 client of the Test + environment.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.payplug.write( + { + "payplug_auth_method": "oauth2", + "payplug_client_id": TEST_CLIENT_ID, + "payplug_client_secret": TEST_CLIENT_SECRET, + "payplug_secret_key": False, + } + ) diff --git a/payment_payplug/tests/fake_payplug.py b/payment_payplug/tests/fake_payplug.py new file mode 100644 index 0000000000..8ea1f20d40 --- /dev/null +++ b/payment_payplug/tests/fake_payplug.py @@ -0,0 +1,548 @@ +# Copyright 2026 Sébastien Berger (sbinfo67) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). +"""Fake Payplug API server, for automated and manual tests only. + +It imitates the subset of the Payplug REST API used by the module (creating, reading +and aborting a payment; creating and reading refunds), the OAuth2 token endpoint (client +credentials grant) and a minimal hosted payment page. It keeps everything in memory and +never contacts Payplug. + +Standalone use, for a manual end-to-end test (point the system parameter +`payment_payplug.api_base_url` of the test database to http://:/v1/): + + python3 fake_payplug.py --host 0.0.0.0 --port 8765 --public-url http://:8765 + +The API accepts any secret key starting with `sk_test_` or `sk_live_`, and the access +tokens issued by `POST /oauth2/token` to the fake OAuth2 clients below. +""" + +import argparse +import base64 +import binascii +import html +import json +import logging +import secrets +import threading +import time +import urllib.error +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlsplit + +_logger = logging.getLogger(__name__) + +API_VERSION = "2019-08-06" +TOKEN_AUDIENCE = "https://www.payplug.com" +TOKEN_LIFETIME = 299 # Seconds, like Payplug. + +# Fake OAuth2 clients: client id -> (client secret, scope of their tokens). +OAUTH2_CLIENTS = { + "fake_client_test": ("fake_client_secret_test", "test"), + "fake_client_live": ("fake_client_secret_live", "live"), +} + + +def _b64url(data): + return base64.urlsafe_b64encode(json.dumps(data).encode()).rstrip(b"=").decode() + + +class FakePayplugError(Exception): + def __init__(self, status, message, details=None): + super().__init__(message) + self.status = status + self.message = message + self.details = details + + +class FakePayplugState: + """In-memory state of the fake server.""" + + def __init__(self): + self.lock = threading.RLock() + self.payments = {} + self.refunds = {} + self.requests = [] # (method, path, headers, body) of the API requests. + self.notifications = [] # (url, body, status) of every notification sent. + self.token_requests = [] # (headers, body) of the token requests. + self.tokens = {} # Access token -> {"scope", "expires_at", "revoked"}. + self._reset_token_settings() + + def _reset_token_settings(self): + self.token_lifetime = TOKEN_LIFETIME + self.token_failure_status = None # Force this status on the token endpoint. + self.token_scope = None # Override the scope of the issued tokens. + self.reject_all_tokens = False # Answer 401 to every access token. + + def reset(self): + with self.lock: + self.payments.clear() + self.refunds.clear() + self.requests.clear() + self.notifications.clear() + self.token_requests.clear() + self.tokens.clear() + self._reset_token_settings() + + +class FakePayplugServer: + """A threaded HTTP server imitating the Payplug API.""" + + def __init__(self, host="127.0.0.1", port=0, public_url=None, notify=False): + self.state = FakePayplugState() + self.notify = notify + handler = type("BoundHandler", (FakePayplugHandler,), {"fake": self}) + self.httpd = ThreadingHTTPServer((host, port), handler) + self.httpd.daemon_threads = True + self.port = self.httpd.server_address[1] + self.public_url = (public_url or f"http://{host}:{self.port}").rstrip("/") + self.api_url = f"http://{host}:{self.port}/v1/" + self.thread = None + + def start(self): + self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) + self.thread.start() + return self + + def stop(self): + self.httpd.shutdown() + self.httpd.server_close() + + # === State helpers (used by tests and by the hosted page) === # + + def get_payment(self, payment_id): + return self.state.payments[payment_id] + + def mark_paid(self, payment_id, brand="Visa"): + with self.state.lock: + payment = self.state.payments[payment_id] + payment.update( + is_paid=True, + paid_at=int(time.time()), + card={"brand": brand, "last4": "4242", "country": "FR"}, + ) + payment["hosted_payment"]["paid_at"] = payment["paid_at"] + return payment + + def mark_failed( + self, payment_id, code="card_declined", message="The card was declined." + ): + with self.state.lock: + payment = self.state.payments[payment_id] + payment["failure"] = {"code": code, "message": message} + return payment + + def create_refund(self, payment_id, amount=None, metadata=None): + """Create a refund like the API (or the Payplug portal, without metadata).""" + with self.state.lock: + payment = self.state.payments[payment_id] + if not payment["is_paid"]: + raise FakePayplugError(400, "The payment is not paid.") + remaining = payment["amount"] - payment["amount_refunded"] + if amount is None: + amount = remaining + if not isinstance(amount, int) or amount < 10: + raise FakePayplugError( + 400, "Invalid amount.", {"amount": "minimum is 10"} + ) + if amount > remaining: + raise FakePayplugError(400, "The amount exceeds the refundable amount.") + refund = { + "id": f"re_{secrets.token_hex(10)}", + "object": "refund", + "payment_id": payment_id, + "is_live": payment["is_live"], + "amount": amount, + "currency": payment["currency"], + "created_at": int(time.time()), + "metadata": metadata or {}, + } + self.state.refunds[refund["id"]] = refund + payment["amount_refunded"] += amount + payment["is_refunded"] = payment["amount_refunded"] >= payment["amount"] + return refund + + def issued_tokens(self): + """Return the access tokens issued, in order.""" + return list(self.state.tokens) + + def revoke_tokens(self): + """Revoke every access token issued: the API then answers 401 to them.""" + with self.state.lock: + for token in self.state.tokens.values(): + token["revoked"] = True + + def issue_token(self, client_id, scope): + """Issue an access token shaped like the Payplug ones (an unsigned JWT).""" + issued_at = int(time.time()) + lifetime = self.state.token_lifetime + claims = { + "aud": [TOKEN_AUDIENCE], + "client_id": client_id, + "exp": issued_at + lifetime, + "iat": issued_at, + "jti": secrets.token_hex(8), + "roles": ["plugin"], + "scp": scope.split(), + } + header = {"alg": "RS256", "typ": "JWT"} + token = ".".join((_b64url(header), _b64url(claims), secrets.token_urlsafe(32))) + self.state.tokens[token] = { + "scope": scope, + "expires_at": issued_at + lifetime, + "revoked": False, + } + return { + "access_token": token, + "expires_in": lifetime, + "scope": scope, + "token_type": "bearer", + } + + def send_notification(self, obj): + """POST the object to the notification URL of its payment, like Payplug does.""" + payment = self.state.payments[obj.get("payment_id") or obj["id"]] + url = payment["notification"]["url"] + if not url: + return None + body = json.dumps(obj).encode() + req = urllib.request.Request( + url, data=body, headers={"Content-Type": "application/json"}, method="POST" + ) + try: + with urllib.request.urlopen(req, timeout=30) as response: + status = response.status + except urllib.error.HTTPError as error: + status = error.code + except OSError as error: + _logger.warning("Notification to %s failed: %s", url, error) + status = None + payment["notification"]["response_code"] = status + self.state.notifications.append((url, obj, status)) + return status + + +class FakePayplugHandler(BaseHTTPRequestHandler): + fake = None # Set on the bound subclass. + + def log_message(self, format, *args): # pylint: disable=redefined-builtin + _logger.debug("fake payplug: " + format, *args) + + # === Plumbing === # + + def _send_json(self, status, data): + body = json.dumps(data).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _send_error(self, error): + data = {"object": "error", "message": error.message, "id": secrets.token_hex(8)} + if error.details: + data["details"] = error.details + self._send_json(error.status, data) + + def _read_body(self): + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length) if length else b"" + return raw + + def _authenticate(self): + auth = self.headers.get("Authorization") or "" + key = auth[len("Bearer ") :] if auth.startswith("Bearer ") else "" + if key.startswith("sk_test_"): + return False + if key.startswith("sk_live_"): + return True + token = self.fake.state.tokens.get(key) + if ( + token + and not token["revoked"] + and not self.fake.state.reject_all_tokens + and time.time() < token["expires_at"] + ): + return "live" in token["scope"].split() + raise FakePayplugError(401, "Invalid API key.") + + # === OAuth2 token endpoint === # + + def _send_oauth2_error(self, status, error, description): + self._send_json(status, {"error": error, "error_description": description}) + + def _token(self, method, raw): + """POST /oauth2/token: the client credentials grant, client_secret_basic.""" + state = self.fake.state + state.token_requests.append((dict(self.headers), raw)) + if method != "POST": + return self._send_oauth2_error(405, "invalid_request", "POST only.") + if state.token_failure_status: + return self._send_json( + state.token_failure_status, {"error": "server_error"} + ) + content_type = (self.headers.get("Content-Type") or "").split(";")[0] + if content_type != "application/x-www-form-urlencoded": + return self._send_oauth2_error(400, "invalid_request", "Form body only.") + body = {key: values[0] for key, values in parse_qs(raw.decode()).items()} + if "client_secret" in body or "client_id" in body: + return self._send_oauth2_error( + 400, "invalid_request", "Use client_secret_basic." + ) + auth = self.headers.get("Authorization") or "" + try: + decoded = base64.b64decode(auth[len("Basic ") :], validate=True).decode() + except (binascii.Error, UnicodeDecodeError): + decoded = "" + client_id, _sep, client_secret = decoded.partition(":") + client = OAUTH2_CLIENTS.get(client_id) + if ( + not auth.startswith("Basic ") + or not client + or not secrets.compare_digest(client[0], client_secret) + ): + return self._send_oauth2_error( + 401, "invalid_client", "Client authentication failed." + ) + if body.get("grant_type") != "client_credentials": + return self._send_oauth2_error( + 400, "unsupported_grant_type", "Unsupported grant type." + ) + if body.get("audience") != TOKEN_AUDIENCE: + return self._send_oauth2_error(400, "invalid_request", "Invalid audience.") + with state.lock: + data = self.fake.issue_token(client_id, state.token_scope or client[1]) + return self._send_json(200, data) + + def _get_payment(self, payment_id, is_live): + payment = self.fake.state.payments.get(payment_id) + if not payment or payment["is_live"] != is_live: + raise FakePayplugError( + 404, "The resource you requested could not be found." + ) + return payment + + def do_GET(self): # noqa: N802 + self._dispatch("GET") + + def do_POST(self): # noqa: N802 + self._dispatch("POST") + + def do_PATCH(self): # noqa: N802 + self._dispatch("PATCH") + + def _dispatch(self, method): + path = urlsplit(self.path).path.rstrip("/") + parts = [p for p in path.split("/") if p] + raw = self._read_body() + try: + if parts[:1] == ["pay"]: + return self._hosted_page(method, parts, raw) + if parts[:1] == ["_control"]: + return self._control(method, parts, raw) + if parts == ["oauth2", "token"]: + return self._token(method, raw) + if parts[:1] != ["v1"]: + raise FakePayplugError(404, "Not found.") + self.fake.state.requests.append((method, path, dict(self.headers), raw)) + if self.headers.get("PayPlug-Version") != API_VERSION: + raise FakePayplugError( + 400, "Missing or unexpected PayPlug-Version header." + ) + is_live = self._authenticate() + body = json.loads(raw) if raw else {} + with self.fake.state.lock: + status, data = self._api(method, parts[1:], body, is_live) + self._send_json(status, data) + except FakePayplugError as error: + self._send_error(error) + except (ValueError, KeyError, TypeError) as error: + self._send_error(FakePayplugError(400, f"Bad request: {error}")) + + # === API === # + + def _api(self, method, parts, body, is_live): + if parts == ["payments"] and method == "POST": + return 201, self._create_payment(body, is_live) + if len(parts) == 2 and parts[0] == "payments": + payment = self._get_payment(parts[1], is_live) + if method == "GET": + return 200, payment + if method == "PATCH": + if body.get("aborted"): + if payment["is_paid"] or payment["failure"]: + raise FakePayplugError(400, "The payment cannot be aborted.") + payment["failure"] = { + "code": "aborted", + "message": "Payment aborted.", + } + return 200, payment + if len(parts) == 3 and parts[0] == "payments" and parts[2] == "refunds": + payment = self._get_payment(parts[1], is_live) + if method == "POST": + refund = self.fake.create_refund( + payment["id"], body.get("amount"), body.get("metadata") + ) + if self.fake.notify: + notification = { + key: refund[key] for key in ("id", "object", "payment_id") + } + threading.Timer( + 1.0, self.fake.send_notification, [notification] + ).start() + return 201, refund + if method == "GET": + refunds = [ + r + for r in self.fake.state.refunds.values() + if r["payment_id"] == parts[1] + ] + return 200, {"object": "list", "data": refunds} + if len(parts) == 4 and parts[0] == "payments" and parts[2] == "refunds": + self._get_payment(parts[1], is_live) + refund = self.fake.state.refunds.get(parts[3]) + if method == "GET" and refund and refund["payment_id"] == parts[1]: + return 200, refund + raise FakePayplugError( + 404, "The resource you requested could not be found." + ) + raise FakePayplugError(404, "Not found.") + + def _create_payment(self, body, is_live): + amount = body.get("amount") + details = {} + if body.get("currency") != "EUR": + details["currency"] = "Only EUR is supported." + if not isinstance(amount, int) or not 30 <= amount <= 2000000: + details["amount"] = "The amount must be an integer between 30 and 2000000." + for key in ("billing", "shipping"): + if not isinstance(body.get(key), dict): + details[key] = "This field is required." + hosted = body.get("hosted_payment") or {} + for key in ("return_url", "cancel_url"): + if hosted.get(key) and not hosted[key].startswith(("http://", "https://")): + details[f"hosted_payment.{key}"] = "Invalid URL." + if len(json.dumps(body.get("metadata") or {})) > 1000: + details["metadata"] = "Too long." + if len(body.get("description") or "") > 80: + details["description"] = "Too long." + if details: + raise FakePayplugError(400, "Invalid parameters.", details) + payment_id = f"pay_{secrets.token_hex(10)}" + mode = "live" if is_live else "test" + payment = { + "id": payment_id, + "object": "payment", + "is_live": is_live, + "amount": amount, + "amount_refunded": 0, + "authorization": None, + "currency": "EUR", + "created_at": int(time.time()), + "description": body.get("description"), + "is_paid": False, + "paid_at": None, + "is_refunded": False, + "is_3ds": None, + "save_card": False, + "card": {"last4": None, "country": None, "exp_year": None, "brand": None}, + "billing": body.get("billing"), + "shipping": body.get("shipping"), + "payment_method": body.get("payment_method"), + "hosted_payment": { + "payment_url": f"{self.fake.public_url}/pay/{mode}/{payment_id}", + "return_url": hosted.get("return_url"), + "cancel_url": hosted.get("cancel_url"), + "paid_at": None, + "sent_by": None, + }, + "notification": { + "url": body.get("notification_url"), + "response_code": None, + }, + "failure": None, + "metadata": body.get("metadata") or {}, + } + self.fake.state.payments[payment_id] = payment + return payment + + # === Control routes (manual tests) === # + + def _control(self, method, parts, raw): + """POST /_control/refund/ {"amount": cents}: portal refund.""" + if method != "POST" or len(parts) != 3 or parts[1] != "refund": + raise FakePayplugError(404, "Not found.") + body = json.loads(raw) if raw else {} + refund = self.fake.create_refund(parts[2], body.get("amount")) + status = self.fake.send_notification( + {key: refund[key] for key in ("id", "object", "payment_id")} + ) + self._send_json(201, dict(refund, notification_status=status)) + + # === Hosted payment page (manual tests) === # + + def _hosted_page(self, method, parts, raw): + # /pay// + payment = self.fake.state.payments.get(parts[2]) if len(parts) == 3 else None + if not payment: + raise FakePayplugError(404, "Unknown payment.") + if method == "GET": + amount = f"{payment['amount'] / 100:.2f} EUR" + title = html.escape(str(payment["description"])) + page = f""" +Faux Payplug + +

Faux Payplug (test)

Paiement {title} : {amount}

+
+ +
""" + body = page.encode() + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return None + action = parse_qs(raw.decode()).get("action", [""])[0] + target = payment["hosted_payment"]["return_url"] + if action == "pay": + self.fake.mark_paid(payment["id"]) + elif action == "refuse": + self.fake.mark_failed(payment["id"]) + else: + target = payment["hosted_payment"]["cancel_url"] + if action in ("pay", "refuse") and self.fake.notify: + self.fake.send_notification( + { + "id": payment["id"], + "object": "payment", + "is_live": payment["is_live"], + } + ) + self.send_response(303) + self.send_header("Location", target) + self.end_headers() + return None + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8765) + parser.add_argument( + "--public-url", help="URL of the hosted pages seen by the browser" + ) + args = parser.parse_args() + logging.basicConfig(level=logging.DEBUG) + server = FakePayplugServer(args.host, args.port, args.public_url, notify=True) + _logger.info( + "Fake Payplug API on %s (pages on %s)", server.api_url, server.public_url + ) + try: + server.httpd.serve_forever() + except KeyboardInterrupt: + server.stop() + + +if __name__ == "__main__": + main() diff --git a/payment_payplug/tests/test_payplug.py b/payment_payplug/tests/test_payplug.py new file mode 100644 index 0000000000..f1cda9954f --- /dev/null +++ b/payment_payplug/tests/test_payplug.py @@ -0,0 +1,495 @@ +# Copyright 2026 Sébastien Berger (sbinfo67) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +import json +from unittest.mock import patch +from urllib.parse import parse_qs, urlsplit + +import requests + +from odoo.exceptions import AccessError, ValidationError +from odoo.tests import tagged +from odoo.tools import mute_logger + +from .. import const +from .common import PayplugCommon + +TX_LOGGER = "odoo.addons.payment_payplug.models.payment_transaction" +LOGGERS = ( + TX_LOGGER, + "odoo.addons.payment.models.payment_provider", + "odoo.addons.payment.models.payment_transaction", +) + + +def _make_response(status_code, body): + """Return a `requests` response with the given status and body (bytes or JSON).""" + response = requests.models.Response() + response.status_code = status_code + response.encoding = "utf-8" + response._content = body if isinstance(body, bytes) else json.dumps(body).encode() + return response + + +@tagged("post_install", "-at_install", "payment_payplug") +class TestPayplugProvider(PayplugCommon): + def test_supported_currencies_are_eur_only(self): + self.assertEqual(self.payplug.available_currency_ids, self.currency_euro) + compatible = self.env["payment.provider"]._get_compatible_providers( + self.company.id, + self.partner.id, + self.amount, + currency_id=self.currency_euro.id, + ) + self.assertIn(self.payplug, compatible) + incompatible = self.env["payment.provider"]._get_compatible_providers( + self.company.id, + self.partner.id, + self.amount, + currency_id=self.currency_usd.id, + ) + self.assertNotIn(self.payplug, incompatible) + + def test_feature_support(self): + self.assertEqual(self.payplug.support_refund, "partial") + self.assertFalse(self.payplug.support_tokenization) + self.assertFalse(self.payplug.support_manual_capture) + + def test_secret_key_restricted_to_system_group(self): + field = self.env["payment.provider"]._fields["payplug_secret_key"] + self.assertEqual(field.groups, "base.group_system") + with self.assertRaises(AccessError): + self.payplug.with_user(self.internal_user).read(["payplug_secret_key"]) + + def test_secret_key_must_match_state(self): + with self.assertRaises(ValidationError): + self.payplug.write({"payplug_secret_key": "sk_live_whatever"}) + with self.assertRaises(ValidationError): + self.payplug.write({"state": "enabled"}) + self.payplug.write( + {"state": "enabled", "payplug_secret_key": "sk_live_whatever"} + ) + self.assertEqual(self.payplug.state, "enabled") + + def test_reset_credentials_clears_secret_key(self): + self.payplug.action_reset_credentials() + self.assertFalse(self.payplug.payplug_secret_key) + self.assertEqual(self.payplug.state, "disabled") + + def test_default_api_url(self): + self.env["ir.config_parameter"].sudo().set_param( + const.API_BASE_URL_PARAM, False + ) + self.assertEqual( + self.payplug._build_request_url("payments"), + "https://api.payplug.com/v1/payments", + ) + + def test_other_providers_are_not_affected(self): + other = self.dummy_provider + self.assertEqual(other._get_default_payment_method_codes(), set()) + self.assertEqual(other._build_request_url("payments"), "") + # The Payplug credentials are never sent to another provider. + self.assertEqual(other._build_request_headers("GET", "payments", None), {}) + response = _make_response( + 400, {"message": "Invalid parameters.", "details": {"amount": "Too low."}} + ) + self.assertEqual(other._parse_response_error(response), response.text) + self.assertEqual( + self.payplug._parse_response_error(response), + "Invalid parameters. {'amount': 'Too low.'}", + ) + + @mute_logger(*LOGGERS) + def test_error_response_without_json_object(self): + # E.g. the HTML page of a proxy, or an unexpected JSON value. + for body, expected in ( + (b"502 Bad Gateway", "502 Bad Gateway"), + (["unexpected"], '["unexpected"]'), + ({"object": "error"}, '{"object": "error"}'), + ): + with self.subTest(body=body): + response = _make_response(502, body) + self.assertEqual(self.payplug._parse_response_error(response), expected) + response = _make_response(502, b"502 Bad Gateway") + with ( + patch.object(requests, "request", return_value=response), + self.assertRaisesRegex(ValidationError, "502 Bad Gateway"), + ): + self.payplug._send_api_request("GET", "payments/pay_unknown") + + +@tagged("post_install", "-at_install", "payment_payplug") +class TestPayplugPayment(PayplugCommon): + def _last_request(self): + method, path, headers, body = self.fake_payplug.state.requests[-1] + return method, path, headers, json.loads(body) if body else {} + + def test_payment_creation_and_redirect_form(self): + tx, processing_values = self._create_payplug_payment() + + method, path, headers, payload = self._last_request() + self.assertEqual((method, path), ("POST", "/v1/payments")) + self.assertEqual( + headers["Authorization"], "Bearer sk_test_odoo_payplug_fake_key" + ) + self.assertEqual(headers["PayPlug-Version"], const.API_VERSION) + self.assertEqual(payload["amount"], 4990) + self.assertEqual(payload["currency"], "EUR") + self.assertEqual(payload["metadata"], {"odoo_reference": tx.reference}) + self.assertNotIn( + "payment_method", payload + ) # Card: the default of the payment page. + self.assertTrue( + payload["notification_url"].endswith("/payment/payplug/webhook") + ) + for key, route in (("return_url", "return"), ("cancel_url", "cancel")): + url = urlsplit(payload["hosted_payment"][key]) + self.assertEqual(url.path, f"/payment/payplug/{route}") + params = parse_qs(url.query) + self.assertEqual(params["reference"], [tx.reference]) + self.assertEqual( + params["access_token"], [self._generate_test_access_token(tx.reference)] + ) + self.assertEqual(payload["billing"]["email"], "norbert.buyer@example.com") + self.assertEqual(payload["billing"]["country"], "BE") + self.assertEqual(payload["billing"]["language"], "en") + self.assertEqual(payload["shipping"]["delivery_type"], "BILLING") + # "0032 12 34 56 78" is international (00 prefix), not a French mobile. + self.assertEqual(payload["billing"]["landline_phone_number"], "+3212345678") + self.assertNotIn("mobile_phone_number", payload["billing"]) + + payment = self.fake_payplug.get_payment(tx.provider_reference) + form = self._extract_values_from_html_form( + processing_values["redirect_form_html"] + ) + self.assertEqual(form["action"], payment["hosted_payment"]["payment_url"]) + self.assertEqual(form["method"], "get") + self.assertEqual(tx.state, "draft") + + def test_alternative_payment_method_is_sent(self): + bancontact = self.env.ref("payment.payment_method_bancontact") + bancontact.active = True + self._create_payplug_payment(payment_method_id=bancontact.id) + payload = self._last_request()[3] + self.assertEqual(payload["payment_method"], "bancontact") + + def test_phone_formatting(self): + tx_model = self.env["payment.transaction"] + self.assertEqual( + tx_model._payplug_format_phone("+33 6 12 34 56 78"), "+33612345678" + ) + self.assertEqual( + tx_model._payplug_format_phone("0033 3 88 12 34 56"), "+33388123456" + ) + # Local numbers are not sent: their country cannot be guessed safely. + self.assertIsNone(tx_model._payplug_format_phone("06 12 34 56 78")) + self.partner.phone = "+33 6 12 34 56 78" + self._create_payplug_payment() + billing = self._last_request()[3]["billing"] + self.assertEqual(billing["mobile_phone_number"], "+33612345678") + + @mute_logger(*LOGGERS) + def test_amount_below_minimum_is_refused_before_any_request(self): + tx, _values = self._create_payplug_payment(amount=0.29) + self.assertEqual(tx.state, "error") + self.assertFalse(self.fake_payplug.state.requests) + + @mute_logger(*LOGGERS) + def test_api_rejection_sets_error(self): + bancontact = self.env.ref("payment.payment_method_bancontact") + bancontact.active = True + # No local check for alternative methods: the (fake) API refuses the amount. + tx, _values = self._create_payplug_payment( + amount=30000.00, payment_method_id=bancontact.id + ) + self.assertEqual(tx.state, "error") + self.assertIn("Invalid parameters", tx.state_message) + + @mute_logger(*LOGGERS) + def test_paid_payment_confirms_transaction(self): + tx, _values = self._create_payplug_payment() + self.fake_payplug.mark_paid(tx.provider_reference, brand="Visa") + tx._payplug_fetch_and_process() + self.assertEqual(tx.state, "done") + self.assertEqual(tx.payment_method_id.code, "visa") + self.assertEqual( + self.fake_payplug.state.requests[-1][:2], + ("GET", f"/v1/payments/{tx.provider_reference}"), + ) + + @mute_logger(*LOGGERS) + def test_unpaid_payment_sets_pending(self): + tx, _values = self._create_payplug_payment() + tx._payplug_fetch_and_process() + self.assertEqual(tx.state, "pending") + + @mute_logger(*LOGGERS) + def test_failed_payment_sets_error(self): + tx, _values = self._create_payplug_payment() + self.fake_payplug.mark_failed( + tx.provider_reference, "card_declined", "Declined." + ) + tx._payplug_fetch_and_process() + self.assertEqual(tx.state, "error") + self.assertIn("card_declined", tx.state_message) + + @mute_logger(*LOGGERS) + def test_timed_out_payment_cancels_transaction(self): + tx, _values = self._create_payplug_payment() + self.fake_payplug.mark_failed(tx.provider_reference, "timeout", "Timeout.") + tx._payplug_fetch_and_process() + self.assertEqual(tx.state, "cancel") + + @mute_logger(*LOGGERS) + def test_customer_cancellation_aborts_payment(self): + tx, _values = self._create_payplug_payment() + tx._payplug_abort_and_process() + self.assertEqual(tx.state, "cancel") + payment = self.fake_payplug.get_payment(tx.provider_reference) + self.assertEqual(payment["failure"]["code"], "aborted") + + @mute_logger(*LOGGERS) + def test_cancellation_of_paid_payment_keeps_it_paid(self): + tx, _values = self._create_payplug_payment() + self.fake_payplug.mark_paid(tx.provider_reference) + tx._payplug_abort_and_process() + self.assertEqual(tx.state, "done") + + @mute_logger(*LOGGERS) + def test_amount_mismatch_sets_error(self): + tx, _values = self._create_payplug_payment() + payment = self.fake_payplug.mark_paid(tx.provider_reference) + payment["amount"] = 100 + tx._payplug_fetch_and_process() + self.assertEqual(tx.state, "error") + + @mute_logger(*LOGGERS) + def test_currency_mismatch_sets_error(self): + tx, _values = self._create_payplug_payment() + payment = self.fake_payplug.mark_paid(tx.provider_reference) + payment["currency"] = "USD" + tx._payplug_fetch_and_process() + self.assertEqual(tx.state, "error") + + @mute_logger(*LOGGERS) + def test_reference_mismatch_sets_error(self): + tx, _values = self._create_payplug_payment() + payment = self.fake_payplug.mark_paid(tx.provider_reference) + payment["metadata"] = {"odoo_reference": "another-reference"} + tx._payplug_fetch_and_process() + self.assertEqual(tx.state, "error") + + @mute_logger(*LOGGERS) + def test_live_payment_on_test_provider_sets_error(self): + tx, _values = self._create_payplug_payment() + payment = self.fake_payplug.mark_paid(tx.provider_reference) + # The fake API hides live payments from test keys: process the data directly. + tx._process("payplug", dict(payment, is_live=True)) + self.assertEqual(tx.state, "error") + + @mute_logger(*LOGGERS) + def test_other_object_than_payment_sets_error(self): + tx, _values = self._create_payplug_payment() + payment = self.fake_payplug.mark_paid(tx.provider_reference) + tx._process("payplug", dict(payment, object="refund")) + self.assertEqual(tx.state, "error") + self.assertIn("not a payment", tx.state_message) + + @mute_logger(*LOGGERS) + def test_payment_id_mismatch_sets_error(self): + tx, _values = self._create_payplug_payment() + payment = self.fake_payplug.mark_paid(tx.provider_reference) + tx._process("payplug", dict(payment, id="pay_another_payment")) + self.assertEqual(tx.state, "error") + self.assertIn("payment id does not match", tx.state_message) + + @mute_logger(*LOGGERS) + def test_missing_payment_page_url_sets_error(self): + payment_data = {"id": "pay_without_url", "object": "payment"} + with patch.object( + type(self.payplug), "_send_api_request", return_value=payment_data + ): + tx, _values = self._create_payplug_payment() + self.assertEqual(tx.state, "error") + self.assertIn("did not return any payment page URL", tx.state_message) + # The payment id is kept, to match the later notifications of the payment. + self.assertEqual(tx.provider_reference, "pay_without_url") + + def test_empty_customer_values_are_not_sent(self): + self.partner.write({"phone": False, "street2": False, "city": "X" * 150}) + self._create_payplug_payment() + billing = self._last_request()[3]["billing"] + self.assertNotIn("mobile_phone_number", billing) + self.assertNotIn("landline_phone_number", billing) + self.assertNotIn("address2", billing) + self.assertEqual(billing["city"], "X" * const.FIELD_MAX_LENGTHS["city"]) + self.assertIsNone(self.env["payment.transaction"]._payplug_format_phone(False)) + + def test_fetch_without_payplug_id_sends_no_request(self): + tx = self._create_transaction("redirect") + with self.assertLogs(TX_LOGGER, "WARNING") as logs: + tx._payplug_fetch_and_process() + self.assertIn("has no Payplug payment id", logs.output[0]) + self.assertEqual(tx.state, "draft") + self.assertFalse(self.fake_payplug.state.requests) + + @mute_logger(*LOGGERS) + def test_cancellation_without_payment_or_once_processed_sends_no_request(self): + draft_tx = self._create_transaction("redirect") # No payment on Payplug. + draft_tx._payplug_abort_and_process() + self.assertEqual(draft_tx.state, "draft") + self.assertFalse(self.fake_payplug.state.requests) + + tx, _values = self._create_payplug_payment(reference="paid-reference") + self.fake_payplug.mark_paid(tx.provider_reference) + tx._payplug_fetch_and_process() + self.assertEqual(tx.state, "done") + requests_count = len(self.fake_payplug.state.requests) + # E.g. the customer opens the cancel URL after paying. + tx._payplug_abort_and_process() + self.assertEqual(tx.state, "done") + self.assertEqual(len(self.fake_payplug.state.requests), requests_count) + + def test_other_providers_transactions_are_not_affected(self): + payplug_like_data = { + "reference": "dummy-reference", + "object": "payment", + "is_paid": True, + "amount": 4990, + "currency": "EUR", + "metadata": {"odoo_reference": "another-reference"}, + } + tx_model = self.env["payment.transaction"] + self.assertEqual( + tx_model._extract_reference("none", payplug_like_data), "dummy-reference" + ) + dummy_tx = self._create_transaction( + "redirect", + provider_id=self.dummy_provider.id, + payment_method_id=self.dummy_provider.payment_method_ids[:1].id, + reference="dummy-reference", + ) + self.assertEqual(dummy_tx._get_specific_rendering_values({}), {}) + self.assertEqual(dummy_tx._extract_amount_data(payplug_like_data), {}) + dummy_tx._apply_updates(payplug_like_data) + self.assertEqual(dummy_tx.state, "draft") + dummy_tx._send_refund_request() + self.assertFalse(self.fake_payplug.state.requests) + + +@tagged("post_install", "-at_install", "payment_payplug") +class TestPayplugRefreshStatus(PayplugCommon): + @mute_logger(*LOGGERS) + def test_refresh_status(self): + tx, _values = self._create_payplug_payment() + self.fake_payplug.mark_paid(tx.provider_reference) + dummy_tx = self._create_transaction( + "redirect", + provider_id=self.dummy_provider.id, + payment_method_id=self.dummy_provider.payment_method_ids[:1].id, + reference="dummy-reference", + ) + action = (tx + dummy_tx).action_payplug_refresh_status() + self.assertEqual(action, {"type": "ir.actions.client", "tag": "soft_reload"}) + self.assertEqual(tx.state, "done") + self.assertEqual(dummy_tx.state, "draft") + # The creation and one reading, for the Payplug transaction only. + self.assertEqual(len(self.fake_payplug.state.requests), 2) + + @mute_logger(*LOGGERS) + def test_refresh_status_reports_api_errors(self): + tx, _values = self._create_payplug_payment() + del self.fake_payplug.state.payments[tx.provider_reference] + action = tx.action_payplug_refresh_status() + self.assertEqual(action["tag"], "display_notification") + self.assertEqual(action["params"]["type"], "danger") + self.assertIn("could not be found", action["params"]["message"]) + self.assertEqual(tx.state, "draft") + + def test_refresh_status_requires_write_access(self): + tx, _values = self._create_payplug_payment() + with self.assertRaises(AccessError): + tx.with_user(self.internal_user).action_payplug_refresh_status() + self.assertEqual(len(self.fake_payplug.state.requests), 1) + + +@tagged("post_install", "-at_install", "payment_payplug") +class TestPayplugRefund(PayplugCommon): + def _create_paid_transaction(self): + tx, _values = self._create_payplug_payment() + self.fake_payplug.mark_paid(tx.provider_reference) + tx._payplug_fetch_and_process() + self.assertEqual(tx.state, "done") + return tx + + @mute_logger(*LOGGERS) + def test_full_refund(self): + tx = self._create_paid_transaction() + tx.action_refund() + refund_tx = tx.child_transaction_ids + self.assertEqual(len(refund_tx), 1) + self.assertEqual(refund_tx.operation, "refund") + self.assertEqual(refund_tx.state, "done") + self.assertAlmostEqual(refund_tx.amount, -self.amount) + self.assertTrue(refund_tx.provider_reference.startswith("re_")) + payment = self.fake_payplug.get_payment(tx.provider_reference) + self.assertTrue(payment["is_refunded"]) + refund = self.fake_payplug.state.refunds[refund_tx.provider_reference] + self.assertEqual(refund["amount"], 4990) + self.assertEqual(refund["metadata"], {"odoo_reference": refund_tx.reference}) + + @mute_logger(*LOGGERS) + def test_partial_refunds(self): + tx = self._create_paid_transaction() + tx.action_refund(amount_to_refund=10.00) + tx.action_refund(amount_to_refund=15.50) + refund_txs = tx.child_transaction_ids + self.assertEqual(len(refund_txs), 2) + self.assertEqual(set(refund_txs.mapped("state")), {"done"}) + self.assertAlmostEqual(sum(refund_txs.mapped("amount")), -25.50) + payment = self.fake_payplug.get_payment(tx.provider_reference) + self.assertEqual(payment["amount_refunded"], 2550) + self.assertFalse(payment["is_refunded"]) + + @mute_logger(*LOGGERS) + def test_refund_above_remaining_amount_sets_error(self): + tx = self._create_paid_transaction() + tx.action_refund(amount_to_refund=40.00) + tx.action_refund(amount_to_refund=20.00) + failed = tx.child_transaction_ids.filtered(lambda t: t.state == "error") + self.assertEqual(len(failed), 1) + self.assertIn("refundable amount", failed.state_message) + + @mute_logger(*LOGGERS) + def test_refund_below_minimum_sets_error(self): + tx = self._create_paid_transaction() + tx.action_refund(amount_to_refund=0.05) + refund_tx = tx.child_transaction_ids + self.assertEqual(refund_tx.state, "error") + self.assertFalse(self.fake_payplug.state.refunds) + + @mute_logger(*LOGGERS) + def test_refund_data_not_matching_the_refund_sets_error(self): + tx = self._create_paid_transaction() + for name, changes in ( + ("object", {"object": "payment"}), + ("payment", {"payment_id": "pay_another_payment"}), + ("id", {"id": "re_another_refund"}), + ("environment", {"is_live": True}), + ): + with self.subTest(name=name): + refund_tx = tx._create_child_transaction(10.0, is_refund=True) + refund = self.fake_payplug.create_refund( + tx.provider_reference, + 1000, + metadata={"odoo_reference": refund_tx.reference}, + ) + refund_tx.provider_reference = refund["id"] + refund_tx._process("payplug", dict(refund, **changes)) + self.assertEqual(refund_tx.state, "error") + self.assertIn( + "environment" if name == "environment" else "does not match", + refund_tx.state_message, + ) + self.assertEqual(refund_tx.provider_reference, refund["id"]) diff --git a/payment_payplug/tests/test_payplug_hooks.py b/payment_payplug/tests/test_payplug_hooks.py new file mode 100644 index 0000000000..bfdce1b0a8 --- /dev/null +++ b/payment_payplug/tests/test_payplug_hooks.py @@ -0,0 +1,35 @@ +# Copyright 2026 Sébastien Berger (sbinfo67) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +from odoo.tests import tagged + +from ..hooks import uninstall_hook +from .common import PayplugCommon + + +@tagged("post_install", "-at_install", "payment_payplug") +class TestPayplugUninstallHook(PayplugCommon): + def test_uninstall_hook(self): + unused_provider = self.payplug.copy( + {"name": "Payplug (unused)", "state": "disabled"} + ) + tx = self._create_transaction("redirect") + self.assertTrue(self.payplug.get_external_id()[self.payplug.id]) + + uninstall_hook(self.env) + + # The provider without transactions is deleted with the module. + self.assertFalse(unused_provider.exists()) + # The provider with transactions is reset and kept, detached from the module, + # so that its transactions stay readable. + self.assertTrue(self.payplug.exists()) + self.assertEqual(self.payplug.code, "none") + self.assertEqual(self.payplug.state, "disabled") + self.assertFalse(self.payplug.is_published) + self.assertFalse(self.payplug.get_external_id()[self.payplug.id]) + self.assertEqual(tx.provider_id, self.payplug) + self.assertFalse( + self.env["payment.provider"] + .with_context(active_test=False) + .search([("code", "=", "payplug")]) + ) diff --git a/payment_payplug/tests/test_payplug_http.py b/payment_payplug/tests/test_payplug_http.py new file mode 100644 index 0000000000..dafba2755b --- /dev/null +++ b/payment_payplug/tests/test_payplug_http.py @@ -0,0 +1,359 @@ +# Copyright 2026 Sébastien Berger (sbinfo67) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +from unittest.mock import patch +from urllib.parse import urlsplit + +from odoo.tests import tagged +from odoo.tools import mute_logger + +from odoo.addons.payment.tests.http_common import PaymentHttpCommon + +from ..controllers.main import PayplugController +from .common import PayplugCommon, PayplugOAuth2Common + +CONTROLLER_LOGGER = "odoo.addons.payment_payplug.controllers.main" +TX_LOGGER = "odoo.addons.payment_payplug.models.payment_transaction" +OTHER_LOGGERS = ( + "odoo.addons.payment.models.payment_provider", + "odoo.addons.payment.models.payment_transaction", +) +LOGGERS = (CONTROLLER_LOGGER, TX_LOGGER, *OTHER_LOGGERS) + + +@tagged("post_install", "-at_install", "payment_payplug") +class TestPayplugHttp(PayplugCommon, PaymentHttpCommon): + def _post_notification(self, data): + url = self._build_url(PayplugController._webhook_url) + return self.url_open(url, json=data) + + def _open_hosted_url(self, tx, key): + """Open the return or cancel URL given to Payplug, without the redirection.""" + payment = self.fake_payplug.get_payment(tx.provider_reference) + url = urlsplit(payment["hosted_payment"][key]) + return self.url_open(f"{url.path}?{url.query}", allow_redirects=False) + + def _paid_tx(self): + tx, _values = self._create_payplug_payment() + self.fake_payplug.mark_paid(tx.provider_reference) + return tx + + # === Payment notifications === # + + @mute_logger(*LOGGERS) + def test_webhook_confirms_payment(self): + tx = self._paid_tx() + payment = self.fake_payplug.get_payment(tx.provider_reference) + response = self._post_notification(self._notification(payment)) + self.assertEqual(response.status_code, 200) + self.assertEqual(tx.state, "done") + + @mute_logger(*LOGGERS) + def test_webhook_forged_body_is_not_trusted(self): + tx, _values = self._create_payplug_payment() + payment = dict(self.fake_payplug.get_payment(tx.provider_reference)) + # The forged body claims the payment is paid; the API says otherwise. + payment.update(is_paid=True, paid_at=1700000000) + response = self._post_notification(payment) + self.assertEqual(response.status_code, 200) + self.assertEqual(tx.state, "pending") + + @mute_logger(*LOGGERS) + def test_webhook_unknown_payment_is_ignored(self): + tx = self._paid_tx() + response = self._post_notification( + {"id": "pay_unknown", "object": "payment", "is_live": False} + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(tx.state, "draft") + # No API request was sent for the unknown payment. + self.assertNotIn( + "/v1/payments/pay_unknown", [r[1] for r in self.fake_payplug.state.requests] + ) + + @mute_logger(*LOGGERS) + def test_webhook_replay_is_idempotent(self): + tx = self._paid_tx() + notification = self._notification( + self.fake_payplug.get_payment(tx.provider_reference) + ) + self._post_notification(notification) + self.assertEqual(tx.state, "done") + last_state_change = tx.last_state_change + messages = self.env["mail.message"].search_count([]) + response = self._post_notification(notification) + self.assertEqual(response.status_code, 200) + self.assertEqual(tx.state, "done") + self.assertEqual(tx.last_state_change, last_state_change) + self.assertEqual(self.env["mail.message"].search_count([]), messages) + + @mute_logger(*LOGGERS) + def test_webhook_invalid_body(self): + url = self._build_url(PayplugController._webhook_url) + response = self.url_open( + url, data="not json", headers={"Content-Type": "application/json"} + ) + self.assertEqual(response.status_code, 400) + + @mute_logger(*LOGGERS) + def test_webhook_api_failure_asks_for_retry(self): + tx = self._paid_tx() + notification = self._notification( + self.fake_payplug.get_payment(tx.provider_reference) + ) + del self.fake_payplug.state.payments[tx.provider_reference] + response = self._post_notification(notification) + self.assertEqual(response.status_code, 503) + self.assertEqual(tx.state, "draft") + + @mute_logger(*LOGGERS) + def test_webhook_body_not_an_object(self): + tx = self._paid_tx() + response = self._post_notification( + [self._notification(self.fake_payplug.get_payment(tx.provider_reference))] + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(tx.state, "draft") + self.assertEqual(len(self.fake_payplug.state.requests), 1) # The creation. + + @mute_logger(CONTROLLER_LOGGER, *OTHER_LOGGERS) + def test_webhook_without_object_id_is_ignored(self): + for notification in ( + {"object": "payment"}, + {"object": "payment", "id": ""}, + {"object": "payment", "id": 42}, + ): + with ( + self.subTest(notification=notification), + self.assertLogs(TX_LOGGER, "WARNING") as logs, + ): + response = self._post_notification(notification) + self.assertEqual(response.status_code, 200) + self.assertIn("without object id", logs.output[0]) + self.assertFalse(self.fake_payplug.state.requests) + + @mute_logger(CONTROLLER_LOGGER, *OTHER_LOGGERS) + def test_webhook_of_unknown_object_type_is_ignored(self): + tx = self._paid_tx() + with self.assertLogs(TX_LOGGER, "INFO") as logs: + response = self._post_notification( + {"id": tx.provider_reference, "object": "installment_plan"} + ) + self.assertEqual(response.status_code, 200) + self.assertIn( + "Ignored Payplug notification of type installment_plan", logs.output[0] + ) + self.assertEqual(tx.state, "draft") + self.assertEqual(len(self.fake_payplug.state.requests), 1) # The creation. + + # === Customer redirections === # + + @mute_logger(*LOGGERS) + def test_return_url_processes_payment(self): + tx = self._paid_tx() + response = self._open_hosted_url(tx, "return_url") + self.assertEqual(response.status_code, 303) + self.assertTrue(response.headers["Location"].endswith("/payment/status")) + self.assertEqual(tx.state, "done") + + @mute_logger(*LOGGERS) + def test_return_url_with_invalid_token_is_ignored(self): + tx = self._paid_tx() + url = self._build_url(PayplugController._return_url) + response = self.url_open( + f"{url}?reference={tx.reference}&access_token=forged", allow_redirects=False + ) + self.assertEqual(response.status_code, 303) + self.assertEqual(tx.state, "draft") + + @mute_logger(*LOGGERS) + def test_cancel_url_aborts_payment(self): + tx, _values = self._create_payplug_payment() + response = self._open_hosted_url(tx, "cancel_url") + self.assertEqual(response.status_code, 303) + self.assertEqual(tx.state, "cancel") + payment = self.fake_payplug.get_payment(tx.provider_reference) + self.assertEqual(payment["failure"]["code"], "aborted") + + def _open_hosted_url_without_payment(self, tx, key): + """Open the return or cancel URL after the payment vanished from Payplug, so + that every API request about it fails.""" + url = urlsplit( + self.fake_payplug.get_payment(tx.provider_reference)["hosted_payment"][key] + ) + del self.fake_payplug.state.payments[tx.provider_reference] + with self.assertLogs(CONTROLLER_LOGGER, "ERROR") as logs: + response = self.url_open(f"{url.path}?{url.query}", allow_redirects=False) + # The customer is still redirected to the status page. + self.assertEqual(response.status_code, 303) + self.assertTrue(response.headers["Location"].endswith("/payment/status")) + self.assertEqual(tx.state, "draft") + return logs.output + + @mute_logger(TX_LOGGER, *OTHER_LOGGERS) + def test_return_url_api_failure_still_redirects(self): + tx = self._paid_tx() + logs = self._open_hosted_url_without_payment(tx, "return_url") + self.assertIn(f"Unable to fetch the Payplug payment of {tx.reference}", logs[0]) + + @mute_logger(TX_LOGGER, *OTHER_LOGGERS) + def test_cancel_url_api_failure_still_redirects(self): + tx, _values = self._create_payplug_payment() + logs = self._open_hosted_url_without_payment(tx, "cancel_url") + self.assertIn( + f"Unable to cancel the Payplug payment of {tx.reference}", logs[0] + ) + # The abortion failed, then the reading of the payment. + self.assertEqual( + [request[0] for request in self.fake_payplug.state.requests], + ["POST", "PATCH", "GET"], + ) + + # === Refund notifications === # + + def _done_tx(self): + tx = self._paid_tx() + tx._payplug_fetch_and_process() + self.assertEqual(tx.state, "done") + return tx + + @mute_logger(*LOGGERS) + def test_refund_from_payplug_portal_creates_refund_transaction(self): + tx = self._done_tx() + refund = self.fake_payplug.create_refund( + tx.provider_reference, 1000 + ) # No metadata. + response = self._post_notification(self._notification(refund)) + self.assertEqual(response.status_code, 200) + refund_tx = tx.child_transaction_ids + self.assertEqual(len(refund_tx), 1) + self.assertEqual(refund_tx.operation, "refund") + self.assertEqual(refund_tx.state, "done") + self.assertAlmostEqual(refund_tx.amount, -10.0) + self.assertEqual(refund_tx.provider_reference, refund["id"]) + + # A replayed notification does not create a second refund. + self._post_notification(self._notification(refund)) + self.assertEqual(len(tx.child_transaction_ids), 1) + + @mute_logger(*LOGGERS) + def test_refund_notification_of_odoo_refund_is_not_duplicated(self): + tx = self._done_tx() + tx.action_refund(amount_to_refund=5.0) + refund_tx = tx.child_transaction_ids + refund = self.fake_payplug.state.refunds[refund_tx.provider_reference] + self._post_notification(self._notification(refund)) + self.assertEqual(tx.child_transaction_ids, refund_tx) + self.assertEqual(refund_tx.state, "done") + + @mute_logger(*LOGGERS) + def test_refund_notification_with_forged_payment_id_is_ignored(self): + tx = self._done_tx() + other_tx = self._done_tx_with_reference("other-reference") + refund = self.fake_payplug.create_refund(tx.provider_reference, 1000) + notification = self._notification(refund) + notification["payment_id"] = ( + other_tx.provider_reference + ) # Points to the wrong payment. + self._post_notification(notification) + self.assertFalse(other_tx.child_transaction_ids) + self.assertFalse(tx.child_transaction_ids) + + def _done_tx_with_reference(self, reference): + tx, _values = self._create_payplug_payment(reference=reference) + self.fake_payplug.mark_paid(tx.provider_reference) + tx._payplug_fetch_and_process() + return tx + + @mute_logger(CONTROLLER_LOGGER, *OTHER_LOGGERS) + def test_refund_notification_of_unknown_payment_is_ignored(self): + for notification in ( + {"id": "re_unknown", "object": "refund", "payment_id": "pay_unknown"}, + {"id": "re_unknown", "object": "refund", "payment_id": 42}, + {"id": "re_unknown", "object": "refund"}, + ): + with ( + self.subTest(notification=notification), + self.assertLogs(TX_LOGGER, "WARNING") as logs, + ): + response = self._post_notification(notification) + self.assertEqual(response.status_code, 200) + self.assertIn("No Payplug transaction found for refund", logs.output[0]) + self.assertFalse(self.fake_payplug.state.requests) + + @mute_logger(CONTROLLER_LOGGER, *OTHER_LOGGERS) + def test_refund_notification_not_matching_its_payment_is_ignored(self): + tx = self._done_tx() + refund = self.fake_payplug.create_refund(tx.provider_reference, 1000) + # Defensive check: the API returns a refund of another payment. + inconsistent_refund = dict(refund, payment_id="pay_another_payment") + with ( + patch.object( + type(tx), "_payplug_get_refund", return_value=inconsistent_refund + ), + self.assertLogs(TX_LOGGER, "WARNING") as logs, + ): + response = self._post_notification(self._notification(refund)) + self.assertEqual(response.status_code, 200) + self.assertIn("does not match its payment", logs.output[0]) + self.assertFalse(tx.child_transaction_ids) + + @mute_logger(*LOGGERS) + def test_refund_notification_completes_odoo_refund_with_lost_response(self): + tx = self._done_tx() + # The refund request reached Payplug, but its response was lost: the refund + # transaction has no Payplug id. + refund_tx = tx._create_child_transaction(10.0, is_refund=True) + refund = self.fake_payplug.create_refund( + tx.provider_reference, + 1000, + metadata={"odoo_reference": refund_tx.reference}, + ) + response = self._post_notification(self._notification(refund)) + self.assertEqual(response.status_code, 200) + self.assertEqual(tx.child_transaction_ids, refund_tx) + self.assertEqual(refund_tx.state, "done") + self.assertEqual(refund_tx.provider_reference, refund["id"]) + + @mute_logger(CONTROLLER_LOGGER, *OTHER_LOGGERS) + def test_refund_notification_of_odoo_refund_in_progress_is_left_to_odoo(self): + tx = self._done_tx() + # Refund requested by Odoo, whose transaction is not committed yet. + refund = self.fake_payplug.create_refund( + tx.provider_reference, + 1000, + metadata={"odoo_reference": f"R-{tx.reference}-in-progress"}, + ) + with self.assertLogs(TX_LOGGER, "INFO") as logs: + response = self._post_notification(self._notification(refund)) + self.assertEqual(response.status_code, 200) + self.assertTrue( + any("is being processed by Odoo" in line for line in logs.output) + ) + self.assertFalse(tx.child_transaction_ids) + + @mute_logger(CONTROLLER_LOGGER, *OTHER_LOGGERS) + def test_refund_notification_of_unconfirmed_payment_is_ignored(self): + tx = self._paid_tx() # Paid on Payplug, not confirmed in Odoo yet. + refund = self.fake_payplug.create_refund(tx.provider_reference, 1000) + with self.assertLogs(TX_LOGGER, "WARNING") as logs: + response = self._post_notification(self._notification(refund)) + self.assertEqual(response.status_code, 200) + self.assertIn(f"unconfirmed transaction {tx.reference}", logs.output[0]) + self.assertFalse(tx.child_transaction_ids) + self.assertEqual(tx.state, "draft") + + +@tagged("post_install", "-at_install", "payment_payplug") +class TestPayplugOAuth2Http(PayplugOAuth2Common, PaymentHttpCommon): + @mute_logger(*LOGGERS) + def test_webhook_with_oauth2(self): + tx, _values = self._create_payplug_payment() + payment = self.fake_payplug.mark_paid(tx.provider_reference) + url = self._build_url(PayplugController._webhook_url) + response = self.url_open(url, json=self._notification(payment)) + self.assertEqual(response.status_code, 200) + self.assertEqual(tx.state, "done") + # The request served by the HTTP worker thread reused the cached token. + self.assertEqual(len(self.fake_payplug.state.token_requests), 1) + self.assertEqual(len(self.fake_payplug.state.requests), 2) diff --git a/payment_payplug/tests/test_payplug_invoice.py b/payment_payplug/tests/test_payplug_invoice.py new file mode 100644 index 0000000000..5fdc2e6ab3 --- /dev/null +++ b/payment_payplug/tests/test_payplug_invoice.py @@ -0,0 +1,64 @@ +# Copyright 2026 Sébastien Berger (sbinfo67) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +from odoo import Command +from odoo.tests import tagged +from odoo.tools import mute_logger + +from odoo.addons.account_payment.tests.common import AccountPaymentCommon + +from .common import PayplugCommon + +LOGGERS = ( + "odoo.addons.payment_payplug.models.payment_transaction", + "odoo.addons.payment.models.payment_provider", + "odoo.addons.payment.models.payment_transaction", +) + + +@tagged("post_install", "-at_install", "payment_payplug") +class TestPayplugInvoice(PayplugCommon, AccountPaymentCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.payplug.journal_id = cls.company_data["default_journal_bank"] + cls.invoice = cls.init_invoice( + "out_invoice", partner=cls.partner, amounts=[120.0], taxes=[], post=True + ) + + @mute_logger(*LOGGERS) + def test_invoice_payment_and_partial_refund(self): + self.assertEqual(self.invoice.amount_total, 120.0) + tx, _values = self._create_payplug_payment( + amount=self.invoice.amount_total, + reference=None, + invoice_ids=[Command.set(self.invoice.ids)], + ) + self.assertIn(self.invoice.name, tx.reference) + self.fake_payplug.mark_paid(tx.provider_reference) + tx._payplug_fetch_and_process() + self.assertEqual(tx.state, "done") + + tx._post_process() + self.assertTrue(tx.payment_id) + self.assertEqual(tx.payment_id.amount, 120.0) + self.assertIn(self.invoice.payment_state, ("in_payment", "paid")) + + # Partial refund from the payment, as with the "Refund" button of the payment. + wizard = ( + self.env["payment.refund.wizard"] + .with_context(active_model="account.payment", active_id=tx.payment_id.id) + .create({}) + ) + self.assertEqual(wizard.support_refund, "partial") + wizard.amount_to_refund = 20.0 + wizard.action_refund() + refund_tx = tx.child_transaction_ids + self.assertEqual(refund_tx.state, "done") + self.assertAlmostEqual(refund_tx.amount, -20.0) + refund_tx._post_process() + self.assertTrue(refund_tx.payment_id) + tx.payment_id.invalidate_recordset(["amount_available_for_refund"]) + self.assertAlmostEqual(tx.payment_id.amount_available_for_refund, 100.0) + payment = self.fake_payplug.get_payment(tx.provider_reference) + self.assertEqual(payment["amount_refunded"], 2000) diff --git a/payment_payplug/tests/test_payplug_oauth.py b/payment_payplug/tests/test_payplug_oauth.py new file mode 100644 index 0000000000..099ed0ffd6 --- /dev/null +++ b/payment_payplug/tests/test_payplug_oauth.py @@ -0,0 +1,508 @@ +# Copyright 2026 Sébastien Berger (sbinfo67) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +import base64 +import threading +from unittest.mock import Mock, patch +from urllib.parse import parse_qs + +import requests + +from odoo.exceptions import AccessError, ValidationError +from odoo.tests import tagged +from odoo.tools import mute_logger + +from .. import const, oauth +from .common import ( + LIVE_CLIENT_ID, + LIVE_CLIENT_SECRET, + TEST_CLIENT_ID, + TEST_CLIENT_SECRET, + TEST_SECRET_KEY, + PayplugOAuth2Common, +) + +LOGGERS = ( + "odoo.addons.payment_payplug.models.payment_provider", + "odoo.addons.payment_payplug.models.payment_transaction", + "odoo.addons.payment.models.payment_provider", + "odoo.addons.payment.models.payment_transaction", +) + +REQUESTS_POST = "odoo.addons.payment_payplug.models.payment_provider.requests.post" + + +@tagged("post_install", "-at_install", "payment_payplug") +class TestPayplugOAuth2(PayplugOAuth2Common): + # === Helpers === # + + def _token_requests(self): + return self.fake_payplug.state.token_requests + + def _api_requests(self): + return self.fake_payplug.state.requests + + def _api_tokens(self): + """Return the bearer tokens of the API requests, in order.""" + return [ + headers["Authorization"].removeprefix("Bearer ") + for _method, _path, headers, _body in self._api_requests() + ] + + def _paid_tx(self): + tx, _values = self._create_payplug_payment() + self.fake_payplug.mark_paid(tx.provider_reference) + return tx + + # === Token request === # + + def test_token_request(self): + tx, _values = self._create_payplug_payment() + self.assertEqual(tx.state, "draft") + self.assertTrue(tx.provider_reference) + + self.assertEqual(len(self._token_requests()), 1) + headers, body = self._token_requests()[0] + credentials = base64.b64encode( + f"{TEST_CLIENT_ID}:{TEST_CLIENT_SECRET}".encode() + ).decode() + self.assertEqual(headers["Authorization"], f"Basic {credentials}") + self.assertEqual(headers["Content-Type"], "application/x-www-form-urlencoded") + self.assertEqual( + parse_qs(body.decode()), + { + "grant_type": ["client_credentials"], + "audience": [const.DEFAULT_TOKEN_AUDIENCE], + }, + ) + + token = self.fake_payplug.issued_tokens()[0] + method, path, api_headers, _body = self._api_requests()[-1] + self.assertEqual((method, path), ("POST", "/v1/payments")) + self.assertEqual(api_headers["Authorization"], f"Bearer {token}") + self.assertEqual(api_headers["PayPlug-Version"], const.API_VERSION) + + def test_default_token_url_and_audience(self): + self.assertEqual( + self.payplug._payplug_get_token_url(), + self.fake_payplug.api_url.replace("/v1/", "/oauth2/token"), + ) + self.env["ir.config_parameter"].sudo().set_param( + const.API_BASE_URL_PARAM, False + ) + self.assertEqual( + self.payplug._payplug_get_token_url(), + "https://api.payplug.com/oauth2/token", + ) + self.assertEqual( + self.payplug._payplug_get_token_audience(), "https://www.payplug.com" + ) + self.env["ir.config_parameter"].sudo().set_param( + const.TOKEN_URL_PARAM, "http://127.0.0.1:1/token" + ) + self.assertEqual( + self.payplug._payplug_get_token_url(), "http://127.0.0.1:1/token" + ) + + @mute_logger(*LOGGERS) + def test_whole_flow_with_oauth2(self): + tx = self._paid_tx() + tx._payplug_fetch_and_process() + self.assertEqual(tx.state, "done") + tx.action_refund(amount_to_refund=10.0) + refund_tx = tx.child_transaction_ids + self.assertEqual(refund_tx.state, "done") + other_tx, _values = self._create_payplug_payment(reference="other-ref") + other_tx._payplug_abort_and_process() + self.assertEqual(other_tx.state, "cancel") + # One token for all the requests: creations, reading, refund, abortion. + self.assertEqual(len(self._token_requests()), 1) + self.assertEqual(len(self._api_requests()), 5) + self.assertEqual( + set(self._api_tokens()), set(self.fake_payplug.issued_tokens()) + ) + + # === Cache === # + + @mute_logger(*LOGGERS) + def test_token_is_cached(self): + tx = self._paid_tx() + tx._payplug_fetch_and_process() + tx._payplug_fetch_and_process() + self._create_payplug_payment(reference="second-ref") + self.assertEqual(len(self._token_requests()), 1) + self.assertEqual(len(set(self._api_tokens())), 1) + + def test_cache_key_does_not_hold_secrets(self): + self.payplug._payplug_get_access_token() + (key,) = oauth.TOKEN_CACHE.keys() + self.assertEqual(key[:2], (self.env.cr.dbname, self.payplug.id)) + self.assertNotIn(TEST_CLIENT_SECRET, repr(key)) + self.assertNotIn(TEST_CLIENT_ID, repr(key)) + + @mute_logger(*LOGGERS) + def test_token_renewed_before_expiry(self): + start = oauth.now() + with patch.object(oauth, "now", return_value=start): + tx = self._paid_tx() + # The token lives 299 s and is renewed 60 s before its expiry. + with patch.object(oauth, "now", return_value=start + 238): + tx._payplug_fetch_and_process() + self.assertEqual(len(self._token_requests()), 1) + with patch.object(oauth, "now", return_value=start + 240): + tx._payplug_fetch_and_process() + self.assertEqual(len(self._token_requests()), 2) + first, second = self.fake_payplug.issued_tokens() + self.assertEqual(self._api_tokens(), [first, first, second]) + + @mute_logger(*LOGGERS) + def test_short_token_renewed_at_half_life(self): + self.fake_payplug.state.token_lifetime = 30 + start = oauth.now() + with patch.object(oauth, "now", return_value=start): + self.payplug._payplug_get_access_token() + with patch.object(oauth, "now", return_value=start + 14): + self.payplug._payplug_get_access_token() + self.assertEqual(len(self._token_requests()), 1) + with patch.object(oauth, "now", return_value=start + 16): + self.payplug._payplug_get_access_token() + self.assertEqual(len(self._token_requests()), 2) + + def test_cache_invalidated_on_credentials_change(self): + self.payplug._payplug_get_access_token() + self.payplug.name = "Payplug (renamed)" + self.payplug._payplug_get_access_token() + self.assertEqual(len(self._token_requests()), 1) + self.payplug.payplug_client_secret = TEST_CLIENT_SECRET + self.payplug._payplug_get_access_token() + self.assertEqual(len(self._token_requests()), 2) + + def test_concurrent_requests_fetch_one_token(self): + cache = oauth.TokenCache(renewal_margin=60) + calls = [] + barrier = threading.Barrier(5) + + def fetch(): + calls.append(1) + threading.Event().wait(0.1) + return f"token-{len(calls)}", 299, oauth.MODE_TEST, "test" + + results = [] + + def worker(): + barrier.wait() + results.append(cache.get_or_fetch(("db", 1, "h"), b"fp", fetch).value) + + threads = [threading.Thread(target=worker) for _i in range(5)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + self.assertEqual(len(calls), 1) + self.assertEqual(results, ["token-1"] * 5) + # Another secret (other fingerprint) gets another token. + self.assertEqual( + cache.get_or_fetch(("db", 1, "h"), b"other", fetch).value, "token-2" + ) + + # === Rejected tokens === # + + @mute_logger(*LOGGERS) + def test_revoked_token_is_renewed_and_request_replayed(self): + tx = self._paid_tx() + self.fake_payplug.revoke_tokens() + tx._payplug_fetch_and_process() + self.assertEqual(tx.state, "done") + self.assertEqual(len(self._token_requests()), 2) + first, second = self.fake_payplug.issued_tokens() + # Creation, rejected reading, replayed reading. + self.assertEqual(self._api_tokens(), [first, first, second]) + self.assertEqual( + [request[1] for request in self._api_requests()[1:]], + [f"/v1/payments/{tx.provider_reference}"] * 2, + ) + + @mute_logger(*LOGGERS) + def test_request_replayed_only_once(self): + self.fake_payplug.state.reject_all_tokens = True + tx, _values = self._create_payplug_payment() + self.assertEqual(tx.state, "error") + self.assertEqual(len(self._token_requests()), 2) + self.assertEqual(len(self._api_requests()), 2) + self.assertFalse(self.fake_payplug.state.payments) + + @mute_logger(*LOGGERS) + def test_api_key_errors_are_not_replayed(self): + self.payplug.write( + {"payplug_auth_method": "api_key", "payplug_secret_key": "sk_test_x"} + ) + self.payplug.payplug_secret_key = "invalid-key" + with self.assertRaises(ValidationError): + self.payplug._send_api_request("GET", "payments/pay_unknown") + self.assertEqual(len(self._api_requests()), 1) + self.assertFalse(self._token_requests()) + + # === Token endpoint errors === # + + @mute_logger(*LOGGERS) + def test_invalid_credentials(self): + wrong_secret = "fake_wrong_secret_8f3a" + self.payplug.payplug_client_secret = wrong_secret + tx, _values = self._create_payplug_payment() + self.assertEqual(tx.state, "error") + self.assertIn("client ID or secret", tx.state_message) + self.assertNotIn(wrong_secret, tx.state_message) + self.assertFalse(self._api_requests()) + self.assertFalse(oauth.TOKEN_CACHE.keys()) + # Nothing is cached after a failure: the next attempt asks again. + with self.assertRaises(ValidationError): + self.payplug._payplug_get_access_token() + self.assertEqual(len(self._token_requests()), 2) + + def test_blank_credentials_are_refused_before_any_request(self): + self.payplug.payplug_client_secret = " " + with self.assertRaisesRegex( + ValidationError, "client ID and secret are not set" + ): + self.payplug._payplug_get_access_token() + self.assertFalse(self._token_requests()) + + @mute_logger(*LOGGERS) + def test_token_request_refused(self): + # The fake server refuses an unknown audience with an `invalid_request` error. + self.env["ir.config_parameter"].sudo().set_param( + const.TOKEN_AUDIENCE_PARAM, "https://audience.example.com" + ) + with self.assertRaisesRegex( + ValidationError, r"refused the access token request \(HTTP 400 invalid_req" + ): + self.payplug._payplug_get_access_token() + self.assertEqual(len(self._token_requests()), 1) + self.assertFalse(oauth.TOKEN_CACHE.keys()) + + @mute_logger(*LOGGERS) + def test_unexpected_token_error_code_is_not_shown(self): + response = Mock(ok=False, status_code=400) + for content in ( + {"error": "Forged error"}, + {"error": 42}, + ["invalid_request"], + ValueError, # Not JSON. + ): + if content is ValueError: + response.json.side_effect = ValueError + else: + response.json.return_value = content + with ( + self.subTest(content=content), + patch(REQUESTS_POST, return_value=response), + self.assertRaises(ValidationError) as error, + ): + self.payplug._payplug_get_access_token() + message = str(error.exception) + self.assertIn("refused the access token request (HTTP 400", message) + self.assertNotIn("Forged", message) + self.assertNotIn("42", message) + self.assertNotIn("invalid_request", message) + + @mute_logger(*LOGGERS) + def test_token_endpoint_unavailable(self): + self.fake_payplug.state.token_failure_status = 503 + tx, _values = self._create_payplug_payment() + self.assertEqual(tx.state, "error") + self.assertIn("unavailable (HTTP 503)", tx.state_message) + self.assertFalse(self._api_requests()) + + @mute_logger(*LOGGERS) + def test_token_endpoint_timeout(self): + with ( + patch(REQUESTS_POST, side_effect=requests.exceptions.Timeout), + self.assertRaisesRegex(ValidationError, "Could not reach"), + ): + self.payplug._payplug_get_access_token() + self.assertFalse(oauth.TOKEN_CACHE.keys()) + + @mute_logger(*LOGGERS) + def test_invalid_token_response(self): + response = Mock(ok=True, status_code=200) + for content in ( + {"token_type": "bearer", "expires_in": 299}, + {"access_token": "abc def", "token_type": "bearer"}, + {"access_token": "abc", "token_type": "mac"}, + ): + response.json.return_value = content + with ( + patch(REQUESTS_POST, return_value=response), + self.assertRaisesRegex(ValidationError, "invalid access token"), + ): + self.payplug._payplug_get_access_token() + response.json.side_effect = ValueError + with ( + patch(REQUESTS_POST, return_value=response), + self.assertRaisesRegex(ValidationError, "invalid access token"), + ): + self.payplug._payplug_get_access_token() + + def test_token_without_lifetime_is_not_reused(self): + response = Mock(ok=True, status_code=200) + response.json.return_value = {"access_token": "opaque", "scope": "test"} + with patch(REQUESTS_POST, return_value=response) as post: + self.assertEqual(self.payplug._payplug_get_access_token(), "opaque") + self.payplug._payplug_get_access_token() + self.assertEqual(post.call_count, 2) + + # === Environment (scope) === # + + @mute_logger(*LOGGERS) + def test_live_key_refused_in_test_mode(self): + self.payplug.write( + { + "payplug_client_id": LIVE_CLIENT_ID, + "payplug_client_secret": LIVE_CLIENT_SECRET, + } + ) + tx, _values = self._create_payplug_payment() + self.assertEqual(tx.state, "error") + self.assertIn("Test environment", tx.state_message) + self.assertFalse(self._api_requests()) + self.assertFalse(oauth.TOKEN_CACHE.keys()) + + @mute_logger(*LOGGERS) + def test_unknown_scope_refused_in_test_mode(self): + self.fake_payplug.state.token_scope = "sandbox" + with self.assertRaisesRegex(ValidationError, "scope: sandbox"): + self.payplug._payplug_get_access_token() + + @mute_logger(*LOGGERS) + def test_test_key_refused_when_enabled(self): + self.payplug.state = "enabled" + with self.assertRaisesRegex(ValidationError, "Live environment"): + self.payplug._payplug_get_access_token() + + @mute_logger(*LOGGERS) + def test_live_key_when_enabled(self): + self.payplug.write( + { + "state": "enabled", + "payplug_client_id": LIVE_CLIENT_ID, + "payplug_client_secret": LIVE_CLIENT_SECRET, + } + ) + tx, _values = self._create_payplug_payment() + self.assertTrue(tx.is_live) + payment = self.fake_payplug.get_payment(tx.provider_reference) + self.assertTrue(payment["is_live"]) + tx._payplug_fetch_and_process() + self.assertEqual(tx.state, "pending") + + @mute_logger(*LOGGERS) + def test_unknown_scope_accepted_when_enabled(self): + # The scope of the Live tokens is not documented: an unknown one is accepted, + # every payment being checked with `is_live`. + self.fake_payplug.state.token_scope = "production" + self.payplug.write( + { + "state": "enabled", + "payplug_client_id": LIVE_CLIENT_ID, + "payplug_client_secret": LIVE_CLIENT_SECRET, + } + ) + self.assertTrue(self.payplug._payplug_get_access_token()) + + @mute_logger(*LOGGERS) + def test_cached_token_checked_against_current_state(self): + self.payplug._payplug_get_access_token() + # As if another worker enabled the provider: this cache is not invalidated. + with patch.object(oauth.TOKEN_CACHE, "invalidate"): + self.payplug.state = "enabled" + self.assertTrue(oauth.TOKEN_CACHE.keys()) + with self.assertRaisesRegex(ValidationError, "Live environment"): + self.payplug._payplug_get_access_token() + self.assertEqual(len(self._token_requests()), 1) + + def test_token_mode(self): + self.assertEqual(oauth.get_token_mode({"test"}), oauth.MODE_TEST) + self.assertEqual(oauth.get_token_mode({"live", "x"}), oauth.MODE_LIVE) + self.assertEqual(oauth.get_token_mode({"test", "live"}), oauth.MODE_MIXED) + self.assertEqual(oauth.get_token_mode(set()), oauth.MODE_UNKNOWN) + token = self.payplug._payplug_get_access_token() + self.assertEqual(oauth.get_token_scopes(token), {"test"}) + self.assertEqual(oauth.get_token_scopes("not-a-jwt", "a b"), {"a", "b"}) + self.assertEqual(oauth.decode_jwt_claims("a.%%%.c"), {}) + + # === Configuration === # + + def test_credentials_required_by_method(self): + with self.assertRaisesRegex(ValidationError, "must be filled"): + self.payplug.payplug_client_secret = False + with self.assertRaisesRegex(ValidationError, "must be filled"): + self.payplug.payplug_auth_method = "api_key" # No secret key. + # A disabled provider needs no credentials. + self.payplug.write({"state": "disabled", "payplug_client_id": False}) + self.payplug.write( + { + "state": "test", + "payplug_auth_method": "api_key", + "payplug_secret_key": TEST_SECRET_KEY, + } + ) + self.assertEqual(self.payplug.state, "test") + + def test_secret_key_prefix_not_checked_with_oauth2(self): + # A legacy live key left in the field does not block the OAuth2 test mode. + self.payplug.payplug_secret_key = "sk_live_whatever" + self.assertEqual(self.payplug.state, "test") + + def test_client_secret_restricted_to_system_group(self): + for field_name in ("payplug_client_id", "payplug_client_secret"): + field = self.env["payment.provider"]._fields[field_name] + self.assertEqual(field.groups, "base.group_system") + with self.assertRaises(AccessError): + self.payplug.with_user(self.internal_user).read([field_name]) + + def test_reset_credentials_clears_oauth2_client(self): + self.payplug.action_reset_credentials() + self.assertFalse(self.payplug.payplug_client_id) + self.assertFalse(self.payplug.payplug_client_secret) + self.assertEqual(self.payplug.state, "disabled") + + @mute_logger(*LOGGERS) + def test_api_key_method_still_works(self): + self.payplug.write( + {"payplug_auth_method": "api_key", "payplug_secret_key": TEST_SECRET_KEY} + ) + tx, _values = self._create_payplug_payment() + self.assertEqual(tx.state, "draft") + self.assertEqual(self._api_tokens(), [TEST_SECRET_KEY]) + self.assertFalse(self._token_requests()) + + # === Logs === # + + def test_no_secret_in_logs(self): + with self.assertLogs(level="DEBUG") as logs: + tx = self._paid_tx() + tx._payplug_fetch_and_process() + tx.action_refund(amount_to_refund=5.0) + self.fake_payplug.revoke_tokens() + tx._payplug_fetch_and_process() + self.payplug.payplug_client_secret = "fake_wrong_secret_8f3a" + with self.assertLogs(level="DEBUG") as error_logs: + failed_tx, _values = self._create_payplug_payment(reference="failed-ref") + self.assertEqual(tx.state, "done") + self.assertEqual(failed_tx.state, "error") + output = "\n".join(logs.output + error_logs.output) + # The logs were captured: the requests and the token renewal are there. + self.assertIn("Obtained a Payplug access token", output) + self.assertIn("Payplug rejected the access token", output) + self.assertIn("/v1/payments", output) + tokens = self.fake_payplug.issued_tokens() + self.assertEqual(len(tokens), 2) + basic = base64.b64encode( + f"{TEST_CLIENT_ID}:{TEST_CLIENT_SECRET}".encode() + ).decode() + for secret in (TEST_CLIENT_SECRET, "fake_wrong_secret_8f3a", basic, *tokens): + self.assertNotIn(secret, output) + for token in tokens: # Not even the signature alone. + self.assertNotIn(token.split(".")[2], output) + self.assertNotIn("Bearer", output) + self.assertNotIn("Basic", output) diff --git a/payment_payplug/views/payment_payplug_templates.xml b/payment_payplug/views/payment_payplug_templates.xml new file mode 100644 index 0000000000..ccd9532c47 --- /dev/null +++ b/payment_payplug/views/payment_payplug_templates.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/payment_payplug/views/payment_provider_views.xml b/payment_payplug/views/payment_provider_views.xml new file mode 100644 index 0000000000..5df189eb0e --- /dev/null +++ b/payment_payplug/views/payment_provider_views.xml @@ -0,0 +1,42 @@ + + + + + Payplug Provider Form + payment.provider + + + + + + + + + + + + + diff --git a/payment_payplug/views/payment_transaction_views.xml b/payment_payplug/views/payment_transaction_views.xml new file mode 100644 index 0000000000..7852bfcf31 --- /dev/null +++ b/payment_payplug/views/payment_transaction_views.xml @@ -0,0 +1,21 @@ + + + + + Payplug Transaction Form + payment.transaction + + + + + +