From af762e5787378c5bc7720d6932fed8dc8ac0290f Mon Sep 17 00:00:00 2001 From: Simon Gonzalez Date: Mon, 26 Jun 2023 14:19:39 +0200 Subject: [PATCH] [ADD] account_payment_return_import_pain_ch parse the payment return for pain CH. Adapt the module that does it automatically. --- .../README.rst | 66 +++ .../__init__.py | 2 + .../__manifest__.py | 22 + .../data/payment.return.reason.csv | 49 ++ .../models/__init__.py | 1 + .../models/payment_return.py | 12 + .../readme/CONTRIBUTORS.rst | 3 + .../readme/DESCRIPTION.rst | 5 + .../static/description/index.html | 426 ++++++++++++++++++ .../wizard/pain_ch_parser.py | 109 +++++ .../wizard/payment_return_import.py | 48 ++ .../edi_import_payment_return_ack.py | 29 ++ .../account_payment_return_import_pain_ch | 1 + .../setup.py | 6 + .../odoo/addons/l10n_ch_import_payment_return | 1 + setup/l10n_ch_import_payment_return/setup.py | 6 + 16 files changed, 786 insertions(+) create mode 100644 account_payment_return_import_pain_ch/README.rst create mode 100644 account_payment_return_import_pain_ch/__init__.py create mode 100644 account_payment_return_import_pain_ch/__manifest__.py create mode 100644 account_payment_return_import_pain_ch/data/payment.return.reason.csv create mode 100644 account_payment_return_import_pain_ch/models/__init__.py create mode 100644 account_payment_return_import_pain_ch/models/payment_return.py create mode 100644 account_payment_return_import_pain_ch/readme/CONTRIBUTORS.rst create mode 100644 account_payment_return_import_pain_ch/readme/DESCRIPTION.rst create mode 100644 account_payment_return_import_pain_ch/static/description/index.html create mode 100644 account_payment_return_import_pain_ch/wizard/pain_ch_parser.py create mode 100644 account_payment_return_import_pain_ch/wizard/payment_return_import.py create mode 100644 l10n_ch_import_payment_return/components/edi_import_payment_return_ack.py create mode 120000 setup/account_payment_return_import_pain_ch/odoo/addons/account_payment_return_import_pain_ch create mode 100644 setup/account_payment_return_import_pain_ch/setup.py create mode 120000 setup/l10n_ch_import_payment_return/odoo/addons/l10n_ch_import_payment_return create mode 100644 setup/l10n_ch_import_payment_return/setup.py diff --git a/account_payment_return_import_pain_ch/README.rst b/account_payment_return_import_pain_ch/README.rst new file mode 100644 index 000000000..0ca3a325d --- /dev/null +++ b/account_payment_return_import_pain_ch/README.rst @@ -0,0 +1,66 @@ +===================================== +Account Payment Return Import PAIN CH +===================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:54745c2c4ef3d0388d46f01beed0e55985ccba0c8fcb7de012440749ed49d01b + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Mature-brightgreen.png + :target: https://odoo-community.org/page/development-status + :alt: Mature +.. |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-CompassionCH%2Fl10n_switzerland-lightgray.png?logo=github + :target: https://github.com/CompassionCH/l10n_switzerland/tree/14.0/account_payment_return_import_pain_ch + :alt: CompassionCH/l10n_switzerland + +|badge1| |badge2| |badge3| + +This module allow you to parse and import pain002 files. It will automatically cancel payment and reconciliation for rejected payment. + +** Features list :** + * import pain002 CH files + * parse pain002 CH files + +**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 +~~~~~~~ + +* Compassion CH + +Contributors +~~~~~~~~~~~~ + +* Simon Gonzalez +* Marco Monzione +* Benoît Schopfer + +Maintainers +~~~~~~~~~~~ + +This module is part of the `CompassionCH/l10n_switzerland `_ project on GitHub. + +You are welcome to contribute. diff --git a/account_payment_return_import_pain_ch/__init__.py b/account_payment_return_import_pain_ch/__init__.py new file mode 100644 index 000000000..9b4296142 --- /dev/null +++ b/account_payment_return_import_pain_ch/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/account_payment_return_import_pain_ch/__manifest__.py b/account_payment_return_import_pain_ch/__manifest__.py new file mode 100644 index 000000000..71a21396e --- /dev/null +++ b/account_payment_return_import_pain_ch/__manifest__.py @@ -0,0 +1,22 @@ +# Copyright 2019 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Account Payment Return Import PAIN CH", + "summary": """ + This addon allows to import payment returns from PAIN CH.""", + "version": "14.0.1.0.0", + "development_status": "Mature", + "license": "AGPL-3", + "author": "Odoo Community Association (OCA),Compassion CH", + "website": "https://github.com/OCA/l10n-switzerland", + "depends": [ + # OCA/account-payment + "account_payment_return", + "account_payment_return_import", + "account_payment_return_import_iso20022", + # OCA/bank-payment + "account_payment_order", + ], + "data": ["data/payment.return.reason.csv"], +} diff --git a/account_payment_return_import_pain_ch/data/payment.return.reason.csv b/account_payment_return_import_pain_ch/data/payment.return.reason.csv new file mode 100644 index 000000000..38c656526 --- /dev/null +++ b/account_payment_return_import_pain_ch/data/payment.return.reason.csv @@ -0,0 +1,49 @@ +id,code,name +AC01,AC01,IncorrectAccountNumber +AC04,AC04,ClosedAccountNumber +AC06,AC06,BlockedAccount +AG01,AG01,TransactionForbidden +AG02,AG02,InvalidBankOperationCode +AM02,AM02,NotAllowedAmount +AM03,AM03,NotAllowedCurrency +AM05,AM05,Duplication +BE01,BE01,InconsistenWithEndCustomer +BE04,BE04,MissingCreditorAddress +BE05,BE05,UnrecognisedInitiatingParty +BE06,BE06,UnknownEndCustomer +BE07,BE07,MissingDebtorAddress +DT01,DT01,InvalidDate +FF01,FF01,InvalidFileFormat +MD01,MD01,NoMandate +MD02,MD02,MissingMandatoryInformationInMandate +MD07,MD07,EndCustomerDeceased +MD08,MD08,NoMandateServiceByAgent +MD09,MD09,NoMandateServiceOnCustomer +MD10,MD10,NoMandateServiceForSpecified +MD11,MD11,UnrecognisedAgent +MD12,MD12,NotUniqueMandateReference +MD13,MD13,IncorrectCustomerAuthentication +MD14,MD14,IncorrectAgent +MD15,MD15,IncorrectCurrency +MD16,MD16,RequestedByCustomer +MD17,MD17,RequestedByInitiatingParty +MD18,MD18,RequestedByInitiatingPartyAndCustomer +MD19,MD19,MandateCancelledDueToEarlySettlement +MD20,MD20,MandateExpired +MD21,MD21,MandateCancelledDueToFraud +MD22,MD22,MandateInitiationCancelled +MD23,MD23,MandateAmendmentCancelled +MS02,MS02,NotSpecifiedReasonCustomerGenerated +MS03,MS03,NotSpecifiedReasonAgentGenerated +NARR,NARR,Narrative +RC01,RC01,BankIdentifierIncorrect +RF01,RF01,NotUniqueTransactionReference +RR01,RR01,MissingDebtorAccountOrIdentification +RR02,RR02,MissingDebtorNameOrAddress +RR03,RR03,MissingCreditorNameOrAddress +RR04,RR04,RegulatoryReason +SL01,SL01,SpecificServiceOfferedByDebtorAgent +SL11,SL11,CreditorNotOnWhitelistOfDebtor +SL12,SL12,CreditorOnBlacklistOfDebtor +SL13,SL13,MaximumNumberOfDirectDebitTransactionsExceeded +SL14,SL14,MaximumDirectDebitTransactionAmountExceeded diff --git a/account_payment_return_import_pain_ch/models/__init__.py b/account_payment_return_import_pain_ch/models/__init__.py new file mode 100644 index 000000000..43bcbbf66 --- /dev/null +++ b/account_payment_return_import_pain_ch/models/__init__.py @@ -0,0 +1 @@ +from . import payment_return diff --git a/account_payment_return_import_pain_ch/models/payment_return.py b/account_payment_return_import_pain_ch/models/payment_return.py new file mode 100644 index 000000000..3bd8dbdcd --- /dev/null +++ b/account_payment_return_import_pain_ch/models/payment_return.py @@ -0,0 +1,12 @@ +# Copyright 2020 Emanuel Cino +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class PaymentReturn(models.Model): + _inherit = "payment.return" + + payment_order_id = fields.Many2one( + "account.payment.order", "Originating Payment order" + ) diff --git a/account_payment_return_import_pain_ch/readme/CONTRIBUTORS.rst b/account_payment_return_import_pain_ch/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..d104f927d --- /dev/null +++ b/account_payment_return_import_pain_ch/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* Simon Gonzalez +* Marco Monzione +* Benoît Schopfer diff --git a/account_payment_return_import_pain_ch/readme/DESCRIPTION.rst b/account_payment_return_import_pain_ch/readme/DESCRIPTION.rst new file mode 100644 index 000000000..80ea443e7 --- /dev/null +++ b/account_payment_return_import_pain_ch/readme/DESCRIPTION.rst @@ -0,0 +1,5 @@ +This module allow you to parse and import pain002 files. It will automatically cancel payment and reconciliation for rejected payment. + +** Features list :** + * import pain002 CH files + * parse pain002 CH files diff --git a/account_payment_return_import_pain_ch/static/description/index.html b/account_payment_return_import_pain_ch/static/description/index.html new file mode 100644 index 000000000..6561bc386 --- /dev/null +++ b/account_payment_return_import_pain_ch/static/description/index.html @@ -0,0 +1,426 @@ + + + + + + +Account Payment Return Import PAIN CH + + + +
+

