diff --git a/README.md b/README.md
index 5de1866..5552c20 100644
--- a/README.md
+++ b/README.md
@@ -11,14 +11,14 @@
Odoo modules for e-invoicing and e-reporting in France starting september 1st 2026.
-This set of modules depends on several OCA modules. At the moment, we don't require any specific pull request, but you should make sure that you are running up-to-date code for the following OCA repositories:
+This set of modules depends on several OCA modules. Make sure that you are running up-to-date code for the following OCA repositories:
* [OCA/community-data-files](https://github.com/OCA/community-data-files)
* [OCA/edi](https://github.com/OCA/edi)
* [OCA/l10n-france](https://github.com/OCA/l10n-france)
* [OCA/account-financial-tools](https://github.com/OCA/account-financial-tools)
-For example, on OCA/community-data-files, you need to have code dated after july 17th 2026... so, when we say that you need up-to-date code, we mean it !
+For the module **account_invoice_import** from OCA/edi, use the code from [pull request 1386](https://github.com/OCA/edi/pull/1386)
You should also make sure that the code of Odoo 18.0 you are running on is up-to-date.
@@ -28,19 +28,19 @@ You should also make sure that the code of Odoo 18.0 you are running on is up-to
[//]: # (addons)
-Available addons
-----------------
+Unported addons
+---------------
addon | version | maintainers | summary
--- | --- | --- | ---
-[account_invoice_en16931](account_invoice_en16931/) | 18.0.1.0.0 | | Base module to generate electronic invoices
-[account_invoice_en16931_py3o](account_invoice_en16931_py3o/) | 18.0.1.0.0 | | Glue module to generate EN16931 invoices with Py3o
-[l10n_fr_account_invoice_en16931](l10n_fr_account_invoice_en16931/) | 18.0.1.0.0 | | Localization of Invoice EN16931 for France
-[l10n_fr_einvoicing](l10n_fr_einvoicing/) | 18.0.1.2.0 | | Community implementation of the e-invoicing reform for France
-[l10n_fr_einvoicing_dashboard_banner](l10n_fr_einvoicing_dashboard_banner/) | 18.0.1.0.0 | | Add widgets for eInvoicing flows in Accounting Dashboard Banner
-[l10n_fr_einvoicing_import](l10n_fr_einvoicing_import/) | 18.0.1.0.0 | | Import vendor bills/refunds from accredited platform
-[l10n_fr_einvoicing_payment_batch_oca](l10n_fr_einvoicing_payment_batch_oca/) | 18.0.1.0.0 | | Option to auto-send payment sent event
-[l10n_fr_einvoicing_purchase](l10n_fr_einvoicing_purchase/) | 18.0.1.0.0 | | Display directory line on purchase order report
-[l10n_fr_einvoicing_sale](l10n_fr_einvoicing_sale/) | 18.0.1.0.0 | | eInvoicing for France in Sales
+[account_invoice_en16931](account_invoice_en16931/) | 19.0.1.0.0 (unported) | | Base module to generate electronic invoices
+[account_invoice_en16931_py3o](account_invoice_en16931_py3o/) | 19.0.1.0.0 (unported) | | Glue module to generate EN16931 invoices with Py3o
+[l10n_fr_account_invoice_en16931](l10n_fr_account_invoice_en16931/) | 19.0.1.0.0 (unported) | | Localization of Invoice EN16931 for France
+[l10n_fr_einvoicing](l10n_fr_einvoicing/) | 19.0.1.0.0 (unported) | | Community implementation of the e-invoicing reform for France
+[l10n_fr_einvoicing_dashboard_banner](l10n_fr_einvoicing_dashboard_banner/) | 19.0.1.0.0 (unported) | | Add widgets for eInvoicing flows in Accounting Dashboard Banner
+[l10n_fr_einvoicing_import](l10n_fr_einvoicing_import/) | 19.0.1.0.0 (unported) | | Import vendor bills/refunds from accredited platform
+[l10n_fr_einvoicing_payment_batch_oca](l10n_fr_einvoicing_payment_batch_oca/) | 19.0.1.0.0 (unported) | | Option to auto-send payment sent event
+[l10n_fr_einvoicing_purchase](l10n_fr_einvoicing_purchase/) | 19.0.1.0.0 (unported) | | Display directory line on purchase order report
+[l10n_fr_einvoicing_sale](l10n_fr_einvoicing_sale/) | 19.0.1.0.0 (unported) | | eInvoicing for France in Sales
[//]: # (end addons)
diff --git a/account_invoice_en16931/__manifest__.py b/account_invoice_en16931/__manifest__.py
index d3c6070..c4d9b2d 100644
--- a/account_invoice_en16931/__manifest__.py
+++ b/account_invoice_en16931/__manifest__.py
@@ -4,7 +4,7 @@
{
"name": "Invoice EN16931",
- "version": "18.0.1.0.0",
+ "version": "19.0.1.0.0",
"category": "Accounting",
"license": "AGPL-3",
"summary": "Base module to generate electronic invoices",
@@ -19,7 +19,12 @@
"intrastat_base",
],
"excludes": ["account_einvoice_generate"],
- "external_dependencies": {"python": ["factur-x>=6.5"]},
+ # fonttools is what odoo.tools.pdf.convert_to_pdfa() uses to rebuild the
+ # glyph width arrays produced by wkhtmltopdf; without it the PDF/A-3
+ # output fails veraPDF rule 6.2.11.5 and Odoo only logs a warning.
+ # No upper pin needed on 19.0: convert_to_pdfa() handles both the old
+ # getGlyphSet()._hmtx and the current hMetrics API.
+ "external_dependencies": {"python": ["factur-x>=6.7", "fonttools"]},
"data": [
"security/ir.model.access.csv",
"wizards/account_invoice_en16931_generate_view.xml",
diff --git a/account_invoice_en16931/i18n/account_invoice_en16931.pot b/account_invoice_en16931/i18n/account_invoice_en16931.pot
index 9b2a5c9..adc8532 100644
--- a/account_invoice_en16931/i18n/account_invoice_en16931.pot
+++ b/account_invoice_en16931/i18n/account_invoice_en16931.pot
@@ -6,8 +6,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-07-17 06:35+0000\n"
-"PO-Revision-Date: 2026-07-17 06:35+0000\n"
+"POT-Creation-Date: 2026-08-08 11:49+0000\n"
+"PO-Revision-Date: 2026-08-08 11:49+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
@@ -32,6 +32,33 @@ msgstr ""
msgid "Archive Format"
msgstr ""
+#. module: account_invoice_en16931
+#: model:ir.model.fields,help:account_invoice_en16931.field_account_bank_statement_line__invoice_attachment_ids
+#: model:ir.model.fields,help:account_invoice_en16931.field_account_move__invoice_attachment_ids
+msgid ""
+"Attachments added to the electronic invoice. In UBL and CII XML, these "
+"attachments are added in the XML (BG-24 / BT-125). In Factur-X, these "
+"attachments are added as additional attachments of the PDF."
+msgstr ""
+
+#. module: account_invoice_en16931
+#: model:ir.model.fields,field_description:account_invoice_en16931.field_account_bank_statement_line__business_process_type
+#: model:ir.model.fields,field_description:account_invoice_en16931.field_account_move__business_process_type
+msgid "Business Process Type"
+msgstr ""
+
+#. module: account_invoice_en16931
+#: model:ir.model.fields,help:account_invoice_en16931.field_account_bank_statement_line__business_process_type
+#: model:ir.model.fields,help:account_invoice_en16931.field_account_move__business_process_type
+msgid "Business Term BT-23 in EN16931"
+msgstr ""
+
+#. module: account_invoice_en16931
+#: model:ir.model.fields,help:account_invoice_en16931.field_account_bank_statement_line__invoice_type_code
+#: model:ir.model.fields,help:account_invoice_en16931.field_account_move__invoice_type_code
+msgid "Business Term BT-3 in EN16931"
+msgstr ""
+
#. module: account_invoice_en16931
#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_invoice_en16931_generate__invoice_format__cii
msgid "CII XML"
@@ -49,7 +76,7 @@ msgstr ""
#. module: account_invoice_en16931
#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__380
-msgid "Commercial invoice"
+msgid "Commercial Invoice"
msgstr ""
#. module: account_invoice_en16931
@@ -81,7 +108,7 @@ msgstr ""
#. module: account_invoice_en16931
#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__384
-msgid "Corrected invoice"
+msgid "Corrected Invoice"
msgstr ""
#. module: account_invoice_en16931
@@ -102,7 +129,7 @@ msgstr ""
#. module: account_invoice_en16931
#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__381
-msgid "Credit note"
+msgid "Credit Note"
msgstr ""
#. module: account_invoice_en16931
@@ -133,12 +160,6 @@ msgstr ""
msgid "Default PDF Invoice Generation"
msgstr ""
-#. module: account_invoice_en16931
-#. odoo-python
-#: code:addons/account_invoice_en16931/models/res_company.py:0
-msgid "Discount decimal precision is %s. For EN16931, the maximum value is 2."
-msgstr ""
-
#. module: account_invoice_en16931
#: model:ir.model.fields,field_description:account_invoice_en16931.field_account_invoice_en16931_generate__display_name
msgid "Display Name"
@@ -176,17 +197,17 @@ msgstr ""
#. module: account_invoice_en16931
#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__472
-msgid "Factored Corrective Invoice, invoice type, Corrected"
+msgid "Factored Corrective Invoice"
msgstr ""
#. module: account_invoice_en16931
#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__396
-msgid "Factored credit note"
+msgid "Factored Credit Note"
msgstr ""
#. module: account_invoice_en16931
#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__393
-msgid "Factored invoice"
+msgid "Factored Invoice"
msgstr ""
#. module: account_invoice_en16931
@@ -219,6 +240,22 @@ msgid ""
"%(err)s"
msgstr ""
+#. module: account_invoice_en16931
+#. odoo-python
+#: code:addons/account_invoice_en16931/models/account_move.py:0
+msgid ""
+"Failed to generate the UBL-2.1 XML file with profile 'extended-ctc-fr'. "
+"Error: %(err)s"
+msgstr ""
+
+#. module: account_invoice_en16931
+#. odoo-python
+#: code:addons/account_invoice_en16931/models/account_move.py:0
+msgid ""
+"Field 'Invoice Type Code' is required on customer invoices/refunds, but it "
+"is not set on '%s'."
+msgstr ""
+
#. module: account_invoice_en16931
#: model:ir.model.fields,field_description:account_invoice_en16931.field_account_invoice_en16931_generate__file_data
msgid "File Data"
@@ -244,12 +281,36 @@ msgstr ""
msgid "ID"
msgstr ""
+#. module: account_invoice_en16931
+#: model_terms:ir.ui.view,arch_db:account_invoice_en16931.res_config_settings_view_form
+msgid ""
+"If this option is enabled, an error will be raised if the Saxon server is "
+"down, unreachable or experience a technical failure during schematron "
+"validation."
+msgstr ""
+
#. module: account_invoice_en16931
#. odoo-python
#: code:addons/account_invoice_en16931/models/account_move.py:0
msgid "Invoice"
msgstr ""
+#. module: account_invoice_en16931
+#. odoo-python
+#: code:addons/account_invoice_en16931/models/account_move.py:0
+msgid ""
+"Invoice '%(invoice)s' has 2 e-invoice attachments with the same filename "
+"'%(filename)s'."
+msgstr ""
+
+#. module: account_invoice_en16931
+#. odoo-python
+#: code:addons/account_invoice_en16931/models/account_move.py:0
+msgid ""
+"Invoice '%(move)s' has Invoice Type Code '%(type_code)s' which is for "
+"refunds."
+msgstr ""
+
#. module: account_invoice_en16931
#: model:ir.model,name:account_invoice_en16931.model_account_invoice_en16931_generate
msgid "Invoice EN16931 Generate"
@@ -270,7 +331,7 @@ msgstr ""
#. odoo-python
#: code:addons/account_invoice_en16931/models/account_move.py:0
msgid ""
-"Invoice line '%(inv_line)s' has several VAT taxes (%(vat_taxes)s). EN-16931 "
+"Invoice line '%(inv_line)s' has several VAT taxes (%(vat_taxes)s). EN16931 "
"only allows one VAT tax."
msgstr ""
@@ -339,12 +400,12 @@ msgstr ""
#. module: account_invoice_en16931
#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__503
-msgid "Prepayment credit note, credit note type, Corrected"
+msgid "Prepayment Credit Note"
msgstr ""
#. module: account_invoice_en16931
#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__386
-msgid "Prepayment invoice"
+msgid "Prepayment Invoice"
msgstr ""
#. module: account_invoice_en16931
@@ -361,12 +422,25 @@ msgid ""
"value is 4."
msgstr ""
+#. module: account_invoice_en16931
+#: model:ir.model.fields,field_description:account_invoice_en16931.field_res_config_settings__saxon_validation_blocking
+msgid "Raise Error if Saxon Validation Fails"
+msgstr ""
+
#. module: account_invoice_en16931
#. odoo-python
#: code:addons/account_invoice_en16931/models/account_move.py:0
msgid "Refund"
msgstr ""
+#. module: account_invoice_en16931
+#. odoo-python
+#: code:addons/account_invoice_en16931/models/account_move.py:0
+msgid ""
+"Refund '%(move)s' has Invoice Type Code '%(type_code)s' which is for "
+"invoices."
+msgstr ""
+
#. module: account_invoice_en16931
#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__res_company__en16931_default_pdf_invoice__none
msgid "Regular PDF invoice"
@@ -378,38 +452,38 @@ msgid "Report Action"
msgstr ""
#. module: account_invoice_en16931
-#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__500
-msgid "Self Prepayment invoice, invoice type, Original"
-msgstr ""
-
-#. module: account_invoice_en16931
-#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__473
-msgid "Self billed Factored corrective invoice, invoice type, Corrected"
+#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__471
+msgid "Self-billed Corrective Invoice"
msgstr ""
#. module: account_invoice_en16931
#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__261
-msgid "Self billed credit note"
+msgid "Self-billed Credit Note"
msgstr ""
#. module: account_invoice_en16931
-#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__501
-msgid "Self billed factored invoice, invoice type, Original"
+#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__473
+msgid "Self-billed Factored Corrective Invoice"
msgstr ""
#. module: account_invoice_en16931
#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__502
-msgid "Self billet factored Credit Note, Credit note type, Corrected"
+msgid "Self-billed Factored Credit Note"
msgstr ""
#. module: account_invoice_en16931
-#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__471
-msgid "Self-billed corrective invoice, invoice type, Corrected"
+#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__501
+msgid "Self-billed Factored Invoice"
msgstr ""
#. module: account_invoice_en16931
#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__389
-msgid "Self-billed invoice"
+msgid "Self-billed Invoice"
+msgstr ""
+
+#. module: account_invoice_en16931
+#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__500
+msgid "Self-billed Prepayment Invoice"
msgstr ""
#. module: account_invoice_en16931
@@ -499,7 +573,7 @@ msgstr ""
#. odoo-python
#: code:addons/account_invoice_en16931/models/account_move.py:0
msgid ""
-"There is no VAT tax on invoice line '%(inv_line)s' of invoice '%(invoice)s'."
+"There is no VAT tax on invoice line '%(inv_line)s'."
" You must set a VAT tax on each invoice line in company '%(company)s' "
"because it is a VAT-registered company."
msgstr ""
@@ -587,6 +661,23 @@ msgstr ""
msgid "VAT tax '%s' has no UNECE Tax Category."
msgstr ""
+#. module: account_invoice_en16931
+#. odoo-python
+#: code:addons/account_invoice_en16931/models/account_move.py:0
+msgid ""
+"You cannot add an e-invoice attachment with filename '%s' because this "
+"filename is reserved."
+msgstr ""
+
+#. module: account_invoice_en16931
+#. odoo-python
+#: code:addons/account_invoice_en16931/models/account_move.py:0
+msgid ""
+"You cannot add e-invoice attachment '%(filename)s' whose MIME type is "
+"'%(mimetype)s'. Allowed MIME types for e-invoice attachments are: "
+"%(allowed_mimetypes)s."
+msgstr ""
+
#. module: account_invoice_en16931
#. odoo-python
#: code:addons/account_invoice_en16931/wizards/account_invoice_en16931_generate.py:0
@@ -610,8 +701,20 @@ msgstr ""
msgid "draft_invoice"
msgstr ""
+#. module: account_invoice_en16931
+#: model_terms:ir.ui.view,arch_db:account_invoice_en16931.view_move_form
+msgid "eInvoice"
+msgstr ""
+
+#. module: account_invoice_en16931
+#: model:ir.model.fields,field_description:account_invoice_en16931.field_account_bank_statement_line__invoice_attachment_ids
+#: model:ir.model.fields,field_description:account_invoice_en16931.field_account_move__invoice_attachment_ids
+msgid "eInvoice Attachments"
+msgstr ""
+
#. module: account_invoice_en16931
#: model_terms:ir.ui.view,arch_db:account_invoice_en16931.res_config_settings_view_form
+#: model_terms:ir.ui.view,arch_db:account_invoice_en16931.view_move_form
msgid "eInvoicing"
msgstr ""
diff --git a/account_invoice_en16931/i18n/fr.po b/account_invoice_en16931/i18n/fr.po
index ae0bc94..45d2dfb 100644
--- a/account_invoice_en16931/i18n/fr.po
+++ b/account_invoice_en16931/i18n/fr.po
@@ -6,8 +6,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-07-24 21:09+0000\n"
-"PO-Revision-Date: 2026-07-24 21:09+0000\n"
+"POT-Creation-Date: 2026-08-08 11:49+0000\n"
+"PO-Revision-Date: 2026-08-08 11:49+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
@@ -32,6 +32,37 @@ msgstr "(pas de date)"
msgid "Archive Format"
msgstr "Format d'archive"
+#. module: account_invoice_en16931
+#: model:ir.model.fields,help:account_invoice_en16931.field_account_bank_statement_line__invoice_attachment_ids
+#: model:ir.model.fields,help:account_invoice_en16931.field_account_move__invoice_attachment_ids
+msgid ""
+"Attachments added to the electronic invoice. In UBL and CII XML, these "
+"attachments are added in the XML (BG-24 / BT-125). In Factur-X, these "
+"attachments are added as additional attachments of the PDF."
+msgstr ""
+"Pièces jointes ajoutées à la facture électronique. En UBL et CII XML, ces "
+"pièces jointes sont ajoutées dans le XML (BG-24 / BT-125). En Factur-X, ces "
+"pièces jointes sont ajoutées comme des pièces jointes supplémentaires du "
+"PDF."
+
+#. module: account_invoice_en16931
+#: model:ir.model.fields,field_description:account_invoice_en16931.field_account_bank_statement_line__business_process_type
+#: model:ir.model.fields,field_description:account_invoice_en16931.field_account_move__business_process_type
+msgid "Business Process Type"
+msgstr "Type de processus métier"
+
+#. module: account_invoice_en16931
+#: model:ir.model.fields,help:account_invoice_en16931.field_account_bank_statement_line__business_process_type
+#: model:ir.model.fields,help:account_invoice_en16931.field_account_move__business_process_type
+msgid "Business Term BT-23 in EN16931"
+msgstr "Terme métier BT-23 dans l'EN16931"
+
+#. module: account_invoice_en16931
+#: model:ir.model.fields,help:account_invoice_en16931.field_account_bank_statement_line__invoice_type_code
+#: model:ir.model.fields,help:account_invoice_en16931.field_account_move__invoice_type_code
+msgid "Business Term BT-3 in EN16931"
+msgstr "Terme métier BT-3 dans l'EN16931"
+
#. module: account_invoice_en16931
#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_invoice_en16931_generate__invoice_format__cii
msgid "CII XML"
@@ -61,7 +92,7 @@ msgstr "Sociétés"
#: model:ir.model.fields,field_description:account_invoice_en16931.field_res_company__no_vat_taxes_vatex_id
#: model:ir.model.fields,field_description:account_invoice_en16931.field_res_config_settings__no_vat_taxes_vatex_id
msgid "Company VAT Exemption Reason"
-msgstr "Motif d'exemption de TVA de la société"
+msgstr "Motif d'exonération de TVA de la société"
#. module: account_invoice_en16931
#: model:ir.model.fields,field_description:account_invoice_en16931.field_res_company__no_vat_taxes
@@ -135,14 +166,6 @@ msgstr "BROUILLON-TEST_UNIQUEMENT"
msgid "Default PDF Invoice Generation"
msgstr "Génération de facture PDF par défaut"
-#. module: account_invoice_en16931
-#. odoo-python
-#: code:addons/account_invoice_en16931/models/res_company.py:0
-msgid "Discount decimal precision is %s. For EN16931, the maximum value is 2."
-msgstr ""
-"La précision décimale des remises est %s. Pour EN16931, la précision "
-"maximale est de 2."
-
#. module: account_invoice_en16931
#: model:ir.model.fields,field_description:account_invoice_en16931.field_account_invoice_en16931_generate__display_name
msgid "Display Name"
@@ -233,6 +256,16 @@ msgstr ""
"La génération du fichier XML %(flavor)s avec le profil %(level)s a échouée. "
"Erreur: %(err)s"
+#. module: account_invoice_en16931
+#. odoo-python
+#: code:addons/account_invoice_en16931/models/account_move.py:0
+msgid ""
+"Failed to generate the UBL-2.1 XML file with profile 'extended-ctc-fr'. "
+"Error: %(err)s"
+msgstr ""
+"Échec lors de la génération du fichier XML UBL-2.1 avec le profil 'extended-"
+"ctc-fr'. Erreur : %(err)s"
+
#. module: account_invoice_en16931
#. odoo-python
#: code:addons/account_invoice_en16931/models/account_move.py:0
@@ -240,6 +273,8 @@ msgid ""
"Field 'Invoice Type Code' is required on customer invoices/refunds, but it "
"is not set on '%s'."
msgstr ""
+"Le champ 'Code du type de facture' est obligatoire sur les factures/avoirs "
+"client, mais il n'est pas renseigné sur '%s'."
#. module: account_invoice_en16931
#: model:ir.model.fields,field_description:account_invoice_en16931.field_account_invoice_en16931_generate__file_data
@@ -266,12 +301,32 @@ msgstr "Générer les factures électroniques"
msgid "ID"
msgstr ""
+#. module: account_invoice_en16931
+#: model_terms:ir.ui.view,arch_db:account_invoice_en16931.res_config_settings_view_form
+msgid ""
+"If this option is enabled, an error will be raised if the Saxon server is "
+"down, unreachable or experience a technical failure during schematron "
+"validation."
+msgstr ""
+"Si cette option est activée, un message d'erreur bloquant sera affiché si le serveur Saxon "
+"est en panne, injoignable ou a un problème technique lors de la validation des schematrons."
+
#. module: account_invoice_en16931
#. odoo-python
#: code:addons/account_invoice_en16931/models/account_move.py:0
msgid "Invoice"
msgstr "Facture"
+#. module: account_invoice_en16931
+#. odoo-python
+#: code:addons/account_invoice_en16931/models/account_move.py:0
+msgid ""
+"Invoice '%(invoice)s' has 2 e-invoice attachments with the same filename "
+"'%(filename)s'."
+msgstr ""
+"La facture '%(invoice)s' a 2 pièces jointes pour la facturation électronique"
+" avec le même nom de fichier '%(filename)s'."
+
#. module: account_invoice_en16931
#. odoo-python
#: code:addons/account_invoice_en16931/models/account_move.py:0
@@ -279,6 +334,8 @@ msgid ""
"Invoice '%(move)s' has Invoice Type Code '%(type_code)s' which is for "
"refunds."
msgstr ""
+"La facture '%(move)s' a un code du type de facture '%(type_code)s' qui est "
+"dédié aux avoirs."
#. module: account_invoice_en16931
#: model:ir.model,name:account_invoice_en16931.model_account_invoice_en16931_generate
@@ -377,7 +434,7 @@ msgstr "Facture PDF avec UBL XML en pièce jointe"
#. module: account_invoice_en16931
#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__503
msgid "Prepayment Credit Note"
-msgstr "Avoir de facture d'acompte"
+msgstr "Avoir d'acompte"
#. module: account_invoice_en16931
#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__386
@@ -402,6 +459,11 @@ msgstr ""
"La précision décimale de l'unité de mesure du produit est de %s. Pour "
"EN16931, la valeur maximale est de 4."
+#. module: account_invoice_en16931
+#: model:ir.model.fields,field_description:account_invoice_en16931.field_res_config_settings__saxon_validation_blocking
+msgid "Raise Error if Saxon Validation Fails"
+msgstr "Erreur bloquante si la validation Saxon échoue"
+
#. module: account_invoice_en16931
#. odoo-python
#: code:addons/account_invoice_en16931/models/account_move.py:0
@@ -415,6 +477,8 @@ msgid ""
"Refund '%(move)s' has Invoice Type Code '%(type_code)s' which is for "
"invoices."
msgstr ""
+"L'avoir '%(move)s' a un code du type de facture '%(type_code)s' qui est pour"
+" les factures."
#. module: account_invoice_en16931
#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__res_company__en16931_default_pdf_invoice__none
@@ -429,37 +493,37 @@ msgstr "Action de rapport"
#. module: account_invoice_en16931
#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__471
msgid "Self-billed Corrective Invoice"
-msgstr "Facture rectificative auto-facturée"
+msgstr "Facture rectificative en auto-facturation"
#. module: account_invoice_en16931
#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__261
msgid "Self-billed Credit Note"
-msgstr "Avoir auto-facturé"
+msgstr "Avoir en auto-facturation"
#. module: account_invoice_en16931
#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__473
msgid "Self-billed Factored Corrective Invoice"
-msgstr "Facture rectificative auto-facturée affacturée"
+msgstr "Facture rectificative affacturée en auto-facturation"
#. module: account_invoice_en16931
#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__502
msgid "Self-billed Factored Credit Note"
-msgstr "Avoir auto-facturé affacturé"
+msgstr "Avoir affacturé en auto-facturation"
#. module: account_invoice_en16931
#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__501
msgid "Self-billed Factored Invoice"
-msgstr "Facture auto-facturée affacturée"
+msgstr "Facture affacturée en auto-facturation"
#. module: account_invoice_en16931
#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__389
msgid "Self-billed Invoice"
-msgstr "Facture auto-facturée"
+msgstr "Facture en auto-facturation"
#. module: account_invoice_en16931
#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__500
msgid "Self-billed Prepayment Invoice"
-msgstr "Facture d'acompte auto-facturée"
+msgstr "Facture d'acompte en auto-facturation"
#. module: account_invoice_en16931
#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_invoice_en16931_generate__type__single
@@ -535,7 +599,7 @@ msgstr ""
"La société actuelle n'a aucune taxe de TVA active. Soit c'est une erreur et "
"il faudrait mettre à jour la configuration des taxes. Ou c'est normal (car "
"la société n'est pas assujettie à la TVA par exemple) et il vous faut alors "
-"configurer le motif d'exemption de TVA ci-dessous."
+"configurer le motif d'exonération de TVA ci-dessous."
#. module: account_invoice_en16931
#: model_terms:ir.ui.view,arch_db:account_invoice_en16931.res_config_settings_view_form
@@ -562,12 +626,12 @@ msgstr ""
#. odoo-python
#: code:addons/account_invoice_en16931/models/account_move.py:0
msgid ""
-"There is no VAT tax on invoice line '%(inv_line)s' of invoice '%(invoice)s'."
+"There is no VAT tax on invoice line '%(inv_line)s'."
" You must set a VAT tax on each invoice line in company '%(company)s' "
"because it is a VAT-registered company."
msgstr ""
-"Il n'y a pas de taxe de TVA sur la ligne '%(inv_line)s' de la facture "
-"'%(invoice)s'. Il est obligatoire de configurer une taxe de TVA sur chaque "
+"Il n'y a pas de taxe de TVA sur la ligne '%(inv_line)s'."
+" Il est obligatoire de configurer une taxe de TVA sur chaque "
"ligne de facture pour la société '%(company)s' car elle est assujettie à la "
"TVA."
@@ -621,7 +685,7 @@ msgid ""
" Exemption Reason."
msgstr ""
"La taxe de TVA '%(tax)s' a pour catégorie UNECE '%(categ)s', et devrait donc"
-" être configurée avec un motif d'exemption de TVA."
+" être configurée avec un motif d'exonération de TVA."
#. module: account_invoice_en16931
#. odoo-python
@@ -631,7 +695,7 @@ msgid ""
" VAT Exemption Reason."
msgstr ""
"La taxe de TVA '%(tax)s' a pour catégorie UNECE '%(categ)s', et ne devrait "
-"donc pas être configurée avec un motif d'exemption de TVA."
+"donc pas être configurée avec un motif d'exonération de TVA."
#. module: account_invoice_en16931
#. odoo-python
@@ -670,6 +734,29 @@ msgstr ""
msgid "VAT tax '%s' has no UNECE Tax Category."
msgstr "La taxe de TVA '%s' n'a pas de catégorie UNECE."
+#. module: account_invoice_en16931
+#. odoo-python
+#: code:addons/account_invoice_en16931/models/account_move.py:0
+msgid ""
+"You cannot add an e-invoice attachment with filename '%s' because this "
+"filename is reserved."
+msgstr ""
+"Vous ne pouvez pas ajouter une pièce jointe pour la facturation électronique"
+" avec le nom de fichier '%s' car ce nom de fichier est réservé."
+
+#. module: account_invoice_en16931
+#. odoo-python
+#: code:addons/account_invoice_en16931/models/account_move.py:0
+msgid ""
+"You cannot add e-invoice attachment '%(filename)s' whose MIME type is "
+"'%(mimetype)s'. Allowed MIME types for e-invoice attachments are: "
+"%(allowed_mimetypes)s."
+msgstr ""
+"Vous ne pouvez pas ajouter une pièce jointe pour la facturation électronique"
+" '%(filename)s' dont le type MIME est '%(mimetype)s'. Les types MIME "
+"autorisés pour les pièces jointes pour la facturation électronique sont : "
+"%(allowed_mimetypes)s."
+
#. module: account_invoice_en16931
#. odoo-python
#: code:addons/account_invoice_en16931/wizards/account_invoice_en16931_generate.py:0
@@ -691,10 +778,22 @@ msgstr ""
#. odoo-python
#: code:addons/account_invoice_en16931/models/account_move.py:0
msgid "draft_invoice"
-msgstr ""
+msgstr "facture_brouillon"
+
+#. module: account_invoice_en16931
+#: model_terms:ir.ui.view,arch_db:account_invoice_en16931.view_move_form
+msgid "eInvoice"
+msgstr "Facture électronique"
+
+#. module: account_invoice_en16931
+#: model:ir.model.fields,field_description:account_invoice_en16931.field_account_bank_statement_line__invoice_attachment_ids
+#: model:ir.model.fields,field_description:account_invoice_en16931.field_account_move__invoice_attachment_ids
+msgid "eInvoice Attachments"
+msgstr "Pièces jointes pour la facturation électronique"
#. module: account_invoice_en16931
#: model_terms:ir.ui.view,arch_db:account_invoice_en16931.res_config_settings_view_form
+#: model_terms:ir.ui.view,arch_db:account_invoice_en16931.view_move_form
msgid "eInvoicing"
msgstr "Facturation électronique"
diff --git a/account_invoice_en16931/models/account_move.py b/account_invoice_en16931/models/account_move.py
index 03161fe..c11ebb2 100644
--- a/account_invoice_en16931/models/account_move.py
+++ b/account_invoice_en16931/models/account_move.py
@@ -5,6 +5,7 @@
import base64
import logging
+import sys
from io import BytesIO
from pprint import pformat
from urllib.parse import urljoin
@@ -15,11 +16,7 @@
from odoo import api, fields, models
from odoo.exceptions import UserError, ValidationError
-from odoo.tools import (
- float_compare,
- html2plaintext,
- is_html_empty,
-)
+from odoo.tools import config, float_compare, html2plaintext, is_html_empty
from odoo.tools.misc import format_amount, format_date
logger = logging.getLogger(__name__)
@@ -27,8 +24,33 @@
try:
from facturx import generate_from_file, generate_xml
except (OSError, ImportError) as err:
- logger.debug("Cannot import facturx. Error details below.")
- logger.debug(err)
+ # Odoo 19 registers an import hook on stdnum (odoo/_monkeypatches/stdnum.py)
+ # which swaps the package loader for a SimpleNamespace exposing only
+ # create_module and exec_module. That fake loader has no
+ # get_resource_reader, so importlib.resources can no longer reach the .dat
+ # files that stdnum.iban and its siblings read at import time, and importing
+ # facturx dies on "Can't open orphan path". The patch it installs is a no-op
+ # from python-stdnum 2.0 on, so dropping the hook and importing again is
+ # safe. Remove once odoo/odoo fixes the loader (still there on master).
+ logger.info("Cannot import facturx, retrying without the stdnum import hook.")
+ logger.info(err)
+ try:
+ from odoo._monkeypatches import HOOK_IMPORT
+
+ HOOK_IMPORT.hooks.discard("stdnum")
+ for _stdnum_module in [
+ name
+ for name in list(sys.modules)
+ if name == "stdnum" or name.startswith("stdnum.")
+ ]:
+ del sys.modules[_stdnum_module]
+ from facturx import generate_from_file, generate_xml
+ except (OSError, ImportError) as retry_err:
+ # Left at warning on purpose: a silent debug here means the module loads
+ # fine and only fails much later, on generation, with a NameError on
+ # generate_xml that says nothing about the real cause.
+ logger.warning("Cannot import facturx. Error details below.")
+ logger.warning(retry_err)
DIRECT_DEBIT_CODES = ("49", "59")
@@ -46,6 +68,17 @@
"473",
)
REFUND_TYPE_CODES = ("261", "381", "396", "502", "503")
+RESERVED_INV_ATTACHMENT_FILENAMES = ("factur-x.xml", "factur-xubl.xml")
+INV_ATTACHMENT_ALLOWED_MIMETYPES = (
+ "application/pdf",
+ "image/png",
+ "image/jpeg",
+ "text/csv",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ "application/vnd.oasis.opendocument.spreadsheet",
+ "text/xml",
+ "application/xml",
+)
class AccountMove(models.Model):
@@ -53,33 +86,35 @@ class AccountMove(models.Model):
invoice_type_code = fields.Selection(
[
- ("261", "Self-billed Credit Note"), # Avoir auto-facturé
- ("380", "Commercial Invoice"), # Facture
- ("381", "Credit Note"), # Avoir
- ("384", "Corrected Invoice"), # Facture rectificative
- ("386", "Prepayment Invoice"), # Facture d'acompte
- ("389", "Self-billed Invoice"), # Facture auto-facturée
- ("393", "Factored Invoice"), # Facture affacturée
- ("396", "Factored Credit Note"), # Avoir affacturé
+ ("261", "Self-billed Credit Note"),
+ ("380", "Commercial Invoice"),
+ ("381", "Credit Note"),
+ ("384", "Corrected Invoice"),
+ ("386", "Prepayment Invoice"),
+ ("389", "Self-billed Invoice"),
+ ("393", "Factored Invoice"),
+ ("396", "Factored Credit Note"),
(
"471",
"Self-billed Corrective Invoice",
- ), # Facture rectificative auto-facturée
- ("472", "Factored Corrective Invoice"), # Facture rectificative affacturée
+ ),
+ ("472", "Factored Corrective Invoice"),
(
"473",
"Self-billed Factored Corrective Invoice",
- ), # Facture rectificative auto-facturée affacturée
+ ),
(
"500",
"Self-billed Prepayment Invoice",
- ), # Facture d'acompte auto-facturée
- ("501", "Self-billed Factored Invoice"), # Facture auto-facturée affacturée
- ("502", "Self-billed Factored Credit Note"), # Avoir auto-facturé affacturé
- ("503", "Prepayment Credit Note"), # Avoir de facture d'acompte
+ ),
+ ("501", "Self-billed Factored Invoice"),
+ ("502", "Self-billed Factored Credit Note"),
+ ("503", "Prepayment Credit Note"),
],
compute="_compute_invoice_type_code",
store=True,
+ tracking=True,
+ help="Business Term BT-3 in EN16931",
)
# we disallow manual modification for the moment, because we would
# need to filter depending on invoice vs refund
@@ -88,18 +123,90 @@ class AccountMove(models.Model):
# It's also useful for in invoice/refund to store the value that was
# present in the XML of the Vendor bill, so that it can then be used
# for life cycles (info needed in CDAR XML)
+ business_process_type = fields.Selection(
+ [], copy=False, tracking=True, help="Business Term BT-23 in EN16931"
+ )
+ invoice_attachment_ids = fields.Many2many(
+ "ir.attachment",
+ "account_move_invoice_attachment_rel",
+ string="eInvoice Attachments",
+ copy=False,
+ help="Attachments added to the electronic invoice. In UBL and CII XML, "
+ "these attachments are added in the XML (BG-24 / BT-125). In Factur-X, "
+ "these attachments are added as additional attachments of the PDF.",
+ )
- @api.depends("move_type")
+ @api.depends("move_type", "invoice_line_ids")
def _compute_invoice_type_code(self):
+ # sale module
+ has_is_downpayment = hasattr(self.env["account.move.line"], "is_downpayment")
+ if has_is_downpayment:
+ qty_prec = self.env["decimal.precision"].precision_get(
+ "Product Unit of Measure"
+ )
for move in self:
type_code = False
if move.is_invoice(include_receipts=True):
- if move.move_type in ("in_refund", "out_refund"):
- type_code = "381"
- else:
- type_code = "380"
+ if has_is_downpayment:
+ for line in move.invoice_line_ids:
+ if (
+ line.display_type == "product"
+ and line.is_downpayment
+ and float_compare(
+ line.quantity, 0, precision_digits=qty_prec
+ )
+ > 0
+ ):
+ if move.move_type in ("in_refund", "out_refund"):
+ type_code = "503"
+ else:
+ type_code = "386"
+ break
+ if not type_code:
+ if move.move_type in ("in_refund", "out_refund"):
+ type_code = "381"
+ else:
+ type_code = "380"
+
move.invoice_type_code = type_code
+ @api.constrains("invoice_attachment_ids")
+ def _check_invoice_attachment_ids(self):
+ for move in self:
+ filenames = set()
+ for attach in move.invoice_attachment_ids:
+ if attach.name.lower() in RESERVED_INV_ATTACHMENT_FILENAMES:
+ raise ValidationError(
+ self.env._(
+ "You cannot add an e-invoice attachment with "
+ "filename '%s' because this filename is reserved.",
+ attach.name,
+ )
+ )
+ if attach.name in filenames:
+ raise ValidationError(
+ self.env._(
+ "Invoice '%(invoice)s' has 2 e-invoice attachments "
+ "with the same filename '%(filename)s'.",
+ invoice=move.display_name,
+ filename=attach.name,
+ )
+ )
+ filenames.add(attach.name)
+ if attach.mimetype not in INV_ATTACHMENT_ALLOWED_MIMETYPES:
+ raise ValidationError(
+ self.env._(
+ "You cannot add e-invoice attachment '%(filename)s' "
+ "whose MIME type is '%(mimetype)s'. Allowed MIME types "
+ "for e-invoice attachments are: %(allowed_mimetypes)s.",
+ filename=attach.name,
+ mimetype=attach.mimetype,
+ allowed_mimetypes=", ".join(
+ INV_ATTACHMENT_ALLOWED_MIMETYPES
+ ),
+ )
+ )
+
@api.constrains("move_type", "invoice_type_code")
def _check_invoice_type_code(self):
type_code2label = dict(
@@ -140,77 +247,39 @@ def _check_invoice_type_code(self):
)
def _post(self, soft=True):
- for move in self.filtered(lambda x: x.is_sale_document()):
- move.company_id._en16931_checks()
- errors = []
- if not move.company_id.no_vat_taxes:
- for line in move.invoice_line_ids.filtered(
- lambda x: x.display_type == "product"
- ):
- vat_tax = False
- for tax in line.tax_ids:
- # either we check both active and inactive taxes in
- # company_id._en16931_checks() or we block invoice validation
- # on inactive taxes
- if not tax.active:
- errors.append(
- self.env._(
- "Invoice line '%(inv_line)s' has tax '%(tax)s' "
- "which is not active.",
- inv_line=line.display_name,
- tax=tax.display_name,
- )
- )
- if tax.unece_type_code == "VAT":
- if vat_tax:
- errors.append(
- self.env._(
- "Invoice line '%(inv_line)s' has several "
- "VAT taxes (%(vat_taxes)s). EN16931 only "
- "allows one VAT tax.",
- inv_line=line.display_name,
- vat_taxes=", ".join(
- [
- t.display_name
- for t in line.tax_ids
- if t.unece_type_code == "VAT"
- ]
- ),
- )
- )
- else:
- vat_tax = tax
- if not vat_tax:
- errors.append(
- self.env._(
- "There is no VAT tax on invoice line '%(inv_line)s' "
- "of invoice '%(invoice)s'. You must set a VAT tax on "
- "each invoice line in company '%(company)s' because "
- "it is a VAT-registered company.",
- inv_line=line.display_name,
- invoice=move.display_name,
- company=move.company_id.display_name,
- )
+ for move in self:
+ if (
+ move.is_sale_document()
+ and not config["test_enable"]
+ and not self.env.context.get("skip_en16931_checks_upon_post")
+ ):
+ if move.company_id.en16931_issuer:
+ move.company_id._en16931_checks()
+ errors = []
+ if not move.company_id.no_vat_taxes:
+ for line in move.invoice_line_ids.filtered(
+ lambda x: x.display_type == "product"
+ ):
+ line._post_check_en16931_sale_document(errors)
+ if move.currency_id.compare_amounts(move.amount_untaxed, 0) < 0:
+ errors.append(
+ self.env._(
+ "Total Untaxed Amount (%(amount_untaxed)s) is negative. "
+ "This is not supported by the EN16931 standard.",
+ amount_untaxed=format_amount(
+ self.env, move.amount_untaxed, move.currency_id
+ ),
)
- if move.currency_id.compare_amounts(move.amount_untaxed, 0) < 0:
- errors.append(
- self.env._(
- "Total Untaxed Amount (%(amount_untaxed)s) is negative. "
- "This is not supported by the EN16931 standard.",
- amount_untaxed=format_amount(
- self.env, move.amount_untaxed, move.currency_id
- ),
)
- )
- if errors:
- raise UserError(
- self.env._(
- "Errors on invoice '%(inv)s' for EN16931 "
- "e-invoicing:\n%(err_msg)s",
- inv=move.display_name,
- err_msg="\n".join([f"- {error}" for error in errors]),
+ if errors:
+ raise UserError(
+ self.env._(
+ "Errors on invoice '%(inv)s' for EN16931 "
+ "e-invoicing:\n%(err_msg)s",
+ inv=move.display_name,
+ err_msg="\n".join([f"- {error}" for error in errors]),
+ )
)
- )
return super()._post(soft=soft)
def _en16931_checks_upon_invoice_generation(self):
@@ -307,39 +376,11 @@ def _prepare_bt20(self, speedy):
def _prepare_bt23(self, speedy):
self.ensure_one()
- # OCA module intrastat_base
- has_is_accessory_cost = hasattr(
- self.env["product.template"], "is_accessory_cost"
- )
- # If an invoice line has no product, we consider it is a service
- line_types = [
- (
- line.product_id and line.product_id.type or "service",
- has_is_accessory_cost and line.product_id.is_accessory_cost or False,
- )
- for line in self.invoice_line_ids
- if line.display_type == "product"
- ]
- service_only = all(
- [ptype == "service" for (ptype, is_accessory_cost) in line_types]
- )
- at_least_one_product = any(
- [ptype == "consu" for (ptype, is_accessory_cost) in line_types]
- )
- all_products_or_accessory_costs = all(
- [
- ptype == "consu" or is_accessory_cost
- for (ptype, is_accessory_cost) in line_types
- ]
- )
- paid = self.payment_state == "paid"
- if service_only:
- business_process_type = paid and "S2" or "S1"
- elif at_least_one_product and all_products_or_accessory_costs:
- business_process_type = paid and "B2" or "B1"
+ if self.business_process_type:
+ # [3:] to skip the country prefix
+ return self.business_process_type[3:]
else:
- business_process_type = paid and "M2" or "M1"
- return business_process_type
+ return None
def _prepare_bt34_with_scheme(self, speedy):
self.ensure_one()
@@ -500,6 +541,19 @@ def _prepare_bg24(self, speedy, pdf_invoice_bin):
"BT-125-2": filename,
}
)
+ for attach in self.invoice_attachment_ids:
+ if attach.type == "binary":
+ bg24.append(
+ {
+ "BT-122": attach.name,
+ "BT-125": attach.datas,
+ "BT-125-1": attach.mimetype,
+ "BT-125-2": attach.name,
+ # for Factur-X
+ "modification_datetime": attach.write_date,
+ "creation_datetime": attach.create_date,
+ }
+ )
return bg24
def _prepare_en16931_payment_data(self, speedy):
@@ -666,7 +720,7 @@ def _prepare_en16931_dict(self, speedy, pdf_invoice_bin=False):
seller_partner_data = self.company_id.partner_id._en16931_partner_data()
if self.user_id:
vals["BT-41"] = self.user_id.name
- phone = self.user_id.partner_id.mobile or self.user_id.partner_id.phone
+ phone = self.user_id.partner_id.phone
if phone:
vals["BT-42"] = phone
vals["BT-43"] = self.user_id.partner_id.email
@@ -740,7 +794,9 @@ def _prepare_en16931_dict(self, speedy, pdf_invoice_bin=False):
vals["BG-24"] = self._prepare_bg24(speedy, pdf_invoice_bin)
return vals
- def generate_en16931_xml(self, flavor2level, pdf_invoice_bin=False):
+ def generate_en16931_xml(
+ self, flavor, level, invoice_format, pdf_invoice_bin=False
+ ):
self.ensure_one()
assert self.is_sale_document()
data_dict = self._generate_en16931_dict(pdf_invoice_bin=pdf_invoice_bin)
@@ -748,6 +804,7 @@ def generate_en16931_xml(self, flavor2level, pdf_invoice_bin=False):
if (
hasattr(self, "fr_directory_partner_entity_type")
and self.fr_directory_company_entity_type == "private"
+ and not self.env.context.get("chorus_old_xml_syntax")
):
if self.fr_directory_partner_entity_type == "private":
check_schematron = "fr-ctc"
@@ -758,18 +815,56 @@ def generate_en16931_xml(self, flavor2level, pdf_invoice_bin=False):
saxon_server_codedb_base_url = self._get_saxon_server_codedb_base_url()
if saxon_server_codedb_dir:
saxon_server_codedb_base_url = None
+ saxon_server_raise_if_http_error = self._get_saxon_server_raise_if_http_error()
logger.debug(
f"Calling generate_xml with "
f"saxon_server_codedb_dir={saxon_server_codedb_dir} and "
- f"saxon_server_codedb_base_url={saxon_server_codedb_base_url}"
+ f"saxon_server_codedb_base_url={saxon_server_codedb_base_url} and "
+ f"saxon_server_raise_if_http_error={saxon_server_raise_if_http_error}"
)
- res = {}
- for flavor, level in flavor2level.items():
- try:
- xml_bytes = generate_xml(
- data_dict,
+ attachments = {}
+ # for Factur-X, we prefer to have attachments in PDF rather than inside XML
+ # (and we don't want to have both !)
+ if invoice_format.startswith("facturx"):
+ for attach in data_dict.get("BG-24", []):
+ if attach.get("BT-125") and attach.get("BT-125-2"):
+ vals = {"filedata": base64.decodebytes(attach["BT-125"])}
+ if attach.get("modification_datetime"):
+ vals["modification_datetime"] = attach["modification_datetime"]
+ if attach.get("creation_datetime"):
+ vals["creation_datetime"] = attach["creation_datetime"]
+ attachments[attach["BT-125-2"]] = vals
+ data_dict.pop("BG-24")
+ try:
+ xml_bytes = generate_xml(
+ data_dict,
+ flavor=flavor,
+ level=level,
+ check_xsd=True,
+ check_schematron=check_schematron,
+ saxon_server_url=saxon_server_url,
+ saxon_server_codedb_base_url=saxon_server_codedb_base_url,
+ saxon_server_codedb_dir=saxon_server_codedb_dir,
+ saxon_server_raise_if_http_error=saxon_server_raise_if_http_error,
+ )
+ except Exception as err:
+ logger.warning("data_dict dumped below")
+ logger.warning(pformat(data_dict))
+ raise UserError(
+ self.env._(
+ "Failed to generate the %(flavor)s XML file "
+ "with profile %(level)s. Error: %(err)s",
flavor=flavor,
level=level,
+ err=str(err),
+ )
+ ) from err
+ if invoice_format == "facturx_ubl":
+ try:
+ ubl_xml_bytes = generate_xml(
+ data_dict,
+ flavor="ubl-2.1",
+ level="extended-ctc-fr",
check_xsd=True,
check_schematron=check_schematron,
saxon_server_url=saxon_server_url,
@@ -781,15 +876,18 @@ def generate_en16931_xml(self, flavor2level, pdf_invoice_bin=False):
logger.warning(pformat(data_dict))
raise UserError(
self.env._(
- "Failed to generate the %(flavor)s XML file "
- "with profile %(level)s. Error: %(err)s",
- flavor=flavor,
- level=level,
+ "Failed to generate the UBL-2.1 XML file "
+ "with profile 'extended-ctc-fr'. Error: %(err)s",
err=str(err),
)
) from err
- res[flavor] = xml_bytes
- return res
+ # Factur-X standard v1.09, end of section 6.4, specifies
+ # that, if we add a UBL XML as attachment, filename should be
+ # factur-xubl.xml. I don't like this name, but it's the standard !
+ attachments["factur-xubl.xml"] = {
+ "filedata": ubl_xml_bytes,
+ }
+ return xml_bytes, attachments
def _prepare_facturx_pdf_metadata(self):
self.ensure_one()
@@ -827,58 +925,44 @@ def _prepare_facturx_pdf_metadata(self):
}
return pdf_metadata
- def _get_pdf_invoice_variant(self):
- """Returns the variant, but only if it is possible to generate the XML
+ def _get_pdf_invoice_format(self):
+ """Returns the invoice_format, but only if it is possible to generate the XML
Otherwize return False"""
self.ensure_one()
- variant = self.company_id.en16931_default_pdf_invoice
+ invoice_format = self.company_id.en16931_default_pdf_invoice
# I want to allow embedded XML even on draft invoice
# So I write here the conditions to be able to generate a valid XML
if (
- variant
- and variant != "none"
+ invoice_format
+ and invoice_format != "none"
and self.is_sale_document()
and self.partner_id
and self.state != "cancel"
and self.invoice_line_ids.filtered(lambda x: x.display_type == "product")
):
- return variant
+ return invoice_format
else:
return False
- def _prepare_facturx_attachments(self):
- # This method is designed to be inherited in other modules
- self.ensure_one()
- return {}
-
def _prepare_ubl_attachment_filename(self):
self.ensure_one()
return "UBL-invoice.xml"
- def _regular_pdf_invoice_to_en16931_pdf_invoice(self, pdf_bytesio, variant):
+ def _regular_pdf_invoice_to_en16931_pdf_invoice(self, pdf_bytesio, invoice_format):
self.ensure_one()
assert pdf_bytesio, "Missing pdf_bytesio"
- if variant in ("facturx", "facturx_ubl"):
+ if invoice_format.startswith("facturx"):
pdf_metadata = self._prepare_facturx_pdf_metadata()
lang = (
self.partner_id.lang and self.partner_id.lang.replace("_", "-") or None
)
# Generate a new PDF with XML file as attachment
- attachments = self._prepare_facturx_attachments()
- flavor2level = {"factur-x": "extended"}
- if variant == "facturx_ubl":
- flavor2level["ubl-2.1"] = "extended-ctc-fr"
- flavor2xmlbytes = self.generate_en16931_xml(flavor2level)
- if variant == "facturx_ubl":
- # Factur-X standard v1.09, end of section 6.4, specifies
- # that, if we add a UBL XML as attachment, filename should be
- # factur-xubl.xml. I don't like this name, but it's the standard
- attachments["factur-xubl.xml"] = {
- "filedata": flavor2xmlbytes["ubl-2.1"]
- }
+ xml_bytes, attachments = self.generate_en16931_xml(
+ "factur-x", "extended", invoice_format
+ )
generate_from_file(
pdf_bytesio,
- flavor2xmlbytes["factur-x"],
+ xml_bytes,
flavor="factur-x",
level="extended",
check_xsd=False,
@@ -888,10 +972,11 @@ def _regular_pdf_invoice_to_en16931_pdf_invoice(self, pdf_bytesio, variant):
attachments=attachments,
)
logger.info("Factur-X PDF invoice successfully generated")
- elif variant == "pdf_ubl":
- flavor2level = {"ubl-2.1": "extended-ctc-fr"}
- flavor2xmlbytes = self.generate_en16931_xml(flavor2level)
- ubl_xml_bytes = flavor2xmlbytes["ubl-2.1"]
+ self._en16931_pdf_to_pdfa(pdf_bytesio)
+ elif invoice_format == "pdf_ubl":
+ ubl_xml_bytes = self.generate_en16931_xml(
+ "ubl-2.1", "extended-ctc-fr", invoice_format
+ )[0]
pdf_writer = PdfWriter(clone_from=pdf_bytesio)
embedded_file = pdf_writer.add_attachment(
filename=self._prepare_ubl_attachment_filename(), data=ubl_xml_bytes
@@ -903,9 +988,41 @@ def _regular_pdf_invoice_to_en16931_pdf_invoice(self, pdf_bytesio, variant):
}
)
pdf_writer.write(pdf_bytesio)
+ self._en16931_pdf_to_pdfa(pdf_bytesio)
+
+ def _en16931_pdf_to_pdfa(self, pdf_bytesio):
+ """Turn the Factur-X PDF into a valid PDF/A-3.
+
+ factur-x embeds the XML and writes the Factur-X XMP declaring
+ pdfaid:part=3 — but it converts nothing. The source PDF comes from
+ wkhtmltopdf and is not PDF/A: no sRGB OutputIntent, glyph width arrays
+ inconsistent with the embedded fonts, PDF header not 1.7. veraPDF fails
+ on those (ISO 19005-3 clauses 6.2.4.3 and 6.2.11.5), so a file that
+ claims PDF/A-3 is rejected by a conformance check.
+
+ odoo.tools.pdf ships OdooPdfFileWriter.convert_to_pdfa(); run the
+ already-Factur-X PDF through it. cloneReaderDocumentRoot keeps the
+ embedded XML and the Factur-X XMP; convert_to_pdfa() adds the
+ OutputIntent, rebuilds the glyph widths and fixes the header/ID.
+ """
+ # Imported here to keep the module import list portable across versions.
+ from odoo.tools.pdf import OdooPdfFileReader, OdooPdfFileWriter
+
+ pdf_bytesio.seek(0)
+ reader = OdooPdfFileReader(pdf_bytesio, strict=False)
+ writer = OdooPdfFileWriter()
+ writer.cloneReaderDocumentRoot(reader)
+ if not writer.is_pdfa:
+ writer.convert_to_pdfa()
+ out = BytesIO()
+ writer.write(out)
+ pdf_bytesio.seek(0)
+ pdf_bytesio.truncate(0)
+ pdf_bytesio.write(out.getvalue())
+ pdf_bytesio.seek(0)
def _get_pdf_invoice_bin(self):
- """Inherit if you use a reporting engine other than qweb"""
+ """This works with both qweb and py3o"""
self.ensure_one()
pdf_invoice_bin, _filetype = (
self.env["ir.actions.report"]
@@ -927,21 +1044,27 @@ def _get_en16931_invoice_bin(self, invoice_format, b64=False):
elif invoice_format == "ubl_pdf":
pdf_invoice_bin = self._get_pdf_invoice_bin()
invoice_bin = self.generate_en16931_xml(
- {"ubl-2.1": "extended-ctc-fr"}, pdf_invoice_bin=pdf_invoice_bin
- )["ubl-2.1"]
+ "ubl-2.1",
+ "extended-ctc-fr",
+ invoice_format,
+ pdf_invoice_bin=pdf_invoice_bin,
+ )[0]
elif invoice_format == "ubl":
- invoice_bin = self.generate_en16931_xml({"ubl-2.1": "extended-ctc-fr"})[
- "ubl-2.1"
- ]
+ invoice_bin = self.generate_en16931_xml(
+ "ubl-2.1", "extended-ctc-fr", invoice_format
+ )[0]
elif invoice_format == "cii_pdf":
pdf_invoice_bin = self._get_pdf_invoice_bin()
invoice_bin = self.generate_en16931_xml(
- {"facturx": "extended-ctc-fr"}, pdf_invoice_bin=pdf_invoice_bin
- )["facturx"]
+ "facturx",
+ "extended-ctc-fr",
+ invoice_format,
+ pdf_invoice_bin=pdf_invoice_bin,
+ )[0]
elif invoice_format == "cii":
- invoice_bin = self.generate_en16931_xml({"facturx": "extended-ctc-fr"})[
- "facturx"
- ]
+ invoice_bin = self.generate_en16931_xml(
+ "facturx", "extended-ctc-fr", invoice_format
+ )[0]
else:
raise ValueError("Wrong value for invoice_format arg")
if b64:
@@ -978,3 +1101,14 @@ def _get_saxon_server_codedb_base_url(self):
if web_base_url:
return urljoin(web_base_url, "en16931/")
return None
+
+ @api.model
+ def _get_saxon_server_raise_if_http_error(self):
+ saxon_validation_blocking = (
+ self.env["ir.config_parameter"]
+ .sudo()
+ .get_param("en16931.saxon_validation_blocking")
+ )
+ if saxon_validation_blocking and saxon_validation_blocking == "True":
+ return True
+ return False
diff --git a/account_invoice_en16931/models/account_move_line.py b/account_invoice_en16931/models/account_move_line.py
index b89c233..5784825 100644
--- a/account_invoice_en16931/models/account_move_line.py
+++ b/account_invoice_en16931/models/account_move_line.py
@@ -23,9 +23,53 @@
class AccountMoveLine(models.Model):
_inherit = "account.move.line"
+ def _en16931_get_vat_taxes(self):
+ """Method designed to be inherited to support exotic setups"""
+ self.ensure_one()
+ return self.tax_ids.filtered(lambda x: x.unece_type_code == "VAT")
+
+ def _post_check_en16931_sale_document(self, errors):
+ self.ensure_one()
+ assert self.display_type == "product"
+ for tax in self.tax_ids:
+ # either we check both active and inactive taxes in
+ # company_id._en16931_checks() or we block invoice validation
+ # on inactive taxes
+ if not tax.active:
+ errors.append(
+ self.env._(
+ "Invoice line '%(inv_line)s' has tax '%(tax)s' "
+ "which is not active.",
+ inv_line=self.display_name,
+ tax=tax.display_name,
+ )
+ )
+ vat_taxes = self._en16931_get_vat_taxes()
+ if not vat_taxes:
+ errors.append(
+ self.env._(
+ "There is no VAT tax on invoice line '%(inv_line)s'. "
+ "You must set a VAT tax on "
+ "each invoice line in company '%(company)s' because "
+ "it is a VAT-registered company.",
+ inv_line=self.display_name,
+ company=self.company_id.display_name,
+ )
+ )
+ elif len(vat_taxes) > 1:
+ errors.append(
+ self.env._(
+ "Invoice line '%(inv_line)s' has several "
+ "VAT taxes (%(vat_taxes)s). EN16931 only "
+ "allows one VAT tax.",
+ inv_line=self.display_name,
+ vat_taxes=", ".join([tax.display_name for tax in vat_taxes]),
+ )
+ )
+
def _check_en16931(self, speedy):
self.ensure_one()
- vat_tax = self.tax_ids.filtered(lambda x: x.unece_type_code == "VAT")
+ vat_tax = self._en16931_get_vat_taxes()
if speedy["company_no_vat_taxes"]:
assert not vat_tax
vat_dict = speedy["vat_info4company_no_vat_taxes"]
@@ -45,7 +89,13 @@ def _check_en16931(self, speedy):
)
assert vat_tax.unece_categ_code
vat_dict = {"categ_code": vat_tax.unece_categ_code}
- if vat_tax.unece_categ_code in ("S", "K", "G"):
+ # BT-152 is required on the line for every VAT category but O:
+ # BR-S-05 wants the rate, BR-Z-05, BR-E-05, BR-AE-05, BR-G-05 and
+ # BR-IC-05 all want an explicit 0, and only BR-O-05 wants it left
+ # out. Restricting this to S/K/G dropped BT-152 on exempt lines,
+ # which BR-E-05 rejects and which then breaks BR-FXEXT-E-08: the
+ # schematron sums no line at all against the E breakdown.
+ if vat_tax.unece_categ_code != "O":
vat_dict["vat_rate"] = vat_tax.amount
if vat_tax.unece_categ_code not in ("S", "Z"):
assert vat_tax.unece_vatex_code
diff --git a/account_invoice_en16931/models/ir_actions_report.py b/account_invoice_en16931/models/ir_actions_report.py
index 92d6ddb..f73de98 100644
--- a/account_invoice_en16931/models/ir_actions_report.py
+++ b/account_invoice_en16931/models/ir_actions_report.py
@@ -26,8 +26,10 @@ def _render_qweb_pdf_prepare_streams(self, report_ref, data, res_ids=None):
and not self.env.context.get("regular_pdf_invoice")
):
move = amo.browse(res_ids)
- variant = move._get_pdf_invoice_variant()
- if variant:
+ invoice_format = move._get_pdf_invoice_format()
+ if invoice_format:
pdf_bytesio = collected_streams[move.id]["stream"]
- move._regular_pdf_invoice_to_en16931_pdf_invoice(pdf_bytesio, variant)
+ move._regular_pdf_invoice_to_en16931_pdf_invoice(
+ pdf_bytesio, invoice_format
+ )
return collected_streams
diff --git a/account_invoice_en16931/models/res_company.py b/account_invoice_en16931/models/res_company.py
index c175237..aaea692 100644
--- a/account_invoice_en16931/models/res_company.py
+++ b/account_invoice_en16931/models/res_company.py
@@ -19,6 +19,14 @@ class ResCompany(models.Model):
default="facturx",
string="Default PDF Invoice Generation",
)
+ en16931_issuer = fields.Boolean(
+ string="Issues EN16931 e-Invoices",
+ help="Check the EN16931 configuration of this company when a customer "
+ "invoice is posted. Leave unchecked for companies that never emit an "
+ "EN16931 document: their tax configuration is then only checked when "
+ "such a document is actually generated. Localisation modules may set "
+ "this field for the companies they cover.",
+ )
no_vat_taxes = fields.Boolean(
compute="_compute_no_vat_taxes", string="Company has no VAT Taxes"
)
@@ -69,15 +77,6 @@ def _en16931_checks(self):
qty_prec,
)
)
- disc_prec = dpo.precision_get("Discount")
- if disc_prec > 2:
- errors.append(
- self.env._(
- "Discount decimal precision is %s. For EN16931, the maximum "
- "value is 2.",
- disc_prec,
- )
- )
if errors:
raise UserError(
self.env._(
diff --git a/account_invoice_en16931/models/res_partner.py b/account_invoice_en16931/models/res_partner.py
index 6c11190..f57eb9a 100644
--- a/account_invoice_en16931/models/res_partner.py
+++ b/account_invoice_en16931/models/res_partner.py
@@ -31,7 +31,7 @@ def _en16931_partner_data(self, country_required=True):
"zip": self.zip,
"city": self.city,
"country_code": country and country.code or False,
- "phone": self.phone or self.mobile,
+ "phone": self.phone,
"email": self.email,
"vat": self.commercial_partner_id.vat,
}
diff --git a/account_invoice_en16931/views/account_move.xml b/account_invoice_en16931/views/account_move.xml
index 7f64091..fa822e0 100644
--- a/account_invoice_en16931/views/account_move.xml
+++ b/account_invoice_en16931/views/account_move.xml
@@ -9,9 +9,37 @@
account.move
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
-
-
+
+
+
+
+
diff --git a/l10n_fr_einvoicing_dashboard_banner/__manifest__.py b/l10n_fr_einvoicing_dashboard_banner/__manifest__.py
index 7c9b0ec..74425d6 100644
--- a/l10n_fr_einvoicing_dashboard_banner/__manifest__.py
+++ b/l10n_fr_einvoicing_dashboard_banner/__manifest__.py
@@ -4,7 +4,7 @@
{
"name": "France eInvoicing Dashboard Banner",
- "version": "18.0.1.0.0",
+ "version": "19.0.1.0.0",
"category": "Accounting",
"license": "AGPL-3",
"summary": "Add widgets for eInvoicing flows in Accounting Dashboard Banner",
@@ -16,5 +16,5 @@
"l10n_fr_einvoicing",
],
"post_init_hook": "create_fr_einvoicing_dashboard_cells",
- "installable": True,
+ "installable": False,
}
diff --git a/l10n_fr_einvoicing_directory_import/__init__.py b/l10n_fr_einvoicing_directory_import/__init__.py
new file mode 100644
index 0000000..aee8895
--- /dev/null
+++ b/l10n_fr_einvoicing_directory_import/__init__.py
@@ -0,0 +1,2 @@
+from . import models
+from . import wizards
diff --git a/l10n_fr_einvoicing_directory_import/__manifest__.py b/l10n_fr_einvoicing_directory_import/__manifest__.py
new file mode 100644
index 0000000..6b5991a
--- /dev/null
+++ b/l10n_fr_einvoicing_directory_import/__manifest__.py
@@ -0,0 +1,20 @@
+# Copyright 2026 Sudokeys
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+{
+ "name": "France eInvoicing Directory Import/Export (CSV)",
+ "version": "19.0.1.0.0",
+ "category": "Accounting/Localizations/EDI",
+ "summary": "Maintain fr.directory.line manually via CSV export/import "
+ "when the AFNOR directory API is not available",
+ "author": "Sudokeys",
+ "website": "https://github.com/akretion/fr-einvoicing",
+ "license": "AGPL-3",
+ "depends": [
+ "l10n_fr_einvoicing",
+ ],
+ "data": [
+ "security/ir.model.access.csv",
+ "wizards/fr_directory_csv_wizard_views.xml",
+ ],
+ "installable": True,
+}
diff --git a/l10n_fr_einvoicing_directory_import/examples/exemple-retour-annuaire.csv b/l10n_fr_einvoicing_directory_import/examples/exemple-retour-annuaire.csv
new file mode 100644
index 0000000..1db94a5
--- /dev/null
+++ b/l10n_fr_einvoicing_directory_import/examples/exemple-retour-annuaire.csv
@@ -0,0 +1,4 @@
+siren,siret,routing_code,routing_code_name,state,commitment_required
+497828095,49782809500012,,,active,0
+450487905,45048790500018,SERVICE_COMPTA,Comptabilité,active,1
+503908576,,,,upcoming,0
diff --git a/l10n_fr_einvoicing_directory_import/i18n/fr.po b/l10n_fr_einvoicing_directory_import/i18n/fr.po
new file mode 100644
index 0000000..a43a146
--- /dev/null
+++ b/l10n_fr_einvoicing_directory_import/i18n/fr.po
@@ -0,0 +1,247 @@
+# Translation of Odoo Server.
+# This file contains the translation of the following modules:
+# * l10n_fr_einvoicing_directory_import
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Odoo Server 18.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2026-07-27 08:13+0000\n"
+"PO-Revision-Date: 2026-07-27 08:13+0000\n"
+"Last-Translator: \n"
+"Language-Team: \n"
+"Language: fr\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: \n"
+"Plural-Forms: \n"
+
+#. module: l10n_fr_einvoicing_directory_import
+#. odoo-python
+#: code:addons/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard.py:0
+msgid "%(c)s SIREN exported in %(f)s file(s) (max 5000 lines / 1 MB each)."
+msgstr "%(c)s SIREN exportés dans %(f)s fichier(s) (max 5000 lignes / 1 Mo chacun)."
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form
+msgid "1. Export SIREN (directory deposit)"
+msgstr "1. Export des SIREN (dépôt annuaire)"
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form
+msgid "2. Import the directory return"
+msgstr "2. Import du retour de l'annuaire"
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form
+msgid ""
+"facturation.chorus-"
+"pro.gouv.fr/annuaire"
+msgstr ""
+"facturation.chorus-"
+"pro.gouv.fr/annuaire"
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form
+msgid "Close"
+msgstr "Fermer"
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__create_uid
+msgid "Created by"
+msgstr "Créé par"
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__create_date
+msgid "Created on"
+msgstr "Créé le"
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__import_file
+msgid "Directory return CSV"
+msgstr "CSV retour de l'annuaire"
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.ui.menu,name:l10n_fr_einvoicing_directory_import.fr_directory_csv_menu
+msgid "Directory — CSV import/export"
+msgstr "Annuaire — Import/Export CSV"
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__display_name
+msgid "Display Name"
+msgstr "Nom affiché"
+
+#. module: l10n_fr_einvoicing_directory_import
+#. odoo-python
+#: code:addons/l10n_fr_einvoicing_directory_import/models/fr_directory_line.py:0
+msgid "Empty or unreadable CSV file."
+msgstr "Fichier CSV vide ou illisible."
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__export_filename
+msgid "Export Filename"
+msgstr "Nom du fichier exporté"
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model.fields,help:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__only_missing
+msgid ""
+"Export only companies that do not have any directory line yet (i.e. not "
+"registered in the directory)."
+msgstr ""
+"N'exporte que les sociétés qui n'ont encore aucune ligne d'annuaire "
+"(c.-à-d. non enregistrées dans l'annuaire)."
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.actions.act_window,name:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_action
+#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form
+msgid "France eInvoicing — Directory (CSV import/export)"
+msgstr "Facturation électronique FR — Annuaire (import/export CSV)"
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form
+msgid "Generate SIREN CSV"
+msgstr "Générer le CSV des SIREN"
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form
+msgid "Generated file"
+msgstr "Fichier généré"
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__id
+msgid "ID"
+msgstr "ID"
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__import_filename
+msgid "Import Filename"
+msgstr "Nom du fichier importé"
+
+#. module: l10n_fr_einvoicing_directory_import
+#. odoo-python
+#: code:addons/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard.py:0
+msgid ""
+"Import done: %(c)s created, %(u)s updated, %(s)s skipped, %(a)s ambiguous."
+msgstr ""
+"Import terminé : %(c)s créée(s), %(u)s mise(s) à jour, %(s)s ignorée(s), "
+"%(a)s ambiguë(s)."
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form
+msgid "Import return CSV"
+msgstr "Importer le CSV retour"
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model,name:l10n_fr_einvoicing_directory_import.model_fr_directory_csv_wizard
+msgid "Import/Export eInvoicing directory via CSV"
+msgstr "Import/Export de l'annuaire e-facturation via CSV"
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__write_uid
+msgid "Last Updated by"
+msgstr "Mis à jour par"
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__write_date
+msgid "Last Updated on"
+msgstr "Mis à jour le"
+
+#. module: l10n_fr_einvoicing_directory_import
+#. odoo-python
+#: code:addons/l10n_fr_einvoicing_directory_import/models/fr_directory_line.py:0
+msgid "No SIREN column found. Columns: %s"
+msgstr "Aucune colonne SIREN trouvée. Colonnes : %s"
+
+#. module: l10n_fr_einvoicing_directory_import
+#. odoo-python
+#: code:addons/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard.py:0
+msgid "No SIREN to export for the selected companies."
+msgstr "Aucun SIREN à exporter pour les sociétés sélectionnées."
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form
+msgid ""
+"Official directory (deposit the SIREN file, then download the\n"
+" return):"
+msgstr ""
+"Annuaire officiel (déposez le fichier SIREN, puis téléchargez le\n"
+" retour) :"
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form
+msgid ""
+"One SIREN per line, without header — to deposit on the\n"
+" official directory. Files are capped at 5000 lines / 1 MB;\n"
+" a ZIP of several CSV is produced when needed."
+msgstr ""
+"Un SIREN par ligne, sans en-tête — à déposer sur l'annuaire\n"
+" officiel. Les fichiers sont limités à 5000 lignes / 1 Mo ;\n"
+" un ZIP de plusieurs CSV est produit si besoin."
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__only_missing
+msgid "Only companies without a directory line"
+msgstr "Uniquement les sociétés sans ligne d'annuaire"
+
+#. module: l10n_fr_einvoicing_directory_import
+#. odoo-python
+#: code:addons/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard.py:0
+msgid "Please upload the directory return CSV first."
+msgstr "Chargez d'abord le CSV retour de l'annuaire."
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__result_summary
+msgid "Result Summary"
+msgstr "Résumé de l'import"
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form
+msgid "Return CSV"
+msgstr "CSV retour"
+
+#. module: l10n_fr_einvoicing_directory_import
+#. odoo-python
+#: code:addons/l10n_fr_einvoicing_directory_import/models/fr_directory_line.py:0
+msgid ""
+"Row %(n)s: SIREN %(s)s is shared by several companies — linked to %(p)s."
+msgstr ""
+"Ligne %(n)s : le SIREN %(s)s est partagé par plusieurs sociétés — rattaché "
+"à %(p)s."
+
+#. module: l10n_fr_einvoicing_directory_import
+#. odoo-python
+#: code:addons/l10n_fr_einvoicing_directory_import/models/fr_directory_line.py:0
+msgid "Row %(n)s: no partner found for SIREN %(s)s."
+msgstr "Ligne %(n)s : aucun partenaire trouvé pour le SIREN %(s)s."
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__export_file
+msgid "SIREN file"
+msgstr "Fichier SIREN"
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form
+msgid ""
+"Upload the CSV returned by the directory (Chorus Pro format),\n"
+" then import it to create/update the directory lines."
+msgstr ""
+"Chargez le CSV renvoyé par l'annuaire (format Chorus Pro),\n"
+" puis importez-le pour créer/mettre à jour les lignes "
+"d'annuaire."
+
+#. module: l10n_fr_einvoicing_directory_import
+#. odoo-python
+#: code:addons/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard.py:0
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__result_partner_ids
+msgid "Updated companies"
+msgstr "Sociétés mises à jour"
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form
+msgid "View updated companies"
+msgstr "Voir les sociétés mises à jour"
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model,name:l10n_fr_einvoicing_directory_import.model_fr_directory_line
+msgid "eInvoicing Directory Line for France"
+msgstr "Ligne d'annuaire e-facturation pour la France"
diff --git a/l10n_fr_einvoicing_directory_import/i18n/l10n_fr_einvoicing_directory_import.pot b/l10n_fr_einvoicing_directory_import/i18n/l10n_fr_einvoicing_directory_import.pot
new file mode 100644
index 0000000..6d9ae53
--- /dev/null
+++ b/l10n_fr_einvoicing_directory_import/i18n/l10n_fr_einvoicing_directory_import.pot
@@ -0,0 +1,230 @@
+# Translation of Odoo Server.
+# This file contains the translation of the following modules:
+# * l10n_fr_einvoicing_directory_import
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Odoo Server 18.0+e-20260630\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2026-07-27 08:20+0000\n"
+"PO-Revision-Date: 2026-07-27 08:20+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: l10n_fr_einvoicing_directory_import
+#. odoo-python
+#: code:addons/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard.py:0
+msgid "%(c)s SIREN exported in %(f)s file(s) (max 5000 lines / 1 MB each)."
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form
+msgid "1. Export SIREN (directory deposit)"
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form
+msgid "2. Import the directory return"
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form
+msgid ""
+"facturation.chorus-"
+"pro.gouv.fr/annuaire"
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form
+msgid "Close"
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__create_uid
+msgid "Created by"
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__create_date
+msgid "Created on"
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__import_file
+msgid "Directory return CSV"
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.ui.menu,name:l10n_fr_einvoicing_directory_import.fr_directory_csv_menu
+msgid "Directory — CSV import/export"
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__display_name
+msgid "Display Name"
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#. odoo-python
+#: code:addons/l10n_fr_einvoicing_directory_import/models/fr_directory_line.py:0
+msgid "Empty or unreadable CSV file."
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__export_filename
+msgid "Export Filename"
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model.fields,help:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__only_missing
+msgid ""
+"Export only companies that do not have any directory line yet (i.e. not "
+"registered in the directory)."
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.actions.act_window,name:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_action
+#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form
+msgid "France eInvoicing — Directory (CSV import/export)"
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form
+msgid "Generate SIREN CSV"
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form
+msgid "Generated file"
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__id
+msgid "ID"
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__import_filename
+msgid "Import Filename"
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#. odoo-python
+#: code:addons/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard.py:0
+msgid ""
+"Import done: %(c)s created, %(u)s updated, %(s)s skipped, %(a)s ambiguous."
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form
+msgid "Import return CSV"
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model,name:l10n_fr_einvoicing_directory_import.model_fr_directory_csv_wizard
+msgid "Import/Export eInvoicing directory via CSV"
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__write_uid
+msgid "Last Updated by"
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__write_date
+msgid "Last Updated on"
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#. odoo-python
+#: code:addons/l10n_fr_einvoicing_directory_import/models/fr_directory_line.py:0
+msgid "No SIREN column found. Columns: %s"
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#. odoo-python
+#: code:addons/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard.py:0
+msgid "No SIREN to export for the selected companies."
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form
+msgid ""
+"Official directory (deposit the SIREN file, then download the\n"
+" return):"
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form
+msgid ""
+"One SIREN per line, without header — to deposit on the\n"
+" official directory. Files are capped at 5000 lines / 1 MB;\n"
+" a ZIP of several CSV is produced when needed."
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__only_missing
+msgid "Only companies without a directory line"
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#. odoo-python
+#: code:addons/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard.py:0
+msgid "Please upload the directory return CSV first."
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__result_summary
+msgid "Result Summary"
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form
+msgid "Return CSV"
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#. odoo-python
+#: code:addons/l10n_fr_einvoicing_directory_import/models/fr_directory_line.py:0
+msgid ""
+"Row %(n)s: SIREN %(s)s is shared by several companies — linked to %(p)s."
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#. odoo-python
+#: code:addons/l10n_fr_einvoicing_directory_import/models/fr_directory_line.py:0
+msgid "Row %(n)s: no partner found for SIREN %(s)s."
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__export_file
+msgid "SIREN file"
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#. odoo-python
+#: code:addons/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard.py:0
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__result_partner_ids
+msgid "Updated companies"
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form
+msgid ""
+"Upload the CSV returned by the directory (Chorus Pro format),\n"
+" then import it to create/update the directory lines."
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form
+msgid "View updated companies"
+msgstr ""
+
+#. module: l10n_fr_einvoicing_directory_import
+#: model:ir.model,name:l10n_fr_einvoicing_directory_import.model_fr_directory_line
+msgid "eInvoicing Directory Line for France"
+msgstr ""
diff --git a/l10n_fr_einvoicing_directory_import/models/__init__.py b/l10n_fr_einvoicing_directory_import/models/__init__.py
new file mode 100644
index 0000000..5d82855
--- /dev/null
+++ b/l10n_fr_einvoicing_directory_import/models/__init__.py
@@ -0,0 +1,2 @@
+from . import fr_directory_line
+from . import account_move
diff --git a/l10n_fr_einvoicing_directory_import/models/account_move.py b/l10n_fr_einvoicing_directory_import/models/account_move.py
new file mode 100644
index 0000000..c9365fe
--- /dev/null
+++ b/l10n_fr_einvoicing_directory_import/models/account_move.py
@@ -0,0 +1,37 @@
+# Copyright 2026 Sudokeys
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+import logging
+
+from odoo import fields, models, tools
+
+logger = logging.getLogger(__name__)
+
+
+class AccountMove(models.Model):
+ _inherit = "account.move"
+
+ # Odoo does not index this column. Resolving a partner's directory status
+ # walks its invoices, so the directory import runs one such lookup per
+ # company: without an index each one is a sequential scan of the whole
+ # account_move table (877k rows on the FPV database, tens of seconds each),
+ # and the import gets killed by the server time limit long before finishing.
+ # Indexing it also benefits every partner-centric accounting screen.
+ commercial_partner_id = fields.Many2one(index=True)
+
+ def init(self):
+ # `index=True` above is enough on a clean database, but the field is
+ # inherited: any module redefining it without `index` would drop the
+ # index again. Creating it here as well makes the module self-healing —
+ # `create_index` is a no-op when the index already exists.
+ res = super().init()
+ tools.create_index(
+ self._cr,
+ "account_move_commercial_partner_id_index",
+ self._table,
+ ["commercial_partner_id"],
+ )
+ logger.info(
+ "account_move.commercial_partner_id index ensured "
+ "(directory import performance)"
+ )
+ return res
diff --git a/l10n_fr_einvoicing_directory_import/models/fr_directory_line.py b/l10n_fr_einvoicing_directory_import/models/fr_directory_line.py
new file mode 100644
index 0000000..4173724
--- /dev/null
+++ b/l10n_fr_einvoicing_directory_import/models/fr_directory_line.py
@@ -0,0 +1,416 @@
+# Copyright 2026 Sudokeys
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+#
+# Official French e-invoicing directory (Chorus Pro):
+# https://facturation.chorus-pro.gouv.fr/annuaire/
+import csv
+import io
+import logging
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError
+
+logger = logging.getLogger(__name__)
+
+# Map the status returned by the directory to the ``state`` field.
+STATE_ALIASES = {
+ "": "inactive",
+ "enabled": "active",
+ "active": "active",
+ "registered": "active",
+ "upcoming": "upcoming",
+ "in_progress": "upcoming",
+ "disabled": "disabled",
+ "inactive": "inactive",
+}
+VALID_TYPES = ("siren", "siret", "routing_code", "suffix", "error")
+BOOL_TRUE = {"1", "true", "vrai", "oui", "yes", "x", "o"}
+
+# Partners processed per transaction during the import. Each batch is
+# committed on its own: marking partners invalidates a stored compute on
+# account.move, whose recompute would otherwise pile up over the entire
+# invoice history and exhaust memory on a production-sized database.
+DIRECTORY_IMPORT_BATCH = 200
+
+# The State directory caps each deposited file at 5000 lines and 1 MB.
+DIRECTORY_MAX_LINES = 5000
+DIRECTORY_MAX_BYTES = 1_000_000
+
+
+class FrDirectoryLine(models.Model):
+ _inherit = "fr.directory.line"
+
+ # ------------------------------------------------------------------
+ # Export: CSV of SIREN numbers to deposit on the State directory
+ # ------------------------------------------------------------------
+ @api.model
+ def _directory_export_siren_list(self, partners):
+ """Return the ordered list of unique SIREN numbers of the partners."""
+ sirens = []
+ seen = set()
+ for partner in partners:
+ siren = partner._get_siren(raise_if_none=False)
+ if siren and siren not in seen:
+ seen.add(siren)
+ sirens.append(siren)
+ return sirens
+
+ @api.model
+ def _directory_export_siren_chunks(self, partners):
+ """Return a list of CSV chunks (bytes), one SIREN per line, WITHOUT a
+ header and with ``\\n`` line endings. Each chunk stays within the State
+ directory limits (<= 5000 lines and <= 1 MB), splitting when needed."""
+ chunks = []
+ buf = io.StringIO()
+ writer = csv.writer(buf, lineterminator="\n")
+ lines = 0
+ for siren in self._directory_export_siren_list(partners):
+ if lines >= DIRECTORY_MAX_LINES or (
+ buf.tell() + len(siren) + 1 > DIRECTORY_MAX_BYTES
+ ):
+ chunks.append(buf.getvalue().encode("utf-8"))
+ buf = io.StringIO()
+ writer = csv.writer(buf, lineterminator="\n")
+ lines = 0
+ writer.writerow([siren])
+ lines += 1
+ if lines:
+ chunks.append(buf.getvalue().encode("utf-8"))
+ return chunks
+
+ @api.model
+ def _directory_export_siren_csv(self, partners):
+ """Return a single CSV (bytes) with every SIREN (no size limit).
+
+ Kept for callers that do not care about the directory 5000-lines / 1 MB
+ deposit limits; the wizard uses :meth:`_directory_export_siren_chunks`.
+ """
+ out = io.StringIO()
+ writer = csv.writer(out, lineterminator="\n")
+ for siren in self._directory_export_siren_list(partners):
+ writer.writerow([siren])
+ return out.getvalue().encode("utf-8")
+
+ # ------------------------------------------------------------------
+ # Import: directory return CSV -> create/update directory lines
+ # ------------------------------------------------------------------
+ @api.model
+ def _directory_import_csv(self, content): # noqa: C901
+ """Create/update directory lines from the directory return CSV.
+
+ Understands the Chorus Pro directory export (columns "SIREN" and
+ "Adresse de facturation" / "Adresse de facturation active") as well as
+ a canonical format (siren, siret, identifier, routing_code, state...).
+ Each row is matched to its commercial partner by SIREN and upserted by
+ (partner, identifier), like the native API sync. The delimiter (``,``
+ or ``;``) is auto-detected.
+ """
+ try:
+ text = content.decode("utf-8-sig")
+ except UnicodeDecodeError:
+ text = content.decode("latin-1")
+ sample = text[:2000]
+ delimiter = ";" if sample.count(";") > sample.count(",") else ","
+ reader = csv.DictReader(io.StringIO(text), delimiter=delimiter)
+ if not reader.fieldnames:
+ raise UserError(_("Empty or unreadable CSV file."))
+ cols = self._directory_detect_columns(reader.fieldnames)
+ if "siren" not in cols:
+ raise UserError(
+ _(
+ "No SIREN column found. Columns: %s",
+ ", ".join(reader.fieldnames),
+ )
+ )
+
+ index = self._directory_partner_index()
+ created = updated = skipped = ambiguous = 0
+ errors = []
+ affected = set()
+ synced = set()
+
+ # Pass 1 — parse the whole file and resolve partners, without touching
+ # the database. A directory return holds one row per company: doing an
+ # ORM search and a write per row costs one query each and re-triggers
+ # the stored computes on res.partner every time, which blows past the
+ # server's request time limit on real files (thousands of rows).
+ parsed = []
+ for line_no, row in enumerate(reader, start=2):
+ siren = (row.get(cols["siren"]) or "").strip().replace(" ", "")
+ if not siren.isdigit() or len(siren) != 9:
+ skipped += 1
+ continue
+ vals = self._directory_row_to_vals(row, cols, siren)
+ partner, issue = self._directory_match_partner(
+ siren, vals.get("siret"), index
+ )
+ if not partner:
+ skipped += 1
+ errors.append(
+ _(
+ "Row %(n)s: no partner found for SIREN %(s)s.",
+ n=line_no,
+ s=siren,
+ )
+ )
+ continue
+ if issue == "ambiguous":
+ ambiguous += 1
+ errors.append(
+ _(
+ "Row %(n)s: SIREN %(s)s is shared by several companies — "
+ "linked to %(p)s.",
+ n=line_no,
+ s=siren,
+ p=partner.display_name,
+ )
+ )
+ synced.add(partner.id)
+ parsed.append((partner, vals))
+
+ # Pre-load every existing line of the partners involved in one query,
+ # indexed by (partner, identifier) — the key used for the upsert.
+ existing_map = {}
+ if parsed:
+ partner_ids = list({partner.id for partner, _vals in parsed})
+ for line in self.with_context(active_test=False).search(
+ [("partner_id", "in", partner_ids)]
+ ):
+ existing_map[(line.partner_id.id, line.identifier)] = line
+
+ # Pass 2 — write in batches of partners, committing between each.
+ #
+ # Marking a partner as present in the directory invalidates the stored
+ # `fr_einvoicing_required` on account.move, which depends on the
+ # partner's entity type. Doing it for every partner in one transaction
+ # makes Odoo recompute that field over the partner's whole invoice
+ # history at flush time — on a production database (877k moves here)
+ # the process runs out of memory. Committing per batch keeps each
+ # recompute bounded and releases the cache as we go.
+ #
+ # Trade-off: the import is no longer atomic. That is deliberate — a
+ # directory return is idempotent (upsert by partner + identifier), so
+ # re-running it after a failure resumes where it stopped.
+ Line = self.sudo().with_context(tracking_disable=True)
+ by_partner = {}
+ for partner, vals in parsed:
+ by_partner.setdefault(partner.id, []).append(vals)
+ all_partner_ids = list(by_partner)
+ batches = range(0, len(all_partner_ids), DIRECTORY_IMPORT_BATCH)
+
+ for offset in batches:
+ batch_ids = all_partner_ids[offset : offset + DIRECTORY_IMPORT_BATCH]
+ to_create = []
+ write_groups = {}
+ for partner_id in batch_ids:
+ for vals in by_partner[partner_id]:
+ existing = existing_map.get((partner_id, vals["identifier"]))
+ if existing:
+ wvals = {
+ key: value
+ for key, value in vals.items()
+ if key != "identifier"
+ and (existing[key] or False) != (value or False)
+ }
+ if wvals:
+ write_groups.setdefault(
+ tuple(sorted(wvals.items(), key=lambda kv: kv[0])), []
+ ).append(existing.id)
+ updated += 1
+ affected.add(partner_id)
+ else:
+ to_create.append(dict(vals, partner_id=partner_id))
+ created += 1
+ affected.add(partner_id)
+ for wvals_items, line_ids in write_groups.items():
+ Line.browse(line_ids).write(dict(wvals_items))
+ if to_create:
+ Line.create(to_create)
+
+ batch_synced = [pid for pid in batch_ids if pid in synced]
+ if batch_synced:
+ self._directory_mark_partners_registered(
+ self.env["res.partner"].browse(batch_synced)
+ )
+
+ # Flush, commit, then drop the cache: without this the recomputes
+ # of the whole run pile up until the final flush.
+ self.env.flush_all()
+ self.env.cr.commit()
+ self.env.invalidate_all()
+ logger.info(
+ "Directory CSV import: %s/%s partners processed.",
+ min(offset + DIRECTORY_IMPORT_BATCH, len(all_partner_ids)),
+ len(all_partner_ids),
+ )
+ logger.info(
+ "Directory CSV import: %s created, %s updated, %s skipped, "
+ "%s ambiguous.",
+ created,
+ updated,
+ skipped,
+ ambiguous,
+ )
+ return {
+ "created": created,
+ "updated": updated,
+ "skipped": skipped,
+ "ambiguous": ambiguous,
+ "errors": errors,
+ "partner_ids": list(affected),
+ }
+
+ @api.model
+ def _directory_mark_partners_registered(self, partners):
+ """Mark partners as present in the directory after a CSV import.
+
+ The native module fills these partner-level fields during the API sync;
+ the CSV import must do the same, otherwise the directory section on the
+ partner (status, default line selector) stays hidden and BT-49 cannot
+ resolve. Entity type defaults to ``private`` when not already set.
+ """
+ today = fields.Date.context_today(self)
+ # One write per partner would mean thousands of UPDATE plus as many
+ # recomputes of the stored directory fields. Partners sharing the exact
+ # same values are written together, and tracking is disabled so the
+ # import doesn't fill the chatter.
+ Partner = partners.sudo().with_context(tracking_disable=True)
+ groups = {}
+ for partner in Partner.commercial_partner_id:
+ vals = {"fr_directory_last_sync_date": today}
+ if not partner.fr_directory_entity_type:
+ vals["fr_directory_entity_type"] = "private"
+ siren = partner._get_siren(raise_if_none=False)
+ if siren:
+ vals["fr_directory_siren"] = siren
+ siret = partner._get_siret(raise_if_none=False)
+ if siret:
+ vals["fr_directory_siret"] = siret
+ # Convenience: when the partner ends up with a single active line and
+ # no default set, use it as the default routing line (BT-49). The
+ # field is a manual selector natively; the directory return usually
+ # confirms one address per company, so this saves a manual pick.
+ if not partner.default_fr_directory_line_id:
+ active_lines = partner.fr_directory_line_ids
+ if len(active_lines) == 1:
+ vals["default_fr_directory_line_id"] = active_lines.id
+ groups.setdefault(
+ tuple(sorted(vals.items(), key=lambda kv: kv[0])), []
+ ).append(partner.id)
+ for vals_items, partner_ids in groups.items():
+ Partner.browse(partner_ids).write(dict(vals_items))
+
+ @api.model
+ def _directory_partner_index(self):
+ """Index companies by SIREN and by SIRET for partner matching."""
+ Partner = self.env["res.partner"]
+ companies = Partner.with_context(active_test=False).search(
+ [("is_company", "=", True)]
+ )
+ by_siren = {}
+ by_siret = {}
+ for partner in companies:
+ siren = partner._get_siren(raise_if_none=False)
+ if siren:
+ by_siren[siren] = by_siren.get(siren, Partner) | partner
+ siret = partner._get_siret(raise_if_none=False)
+ if siret:
+ by_siret.setdefault(siret, partner)
+ return {"siren": by_siren, "siret": by_siret}
+
+ @api.model
+ def _directory_match_partner(self, siren, siret, index):
+ """Return (commercial_partner, anomaly).
+
+ Prefer the SIRET (disambiguates when several companies share a SIREN);
+ otherwise match by SIREN. ``anomaly`` is 'ambiguous' when the SIREN maps
+ to several distinct companies.
+ """
+ empty = self.env["res.partner"]
+ if siret and siret in index["siret"]:
+ return index["siret"][siret].commercial_partner_id, None
+ partners = index["siren"].get(siren, empty)
+ commercials = partners.commercial_partner_id
+ if not commercials:
+ return empty, "no_partner"
+ if len(commercials) == 1:
+ return commercials, None
+ return commercials[0], "ambiguous"
+
+ @api.model
+ def _directory_detect_columns(self, fieldnames):
+ """Map each column to a role (Chorus Pro or canonical format)."""
+ cols = {}
+ for src in fieldnames:
+ low = (src or "").strip().lower()
+ if low == "siren":
+ cols["siren"] = src
+ elif "adresse de facturation" in low and "active" in low:
+ cols["active"] = src
+ elif "adresse de facturation" in low:
+ cols["identifier"] = src
+ elif low in ("identifier", "adresse"):
+ cols.setdefault("identifier", src)
+ elif low in ("state", "etat", "état"):
+ cols["state"] = src
+ elif low == "siret":
+ cols["siret"] = src
+ elif low in ("routing_code", "code_routage", "code routage"):
+ cols["routing_code"] = src
+ elif low in ("routing_code_name", "libelle", "libellé"):
+ cols["routing_code_name"] = src
+ elif low in ("commitment_required", "engagement"):
+ cols["commitment"] = src
+ return cols
+
+ @api.model
+ def _directory_row_to_vals(self, row, cols, siren):
+ """Turn a CSV row into fr.directory.line values."""
+
+ def val(role):
+ return (row.get(cols[role]) or "").strip() if role in cols else ""
+
+ identifier = val("identifier") or siren
+ rtype, siret, routing_code = self._directory_parse_identifier(identifier, siren)
+ if "active" in cols:
+ state = "active" if val("active").lower() in BOOL_TRUE else "disabled"
+ elif "state" in cols:
+ state = STATE_ALIASES.get(val("state").lower(), "active")
+ else:
+ state = "active"
+ return {
+ "identifier": identifier,
+ "type": rtype,
+ "siren": siren,
+ "siret": (val("siret") or siret) or False,
+ "routing_code": (val("routing_code") or routing_code) or False,
+ "routing_code_name": val("routing_code_name") or False,
+ "state": state,
+ "commitment_required": (
+ val("commitment").lower() in BOOL_TRUE
+ if "commitment" in cols
+ else False
+ ),
+ }
+
+ @api.model
+ def _directory_parse_identifier(self, identifier, siren):
+ """Derive (type, siret, routing_code) from the identifier.
+
+ Formats: SIREN | SIREN_SIRET | SIREN_SIRET_RoutingCode | SIREN_Suffix.
+ The routing code may contain "_": it is whatever follows the SIRET.
+ """
+ parts = identifier.split("_")
+ siret = routing_code = False
+ if len(parts) == 1:
+ return "siren", siret, routing_code
+ second = parts[1]
+ is_siret = len(second) == 14 and second.isdigit()
+ if is_siret:
+ siret = second
+ if len(parts) == 2:
+ return "siret", siret, routing_code
+ routing_code = "_".join(parts[2:])
+ return "routing_code", siret, routing_code
+ # 2nd segment is not a SIRET => addressing suffix
+ return "suffix", siret, routing_code
diff --git a/l10n_fr_einvoicing_directory_import/pyproject.toml b/l10n_fr_einvoicing_directory_import/pyproject.toml
new file mode 100644
index 0000000..4231d0c
--- /dev/null
+++ b/l10n_fr_einvoicing_directory_import/pyproject.toml
@@ -0,0 +1,3 @@
+[build-system]
+requires = ["whool"]
+build-backend = "whool.buildapi"
diff --git a/l10n_fr_einvoicing_directory_import/security/ir.model.access.csv b/l10n_fr_einvoicing_directory_import/security/ir.model.access.csv
new file mode 100644
index 0000000..5ba3abb
--- /dev/null
+++ b/l10n_fr_einvoicing_directory_import/security/ir.model.access.csv
@@ -0,0 +1,2 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_fr_directory_csv_wizard,fr.directory.csv.wizard,model_fr_directory_csv_wizard,account.group_account_manager,1,1,1,1
diff --git a/l10n_fr_einvoicing_directory_import/static/description/icon.png b/l10n_fr_einvoicing_directory_import/static/description/icon.png
new file mode 100644
index 0000000..1dcc49c
Binary files /dev/null and b/l10n_fr_einvoicing_directory_import/static/description/icon.png differ
diff --git a/l10n_fr_einvoicing_directory_import/tests/__init__.py b/l10n_fr_einvoicing_directory_import/tests/__init__.py
new file mode 100644
index 0000000..210cc1d
--- /dev/null
+++ b/l10n_fr_einvoicing_directory_import/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_directory_csv
diff --git a/l10n_fr_einvoicing_directory_import/tests/test_directory_csv.py b/l10n_fr_einvoicing_directory_import/tests/test_directory_csv.py
new file mode 100644
index 0000000..0fbf955
--- /dev/null
+++ b/l10n_fr_einvoicing_directory_import/tests/test_directory_csv.py
@@ -0,0 +1,413 @@
+# Copyright 2026 Sudokeys
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+#
+# Plain TransactionCase on purpose: the from-source Odoo images used to run
+# these modules strip the `tests/` directories of the standard addons, so
+# helpers such as odoo.addons.account.tests.common are not importable.
+from odoo.exceptions import UserError
+from odoo.tests.common import TransactionCase
+
+# SIREN and SIRET with valid checksums: l10n_fr_siret rejects anything else,
+# and _get_siren() silently returns None for an invalid SIREN.
+SIREN_A = "100000009"
+SIRET_A = "10000000900009"
+SIREN_B = "100000017"
+SIRET_B = "10000001700002"
+SIREN_C = "100000025"
+
+
+class DirectoryCase(TransactionCase):
+ def setUp(self):
+ super().setUp()
+ # _directory_import_csv() commits between batches on purpose (a single
+ # transaction would pile up the account.move recomputes until it runs
+ # out of memory). Inside a test that commit releases the savepoint
+ # TransactionCase rolls back to, which aborts the whole run. Neutralise
+ # the commit only: the flush that precedes it still writes the rows.
+ #
+ # 19.0 patches `commit` on the cursor *instance* (see the `forbidden`
+ # helper in odoo/tests/common.py), which shadows any patch set on the
+ # class, so the instance is what has to be patched here.
+ self.patch(self.env.cr, "commit", lambda: None)
+
+
+class TestDirectoryCsv(DirectoryCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.Line = cls.env["fr.directory.line"]
+ cls.Partner = cls.env["res.partner"]
+ cls.partner_a = cls.Partner.create(
+ {
+ "name": "Directory Test A",
+ "is_company": True,
+ "company_registry": SIRET_A,
+ "country_id": cls.env.ref("base.fr").id,
+ }
+ )
+ cls.partner_b = cls.Partner.create(
+ {
+ "name": "Directory Test B",
+ "is_company": True,
+ "company_registry": SIRET_B,
+ "country_id": cls.env.ref("base.fr").id,
+ }
+ )
+
+ # ------------------------------------------------------------------
+ # Identifier parsing
+ # ------------------------------------------------------------------
+ def test_parse_identifier_siren_only(self):
+ self.assertEqual(
+ self.Line._directory_parse_identifier(SIREN_A, SIREN_A),
+ ("siren", False, False),
+ )
+
+ def test_parse_identifier_siret(self):
+ identifier = f"{SIREN_A}_{SIRET_A}"
+ self.assertEqual(
+ self.Line._directory_parse_identifier(identifier, SIREN_A),
+ ("siret", SIRET_A, False),
+ )
+
+ def test_parse_identifier_routing_code(self):
+ identifier = f"{SIREN_A}_{SIRET_A}_SERVICE1"
+ self.assertEqual(
+ self.Line._directory_parse_identifier(identifier, SIREN_A),
+ ("routing_code", SIRET_A, "SERVICE1"),
+ )
+
+ def test_parse_identifier_routing_code_keeps_underscores(self):
+ """The routing code is everything after the SIRET, underscores included."""
+ identifier = f"{SIREN_A}_{SIRET_A}_SERVICE_COMPTA_2"
+ self.assertEqual(
+ self.Line._directory_parse_identifier(identifier, SIREN_A),
+ ("routing_code", SIRET_A, "SERVICE_COMPTA_2"),
+ )
+
+ def test_parse_identifier_suffix(self):
+ """A second segment that is not a 14-digit SIRET is an addressing suffix."""
+ self.assertEqual(
+ self.Line._directory_parse_identifier(f"{SIREN_A}_ABC", SIREN_A),
+ ("suffix", False, False),
+ )
+
+ # ------------------------------------------------------------------
+ # Column detection
+ # ------------------------------------------------------------------
+ def test_detect_columns_chorus_pro(self):
+ cols = self.Line._directory_detect_columns(
+ ["SIREN", "Adresse de facturation", "Adresse de facturation active"]
+ )
+ self.assertEqual(cols["siren"], "SIREN")
+ self.assertEqual(cols["identifier"], "Adresse de facturation")
+ self.assertEqual(cols["active"], "Adresse de facturation active")
+
+ def test_detect_columns_canonical(self):
+ cols = self.Line._directory_detect_columns(
+ ["siren", "siret", "identifier", "routing_code", "state", "engagement"]
+ )
+ self.assertEqual(cols["siret"], "siret")
+ self.assertEqual(cols["identifier"], "identifier")
+ self.assertEqual(cols["routing_code"], "routing_code")
+ self.assertEqual(cols["state"], "state")
+ self.assertEqual(cols["commitment"], "engagement")
+
+ def test_detect_columns_ignores_unknown(self):
+ cols = self.Line._directory_detect_columns(["SIREN", "Raison sociale"])
+ self.assertEqual(cols, {"siren": "SIREN"})
+
+ # ------------------------------------------------------------------
+ # Row -> values
+ # ------------------------------------------------------------------
+ def test_row_to_vals_inactive_address(self):
+ """Chorus Pro answers 'present but inactive' for most companies."""
+ cols = {"siren": "SIREN", "identifier": "Adresse", "active": "Adresse active"}
+ vals = self.Line._directory_row_to_vals(
+ {
+ "SIREN": SIREN_A,
+ "Adresse": f"{SIREN_A}_{SIRET_A}",
+ "Adresse active": "0",
+ },
+ cols,
+ SIREN_A,
+ )
+ self.assertEqual(vals["state"], "disabled")
+ self.assertEqual(vals["type"], "siret")
+ self.assertEqual(vals["siret"], SIRET_A)
+
+ def test_row_to_vals_active_address(self):
+ cols = {"siren": "SIREN", "identifier": "Adresse", "active": "Adresse active"}
+ vals = self.Line._directory_row_to_vals(
+ {"SIREN": SIREN_A, "Adresse": SIREN_A, "Adresse active": "oui"},
+ cols,
+ SIREN_A,
+ )
+ self.assertEqual(vals["state"], "active")
+ self.assertEqual(vals["type"], "siren")
+
+ def test_row_to_vals_state_alias(self):
+ cols = {"siren": "siren", "state": "state"}
+ vals = self.Line._directory_row_to_vals(
+ {"siren": SIREN_A, "state": "in_progress"}, cols, SIREN_A
+ )
+ self.assertEqual(vals["state"], "upcoming")
+
+ def test_row_to_vals_defaults_to_siren_identifier(self):
+ """Without an identifier column the SIREN itself is the identifier."""
+ vals = self.Line._directory_row_to_vals(
+ {"siren": SIREN_A}, {"siren": "siren"}, SIREN_A
+ )
+ self.assertEqual(vals["identifier"], SIREN_A)
+ self.assertEqual(vals["state"], "active")
+
+ # ------------------------------------------------------------------
+ # Export
+ # ------------------------------------------------------------------
+ def test_export_siren_list_dedupes(self):
+ partners = self.partner_a | self.partner_b | self.partner_a
+ self.assertEqual(
+ self.Line._directory_export_siren_list(partners),
+ [SIREN_A, SIREN_B],
+ )
+
+ def test_export_siren_list_skips_partners_without_siren(self):
+ no_siren = self.Partner.create({"name": "No SIREN", "is_company": True})
+ self.assertEqual(
+ self.Line._directory_export_siren_list(self.partner_a | no_siren),
+ [SIREN_A],
+ )
+
+ def test_export_siren_csv_has_no_header(self):
+ csv_bytes = self.Line._directory_export_siren_csv(self.partner_a)
+ self.assertEqual(csv_bytes, (SIREN_A + "\n").encode("utf-8"))
+
+ def test_export_chunks_split_on_line_limit(self):
+ """The State directory caps each deposited file at 5000 lines."""
+ sirens = [str(100000000 + i) for i in range(12000)]
+ self.patch(
+ type(self.Line),
+ "_directory_export_siren_list",
+ lambda self, partners: sirens,
+ )
+ chunks = self.Line._directory_export_siren_chunks(self.partner_a)
+ self.assertEqual(len(chunks), 3)
+ self.assertEqual(chunks[0].count(b"\n"), 5000)
+ self.assertEqual(chunks[1].count(b"\n"), 5000)
+ self.assertEqual(chunks[2].count(b"\n"), 2000)
+
+ def test_export_chunks_single_file_below_limits(self):
+ chunks = self.Line._directory_export_siren_chunks(
+ self.partner_a | self.partner_b
+ )
+ self.assertEqual(len(chunks), 1)
+ self.assertEqual(chunks[0], f"{SIREN_A}\n{SIREN_B}\n".encode("utf-8"))
+
+ # ------------------------------------------------------------------
+ # Partner matching
+ # ------------------------------------------------------------------
+ def test_match_partner_prefers_siret(self):
+ """A SIRET disambiguates companies sharing a SIREN."""
+ twin = self.Partner.create(
+ {
+ "name": "Directory Test A bis",
+ "is_company": True,
+ "company_registry": SIREN_A + "00017",
+ "country_id": self.env.ref("base.fr").id,
+ }
+ )
+ index = self.Line._directory_partner_index()
+ partner, issue = self.Line._directory_match_partner(
+ SIREN_A, "10000000900017", index
+ )
+ self.assertEqual(partner, twin)
+ self.assertFalse(issue)
+
+ def test_match_partner_reports_ambiguous_siren(self):
+ self.Partner.create(
+ {
+ "name": "Directory Test A bis",
+ "is_company": True,
+ "company_registry": SIREN_A + "00017",
+ "country_id": self.env.ref("base.fr").id,
+ }
+ )
+ index = self.Line._directory_partner_index()
+ partner, issue = self.Line._directory_match_partner(SIREN_A, None, index)
+ self.assertEqual(issue, "ambiguous")
+ self.assertTrue(partner)
+
+ def test_match_partner_unknown_siren(self):
+ index = self.Line._directory_partner_index()
+ partner, issue = self.Line._directory_match_partner(SIREN_C, None, index)
+ self.assertFalse(partner)
+ self.assertEqual(issue, "no_partner")
+
+ # ------------------------------------------------------------------
+ # Import
+ # ------------------------------------------------------------------
+ def _import(self, text):
+ return self.Line._directory_import_csv(text.encode("utf-8"))
+
+ def test_import_creates_lines(self):
+ res = self._import(
+ "SIREN;Adresse de facturation;Adresse de facturation active\n"
+ f"{SIREN_A};{SIREN_A}_{SIRET_A};1\n"
+ f"{SIREN_B};{SIREN_B};0\n"
+ )
+ self.assertEqual(res["created"], 2)
+ self.assertEqual(res["skipped"], 0)
+ line = self.Line.with_context(active_test=False).search(
+ [
+ ("partner_id", "=", self.partner_a.id),
+ ]
+ )
+ self.assertEqual(line.identifier, f"{SIREN_A}_{SIRET_A}")
+ self.assertEqual(line.state, "active")
+ self.assertEqual(line.siret, SIRET_A)
+
+ def test_import_is_idempotent(self):
+ """Upsert on (partner, identifier): the batched import is not atomic,
+ so re-running a failed file must not duplicate lines."""
+ csv_text = (
+ "SIREN;Adresse de facturation;Adresse de facturation active\n"
+ f"{SIREN_A};{SIREN_A}_{SIRET_A};1\n"
+ )
+ self._import(csv_text)
+ res = self._import(csv_text)
+ self.assertEqual(res["created"], 0)
+ self.assertEqual(res["updated"], 0)
+ self.assertEqual(
+ self.Line.with_context(active_test=False).search_count(
+ [("partner_id", "=", self.partner_a.id)]
+ ),
+ 1,
+ )
+
+ def test_import_updates_changed_state(self):
+ base = "SIREN;Adresse de facturation;Adresse de facturation active\n"
+ self._import(base + f"{SIREN_A};{SIREN_A};0\n")
+ res = self._import(base + f"{SIREN_A};{SIREN_A};1\n")
+ self.assertEqual(res["updated"], 1)
+ line = self.Line.with_context(active_test=False).search(
+ [("partner_id", "=", self.partner_a.id)]
+ )
+ self.assertEqual(line.state, "active")
+
+ def test_import_marks_partner_registered(self):
+ """Without these partner fields the directory section stays hidden and
+ BT-49 cannot resolve."""
+ self._import(
+ "SIREN;Adresse de facturation;Adresse de facturation active\n"
+ f"{SIREN_A};{SIREN_A};1\n"
+ )
+ self.partner_a.invalidate_recordset()
+ self.assertEqual(self.partner_a.fr_directory_entity_type, "private")
+ self.assertTrue(self.partner_a.fr_directory_last_sync_date)
+ self.assertEqual(self.partner_a.fr_directory_siren, SIREN_A)
+
+ def test_import_sets_single_active_line_as_default(self):
+ self._import(
+ "SIREN;Adresse de facturation;Adresse de facturation active\n"
+ f"{SIREN_A};{SIREN_A};1\n"
+ )
+ self.partner_a.invalidate_recordset()
+ self.assertEqual(
+ self.partner_a.default_fr_directory_line_id.identifier, SIREN_A
+ )
+
+ def test_import_skips_unknown_and_malformed_siren(self):
+ res = self._import(
+ "SIREN;Adresse de facturation\n"
+ f"{SIREN_C};{SIREN_C}\n" # valid but no partner
+ "12345;12345\n" # not 9 digits
+ f"{SIREN_A};{SIREN_A}\n"
+ )
+ self.assertEqual(res["created"], 1)
+ self.assertEqual(res["skipped"], 2)
+ self.assertTrue(any(SIREN_C in err for err in res["errors"]))
+
+ def test_import_detects_comma_delimiter(self):
+ res = self._import(
+ "SIREN,Adresse de facturation,Adresse de facturation active\n"
+ f"{SIREN_A},{SIREN_A},1\n"
+ )
+ self.assertEqual(res["created"], 1)
+
+ def test_import_accepts_utf8_bom(self):
+ res = self.Line._directory_import_csv(
+ (f"SIREN;Adresse de facturation\n{SIREN_A};{SIREN_A}\n").encode("utf-8-sig")
+ )
+ self.assertEqual(res["created"], 1)
+
+ def test_import_without_siren_column_raises(self):
+ with self.assertRaises(UserError):
+ self._import("Raison sociale;Adresse\nACME;whatever\n")
+
+ def test_import_empty_file_raises(self):
+ with self.assertRaises(UserError):
+ self._import("")
+
+ def test_import_reports_ambiguous_siren(self):
+ self.Partner.create(
+ {
+ "name": "Directory Test A bis",
+ "is_company": True,
+ "company_registry": SIREN_A + "00017",
+ "country_id": self.env.ref("base.fr").id,
+ }
+ )
+ res = self._import(f"SIREN;Adresse de facturation\n{SIREN_A};{SIREN_A}\n")
+ self.assertEqual(res["ambiguous"], 1)
+ # Assert on the SIREN, not on the wording: the message goes through _()
+ # and the test database may run in any language.
+ self.assertTrue(any(SIREN_A in err for err in res["errors"]))
+
+
+class TestDirectoryCsvWizard(DirectoryCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.partner = cls.env["res.partner"].create(
+ {
+ "name": "Wizard Test",
+ "is_company": True,
+ "company_registry": SIRET_A,
+ "country_id": cls.env.ref("base.fr").id,
+ }
+ )
+
+ def test_export_uses_active_ids(self):
+ wizard = (
+ self.env["fr.directory.csv.wizard"]
+ .with_context(active_ids=self.partner.ids)
+ .create({"only_missing": False})
+ )
+ wizard.action_export_siren()
+ self.assertEqual(wizard.export_filename, "directory_siren.csv")
+ self.assertTrue(wizard.export_file)
+
+ def test_export_only_missing_excludes_registered_partners(self):
+ """A partner already holding a line — even a disabled one — is
+ registered in the directory and must not be deposited again."""
+ self.env["fr.directory.line"].create(
+ {
+ "partner_id": self.partner.id,
+ "identifier": SIREN_A,
+ "type": "siren",
+ "siren": SIREN_A,
+ "state": "disabled",
+ }
+ )
+ wizard = (
+ self.env["fr.directory.csv.wizard"]
+ .with_context(active_ids=self.partner.ids)
+ .create({"only_missing": True})
+ )
+ with self.assertRaises(UserError):
+ wizard.action_export_siren()
+
+ def test_import_requires_a_file(self):
+ wizard = self.env["fr.directory.csv.wizard"].create({})
+ with self.assertRaises(UserError):
+ wizard.action_import()
diff --git a/l10n_fr_einvoicing_directory_import/wizards/__init__.py b/l10n_fr_einvoicing_directory_import/wizards/__init__.py
new file mode 100644
index 0000000..b69a70f
--- /dev/null
+++ b/l10n_fr_einvoicing_directory_import/wizards/__init__.py
@@ -0,0 +1 @@
+from . import fr_directory_csv_wizard
diff --git a/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard.py b/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard.py
new file mode 100644
index 0000000..cd83876
--- /dev/null
+++ b/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard.py
@@ -0,0 +1,123 @@
+# Copyright 2026 Sudokeys
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+import base64
+import io
+import zipfile
+
+from odoo import _, fields, models
+from odoo.exceptions import UserError
+
+
+class FrDirectoryCsvWizard(models.TransientModel):
+ _name = "fr.directory.csv.wizard"
+ _description = "Import/Export eInvoicing directory via CSV"
+
+ # Step 1: export the SIREN numbers to deposit on the directory
+ only_missing = fields.Boolean(
+ string="Only companies without a directory line",
+ default=True,
+ help="Export only companies that do not have any directory line yet "
+ "(i.e. not registered in the directory).",
+ )
+ export_file = fields.Binary(string="SIREN file", readonly=True)
+ export_filename = fields.Char(readonly=True)
+ # Step 2: import the directory return CSV
+ import_file = fields.Binary(string="Directory return CSV")
+ import_filename = fields.Char()
+ result_summary = fields.Text(readonly=True)
+ result_partner_ids = fields.Many2many(
+ "res.partner", string="Updated companies", readonly=True
+ )
+
+ def action_export_siren(self):
+ self.ensure_one()
+ partners = self.env["res.partner"].browse(
+ self.env.context.get("active_ids") or []
+ )
+ if not partners:
+ partners = self.env["res.partner"].search(
+ [("is_company", "=", True), ("parent_id", "=", False)]
+ )
+ if self.only_missing:
+ # Drop companies that already have at least one directory line
+ # (active or not): they are already registered.
+ with_lines = (
+ self.env["fr.directory.line"]
+ .with_context(active_test=False)
+ .search([("partner_id", "in", partners.ids)])
+ .partner_id
+ )
+ partners = partners - with_lines
+ Line = self.env["fr.directory.line"]
+ chunks = Line._directory_export_siren_chunks(partners)
+ if not chunks:
+ raise UserError(_("No SIREN to export for the selected companies."))
+ # A single CSV, or a ZIP of several CSV when the directory limits
+ # (5000 lines / 1 MB per file) require splitting.
+ if len(chunks) == 1:
+ data, name = chunks[0], "directory_siren.csv"
+ else:
+ zbuf = io.BytesIO()
+ with zipfile.ZipFile(zbuf, "w", zipfile.ZIP_DEFLATED) as zf:
+ for i, chunk in enumerate(chunks, start=1):
+ zf.writestr("directory_siren_%02d.csv" % i, chunk)
+ data, name = zbuf.getvalue(), "directory_siren.zip"
+ count = len(Line._directory_export_siren_list(partners))
+ self.write(
+ {
+ "export_file": base64.b64encode(data),
+ "export_filename": name,
+ "result_summary": _(
+ "%(c)s SIREN exported in %(f)s file(s) "
+ "(max 5000 lines / 1 MB each).",
+ c=count,
+ f=len(chunks),
+ ),
+ }
+ )
+ return self._reopen()
+
+ def action_import(self):
+ self.ensure_one()
+ if not self.import_file:
+ raise UserError(_("Please upload the directory return CSV first."))
+ res = self.env["fr.directory.line"]._directory_import_csv(
+ base64.b64decode(self.import_file)
+ )
+ summary = _(
+ "Import done: %(c)s created, %(u)s updated, "
+ "%(s)s skipped, %(a)s ambiguous.",
+ c=res["created"],
+ u=res["updated"],
+ s=res["skipped"],
+ a=res.get("ambiguous", 0),
+ )
+ if res["errors"]:
+ summary += "\n\n" + "\n".join(res["errors"][:50])
+ self.write(
+ {
+ "result_summary": summary,
+ "result_partner_ids": [(6, 0, res.get("partner_ids", []))],
+ }
+ )
+ return self._reopen()
+
+ def action_view_partners(self):
+ """Open the list of companies created/updated by the last import."""
+ self.ensure_one()
+ return {
+ "type": "ir.actions.act_window",
+ "name": _("Updated companies"),
+ "res_model": "res.partner",
+ "view_mode": "list,form",
+ "domain": [("id", "in", self.result_partner_ids.ids)],
+ }
+
+ def _reopen(self):
+ return {
+ "type": "ir.actions.act_window",
+ "res_model": self._name,
+ "res_id": self.id,
+ "view_mode": "form",
+ "target": "new",
+ }
diff --git a/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard_views.xml b/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard_views.xml
new file mode 100644
index 0000000..19f75a7
--- /dev/null
+++ b/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard_views.xml
@@ -0,0 +1,104 @@
+
+
+ fr.directory.csv.wizard.form
+ fr.directory.csv.wizard
+
+
+
+
+
+
+ France eInvoicing — Directory (CSV import/export)
+ fr.directory.csv.wizard
+ form
+ new
+
+
+
+
diff --git a/l10n_fr_einvoicing_import/__manifest__.py b/l10n_fr_einvoicing_import/__manifest__.py
index 871d11f..34f759f 100644
--- a/l10n_fr_einvoicing_import/__manifest__.py
+++ b/l10n_fr_einvoicing_import/__manifest__.py
@@ -13,7 +13,7 @@
{
"name": "France eInvoicing: Import Vendor Bills",
- "version": "18.0.1.0.0",
+ "version": "19.0.1.0.0",
"category": "Accounting",
"license": "AGPL-3",
"summary": "Import vendor bills/refunds from accredited platform",
@@ -24,6 +24,6 @@
"l10n_fr_einvoicing",
"account_invoice_import",
],
- "data": ["views/account_journal.xml"],
+ "data": ["views/account_journal.xml", "views/fr_directory_line.xml"],
"installable": True,
}
diff --git a/l10n_fr_einvoicing_import/i18n/fr.po b/l10n_fr_einvoicing_import/i18n/fr.po
index 5d5803c..52704b6 100644
--- a/l10n_fr_einvoicing_import/i18n/fr.po
+++ b/l10n_fr_einvoicing_import/i18n/fr.po
@@ -6,8 +6,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-07-24 21:36+0000\n"
-"PO-Revision-Date: 2026-07-24 21:36+0000\n"
+"POT-Creation-Date: 2026-08-08 22:55+0000\n"
+"PO-Revision-Date: 2026-08-08 22:55+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
@@ -16,15 +16,25 @@ msgstr ""
"Plural-Forms: \n"
#. module: l10n_fr_einvoicing_import
-#: model:ir.model.fields,field_description:l10n_fr_einvoicing_import.field_account_journal__company_partner_id
-msgid "Account Holder"
-msgstr "Titulaire de compte"
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_import.field_fr_directory_line__belongs_to_company_id
+msgid "Belongs To Company"
+msgstr "Rattaché à la société"
#. module: l10n_fr_einvoicing_import
#: model:ir.model.fields,field_description:l10n_fr_einvoicing_import.field_account_journal__fr_directory_line_ids
msgid "Company Directory Lines"
msgstr "Lignes d'annuaire de la société"
+#. module: l10n_fr_einvoicing_import
+#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_import.fr_directory_line_form
+msgid "Company Settings"
+msgstr "Paramètres de la société"
+
+#. module: l10n_fr_einvoicing_import
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_import.field_fr_directory_line__purchase_journal_id
+msgid "Force Purchase Journal"
+msgstr "Forcer le journal d'achat"
+
#. module: l10n_fr_einvoicing_import
#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_import.view_account_journal_form
msgid "France eInvoicing"
@@ -50,9 +60,23 @@ msgid "Journal"
msgstr ""
#. module: l10n_fr_einvoicing_import
-#: model:ir.model.fields,field_description:l10n_fr_einvoicing_import.field_fr_directory_line__purchase_journal_id
-msgid "Purchase Journal"
-msgstr "Journal d'achat"
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_import.field_fr_directory_line__no_vat_deduction
+msgid "No VAT Deduction"
+msgstr "Pas de déduction de TVA"
+
+#. module: l10n_fr_einvoicing_import
+#: model:ir.model.fields,help:l10n_fr_einvoicing_import.field_fr_directory_line__no_vat_deduction
+msgid ""
+"This option is designed for entities that can deduct VAT on some vendor "
+"bills but not all. If they decide to create a directory line dedicated to "
+"the vendor bills for which they cannot deduct VAT, they should enable this "
+"option on this specific directory line."
+msgstr ""
+"Cette option est conçue pour les entités qui peuvent déduire la TVA sur "
+"certaines factures fournisseur mais pas toutes. Si ces entités décident de créer "
+"une ligne d'annuaire dédiée aux factures fournisseur pour lesquelles elles "
+"ne peuvent pas déduire la TVA, elles activeront cette option "
+"sur cette ligne d'annuaire spécifique."
#. module: l10n_fr_einvoicing_import
#: model:ir.model,name:l10n_fr_einvoicing_import.model_account_invoice_import
diff --git a/l10n_fr_einvoicing_import/i18n/l10n_fr_einvoicing_import.pot b/l10n_fr_einvoicing_import/i18n/l10n_fr_einvoicing_import.pot
index 49fb90f..0da3be2 100644
--- a/l10n_fr_einvoicing_import/i18n/l10n_fr_einvoicing_import.pot
+++ b/l10n_fr_einvoicing_import/i18n/l10n_fr_einvoicing_import.pot
@@ -6,8 +6,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-06-18 15:40+0000\n"
-"PO-Revision-Date: 2026-06-18 15:40+0000\n"
+"POT-Creation-Date: 2026-08-08 22:54+0000\n"
+"PO-Revision-Date: 2026-08-08 22:54+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
@@ -16,8 +16,8 @@ msgstr ""
"Plural-Forms: \n"
#. module: l10n_fr_einvoicing_import
-#: model:ir.model.fields,field_description:l10n_fr_einvoicing_import.field_account_journal__company_partner_id
-msgid "Account Holder"
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_import.field_fr_directory_line__belongs_to_company_id
+msgid "Belongs To Company"
msgstr ""
#. module: l10n_fr_einvoicing_import
@@ -25,6 +25,16 @@ msgstr ""
msgid "Company Directory Lines"
msgstr ""
+#. module: l10n_fr_einvoicing_import
+#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_import.fr_directory_line_form
+msgid "Company Settings"
+msgstr ""
+
+#. module: l10n_fr_einvoicing_import
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_import.field_fr_directory_line__purchase_journal_id
+msgid "Force Purchase Journal"
+msgstr ""
+
#. module: l10n_fr_einvoicing_import
#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_import.view_account_journal_form
msgid "France eInvoicing"
@@ -48,8 +58,17 @@ msgid "Journal"
msgstr ""
#. module: l10n_fr_einvoicing_import
-#: model:ir.model.fields,field_description:l10n_fr_einvoicing_import.field_fr_directory_line__purchase_journal_id
-msgid "Purchase Journal"
+#: model:ir.model.fields,field_description:l10n_fr_einvoicing_import.field_fr_directory_line__no_vat_deduction
+msgid "No VAT Deduction"
+msgstr ""
+
+#. module: l10n_fr_einvoicing_import
+#: model:ir.model.fields,help:l10n_fr_einvoicing_import.field_fr_directory_line__no_vat_deduction
+msgid ""
+"This option is designed for entities that can deduct VAT on some vendor "
+"bills but not all. If they decide to create a directory line dedicated to "
+"the vendor bills for which they cannot deduct VAT, they should enable this "
+"option on this specific directory line."
msgstr ""
#. module: l10n_fr_einvoicing_import
diff --git a/l10n_fr_einvoicing_import/models/account_journal.py b/l10n_fr_einvoicing_import/models/account_journal.py
index 98677c6..3d72809 100644
--- a/l10n_fr_einvoicing_import/models/account_journal.py
+++ b/l10n_fr_einvoicing_import/models/account_journal.py
@@ -8,7 +8,6 @@
class AccountJournal(models.Model):
_inherit = "account.journal"
- company_partner_id = fields.Many2one(related="company_id.partner_id")
fr_directory_line_ids = fields.One2many(
"fr.directory.line",
"purchase_journal_id",
diff --git a/l10n_fr_einvoicing_import/models/fr_directory_line.py b/l10n_fr_einvoicing_import/models/fr_directory_line.py
index ad94d92..dabdd3e 100644
--- a/l10n_fr_einvoicing_import/models/fr_directory_line.py
+++ b/l10n_fr_einvoicing_import/models/fr_directory_line.py
@@ -2,12 +2,47 @@
# @author: Alexis de Lattre
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
-from odoo import fields, models
+from odoo import api, fields, models
class FrDirectoryLine(models.Model):
_inherit = "fr.directory.line"
purchase_journal_id = fields.Many2one(
- "account.journal", string="Purchase Journal", copy=False, readonly=True
+ "account.journal",
+ string="Force Purchase Journal",
+ copy=False,
+ domain="[('company_id', '=', belongs_to_company_id), "
+ "('type', '=', 'purchase')]",
)
+ # Field "no_vat_deduction" has been designed for
+ # "Association avec secteur lucratif":
+ # to speedy-up the processing of their vendor bills, they can create 2 dir lines:
+ # - one dedicated to the "secteur lucratif" where they can deduct VAT,
+ # - another one dedicated to the "secteur non lucratif" on which they would enable
+ # the option 'no_vat_deduction'.
+ no_vat_deduction = fields.Boolean(
+ string="No VAT Deduction",
+ copy=False,
+ help="This option is designed for entities that can deduct VAT on some "
+ "vendor bills but not all. If they decide to create a directory line "
+ "dedicated to the vendor bills for which they cannot deduct VAT, "
+ "they should enable this option on this specific directory line.",
+ )
+ # in the module l10n_fr_einvoicing, there is a field 'company_id'
+ # which is a related of partner_id.company_id, used by the ir.rule
+ # Here, we need a field which has a value only if the directory line
+ # is attached to the partner of a company. The field name is not very good,
+ # but I couldn't find a better one, sorry!
+ belongs_to_company_id = fields.Many2one(
+ "res.company", compute="_compute_belongs_to_company_id", store=True
+ )
+
+ @api.depends("partner_id")
+ def _compute_belongs_to_company_id(self):
+ company_sr = self.env["res.company"].search_read(
+ [("partner_id", "!=", False)], ["partner_id"]
+ )
+ partner_id2company_id = {c["partner_id"][0]: c["id"] for c in company_sr}
+ for line in self:
+ line.belongs_to_company_id = partner_id2company_id.get(line.partner_id.id)
diff --git a/l10n_fr_einvoicing_import/views/account_journal.xml b/l10n_fr_einvoicing_import/views/account_journal.xml
index c539f46..41325d2 100644
--- a/l10n_fr_einvoicing_import/views/account_journal.xml
+++ b/l10n_fr_einvoicing_import/views/account_journal.xml
@@ -9,14 +9,14 @@
account.journal
-
+
+
-
diff --git a/l10n_fr_einvoicing_import/views/fr_directory_line.xml b/l10n_fr_einvoicing_import/views/fr_directory_line.xml
new file mode 100644
index 0000000..5e21ff7
--- /dev/null
+++ b/l10n_fr_einvoicing_import/views/fr_directory_line.xml
@@ -0,0 +1,25 @@
+
+
+
+
+ fr.directory.line
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/l10n_fr_einvoicing_import/wizards/account_invoice_import.py b/l10n_fr_einvoicing_import/wizards/account_invoice_import.py
index 96f4628..c58f2fc 100644
--- a/l10n_fr_einvoicing_import/wizards/account_invoice_import.py
+++ b/l10n_fr_einvoicing_import/wizards/account_invoice_import.py
@@ -14,11 +14,7 @@ class AccountInvoiceImport(models.TransientModel):
@api.model
def _prepare_create_invoice_vals(self, parsed_inv, import_config):
- vals = super()._prepare_create_invoice_vals(parsed_inv, import_config)
- if parsed_inv.get("partner", {}).get("einvoice_address"):
- vals["fr_directory_line_identifier"] = parsed_inv["partner"][
- "einvoice_address"
- ]
+ company_fr_dir_line = False
if parsed_inv.get("company", {}).get("einvoice_address"):
company_fr_dir_line_ident = parsed_inv["company"]["einvoice_address"]
company = import_config["company"]
@@ -29,18 +25,27 @@ def _prepare_create_invoice_vals(self, parsed_inv, import_config):
],
limit=1,
)
- if company_fr_dir_line:
- vals["company_fr_directory_line_id"] = company_fr_dir_line.id
- if company_fr_dir_line.state != "active":
- logger.warning(
- f"Company directory line state is {company_fr_dir_line.state} "
- "(should be active)"
- )
- if company_fr_dir_line.purchase_journal_id:
- logger.info(
- "Import import forced to journal %s because the destination "
- "einvoice address is configured on it.",
- company_fr_dir_line.purchase_journal_id.display_name,
- )
- vals["journal_id"] = company_fr_dir_line.purchase_journal_id.id
+ if company_fr_dir_line and company_fr_dir_line.no_vat_deduction:
+ self._pre_process_parsed_inv_taxes(
+ parsed_inv, company, force_no_vat_deduction=True
+ )
+ vals = super()._prepare_create_invoice_vals(parsed_inv, import_config)
+ if parsed_inv.get("partner", {}).get("einvoice_address"):
+ vals["fr_directory_line_identifier"] = parsed_inv["partner"][
+ "einvoice_address"
+ ]
+ if company_fr_dir_line:
+ vals["company_fr_directory_line_id"] = company_fr_dir_line.id
+ if company_fr_dir_line.state != "active":
+ logger.warning(
+ f"Company directory line state is {company_fr_dir_line.state} "
+ "(should be active)"
+ )
+ if company_fr_dir_line.purchase_journal_id:
+ logger.info(
+ "Import import forced to journal %s because the destination "
+ "einvoice address is configured on it.",
+ company_fr_dir_line.purchase_journal_id.display_name,
+ )
+ vals["journal_id"] = company_fr_dir_line.purchase_journal_id.id
return vals
diff --git a/l10n_fr_einvoicing_payment_batch_oca/__manifest__.py b/l10n_fr_einvoicing_payment_batch_oca/__manifest__.py
index b4cf196..9df43c2 100644
--- a/l10n_fr_einvoicing_payment_batch_oca/__manifest__.py
+++ b/l10n_fr_einvoicing_payment_batch_oca/__manifest__.py
@@ -4,7 +4,7 @@
{
"name": "France eInvoicing: Account Payment Batch OCA",
- "version": "18.0.1.0.0",
+ "version": "19.0.1.0.0",
"category": "Accounting",
"license": "AGPL-3",
"summary": "Option to auto-send payment sent event",
diff --git a/l10n_fr_einvoicing_purchase/__manifest__.py b/l10n_fr_einvoicing_purchase/__manifest__.py
index dc360ce..ecb7610 100644
--- a/l10n_fr_einvoicing_purchase/__manifest__.py
+++ b/l10n_fr_einvoicing_purchase/__manifest__.py
@@ -4,7 +4,7 @@
{
"name": "France eInvoicing Purchase",
- "version": "18.0.1.0.0",
+ "version": "19.0.1.0.0",
"category": "Inventory/Purchase",
"license": "AGPL-3",
"summary": "Display directory line on purchase order report",
diff --git a/l10n_fr_einvoicing_sale/__manifest__.py b/l10n_fr_einvoicing_sale/__manifest__.py
index f1a9c7e..59c96f7 100644
--- a/l10n_fr_einvoicing_sale/__manifest__.py
+++ b/l10n_fr_einvoicing_sale/__manifest__.py
@@ -4,7 +4,7 @@
{
"name": "France eInvoicing Sale",
- "version": "18.0.1.0.0",
+ "version": "19.0.1.0.0",
"category": "Sales",
"license": "AGPL-3",
"summary": "eInvoicing for France in Sales",
diff --git a/l10n_fr_einvoicing_sale/models/res_company.py b/l10n_fr_einvoicing_sale/models/res_company.py
index 4ef6732..8fed1a8 100644
--- a/l10n_fr_einvoicing_sale/models/res_company.py
+++ b/l10n_fr_einvoicing_sale/models/res_company.py
@@ -22,11 +22,8 @@ class ResCompany(models.Model):
default=30,
)
- _sql_constraints = [
- (
- "fr_ctc_directory_sync_on_sale_order_confirm_days_positive",
- "CHECK(fr_ctc_directory_sync_on_sale_order_confirm_days >= 0)",
- "The number of days for directory sync on sale order confirmation "
- "must be positive.",
- )
- ]
+ _fr_ctc_directory_sync_on_sale_order_confirm_days_positive = models.Constraint(
+ "CHECK(fr_ctc_directory_sync_on_sale_order_confirm_days >= 0)",
+ "The number of days for directory sync on sale order confirmation "
+ "must be positive.",
+ )
diff --git a/l10n_fr_einvoicing_sale/models/sale_order.py b/l10n_fr_einvoicing_sale/models/sale_order.py
index a5a55e9..a451795 100644
--- a/l10n_fr_einvoicing_sale/models/sale_order.py
+++ b/l10n_fr_einvoicing_sale/models/sale_order.py
@@ -25,6 +25,12 @@ class SaleOrder(models.Model):
related="partner_invoice_id.commercial_partner_id.fr_directory_entity_type",
string="Invoicing Partner Directory Entity Type",
)
+ # sale_commercial_partner dropped commercial_partner_invoice_id in 19.0, but
+ # the directory line domain still needs the invoicing partner's entity.
+ fr_directory_partner_invoice_id = fields.Many2one(
+ related="partner_invoice_id.commercial_partner_id",
+ string="Invoicing Partner Entity",
+ )
fr_directory_line_id = fields.Many2one(
"fr.directory.line",
compute="_compute_fr_directory_line_id",
@@ -34,7 +40,7 @@ class SaleOrder(models.Model):
tracking=True,
string="Directory Line",
ondelete="restrict",
- domain="[('partner_id', '=', commercial_partner_invoice_id), "
+ domain="[('partner_id', '=', fr_directory_partner_invoice_id), "
"('state', '=', 'active')]",
)
@@ -131,9 +137,9 @@ def _get_invoice_grouping_keys(self):
return group_keys
def _action_confirm(self):
- self.ensure_one()
for order in self:
- order._fr_ctc_confirm_checks()
+ if order.company_id._fr_ctc_is_vat_registered(raise_if_misconfigured=True):
+ order._fr_ctc_confirm_checks()
return super()._action_confirm()
def _fr_ctc_confirm_checks(self):
@@ -141,15 +147,7 @@ def _fr_ctc_confirm_checks(self):
cinvpartner = self.partner_invoice_id.commercial_partner_id
company = self.company_id
dir_sync_done = False # just to avoid double message in chatter
- if (
- (
- not cinvpartner.fr_directory_entity_type
- or cinvpartner.fr_directory_entity_type == "private_inactive"
- )
- and cinvpartner.is_company
- and cinvpartner.is_france_country
- and cinvpartner._get_siren()
- ):
+ if cinvpartner._fr_directory_should_sync_upon_confirmation():
try:
cinvpartner._fr_directory_sync_logs(company, self.name)
self._compute_fr_directory_line_id()
diff --git a/l10n_fr_einvoicing_sale/views/sale_order.xml b/l10n_fr_einvoicing_sale/views/sale_order.xml
index 074105a..f669454 100644
--- a/l10n_fr_einvoicing_sale/views/sale_order.xml
+++ b/l10n_fr_einvoicing_sale/views/sale_order.xml
@@ -7,15 +7,18 @@
sale.order
-
+
+
+
diff --git a/requirements.txt b/requirements.txt
index 021838a..d093625 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,3 @@
# generated from manifests external_dependencies
-factur-x>=6.5
-pyfrctc>=0.14
+factur-x>=6.7
+pyfrctc>=0.15