diff --git a/account_banking_fr_lcr/README.rst b/account_banking_fr_lcr/README.rst index 66c1fa71b..fe9b57566 100644 --- a/account_banking_fr_lcr/README.rst +++ b/account_banking_fr_lcr/README.rst @@ -28,23 +28,36 @@ French Letter of Change |badge1| |badge2| |badge3| |badge4| |badge5| -This module adds support for French Letters of Change (in French: -*Lettre de Change Relevé* aka LCR). This module supports direct LCR -(in French, *LCR Directe*) and not paper LCR. +This module adds support for French Letters of Change. This module supports: -This payment type is still in use in France and it is *not* replaced by SEPA -one-off Direct Debits. +* **Direct letter of change** (in French : *Lettre de change directe* or *LCR directe*), +* **Accepted letter of change** (in French : *Lettre de change acceptée* ; I call it *paper letter of change*), +* **Promissory note** (in French : *Billet à ordre*), -With this module, you can generate an LCR CFONB file to send to your -bank. Then, your customer will be notified by their bank about the debit -(amount, date of debit). Eventually, the debit will take place at the -planned date. +It supports cash discounts debit orders and Dailly convention. + +This module has 2 main features: + +* for **Accepted Letter of Change**, generate a paper letter of change as PDF following the official layout NF K 11-030-1. +* generate of LCR (or BOR) CFONB files to send to your bank. + +This module follows the specifications published on the `CFONB website `_, section *Espace documentaire > Instruments de paiement > Effet de commerce* (document version of September 2002). **Table of contents** .. contents:: :local: +Installation +============ + +This module requires 2 Python libs: + +* `pypdf `_ version 3.10 or above, +* `unidecode `_ (any version). + +In order to have the SIREN of the company and of the customer set in the CFONB file (optional field) and printed on the paper letter of exchange, the OCA module **l10n_fr_siret** must be installed. The installation of the module **l10n_fr_siret** is optional (because the SIREN field in the CFONB file is optional). + Configuration ============= @@ -52,11 +65,25 @@ To configure this module, you need to create a new payment mode linked to the payment method *Lettre de Change Relevé* that is automatically created when you install this module. +Once you selected this payment method, you will have a new section *Bill of Exchange* on the payment mode where you will have to configure: + +* the *LCR type*: *Lettre de change non acceptée (LCR directe)*, *Lettre de change acceptée* or *Billet à ordre*, +* the *Default Collection Option*, +* if you have a *Dailly Convention*, +* in case you have a Dailly convention, you will be able to configure the *Default Dailly Option* and the *Convention Type*. + Usage ===== -To use this module, you need to create a new Debit Order and -select the LCR payment mode. +This module adds a new field *Bill of Exchange Bank Account* on customer invoices to select the bank account of the customer that will be debited by the letter of exchange. This bank account must be a french IBAN. + +If you configured the payment mode for **Accepted Letter of Change**, you will have a button *Print Bill of Exchange* on customer invoices to get the letter of change as PDF. + +This module uses the standard workflow of debit orders as implemented in the OCA module **account_payment_order**. A debit order linked to a payment mode with the payment method *Lettre de change relevé* has a few additionnal constraints: + +* all payment lines must be in euro currency, +* the bank accounts on the payment lines must be french IBANs, +* if the payment order is configured with cash discount, you must configure the value date on the payment order (new field added by this module). Bug Tracker =========== diff --git a/account_banking_fr_lcr/__manifest__.py b/account_banking_fr_lcr/__manifest__.py index d519d85f6..c96f5d007 100644 --- a/account_banking_fr_lcr/__manifest__.py +++ b/account_banking_fr_lcr/__manifest__.py @@ -13,8 +13,13 @@ "website": "https://github.com/OCA/l10n-france", "category": "French localisation", "depends": ["account_payment_order"], - "external_dependencies": {"python": ["unidecode"]}, - "data": ["data/account_payment_method.xml"], + "external_dependencies": {"python": ["unidecode", "pypdf>=3.1.0"]}, + "data": [ + "data/account_payment_method.xml", + "views/account_payment_mode.xml", + "views/account_payment_order.xml", + "views/account_move.xml", + ], "demo": ["demo/lcr_demo.xml"], "post_init_hook": "lcr_set_unece", "installable": True, diff --git a/account_banking_fr_lcr/data/account_payment_method.xml b/account_banking_fr_lcr/data/account_payment_method.xml index 09c81a2bf..772ea54f3 100644 --- a/account_banking_fr_lcr/data/account_payment_method.xml +++ b/account_banking_fr_lcr/data/account_payment_method.xml @@ -5,6 +5,6 @@ fr_lcr inbound - + diff --git a/account_banking_fr_lcr/i18n/account_banking_fr_lcr.pot b/account_banking_fr_lcr/i18n/account_banking_fr_lcr.pot index 2b5d2e11f..0675988a7 100644 --- a/account_banking_fr_lcr/i18n/account_banking_fr_lcr.pot +++ b/account_banking_fr_lcr/i18n/account_banking_fr_lcr.pot @@ -6,6 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 16.0\n" "Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-08-27 10:38+0000\n" +"PO-Revision-Date: 2024-08-27 10:38+0000\n" "Last-Translator: \n" "Language-Team: \n" "MIME-Version: 1.0\n" @@ -13,6 +15,84 @@ msgstr "" "Content-Transfer-Encoding: \n" "Plural-Forms: \n" +#. module: account_banking_fr_lcr +#: model:ir.model,name:account_banking_fr_lcr.model_res_partner_bank +msgid "Bank Accounts" +msgstr "" + +#. module: account_banking_fr_lcr +#: model:ir.model.fields,help:account_banking_fr_lcr.field_account_bank_statement_line__fr_lcr_partner_bank_id +#: model:ir.model.fields,help:account_banking_fr_lcr.field_account_move__fr_lcr_partner_bank_id +#: model:ir.model.fields,help:account_banking_fr_lcr.field_account_payment__fr_lcr_partner_bank_id +msgid "" +"Bank account of the customer that will be debited by the bill of exchange. " +"By default, Odoo selects the first French IBAN bank account of the partner." +msgstr "" + +#. module: account_banking_fr_lcr +#: model_terms:ir.ui.view,arch_db:account_banking_fr_lcr.account_payment_mode_form +msgid "Bill of Exchange" +msgstr "" + +#. module: account_banking_fr_lcr +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_bank_statement_line__fr_lcr_attachment_id +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_move__fr_lcr_attachment_id +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_payment__fr_lcr_attachment_id +msgid "Bill of Exchange Attachment" +msgstr "" + +#. module: account_banking_fr_lcr +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_bank_statement_line__fr_lcr_partner_bank_id +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_move__fr_lcr_partner_bank_id +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_payment__fr_lcr_partner_bank_id +msgid "Bill of Exchange Bank Account" +msgstr "" + +#. module: account_banking_fr_lcr +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_bank_statement_line__fr_lcr_attachment_datas +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_move__fr_lcr_attachment_datas +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_payment__fr_lcr_attachment_datas +msgid "Bill of Exchange File" +msgstr "" + +#. module: account_banking_fr_lcr +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_bank_statement_line__fr_lcr_attachment_name +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_move__fr_lcr_attachment_name +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_payment__fr_lcr_attachment_name +msgid "Bill of Exchange Filename" +msgstr "" + +#. module: account_banking_fr_lcr +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_bank_statement_line__payment_mode_fr_lcr_type +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_move__payment_mode_fr_lcr_type +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_payment__payment_mode_fr_lcr_type +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_payment_mode__fr_lcr_type +msgid "Bill of Exchange Type" +msgstr "" + +#. module: account_banking_fr_lcr +#: model:ir.model.fields.selection,name:account_banking_fr_lcr.selection__account_payment_mode__fr_lcr_type__promissory_note +msgid "Billet à ordre" +msgstr "" + +#. module: account_banking_fr_lcr +#. odoo-python +#: code:addons/account_banking_fr_lcr/models/res_partner_bank.py:0 +#, python-format +msgid "" +"Bills of exchange can only use French bank accounts. The IBAN " +"'%(acc_number)s' of partner '%(partner)s' is not a French IBAN." +msgstr "" + +#. module: account_banking_fr_lcr +#. odoo-python +#: code:addons/account_banking_fr_lcr/models/res_partner_bank.py:0 +#, python-format +msgid "" +"Bills of exchange can only use IBAN bank accounts. Bank account " +"'%(acc_number)s' of partner '%(partner)s' is not an IBAN." +msgstr "" + #. module: account_banking_fr_lcr #. odoo-python #: code:addons/account_banking_fr_lcr/models/account_payment_order.py:0 @@ -20,6 +100,69 @@ msgstr "" msgid "Cannot convert the field '%s' to ASCII" msgstr "" +#. module: account_banking_fr_lcr +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_payment_order__fr_lcr_collection_option +msgid "Collection Option" +msgstr "" + +#. module: account_banking_fr_lcr +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_payment_mode__fr_lcr_convention_type +msgid "Convention Type" +msgstr "" + +#. module: account_banking_fr_lcr +#. odoo-python +#: code:addons/account_banking_fr_lcr/models/account_move.py:0 +#, python-format +msgid "" +"Customer invoice '%(move)s' is configured with payment mode " +"'%(payment_mode)s' which require a bill of exchange bank account." +msgstr "" + +#. module: account_banking_fr_lcr +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_payment_mode__fr_lcr_dailly +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_payment_order__fr_lcr_dailly +msgid "Dailly Convention" +msgstr "" + +#. module: account_banking_fr_lcr +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_payment_order__fr_lcr_dailly_option +msgid "Dailly Option" +msgstr "" + +#. module: account_banking_fr_lcr +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_payment_mode__fr_lcr_default_collection_option +msgid "Default Collection Option" +msgstr "" + +#. module: account_banking_fr_lcr +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_payment_mode__fr_lcr_default_dailly_option +msgid "Default Dailly Option" +msgstr "" + +#. module: account_banking_fr_lcr +#. odoo-python +#: code:addons/account_banking_fr_lcr/models/account_payment_order.py:0 +#, python-format +msgid "" +"Error in the generation of the CFONB file: '%(field)s' should be a string, " +"but it is %(value_type)s (value: %(value)s)." +msgstr "" + +#. module: account_banking_fr_lcr +#. odoo-python +#: code:addons/account_banking_fr_lcr/models/account_payment_order.py:0 +#, python-format +msgid "" +"Error in the generation of the CFONB file: the field '%s' is empty or 0. It " +"should have a non-null value." +msgstr "" + +#. module: account_banking_fr_lcr +#: model:ir.model.fields,help:account_banking_fr_lcr.field_account_payment_mode__fr_lcr_convention_type +msgid "Field C1 'Convention Type' in CFONB header line, 6 characters maximum." +msgstr "" + #. module: account_banking_fr_lcr #. odoo-python #: code:addons/account_banking_fr_lcr/models/account_payment_order.py:0 @@ -30,8 +173,8 @@ msgid "" msgstr "" #. module: account_banking_fr_lcr -#: model:ir.model,name:account_banking_fr_lcr.model_account_move_line -msgid "Journal Item" +#: model:ir.model,name:account_banking_fr_lcr.model_account_move +msgid "Journal Entry" msgstr "" #. module: account_banking_fr_lcr @@ -53,16 +196,71 @@ msgstr "" msgid "Lettre de Change Relevé" msgstr "" +#. module: account_banking_fr_lcr +#: model:ir.model.fields.selection,name:account_banking_fr_lcr.selection__account_payment_mode__fr_lcr_type__accepted +msgid "Lettre de change acceptée" +msgstr "" + +#. module: account_banking_fr_lcr +#: model:ir.model.fields.selection,name:account_banking_fr_lcr.selection__account_payment_mode__fr_lcr_type__not_accepted +msgid "Lettre de change non acceptée (LCR directe)" +msgstr "" + +#. module: account_banking_fr_lcr +#. odoo-python +#: code:addons/account_banking_fr_lcr/models/account_payment_order.py:0 +#, python-format +msgid "" +"On debit order '%(order)s', the value date has been set to %(value_date)s: " +"it must be in the future." +msgstr "" + +#. module: account_banking_fr_lcr +#: model:ir.model,name:account_banking_fr_lcr.model_account_payment_line +msgid "Payment Lines" +msgstr "" + #. module: account_banking_fr_lcr #: model:ir.model,name:account_banking_fr_lcr.model_account_payment_method msgid "Payment Methods" msgstr "" +#. module: account_banking_fr_lcr +#: model:ir.model,name:account_banking_fr_lcr.model_account_payment_mode +msgid "Payment Modes" +msgstr "" + #. module: account_banking_fr_lcr #: model:ir.model,name:account_banking_fr_lcr.model_account_payment_order msgid "Payment Order" msgstr "" +#. module: account_banking_fr_lcr +#: model:ir.model,name:account_banking_fr_lcr.model_account_payment +msgid "Payments" +msgstr "" + +#. module: account_banking_fr_lcr +#: model_terms:ir.ui.view,arch_db:account_banking_fr_lcr.view_move_form +msgid "Print Bill of Exchange" +msgstr "" + +#. module: account_banking_fr_lcr +#. odoo-python +#: code:addons/account_banking_fr_lcr/models/account_payment_order.py:0 +#, python-format +msgid "The Collection Option is not set on debit order '%s'." +msgstr "" + +#. module: account_banking_fr_lcr +#. odoo-python +#: code:addons/account_banking_fr_lcr/models/account_payment_line.py:0 +#, python-format +msgid "" +"The currency of payment line '%(payment_line)s' is %(currency)s. To be " +"included in a french bill of exchange, the currency must be EUR." +msgstr "" + #. module: account_banking_fr_lcr #. odoo-python #: code:addons/account_banking_fr_lcr/models/account_payment_order.py:0 @@ -78,3 +276,24 @@ msgstr "" #, python-format msgid "The field '%s' is empty or 0. It should have a non-null value." msgstr "" + +#. module: account_banking_fr_lcr +#. odoo-python +#: code:addons/account_banking_fr_lcr/models/account_payment_mode.py:0 +#, python-format +msgid "The field 'Bill of Exchange Type' must be set on payment mode '%s'." +msgstr "" + +#. module: account_banking_fr_lcr +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_payment_order__fr_lcr_value_date +msgid "Value Date" +msgstr "" + +#. module: account_banking_fr_lcr +#. odoo-python +#: code:addons/account_banking_fr_lcr/models/account_payment_order.py:0 +#, python-format +msgid "" +"Value date is not set on debit order '%s'. It is required on letters of " +"exchange with cash discount." +msgstr "" diff --git a/account_banking_fr_lcr/i18n/fr.po b/account_banking_fr_lcr/i18n/fr.po index f022ac22e..cec6d934f 100644 --- a/account_banking_fr_lcr/i18n/fr.po +++ b/account_banking_fr_lcr/i18n/fr.po @@ -1,23 +1,107 @@ # Translation of Odoo Server. # This file contains the translation of the following modules: -# * account_banking_fr_lcr +# * account_banking_fr_lcr # -# Translators: -# OCA Transbot , 2017 msgid "" msgstr "" -"Project-Id-Version: Odoo Server 10.0\n" +"Project-Id-Version: Odoo Server 16.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-11-30 10:45+0000\n" -"PO-Revision-Date: 2023-06-27 18:08+0000\n" -"Last-Translator: Alexis de Lattre \n" -"Language-Team: French (https://www.transifex.com/oca/teams/23907/fr/)\n" +"POT-Creation-Date: 2024-08-27 10:39+0000\n" +"PO-Revision-Date: 2024-08-27 10:40+0000\n" +"Last-Translator: Alexis de Lattre \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" -"X-Generator: Weblate 4.17\n" +"Plural-Forms: \n" + +#. module: account_banking_fr_lcr +#: model:ir.model,name:account_banking_fr_lcr.model_res_partner_bank +msgid "Bank Accounts" +msgstr "Comptes bancaires" + +#. module: account_banking_fr_lcr +#: model:ir.model.fields,help:account_banking_fr_lcr.field_account_bank_statement_line__fr_lcr_partner_bank_id +#: model:ir.model.fields,help:account_banking_fr_lcr.field_account_move__fr_lcr_partner_bank_id +#: model:ir.model.fields,help:account_banking_fr_lcr.field_account_payment__fr_lcr_partner_bank_id +msgid "" +"Bank account of the customer that will be debited by the bill of exchange. " +"By default, Odoo selects the first French IBAN bank account of the partner." +msgstr "" +"Compte bancaire du client qui sera débité par la lettre de change. Par " +"défaut, Odoo sélectionne le premier IBAN français du " +"partenaire." + +#. module: account_banking_fr_lcr +#: model_terms:ir.ui.view,arch_db:account_banking_fr_lcr.account_payment_mode_form +msgid "Bill of Exchange" +msgstr "Lettre de change" + +#. module: account_banking_fr_lcr +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_bank_statement_line__fr_lcr_attachment_id +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_move__fr_lcr_attachment_id +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_payment__fr_lcr_attachment_id +msgid "Bill of Exchange Attachment" +msgstr "Lettre de change jointe" + +#. module: account_banking_fr_lcr +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_bank_statement_line__fr_lcr_partner_bank_id +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_move__fr_lcr_partner_bank_id +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_payment__fr_lcr_partner_bank_id +msgid "Bill of Exchange Bank Account" +msgstr "Compte bancaire pour la lettre de change" + +#. module: account_banking_fr_lcr +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_bank_statement_line__fr_lcr_attachment_datas +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_move__fr_lcr_attachment_datas +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_payment__fr_lcr_attachment_datas +msgid "Bill of Exchange File" +msgstr "Fichier de lettre de change" + +#. module: account_banking_fr_lcr +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_bank_statement_line__fr_lcr_attachment_name +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_move__fr_lcr_attachment_name +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_payment__fr_lcr_attachment_name +msgid "Bill of Exchange Filename" +msgstr "Nom du fichier de la lettre de change" + +#. module: account_banking_fr_lcr +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_bank_statement_line__payment_mode_fr_lcr_type +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_move__payment_mode_fr_lcr_type +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_payment__payment_mode_fr_lcr_type +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_payment_mode__fr_lcr_type +msgid "Bill of Exchange Type" +msgstr "Type de lettre de change" + +#. module: account_banking_fr_lcr +#: model:ir.model.fields.selection,name:account_banking_fr_lcr.selection__account_payment_mode__fr_lcr_type__promissory_note +msgid "Billet à ordre" +msgstr "Billet à ordre" + +#. module: account_banking_fr_lcr +#. odoo-python +#: code:addons/account_banking_fr_lcr/models/res_partner_bank.py:0 +#, python-format +msgid "" +"Bills of exchange can only use French bank accounts. The IBAN " +"'%(acc_number)s' of partner '%(partner)s' is not a French IBAN." +msgstr "" +"Les lettres de change ne peuvent utiliser que des comptes bancaires " +"français. L'IBAN '%(acc_number)s' du partenaire '%(partner)s' n'est pas un " +"IBAN français." + +#. module: account_banking_fr_lcr +#. odoo-python +#: code:addons/account_banking_fr_lcr/models/res_partner_bank.py:0 +#, python-format +msgid "" +"Bills of exchange can only use IBAN bank accounts. Bank account " +"'%(acc_number)s' of partner '%(partner)s' is not an IBAN." +msgstr "" +"Les lettres de change ne peuvent utiliser que des comptes bancaires de type IBAN. Le " +"compte bancaire '%(acc_number)s' du partenaire '%(partner)s' n'est pas un " +"IBAN." #. module: account_banking_fr_lcr #. odoo-python @@ -26,6 +110,77 @@ msgstr "" msgid "Cannot convert the field '%s' to ASCII" msgstr "Impossible de convertir le champ '%s' en ASCII" +#. module: account_banking_fr_lcr +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_payment_order__fr_lcr_collection_option +msgid "Collection Option" +msgstr "Option d'encaissement" + +#. module: account_banking_fr_lcr +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_payment_mode__fr_lcr_convention_type +msgid "Convention Type" +msgstr "Type de convention" + +#. module: account_banking_fr_lcr +#. odoo-python +#: code:addons/account_banking_fr_lcr/models/account_move.py:0 +#, python-format +msgid "" +"Customer invoice '%(move)s' is configured with payment mode " +"'%(payment_mode)s' which require a bill of exchange bank account." +msgstr "" +"La facture client \"%(move)s\" est configurée avec le mode de paiement " +"\"%(payment_mode)s\" qui nécessite un compte bancaire pour la lettre de change." + +#. module: account_banking_fr_lcr +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_payment_mode__fr_lcr_dailly +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_payment_order__fr_lcr_dailly +msgid "Dailly Convention" +msgstr "Convention Dailly" + +#. module: account_banking_fr_lcr +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_payment_order__fr_lcr_dailly_option +msgid "Dailly Option" +msgstr "Option Dailly" + +#. module: account_banking_fr_lcr +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_payment_mode__fr_lcr_default_collection_option +msgid "Default Collection Option" +msgstr "Option d'encaissement par défaut" + +#. module: account_banking_fr_lcr +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_payment_mode__fr_lcr_default_dailly_option +msgid "Default Dailly Option" +msgstr "Option Dailly par défaut" + +#. module: account_banking_fr_lcr +#. odoo-python +#: code:addons/account_banking_fr_lcr/models/account_payment_order.py:0 +#, python-format +msgid "" +"Error in the generation of the CFONB file: '%(field)s' should be a string, " +"but it is %(value_type)s (value: %(value)s)." +msgstr "" +"Erreur dans la génération du fichier CFONB : '%(field)s' devrait être une " +"chaîne de caractères, mais il s'agit de %(value_type)s (valeur : %(value)s)." + +#. module: account_banking_fr_lcr +#. odoo-python +#: code:addons/account_banking_fr_lcr/models/account_payment_order.py:0 +#, python-format +msgid "" +"Error in the generation of the CFONB file: the field '%s' is empty or 0. It " +"should have a non-null value." +msgstr "" +"Erreur dans la génération du fichier CFONB : le champ '%s' est vide ou 0. Il " +"doit avoir une valeur non nulle." + +#. module: account_banking_fr_lcr +#: model:ir.model.fields,help:account_banking_fr_lcr.field_account_payment_mode__fr_lcr_convention_type +msgid "Field C1 'Convention Type' in CFONB header line, 6 characters maximum." +msgstr "" +"Champ C1 \"Convention Type\" dans la ligne d'en-tête de la CFONB, 6 " +"caractères maximum." + #. module: account_banking_fr_lcr #. odoo-python #: code:addons/account_banking_fr_lcr/models/account_payment_order.py:0 @@ -34,11 +189,13 @@ msgid "" "For the bank account '%(acc_number)s' of partner '%(partner)s', the Bank " "Account Type should be 'IBAN'." msgstr "" +"Pour le compte bancaire \"%(acc_number)s\" du partenaire \"%(partner)s\", le " +"type de compte bancaire doit être \"IBAN\"." #. module: account_banking_fr_lcr -#: model:ir.model,name:account_banking_fr_lcr.model_account_move_line -msgid "Journal Item" -msgstr "Écriture comptable" +#: model:ir.model,name:account_banking_fr_lcr.model_account_move +msgid "Journal Entry" +msgstr "Pièce comptable" #. module: account_banking_fr_lcr #: model:account.payment.mode,name:account_banking_fr_lcr.payment_mode_lcr @@ -53,22 +210,83 @@ msgid "" "LCR are only for French bank accounts. The IBAN '%(acc_number)s' of partner " "'%(partner)s' is not a French IBAN." msgstr "" +"Les LCR ne concernent que les comptes bancaires français. L'IBAN " +"'%(acc_number)s' du partenaire '%(partner)s' n'est pas un IBAN français." #. module: account_banking_fr_lcr #: model:account.payment.method,name:account_banking_fr_lcr.fr_lcr msgid "Lettre de Change Relevé" msgstr "Lettre de Change Relevé" +#. module: account_banking_fr_lcr +#: model:ir.model.fields.selection,name:account_banking_fr_lcr.selection__account_payment_mode__fr_lcr_type__accepted +msgid "Lettre de change acceptée" +msgstr "Lettre de change acceptée" + +#. module: account_banking_fr_lcr +#: model:ir.model.fields.selection,name:account_banking_fr_lcr.selection__account_payment_mode__fr_lcr_type__not_accepted +msgid "Lettre de change non acceptée (LCR directe)" +msgstr "Lettre de change non acceptée (LCR directe)" + +#. module: account_banking_fr_lcr +#. odoo-python +#: code:addons/account_banking_fr_lcr/models/account_payment_order.py:0 +#, python-format +msgid "" +"On debit order '%(order)s', the value date has been set to %(value_date)s: " +"it must be in the future." +msgstr "" +"Sur l'ordre de prélèvement '%(order)s', la date de valeur a été fixée à " +"%(value_date)s: elle doit être dans le futur." + +#. module: account_banking_fr_lcr +#: model:ir.model,name:account_banking_fr_lcr.model_account_payment_line +msgid "Payment Lines" +msgstr "Lignes de paiement" + #. module: account_banking_fr_lcr #: model:ir.model,name:account_banking_fr_lcr.model_account_payment_method msgid "Payment Methods" -msgstr "" +msgstr "Méthodes de paiement" + +#. module: account_banking_fr_lcr +#: model:ir.model,name:account_banking_fr_lcr.model_account_payment_mode +msgid "Payment Modes" +msgstr "Modes de paiement" #. module: account_banking_fr_lcr #: model:ir.model,name:account_banking_fr_lcr.model_account_payment_order msgid "Payment Order" msgstr "Ordre de paiement" +#. module: account_banking_fr_lcr +#: model:ir.model,name:account_banking_fr_lcr.model_account_payment +msgid "Payments" +msgstr "Paiements" + +#. module: account_banking_fr_lcr +#: model_terms:ir.ui.view,arch_db:account_banking_fr_lcr.view_move_form +msgid "Print Bill of Exchange" +msgstr "Imprimer la lettre de change" + +#. module: account_banking_fr_lcr +#. odoo-python +#: code:addons/account_banking_fr_lcr/models/account_payment_order.py:0 +#, python-format +msgid "The Collection Option is not set on debit order '%s'." +msgstr "L'option d'encaissement n'est pas activée pour l'ordre de prélèvement '%s'." + +#. module: account_banking_fr_lcr +#. odoo-python +#: code:addons/account_banking_fr_lcr/models/account_payment_line.py:0 +#, python-format +msgid "" +"The currency of payment line '%(payment_line)s' is %(currency)s. To be " +"included in a french bill of exchange, the currency must be EUR." +msgstr "" +"La devise de la ligne de paiement '%(payment_line)s' est %(currency)s. Pour " +"être incluse dans une lettre de change française, la devise doit être l'euro." + #. module: account_banking_fr_lcr #. odoo-python #: code:addons/account_banking_fr_lcr/models/account_payment_order.py:0 @@ -77,6 +295,8 @@ msgid "" "The currency of payment line '%(payment_line)s' is '%(currency)s'. To be " "included in a French LCR, the currency must be EUR." msgstr "" +"La devise de la ligne de paiement \"%(payment_line)s\" est \"%(currency)s\". " +"Pour être incluse dans un LCR français, la devise doit être l'euro." #. module: account_banking_fr_lcr #. odoo-python @@ -86,35 +306,27 @@ msgid "The field '%s' is empty or 0. It should have a non-null value." msgstr "" "Le champ '%s' est vide ou égal à 0. Il devrait avoir une valeur non nulle." -#~ msgid "Display Name" -#~ msgstr "Nom affiché" - -#, python-format -#~ msgid "" -#~ "For the bank account '%s' of partner '%s', the Bank Account Type should " -#~ "be 'IBAN'." -#~ msgstr "" -#~ "Pour le compte bancaire '%s' du partenaire '%s', le type de compte " -#~ "bancaire devrait être 'IBAN'." - -#~ msgid "ID" -#~ msgstr "ID" - +#. module: account_banking_fr_lcr +#. odoo-python +#: code:addons/account_banking_fr_lcr/models/account_payment_mode.py:0 #, python-format -#~ msgid "" -#~ "LCR are only for French bank accounts. The IBAN '%s' of partner '%s' is " -#~ "not a French IBAN." -#~ msgstr "" -#~ "Les LCR ne fonctionnent qu'avec des comptes bancaires français. L'IBAN " -#~ "'%s' du partenaire '%s' n'est pas un IBAN français." +msgid "The field 'Bill of Exchange Type' must be set on payment mode '%s'." +msgstr "" +"Le champ \"Type de lettre de change\" doit être défini sur le mode de " +"paiement \"%s\"." -#~ msgid "Last Modified on" -#~ msgstr "Dernière modification le" +#. module: account_banking_fr_lcr +#: model:ir.model.fields,field_description:account_banking_fr_lcr.field_account_payment_order__fr_lcr_value_date +msgid "Value Date" +msgstr "Date de valeur" +#. module: account_banking_fr_lcr +#. odoo-python +#: code:addons/account_banking_fr_lcr/models/account_payment_order.py:0 #, python-format -#~ msgid "" -#~ "The currency of payment line '%s' is '%s'. To be included in a French " -#~ "LCR, the currency must be EUR." -#~ msgstr "" -#~ "La devise de la ligne de paiement \"%s\" est \"%s\". Pour être incluse " -#~ "dans une LCR, la devise doit être l'euro." +msgid "" +"Value date is not set on debit order '%s'. It is required on letters of " +"exchange with cash discount." +msgstr "" +"La date de valeur n'est pas fixée sur l'ordre de prélèvement \"%s\". Elle est " +"requise sur les lettres de change avec escompte." diff --git a/account_banking_fr_lcr/models/__init__.py b/account_banking_fr_lcr/models/__init__.py index 0c5be616c..66a161100 100644 --- a/account_banking_fr_lcr/models/__init__.py +++ b/account_banking_fr_lcr/models/__init__.py @@ -1,3 +1,7 @@ from . import account_payment_method +from . import account_payment_mode from . import account_payment_order -from . import account_move_line +from . import account_payment_line +from . import account_payment +from . import account_move +from . import res_partner_bank diff --git a/account_banking_fr_lcr/models/account_move.py b/account_banking_fr_lcr/models/account_move.py new file mode 100644 index 000000000..a0b5146e3 --- /dev/null +++ b/account_banking_fr_lcr/models/account_move.py @@ -0,0 +1,290 @@ +# Copyright 2024 Akretion France (https://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import io +import logging + +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import getSampleStyleSheet +from reportlab.pdfgen import canvas +from reportlab.platypus import Paragraph + +from odoo import _, api, fields, models, tools +from odoo.exceptions import UserError +from odoo.tools.misc import format_amount, format_date + +logger = logging.getLogger(__name__) + + +try: + from pypdf import PdfReader, PdfWriter +except (ImportError, IOError) as err: + logger.debug(err) + + +class AccountMove(models.Model): + _inherit = "account.move" + + payment_mode_fr_lcr_type = fields.Selection( + related="payment_mode_id.fr_lcr_type", store=True + ) + fr_lcr_attachment_id = fields.Many2one( + "ir.attachment", string="Bill of Exchange Attachment" + ) + fr_lcr_attachment_name = fields.Char( + related="fr_lcr_attachment_id.name", string="Bill of Exchange Filename" + ) + fr_lcr_attachment_datas = fields.Binary( + related="fr_lcr_attachment_id.datas", string="Bill of Exchange File" + ) + fr_lcr_partner_bank_id = fields.Many2one( + "res.partner.bank", + compute="_compute_fr_lcr_partner_bank_id", + store=True, + precompute=True, + states={"draft": [("readonly", False)]}, + string="Bill of Exchange Bank Account", + help="Bank account of the customer that will be debited by " + "the bill of exchange. By default, Odoo selects the first French " + "IBAN bank account of the partner.", + check_company=True, + tracking=True, + domain="[('partner_id', '=', commercial_partner_id)]", + ) + + @api.depends("partner_id", "payment_mode_id") + def _compute_fr_lcr_partner_bank_id(self): + for move in self: + partner_bank_id = False + if ( + move.move_type == "out_invoice" + and move.payment_method_code == "fr_lcr" + and move.partner_id + and move.partner_id.commercial_partner_id.bank_ids + ): + for partner_bank in move.partner_id.commercial_partner_id.bank_ids: + if ( + partner_bank.acc_type == "iban" + and partner_bank.sanitized_acc_number + and partner_bank.sanitized_acc_number.startswith("FR") + and ( + not partner_bank.company_id + or partner_bank.company_id == move.company_id + ) + ): + partner_bank_id = partner_bank.id + break + move.fr_lcr_partner_bank_id = partner_bank_id + + def _post(self, soft=True): + for move in self: + if move.move_type == "out_invoice" and move.payment_method_code == "fr_lcr": + # We consider bank account as required only for letter of change, + # not for promissory note (we may only know the bank account when receiving it) + if ( + move.payment_mode_fr_lcr_type in ("accepted", "not_accepted") + and not move.fr_lcr_partner_bank_id + ): + raise UserError( + _( + "Customer invoice '%(move)s' is configured with " + "payment mode '%(payment_mode)s' which require " + "a bill of exchange bank account.", + move=move.display_name, + payment_mode=move.payment_mode_id.display_name, + ) + ) + if move.fr_lcr_partner_bank_id: + move.fr_lcr_partner_bank_id._fr_iban_validate() + return super()._post(soft=soft) + + def fr_lcr_print(self): + self.ensure_one() + assert self.state == "posted" + assert self.move_type == "out_invoice" + assert self.payment_method_code == "fr_lcr" + assert self.payment_mode_fr_lcr_type == "accepted" + if self.fr_lcr_attachment_id and self.payment_state not in ( + "in_payment", + "paid", + ): + self.fr_lcr_attachment_id.unlink() + if not self.fr_lcr_attachment_id: + self.fr_lcr_generate_attachment() + action = { + "name": self.fr_lcr_attachment_id.name, + "type": "ir.actions.act_url", + "url": f"web/content/?model={self._name}&id={self.id}&" + f"filename_field=fr_lcr_attachment_name&field=fr_lcr_attachment_datas&" + f"download=true&filename={self.fr_lcr_attachment_id.name}", + "target": "new", + # target: "new" and NOT "self", otherwise you get the following bug: + # after this action, all UserError won't show a pop-up to the user + # but will only show a warning message in the logs until the web + # page is reloaded + } + return action + + def _prepare_fr_lcr_report_values(self): + self.ensure_one() + lang = "fr_FR" + rib = self.fr_lcr_partner_bank_id._fr_iban2rib() + # I take the commercial_partner, because I don't want to have the name + # of a specific contact as customer address + partner = self.commercial_partner_id + company_partner = self.company_id.partner_id + amount_value = format_amount( + self.env, self.amount_residual, self.currency_id, lang_code=lang + ) + ref_tire = self._get_payment_order_communication_direct() + ref_tire_ascii = self.env["account.payment.order"]._prepare_lcr_field( + "Reférence tiré", ref_tire, 10, reference=True + ) + res = { + "amount_check": { + "value": amount_value, + "x": 123, + "y": 147, + "align": "right", + }, + "amount": { + "value": amount_value, + "x": 548, + "y": 147, + "align": "right", + }, + "invoice_date": { + "value": format_date(self.env, self.invoice_date, lang_code=lang), + "x": 145, + "y": 147, + }, + "invoice_date_due": { + "value": format_date(self.env, self.invoice_date_due, lang_code=lang), + "x": 223, + "y": 147, + }, + "ref_tire": { + "value": ref_tire_ascii, + "x": 293, + "y": 147, + }, + "partner_address": { + "value": partner._display_address(), + "x": 251, + "y": 43, + "width": 403 - 251, + "height": 91 - 43, + }, + "company_address": { + "value": company_partner._display_address(), + "x": 46, + "y": 202, + "width": 202 - 46, + "height": 255 - 202, + }, + "company_name": { + "value": company_partner.name, + "x": 304, + "y": 204, + }, + "company_city": { + "value": company_partner.city, + "x": 63, + "y": 181, + }, + "rib_bank": { + "value": rib["bank"], + "x": 56, + "y": 94, + }, + "rib_branch": { + "value": rib["branch"], + "x": 99, + "y": 94, + }, + "rib_account": { + "value": rib["account"], + "x": 144, + "y": 94, + }, + "rib_key": { + "value": rib["key"], + "x": 219, + "y": 94, + }, + "bank_name": { + "value": self.fr_lcr_partner_bank_id.bank_id.name, + "x": 410, + "y": 90, + }, + "partner_siren": { + "value": hasattr(partner, "siren") and partner.siren or False, + "x": 115, + "y": 40, + }, + } + return res + + def fr_lcr_generate_attachment(self): + packet = io.BytesIO() + # create a new PDF that contains the additional text with Reportlab + text_canvas = canvas.Canvas(packet, pagesize=A4) + text_canvas.setFont("Helvetica", 10) + + # for address blocks + styleSheet = getSampleStyleSheet() + style = styleSheet["BodyText"] + style.fontSize = 8 + style.leading = 9 + + # Add text strings and blocks + report_values = self._prepare_fr_lcr_report_values() + for field_name, field_val in report_values.items(): + if field_val["value"]: + if field_name.endswith("_address"): + # Address => use flowable because it is multiline + addr_para = Paragraph( + field_val["value"].replace("\n", "
"), style + ) + addr_para.wrap(field_val["width"], field_val["height"]) + addr_para.drawOn(text_canvas, field_val["x"], field_val["y"]) + elif field_val.get("align") == "right": + text_canvas.drawRightString( + field_val["x"], field_val["y"], field_val["value"] + ) + else: + text_canvas.drawString( + field_val["x"], field_val["y"], field_val["value"] + ) + text_canvas.save() + + # move to the beginning of the StringIO buffer + packet.seek(0) + watermark_pdf_reader = PdfReader(packet) + # read your existing PDF + with tools.file_open( + "account_banking_fr_lcr/reports/lettre_de_change.pdf", "rb" + ) as empty_report_fd: + empty_report_reader = PdfReader(empty_report_fd) + final_report_writer = PdfWriter() + # add the "watermark" (which is the new pdf) on the existing page + page = empty_report_reader.pages[0] + page.merge_page(watermark_pdf_reader.pages[0]) + final_report_writer.add_page(page) + final_report_writer.pages[0].compress_content_streams() + # finally, write "output" to a real file + final_report_io = io.BytesIO() + final_report_writer.write(final_report_io) + final_report_bytes = final_report_io.getvalue() + + filename = "lettre_de_change-%s.pdf" % self.name.replace("/", "-") + attach = self.env["ir.attachment"].create( + { + "name": filename, + "res_id": self.id, + "res_model": self._name, + "raw": final_report_bytes, + } + ) + self.write({"fr_lcr_attachment_id": attach.id}) diff --git a/account_banking_fr_lcr/models/account_move_line.py b/account_banking_fr_lcr/models/account_move_line.py deleted file mode 100644 index b493d8346..000000000 --- a/account_banking_fr_lcr/models/account_move_line.py +++ /dev/null @@ -1,21 +0,0 @@ -# Copyright 2016-2022 Akretion France (http://www.akretion.com/) -# @author: Alexis de Lattre -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). - -from odoo import models - - -class AccountMoveLine(models.Model): - _inherit = "account.move.line" - - def _prepare_payment_line_vals(self, payment_order): - vals = super()._prepare_payment_line_vals(payment_order) - if payment_order.payment_mode_id.payment_method_id.code == "fr_lcr": - # Take the first IBAN account of the partner - bank_account = self.env["res.partner.bank"].search( - [("partner_id", "=", self.partner_id.id), ("acc_type", "=", "iban")], - limit=1, - ) - if bank_account: - vals["partner_bank_id"] = bank_account.id - return vals diff --git a/account_banking_fr_lcr/models/account_payment.py b/account_banking_fr_lcr/models/account_payment.py new file mode 100644 index 000000000..cee8e05d2 --- /dev/null +++ b/account_banking_fr_lcr/models/account_payment.py @@ -0,0 +1,83 @@ +# Copyright 2014-2022 Akretion France (http://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + +from .account_payment_order import LCR_DATE_FORMAT + +LCR_TYPE_CODES = { + "not_accepted": "0", + "accepted": "1", + "promissory_note": "2", +} + + +class AccountPayment(models.Model): + _inherit = "account.payment" + + def _prepare_cfonb_line(self, transactions_count): + """Generate each debit line of the CFONB file""" + # I use French variable names because the specs are in French + self.ensure_one() + order = self.payment_order_id + payment_line = self.payment_line_ids[0] + assert order + code_enregistrement = "06" + code_operation = "60" + numero_enregistrement = str(transactions_count + 1).zfill(8) + reference_tire = order._prepare_lcr_field( + "Référence tiré", self.payment_reference, 10, reference=True + ) + rib = self.partner_bank_id._fr_iban2rib() + + nom_tire = order._prepare_lcr_field("Nom tiré", self.partner_id.name, 24) + if self.partner_bank_id.bank_id: + nom_banque = order._prepare_lcr_field( + "Nom banque", self.partner_bank_id.bank_id.name, 24 + ) + else: + nom_banque = " " * 24 + code_acceptation = LCR_TYPE_CODES[order.payment_mode_id.fr_lcr_type] + montant_centimes = str(round(self.amount * 100)) + zero_montant_centimes = montant_centimes.zfill(12) + if payment_line.move_line_id and payment_line.move_line_id.move_id.invoice_date: + date_creation_dt = payment_line.move_line_id.move_id.invoice_date + else: + date_creation_dt = fields.Date.context_today(self) + date_creation = date_creation_dt.strftime(LCR_DATE_FORMAT) + date_echeance = self.date.strftime(LCR_DATE_FORMAT) + if hasattr(self.partner_id, "siren") and self.partner_id.siren: + siren_tire = self.partner_id.siren + else: + siren_tire = " " * 9 + # I can't use self.name because payment.state == 'draft' so self.name = '/' + reference_tireur = order._prepare_lcr_field( + "Référence tireur", payment_line.name, 10, reference=True + ) + + cfonb_line = "".join( + [ + code_enregistrement, + code_operation, + numero_enregistrement, + " " * (6 + 2), + reference_tire, + nom_tire, + nom_banque, + code_acceptation, + " " * 2, + rib["bank"], + rib["branch"], + rib["account"], + zero_montant_centimes, + " " * 4, + date_echeance, + date_creation, + " " * (4 + 1 + 3 + 3), + siren_tire, + reference_tireur, + ] + ) + assert len(cfonb_line) == 160, "LCR CFONB line must have 160 chars" + return cfonb_line diff --git a/account_banking_fr_lcr/models/account_payment_line.py b/account_banking_fr_lcr/models/account_payment_line.py new file mode 100644 index 000000000..4da5c5c93 --- /dev/null +++ b/account_banking_fr_lcr/models/account_payment_line.py @@ -0,0 +1,39 @@ +# Copyright 2016-2022 Akretion France (http://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import _, models +from odoo.exceptions import UserError + + +class AccountPaymentLine(models.Model): + _inherit = "account.payment.line" + + def _compute_payment_line(self): + res = super()._compute_payment_line() + for line in self: + if ( + line.order_id.payment_mode_id.payment_method_id.code == "fr_lcr" + and line.move_line_id + and line.move_line_id.move_id.fr_lcr_partner_bank_id + ): + line.partner_bank_id = ( + line.move_line_id.move_id.fr_lcr_partner_bank_id.id + ) + return res + + def draft2open_payment_line_check(self): + res = super().draft2open_payment_line_check() + eur_currency_id = self.env.ref("base.EUR").id + if self.currency_id.id != eur_currency_id: + raise UserError( + _( + "The currency of payment line '%(payment_line)s' is " + "%(currency)s. To be included in a french bill of exchange, " + "the currency must be EUR.", + payment_line=self.display_name, + currency=self.currency_id.name, + ) + ) + self.partner_bank_id._fr_iban_validate() + return res diff --git a/account_banking_fr_lcr/models/account_payment_mode.py b/account_banking_fr_lcr/models/account_payment_mode.py new file mode 100644 index 000000000..feaa8de77 --- /dev/null +++ b/account_banking_fr_lcr/models/account_payment_mode.py @@ -0,0 +1,93 @@ +# Copyright 2022 Akretion France (http://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class AccountPaymentMode(models.Model): + _inherit = "account.payment.mode" + + fr_lcr_type = fields.Selection( + [ + ("not_accepted", "Lettre de change non acceptée (LCR directe)"), + ("accepted", "Lettre de change acceptée"), + ("promissory_note", "Billet à ordre"), + ], + compute="_compute_fr_lcr_type", + store=True, + readonly=False, + precompute=True, + string="Bill of Exchange Type", + ) + fr_lcr_default_collection_option = fields.Selection( + "_fr_lcr_collection_option_selection", + string="Default Collection Option", + default="due_date", + ) + fr_lcr_dailly = fields.Boolean(string="Dailly Convention") + fr_lcr_default_dailly_option = fields.Selection( + "_fr_lcr_dailly_option_selection", + default="none", + string="Default Dailly Option", + ) + # It seems this field is only used for Dailly... but not 100% sure + # For the moment, we only display it for Dailly + fr_lcr_convention_type = fields.Char( + string="Convention Type", + size=6, + help="Field C1 'Convention Type' in CFONB header line, 6 characters maximum.", + ) + + @api.model + def _fr_lcr_collection_option_selection(self): + sel = [ + ("due_date", "Encaissement, crédit forfaitaire après l’échéance"), + ( + "due_date_fixed_delay", + "Encaissement, crédit après expiration d’un délai forfaitaire", + ), + ("cash_discount", "Escompte"), + # "Escompte en valeur" is also called "Escompte en compte" + # Great explaination here https://www.netpme.fr/conseil/escompte/ + # click on "2. Modalités de fonctionnement de l’escompte" + ("value_cash_discount", "Escompte en valeur"), + ] + return sel + + @api.model + def _fr_lcr_dailly_option_selection(self): + sel = [ + ("none", "Pas d’indication"), + ("cash_discount", "Cession escompte dans le cadre d’une convention Dailly"), + ( + "debt_pledge", + "Nantissement de créance dans le cadre d’une convention Dailly", + ), + ("out_of_agreement", "Cession ou nantissement hors convention Dailly"), + ] + return sel + + @api.depends("payment_method_id") + def _compute_fr_lcr_type(self): + for mode in self: + fr_lcr_type = False + if mode.payment_method_id and mode.payment_method_id.code == "fr_lcr": + fr_lcr_type = "not_accepted" + mode.fr_lcr_type = fr_lcr_type + + @api.constrains("payment_method_id", "fr_lcr_type") + def _check_fr_lcr(self): + for mode in self: + if ( + mode.payment_method_id + and mode.payment_method_id.code == "fr_lcr" + and not mode.fr_lcr_type + ): + raise ValidationError( + _( + "The field 'Bill of Exchange Type' must be set on payment mode '%s'." + ) + % mode.display_name + ) diff --git a/account_banking_fr_lcr/models/account_payment_order.py b/account_banking_fr_lcr/models/account_payment_order.py index ab276008a..9039a2ceb 100644 --- a/account_banking_fr_lcr/models/account_payment_order.py +++ b/account_banking_fr_lcr/models/account_payment_order.py @@ -3,9 +3,11 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). import logging +import re from odoo import _, api, fields, models from odoo.exceptions import UserError +from odoo.tools.misc import format_date logger = logging.getLogger(__name__) @@ -17,101 +19,173 @@ unidecode = False LCR_DATE_FORMAT = "%d%m%y" +LCR_TYPE_CODES = { + "not_accepted": "0", + "accepted": "1", + "promissory_note": "2", +} +LCR_COLLECTION_OPTION = { + "due_date": "3", + "due_date_fixed_delay": "4", + "cash_discount": "1", + "value_cash_discount": "2", +} +LCR_DAILLY_OPTION = { + "none": "0", + "cash_discount": "1", + "debt_pledge": "2", + "out_of_agreement": "3", +} class AccountPaymentOrder(models.Model): _inherit = "account.payment.order" - @api.model - def _prepare_lcr_field(self, field_name, field_value, size): - """This function is designed to be inherited.""" - if not field_value: - raise UserError( - _("The field '%s' is empty or 0. It should have a non-null " "value.") - % field_name - ) - try: - value = unidecode(field_value) - unallowed_ascii_chars = [ - '"', - "#", - "$", - "%", - "&", - ";", - "<", - ">", - "=", - "@", - "[", - "]", - "^", - "_", - "`", - "{", - "}", - "|", - "~", - "\\", - "!", - ] - for unallowed_ascii_char in unallowed_ascii_chars: - value = value.replace(unallowed_ascii_char, "-") - except Exception: - # seems that unidecode doesn't raise exception so might - # be useless - raise UserError( - _("Cannot convert the field '%s' to ASCII") % field_name - ) from None - value = value.upper() - # Cut if too long - value = value[0:size] - # enlarge if too small - if len(value) < size: - value = value.ljust(size, " ") - assert len(value) == size, "The length of the field is wrong" - return value + fr_lcr_collection_option = fields.Selection( + lambda self: self.env[ + "account.payment.mode" + ]._fr_lcr_collection_option_selection(), + compute="_compute_fr_lcr_fields", + store=True, + precompute=True, + states={"draft": [("readonly", False)]}, + string="Collection Option", + ) + # if fr_lcr_value_date is also used for Dailly, we'll have to change the code and the view + fr_lcr_value_date = fields.Date(string="Value Date") + # invisible, to show or not the field 'fr_lcr_dailly_option' + fr_lcr_dailly = fields.Boolean( + string="Dailly Convention", + compute="_compute_fr_lcr_fields", + store=True, + precompute=True, + ) + fr_lcr_dailly_option = fields.Selection( + lambda self: self.env["account.payment.mode"]._fr_lcr_dailly_option_selection(), + compute="_compute_fr_lcr_fields", + store=True, + precompute=True, + states={"draft": [("readonly", False)]}, + string="Dailly Option", + ) + + @api.depends("payment_mode_id") + def _compute_fr_lcr_fields(self): + for order in self: + fr_lcr_collection_option = False + fr_lcr_dailly = False + fr_lcr_dailly_option = False + if order.payment_mode_id.payment_method_id.code == "fr_lcr": + mode = order.payment_mode_id + fr_lcr_collection_option = mode.fr_lcr_default_collection_option + fr_lcr_dailly = mode.fr_lcr_dailly + fr_lcr_dailly_option = mode.fr_lcr_default_dailly_option + order.fr_lcr_collection_option = fr_lcr_collection_option + order.fr_lcr_dailly = fr_lcr_dailly + order.fr_lcr_dailly_option = fr_lcr_dailly_option + + def draft2open(self): + # I call super() first to raise error immediately if partner_bank_id is missing + # so, in my code below, I know that line.partner_bank_id is set + # Check on payment lines is handled by draft2open_payment_line_check() + # on account.payment.line + res = super().draft2open() + today = fields.Date.context_today(self) + self.env.ref("base.EUR").id + for order in self: + if order.payment_method_code == "fr_lcr": + if not order.fr_lcr_collection_option: + raise UserError( + _("The Collection Option is not set on debit order '%s'.") + % order.display_name + ) + if order.fr_lcr_collection_option in ( + "cash_discount", + "value_cash_discount", + ): + if not order.fr_lcr_value_date: + raise UserError( + _( + "Value date is not set on debit order '%s'. It is " + "required on letters of exchange with cash discount." + ) + % order.display_name + ) + elif order.fr_lcr_value_date < today: + raise UserError( + _( + "On debit order '%(order)s', the value date has been " + "set to %(value_date)s: it must be in the future.", + order=order.display_name, + value_date=format_date( + self.env, order.fr_lcr_value_date + ), + ) + ) + return res @api.model - def _get_rib_from_iban(self, partner_bank): - if partner_bank.acc_type != "iban": + def _prepare_lcr_field(self, field_name, value, size, reference=False): + """if reference is True: cut from end (not from start) + adjust with 0 (instead of space) and only accept letters and digits + """ + if not value: raise UserError( _( - "For the bank account '%(acc_number)s' of partner '%(partner)s', " - "the Bank Account Type should be 'IBAN'." + "Error in the generation of the CFONB file: " + "the field '%s' is empty or 0. It should have a non-null value." ) - % { - "acc_number": partner_bank.acc_number, - "partner": partner_bank.partner_id.display_name, - } + % field_name ) - iban = partner_bank.sanitized_acc_number - if iban[0:2] != "FR": + if not isinstance(value, str): raise UserError( _( - "LCR are only for French bank accounts. The IBAN '%(acc_number)s' " - "of partner '%(partner)s' is not a French IBAN." + "Error in the generation of the CFONB file: " + "'%(field)s' should be a string, " + "but it is %(value_type)s (value: %(value)s).", + field=field_name, + value_type=type(value), + value=value, ) - % { - "acc_number": partner_bank.acc_number, - "partner": partner_bank.partner_id.display_name, - } ) - assert len(iban) == 27, "French IBANs must have 27 caracters" - return { - "code_banque": iban[4:9], - "code_guichet": iban[9:14], - "numero_compte": iban[14:25], - "cle_rib": iban[25:27], - } + value = unidecode(value) + # page 25 of the CFONB specs: + # allowed chars are a-z, digits and * ( ) . , / + - : + value = value.upper() + if reference: + value = re.sub(r"[^A-Z0-9]", "", value) + else: + value = re.sub(r"[^A-Z0-9\*\(\)\.,/\+\-:\s]", "-", value) + # Cut if too long + if len(value) > size: + if reference: + value = value[-size:] # cut from end + else: + value = value[:size] # cut from start + # enlarge if too small: add spaces at the end + elif len(value) < size: + if reference: + value = value.rjust(size, "0") + else: + value = value.ljust(size, " ") + assert len(value) == size, "The length of the field is wrong" + return value - @api.model def _prepare_first_cfonb_line(self): """Generate the header line of the CFONB file""" + self.ensure_one() code_enregistrement = "03" code_operation = "60" numero_enregistrement = "00000001" numero_emetteur = "000000" # It is not needed for LCR + if self.payment_mode_id.fr_lcr_convention_type: + type_convention = self._prepare_lcr_field( + "Type de convention", + self.payment_mode_id.fr_lcr_convention_type, + 6, + ) + else: + type_convention = " " * 6 # this number is only required for old national direct debits today_dt = fields.Date.context_today(self) date_remise = today_dt.strftime(LCR_DATE_FORMAT) @@ -123,28 +197,45 @@ def _prepare_first_cfonb_line(self): self.company_partner_bank_id.bank_id.name, 24, ) - code_entree = "3" - code_dailly = " " + code_entree = LCR_COLLECTION_OPTION[self.fr_lcr_collection_option] + if self.fr_lcr_dailly and self.fr_lcr_dailly_option: + code_dailly = LCR_DAILLY_OPTION[self.fr_lcr_dailly_option] + else: + code_dailly = " " code_monnaie = "E" - rib = self._get_rib_from_iban(self.company_partner_bank_id) + rib = self.company_partner_bank_id._fr_iban2rib() ref_remise = self._prepare_lcr_field("Référence de la remise", self.name, 11) + if self.fr_lcr_collection_option in ("cash_discount", "value_cash_discount"): + date_de_valeur = self.fr_lcr_value_date.strftime(LCR_DATE_FORMAT) + else: + date_de_valeur = " " * 6 + if ( + hasattr(self.company_id.partner_id, "siren") + and self.company_id.partner_id.siren + ): + siren_cedant = self.company_id.partner_id.siren + " " * 6 + else: + siren_cedant = " " * 15 cfonb_line = "".join( [ code_enregistrement, code_operation, numero_enregistrement, numero_emetteur, - " " * 6, + type_convention, date_remise, raison_sociale_cedant, domiciliation_bancaire_cedant, code_entree, code_dailly, code_monnaie, - rib["code_banque"], - rib["code_guichet"], - rib["numero_compte"], - " " * (16 + 6 + 10 + 15), + rib["bank"], + rib["branch"], + rib["account"], + " " * 16, + date_de_valeur, + " " * 10, + siren_cedant, # Date de valeur is left empty because it is only for # "remise à l'escompte" and we do # "Encaissement, crédit forfaitaire après l’échéance" @@ -152,61 +243,6 @@ def _prepare_first_cfonb_line(self): ] ) assert len(cfonb_line) == 160, "LCR CFONB line must have 160 chars" - cfonb_line += "\r\n" - return cfonb_line - - @api.model - def _prepare_cfonb_line(self, line, transactions_count): - """Generate each debit line of the CFONB file""" - # I use French variable names because the specs are in French - code_enregistrement = "06" - code_operation = "60" - numero_enregistrement = str(transactions_count + 1).zfill(8) - reference_tire = self._prepare_lcr_field( - "Référence tiré", line.payment_reference, 10 - ) - rib = self._get_rib_from_iban(line.partner_bank_id) - - nom_tire = self._prepare_lcr_field("Nom tiré", line.partner_id.name, 24) - if line.partner_bank_id.bank_id: - nom_banque = self._prepare_lcr_field( - "Nom banque", line.partner_bank_id.bank_id.name, 24 - ) - else: - nom_banque = " " * 24 - code_acceptation = "0" - montant_centimes = str(round(line.amount * 100)) - zero_montant_centimes = montant_centimes.zfill(12) - today_dt = fields.Date.context_today(self) - date_creation = today_dt.strftime(LCR_DATE_FORMAT) - requested_date_dt = line.date - date_echeance = requested_date_dt.strftime(LCR_DATE_FORMAT) - reference_tireur = reference_tire - - cfonb_line = "".join( - [ - code_enregistrement, - code_operation, - numero_enregistrement, - " " * (6 + 2), - reference_tire, - nom_tire, - nom_banque, - code_acceptation, - " " * 2, - rib["code_banque"], - rib["code_guichet"], - rib["numero_compte"], - zero_montant_centimes, - " " * 4, - date_echeance, - date_creation, - " " * (4 + 1 + 3 + 3 + 9), - reference_tireur, - ] - ) - assert len(cfonb_line) == 160, "LCR CFONB line must have 160 chars" - cfonb_line += "\r\n" return cfonb_line def _prepare_final_cfonb_line(self, total_amount, transactions_count): @@ -229,35 +265,38 @@ def _prepare_final_cfonb_line(self, total_amount, transactions_count): assert len(cfonb_line) == 160, "LCR CFONB line must have 160 chars" return cfonb_line + def _fr_lcr_line_separator(self): + """It seems that some bank don't want a line break. For the moment, we have this hook. + If it is confirm, we'll make it a configuration parameter""" + return "\r\n" + def generate_payment_file(self): """Creates the LCR CFONB file.""" self.ensure_one() if self.payment_method_id.code != "fr_lcr": return super().generate_payment_file() - cfonb_string = self._prepare_first_cfonb_line() + cfonb_lines = [] + cfonb_lines.append(self._prepare_first_cfonb_line()) total_amount = 0.0 transactions_count = 0 - eur_currency = self.env.ref("base.EUR") - # Iterate each bank payment lines - for line in self.payment_ids: - if line.currency_id != eur_currency: - raise UserError( - _( - "The currency of payment line '%(payment_line)s' is " - "'%(currency)s'. To be included in a French LCR, " - "the currency must be EUR." - ) - % { - "payment_line": line.display_name, - "currency": line.currency_id.name, - } - ) + eur_currency_id = self.env.ref("base.EUR").id + for payment in self.payment_ids: + assert payment.currency_id.id == eur_currency_id transactions_count += 1 - cfonb_string += self._prepare_cfonb_line(line, transactions_count) - total_amount += line.amount + cfonb_lines.append(payment._prepare_cfonb_line(transactions_count)) + total_amount += payment.amount - cfonb_string += self._prepare_final_cfonb_line(total_amount, transactions_count) - - filename = "LCR_%s.txt" % self.name.replace("/", "-") - return (cfonb_string.encode("ascii"), filename) + cfonb_lines.append( + self._prepare_final_cfonb_line(total_amount, transactions_count) + ) + if self.payment_mode_id.fr_lcr_type == "promissory_note": + file_prefix = "BOR" + else: + file_prefix = "LCR" + filename = f"{file_prefix}_{self.name.replace('/', '-')}.txt" + line_separator = self._fr_lcr_line_separator() + logger.debug("LCR line separator is %s", line_separator) + cfonb_string = line_separator.join(cfonb_lines) + cfonb_bytes = cfonb_string.encode("ascii") + return (cfonb_bytes, filename) diff --git a/account_banking_fr_lcr/models/res_partner_bank.py b/account_banking_fr_lcr/models/res_partner_bank.py new file mode 100644 index 000000000..144e5b060 --- /dev/null +++ b/account_banking_fr_lcr/models/res_partner_bank.py @@ -0,0 +1,50 @@ +# Copyright 2024 Akretion France (https://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import _, models +from odoo.exceptions import UserError + + +class ResPartnerBank(models.Model): + _inherit = "res.partner.bank" + + def _fr_iban_validate(self): + self.ensure_one() + if self.acc_type != "iban": + raise UserError( + _( + "Bills of exchange can only use IBAN bank accounts. " + "Bank account '%(acc_number)s' of partner '%(partner)s' " + "is not an IBAN." + ) + % { + "acc_number": self.acc_number, + "partner": self.partner_id.display_name, + } + ) + if not self.sanitized_acc_number.startswith("FR"): + raise UserError( + _( + "Bills of exchange can only use French bank accounts. " + "The IBAN '%(acc_number)s' of partner '%(partner)s' " + "is not a French IBAN." + ) + % { + "acc_number": self.acc_number, + "partner": self.partner_id.display_name, + } + ) + assert ( + len(self.sanitized_acc_number) == 27 + ), "French IBANs must have 27 caracters" + + def _fr_iban2rib(self): + self._fr_iban_validate() + acc_number = self.sanitized_acc_number + return { + "bank": acc_number[4:9], # code banque + "branch": acc_number[9:14], # code guichet + "account": acc_number[14:25], # numéro de compte + "key": acc_number[25:27], # clé RIB + } diff --git a/account_banking_fr_lcr/readme/CONFIGURE.rst b/account_banking_fr_lcr/readme/CONFIGURE.rst index 6ea29093d..dfe26bea0 100644 --- a/account_banking_fr_lcr/readme/CONFIGURE.rst +++ b/account_banking_fr_lcr/readme/CONFIGURE.rst @@ -1,3 +1,10 @@ To configure this module, you need to create a new payment mode linked to the payment method *Lettre de Change Relevé* that is automatically created when you install this module. + +Once you selected this payment method, you will have a new section *Bill of Exchange* on the payment mode where you will have to configure: + +* the *LCR type*: *Lettre de change non acceptée (LCR directe)*, *Lettre de change acceptée* or *Billet à ordre*, +* the *Default Collection Option*, +* if you have a *Dailly Convention*, +* in case you have a Dailly convention, you will be able to configure the *Default Dailly Option* and the *Convention Type*. diff --git a/account_banking_fr_lcr/readme/DESCRIPTION.rst b/account_banking_fr_lcr/readme/DESCRIPTION.rst index 936284726..392addab6 100644 --- a/account_banking_fr_lcr/readme/DESCRIPTION.rst +++ b/account_banking_fr_lcr/readme/DESCRIPTION.rst @@ -1,11 +1,14 @@ -This module adds support for French Letters of Change (in French: -*Lettre de Change Relevé* aka LCR). This module supports direct LCR -(in French, *LCR Directe*) and not paper LCR. +This module adds support for French Letters of Change. This module supports: -This payment type is still in use in France and it is *not* replaced by SEPA -one-off Direct Debits. +* **Direct letter of change** (in French : *Lettre de change directe* or *LCR directe*), +* **Accepted letter of change** (in French : *Lettre de change acceptée* ; I call it *paper letter of change*), +* **Promissory note** (in French : *Billet à ordre*), -With this module, you can generate an LCR CFONB file to send to your -bank. Then, your customer will be notified by their bank about the debit -(amount, date of debit). Eventually, the debit will take place at the -planned date. +It supports cash discounts debit orders and Dailly convention. + +This module has 2 main features: + +* for **Accepted Letter of Change**, generate a paper letter of change as PDF following the official layout NF K 11-030-1. +* generate of LCR (or BOR) CFONB files to send to your bank. + +This module follows the specifications published on the `CFONB website `_, section *Espace documentaire > Instruments de paiement > Effet de commerce* (document version of September 2002). diff --git a/account_banking_fr_lcr/readme/INSTALL.rst b/account_banking_fr_lcr/readme/INSTALL.rst new file mode 100644 index 000000000..1cc815f1b --- /dev/null +++ b/account_banking_fr_lcr/readme/INSTALL.rst @@ -0,0 +1,6 @@ +This module requires 2 Python libs: + +* `pypdf `_ version 3.10 or above, +* `unidecode `_ (any version). + +In order to have the SIREN of the company and of the customer set in the CFONB file (optional field) and printed on the paper letter of exchange, the OCA module **l10n_fr_siret** must be installed. The installation of the module **l10n_fr_siret** is optional (because the SIREN field in the CFONB file is optional). diff --git a/account_banking_fr_lcr/readme/USAGE.rst b/account_banking_fr_lcr/readme/USAGE.rst index 2bacb4bd0..25354933e 100644 --- a/account_banking_fr_lcr/readme/USAGE.rst +++ b/account_banking_fr_lcr/readme/USAGE.rst @@ -1,2 +1,9 @@ -To use this module, you need to create a new Debit Order and -select the LCR payment mode. +This module adds a new field *Bill of Exchange Bank Account* on customer invoices to select the bank account of the customer that will be debited by the letter of exchange. This bank account must be a french IBAN. + +If you configured the payment mode for **Accepted Letter of Change**, you will have a button *Print Bill of Exchange* on customer invoices to get the letter of change as PDF. + +This module uses the standard workflow of debit orders as implemented in the OCA module **account_payment_order**. A debit order linked to a payment mode with the payment method *Lettre de change relevé* has a few additionnal constraints: + +* all payment lines must be in euro currency, +* the bank accounts on the payment lines must be french IBANs, +* if the payment order is configured with cash discount, you must configure the value date on the payment order (new field added by this module). diff --git a/account_banking_fr_lcr/reports/lettre_de_change.pdf b/account_banking_fr_lcr/reports/lettre_de_change.pdf new file mode 100644 index 000000000..e820e098a Binary files /dev/null and b/account_banking_fr_lcr/reports/lettre_de_change.pdf differ diff --git a/account_banking_fr_lcr/static/description/index.html b/account_banking_fr_lcr/static/description/index.html index 82a93b73f..52ea2fc0e 100644 --- a/account_banking_fr_lcr/static/description/index.html +++ b/account_banking_fr_lcr/static/description/index.html @@ -1,4 +1,3 @@ - @@ -370,42 +369,69 @@

French Letter of Change

!! source digest: sha256:cc2dfe4e4e92e172fa75427250ce7cf014a641c59a87e572cd9733e71c65b408 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

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

-

This module adds support for French Letters of Change (in French: -Lettre de Change Relevé aka LCR). This module supports direct LCR -(in French, LCR Directe) and not paper LCR.

-

This payment type is still in use in France and it is not replaced by SEPA -one-off Direct Debits.

-

With this module, you can generate an LCR CFONB file to send to your -bank. Then, your customer will be notified by their bank about the debit -(amount, date of debit). Eventually, the debit will take place at the -planned date.

+

This module adds support for French Letters of Change. This module supports:

+
    +
  • Direct letter of change (in French : Lettre de change directe or LCR directe),
  • +
  • Accepted letter of change (in French : Lettre de change acceptée ; I call it paper letter of change),
  • +
  • Promissory note (in French : Billet à ordre),
  • +
+

It supports cash discounts debit orders and Dailly convention.

+

This module has 2 main features:

+
    +
  • for Accepted Letter of Change, generate a paper letter of change as PDF following the official layout NF K 11-030-1.
  • +
  • generate of LCR (or BOR) CFONB files to send to your bank.
  • +
+

This module follows the specifications published on the CFONB website, section Espace documentaire > Instruments de paiement > Effet de commerce (document version of September 2002).

Table of contents

+
+

Installation

+

This module requires 2 Python libs:

+ +

In order to have the SIREN of the company and of the customer set in the CFONB file (optional field) and printed on the paper letter of exchange, the OCA module l10n_fr_siret must be installed. The installation of the module l10n_fr_siret is optional (because the SIREN field in the CFONB file is optional).

+
-

Configuration

+

Configuration

To configure this module, you need to create a new payment mode linked to the payment method Lettre de Change Relevé that is automatically created when you install this module.

+

Once you selected this payment method, you will have a new section Bill of Exchange on the payment mode where you will have to configure:

+
    +
  • the LCR type: Lettre de change non acceptée (LCR directe), Lettre de change acceptée or Billet à ordre,
  • +
  • the Default Collection Option,
  • +
  • if you have a Dailly Convention,
  • +
  • in case you have a Dailly convention, you will be able to configure the Default Dailly Option and the Convention Type.
  • +
-

Usage

-

To use this module, you need to create a new Debit Order and -select the LCR payment mode.

+

Usage

+

This module adds a new field Bill of Exchange Bank Account on customer invoices to select the bank account of the customer that will be debited by the letter of exchange. This bank account must be a french IBAN.

+

If you configured the payment mode for Accepted Letter of Change, you will have a button Print Bill of Exchange on customer invoices to get the letter of change as PDF.

+

This module uses the standard workflow of debit orders as implemented in the OCA module account_payment_order. A debit order linked to a payment mode with the payment method Lettre de change relevé has a few additionnal constraints:

+
    +
  • all payment lines must be in euro currency,
  • +
  • the bank accounts on the payment lines must be french IBANs,
  • +
  • if the payment order is configured with cash discount, you must configure the value date on the payment order (new field added by this module).
  • +
-

Bug Tracker

+

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 @@ -413,21 +439,21 @@

Bug Tracker

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

-

Credits

+

Credits

-

Authors

+

Authors

  • Akretion
-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association

OCA, or the Odoo Community Association, is a nonprofit organization whose diff --git a/account_banking_fr_lcr/tests/__init__.py b/account_banking_fr_lcr/tests/__init__.py new file mode 100644 index 000000000..18530013c --- /dev/null +++ b/account_banking_fr_lcr/tests/__init__.py @@ -0,0 +1 @@ +from . import test_fr_lcr diff --git a/account_banking_fr_lcr/tests/test_fr_lcr.py b/account_banking_fr_lcr/tests/test_fr_lcr.py new file mode 100644 index 000000000..8306c80fe --- /dev/null +++ b/account_banking_fr_lcr/tests/test_fr_lcr.py @@ -0,0 +1,362 @@ +# Copyright 2024 Akretion France (https://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import base64 +from datetime import timedelta + +from odoo import Command, fields +from odoo.exceptions import UserError +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged("post_install", "-at_install") +class TestFrLcr(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.order_obj = cls.env["account.payment.order"] + cls.eur_currency = cls.env.ref("base.EUR") + cls.today = fields.Date.today() + cls.today_plus2 = cls.today + timedelta(days=2) + cls.company = cls.env["res.company"].create( + {"name": "LCR Company", "currency_id": cls.eur_currency.id} + ) + cls.account_payable = cls.env["account.account"].create( + { + "code": "401100XX", + "name": "Test Payable Account", + "account_type": "liability_payable", + "reconcile": True, + "company_id": cls.company.id, + } + ) + cls.account_receivable = cls.env["account.account"].create( + { + "code": "411100XX", + "name": "Test Receivable Account", + "account_type": "asset_receivable", + "reconcile": True, + "company_id": cls.company.id, + } + ) + cls.income_account = cls.env["account.account"].create( + { + "code": "707000XX", + "name": "Test Income Account", + "account_type": "income", + "company_id": cls.company.id, + } + ) + cls.in_payment_account = cls.env["account.account"].create( + { + "code": "511500XX", + "name": "Test Incoming Payment Account", + "account_type": "asset_current", + "reconcile": True, + "company_id": cls.company.id, + } + ) + cls.company.account_journal_payment_debit_account_id = cls.in_payment_account.id + + cls.partner1 = cls.env["res.partner"].create( + { + "name": "Customer1 LCR", + "company_id": cls.company.id, + } + ) + cls.partner1_bank1 = cls.env["res.partner.bank"].create( + { + "acc_number": "73925873265832", # Not an IBAN + "partner_id": cls.partner1.id, + } + ) + cls.partner1_bank2 = cls.env["res.partner.bank"].create( + { + "acc_number": "DK11 1234 5678 4444 99", + "partner_id": cls.partner1.id, + } + ) + + cls.partner1_bank3 = cls.env["res.partner.bank"].create( + { + "acc_number": "FR89 1111 9999 8888 5555 9999 987", + "partner_id": cls.partner1.id, + } + ) + cls.partner1_bank4 = cls.env["res.partner.bank"].create( + { + "acc_number": "FR31 5353 4646 1212 7474 2323 678", + "partner_id": cls.partner1.id, + } + ) + + cls.partner2 = cls.env["res.partner"].create( + { + "name": "Customer2 LCR", + "company_id": cls.company.id, + } + ) + cls.partner2_bank1 = cls.env["res.partner.bank"].create( + { + "acc_number": "FR04 1212 2626 3636 4646 4747 676", + "partner_id": cls.partner2.id, + } + ) + cls.partner2_bank2 = cls.env["res.partner.bank"].create( + { + "acc_number": "FR89 5454 7777 3434 6363 7654 987", + "partner_id": cls.partner2.id, + } + ) + + cls.company_bank = cls.env["res.partner.bank"].create( + { + "company_id": cls.company.id, + "partner_id": cls.company.partner_id.id, + "bank_id": ( + cls.env.ref("account_payment_mode.bank_la_banque_postale").id + ), + "acc_number": "FR10 1212 2323 3434 4545 4747 676", + } + ) + cls.bank_journal = cls.env["account.journal"].create( + { + "company_id": cls.company.id, + "name": "Company Bank journal", + "type": "bank", + "code": "BNKLC", + "payment_sequence": False, + "bank_account_id": cls.company_bank.id, + "bank_id": cls.company_bank.bank_id.id, + } + ) + cls.sale_journal = cls.env["account.journal"].create( + { + "name": "Sale Journal Test", + "code": "SALE", + "type": "sale", + "company_id": cls.company.id, + "default_account_id": cls.income_account.id, + } + ) + cls.payment_mode = cls.env["account.payment.mode"].create( + { + "name": "LCR client", + "company_id": cls.company.id, + "payment_method_id": cls.env.ref("account_banking_fr_lcr.fr_lcr").id, + "bank_account_link": "fixed", + "fixed_journal_id": cls.bank_journal.id, + "fr_lcr_type": "not_accepted", + } + ) + + def create_invoice(self, partner_id, price_unit, inv_type="out_invoice", post=True): + line_vals = { + "name": "Great service", + "quantity": 1, + "account_id": self.income_account.id, + "price_unit": price_unit, + } + invoice = self.env["account.move"].create( + { + "partner_id": partner_id, + "reference_type": "free", + "currency_id": self.eur_currency.id, + "move_type": inv_type, + "journal_id": self.sale_journal.id, + "date": self.today, + "payment_mode_id": self.payment_mode.id, + "invoice_line_ids": [Command.create(line_vals)], + } + ) + if post: + invoice.action_post() + self.assertEqual(invoice.state, "posted") + else: + self.assertEqual(invoice.state, "draft") + return invoice + + def test_prepare_lcr_field(self): + allowed_chars = "ABCZ0123456789*().,/+-: " + self.assertEqual( + self.order_obj._prepare_lcr_field( + "TEST", allowed_chars, len(allowed_chars) + ), + allowed_chars, + ) + testmap = { + '42:üûéèàÉÈ?@"^': "42:UUEEAEE----", + "({non %*€})": "(-NON -*EUR-)", + "_Niña$;,[]": "-NINA--,--", + "ça va /pas!.\\|": "CA VA /PAS-.--", + "narrow white space:\u2009.": "NARROW WHITE SPACE: .", + } + for src, dest in testmap.items(): + self.assertEqual( + self.order_obj._prepare_lcr_field("TEST", src, 30), + dest + " " * (30 - len(dest)), + ) + with self.assertRaises(UserError): + self.order_obj._prepare_lcr_field("TEST", False, 50) + with self.assertRaises(UserError): + self.order_obj._prepare_lcr_field("TEST", 42, 50) + with self.assertRaises(UserError): + self.order_obj._prepare_lcr_field("TEST", 42.12, 140) + self.assertEqual( + self.order_obj._prepare_lcr_field("TEST", "123@ûZZZ", 5), "123-U" + ) + self.assertEqual(self.order_obj._prepare_lcr_field("TEST", "1234", 2), "12") + # test reference longer + self.assertEqual( + self.order_obj._prepare_lcr_field( + "TEST", "Fa/2024/0055", 9, reference=True + ), + "A20240055", + ) + # test reference shorter + self.assertEqual( + self.order_obj._prepare_lcr_field("TEST", "Fa/24-0055", 10, reference=True), + "00FA240055", + ) + + def lcr_full_scenario(self): + invoice1 = self.create_invoice(self.partner1.id, 112.0, post=False) + self.assertEqual(invoice1.fr_lcr_partner_bank_id, self.partner1_bank3) + invoice1.fr_lcr_partner_bank_id = self.partner1_bank1.id + with self.assertRaises(UserError): + invoice1.action_post() + invoice1.fr_lcr_partner_bank_id = self.partner1_bank2.id + with self.assertRaises(UserError): + invoice1.action_post() + # change bank account + invoice1.fr_lcr_partner_bank_id = self.partner1_bank4.id + invoice1.action_post() + invoice2 = self.create_invoice(self.partner2.id, 42.0) + self.assertEqual(invoice2.fr_lcr_partner_bank_id, self.partner2_bank1) + for inv in [invoice1, invoice2]: + if inv.payment_mode_fr_lcr_type == "accepted": + inv.fr_lcr_print() + self.assertTrue(inv.fr_lcr_attachment_id) + action = inv.create_account_payment_line() + self.assertEqual(action["res_model"], "account.payment.order") + payment_order = self.order_obj.browse(action["res_id"]) + self.assertEqual(payment_order.payment_type, "inbound") + self.assertEqual(payment_order.payment_mode_id, self.payment_mode) + self.assertEqual(payment_order.journal_id, self.bank_journal) + self.assertEqual( + payment_order.fr_lcr_collection_option, + payment_order.payment_mode_id.fr_lcr_default_collection_option, + ) + self.assertEqual( + payment_order.fr_lcr_dailly, payment_order.payment_mode_id.fr_lcr_dailly + ) + self.assertEqual( + payment_order.fr_lcr_dailly_option, + payment_order.payment_mode_id.fr_lcr_default_dailly_option, + ) + for line in payment_order.payment_line_ids: + self.assertEqual( + line.partner_bank_id, line.move_line_id.move_id.fr_lcr_partner_bank_id + ) + if payment_order.fr_lcr_collection_option in ( + "cash_discount", + "value_cash_discount", + ): + payment_order.fr_lcr_value_date = self.today_plus2 + payment_order.draft2open() + self.assertEqual(payment_order.state, "open") + payment_order.open2generated() + self.assertEqual(payment_order.state, "generated") + attachment = payment_order.payment_file_id + self.assertTrue(attachment) + self.assertEqual(attachment.name[-4:], ".txt") + cfonb_bytes = base64.b64decode(attachment.datas) + cfonb_str = cfonb_bytes.decode("ascii") + cfonb_lines = cfonb_str.split("\r\n") + self.assertEqual(len(cfonb_lines), 4) + return cfonb_lines + + def test_lcr_not_accepted(self): + self.payment_mode.write( + { + "fr_lcr_type": "not_accepted", + "fr_lcr_default_collection_option": "due_date", + "fr_lcr_dailly": False, + } + ) + cfonb_lines = self.lcr_full_scenario() + self.assertEqual(cfonb_lines[0][78], "3") # code entrée + self.assertEqual(cfonb_lines[0][79], " ") # code dailly + # value date + self.assertEqual(cfonb_lines[0][118:124], " " * 6) + for content_line in cfonb_lines[1:-1]: + # check Acceptation + self.assertEqual(content_line[78], "0") # lcr type + + def test_lcr_accepted(self): + self.payment_mode.write( + { + "fr_lcr_type": "accepted", + "fr_lcr_default_collection_option": "cash_discount", + "fr_lcr_dailly": False, + } + ) + + cfonb_lines = self.lcr_full_scenario() + self.assertEqual(cfonb_lines[0][78], "1") # code entrée + self.assertEqual(cfonb_lines[0][79], " ") # code dailly + # value date + self.assertEqual(cfonb_lines[0][118:124], self.today_plus2.strftime("%d%m%y")) + for content_line in cfonb_lines[1:-1]: + self.assertEqual(content_line[78], "1") # lcr type + + def test_lcr_accepted_dailly(self): + self.payment_mode.write( + { + "fr_lcr_type": "accepted", + "fr_lcr_default_collection_option": "due_date", + "fr_lcr_dailly": True, + "fr_lcr_default_dailly_option": "cash_discount", + "fr_lcr_convention_type": "CONV1", + } + ) + cfonb_lines = self.lcr_full_scenario() + self.assertEqual(cfonb_lines[0][78], "3") # code entrée + self.assertEqual(cfonb_lines[0][79], "1") # code dailly + self.assertEqual(cfonb_lines[0][18:24], "CONV1 ") # code dailly + # value date + self.assertEqual(cfonb_lines[0][118:124], " " * 6) + for content_line in cfonb_lines[1:-1]: + self.assertEqual(content_line[78], "1") # lcr type + + def test_promissory_note(self): + self.payment_mode.write( + { + "fr_lcr_type": "promissory_note", + "fr_lcr_default_collection_option": "value_cash_discount", + "fr_lcr_dailly": False, + } + ) + cfonb_lines = self.lcr_full_scenario() + self.assertEqual(cfonb_lines[0][78], "2") # code entrée + self.assertEqual(cfonb_lines[0][79], " ") # code dailly + # value date + self.assertEqual(cfonb_lines[0][118:124], self.today_plus2.strftime("%d%m%y")) + for content_line in cfonb_lines[1:-1]: + self.assertEqual(content_line[78], "2") # lcr type + + def test_iban2rib(self): + rib = self.partner1_bank3._fr_iban2rib() + self.assertEqual(rib["bank"], "11119") + self.assertEqual(rib["branch"], "99988") + self.assertEqual(rib["account"], "88555599999") + self.assertEqual(rib["key"], "87") + self.assertEqual(self.partner1_bank1.acc_type, "bank") + with self.assertRaises(UserError): + self.partner1_bank1._fr_iban2rib() + self.assertEqual(self.partner1_bank2.acc_type, "iban") + with self.assertRaises(UserError): + self.partner1_bank2._fr_iban2rib() diff --git a/account_banking_fr_lcr/views/account_move.xml b/account_banking_fr_lcr/views/account_move.xml new file mode 100644 index 000000000..985e866b9 --- /dev/null +++ b/account_banking_fr_lcr/views/account_move.xml @@ -0,0 +1,32 @@ + + + + + account_payment_order.view_move_form + account.move + + + + + + + + + + + + diff --git a/account_banking_fr_lcr/views/account_payment_mode.xml b/account_banking_fr_lcr/views/account_payment_mode.xml new file mode 100644 index 000000000..06a7928b4 --- /dev/null +++ b/account_banking_fr_lcr/views/account_payment_mode.xml @@ -0,0 +1,42 @@ + + + + + + fr_lcr.account.payment.mode.form + account.payment.mode + + + + + + + + + + + + + + + diff --git a/account_banking_fr_lcr/views/account_payment_order.xml b/account_banking_fr_lcr/views/account_payment_order.xml new file mode 100644 index 000000000..d46f6c58c --- /dev/null +++ b/account_banking_fr_lcr/views/account_payment_order.xml @@ -0,0 +1,33 @@ + + + + + pain.base.account.payment.order.form + account.payment.order + + + + + + + + + + + +