diff --git a/account_banking_international_credit_transfer/README.rst b/account_banking_international_credit_transfer/README.rst new file mode 100644 index 00000000000..8b41917969c --- /dev/null +++ b/account_banking_international_credit_transfer/README.rst @@ -0,0 +1,117 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +============================================= +Account Banking International Credit Transfer +============================================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:552a1c87d4179d24906b892f62427effc99400ea15a83bd46a6c89506e231425 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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-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%2Fbank--payment-lightgray.png?logo=github + :target: https://github.com/OCA/bank-payment/tree/17.0/account_banking_international_credit_transfer + :alt: OCA/bank-payment +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/bank-payment-17-0/bank-payment-17-0-account_banking_international_credit_transfer + :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/bank-payment&target_branch=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Create PAIN files for Direct Debit + +Module to export international payment orders in PAIN XML file format. + +PAIN (PAyment INitiation) is the new european standard for +Customer-to-Bank payment instructions. This module implements +International Credit Transfer, more specifically PAIN version +001.001.03. It is part of the ISO 20022 standard, available on +https://www.iso20022.org. + +The Implementation Guidelines for International Credit Transfer +published by the European Payments Council +(https://www.europeanpaymentscouncil.eu) use PAIN version 001.001.03, so +it's probably the version of PAIN that you should try first. + +**Table of contents** + +.. contents:: + :local: + +Installation +============ + +This module depends on : \* account_banking_pain_base + +This module is part of the OCA/bank-payment suite. + +Configuration +============= + +- Create a Payment Mode dedicated to International Credit Transfer. +- Select the Payment Method *International Credit Transfer* (which is + automatically created upon module installation). +- Check that this payment method uses the proper version of PAIN. + +Usage +===== + +In the menu *Invoicing/Accounting > Vendors > Payment Orders*, create a +new payment order and select the Payment Mode dedicated to International +Credit Transfer that you created during the configuration step. + +Known issues / Roadmap +====================== + +- Add support for additional PAIN versions + +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 +* Tecnativa +* Camptocamp SA + +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/bank-payment `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/account_banking_international_credit_transfer/__init__.py b/account_banking_international_credit_transfer/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/account_banking_international_credit_transfer/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/account_banking_international_credit_transfer/__manifest__.py b/account_banking_international_credit_transfer/__manifest__.py new file mode 100644 index 00000000000..86fff746b8c --- /dev/null +++ b/account_banking_international_credit_transfer/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2010-2020 Akretion (www.akretion.com) +# Copyright 2016 Tecnativa - Antonio Espinosa +# Copyright 2016-2022 Tecnativa - Pedro M. Baeza +# Copyright 2025 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) + +{ + "name": "Account Banking International Credit Transfer", + "summary": "Create PAIN XML files for International Credit Transfers", + "version": "17.0.1.0.0", + "license": "AGPL-3", + "author": "Akretion, Tecnativa, Odoo Community Association (OCA), Camptocamp SA", + "website": "https://github.com/OCA/bank-payment", + "category": "Banking addons", + "depends": ["account_banking_pain_base"], + "data": [ + "data/account_payment_method.xml", + "views/res_partner_bank_views.xml", + ], + "installable": True, +} diff --git a/account_banking_international_credit_transfer/data/account_payment_method.xml b/account_banking_international_credit_transfer/data/account_payment_method.xml new file mode 100644 index 00000000000..67f6ac574a1 --- /dev/null +++ b/account_banking_international_credit_transfer/data/account_payment_method.xml @@ -0,0 +1,11 @@ + + + + International Credit Transfer + international_credit_transfer + outbound + + pain.001.001.03 + + + diff --git a/account_banking_international_credit_transfer/data/pain.001.001.03.xsd b/account_banking_international_credit_transfer/data/pain.001.001.03.xsd new file mode 100644 index 00000000000..f414be34de3 --- /dev/null +++ b/account_banking_international_credit_transfer/data/pain.001.001.03.xsd @@ -0,0 +1,1457 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/account_banking_international_credit_transfer/demo/international_credit_transfer_demo.xml b/account_banking_international_credit_transfer/demo/international_credit_transfer_demo.xml new file mode 100644 index 00000000000..ed7098eb09a --- /dev/null +++ b/account_banking_international_credit_transfer/demo/international_credit_transfer_demo.xml @@ -0,0 +1,34 @@ + + + + + International Credit Transfer + + variable + + + + + + + + + + + + + + + + + + + + + diff --git a/account_banking_international_credit_transfer/i18n/account_banking_international_credit_transfer.pot b/account_banking_international_credit_transfer/i18n/account_banking_international_credit_transfer.pot new file mode 100644 index 00000000000..ef59c457b72 --- /dev/null +++ b/account_banking_international_credit_transfer/i18n/account_banking_international_credit_transfer.pot @@ -0,0 +1,90 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * account_banking_international_credit_transfer +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-10-10 10:03+0000\n" +"PO-Revision-Date: 2025-10-10 10:03+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: account_banking_international_credit_transfer +#: model:ir.model,name:account_banking_international_credit_transfer.model_res_partner_bank +msgid "Bank Accounts" +msgstr "" + +#. module: account_banking_international_credit_transfer +#. odoo-python +#: code:addons/account_banking_international_credit_transfer/models/account_payment_order.py:0 +#, python-format +msgid "" +"Bank account is missing on the bank payment line of partner '{partner}' " +"(reference '{reference}')." +msgstr "" + +#. module: account_banking_international_credit_transfer +#: model:ir.model.fields,help:account_banking_international_credit_transfer.field_account_setup_bank_manual_config__intermediary_bank_id +#: model:ir.model.fields,help:account_banking_international_credit_transfer.field_res_partner_bank__intermediary_bank_id +msgid "Bank used as intermediary for international payments" +msgstr "" + +#. module: account_banking_international_credit_transfer +#: model:ir.model.fields,field_description:account_banking_international_credit_transfer.field_account_setup_bank_manual_config__intermediary_bank_id +#: model:ir.model.fields,field_description:account_banking_international_credit_transfer.field_res_partner_bank__intermediary_bank_id +msgid "Intermediary Bank" +msgstr "" + +#. module: account_banking_international_credit_transfer +#. odoo-python +#: code:addons/account_banking_international_credit_transfer/models/account_payment_order.py:0 +#, python-format +msgid "" +"Intermediary bank is missing on the recipient bank account of partner " +"'{partner}' (reference '{reference}')." +msgstr "" + +#. module: account_banking_international_credit_transfer +#: model:account.payment.method,name:account_banking_international_credit_transfer.international_credit_transfer +msgid "International Credit Transfer" +msgstr "" + +#. module: account_banking_international_credit_transfer +#: model:ir.model.fields,field_description:account_banking_international_credit_transfer.field_account_payment_method__pain_version +msgid "PAIN Version" +msgstr "" + +#. module: account_banking_international_credit_transfer +#. odoo-python +#: code:addons/account_banking_international_credit_transfer/models/account_payment_order.py:0 +#, python-format +msgid "PAIN version '%s' is not supported for international credit transfers." +msgstr "" + +#. module: account_banking_international_credit_transfer +#. odoo-python +#: code:addons/account_banking_international_credit_transfer/models/account_payment_order.py:0 +#, python-format +msgid "PAIN version must be set on the payment method." +msgstr "" + +#. module: account_banking_international_credit_transfer +#: model:ir.model,name:account_banking_international_credit_transfer.model_account_payment_method +msgid "Payment Methods" +msgstr "" + +#. module: account_banking_international_credit_transfer +#: model:ir.model,name:account_banking_international_credit_transfer.model_account_payment_order +msgid "Payment Order" +msgstr "" + +#. module: account_banking_international_credit_transfer +#: model:ir.model.fields.selection,name:account_banking_international_credit_transfer.selection__account_payment_method__pain_version__pain_001_001_03 +msgid "pain.001.001.03 (recommended for credit transfer)" +msgstr "" diff --git a/account_banking_international_credit_transfer/i18n/fr.po b/account_banking_international_credit_transfer/i18n/fr.po new file mode 100644 index 00000000000..e5770936e74 --- /dev/null +++ b/account_banking_international_credit_transfer/i18n/fr.po @@ -0,0 +1,92 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * account_banking_international_credit_transfer +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-10-10 12:31+0000\n" +"PO-Revision-Date: 2025-10-10 12:31+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: account_banking_international_credit_transfer +#: model:ir.model,name:account_banking_international_credit_transfer.model_res_partner_bank +msgid "Bank Accounts" +msgstr "Comptes bancaires" + +#. module: account_banking_international_credit_transfer +#. odoo-python +#: code:addons/account_banking_international_credit_transfer/models/account_payment_order.py:0 +#, python-format +msgid "" +"Bank account is missing on the bank payment line of partner '%s' (reference " +"'%s')." +msgstr "Le compte bancaire est manquant sur la ligne de paiement bancaire du partenaire '%s' (référence '%s')." + +#. module: account_banking_international_credit_transfer +#: model:ir.model.fields,help:account_banking_international_credit_transfer.field_account_setup_bank_manual_config__intermediary_bank_id +#: model:ir.model.fields,help:account_banking_international_credit_transfer.field_res_partner_bank__intermediary_bank_id +msgid "Bank used as intermediary for international payments" +msgstr "Banque utilisée comme intermédiaire pour les paiements internationaux" + +#. module: account_banking_international_credit_transfer +#: model:ir.model.fields,field_description:account_banking_international_credit_transfer.field_account_setup_bank_manual_config__intermediary_bank_id +#: model:ir.model.fields,field_description:account_banking_international_credit_transfer.field_res_partner_bank__intermediary_bank_id +msgid "Intermediary Bank" +msgstr "Banque Intermédiaire" + +#. module: account_banking_international_credit_transfer +#. odoo-python +#: code:addons/account_banking_international_credit_transfer/models/account_payment_order.py:0 +#, python-format +msgid "" +"Intermediary bank is missing on the recipient bank account of partner '%s' " +"(reference '%s')." +msgstr "La banque intermédiaire est manquante sur le compte bancaire bénéficiaire du partenaire '%s' (référence '%s')." + +#. module: account_banking_international_credit_transfer +#: model:account.payment.method,name:account_banking_international_credit_transfer.international_credit_transfer +msgid "International Credit Transfer" +msgstr "Virement international" + +#. module: account_banking_international_credit_transfer +#: model:ir.model.fields,field_description:account_banking_international_credit_transfer.field_account_payment_method__pain_version +msgid "PAIN Version" +msgstr "Version PAIN" + +#. module: account_banking_international_credit_transfer +#. odoo-python +#: code:addons/account_banking_international_credit_transfer/models/account_payment_order.py:0 +#, python-format +msgid "PAIN version '%s' is not supported for international credit transfers." +msgstr "" +"La version PAIN '%s' n'est pas prise en charge pour les virements " +"internationaux." + +#. module: account_banking_international_credit_transfer +#. odoo-python +#: code:addons/account_banking_international_credit_transfer/models/account_payment_order.py:0 +#, python-format +msgid "PAIN version must be set on the payment method." +msgstr "La version PAIN doit être définie sur le mode de paiement." + +#. module: account_banking_international_credit_transfer +#: model:ir.model,name:account_banking_international_credit_transfer.model_account_payment_method +msgid "Payment Methods" +msgstr "Modes de paiement" + +#. module: account_banking_international_credit_transfer +#: model:ir.model,name:account_banking_international_credit_transfer.model_account_payment_order +msgid "Payment Order" +msgstr "Ordre de paiement" + +#. module: account_banking_international_credit_transfer +#: model:ir.model.fields.selection,name:account_banking_international_credit_transfer.selection__account_payment_method__pain_version__pain_001_001_03 +msgid "pain.001.001.03 (recommended for credit transfer)" +msgstr "pain.001.001.03 (recommandé pour les virements)" diff --git a/account_banking_international_credit_transfer/models/__init__.py b/account_banking_international_credit_transfer/models/__init__.py new file mode 100644 index 00000000000..f0e7ff11c00 --- /dev/null +++ b/account_banking_international_credit_transfer/models/__init__.py @@ -0,0 +1,3 @@ +from . import account_payment_method +from . import account_payment_order +from . import res_partner_bank diff --git a/account_banking_international_credit_transfer/models/account_payment_method.py b/account_banking_international_credit_transfer/models/account_payment_method.py new file mode 100644 index 00000000000..b724659e078 --- /dev/null +++ b/account_banking_international_credit_transfer/models/account_payment_method.py @@ -0,0 +1,26 @@ +# Copyright 2016-2020 Akretion (Alexis de Lattre ) +# Copyright 2025 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class AccountPaymentMethod(models.Model): + _inherit = "account.payment.method" + + pain_version = fields.Selection( + selection_add=[ + ("pain.001.001.03", "pain.001.001.03 (recommended for credit transfer)") + ], + ondelete={"pain.001.001.03": "set null"}, + ) + + def get_xsd_file_path(self): + self.ensure_one() + if self.pain_version in ["pain.001.001.03"]: + path = ( + "account_banking_international_credit_transfer/data/%s.xsd" + % self.pain_version + ) + return path + return super().get_xsd_file_path() diff --git a/account_banking_international_credit_transfer/models/account_payment_order.py b/account_banking_international_credit_transfer/models/account_payment_order.py new file mode 100644 index 00000000000..4bc06f716c3 --- /dev/null +++ b/account_banking_international_credit_transfer/models/account_payment_order.py @@ -0,0 +1,216 @@ +# Copyright 2010-2020 Akretion (www.akretion.com) +# Copyright 2014-2022 Tecnativa - Pedro M. Baeza +# Copyright 2025 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from lxml import etree + +from odoo import _, fields, models +from odoo.exceptions import UserError +from odoo.tools.xml_utils import create_xml_node_chain + + +class AccountPaymentOrder(models.Model): + _inherit = "account.payment.order" + + def generate_payment_file(self): # noqa: C901 + """Creates the International Transfer file. That's the important code!""" + # Code very similar to https://github.com/OCA/bank-payment/blob/17.0/account_banking_sepa_credit_transfer/models/account_payment_order.py#L14 + # but with intermediary bank support + self.ensure_one() + if self.payment_method_id.code != "international_credit_transfer": + return super().generate_payment_file() + + pain_flavor = self.payment_method_id.pain_version + # We use pain_flavor.startswith('pain.001.001.xx') + # to support country-specific extensions such as + # pain.001.001.03.ch.02 (cf l10n_ch_sepa) + if not pain_flavor: + raise UserError(_("PAIN version must be set on the payment method.")) + elif pain_flavor.startswith("pain.001.001.03"): + bic_xml_tag = "BIC" + # size 70 -> 140 for with pain.001.001.03 + # BUT the European Payment Council, in the document + # "SEPA Credit Transfer Scheme Customer-to-bank + # Implementation guidelines" v6.0 available on + # http://www.europeanpaymentscouncil.eu/knowledge_bank.cfm + # says that 'Nm' should be limited to 70 + # so we follow the "European Payment Council" + # and we put 70 and not 140 + name_maxsize = 70 + root_xml_tag = "CstmrCdtTrfInitn" + else: + raise UserError( + _( + "PAIN version '%s' is not supported for international credit " + "transfers.", + pain_flavor, + ) + ) + xsd_file = self.payment_method_id.get_xsd_file_path() + gen_args = { + "bic_xml_tag": bic_xml_tag, + "name_maxsize": name_maxsize, + "convert_to_ascii": self.payment_method_id.convert_to_ascii, + "payment_method": "TRF", + "file_prefix": "sct_", + "pain_flavor": pain_flavor, + "pain_xsd_file": xsd_file, + } + nsmap = self.generate_pain_nsmap() + attrib = self.generate_pain_attrib() + xml_root = etree.Element("Document", nsmap=nsmap, attrib=attrib) + pain_root = etree.SubElement(xml_root, root_xml_tag) + # A. Group header + header = self.generate_group_header_block(pain_root, gen_args) + group_header, nb_of_transactions_a, control_sum_a = header + transactions_count_a = 0 + amount_control_sum_a = 0.0 + lines_per_group = {} + # key = (requested_date, priority, local_instrument, categ_purpose) + # values = list of lines as object + for line in self.payment_ids: + payment_line = line.payment_line_ids[:1] + priority = payment_line.priority + local_instrument = payment_line.local_instrument + categ_purpose = payment_line.category_purpose + # The field line.payment_line_date is the requested payment date + key = (line.payment_line_date, priority, local_instrument, categ_purpose) + if key in lines_per_group: + lines_per_group[key].append(line) + else: + lines_per_group[key] = [line] + for (requested_date, priority, local_instrument, categ_purpose), lines in list( + lines_per_group.items() + ): + # B. Payment info + requested_date = fields.Date.to_string(requested_date) + ( + payment_info, + nb_of_transactions_b, + control_sum_b, + ) = self.generate_start_payment_info_block( + pain_root, + "self.name + '-' " + "+ requested_date.replace('-', '') + '-' + priority + " + "'-' + local_instrument + '-' + category_purpose", + priority, + local_instrument, + categ_purpose, + False, + requested_date, + { + "self": self, + "priority": priority, + "requested_date": requested_date, + "local_instrument": local_instrument or "NOinstr", + "category_purpose": categ_purpose or "NOcateg", + }, + gen_args, + ) + self.generate_party_block( + payment_info, "Dbtr", "B", self.company_partner_bank_id, gen_args + ) + charge_bearer = etree.SubElement(payment_info, "ChrgBr") + if self.sepa: + charge_bearer_text = "SLEV" + else: + charge_bearer_text = self.charge_bearer + charge_bearer.text = charge_bearer_text + transactions_count_b = 0 + amount_control_sum_b = 0.0 + for line in lines: + transactions_count_a += 1 + transactions_count_b += 1 + # C. Credit Transfer Transaction Info + credit_transfer_transaction_info = etree.SubElement( + payment_info, "CdtTrfTxInf" + ) + payment_identification = etree.SubElement( + credit_transfer_transaction_info, "PmtId" + ) + instruction_identification = etree.SubElement( + payment_identification, "InstrId" + ) + instruction_identification.text = self._prepare_field( + "Instruction Identification", + "str(line.move_id.id)", + {"line": line}, + 35, + gen_args=gen_args, + ) + end2end_identification = etree.SubElement( + payment_identification, "EndToEndId" + ) + end2end_identification.text = self._prepare_field( + "End to End Identification", + "str(line.move_id.id)", + {"line": line}, + 35, + gen_args=gen_args, + ) + currency_name = self._prepare_field( + "Currency Code", + "line.currency_id.name", + {"line": line}, + 3, + gen_args=gen_args, + ) + amount = etree.SubElement(credit_transfer_transaction_info, "Amt") + instructed_amount = etree.SubElement( + amount, "InstdAmt", Ccy=currency_name + ) + instructed_amount.text = "%.2f" % line.amount + amount_control_sum_a += line.amount + amount_control_sum_b += line.amount + if not (line_partner_bank := line.partner_bank_id): + raise UserError( + _( + "Bank account is missing on the bank payment line " + "of partner '%(partner)s' (reference '%(reference)s').", + partner=line.partner_id.name, + reference=line.name, + ) + ) + # Intermediary bank, specific to international credit transfers + if not (intermediary_bank := line_partner_bank.intermediary_bank_id): + raise UserError( + _( + "Intermediary bank is missing on the recipient bank " + "account of partner '%(partner)s' " + "(reference '%(reference)s').", + partner=line.partner_id.name, + reference=line.name, + ) + ) + financial_institution = create_xml_node_chain( + credit_transfer_transaction_info, ["IntrmyAgt1", "FinInstnId"] + )[-1] + intermediary_bic = etree.SubElement(financial_institution, bic_xml_tag) + intermediary_bic.text = intermediary_bank.bic + intermediary_name = etree.SubElement(financial_institution, "Nm") + intermediary_name.text = intermediary_bank.name[:name_maxsize] + self.generate_party_block( + credit_transfer_transaction_info, + "Cdtr", + "C", + line_partner_bank, + gen_args, + line, + bank_name=line_partner_bank.bank_id.name + if line_partner_bank.bank_id + else None, + ) + line_purpose = line.payment_line_ids[:1].purpose + if line_purpose: + purpose = etree.SubElement(credit_transfer_transaction_info, "Purp") + etree.SubElement(purpose, "Cd").text = line_purpose + self.generate_remittance_info_block( + credit_transfer_transaction_info, line, gen_args + ) + + nb_of_transactions_b.text = str(transactions_count_b) + control_sum_b.text = "%.2f" % amount_control_sum_b + nb_of_transactions_a.text = str(transactions_count_a) + control_sum_a.text = "%.2f" % amount_control_sum_a + return self.finalize_pain_file_creation(xml_root, gen_args) diff --git a/account_banking_international_credit_transfer/models/res_partner_bank.py b/account_banking_international_credit_transfer/models/res_partner_bank.py new file mode 100644 index 00000000000..13b7c58eb19 --- /dev/null +++ b/account_banking_international_credit_transfer/models/res_partner_bank.py @@ -0,0 +1,14 @@ +# Copyright 2025 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class ResPartnerBank(models.Model): + _inherit = "res.partner.bank" + + intermediary_bank_id = fields.Many2one( + string="Intermediary Bank", + comodel_name="res.bank", + help="Bank used as intermediary for international payments", + ) diff --git a/account_banking_international_credit_transfer/pyproject.toml b/account_banking_international_credit_transfer/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/account_banking_international_credit_transfer/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/account_banking_international_credit_transfer/readme/CONFIGURE.md b/account_banking_international_credit_transfer/readme/CONFIGURE.md new file mode 100644 index 00000000000..3cae02bd031 --- /dev/null +++ b/account_banking_international_credit_transfer/readme/CONFIGURE.md @@ -0,0 +1,4 @@ +- Create a Payment Mode dedicated to International Credit Transfer. +- Select the Payment Method *International Credit Transfer* (which + is automatically created upon module installation). +- Check that this payment method uses the proper version of PAIN. diff --git a/account_banking_international_credit_transfer/readme/DESCRIPTION.md b/account_banking_international_credit_transfer/readme/DESCRIPTION.md new file mode 100644 index 00000000000..9017ee84fc9 --- /dev/null +++ b/account_banking_international_credit_transfer/readme/DESCRIPTION.md @@ -0,0 +1,13 @@ +Create PAIN files for Direct Debit + +Module to export international payment orders in PAIN XML file format. + +PAIN (PAyment INitiation) is the new european standard for +Customer-to-Bank payment instructions. This module implements International +Credit Transfer, more specifically PAIN version 001.001.03. It is part of the ISO 20022 +standard, available on . + +The Implementation Guidelines for International Credit Transfer published by the +European Payments Council () use +PAIN version 001.001.03, so it's probably the version of PAIN that you +should try first. diff --git a/account_banking_international_credit_transfer/readme/INSTALL.md b/account_banking_international_credit_transfer/readme/INSTALL.md new file mode 100644 index 00000000000..603ffd257e5 --- /dev/null +++ b/account_banking_international_credit_transfer/readme/INSTALL.md @@ -0,0 +1,3 @@ +This module depends on : \* account_banking_pain_base + +This module is part of the OCA/bank-payment suite. diff --git a/account_banking_international_credit_transfer/readme/ROADMAP.md b/account_banking_international_credit_transfer/readme/ROADMAP.md new file mode 100644 index 00000000000..dfe87b3cf8c --- /dev/null +++ b/account_banking_international_credit_transfer/readme/ROADMAP.md @@ -0,0 +1 @@ +- Add support for additional PAIN versions diff --git a/account_banking_international_credit_transfer/readme/USAGE.md b/account_banking_international_credit_transfer/readme/USAGE.md new file mode 100644 index 00000000000..ac73719f9bc --- /dev/null +++ b/account_banking_international_credit_transfer/readme/USAGE.md @@ -0,0 +1,3 @@ +In the menu *Invoicing/Accounting \> Vendors \> Payment Orders*, create +a new payment order and select the Payment Mode dedicated to International Credit +Transfer that you created during the configuration step. diff --git a/account_banking_international_credit_transfer/static/description/index.html b/account_banking_international_credit_transfer/static/description/index.html new file mode 100644 index 00000000000..b49e9071bea --- /dev/null +++ b/account_banking_international_credit_transfer/static/description/index.html @@ -0,0 +1,464 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Account Banking International Credit Transfer

