diff --git a/payment_monetico/README.rst b/payment_monetico/README.rst new file mode 100644 index 0000000000..5823033b5b --- /dev/null +++ b/payment_monetico/README.rst @@ -0,0 +1,79 @@ +========================= +Monetico Payment Acquirer +========================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:91e6c262ca7224be33209224b2b47eb7926fdf58181691b6a4615aded5a21674 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fl10n--france-lightgray.png?logo=github + :target: https://github.com/OCA/l10n-france/tree/14.0/payment_monetico + :alt: OCA/l10n-france +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/l10n-france-14-0/l10n-france-14-0-payment_monetico + :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/l10n-france&target_branch=14.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Payment Acquirer for `Monetico `_ french payment gateway. + + +**Table of contents** + +.. contents:: + :local: + +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 +~~~~~~~ + +* Akretion + +Contributors +~~~~~~~~~~~~ + +* `Akretion `_: + + * Florian Mounier + +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. + +This module is part of the `OCA/l10n-france `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/payment_monetico/__init__.py b/payment_monetico/__init__.py new file mode 100644 index 0000000000..91c5580fed --- /dev/null +++ b/payment_monetico/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/payment_monetico/__manifest__.py b/payment_monetico/__manifest__.py new file mode 100644 index 0000000000..e6dc1f95f9 --- /dev/null +++ b/payment_monetico/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Monetico Payment Acquirer", + "summary": "Accept payments with Monetico secure payment gateway.", + "version": "14.0.1.0.0", + "license": "AGPL-3", + "category": "Accounting", + "author": "Akretion,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/l10n-france", + "depends": ["payment"], + "data": [ + "views/payment_views.xml", + "views/payment_monetico_templates.xml", + "data/payment_acquirer_data.xml", + ], + "images": ["static/description/icon.png"], + "installable": True, +} diff --git a/payment_monetico/controllers/__init__.py b/payment_monetico/controllers/__init__.py new file mode 100644 index 0000000000..12a7e529b6 --- /dev/null +++ b/payment_monetico/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/payment_monetico/controllers/main.py b/payment_monetico/controllers/main.py new file mode 100644 index 0000000000..51698970a5 --- /dev/null +++ b/payment_monetico/controllers/main.py @@ -0,0 +1,73 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging +import pprint + +import werkzeug + +from odoo import http +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class MoneticoController(http.Controller): + _notify_url = "/payment/monetico/webhook/" + _return_url = "/payment/monetico/return/" + + def monetico_validate_data(self, **post): + monetico = request.env["payment.acquirer"].search( + [("provider", "=", "monetico")], limit=1 + ) + values = dict(post) + shasign = values.pop("MAC", False) + if shasign.upper() != monetico._monetico_generate_shasign(values).upper(): + _logger.debug("Monetico: validated data") + return ( + request.env["payment.transaction"] + .sudo() + .form_feedback(post, "monetico") + ) + _logger.warning("Monetico: data are corrupted") + return False + + @http.route( + "/payment/monetico/webhook/", + type="http", + auth="public", + methods=["POST"], + csrf=False, + ) + def monetico_webhook(self, **post): + """Monetico IPN.""" + _logger.info( + "Beginning Monetico IPN form_feedback with post data %s", + pprint.pformat(post), + ) + if not post: + _logger.warning("Monetico: received empty notification; skip.") + else: + self.monetico_validate_data(**post) + return "" + + @http.route( + "/payment/monetico/return", + type="http", + auth="public", + methods=["POST"], + csrf=False, + save_session=False, + ) + def monetico_return(self, **post): + """Monetico DPN.""" + try: + _logger.info( + "Beginning Monetico DPN form_feedback with post data %s", + pprint.pformat(post), + ) + self.monetico_validate_data(**post) + except Exception: + pass + return werkzeug.utils.redirect("/payment/process") diff --git a/payment_monetico/data/payment_acquirer_data.xml b/payment_monetico/data/payment_acquirer_data.xml new file mode 100644 index 0000000000..522af13728 --- /dev/null +++ b/payment_monetico/data/payment_acquirer_data.xml @@ -0,0 +1,40 @@ + + + + + + + Monetico + + monetico + test + + + + 0000001 + company_code + 12345678901234567890123456789012345678P0 + + +