Account Payment Return Import PAIN CH

+ + +

Mature License: AGPL-3 CompassionCH/l10n_switzerland

+

This module allow you to parse and import pain002 files. It will automatically cancel payment and reconciliation for rejected payment.

+
+
** Features list :**
+
    +
  • import pain002 CH files
  • +
  • parse pain002 CH files
  • +
+
+
+

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

+
    +
  • Compassion CH
  • +
+
+
+

Contributors

+
    +
  • Simon Gonzalez
  • +
  • Marco Monzione
  • +
  • Benoît Schopfer
  • +
+
+
+

Maintainers

+

This module is part of the CompassionCH/l10n_switzerland project on GitHub.

+

You are welcome to contribute.

+
+
+
+ + diff --git a/account_payment_return_import_pain_ch/wizard/pain_ch_parser.py b/account_payment_return_import_pain_ch/wizard/pain_ch_parser.py new file mode 100644 index 000000000..aee2944fa --- /dev/null +++ b/account_payment_return_import_pain_ch/wizard/pain_ch_parser.py @@ -0,0 +1,109 @@ +import base64 +import re + +from lxml import etree + +from odoo.addons.account_payment_return_import_iso20022.wizard.pain_parser import ( + PainParser, +) + + +class PainCHParser(PainParser): + _name = "account.pain002.parser" + _description = "Parse pain002 CH" + + @staticmethod + def validate_status(file): + """Validate the status of the pain 002""" + root, ns = PainCHParser.parse_xml(base64.b64decode(file)) + STATUS_XML_NODE = "./ns:CstmrPmtStsRpt/ns:OrgnlGrpInfAndSts/ns:GrpSts" + found_node = root.xpath(STATUS_XML_NODE, namespaces={"ns": ns}) + if found_node: + return_status = found_node[0].text + if return_status in ["ACTC", "ACCP"]: + return True + elif return_status == "PART": + return True + elif return_status == "RJCT": + info_status = root.xpath( + "./ns:CstmrPmtStsRpt/ns:OrgnlGrpInfAndSts/ns:StsRsnInf/ns:AddtlInf", + namespaces={"ns": ns}, + ) + raise ValueError(f"File rejected !\nReason: {info_status[0].text}") + else: + raise ValueError("Status not known by Pain Parser CH") + else: + raise ValueError("Status Node not found") + + def parse_transaction(self, ns, node, transaction): + """Parse transaction (entry) node.""" + super().parse_transaction(ns, node, transaction) + self.add_value_from_node(ns, node, "./ns:TxSts", transaction, "concept") + return transaction + + def check_version(self, ns, root): + """Validate validity of pain 002 CH Report file.""" + # Check wether it is SEPA Direct Debit Unpaid Report at all: + re_pain = re.compile(r"(^http://www.six-interbank-clearing.com/de/pain.)") + if not re_pain.search(ns): + raise ValueError("no pain: " + ns) + # Check wether version 002.001.03.ch.02: + re_pain_version = re.compile( + r"(^urn:iso:std:iso:20022:tech:xsd:pain.002.001.03" + r"|pain.002.001.03.ch.02)" + ) + if not re_pain_version.search(ns): + raise ValueError("no PAIN.002.001.03.ch.02: " + ns) + # Check GrpHdr element: + root_0_0 = root[0][0].tag[len(ns) + 2 :] # strip namespace + if root_0_0 != "GrpHdr": + raise ValueError("expected GrpHdr, got: " + root_0_0) + + @staticmethod + def parse_xml(payment_return): + try: + root = etree.fromstring( + payment_return, parser=etree.XMLParser(recover=True) + ) + except etree.XMLSyntaxError: + # ABNAmro is known to mix up encodings + root = etree.fromstring( + payment_return.decode("iso-8859-15").encode("utf-8") + ) + if root is None: + raise ValueError("Not a valid xml file, or not an xml file at all.") + return root, root.tag[1 : root.tag.index("}")] + + def parse(self, payment_return): + """Parse a pain.002.001.03 file.""" + root, ns = self.parse_xml(payment_return) + self.check_version(ns, root) + payment_returns = [] + for node in root: + payment_return = super().parse_payment_return(ns, node) + + if not payment_return["transactions"]: + self.add_value_from_node( + ns, + node, + "./ns:OrgnlGrpInfAndSts/ns:StsRsnInf/ns:Rsn/ns:Cd", + payment_return, + "error_code", + ) + self.add_value_from_node( + ns, + node, + "./ns:OrgnlGrpInfAndSts/ns:StsRsnInf/ns:AddtlInf", + payment_return, + "error", + ) + + self.add_value_from_node( + ns, + node, + "./ns:OrgnlGrpInfAndSts/ns:OrgnlMsgId", + payment_return, + "order_name", + ) + payment_returns.append(payment_return) + return payment_returns diff --git a/account_payment_return_import_pain_ch/wizard/payment_return_import.py b/account_payment_return_import_pain_ch/wizard/payment_return_import.py new file mode 100644 index 000000000..006c10aed --- /dev/null +++ b/account_payment_return_import_pain_ch/wizard/payment_return_import.py @@ -0,0 +1,48 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from odoo import api, models + +from .pain_ch_parser import PainCHParser + +_logger = logging.getLogger(__name__) + + +class PaymentReturnImport(models.TransientModel): + _inherit = "payment.return.import" + + def _complete_parsed_data(self, payment_returns): + """Complete CH Pain parser data""" + new_payment_returns = [] + for payment_return in payment_returns: + for transaction in payment_return["transactions"]: + move = self.env["account.move"].browse(int(transaction["reference"])) + transaction["amount"] = move.amount_total + transaction["partner_id"] = move.partner_id.id + transaction["reference"] = move.ref + payment_order = self.env["account.payment.order"].search( + [("name", "=", payment_return.pop("order_name"))] + ) + payment_return["payment_order_id"] = payment_order.id + if "account_number" not in payment_return: + payment_return[ + "account_number" + ] = payment_order.company_partner_bank_id.acc_number + payment_return["journal_id"] = payment_order.journal_id.id + new_payment_returns.append(payment_return) + return new_payment_returns + + @api.model + def _parse_file(self, data_file): + """Parse a PAIN.002.001.03 XML file.""" + try: + _logger.debug("Try parsing with Direct Debit Unpaid Report.") + return self._complete_parsed_data(PainCHParser().parse(data_file)) + except ValueError: + # Not a valid file, returning super will call next candidate: + _logger.debug( + "Payment return file was not a Direct Debit Unpaid " "Report file.", + exc_info=True, + ) + return super()._parse_file(data_file) diff --git a/l10n_ch_import_payment_return/components/edi_import_payment_return_ack.py b/l10n_ch_import_payment_return/components/edi_import_payment_return_ack.py new file mode 100644 index 000000000..0bcd0b0d5 --- /dev/null +++ b/l10n_ch_import_payment_return/components/edi_import_payment_return_ack.py @@ -0,0 +1,29 @@ +# Copyright 2023 Compassion CH (https://www.compassion.ch) +# @author: Simon Gonzalez +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.account_payment_return_import_pain_ch.wizard.pain_ch_parser import ( + PainCHParser, +) +from odoo.addons.component.core import Component + + +class EDIExchangeProcessPaymentReturn(Component): + """ACK for payment order pain002.01.03.CH""" + + _name = "edi.input.payment.ch.ack.process" + _inherit = "edi.component.input.mixin" + _backend_type = "sftp_pay_ord_ack" + _usage = "input.process" + + def process(self): + origin_output = self.exchange_record.parent_id + payment_order = self.env[origin_output.model].browse( + self.exchange_record.parent_id.res_id + ) + try: + PainCHParser.validate_status(self.exchange_record.exchange_file) + except Exception as e: + payment_order.action_cancel() + raise e + payment_order.generated2uploaded() diff --git a/setup/account_payment_return_import_pain_ch/odoo/addons/account_payment_return_import_pain_ch b/setup/account_payment_return_import_pain_ch/odoo/addons/account_payment_return_import_pain_ch new file mode 120000 index 000000000..3e04c2838 --- /dev/null +++ b/setup/account_payment_return_import_pain_ch/odoo/addons/account_payment_return_import_pain_ch @@ -0,0 +1 @@ +../../../../account_payment_return_import_pain_ch \ No newline at end of file diff --git a/setup/account_payment_return_import_pain_ch/setup.py b/setup/account_payment_return_import_pain_ch/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/account_payment_return_import_pain_ch/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/l10n_ch_import_payment_return/odoo/addons/l10n_ch_import_payment_return b/setup/l10n_ch_import_payment_return/odoo/addons/l10n_ch_import_payment_return new file mode 120000 index 000000000..108df84a9 --- /dev/null +++ b/setup/l10n_ch_import_payment_return/odoo/addons/l10n_ch_import_payment_return @@ -0,0 +1 @@ +../../../../l10n_ch_import_payment_return \ No newline at end of file diff --git a/setup/l10n_ch_import_payment_return/setup.py b/setup/l10n_ch_import_payment_return/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/l10n_ch_import_payment_return/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)