+ +

Beta License: AGPL-3 OCA/bank-payment Translate me on Weblate Try me on Runboat

+

Create PAIN files for Direct Debit

+

Module to export international payment orders in PAIN XML file format.

+

PAIN (PAyment INitiation) is the new european standard for +Customer-to-Bank payment instructions. This module implements +International Credit Transfer, more specifically PAIN version +001.001.03. It is part of the ISO 20022 standard, available on +https://www.iso20022.org.

+

The Implementation Guidelines for International Credit Transfer +published by the European Payments Council +(https://www.europeanpaymentscouncil.eu) use PAIN version 001.001.03, so +it’s probably the version of PAIN that you should try first.

+

Table of contents

+ +
+

Installation

+

This module depends on : * account_banking_pain_base

+

This module is part of the OCA/bank-payment suite.

+
+
+

Configuration

+
    +
  • Create a Payment Mode dedicated to International Credit Transfer.
  • +
  • Select the Payment Method International Credit Transfer (which is +automatically created upon module installation).
  • +
  • Check that this payment method uses the proper version of PAIN.
  • +
+
+
+

Usage

+

In the menu Invoicing/Accounting > Vendors > Payment Orders, create a +new payment order and select the Payment Mode dedicated to International +Credit Transfer that you created during the configuration step.

+
+
+

Known issues / Roadmap

+
    +
  • Add support for additional PAIN versions
  • +
+
+
+

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
  • +
  • Tecnativa
  • +
  • Camptocamp SA
  • +
+
+
+

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/bank-payment project on GitHub.

+

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

+
+
+
+
+ + diff --git a/account_banking_international_credit_transfer/tests/__init__.py b/account_banking_international_credit_transfer/tests/__init__.py new file mode 100644 index 00000000000..e9915c04e2a --- /dev/null +++ b/account_banking_international_credit_transfer/tests/__init__.py @@ -0,0 +1 @@ +from . import test_international_credit_transfer diff --git a/account_banking_international_credit_transfer/tests/test_international_credit_transfer.py b/account_banking_international_credit_transfer/tests/test_international_credit_transfer.py new file mode 100644 index 00000000000..c2a91dc55a6 --- /dev/null +++ b/account_banking_international_credit_transfer/tests/test_international_credit_transfer.py @@ -0,0 +1,327 @@ +# Copyright 2016 Akretion (Alexis de Lattre ) +# Copyright 2020 Sygel Technology - Valentin Vinagre +# Copyright 2018-2022 Tecnativa - Pedro M. Baeza +# Copyright 2025 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +import time + +from lxml import etree + +from odoo.exceptions import UserError + +from odoo.addons.base.tests.common import BaseCommon + + +class TestSCT(BaseCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.account_model = cls.env["account.account"] + cls.journal_model = cls.env["account.journal"] + cls.payment_order_model = cls.env["account.payment.order"] + cls.payment_line_model = cls.env["account.payment.line"] + cls.partner_bank_model = cls.env["res.partner.bank"] + cls.attachment_model = cls.env["ir.attachment"] + cls.partner_agrolait = cls.env["res.partner"].create({"name": "Agrolait"}) + cls.partner_c2c = cls.env["res.partner"].create({"name": "C2C"}) + cls.eur_currency = cls.env.ref("base.EUR") + cls.eur_currency.active = True + cls.main_company = cls.env["res.company"].create( + {"name": "Test EUR company", "currency_id": cls.eur_currency.id} + ) + cls.partner_agrolait.company_id = cls.main_company.id + cls.partner_c2c.company_id = cls.main_company.id + cls.env.user.write( + { + "company_ids": [(6, 0, cls.main_company.ids)], + "company_id": cls.main_company.id, + } + ) + cls.account_expense = cls.account_model.create( + { + "account_type": "expense", + "company_id": cls.main_company.id, + "name": "Test expense", + "code": "TE.1", + } + ) + cls.account_payable = cls.account_model.create( + { + "account_type": "liability_payable", + "company_id": cls.main_company.id, + "name": "Test payable", + "code": "TP.1", + } + ) + (cls.partner_c2c + cls.partner_agrolait).with_company( + cls.main_company.id + ).write({"property_account_payable_id": cls.account_payable.id}) + cls.purchase_journal = cls.journal_model.create( + { + "name": "Purchase journal", + "type": "purchase", + "code": "PUR", + "company_id": cls.main_company.id, + } + ) + cls.bank_1 = cls.env["res.bank"].create( + { + "name": "La Banque Postale", + "bic": "PSSTFRPPXXX", + "street": "115 rue de Sèvres", + "zip": "75007", + "city": "Paris", + "country": cls.env.ref("base.fr").id, + } + ) + cls.bank_2 = cls.env["res.bank"].create( + { + "name": "BNP Paribas Fortis Charleroi", + "bic": "GEBABEBB03A", + "city": "Charleroi", + "country": cls.env.ref("base.be").id, + } + ) + cls.partner_bank = cls.env["res.partner.bank"].create( + { + "acc_number": "ES52 0182 2782 5688 3882 1868", + "bank_id": cls.bank_1.id, + "partner_id": cls.main_company.partner_id.id, + "company_id": cls.main_company.id, + } + ) + cls.partner_1 = cls.env["res.partner"].create( + { + "name": "Test Partner 1", + } + ) + cls.partner_bank_1 = cls.env["res.partner.bank"].create( + { + "acc_number": "FR66 1212 1212 1212 1212 1212 121", + "bank_id": cls.bank_1.id, + "partner_id": cls.partner_1.id, + } + ) + cls.partner_2 = cls.env["res.partner"].create( + { + "name": "Test Partner 2", + } + ) + cls.partner_bank_2 = cls.env["res.partner.bank"].create( + { + "acc_number": "BE96 9988 7766 5544", + "bank_id": cls.bank_2.id, + "partner_id": cls.partner_2.id, + } + ) + cls.bank_journal = cls.journal_model.create( + { + "name": "Company Bank journal", + "type": "bank", + "code": "BNKFB", + "bank_account_id": cls.partner_bank.id, + "bank_id": cls.partner_bank.bank_id.id, + "company_id": cls.main_company.id, + "outbound_payment_method_line_ids": [ + ( + 0, + 0, + { + "payment_method_id": cls.env.ref( + "account_banking_international_credit_transfer.international_credit_transfer" + ).id, + "payment_account_id": cls.account_expense.id, + }, + ) + ], + } + ) + + # update payment mode + cls.payment_mode = cls.env["account.payment.mode"].create( + { + "name": "SEPA Credit Transfer to suppliers", + "company_id": cls.main_company.id, + "payment_method_id": cls.env.ref( + "account_banking_international_credit_transfer.international_credit_transfer" + ).id, + "bank_account_link": "fixed", + "fixed_journal_id": cls.bank_journal.id, + } + ) + # Trigger the recompute of account type on res.partner.bank + cls.partner_bank_model.search([])._compute_acc_type() + + def test_no_pain(self): + self.payment_mode.payment_method_id.pain_version = False + with self.assertRaises(UserError): + self.check_international_credit_transfer() + + def test_no_intermediary_bank(self): + self.payment_mode.payment_method_id.pain_version = "pain.001.001.03" + self.partner_bank_1.intermediary_bank_id = False + self.partner_bank_2.intermediary_bank_id = False + with self.assertRaises(UserError): + self.check_international_credit_transfer() + + def test_pain_001_03(self): + self.payment_mode.payment_method_id.pain_version = "pain.001.001.03" + self.partner_bank_1.intermediary_bank_id = self.bank_2.id + self.partner_bank_2.intermediary_bank_id = self.bank_1.id + self.check_international_credit_transfer() + + def check_international_credit_transfer(self): + invoice1 = self.create_invoice( + self.partner_agrolait.id, + self.partner_bank_1, + self.eur_currency.id, + 42.0, + "F1341", + ) + invoice2 = self.create_invoice( + self.partner_agrolait.id, + self.partner_bank_1, + self.eur_currency.id, + 12.0, + "F1342", + ) + invoice3 = self.create_invoice( + self.partner_agrolait.id, + self.partner_bank_1, + self.eur_currency.id, + 5.0, + "A1301", + "in_refund", + ) + invoice4 = self.create_invoice( + self.partner_c2c.id, + self.partner_bank_2, + self.eur_currency.id, + 11.0, + "I1642", + ) + invoice5 = self.create_invoice( + self.partner_c2c.id, + self.partner_bank_2, + self.eur_currency.id, + 41.0, + "I1643", + ) + for inv in [invoice1, invoice2, invoice3, invoice4, invoice5]: + action = inv.create_account_payment_line() + self.assertEqual(action["res_model"], "account.payment.order") + self.payment_order = self.payment_order_model.browse(action["res_id"]) + self.assertEqual(self.payment_order.payment_type, "outbound") + self.assertEqual(self.payment_order.payment_mode_id, self.payment_mode) + self.assertEqual(self.payment_order.journal_id, self.bank_journal) + pay_lines = self.payment_line_model.search( + [ + ("partner_id", "=", self.partner_agrolait.id), + ("order_id", "=", self.payment_order.id), + ] + ) + self.assertEqual(len(pay_lines), 3) + agrolait_pay_line1 = pay_lines[0] + self.assertEqual(agrolait_pay_line1.currency_id, self.eur_currency) + self.assertEqual(agrolait_pay_line1.partner_bank_id, invoice1.partner_bank_id) + self.assertEqual( + agrolait_pay_line1.currency_id.compare_amounts( + agrolait_pay_line1.amount_currency, 42 + ), + 0, + ) + self.assertEqual(agrolait_pay_line1.communication_type, "normal") + self.assertEqual(agrolait_pay_line1.communication, "F1341") + self.payment_order.draft2open() + self.assertEqual(self.payment_order.state, "open") + self.assertTrue(self.payment_order.payment_ids) + agrolait_bank_line = self.payment_order.payment_ids[0] + self.assertEqual(agrolait_bank_line.currency_id, self.eur_currency) + self.assertEqual( + agrolait_bank_line.currency_id.compare_amounts( + agrolait_bank_line.amount, 49.0 + ), + 0, + ) + self.assertEqual(agrolait_bank_line.payment_reference, "F1341 - F1342 - A1301") + self.assertEqual(agrolait_bank_line.partner_bank_id, invoice1.partner_bank_id) + + action = self.payment_order.open2generated() + self.assertEqual(self.payment_order.state, "generated") + self.assertEqual(action["res_model"], "ir.attachment") + attachment = self.attachment_model.browse(action["res_id"]) + self.assertEqual(attachment.name[-4:], ".xml") + xml_file = base64.b64decode(attachment.datas) + xml_root = etree.fromstring(xml_file) + namespaces = xml_root.nsmap + namespaces["p"] = xml_root.nsmap[None] + namespaces.pop(None) + pay_method_xpath = xml_root.xpath("//p:PmtInf/p:PmtMtd", namespaces=namespaces) + self.assertEqual(pay_method_xpath[0].text, "TRF") + debtor_acc_xpath = xml_root.xpath( + "//p:PmtInf/p:DbtrAcct/p:Id/p:IBAN", namespaces=namespaces + ) + self.assertEqual( + debtor_acc_xpath[0].text, + self.payment_order.company_partner_bank_id.sanitized_acc_number, + ) + intermediary_bank_xpath = xml_root.xpath( + "//p:PmtInf/p:CdtTrfTxInf/p:IntrmyAgt1/p:FinInstnId", + namespaces=namespaces, + ) + intermediary_bic = intermediary_bank_xpath[0].xpath( + "./p:BIC", namespaces=namespaces + ) + intermediary_name = intermediary_bank_xpath[0].xpath( + "./p:Nm", namespaces=namespaces + ) + self.assertEqual( + intermediary_bic[0].text, self.partner_bank_1.intermediary_bank_id.bic + ) + self.assertEqual( + intermediary_name[0].text, self.partner_bank_1.intermediary_bank_id.name + ) + self.payment_order.generated2uploaded() + self.assertEqual(self.payment_order.state, "uploaded") + for inv in [invoice1, invoice2, invoice3, invoice4, invoice5]: + self.assertEqual(inv.state, "posted") + self.assertEqual( + inv.currency_id.compare_amounts(inv.amount_residual, 0.0), + 0, + ) + return + + @classmethod + def create_invoice( + cls, + partner_id, + partner_bank, + currency_id, + price_unit, + reference, + move_type="in_invoice", + ): + data = { + "partner_id": partner_id, + "reference_type": "none", + "ref": reference, + "currency_id": currency_id, + "invoice_date": time.strftime("%Y-%m-%d"), + "move_type": move_type, + "payment_mode_id": cls.payment_mode.id, + "partner_bank_id": partner_bank.id, + "company_id": cls.main_company.id, + "invoice_line_ids": [], + } + line_data = { + "name": "Great service", + "account_id": cls.account_expense.id, + "price_unit": price_unit, + "quantity": 1, + } + data["invoice_line_ids"].append((0, 0, line_data)) + inv = cls.env["account.move"].create(data) + inv.action_post() + return inv diff --git a/account_banking_international_credit_transfer/views/res_partner_bank_views.xml b/account_banking_international_credit_transfer/views/res_partner_bank_views.xml new file mode 100644 index 00000000000..69a41924923 --- /dev/null +++ b/account_banking_international_credit_transfer/views/res_partner_bank_views.xml @@ -0,0 +1,14 @@ + + + + + res.partner.bank + + + + + + + + diff --git a/account_banking_pain_base/models/account_payment_order.py b/account_banking_pain_base/models/account_payment_order.py index 3089277cbc8..83f06b42bef 100644 --- a/account_banking_pain_base/models/account_payment_order.py +++ b/account_banking_pain_base/models/account_payment_order.py @@ -283,12 +283,12 @@ def _validate_xml(self, xml_string, gen_args): ) from None return True - def finalize_sepa_file_creation(self, xml_root, gen_args): + def finalize_pain_file_creation(self, xml_root, gen_args): xml_string = etree.tostring( xml_root, pretty_print=True, encoding="UTF-8", xml_declaration=True ) logger.debug( - "Generated SEPA XML file in format %s below" % gen_args["pain_flavor"] + "Generated pain XML file in format %s below" % gen_args["pain_flavor"] ) logger.debug(xml_string) self._validate_xml(xml_string, gen_args) @@ -461,7 +461,14 @@ def generate_initiating_party_block(self, parent_node, gen_args): @api.model def generate_party_agent( - self, parent_node, party_type, order, partner_bank, gen_args, bank_line=None + self, + parent_node, + party_type, + order, + partner_bank, + gen_args, + bank_line=None, + bank_name=None, ): """Generate the piece of the XML file corresponding to BIC This code is mutualized between TRF and DD @@ -470,7 +477,9 @@ def generate_party_agent( http://www.europeanpaymentscouncil.eu/index.cfm/ sepa-credit-transfer/iban-and-bic/ In some localization (l10n_ch_sepa for example), they need the - bank_line argument""" + bank_line argument + For some special transfers such as International Credit Transfers, we want the + bank name to be set besides the BIC.""" assert order in ("B", "C"), "Order can be 'B' or 'C'" if partner_bank.bank_bic: party_agent = etree.SubElement(parent_node, "%sAgt" % party_type) @@ -479,6 +488,9 @@ def generate_party_agent( party_agent_institution, gen_args.get("bic_xml_tag") ) party_agent_bic.text = partner_bank.bank_bic + if bank_name: + party_agent_name = etree.SubElement(party_agent_institution, "Nm") + party_agent_name.text = bank_name else: if order == "B" or (order == "C" and gen_args["payment_method"] == "DD"): party_agent = etree.SubElement(parent_node, "%sAgt" % party_type) @@ -567,12 +579,21 @@ def generate_address_block(self, parent_node, partner, gen_args): @api.model def generate_party_block( - self, parent_node, party_type, order, partner_bank, gen_args, bank_line=None + self, + parent_node, + party_type, + order, + partner_bank, + gen_args, + bank_line=None, + bank_name=None, ): """Generate the piece of the XML file corresponding to Name+IBAN+BIC This code is mutualized between TRF and DD In some localization (l10n_ch_sepa for example), they need the - bank_line argument""" + bank_line argument + For some special transfers such as International Credit Transfers, we want the + bank name to be set besides the BIC.""" assert order in ("B", "C"), "Order can be 'B' or 'C'" party_type_label = _("Partner name") if party_type == "Cdtr": @@ -598,6 +619,7 @@ def generate_party_block( partner_bank, gen_args, bank_line=bank_line, + bank_name=bank_name, ) party = etree.SubElement(parent_node, party_type) party_nm = etree.SubElement(party, "Nm") @@ -620,6 +642,7 @@ def generate_party_block( partner_bank, gen_args, bank_line=bank_line, + bank_name=bank_name, ) return True diff --git a/account_banking_sepa_credit_transfer/models/account_payment_order.py b/account_banking_sepa_credit_transfer/models/account_payment_order.py index 885b5ef5a0b..f0d2b258356 100644 --- a/account_banking_sepa_credit_transfer/models/account_payment_order.py +++ b/account_banking_sepa_credit_transfer/models/account_payment_order.py @@ -204,4 +204,4 @@ def generate_payment_file(self): # noqa: C901 else: nb_of_transactions_a.text = str(transactions_count_a) control_sum_a.text = "%.2f" % amount_control_sum_a - return self.finalize_sepa_file_creation(xml_root, gen_args) + return self.finalize_pain_file_creation(xml_root, gen_args) diff --git a/account_banking_sepa_direct_debit/models/account_payment_order.py b/account_banking_sepa_direct_debit/models/account_payment_order.py index a20b9e05c55..84ca7544d17 100644 --- a/account_banking_sepa_direct_debit/models/account_payment_order.py +++ b/account_banking_sepa_direct_debit/models/account_payment_order.py @@ -262,7 +262,7 @@ def generate_payment_file(self): nb_of_transactions_a.text = str(transactions_count_a) control_sum_a.text = "%.2f" % amount_control_sum_a - return self.finalize_sepa_file_creation(xml_root, gen_args) + return self.finalize_pain_file_creation(xml_root, gen_args) def generated2uploaded(self): """Write 'last debit date' on mandates