Accept payments with Monetico secure payment gateway.

+
    +
  • Online Payment
  • +
  • eCommerce
  • +
+
+
+ +
+
diff --git a/payment_monetico/models/__init__.py b/payment_monetico/models/__init__.py new file mode 100644 index 0000000000..f65284264d --- /dev/null +++ b/payment_monetico/models/__init__.py @@ -0,0 +1 @@ +from . import payment diff --git a/payment_monetico/models/payment.py b/payment_monetico/models/payment.py new file mode 100644 index 0000000000..5c591fb58d --- /dev/null +++ b/payment_monetico/models/payment.py @@ -0,0 +1,268 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +import json +import logging +from base64 import b64encode +from datetime import datetime +from encodings import hex_codec +from hashlib import sha1 +from hmac import HMAC + +import pytz +from werkzeug import urls + +from odoo import api, fields, models +from odoo.tools.float_utils import float_compare +from odoo.tools.translate import _ + +from odoo.addons.payment.models.payment_acquirer import ValidationError + +from ..controllers.main import MoneticoController + +_logger = logging.getLogger(__name__) + +OUT_DATE_FORMAT = "%d/%m/%Y:%H:%M:%S" +IN_DATE_FORMAT = "%d/%m/%Y_a_%H:%M:%S" + + +class AcquirerMonetico(models.Model): + _inherit = "payment.acquirer" + + provider = fields.Selection( + selection_add=[("monetico", "Monetico")], ondelete={"monetico": "set default"} + ) + monetico_ept = fields.Char( + "EPT number", required_if_provider="monetico", groups="base.group_user" + ) + monetico_company_code = fields.Char( + "Company code", + required_if_provider="monetico", + ) + monetico_secret = fields.Char( + "Secret Key", required_if_provider="monetico", groups="base.group_user" + ) + monetico_test_url = fields.Char( + "Test url", + required_if_provider="monetico", + default="https://p.monetico-services.com/test/paiement.cgi", + ) + monetico_prod_url = fields.Char( + "Production url", + required_if_provider="monetico", + default="https://p.monetico-services.com/paiement.cgi", + ) + monetico_version = fields.Char( + "Interface Version", required_if_provider="monetico", default="3.0" + ) + + def _get_monetico_sign_key(self): + key = self.monetico_secret + hexStrKey = key[0:38] + hexFinal = key[38:40] + "00" + + cca0 = ord(hexFinal[0:1]) + + if cca0 > 70 and cca0 < 97: + hexStrKey += chr(cca0 - 23) + hexFinal[1:2] + elif hexFinal[1:2] == "M": + hexStrKey += hexFinal[0:1] + "0" + else: + hexStrKey += hexFinal[0:2] + + c = hex_codec.Codec() + hexStrKey = c.decode(hexStrKey)[0] + + return hexStrKey + + def _get_monetico_contexte_commande(self, values): + billing_country = values.get("billing_partner_country") + billing_country = billing_country.code if billing_country else None + billing_state = values.get("billing_partner_state") + billing_state = billing_state.code if billing_state else None + + billing = dict( + firstName=values.get("billing_partner_first_name"), + lastName=values.get("billing_partner_last_name"), + mobilePhone=values.get("billing_partner_phone"), + addressLine1=values.get("billing_partner_address"), + city=values.get("billing_partner_city"), + postalCode=values.get("billing_partner_zip"), + country=billing_country, + email=values.get("billing_partner_email"), + stateOrProvince=billing_state, + ) + + # shipping = dict( + # firstName="Ada", + # lastName="Lovelace", + # addressLine1="101 Rue de Roisel", + # city="Y", + # postalCode="80190", + # country="FR", + # email="ada@some.tld", + # phone="+33-612345678", + # shipIndicator="billing_address", + # deliveryTimeframe="two_day", + # firstUseDate="2017-01-25", + # matchBillingAddress=True, + # ) + + # client = dict( + # email="ada@some.tld", + # birthCity="Londre", + # birthPostalCode="W1", + # birthCountry="GB", + # birthdate="2000-12-10", + # ) + + return dict(billing=billing) + + def _monetico_generate_shasign(self, values): + """Generate the shasign for incoming or outgoing communications. + :param dict values: transaction values + :return string: shasign + """ + if self.provider != "monetico": + raise ValidationError(_("Incorrect payment acquirer provider")) + signed_items = dict(sorted(values.items())) + signed_items.pop("MAC", None) + signed_str = "*".join(f"{k}={v}" for k, v in signed_items.items()) + + hmac = HMAC(self._get_monetico_sign_key(), None, sha1) + hmac.update(signed_str.encode("iso8859-1")) + + return hmac.hexdigest() + + def _monetico_form_presign_hook(self, values): + return values + + def monetico_form_generate_values(self, values): + self.ensure_one() + base_url = self.get_base_url() + currency = self.env["res.currency"].sudo().browse(values["currency_id"]) + amount = f"{values['amount']:.2f}{currency.name}" + + lang = values.get("partner_lang") + if lang: + lang = lang.split("_")[0].upper() + + if lang not in ["DE", "EN", "ES", "FR", "IT", "JA", "NL", "PT", "SV"]: + lang = "FR" + + monetico_tx_values = dict( + TPE=self.monetico_ept, + contexte_commande=b64encode( + json.dumps(self._get_monetico_contexte_commande(values)).encode("utf-8") + ).decode("utf-8"), + date=fields.Datetime.now().strftime(OUT_DATE_FORMAT), + lgue=lang, + mail=values.get("partner_email"), + montant=amount, + reference=values["reference"], + societe=self.monetico_company_code, + url_retour_ok=urls.url_join(base_url, MoneticoController._return_url), + url_retour_err=urls.url_join(base_url, MoneticoController._return_url), + version=self.monetico_version, + ) + + monetico_tx_values = self._monetico_form_presign_hook(monetico_tx_values) + + shasign = self._monetico_generate_shasign(monetico_tx_values) + monetico_tx_values["MAC"] = shasign + return monetico_tx_values + + def monetico_get_form_action_url(self): + self.ensure_one() + return ( + self.monetico_prod_url + if self.state == "enabled" + else self.monetico_test_url + ) + + +class TxMonetico(models.Model): + _inherit = "payment.transaction" + + _monetico_valid_tx_status = ["paiement", "payetest"] + _monetico_refused_tx_status = ["annulation"] + + @api.model + def _monetico_form_get_tx_from_data(self, data): + """Given a data dict coming from monetico, verify it and find the related + transaction record.""" + values = dict(data) + shasign = values.pop("MAC", False) + if not shasign: + raise ValidationError(_("Monetico: received data with missing MAC")) + + tx = self.search([("reference", "=", values.get("reference"))]) + if not tx: + error_msg = _( + "Monetico: received data for reference %s; no order found" + ) % values.get("reference") + _logger.error(error_msg) + raise ValidationError(error_msg) + + if shasign.upper() != tx.acquirer_id._monetico_generate_shasign(data).upper(): + + raise ValidationError(_("Monetico: invalid shasign")) + + return tx + + def _monetico_form_get_invalid_parameters(self, data): + invalid_parameters = [] + + amount = data.get("montant", data.get("montantestime")) + # currency and amount should match + amount, currency = amount[:-3], amount[-3:] + if currency != self.currency_id.name: + invalid_parameters.append(("currency", currency, self.currency_id.name)) + + if float_compare(float(amount), self.amount, 2) != 0: + invalid_parameters.append(("amount", amount, "%.2f" % self.amount)) + + return invalid_parameters + + def _monetico_form_validate(self, data): + status = data.get("code-retour").lower() + date = data.get("date") + if date: + try: + date = ( + datetime.strptime(date, IN_DATE_FORMAT) + .astimezone(pytz.utc) + .replace(tzinfo=None) + ) + except Exception: + date = fields.Datetime.now() + data = { + "acquirer_reference": data.get("reference"), + "date": date, + } + + # TODO: add html_3ds status from authentification param + + res = False + if status in self._monetico_valid_tx_status: + msg = f"ref: {self.reference}, got valid response [{status}], set as done." + _logger.info(msg) + data.update(state_message=msg) + self.write(data) + self._set_transaction_done() + res = True + elif status in self._monetico_refused_tx_status: + msg = f"ref: {self.reference}, got refused response [{status}], set as cancel." + data.update(state_message=msg) + self.write(data) + self._set_transaction_cancel() + else: + msg = f"ref: {self.reference}, got unrecognized response [{status}], set as cancel." + data.update(state_message=msg) + self.write(data) + self._set_transaction_cancel() + + _logger.info(msg) + return res diff --git a/payment_monetico/readme/CONTRIBUTORS.rst b/payment_monetico/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..a4d0ad9229 --- /dev/null +++ b/payment_monetico/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Akretion `_: + + * Florian Mounier diff --git a/payment_monetico/readme/DESCRIPTION.rst b/payment_monetico/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..8ae28ec216 --- /dev/null +++ b/payment_monetico/readme/DESCRIPTION.rst @@ -0,0 +1,2 @@ +Payment Acquirer for `Monetico `_ french payment gateway. + diff --git a/payment_monetico/static/description/index.html b/payment_monetico/static/description/index.html new file mode 100644 index 0000000000..de144b4fd7 --- /dev/null +++ b/payment_monetico/static/description/index.html @@ -0,0 +1,423 @@ + + + + + +Monetico Payment Acquirer + + + +
+

