From 9eb25f9c0865aa1ab08f340ab6bc33b3b94231c1 Mon Sep 17 00:00:00 2001 From: sbinfo67 Date: Sat, 24 Oct 2026 21:38:29 +0200 Subject: [PATCH 1/2] [ADD] payment_payplug: Payplug payment provider Online payment provider for Payplug (France): hosted payment page, invoice payment from the portal, full and partial refunds, webhook (IPN) with server-side re-reading of the payment, API key or OAuth2 client credentials authentication, EUR only. No external Python dependency (requests only). Co-Authored-By: Claude Sonnet 5.5 --- payment_payplug/README.rst | 372 +++++++++ payment_payplug/__init__.py | 6 + payment_payplug/__manifest__.py | 24 + payment_payplug/const.py | 92 +++ payment_payplug/controllers/__init__.py | 1 + payment_payplug/controllers/main.py | 122 +++ payment_payplug/data/neutralize.sql | 4 + .../data/payment_provider_data.xml | 25 + payment_payplug/hooks.py | 39 + payment_payplug/i18n/fr.po | 291 ++++++++ payment_payplug/i18n/payment_payplug.pot | 311 ++++++++ payment_payplug/models/__init__.py | 2 + payment_payplug/models/payment_provider.py | 496 ++++++++++++ payment_payplug/models/payment_transaction.py | 573 ++++++++++++++ payment_payplug/oauth.py | 168 +++++ payment_payplug/pyproject.toml | 3 + payment_payplug/readme/CONFIGURE.md | 80 ++ payment_payplug/readme/CONTRIBUTORS.md | 1 + payment_payplug/readme/DESCRIPTION.md | 20 + payment_payplug/readme/DEVELOP.md | 22 + payment_payplug/readme/HISTORY.md | 11 + payment_payplug/readme/INSTALL.md | 15 + payment_payplug/readme/ROADMAP.md | 31 + payment_payplug/readme/USAGE.md | 29 + payment_payplug/static/description/icon.png | Bin 0 -> 2418 bytes payment_payplug/static/description/index.html | 704 ++++++++++++++++++ payment_payplug/tests/__init__.py | 4 + payment_payplug/tests/common.py | 77 ++ payment_payplug/tests/fake_payplug.py | 548 ++++++++++++++ payment_payplug/tests/test_payplug.py | 299 ++++++++ payment_payplug/tests/test_payplug_http.py | 205 +++++ payment_payplug/tests/test_payplug_invoice.py | 64 ++ payment_payplug/tests/test_payplug_oauth.py | 462 ++++++++++++ .../views/payment_payplug_templates.xml | 17 + .../views/payment_provider_views.xml | 42 ++ .../views/payment_transaction_views.xml | 21 + 36 files changed, 5181 insertions(+) create mode 100644 payment_payplug/README.rst create mode 100644 payment_payplug/__init__.py create mode 100644 payment_payplug/__manifest__.py create mode 100644 payment_payplug/const.py create mode 100644 payment_payplug/controllers/__init__.py create mode 100644 payment_payplug/controllers/main.py create mode 100644 payment_payplug/data/neutralize.sql create mode 100644 payment_payplug/data/payment_provider_data.xml create mode 100644 payment_payplug/hooks.py create mode 100644 payment_payplug/i18n/fr.po create mode 100644 payment_payplug/i18n/payment_payplug.pot create mode 100644 payment_payplug/models/__init__.py create mode 100644 payment_payplug/models/payment_provider.py create mode 100644 payment_payplug/models/payment_transaction.py create mode 100644 payment_payplug/oauth.py create mode 100644 payment_payplug/pyproject.toml create mode 100644 payment_payplug/readme/CONFIGURE.md create mode 100644 payment_payplug/readme/CONTRIBUTORS.md create mode 100644 payment_payplug/readme/DESCRIPTION.md create mode 100644 payment_payplug/readme/DEVELOP.md create mode 100644 payment_payplug/readme/HISTORY.md create mode 100644 payment_payplug/readme/INSTALL.md create mode 100644 payment_payplug/readme/ROADMAP.md create mode 100644 payment_payplug/readme/USAGE.md create mode 100644 payment_payplug/static/description/icon.png create mode 100644 payment_payplug/static/description/index.html create mode 100644 payment_payplug/tests/__init__.py create mode 100644 payment_payplug/tests/common.py create mode 100644 payment_payplug/tests/fake_payplug.py create mode 100644 payment_payplug/tests/test_payplug.py create mode 100644 payment_payplug/tests/test_payplug_http.py create mode 100644 payment_payplug/tests/test_payplug_invoice.py create mode 100644 payment_payplug/tests/test_payplug_oauth.py create mode 100644 payment_payplug/views/payment_payplug_templates.xml create mode 100644 payment_payplug/views/payment_provider_views.xml create mode 100644 payment_payplug/views/payment_transaction_views.xml 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 0000000000000000000000000000000000000000..117e0eca03bf087f74f6cef2096bdd765eed3f27 GIT binary patch literal 2418 zcmaJ@cTm&W7XF1$5+O)&AwgOYg;k=2A_PeU0tAp|8DIqhBore>szGXi6^T+5Y0?C; zVgodEVUZBp0tzdrus{GsWtAw=APL1}ao>CMW@p~KKh8PxopSHFe|+a$y1Od@wnJ?P z001yY2ip_dmiApB(%ZTh7cjDIppgzg#Q=azz;^)^_O`cfI}x#VUa=?71;zuijCA-hbM=2DWI@4A#8X0#x*popBF)|bqeOuE;z6ZhLy zW|(S8RvI3*hX;JGM^T>z2`WV(r8jRQ%mu{P>&c>YPGYLSEU~-1>(Hkl8;I>?=J*|M zQkw=6_XEqzFdKALUR%*Bwol4YcN&NXorl@@NRn??QwSBRnH5STs{T01ARn+SFhhBh zlMpnc<_D2R@jl>(S7bj@i-bI5nHD7upzTo>4&c;dT$Ib z3(AC#K|+#fHRPiIZa{bF!&Ug}S_SUrXb1ktX4#!F;LTF;z^qbbyrsY3~yRgjGFBc@F(ujBm*PWX}8|$M3hXLpgNSB5YNm znB^~LyqI474TG7_{wN~m0nIx%OM;w3`^@XwQHO>0d7hmqQn6vYaI;1f$pAUk))4_e zFI`3UT(P7DHkIzlY^N}JXew3lNVkN=8lUp^s4yRI{c(w!8ejv-K3HOEbcX^1NX}u| z@Y^}EsHJG%!s}ow_lcqmkH=0mQhBF$0Oia$bvd^(jv~59fdnGoI&N^JJ6^{mWtN+n z^%)+ITrt0OtYFvvcMGrY2e(;2L9zy;Aw{9*_w*T%TU>>_~N)upVWb^-mz{~ri>))ar%$w&oNlJR+8C~GpY0IlsKv9_In(LD%Gq_#CB_?p1qg0?-Q0iw-8s| zv&=rY6!m0eKYJMMrc+8ljC-a!V^rNwS!$uQQgmaO$xdyzs%y^{e;R?Fu{OAyEPHzv z7T|Ku4UvOde5@P=(aeeg+1DolXQB{Ib)KW*z1+f`z;=7Y&WC=3xorIIG^OU$rJHi0 z)A`rVn+I=6T%XV!&}w*I)w4YQs8e)9iMINRGJqPX6*sP#U=os+1i_gf4|wq%pmjxF zh67blSYny$`#HbV?Wb+D9FT)rcc5V1KAkp#x8<>u^VCx4kBra541Nz{aNyVqVUC1ebKUW z;g*?ZV$TZAF_rF+?2S~_Y`1#jg~3CBcSnof8!h!~u8 zCg+}5>n~9B*FK;HY?qtAGe5v&qzxfRy}#DgBog_Ozv^R|Aiir@pI*3#xRhuSs`I$O zd^SsEGtdc;Zu}DT@+#$RiQe&&eC4%OyB{&aXT42vTz$)0Nmi*LZ)RZR4)UZ)9{XL? z74*@~(1p5{yh7vLM(zVLU)cZ9knbjaqY-8}M5K{(o4_;D`UY2LU$E@>`gOx)8RK<2 z+;EGFYJ$Bcn==Bwu~I%7ZK@h$tXG2LQtiZuRSe&%9HyIbX%!G|)qf`;@!U$HG8l;k zTs*x4i*+>`{^b4Pw3GFU@v1^q@7j?qf!cf1TQdHM{WxLLmrMI!T`OEep#`o^Ik+!e$6@6J5Hw6ND46yZg{{JuH z{%bP)GXjQ`a3s-BHRM^L=T-Ym+RRyhT142PGgahkZzLA$kkpVVQGHSS8ld zYHzsQba2(8gMaTI!}|FE9~Ve@Fp45O)z89q%MFr2vhGOIz-{lXyon9tuEaQSNjFnPmiUZK)* zH*2PYN{H>Jg;PG8g$P=|q~Yvo4UPO@TyMz~MEj>aX?lbu&1>%4k?zh-qT-vnBhAJZ z3Kb)?@Tv^En=&9!YYPhgq-%ayh7AXGsQDU(20CN~Nz}+mAi!s@I}9`!A@|qR2zSFm z4wSJgmz!pv(3x>br{?INt?qcy@`9Y-Twb%%mB^yEYtwB3S~1#0w&$SHK36=CkrSsZ h-rJ{!8YhWmUZjsT9cP(_Z~vD7M>|*B1{?qMzX0dRYDNG6 literal 0 HcmV?d00001 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..18f0457a9a --- /dev/null +++ b/payment_payplug/tests/__init__.py @@ -0,0 +1,4 @@ +from . import test_payplug +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..de91dc6f30 --- /dev/null +++ b/payment_payplug/tests/test_payplug.py @@ -0,0 +1,299 @@ +# Copyright 2026 Sébastien Berger (sbinfo67) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +import json +from urllib.parse import parse_qs, urlsplit + +from odoo.exceptions import AccessError, ValidationError +from odoo.tests import tagged +from odoo.tools import mute_logger + +from .. import const +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 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", + ) + + +@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") + + +@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) diff --git a/payment_payplug/tests/test_payplug_http.py b/payment_payplug/tests/test_payplug_http.py new file mode 100644 index 0000000000..827110a416 --- /dev/null +++ b/payment_payplug/tests/test_payplug_http.py @@ -0,0 +1,205 @@ +# Copyright 2026 Sébastien Berger (sbinfo67) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +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 + +LOGGERS = ( + "odoo.addons.payment_payplug.controllers.main", + "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 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") + + # === 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") + + # === 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 + + +@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..139ef18c93 --- /dev/null +++ b/payment_payplug/tests/test_payplug_oauth.py @@ -0,0 +1,462 @@ +# 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) + + @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 + + + + + + From 959f3302e8f91c8c84935a70ee6d6de03163f89b Mon Sep 17 00:00:00 2001 From: sbinfo67 Date: Sat, 24 Oct 2026 22:08:49 +0200 Subject: [PATCH 2/2] [IMP] payment_payplug: add tests to raise coverage Cover the remaining branches: uninstall hook, controller error paths, webhook bodies without object or id, refund notifications (unknown payment, inconsistent refund, lost response, refund in progress, unconfirmed payment), payment and refund consistency checks, missing payment page URL, OAuth2 token errors, non-JSON API errors, the status refresh action, and the other providers left untouched. Co-Authored-By: Claude Opus 5.5 --- payment_payplug/tests/__init__.py | 1 + payment_payplug/tests/test_payplug.py | 198 +++++++++++++++++++- payment_payplug/tests/test_payplug_hooks.py | 35 ++++ payment_payplug/tests/test_payplug_http.py | 160 +++++++++++++++- payment_payplug/tests/test_payplug_oauth.py | 46 +++++ 5 files changed, 436 insertions(+), 4 deletions(-) create mode 100644 payment_payplug/tests/test_payplug_hooks.py diff --git a/payment_payplug/tests/__init__.py b/payment_payplug/tests/__init__.py index 18f0457a9a..64b9528e45 100644 --- a/payment_payplug/tests/__init__.py +++ b/payment_payplug/tests/__init__.py @@ -1,4 +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/test_payplug.py b/payment_payplug/tests/test_payplug.py index de91dc6f30..f1cda9954f 100644 --- a/payment_payplug/tests/test_payplug.py +++ b/payment_payplug/tests/test_payplug.py @@ -2,8 +2,11 @@ # 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 @@ -11,13 +14,23 @@ from .. import const from .common import PayplugCommon +TX_LOGGER = "odoo.addons.payment_payplug.models.payment_transaction" LOGGERS = ( - "odoo.addons.payment_payplug.models.payment_transaction", + 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): @@ -72,6 +85,39 @@ def test_default_api_url(self): "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): @@ -242,6 +288,131 @@ def test_live_payment_on_test_provider_sets_error(self): 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): @@ -297,3 +468,28 @@ def test_refund_below_minimum_sets_error(self): 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 index 827110a416..dafba2755b 100644 --- a/payment_payplug/tests/test_payplug_http.py +++ b/payment_payplug/tests/test_payplug_http.py @@ -1,6 +1,7 @@ # 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 @@ -11,12 +12,13 @@ from ..controllers.main import PayplugController from .common import PayplugCommon, PayplugOAuth2Common -LOGGERS = ( - "odoo.addons.payment_payplug.controllers.main", - "odoo.addons.payment_payplug.models.payment_transaction", +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") @@ -104,6 +106,46 @@ def test_webhook_api_failure_asks_for_retry(self): 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) @@ -133,6 +175,40 @@ def test_cancel_url_aborts_payment(self): 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): @@ -189,6 +265,84 @@ def _done_tx_with_reference(self, 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): diff --git a/payment_payplug/tests/test_payplug_oauth.py b/payment_payplug/tests/test_payplug_oauth.py index 139ef18c93..099ed0ffd6 100644 --- a/payment_payplug/tests/test_payplug_oauth.py +++ b/payment_payplug/tests/test_payplug_oauth.py @@ -259,6 +259,52 @@ def test_invalid_credentials(self): 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