Monetico Payment Acquirer

+ + +

Beta License: AGPL-3 OCA/l10n-france Translate me on Weblate Try me on Runboat

+

Payment Acquirer for Monetico french payment gateway.

+

Table of contents

+ +
+

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

+
    +
  • Akretion
  • +
+
+
+

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.

+

This module is part of the OCA/l10n-france project on GitHub.

+

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

+
+
+
+ + diff --git a/payment_monetico/static/src/img/logo.png b/payment_monetico/static/src/img/logo.png new file mode 100644 index 0000000000..1d0a1312f6 Binary files /dev/null and b/payment_monetico/static/src/img/logo.png differ diff --git a/payment_monetico/tests/__init__.py b/payment_monetico/tests/__init__.py new file mode 100644 index 0000000000..6a8339889d --- /dev/null +++ b/payment_monetico/tests/__init__.py @@ -0,0 +1 @@ +from . import test_monetico diff --git a/payment_monetico/tests/test_monetico.py b/payment_monetico/tests/test_monetico.py new file mode 100644 index 0000000000..0435553301 --- /dev/null +++ b/payment_monetico/tests/test_monetico.py @@ -0,0 +1,176 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import json +from base64 import b64encode +from urllib.parse import parse_qs + +from freezegun import freeze_time + +from odoo.tests import tagged + +from odoo.addons.payment.tests.common import PaymentAcquirerCommon + + +@tagged("post_install", "-at_install", "-standard", "external") +class TestPaymentMonetico(PaymentAcquirerCommon): + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + cls.monetico = cls.env.ref("payment_monetico.payment_acquirer_monetico") + cls.monetico.write( + { + "state": "test", + "monetico_ept": "1234567", + "monetico_company_code": "company_1", + "monetico_secret": "12345678901234567890123456789012345678P0", + } + ) + + @classmethod + def _from_qs(cls, qs): + return {k: v[0] for k, v in parse_qs(qs).items()} + + @freeze_time("2024-04-09 14:08:37") + def test_monetico_form_render(self): + self.assertEqual(self.monetico.state, "test", "test without test environment") + + self.env["payment.transaction"].create( + { + "acquirer_id": self.monetico.id, + "amount": 100.0, + "reference": "SO404", + "currency_id": self.currency_euro.id, + "partner_country_id": self.country_france.id, + } + ) + order_ctx = b64encode( + json.dumps( + dict( + billing=dict( + firstName="Norbert", + lastName="Buyer", + mobilePhone="0032 12 34 56 78", + addressLine1="Huge Street 2/543", + city="Sin City", + postalCode="1000", + country="BE", + email="norbert.buyer@example.com", + stateOrProvince=None, + ) + ) + ).encode("utf-8") + ).decode("utf-8") + + self.assertEqual( + self.monetico.render( + "SO404", 100.0, self.currency_euro.id, values=self.buyer_values + ).decode("utf-8"), + """ + + + + + + + + + + + + + + """ # noqa + % order_ctx, + ) + + def test_monetico_form_management_success(self): + self.assertEqual(self.monetico.state, "test", "test without test environment") + # Monetico sample post data + monetico_post_data = self._from_qs( + "TPE=1234567" + "&date=05%2f12%2f2006%5fa%5f11%3a55%3a23" + "&montant=62%2e75EUR" + "&reference=SO100x1" + "&MAC=A384F76DBD3A59B2F7B019F3574589217CAFB2CE" + "&texte-libre=LeTexteLibre" + "&code-retour=paiement" + "&cvx=oui" + "&vld=1208" + "&brand=VI" + "&status3ds=1" + "&numauto=010101" + "&originecb=FRA" + "&bincb=12345678" + "&hpancb=74E94B03C22D786E0F2C2CADBFC1C00B004B7C45" + "&ipclient=127%2e0%2e0%2e1" + "&originetr=FRA" + "&modepaiement=CB" + "&veres=Y" + "&pares=Y" + ) + + tx = self.env["payment.transaction"].create( + { + "amount": 62.75, + "acquirer_id": self.monetico.id, + "currency_id": self.currency_euro.id, + "reference": "SO100x1", + "partner_name": "Norbert Buyer", + "partner_country_id": self.country_france.id, + } + ) + + tx.form_feedback(monetico_post_data, "monetico") + self.assertEqual( + tx.state, "done", "Monetico: validation did not put tx into done state" + ) + self.assertEqual( + tx.acquirer_reference, + "SO100x1", + "Monetico: validation did not update tx id", + ) + + def test_monetico_form_management_error(self): + self.assertEqual(self.monetico.state, "test", "test without test environment") + # Monetico sample post data + monetico_post_data = self._from_qs( + "TPE=9000001" + "&date=05%2f10%2f2011%5fa%5f15%3a33%3a06" + "&montant=1%2e01EUR" + "&reference=SO100x2" + "&MAC=DE96CB30E9239E2D5AE03063799C9B76F3F9FA60" + "&textelibre=Ceci+est+un+test%2c+ne+pas+tenir+compte%2e" + "&code-retour=Annulation" + "&cvx=oui" + "&vld=0912" + "&brand=MC" + "&status3ds=-1" + "&motifrefus=filtrage" + "&originecb=FRA" + "&bincb=12345678" + "&hpancb=764AD24CFABBB818E8A7DC61D4D6B4B89EA837ED" + "&ipclient=10%2e45%2e166%2e76" + "&originetr=inconnue" + "&modepaiement=CB" + "&veres=" + "&pares=" + "&filtragecause=4-" + "&filtragevaleur=FRA-" + ) + tx = self.env["payment.transaction"].create( + { + "amount": 1.01, + "acquirer_id": self.monetico.id, + "currency_id": self.currency_euro.id, + "reference": "SO100x2", + "partner_name": "Norbert Buyer", + "partner_country_id": self.country_france.id, + } + ) + tx.form_feedback(monetico_post_data, "monetico") + self.assertEqual( + tx.state, + "cancel", + ) diff --git a/payment_monetico/views/payment_monetico_templates.xml b/payment_monetico/views/payment_monetico_templates.xml new file mode 100644 index 0000000000..e6451230ad --- /dev/null +++ b/payment_monetico/views/payment_monetico_templates.xml @@ -0,0 +1,34 @@ + + + + + + + diff --git a/payment_monetico/views/payment_views.xml b/payment_monetico/views/payment_views.xml new file mode 100644 index 0000000000..72d4eb4d1a --- /dev/null +++ b/payment_monetico/views/payment_views.xml @@ -0,0 +1,49 @@ + + + + + + + acquirer.form.monetico + payment.acquirer + + + + + + + + + + + + + + + + + diff --git a/setup/payment_monetico/odoo/addons/payment_monetico b/setup/payment_monetico/odoo/addons/payment_monetico new file mode 120000 index 0000000000..03ac706c74 --- /dev/null +++ b/setup/payment_monetico/odoo/addons/payment_monetico @@ -0,0 +1 @@ +../../../../payment_monetico \ No newline at end of file diff --git a/setup/payment_monetico/setup.py b/setup/payment_monetico/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/payment_monetico/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)