From 6ffcda567700b195740f67f1bda87890642776e8 Mon Sep 17 00:00:00 2001 From: "Paolo Gatti (pgi)" Date: Fri, 14 Jul 2023 13:36:05 +0200 Subject: [PATCH] [IMP] l10n_it_edi: remove account_edi from depends The account_edi framework is no more a dependancy. - Models account.edi.document and account.edi.format have been removed. All the functions have been moved to the new account.move.send, account.move, account.edi.proxy.client.user - The move now holds the l10n_it_edi_state which admits all values that the SdI sends us as a status update - The sending is now not handled by the cron but by the Send and Print dialog. - Vendor bills Tax Integration sending by dedicated button - The move form will now hold the state of the EDI transaction, its number and the EDI attachment as a Binary field on the move itself. - A new banner will keep the latest message from the SdI, that's not the account_edi old banner. - All the user messages have been simplified. IAP-apps PR: odoo/iap-apps#652 Upgrade PR: odoo/upgrade#4893 Task link: https://www.odoo.com/web#id=3339971&cids=1&model=project.task Task-3339971 Part-of: odoo/odoo#122194 --- addons/account/models/ir_attachment.py | 1 + addons/l10n_it_edi/__manifest__.py | 7 +- addons/l10n_it_edi/data/account_edi_data.xml | 11 - .../l10n_it_edi/data/invoice_it_template.xml | 2 +- addons/l10n_it_edi/data/ir_cron.xml | 8 +- addons/l10n_it_edi/i18n/it.po | 750 ++++++--- addons/l10n_it_edi/i18n/l10n_it_edi.pot | 507 ++++-- addons/l10n_it_edi/models/__init__.py | 5 +- .../models/account_edi_document.py | 13 - .../l10n_it_edi/models/account_edi_format.py | 1086 ------------ .../models/account_edi_proxy_user.py | 15 +- addons/l10n_it_edi/models/account_invoice.py | 458 ----- addons/l10n_it_edi/models/account_move.py | 1479 +++++++++++++++++ addons/l10n_it_edi/models/account_tax.py | 88 + addons/l10n_it_edi/models/ir_attachment.py | 61 +- addons/l10n_it_edi/models/res_company.py | 11 +- .../l10n_it_edi/models/res_config_settings.py | 38 +- addons/l10n_it_edi/models/res_partner.py | 23 +- addons/l10n_it_edi/tests/common.py | 27 +- .../tests/export_xmls/bill_reverse_charge.xml | 5 - .../export_xmls/bill_reverse_charge_2.xml | 5 - .../bill_reverse_charge_san_marino.xml | 5 - .../credit_note_negative_price.xml | 5 - .../credit_note_reverse_charge.xml | 5 - .../invoice_below_400_codice_simplified.xml | 5 - .../export_xmls/invoice_fully_discounted.xml | 5 - .../export_xmls/invoice_negative_price.xml | 5 - .../invoice_non_latin_and_latin.xml | 7 +- .../invoice_partially_discounted.xml | 5 - .../invoice_price_included_taxes.xml | 5 - .../export_xmls/invoice_reverse_charge.xml | 5 - .../invoice_total_400_VAT_simplified.xml | 5 - .../invoice_zero_percent_taxes.xml | 5 - addons/l10n_it_edi/tests/test_edi_export.py | 17 +- addons/l10n_it_edi/tests/test_edi_import.py | 45 +- addons/l10n_it_edi/tools/remove_signature.py | 2 +- addons/l10n_it_edi/views/l10n_it_view.xml | 104 +- .../views/res_config_settings_views.xml | 8 +- addons/l10n_it_edi/wizard/__init__.py | 2 + .../l10n_it_edi/wizard/account_move_send.py | 102 +- .../wizard/account_move_send_views.xml | 27 + addons/l10n_it_edi_pa/tests/test_edi_pa.py | 3 - addons/l10n_it_edi_withholding/__init__.py | 8 +- .../data/invoice_it_template.xml | 2 +- .../models/__init__.py | 1 - .../models/account_edi_format.py | 130 -- .../models/account_move.py | 134 +- .../export_xmls/enasarco_tax_invoice.xml | 5 - .../export_xmls/pension_fund_tax_invoice.xml | 5 - .../export_xmls/withholding_tax_invoice.xml | 5 - .../tests/test_withholding.py | 1 - addons/l10n_it_stock_ddt/models/__init__.py | 1 - .../models/account_edi_format.py | 30 - .../models/account_invoice.py | 26 +- .../tests/expected_xmls/deferred_invoice.xml | 1 - addons/l10n_it_stock_ddt/tests/test_edi.py | 7 +- 56 files changed, 2866 insertions(+), 2462 deletions(-) delete mode 100644 addons/l10n_it_edi/data/account_edi_data.xml delete mode 100644 addons/l10n_it_edi/models/account_edi_document.py delete mode 100644 addons/l10n_it_edi/models/account_edi_format.py delete mode 100644 addons/l10n_it_edi/models/account_invoice.py create mode 100644 addons/l10n_it_edi/models/account_move.py create mode 100644 addons/l10n_it_edi/models/account_tax.py create mode 100644 addons/l10n_it_edi/wizard/account_move_send_views.xml delete mode 100644 addons/l10n_it_edi_withholding/models/account_edi_format.py delete mode 100644 addons/l10n_it_stock_ddt/models/account_edi_format.py diff --git a/addons/account/models/ir_attachment.py b/addons/account/models/ir_attachment.py index f8d30912af9..b0701fce0ff 100644 --- a/addons/account/models/ir_attachment.py +++ b/addons/account/models/ir_attachment.py @@ -96,6 +96,7 @@ class IrAttachment(models.Model): This function is meant to be overriden to add formats. :returns: A list of dictionary. + * format: Optional but helps debugging. There are other methods that require the attachment to be an XML other than the standard one. diff --git a/addons/l10n_it_edi/__manifest__.py b/addons/l10n_it_edi/__manifest__.py index e592961bf30..efc3de517e7 100644 --- a/addons/l10n_it_edi/__manifest__.py +++ b/addons/l10n_it_edi/__manifest__.py @@ -7,9 +7,6 @@ 'version': '0.3', 'depends': [ 'l10n_it', - # Although account_edi is a dependency of account_edi_proxy_client, - # it is here because it's in the auto-install - 'account_edi', 'account_edi_proxy_client', ], 'auto_install': ['l10n_it'], @@ -20,13 +17,13 @@ E-invoice implementation 'website': 'http://www.odoo.com/', 'data': [ 'security/ir.model.access.csv', - 'data/account_edi_data.xml', 'data/invoice_it_template.xml', 'data/invoice_it_simplified_template.xml', 'data/ir_cron.xml', 'views/res_config_settings_views.xml', 'views/l10n_it_view.xml', - ], + 'wizard/account_move_send_views.xml', + ], 'demo': [ 'data/account_invoice_demo.xml', ], diff --git a/addons/l10n_it_edi/data/account_edi_data.xml b/addons/l10n_it_edi/data/account_edi_data.xml deleted file mode 100644 index fb454fffbd1..00000000000 --- a/addons/l10n_it_edi/data/account_edi_data.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - Fattura PA (IT) - fattura_pa - - - - \ No newline at end of file diff --git a/addons/l10n_it_edi/data/invoice_it_template.xml b/addons/l10n_it_edi/data/invoice_it_template.xml index e67d3a8e591..9c4def17fae 100644 --- a/addons/l10n_it_edi/data/invoice_it_template.xml +++ b/addons/l10n_it_edi/data/invoice_it_template.xml @@ -19,7 +19,7 @@ t-out="format_alphanumeric(line.product_uom_id.name, 10)"/> - + diff --git a/addons/l10n_it_edi/data/ir_cron.xml b/addons/l10n_it_edi/data/ir_cron.xml index 0515ecef3e1..aa5830164bb 100644 --- a/addons/l10n_it_edi/data/ir_cron.xml +++ b/addons/l10n_it_edi/data/ir_cron.xml @@ -1,12 +1,12 @@ - - FatturaPA: Receive invoices from the exchange system + + IT EDI: Receive invoices from the SdI 1 days -1 - - model._cron_receive_fattura_pa() + + model.cron_l10n_it_edi_download_and_update() code diff --git a/addons/l10n_it_edi/i18n/it.po b/addons/l10n_it_edi/i18n/it.po index 4447f2f487d..34ca72c9ed2 100644 --- a/addons/l10n_it_edi/i18n/it.po +++ b/addons/l10n_it_edi/i18n/it.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 16.4alpha1+e\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-05-31 09:04+0000\n" -"PO-Revision-Date: 2023-05-31 09:04+0000\n" +"POT-Creation-Date: 2023-06-09 15:24+0000\n" +"PO-Revision-Date: 2023-06-09 15:24+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: it\n" @@ -18,84 +18,109 @@ msgstr "" #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "%s has an amount of 0.0, you must indicate the kind of exoneration." msgstr "%s ha valore di 0.0, devi indicare il tipo di esenzione." #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "%s must have a VAT number" msgstr "%s deve avere una Partita IVA" #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "%s must have a city." msgstr "%s deve avere una Cittá." #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "%s must have a codice fiscale number" msgstr "%s deve avere un Codice Fiscale" #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "%s must have a country" msgstr "%s deve avere una Nazione" #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "%s must have a country." msgstr "%s deve avere una Nazione." #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "%s must have a post code of length 5." msgstr "%s deve avere un CAP di lunghezza 5." #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "%s must have a post code." msgstr "%s deve avere un CAP." #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "%s must have a street." msgstr "%s deve avere un Indirizzo." #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_invoice.py:0 +#: code:addons/l10n_it_edi/models/account_tax.py:0 #, python-format msgid "" "'Scissione dei pagamenti' is not compatible with exoneration of kind 'N6'" msgstr "" "'Scissione dei pagamenti' non è compatibile con l'esenzione di tipo 'N6'" +#. module: l10n_it_edi +#: model_terms:ir.ui.view,arch_db:l10n_it_edi.account_invoice_form_l10n_it +msgid "" +"E-invoicing (Italy)" +msgstr "" +"Fatturazione Elettronica (Italia)" + +#. module: l10n_it_edi +#: model_terms:ir.ui.view,arch_db:l10n_it_edi.account_move_send_inherit_l10n_it_edi +msgid "" +"\n" -" In test mode (experimental) Odoo will send " -"the invoices to a non-production service.\n" -" Saving this change will direct all companies " -"on this database to this use this configuration.\n" -" Once registered for testing or official, the " -"mode cannot be changed." +"In demo mode Odoo will just simulate the sending of invoices to the government.
\n" +" In test mode (experimental) Odoo will send the invoices to a non-production service.\n" +" Saving this change will direct all companies on this database to this use this configuration.\n" +" Once registered for testing or official, the mode cannot be changed." msgstr "" "In modalità Demo, Odoo simula l'invio delle fatture all'Agenzia delle " -"Entrate. In modalità Test (sperimentale) Odoo invia le fatture a un servizio " -"non ufficiale. Una volta salvata l'impostazione, tutte le aziende nel " -"database utilizzano questa configurazione. Una volta registrata, la modalità " -"non può essere cambiata." +"Entrate. In modalità Test (sperimentale) Odoo invia le fatture a un servizio" +" non ufficiale. Una volta salvata l'impostazione, tutte le aziende nel " +"database utilizzano questa configurazione. Una volta registrata, la modalità" +" non può essere cambiata." #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "In line %s, you must select one and only one tax." msgstr "La linea %s deve avere una e solo una imposta.." @@ -465,18 +497,6 @@ msgstr "" msgid "Invoice Reference" msgstr "Riferimento Fattura" -#. module: l10n_it_edi -#. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 -#, python-format -msgid "" -"Invoices for PA are not managed by Odoo, you can download the document and " -"send it on your own." -msgstr "" -"Le Fatture per la Pubblica Amministrazione non sono gestite da Odoo. É " -"possibile scaricare il documento e inviarlo tramite il sito dell'Agenzia " -"delle Entrate." - #. module: l10n_it_edi #: model:ir.model.fields,field_description:l10n_it_edi.field_res_config_settings__is_edi_proxy_active msgid "Is Edi Proxy Active" @@ -485,20 +505,43 @@ msgstr "É attivo il Proxy EDI" #. module: l10n_it_edi #: model:ir.model.fields.selection,name:l10n_it_edi.selection__account_edi_proxy_client_user__proxy_type__l10n_it_edi msgid "Italian EDI" -msgstr "" - -#. module: l10n_it_edi -#. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 -#: code:addons/l10n_it_edi/models/account_invoice.py:0 -#, python-format -msgid "Italian invoice: %s" -msgstr "Fattura italiana: %s" +msgstr "Fatturazione Elettronica (Italia)" #. module: l10n_it_edi #: model:ir.model,name:l10n_it_edi.model_account_move msgid "Journal Entry" -msgstr "Movimento Contabile" +msgstr "Registrazione contabile" + +#. module: l10n_it_edi +#: model:ir.model.fields,field_description:l10n_it_edi.field_account_bank_statement_line__l10n_it_edi_attachment_file +#: model:ir.model.fields,field_description:l10n_it_edi.field_account_move__l10n_it_edi_attachment_file +#: model:ir.model.fields,field_description:l10n_it_edi.field_account_payment__l10n_it_edi_attachment_file +msgid "L10N It Edi Attachment File" +msgstr "" + +#. module: l10n_it_edi +#: model:ir.model.fields,field_description:l10n_it_edi.field_res_config_settings__l10n_it_edi_demo_mode +msgid "L10N It Edi Demo Mode" +msgstr "Modalitá demo L10n It EDI" + +#. module: l10n_it_edi +#: model:ir.model.fields,field_description:l10n_it_edi.field_account_move_send__l10n_it_edi_enable_send +msgid "L10N It Edi Enable Send" +msgstr "Abilita invio alla Agenzia delle Entrate (Italia)" + +#. module: l10n_it_edi +#: model:ir.model.fields,field_description:l10n_it_edi.field_account_bank_statement_line__l10n_it_edi_header +#: model:ir.model.fields,field_description:l10n_it_edi.field_account_move__l10n_it_edi_header +#: model:ir.model.fields,field_description:l10n_it_edi.field_account_payment__l10n_it_edi_header +msgid "L10N It Edi Header" +msgstr "" + +#. module: l10n_it_edi +#: model:ir.model.fields,field_description:l10n_it_edi.field_account_bank_statement_line__l10n_it_edi_is_self_invoice +#: model:ir.model.fields,field_description:l10n_it_edi.field_account_move__l10n_it_edi_is_self_invoice +#: model:ir.model.fields,field_description:l10n_it_edi.field_account_payment__l10n_it_edi_is_self_invoice +msgid "L10N It Edi Is Self Invoice" +msgstr "Autofattura" #. module: l10n_it_edi #: model:ir.model.fields,field_description:l10n_it_edi.field_res_config_settings__l10n_it_edi_proxy_current_state @@ -506,21 +549,31 @@ msgid "L10N It Edi Proxy Current State" msgstr "Stato corrente del Proxy L10n It EDI" #. module: l10n_it_edi -#: model:ir.model.fields,field_description:l10n_it_edi.field_res_config_settings__l10n_it_edi_sdicoop_demo_mode -msgid "L10N It Edi Sdicoop Demo Mode" -msgstr "Modalitá demo L10n It EDI Sdicoop" +#: model:ir.model.fields,field_description:l10n_it_edi.field_res_company__l10n_it_edi_proxy_user_id +msgid "L10N It Edi Proxy User" +msgstr "Utente Proxy EdI (Italia)" #. module: l10n_it_edi -#: model:ir.model.fields,field_description:l10n_it_edi.field_res_config_settings__l10n_it_edi_sdicoop_register -msgid "L10N It Edi Sdicoop Register" +#: model:ir.model.fields,field_description:l10n_it_edi.field_account_move_send__l10n_it_edi_readonly +msgid "L10N It Edi Readonly" +msgstr "Invio SdI (Italia) Readonly" + +#. module: l10n_it_edi +#: model:ir.model.fields,field_description:l10n_it_edi.field_res_config_settings__l10n_it_edi_register +msgid "L10N It Edi Register" msgstr "Registra L10n It EDI Sdicoop" #. module: l10n_it_edi -#: model:ir.model.fields,field_description:l10n_it_edi.field_account_bank_statement_line__l10n_it_einvoice_name -#: model:ir.model.fields,field_description:l10n_it_edi.field_account_move__l10n_it_einvoice_name -#: model:ir.model.fields,field_description:l10n_it_edi.field_account_payment__l10n_it_einvoice_name -msgid "L10N It Einvoice Name" -msgstr "Nome Fattura Elettronica" +#: model:ir.model.fields,field_description:l10n_it_edi.field_account_bank_statement_line__l10n_it_edi_state +#: model:ir.model.fields,field_description:l10n_it_edi.field_account_move__l10n_it_edi_state +#: model:ir.model.fields,field_description:l10n_it_edi.field_account_payment__l10n_it_edi_state +msgid "L10N It Edi State" +msgstr "Stato Invio EDI (Italia)" + +#. module: l10n_it_edi +#: model:ir.model.fields,field_description:l10n_it_edi.field_account_move_send__l10n_it_edi_warning_message +msgid "L10N It Edi Warning Message" +msgstr "Avviso EDI (Italia)" #. module: l10n_it_edi #: model:ir.model.fields,field_description:l10n_it_edi.field_res_company__l10n_it_has_eco_index @@ -561,8 +614,8 @@ msgstr "" #: model:ir.model.fields,help:l10n_it_edi.field_res_company__l10n_it_eco_index_share_capital msgid "" "Mandatory if the seller/provider is a company with share capital " -"(SpA, SApA, Srl), this field must contain the amount of share capital " -"actually paid up as resulting from the last financial statement" +"(SpA, SApA, Srl), this field must contain the amount of share capital" +" actually paid up as resulting from the last financial statement" msgstr "" "Obbligatorio se il venditore/fornitore è una azienda con capitale sociale " "(SpA, SApA, Srl), rappresenta l'ammontare del capitale sociale attuale come " @@ -572,11 +625,10 @@ msgstr "" #: model:ir.model.fields,help:l10n_it_edi.field_res_partner__l10n_it_pa_index #: model:ir.model.fields,help:l10n_it_edi.field_res_users__l10n_it_pa_index msgid "" -"Must contain the 6-character (or 7) code, present in the PA " -"Index in the information relative to the electronic invoicing " -"service, associated with the office which, within the addressee " -"administration, deals with receiving (and processing) the " -"invoice." +"Must contain the 6-character (or 7) code, present in the PA Index in the " +"information relative to the electronic invoicing service, associated with " +"the office which, within the addressee administration, deals with receiving " +"(and processing) the invoice." msgstr "" "Deve contenere il codice da 6-7 caratteri presente nell'indice della PA " "nelle informazioni relative al servizio di Fatturazione Elettronica, " @@ -596,10 +648,10 @@ msgstr "Numero nel Registro delle Imprese" #. module: l10n_it_edi #: model:ir.model.fields,field_description:l10n_it_edi.field_l10n_it_ddt__name msgid "Numero DDT" -msgstr "Numero DDT" +msgstr "" #. module: l10n_it_edi -#: model:ir.model.fields.selection,name:l10n_it_edi.selection__res_config_settings__l10n_it_edi_sdicoop_demo_mode__prod +#: model:ir.model.fields.selection,name:l10n_it_edi.selection__res_config_settings__l10n_it_edi_demo_mode__prod msgid "Official" msgstr "Ufficiale" @@ -607,7 +659,7 @@ msgstr "Ufficiale" #: model_terms:ir.ui.view,arch_db:l10n_it_edi.account_invoice_it_FatturaPA_export #: model_terms:ir.ui.view,arch_db:l10n_it_edi.account_invoice_it_simplified_FatturaPA_export msgid "PDF" -msgstr "PDF" +msgstr "" #. module: l10n_it_edi #: model:ir.model.fields,field_description:l10n_it_edi.field_res_partner__l10n_it_pec_email @@ -617,10 +669,10 @@ msgstr "Email PEC" #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format -msgid "Payment due date invalid in XML file:" -msgstr "Data Pagamento non valida nel file XML:" +msgid "Payment due date invalid in XML file: %s" +msgstr "Data di pagamento non valida nel file XML: %s" #. module: l10n_it_edi #: model:ir.model.fields.selection,name:l10n_it_edi.selection__res_company__l10n_it_eco_index_sole_shareholder__sm @@ -629,13 +681,13 @@ msgstr "" #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_edi_proxy_user.py:0 #, python-format msgid "" -"Please fill your codice fiscale to be able to receive invoices from FatturaPA" -msgstr "" -"Per favore, inserire il Codice Fiscale per poter ricevere le fatture da " +"Please fill your codice fiscale to be able to receive invoices from " "FatturaPA" +msgstr "" +"Per favore, inserire il Codice Fiscale per poter ricevere le fatture dal SdI" #. module: l10n_it_edi #: model:ir.model.fields,help:l10n_it_edi.field_res_company__l10n_it_tax_system @@ -650,16 +702,88 @@ msgstr "Provincia dell'ufficio del Registro delle Imprese" #. module: l10n_it_edi #: model:ir.model.fields,field_description:l10n_it_edi.field_account_edi_proxy_client_user__proxy_type msgid "Proxy type" -msgstr "" +msgstr "Tipo Proxy" + +#. module: l10n_it_edi +#: model:ir.model.fields.selection,name:l10n_it_edi.selection__account_move__l10n_it_edi_state__requires_user_signature +msgid "Requires user signature" +msgstr "Richiede Firma Utente" #. module: l10n_it_edi #: model_terms:ir.ui.view,arch_db:l10n_it_edi.account_invoice_it_FatturaPA_export msgid "SI" -msgstr "SI" +msgstr "" + +#. module: l10n_it_edi +#: model:ir.model.fields.selection,name:l10n_it_edi.selection__account_move__l10n_it_edi_state__accepted_by_pa_partner +msgid "SdI Accepted, Accepted by the PA Partner" +msgstr "Accettato SdI, Accettato dal Partner PA" + +#. module: l10n_it_edi +#: model:ir.model.fields.selection,name:l10n_it_edi.selection__account_move__l10n_it_edi_state__forward_failed +msgid "SdI Accepted, Forward to Partner Failed" +msgstr "Accettato SdI, Inoltro al Partner Fallito" + +#. module: l10n_it_edi +#: model:ir.model.fields.selection,name:l10n_it_edi.selection__account_move__l10n_it_edi_state__forwarded +msgid "SdI Accepted, Forwarded to Partner" +msgstr "Accettato SdI, Inoltrato al Partner" + +#. module: l10n_it_edi +#: model:ir.model.fields.selection,name:l10n_it_edi.selection__account_move__l10n_it_edi_state__forward_attempt +msgid "SdI Accepted, Forwarding to Partner" +msgstr "Accettato SdI, Inoltro in Corso" + +#. module: l10n_it_edi +#: model:ir.model.fields.selection,name:l10n_it_edi.selection__account_move__l10n_it_edi_state__accepted_by_pa_partner_after_expiry +msgid "SdI Accepted, PA Partner Expired Terms" +msgstr "Accettato SdI, Decadimento Termini Partner PA" + +#. module: l10n_it_edi +#: model:ir.model.fields.selection,name:l10n_it_edi.selection__account_move__l10n_it_edi_state__rejected_by_pa_partner +msgid "SdI Accepted, Rejected by the PA Partner" +msgstr "Accettato SdI, Rifiutato dal Partner PA" + +#. module: l10n_it_edi +#: model:ir.model.fields.selection,name:l10n_it_edi.selection__account_move__l10n_it_edi_state__processing +msgid "SdI Processing" +msgstr "Elaborazione SdI" + +#. module: l10n_it_edi +#: model:ir.model.fields.selection,name:l10n_it_edi.selection__account_move__l10n_it_edi_state__rejected +msgid "SdI Rejected" +msgstr "Rifiutato SdI" + +#. module: l10n_it_edi +#: model_terms:ir.ui.view,arch_db:l10n_it_edi.account_invoice_form_l10n_it +msgid "Send Tax Integration" +msgstr "Invio Integrazione Imposte" + +#. module: l10n_it_edi +#: model:ir.model.fields,help:l10n_it_edi.field_account_move_send__l10n_it_edi_checkbox_send +msgid "" +"Send the invoice to the Italian Tax Agency.\n" +"It is set as readonly if a report has already been created, to avoid inconsistencies.\n" +"To re-enable it, delete the PDF attachment." +msgstr "" +"Invia la Fatturazione Elettronica all'Agenzia delle Entrate\n" +"Viene impostato in sola lettura se è stato già creato un resoconto, per evitare disallineamenti.\n" +"Per riabilitarlo, cancellare l'allegato PDF." #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 +#, python-format +msgid "" +"Sending invoices to Public Administration partners is not supported.\n" +"The IT EDI XML file is generated, please sign the document and upload it through the 'Fatture e Corrispettivi' portal of the Tax Agency." +msgstr "" +"L'invio della Fatturazione Elettronica a Partner PA non è supportato.\n" +" Il file XML per l'EDI (Italia) è stato generato, per favore firmalo digitalmente e caricalo tramite il portale 'Fatture e Corrispettivi' dell'Agenzia delle Entrate." + +#. module: l10n_it_edi +#. odoo-python +#: code:addons/l10n_it_edi/models/account_edi_proxy_user.py:0 #, python-format msgid "Service momentarily unavailable" msgstr "Servizio momentaneamente non disponibile" @@ -701,6 +825,11 @@ msgstr "" msgid "Tax" msgstr "Imposta" +#. module: l10n_it_edi +#: model:ir.model.fields,field_description:l10n_it_edi.field_account_move_send__l10n_it_edi_checkbox_send +msgid "Tax Agency (Italy)" +msgstr "Agenzia delle Entrate (IT)" + #. module: l10n_it_edi #: model:ir.model.fields,field_description:l10n_it_edi.field_res_company__l10n_it_tax_system msgid "Tax System" @@ -709,11 +838,11 @@ msgstr "Sistema Fiscale" #. module: l10n_it_edi #: model:ir.model.fields,help:l10n_it_edi.field_account_tax__l10n_it_has_exoneration msgid "Tax has a tax exoneration." -msgstr "La tassa ha un'Esenzione." +msgstr "L'Imposta ha un'Esenzione." #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "Tax not found for line with description '%s'" msgstr "Imposta non trovata per la linea con descrizione '%s'" @@ -730,36 +859,38 @@ msgstr "Rappresentante fiscale del partner" #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "Tax representative partner %s of %s must have a tax number." msgstr "" -"Il rappresentante fiscale del partner %s di %s deve avere un codice fiscale." +"Il rappresentante fiscale del partner %s di %s deve avere una Partita IVA." #. module: l10n_it_edi -#: model:ir.model.fields.selection,name:l10n_it_edi.selection__res_config_settings__l10n_it_edi_sdicoop_demo_mode__test +#: model:ir.model.fields.selection,name:l10n_it_edi.selection__res_config_settings__l10n_it_edi_demo_mode__test msgid "Test (experimental)" msgstr "Test (sperimentale)" #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "" "The buyer, %s, or his company must have a VAT number and/or a tax code " "(Codice Fiscale)." msgstr "" +"Il Cessionario/Committente %s o la sua Azienda devono avere una Partita IVA " +"e un Codice Fiscale." #. module: l10n_it_edi #. odoo-python #: code:addons/l10n_it_edi/models/res_config_settings.py:0 #, python-format msgid "" -"The company has already registered with the service as 'Test' or 'Official', " -"it cannot change." +"The company has already registered with the service as 'Test' or 'Official'," +" it cannot change." msgstr "" -"L'Azienda è giá registrata al servizio con la modalitá Test o Officiale, non " -"può essere cambiata." +"L'Azienda è giá registrata al servizio con la modalitá Test o Officiale, non" +" può essere cambiata." #. module: l10n_it_edi #: model:ir.model.fields.selection,name:l10n_it_edi.selection__res_company__l10n_it_eco_index_liquidation_state__ls @@ -773,12 +904,14 @@ msgstr "L'azienda non è in stato di liquidazione" #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "" -"The filename is duplicated. Try again (or adjust the FatturaPA Filename " -"sequence). Original message from the SDI: %s" +"The e-invoice file %s can't be forward to %s (Public Administration) by the SdI at the moment.\n" +"It will try again for 10 days, after which it will be considered accepted, but you will still have to send it by post or e-mail." msgstr "" +"Il file di Fatturazione Elettronica %s non può essere inoltrato a %s (Pubblica Amministrazione) al momento.\n" +"Riproverà per 10 giorni, dopo i quali il documento sarà considerato accettato, ma dovrai comunque inviarlo per Posta o e-mail." #. module: l10n_it_edi #: code:addons/l10n_it_edi/models/account_edi_format.py:0 @@ -789,87 +922,152 @@ msgstr "La lunghezza minima per la Partita IVA è 30. %s ha una Partita IVA trop #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "" -"The invoice has been correctly issued. The Public Administration recipient " -"had 15 days to either accept or refused this document, but they did not " -"reply, so from now on we consider it accepted." -msgstr "La Fattura è stata emessa con successo. Il partner della Pubblica Amministrazione " -"ha avuto 15 giorni per accettare o rifiutare il documento, ma non hanno risposto, " -"quindi da ora consideriamo la Fattura accettata." +"The e-invoice file %s couldn't be forwarded to %s.\n" +"Please remember to send it via post or e-mail." +msgstr "" +"Il file di Fatturazione Elettronica %s non può essere inoltrato a %s\n" +"Per favore, ricordati di inviarlo per Posta o per e-mail." #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 -#, python-format -msgid "Service momentarily unavailable" -msgstr "Servizio momentaneamente non disponibile" - -#. module: l10n_it_edi -#. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "" -"The invoice has been issued, but the delivery to the Addressee has failed. " -"You will be required to send a courtesy copy of the invoice to your customer" -" through another channel, outside of the Exchange System, and promptly " -"notify him that the original is deposited in his personal area on the portal" -" \"Invoices and Fees\" of the Revenue Agency." -msgstr "La fattura è stata inviata, ma la consegna al Destinatario è fallita. " -"É necessario mandare una copia di cortesia della fattura al cliente " -"tramite un altro canale al di fuori dell'SdI, e notificarlo che l'originale " -"è depositato nella sua Area Personale all' interno del portale 'Fatture e Corrispettivi' " -"sul sito dell'Agenzia delle Entrate" +"The e-invoice file %s has been accepted by %s (Public Administration), a " +"payment will be issued soon" +msgstr "" +"Il file di Fatturazione Elettronica %s è stato accettato da %s (Pubblica " +"Amministrazione), il pagamento verrà inviato presto." #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "" -"The invoice has been issued, but the delivery to the Public Administration " -"has failed. The Exchange System will contact them to report the problem and " -"request that they provide a solution. During the following 10 days, the " -"Exchange System will try to forward the FatturaPA file to the Public " -"Administration in question again. Should this also fail, the System will " -"notify Odoo of the failed delivery, and you will be required to send the " -"invoice to the Administration through another channel, outside of the " -"Exchange System." -msgstr "La fattura è stata inviata, ma la consegna alla Pubblica Amministrazione " -"è fallita. Il Sistema d'Interscambio contatterá il Destinatario per far rapporto " -"sul problema, e richiederá che venga approntata una soluzione. Durante i " -"10 giorni successivi, il Sistema d'Interscambio proverá a inoltrare il file " -"FatturaPA in questione ancora. Dovesse fallire anche questo tentativo, il " -"sistema notificherá Odoo della mancata consegna, e sará necessario inviare " -"la fattura tramite un altro canale, al di fuori dell'SdI" +"The e-invoice file %s has been accepted by the SdI.\n" +"The SdI is trying to forward it to %s.\n" +"It will try for up to 2 days, after which you'll eventually need to send it the invoice to the partner by post or e-mail." +msgstr "" +"Il file di Fatturazione Elettronica %s è stato accettato dal SdI.\n" +"Il SdI sta provando ad inviarlo a %s.\n" +"Proverà per due giorni, dopo i quali bisognerà eventualmente inviare la fattura al partner per posta o e-mail." #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 -#, python-format -msgid "The invoice has been refused by the Exchange System" -msgstr "La fattura è stata rifiutata dal Sistema d'Interscambio" - -#. module: l10n_it_edi -#. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "" -"The invoice has been succesfully transmitted. The addressee has 15 days to " -"accept or reject it." -msgstr "La fattura è stata trasmessa con successo. Il Destinatario ha 15 giorni per accettarla o rifiutarla." +"The e-invoice file %s has been refused by %s (Public Administration).\n" +"You have 5 days from now to issue a full refund for this invoice, then contact the PA partner to create a new one according to their requests and submit it." +msgstr "" +"Il file di Fatturazione Elettronica %s è stato rifiutato da %s (Pubblica Amministrazione).\n" +" Hai 5 giorni per fare una Nota di Credito completa, poi contattare il Partner PA e creare una nuova Fattura Elettronica seguendo i loro requisiti, e reinviarla." #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format -msgid "The invoice was refused by the addressee." -msgstr "La fattura è stata rifiutata dal Destinatario." +msgid "The e-invoice file %s has been sent in Demo EDI mode." +msgstr "" +"Il file di Fatturazione Elettronica %s è stato inviato in modalità EDI Demo." #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 +#, python-format +msgid "The e-invoice file %s has not been found on the EDI Proxy server." +msgstr "" +"Il file di Fatturazione Elettronica %s non è stato trovato sul Proxy Server " +"EDI (Italia)" + +#. module: l10n_it_edi +#. odoo-python +#: code:addons/l10n_it_edi/models/account_move.py:0 +#, python-format +msgid "" +"The e-invoice file %s is duplicated.\n" +"Original message from the SdI: %s" +msgstr "" +"Il file di Fatturazione Elettronica %s è duplicato.\n" +"Messaggio originale dal SdI: %s" + +#. module: l10n_it_edi +#. odoo-python +#: code:addons/l10n_it_edi/models/account_move.py:0 +#, python-format +msgid "" +"The e-invoice file %s is succesfully sent to the SdI. The invoice is now considered fiscally relevant.\n" +"The %s (Public Administration) had 15 days to either accept or refused this document,but since they did not reply, it's now considered accepted." +msgstr "" +"Il file di Fatturazione Elettronica %s è stato mandato al SdI con successo. La fattura è ora considerata fiscalmente rilevante.\n" +" %s (Pubblica Amministrazione) ha avuto 15 giorni per accettare o rifiutare questo documento, ma dato che non ha risposto, ora la fattura è considerata accettata." + +#. module: l10n_it_edi +#. odoo-python +#: code:addons/l10n_it_edi/models/account_move.py:0 +#, python-format +msgid "" +"The e-invoice file %s was accepted and succesfully forwarded it to %s by the" +" SdI." +msgstr "" +"Il file di Fatturazione Elettronica %s è stato accettato e inoltrato con " +"successo a %s dal SdI." + +#. module: l10n_it_edi +#. odoo-python +#: code:addons/l10n_it_edi/models/account_move.py:0 +#, python-format +msgid "The e-invoice file %s was sent to the SdI for processing." +msgstr "" +"Il file di Fatturazione Elettronica %s è stato inviato al SdI per " +"l'elaborazione." + +#. module: l10n_it_edi +#. odoo-python +#: code:addons/l10n_it_edi/models/account_move.py:0 +#, python-format +msgid "" +"The e-invoice file %s was sent to the SdI for validation.\n" +"It is not yet considered accepted, please wait further notifications." +msgstr "" +"Il file di Fatturazione Elettronica %s è stato inviato al SdI per il controllo.\n" +"Non è ancora considerato valido, bisogna attendere ulteriori notifiche." + +#. module: l10n_it_edi +#. odoo-python +#: code:addons/l10n_it_edi/models/account_move.py:0 +#, python-format +msgid "" +"The e-invoice file %s was succesfully sent to the SdI.\n" +"%s has 15 days to accept or reject it." +msgstr "" +"Il file di Fatturazione Elettronica %s è stato inviato con successo al SdI.\n" +"%s ha 15 giorni per accettarlo o rifiutarlo." + +#. module: l10n_it_edi +#. odoo-python +#: code:addons/l10n_it_edi/models/account_move.py:0 +#, python-format +msgid "" +"The e-invoice filename %s is duplicated. Please check the FatturaPA Filename sequence.\n" +"Original message from the SdI: %s" +msgstr "" +"Il file di Fatturazione Elettronica %s è duplicato. Per favore, controlla la sequenza relativa ai nomi dei file della FatturaPA.\n" +"Messaggio originale dal SdI: %s" + +#. module: l10n_it_edi +#. odoo-python +#: code:addons/l10n_it_edi/models/account_move.py:0 +#, python-format +msgid "The e-invoice has been refused by the SdI." +msgstr "La Fattura Elettronica è stata rifiutata dal SdI." + +#. module: l10n_it_edi +#. odoo-python +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "" "The invoice was successfully transmitted to the Public Administration and we" @@ -879,7 +1077,7 @@ msgstr "La fattura è stata trasmessa con successo alla Pubblica Amministrazione #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "The seller's company must have a tax system." msgstr "L'azienda del venditore deve avere specificato un Sistema Fiscale." @@ -888,8 +1086,7 @@ msgstr "L'azienda del venditore deve avere specificato un Sistema Fiscale." #: model_terms:ir.ui.view,arch_db:l10n_it_edi.res_company_form_l10n_it msgid "" "The seller/provider is a company listed on the register of companies and as\n" -" such must also indicate the registration data on " -"all documents (art. 2250, Italian\n" +" such must also indicate the registration data on all documents (art. 2250, Italian\n" " Civil Code)" msgstr "" "Il venditore/fornitore è un'azienda presente nel Registro delle Imprese\n" @@ -899,20 +1096,19 @@ msgstr "" #. module: l10n_it_edi #: model:ir.model.fields,help:l10n_it_edi.field_res_company__l10n_it_has_eco_index msgid "" -"The seller/provider is a company listed on the register of companies and " -"as such must also indicate the registration data on all documents " -"(art. 2250, Italian Civil Code)" +"The seller/provider is a company listed on the register of companies and as" +" such must also indicate the registration data on all documents (art." +" 2250, Italian Civil Code)" msgstr "" -"Il venditore/fornitore è un'azienda presente nel Registro delle Impresee " -"come tale deve indicare i dati di registrazione su tutti i documenti\n" +"Il venditore/fornitore è un'azienda presente nel Registro delle Impresee come tale deve indicare i dati di registrazione su tutti i documenti\n" "(art. 2250 del Codice Civile)" #. module: l10n_it_edi #: model:ir.model.fields,help:l10n_it_edi.field_res_company__l10n_it_has_tax_representative msgid "" "The seller/provider is a non-resident subject which carries out " -"transactions in Italy with relevance for VAT purposes and which takes " -"avail of a tax representative in Italy" +"transactions in Italy with relevance for VAT purposes and which takes" +" avail of a tax representative in Italy" msgstr "" "Il venditore/fornitore è un soggetto non-residente che svolge le sue " "transazioni in Italia con rilevanza fiscale e che fa riferimento a un " @@ -921,10 +1117,8 @@ msgstr "" #. module: l10n_it_edi #: model_terms:ir.ui.view,arch_db:l10n_it_edi.res_company_form_l10n_it msgid "" -"The seller/provider is a non-resident subject which carries out transactions " -"in Italy\n" -" with relevance for VAT purposes and which takes " -"avail of a tax representative in Italy" +"The seller/provider is a non-resident subject which carries out transactions in Italy\n" +" with relevance for VAT purposes and which takes avail of a tax representative in Italy" msgstr "" "Il venditore/fornitore è un soggetto non-residente che svolge le sue " "transazioni in Italia con rilevanza fiscale e che fa riferimento a un " @@ -933,31 +1127,29 @@ msgstr "" #. module: l10n_it_edi #: model:ir.model.fields,help:l10n_it_edi.field_res_company__l10n_it_eco_index_number msgid "" -"This field must contain the number under which the seller/provider is " -"listed on the register of companies." +"This field must contain the number under which the seller/provider is" +" listed on the register of companies." msgstr "" -"Questo campo deve contenere il numero sotto il quale è presente nel Registro " -"delle Imprese" +"Questo campo deve contenere il numero sotto il quale è presente nel Registro" +" delle Imprese" #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format -msgid "" -"This invoice number had already been submitted to the SdI, so it is set as " -"Sent. Please verify that the system is correctly configured, because the " -"correct flow does not need to send the same invoice twice for any reason.\n" -" Original message from the SDI: %s" -msgstr "" -"Una fattura con questo numero è giá stata inviata all'SdI, quindi viene " -"impostata come inviata. Per favore, verificare che il sistema sia " -"configurato correttamente, perchè in nessun caso il sistema ha motivo di " -"inviare la stessa fattura più volte.\n" -"Messaggio originale dall'SdI: %s" +msgid "This move is not waiting for updates from the SdI." +msgstr "Questa Registrazione non sta attendendo aggiornamenti dal SdI." + +#. module: l10n_it_edi +#: model:ir.model.fields,help:l10n_it_edi.field_account_bank_statement_line__l10n_it_edi_state +#: model:ir.model.fields,help:l10n_it_edi.field_account_move__l10n_it_edi_state +#: model:ir.model.fields,help:l10n_it_edi.field_account_payment__l10n_it_edi_state +msgid "This state is updated by default, but you can force the value. " +msgstr "Questo stato viene aggiornato automaticamente, ma è possibile forzare il valore." #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "Total amount from the XML File: %s" msgstr "Valore totale del file XML: %s" @@ -980,18 +1172,35 @@ msgstr "Numero Documento Di Trasporto" #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "Transport informations from XML file:" msgstr "Informazioni di Trasporto dal file XML:" #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_edi_proxy_user.py:0 #, python-format msgid "Unauthorized user" msgstr "Utente non autorizzato" +#. module: l10n_it_edi +#. odoo-python +#: code:addons/l10n_it_edi/models/account_edi_proxy_user.py:0 +#, python-format +msgid "Unknown error" +msgstr "Errore sconosciuto" + +#. module: l10n_it_edi +#: model:ir.model.fields,help:l10n_it_edi.field_account_bank_statement_line__l10n_it_edi_header +#: model:ir.model.fields,help:l10n_it_edi.field_account_move__l10n_it_edi_header +#: model:ir.model.fields,help:l10n_it_edi.field_account_payment__l10n_it_edi_header +msgid "" +"User description of the current state, with hints to make the flow progress" +msgstr "" +"Descrizione utente dello stato corrente, con suggerimenti sul progresso del " +"flusso" + #. module: l10n_it_edi #: model:ir.model.fields,field_description:l10n_it_edi.field_account_tax__l10n_it_vat_due_date msgid "VAT due date" @@ -999,37 +1208,42 @@ msgstr "Data scadenza IVA" #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "" "Vendor bills sent as self-invoices to the SdI require a valid PA Index " "(Codice Destinatario) on the company's contact." -msgstr "Fatture d'acquisto inviate come autofatture all'SdI richiedono " -"un Codice Destinatario valido nel Contatto dell'Impresa" +msgstr "" +"Le Parcelle inviate come AutoFatture/Integrazioni richiedono l'inserimento " +"di un Codice Destinatario valido sul Contatto dell'Azienda." #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "Vendor not found, useful informations from XML file:" msgstr "Fornitore non trovato, informazioni dal file XML:" #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format -msgid "You are not allowed to check the status of this invoice." -msgstr "Non sei autorizzato a controllare lo stato di questa fattura." +msgid "" +"We are simulating the sending of the e-invoice file %s, as we are in demo " +"mode." +msgstr "" +"Simulazione dell'invio del file di Fatturazione Elettronica %s in modalità " +"demo in corso" #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/wizard/account_move_send.py:0 #, python-format msgid "" -"You must accept the terms and conditions in the settings to use FatturaPA." +"You must accept the terms and conditions in the Settings to use the IT EDI." msgstr "" -"Devi accettare i Termini e le Condizioni d'uso nelle impostazioni per poter " -"utilizzare FatturaPA." +"Devi accettare i Termini e le Condiioni nelle Impostazioni di Contabilità " +"per usare l'EDI (Italia)" #. module: l10n_it_edi #. odoo-python @@ -1209,7 +1423,8 @@ msgstr "" #. module: l10n_it_edi #: model:ir.model.fields.selection,name:l10n_it_edi.selection__res_company__l10n_it_tax_system__rf04 msgid "" -"[RF04] Agricoltura e attività connesse e pesca (artt.34 e 34-bis, DPR 633/72)" +"[RF04] Agricoltura e attività connesse e pesca (artt.34 e 34-bis, DPR " +"633/72)" msgstr "" #. module: l10n_it_edi @@ -1242,8 +1457,8 @@ msgstr "" #. module: l10n_it_edi #: model:ir.model.fields.selection,name:l10n_it_edi.selection__res_company__l10n_it_tax_system__rf10 msgid "" -"[RF10] Intrattenimenti, giochi e altre attività di cui alla tariffa allegata " -"al DPR 640/72 (art.74, c.6, DPR 633/72)" +"[RF10] Intrattenimenti, giochi e altre attività di cui alla tariffa allegata" +" al DPR 640/72 (art.74, c.6, DPR 633/72)" msgstr "" #. module: l10n_it_edi @@ -1302,20 +1517,7 @@ msgstr "" #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "from XML file:" msgstr "dal file XML:" - -#~ msgid "Last Modified on" -#~ msgstr "Data Modifica" - -#~ msgid "" -#~ "Tax not found with percentage: %s and exoneration %s for the article: %s" -#~ msgstr "" -#~ "L'imposta con percentuale: %s e Esenzione %s per il prodotto: %s non è " -#~ "stata trovata." - -#~ msgid "Tax not found with percentage: %s for the article: %s" -#~ msgstr "" -#~ "L'imposta con percentuale: %s per il prodotto: %s non è stata trovata" diff --git a/addons/l10n_it_edi/i18n/l10n_it_edi.pot b/addons/l10n_it_edi/i18n/l10n_it_edi.pot index f4743ca61d2..a0d0e2b269a 100644 --- a/addons/l10n_it_edi/i18n/l10n_it_edi.pot +++ b/addons/l10n_it_edi/i18n/l10n_it_edi.pot @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 16.4alpha1+e\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-05-31 09:04+0000\n" -"PO-Revision-Date: 2023-05-31 09:04+0000\n" +"POT-Creation-Date: 2023-06-09 15:24+0000\n" +"PO-Revision-Date: 2023-06-09 15:24+0000\n" "Last-Translator: \n" "Language-Team: \n" "MIME-Version: 1.0\n" @@ -17,81 +17,96 @@ msgstr "" #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "%s has an amount of 0.0, you must indicate the kind of exoneration." msgstr "" #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "%s must have a VAT number" msgstr "" #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "%s must have a city." msgstr "" #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "%s must have a codice fiscale number" msgstr "" #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "%s must have a country" msgstr "" #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "%s must have a country." msgstr "" #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "%s must have a post code of length 5." msgstr "" #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "%s must have a post code." msgstr "" #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 -#: code:addons/l10n_it_edi/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 +#: code:addons/l10n_it_edi/models/account_move.py:0 #, python-format msgid "%s must have a street." msgstr "" #. module: l10n_it_edi #. odoo-python -#: code:addons/l10n_it_edi/models/account_invoice.py:0 +#: code:addons/l10n_it_edi/models/account_tax.py:0 #, python-format msgid "" "'Scissione dei pagamenti' is not compatible with exoneration of kind 'N6'" msgstr "" +#. module: l10n_it_edi +#: model_terms:ir.ui.view,arch_db:l10n_it_edi.account_invoice_form_l10n_it +msgid "" +"E-invoicing (Italy)" +msgstr "" + +#. module: l10n_it_edi +#: model_terms:ir.ui.view,arch_db:l10n_it_edi.account_move_send_inherit_l10n_it_edi +msgid "" +" - if not seller.country_id: - errors.append(_("%s must have a country", seller.display_name)) - - # <1.1.1.2> - if not invoice.company_id.vat: - errors.append(_("%s must have a VAT number", invoice.company_id.name)) - if seller.vat and len(seller.vat) > 30: - errors.append(_("The maximum length for VAT number is 30. %s have a VAT number too long: %s.", seller.display_name, seller.vat)) - - # <1.2.1.2> - if not is_self_invoice and not seller.l10n_it_codice_fiscale: - errors.append(_("%s must have a codice fiscale number", seller.display_name)) - - # <1.2.1.8> - if not is_self_invoice and not seller.l10n_it_tax_system: - errors.append(_("The seller's company must have a tax system.")) - - # <1.2.2> - if not seller.street and not seller.street2: - errors.append(_("%s must have a street.", seller.display_name)) - if not seller.zip: - errors.append(_("%s must have a post code.", seller.display_name)) - elif len(seller.zip) != 5 and seller.country_id.code == 'IT': - errors.append(_("%s must have a post code of length 5.", seller.display_name)) - if not seller.city: - errors.append(_("%s must have a city.", seller.display_name)) - if not seller.country_id: - errors.append(_("%s must have a country.", seller.display_name)) - - if not is_self_invoice and seller.l10n_it_has_tax_representative and not seller.l10n_it_tax_representative_partner_id.vat: - errors.append(_("Tax representative partner %s of %s must have a tax number.", seller.l10n_it_tax_representative_partner_id.display_name, seller.display_name)) - - # <1.4.1> - if not buyer.vat and not buyer.l10n_it_codice_fiscale and buyer.country_id.code == 'IT': - errors.append(_("The buyer, %s, or his company must have a VAT number and/or a tax code (Codice Fiscale).", buyer.display_name)) - - if is_self_invoice and self._l10n_it_edi_services_or_goods(invoice) == 'both': - errors.append(_("Cannot apply Reverse Charge to a bill which contains both services and goods.")) - - if is_self_invoice and not buyer.partner_id.l10n_it_pa_index: - errors.append(_("Vendor bills sent as self-invoices to the SdI require a valid PA Index (Codice Destinatario) on the company's contact.")) - - for tax_line in invoice.line_ids.filtered(lambda line: line.tax_line_id): - if not tax_line.tax_line_id.l10n_it_kind_exoneration and tax_line.tax_line_id.amount == 0: - errors.append(_("%s has an amount of 0.0, you must indicate the kind of exoneration.", tax_line.name)) - - errors += self._l10n_it_edi_check_taxes_configuration(invoice) - - return errors - - def _l10n_it_edi_check_taxes_configuration(self, invoice): - """ - Can be overridden by submodules like l10n_it_edi_withholding, which also allows for withholding and pension_fund taxes. - """ - errors = [] - for invoice_line in invoice.invoice_line_ids.filtered(lambda x: x.display_type == 'product'): - all_taxes = invoice_line.tax_ids.flatten_taxes_hierarchy() - vat_taxes = all_taxes.filtered(lambda t: t.amount_type == 'percent' and t.amount >= 0) - if len(vat_taxes) != 1: - errors.append(_("In line %s, you must select one and only one VAT tax.", invoice_line.name)) - return errors - - def _l10n_it_edi_is_simplified(self, invoice): - """ - Simplified Invoices are a way for the invoice issuer to create an invoice with limited data. - Example: a consultant goes to the restaurant and wants the invoice instead of the receipt, - to be able to deduct the expense from his Taxes. The Italian State allows the restaurant - to issue a Simplified Invoice with the VAT number only, to speed up times, instead of - requiring the address and other informations about the buyer. - Only invoices under the threshold of 400 Euroes are allowed, to avoid this tool - be abused for bigger transactions, that would enable less transparency to tax institutions. - """ - buyer = invoice.commercial_partner_id - return all([ - self.env.ref('l10n_it_edi.account_invoice_it_simplified_FatturaPA_export', raise_if_not_found=False), - not self._l10n_it_edi_is_self_invoice(invoice), - self._l10n_it_edi_check_buyer_invoice_configuration(invoice), - not buyer.country_id or buyer.country_id.code == 'IT', - buyer.l10n_it_codice_fiscale or (buyer.vat and (buyer.vat[:2].upper() == 'IT' or buyer.vat[:2].isdecimal())), - invoice.amount_total <= 400, - ]) - - def _l10n_it_edi_check_simplified_invoice_configuration(self, invoice): - return [] if self._l10n_it_edi_is_simplified(invoice) else self._l10n_it_edi_check_buyer_invoice_configuration(invoice) - - def _l10n_it_edi_services_or_goods(self, invoice): - """ - Services and goods have different tax grids when VAT is Reverse Charged, and they can't - be mixed in the same invoice, because the TipoDocumento depends on which which kind - of product is bought and it's unambiguous. - """ - scopes = [] - for line in invoice.invoice_line_ids.filtered(lambda l: l.display_type not in ('line_note', 'line_section')): - tax_ids_with_tax_scope = line.tax_ids.filtered(lambda x: x.tax_scope) - if tax_ids_with_tax_scope: - scopes += tax_ids_with_tax_scope.mapped('tax_scope') - else: - scopes.append(line.product_id and line.product_id.type or 'consu') - - if set(scopes) == set(['consu', 'service']): - return "both" - return scopes and scopes.pop() - - def _l10n_it_edi_check_buyer_invoice_configuration(self, invoice): - errors = [] - buyer = invoice.commercial_partner_id - - # <1.4.2> - if not buyer.street and not buyer.street2: - errors.append(_("%s must have a street.", buyer.display_name)) - if not buyer.country_id: - errors.append(_("%s must have a country.", buyer.display_name)) - if not buyer.zip: - errors.append(_("%s must have a post code.", buyer.display_name)) - elif len(buyer.zip) != 5 and buyer.country_id.code == 'IT': - errors.append(_("%s must have a post code of length 5.", buyer.display_name)) - if not buyer.city: - errors.append(_("%s must have a city.", buyer.display_name)) - - for tax_line in invoice.line_ids.filtered(lambda line: line.tax_line_id): - if not tax_line.tax_line_id.l10n_it_kind_exoneration and tax_line.tax_line_id.amount == 0: - errors.append(_("%s has an amount of 0.0, you must indicate the kind of exoneration.", tax_line.name)) - - return errors - - def _l10n_it_goods_in_italy(self, invoice): - """ - There is a specific TipoDocumento (Document Type TD19) and tax grid (VJ3) for goods - that are phisically in Italy but are in a VAT deposit, meaning that the goods - have not passed customs. - """ - invoice_lines_tags = invoice.line_ids.tax_tag_ids - it_tax_report_vj3_lines = self.env['account.report.line'].search([ - ('report_id.country_id.code', '=', 'IT'), - ('code', '=', 'VJ3'), - ]) - vj3_lines_tags = it_tax_report_vj3_lines.expression_ids._get_matching_tags() - return bool(invoice_lines_tags & vj3_lines_tags) - - def _l10n_it_document_type_mapping(self): - """ Returns a dictionary with the required features for every TDxx FatturaPA document type """ - return { - 'TD01': dict(move_types=['out_invoice'], import_type='in_invoice', self_invoice=False, simplified=False, downpayment=False), - 'TD02': dict(move_types=['out_invoice'], import_type='in_invoice', self_invoice=False, simplified=False, downpayment=True), - 'TD04': dict(move_types=['out_refund'], import_type='in_refund', self_invoice=False, simplified=False), - 'TD07': dict(move_types=['out_invoice'], import_type='in_invoice', self_invoice=False, simplified=True), - 'TD08': dict(move_types=['out_refund'], import_type='in_refund', self_invoice=False, simplified=True), - 'TD09': dict(move_types=['out_invoice'], import_type='in_invoice', self_invoice=False, simplified=True), - 'TD28': dict(move_types=['in_invoice', 'in_refund'], import_type='in_invoice', simplified=False, self_invoice=True, partner_country_code="SM"), - 'TD17': dict(move_types=['in_invoice', 'in_refund'], import_type='in_invoice', simplified=False, self_invoice=True, services_or_goods="service"), - 'TD18': dict(move_types=['in_invoice', 'in_refund'], import_type='in_invoice', simplified=False, self_invoice=True, services_or_goods="consu", goods_in_italy=False, partner_in_eu=True), - 'TD19': dict(move_types=['in_invoice', 'in_refund'], import_type='in_invoice', simplified=False, self_invoice=True, services_or_goods="consu", goods_in_italy=True), - } - - def _l10n_it_get_invoice_features_for_document_type_selection(self, invoice): - """ Returns a dictionary of features to be compared with the TDxx FatturaPA - document type requirements. """ - services_or_goods = self._l10n_it_edi_services_or_goods(invoice) - partner_values = invoice.commercial_partner_id._l10n_it_edi_get_values() - return { - 'move_types': invoice.move_type, - 'partner_in_eu': partner_values.get('in_eu', False), - 'partner_country_code': partner_values.get('country_code', False), - 'simplified': self._l10n_it_edi_is_simplified(invoice), - 'self_invoice': self._l10n_it_edi_is_self_invoice(invoice), - 'downpayment': invoice._is_downpayment(), - 'services_or_goods': services_or_goods, - 'goods_in_italy': services_or_goods == 'consu' and self._l10n_it_goods_in_italy(invoice), - } - - def _l10n_it_get_document_type(self, invoice): - """ Compare the features of the invoice to the requirements of each TDxx FatturaPA - document type until you find a valid one. """ - invoice_features = self._l10n_it_get_invoice_features_for_document_type_selection(invoice) - for code, document_type_features in self._l10n_it_document_type_mapping().items(): - comparisons = [] - for key, invoice_feature in invoice_features.items(): - if key not in document_type_features: - continue - document_type_feature = document_type_features.get(key) - if isinstance(document_type_feature, (tuple, list)): - comparisons.append(invoice_feature in document_type_feature) - else: - comparisons.append(invoice_feature == document_type_feature) - if all(comparisons): - return code - return False - - def _l10n_it_is_simplified_document_type(self, document_type): - return self._l10n_it_document_type_mapping().get(document_type, {}).get('simplified', False) - - # ------------------------------------------------------------------------- - # Import - # ------------------------------------------------------------------------- - - def _cron_receive_fattura_pa(self): - ''' Check the proxy for incoming invoices for all companies. - ''' - for proxy_user in self.env['account_edi_proxy_client.user'].search([ - ('proxy_type', '=', 'l10n_it_edi'), - ('edi_mode', '!=', 'demo'), - ]): - self._receive_fattura_pa(proxy_user) - - def _receive_fattura_pa(self, proxy_user): - ''' Check the proxy for incoming invoices for a specified proxy user. - ''' - try: - res = proxy_user._make_request( - proxy_user._get_server_url() + '/api/l10n_it_edi/1/in/RicezioneInvoice', - params={'recipient_codice_fiscale': proxy_user.company_id.l10n_it_codice_fiscale}) - except AccountEdiProxyError as e: - res = {} - _logger.warning('Error while receiving file from SdiCoop: %s', e) - - retrigger = False - proxy_acks = [] - for id_transaction, fattura in res.items(): - - # The server has a maximum number of documents it can send at a time - # If that maximum is reached, then we search for more - # by re-triggering the download cron, avoiding the timeout. - current_num, max_num = fattura.get('current_num', 0), fattura.get('max_num', 0) - retrigger = retrigger or current_num == max_num > 0 - - if self._save_incoming_attachment_fattura_pa(proxy_user, id_transaction, fattura['filename'], fattura['file'], fattura['key']): - proxy_acks.append(id_transaction) - - if proxy_acks: - try: - proxy_user._make_request( - proxy_user._get_server_url() + '/api/l10n_it_edi/1/ack', - params={'transaction_ids': proxy_acks}) - except AccountEdiProxyError as e: - _logger.warning('Error while receiving file from SdiCoop: %s', e) - - if retrigger: - _logger.info('Retriggering "Receive invoices from the exchange system"...') - self.env.ref('l10n_it_edi.ir_cron_receive_fattura_pa_invoice')._trigger() - - def _save_incoming_attachment_fattura_pa(self, proxy_user, id_transaction, filename, content, key): - ''' Save an incoming file from the SdI as an attachment. - Commits if successful. - - :param proxy_user: the user that saves the attachment. - :param id_transaction: id of the SdI transaction for communication with the IAP proxy. - :param filename: name of the file to be saved. - :param content: encrypted content of the file to be saved. - :param key: key to decrypt the file. - :returns: True if everything went well, or the file already exists. - False if the file cannot be parsed as an XML. - ''' - - if self.env['ir.attachment'].search([('name', '=', filename), ('res_model', '=', 'account.move')], limit=1): - # name should be unique, the invoice already exists - _logger.info('E-invoice already exists: %s', filename) - return True - - decrypted_content = proxy_user._decrypt_data(content, key) - attachment = self.env['ir.attachment'].create({ - 'name': filename, - 'raw': decrypted_content, - 'type': 'binary' - }) - - # Import the move from the attachment. - # `_create_document_from_attachment` will create an empty move - # then try and fill it with the content imported from the attachment. - # Should the import fail, thanks to try..except and savepoint, - # we will anyway end up with an empty `in_invoice` with the attachment posted on it. - (self.env['account.journal'].with_company(proxy_user.company_id) - .with_context(default_move_type='in_invoice') - ._create_document_from_attachment(attachment.ids)) - - # Commit the created move, be it correctly imported or empty. - self.env.cr.commit() - - return True - - def _check_filename_is_fattura_pa(self, filename): - return re.search("[A-Z]{2}[A-Za-z0-9]{2,28}_[A-Za-z0-9]{0,5}.((?i:xml.p7m|xml))", filename) - - def _is_fattura_pa(self, filename, tree=None): - return self.code == 'fattura_pa' and self._check_filename_is_fattura_pa(filename) - - def _l10n_it_get_partner_invoice(self, tree, company): - # Partner (first step to avoid warning 'Warning! You must first select a partner.'). <1.2> - elements = tree.xpath('//CedentePrestatore//IdCodice') - partner = elements and self.env['res.partner'].search([ - *self.env['res.partner']._check_company_domain(company), - ('vat', 'ilike', elements[0].text) - ], limit=1) - if not partner: - elements = tree.xpath('//CedentePrestatore//CodiceFiscale') - if elements: - codice = elements[0].text - domains = [[('l10n_it_codice_fiscale', '=', codice)]] - if re.match(r'^[0-9]{11}$', codice): - domains.append([('l10n_it_codice_fiscale', '=', 'IT' + codice)]) - elif re.match(r'^IT[0-9]{11}$', codice): - domains.append([('l10n_it_codice_fiscale', '=', - self.env['res.partner']._l10n_it_edi_normalized_codice_fiscale(codice))]) - partner = elements and self.env['res.partner'].search(AND([ - *self.env['res.partner']._check_company_domain(company), - OR(domains) - ]), limit=1) - if not partner: - elements = tree.xpath('//DatiTrasmissione//Email') - partner = elements and self.env['res.partner'].search([ - *self.env['res.partner']._check_company_domain(company), - '|', ('email', '=', elements[0].text), ('l10n_it_pec_email', '=', elements[0].text) - ], limit=1) - - return partner - - def _convert_date_from_xml(self, xsdate_str): - """ Dates in FatturaPA are ISO 8601 date format, pattern '[-]CCYY-MM-DD[Z|(+|-)hh:mm]' """ - xsdate_str = xsdate_str.strip() - xsdate_pattern = r"^-?(?P-?\d{4}-\d{2}-\d{2})(?P[zZ]|[+-]\d{2}:\d{2})?$" - try: - match = re.match(xsdate_pattern, xsdate_str) - converted_date = datetime.strptime(match.group("date"), DEFAULT_FACTUR_ITALIAN_DATE_FORMAT).date() - except Exception: - converted_date = False - return converted_date - - def _l10n_it_edi_search_tax_for_import(self, company, percentage, extra_domain=None): - """ Returns the VAT, Withholding or Pension Fund tax that suits the conditions given - and matches the percentage found in the XML for the company. """ - domain = [ - *self.env['account.tax']._check_company_domain(company), - ('amount', '=', percentage), - ('amount_type', '=', 'percent'), - ('type_tax_use', '=', 'purchase'), - ] + (extra_domain or []) - - # As we're importing vendor bills, we're excluding Reverse Charge Taxes - # which have a [100.0, 100.0, -100.0] repartition lines factor_percent distribution. - # We only allow for taxes that have all positive repartition lines factor_percent distribution. - taxes = self.env['account.tax'].search(domain).filtered( - lambda tax: all([rep_line.factor_percent >= 0 for rep_line in tax.invoice_repartition_line_ids])) - - return taxes[0] if taxes else taxes - - def _l10n_it_edi_get_extra_info(self, company, document_type, body_tree): - """ This function is meant to collect other information that has to be inserted on the invoice lines by submodules. - :return extra_info, messages_to_log""" - return {'simplified': self._l10n_it_is_simplified_document_type(document_type)}, [] - - def _import_fattura_pa(self, tree, invoice): - """ Decodes a fattura_pa invoice into an invoice. - - :param tree: the fattura_pa tree to decode. - :param invoice: the invoice to update or an empty recordset. - :returns: the invoice where the fattura_pa data was imported. - """ - company = invoice.company_id - - # Refund type. - # TD01 == invoice - # TD02 == advance/down payment on invoice - # TD03 == advance/down payment on fee - # TD04 == credit note - # TD05 == debit note - # TD06 == fee - # TD07 == simplified invoice - # TD08 == simplified credit note - # TD09 == simplified debit note - # For unsupported document types, just assume in_invoice, and log that the type is unsupported - elements = tree.xpath('//DatiGeneraliDocumento/TipoDocumento') - document_type = elements[0].text if elements else '' - move_type = self._l10n_it_document_type_mapping().get(document_type, {}).get('import_type') - if not move_type: - move_type = "in_invoice" - _logger.info('Document type not managed: %s. Invoice type is set by default.', document_type) - - invoice.move_type = move_type - - # Collect extra info from the XML that may be used by submodules to further put information on the invoice lines - extra_info, message_to_log = self._l10n_it_edi_get_extra_info(company, document_type, tree) - - partner = self._l10n_it_get_partner_invoice(tree, company) - if partner: - invoice.partner_id = partner - else: - message_to_log.append("%s
%s" % ( - _("Vendor not found, useful informations from XML file:"), - invoice._compose_info_message(tree, './/CedentePrestatore'))) - - # Numbering attributed by the transmitter. <1.1.2> - elements = tree.xpath('//ProgressivoInvio') - if elements: - invoice.payment_reference = elements[0].text - - elements = tree.xpath('.//DatiGeneraliDocumento//Numero') - if elements: - invoice.ref = elements[0].text - - # Currency. <2.1.1.2> - elements = tree.xpath('.//DatiGeneraliDocumento/Divisa') - if elements: - currency_str = elements[0].text - currency = self.env.ref('base.%s' % currency_str.upper(), raise_if_not_found=False) - if currency != self.env.company.currency_id and currency.active: - invoice.currency_id = currency - - # Date. <2.1.1.3> - elements = tree.xpath('.//DatiGeneraliDocumento/Data') - if elements: - document_date = self._convert_date_from_xml(elements[0].text) - if document_date: - invoice.invoice_date = document_date - else: - message_to_log.append("%s
%s" % ( - _("Document date invalid in XML file:"), - invoice._compose_info_message(elements[0], '.') - )) - - # Dati Bollo. <2.1.1.6> - elements = tree.xpath('.//DatiGeneraliDocumento/DatiBollo/ImportoBollo') - if elements: - invoice.l10n_it_stamp_duty = float(elements[0].text) - - - # Comment. <2.1.1.11> - elements = tree.xpath('.//DatiGeneraliDocumento//Causale') - for element in elements: - invoice.narration = '%s%s
' % (invoice.narration or '', element.text) - - # Informations relative to the purchase order, the contract, the agreement, - # the reception phase or invoices previously transmitted - # <2.1.2> - <2.1.6> - for document_type in ['DatiOrdineAcquisto', 'DatiContratto', 'DatiConvenzione', 'DatiRicezione', 'DatiFattureCollegate']: - elements = tree.xpath('.//DatiGenerali/' + document_type) - if elements: - for element in elements: - message_to_log.append("%s %s
%s" % (document_type, _("from XML file:"), - invoice._compose_info_message(element, '.'))) - - # Dati DDT. <2.1.8> - elements = tree.xpath('.//DatiGenerali/DatiDDT') - if elements: - message_to_log.append("%s
%s" % ( - _("Transport informations from XML file:"), - invoice._compose_info_message(tree, './/DatiGenerali/DatiDDT'))) - - # Due date. <2.4.2.5> - elements = tree.xpath('.//DatiPagamento/DettaglioPagamento/DataScadenzaPagamento') - if elements: - date_str = elements[0].text.strip() - if date_str: - due_date = self._convert_date_from_xml(date_str) - if due_date: - invoice.invoice_date_due = fields.Date.to_string(due_date) - else: - message_to_log.append("%s
%s" % ( - _("Payment due date invalid in XML file:"), - invoice._compose_info_message(elements[0], '.') - )) - - # Information related to the purchase order <2.1.2> - po_refs = [] - elements = tree.xpath('//DatiGenerali/DatiOrdineAcquisto/IdDocumento') - if elements: - po_refs = [element.text.strip() for element in elements] - invoice.invoice_origin = ", ".join(po_refs) - - # Total amount. <2.4.2.6> - elements = tree.xpath('.//ImportoPagamento') - amount_total_import = 0 - for element in elements: - amount_total_import += float(element.text) - if amount_total_import: - message_to_log.append(_("Total amount from the XML File: %s") % ( - amount_total_import)) - - # Bank account. <2.4.2.13> - if invoice.move_type not in ('out_invoice', 'in_refund'): - elements = tree.xpath('.//DatiPagamento/DettaglioPagamento/IBAN') - if elements: - if invoice.partner_id and invoice.partner_id.commercial_partner_id: - bank = self.env['res.partner.bank'].search([ - ('acc_number', '=', elements[0].text), - ('partner_id', '=', invoice.partner_id.commercial_partner_id.id), - ('company_id', 'in', [invoice.company_id.id, False]) - ], order='company_id', limit=1) - else: - bank = self.env['res.partner.bank'].search([ - ('acc_number', '=', elements[0].text), ('company_id', 'in', [invoice.company_id.id, False]) - ], order='company_id', limit=1) - if bank: - invoice.partner_bank_id = bank - else: - message_to_log.append("%s
%s" % ( - _("Bank account not found, useful informations from XML file:"), - invoice._compose_multi_info_message( - tree, ['.//DatiPagamento//Beneficiario', - './/DatiPagamento//IstitutoFinanziario', - './/DatiPagamento//IBAN', - './/DatiPagamento//ABI', - './/DatiPagamento//CAB', - './/DatiPagamento//BIC', - './/DatiPagamento//ModalitaPagamento']))) - else: - elements = tree.xpath('.//DatiPagamento/DettaglioPagamento') - if elements: - message_to_log.append("%s
%s" % ( - _("Bank account not found, useful informations from XML file:"), - invoice._compose_info_message(tree, './/DatiPagamento'))) - - # Invoice lines. <2.2.1> - if not extra_info['simplified']: - elements = tree.xpath('.//DettaglioLinee') - else: - elements = tree.xpath('.//DatiBeniServizi') - - for element in (elements or []): - invoice_line_form = invoice.invoice_line_ids.create({ - 'move_id': invoice.id, - 'tax_ids': [fields.Command.clear()], - }) - if invoice_line_form: - message_to_log += self._import_fattura_pa_line(element, invoice_line_form, extra_info) - - # Global discount summarized in 1 amount - discount_elements = tree.xpath('.//DatiGeneraliDocumento/ScontoMaggiorazione') - if discount_elements: - taxable_amount = float(invoice.tax_totals['amount_untaxed']) - discounted_amount = taxable_amount - for discount_element in discount_elements: - discount_type = discount_element.xpath('.//Tipo') - discount_sign = 1 - if discount_type and discount_type[0].text == 'MG': - discount_sign = -1 - discount_amount = discount_element.xpath('.//Importo') - if discount_amount: - discounted_amount -= discount_sign * float(discount_amount[0].text) - continue - discount_percentage = discount_element.xpath('.//Percentuale') - if discount_percentage: - discounted_amount *= 1 - discount_sign * float(discount_percentage[0].text) / 100 - - general_discount = discounted_amount - taxable_amount - sequence = len(elements) + 1 - - invoice.invoice_line_ids = [Command.create({ - 'sequence': sequence, - 'name': 'SCONTO' if general_discount < 0 else 'MAGGIORAZIONE', - 'price_unit': general_discount, - })] - - elements = tree.xpath('.//Allegati') - if elements: - for element in elements: - name_attachment = element.xpath('.//NomeAttachment')[0].text - attachment_64 = str.encode(element.xpath('.//Attachment')[0].text) - attachment_64 = self.env['ir.attachment'].create({ - 'name': name_attachment, - 'datas': attachment_64, - 'type': 'binary', - 'res_model': 'account.move', - 'res_id': invoice.id, - }) - - # no_new_invoice to prevent from looping on the message_post that would create a new invoice without it - invoice.with_context(no_new_invoice=True).message_post( - body=(_("Attachment from XML")), - attachment_ids=[attachment_64.id] - ) - for message in message_to_log: - invoice.message_post(body=message) - - def _import_fattura_pa_line(self, element, invoice_line_form, extra_info=None): - extra_info = extra_info or {} - company = invoice_line_form.company_id - partner = invoice_line_form.partner_id - message_to_log = [] - - # Sequence. - line_elements = element.xpath('.//NumeroLinea') - if line_elements: - invoice_line_form.sequence = int(line_elements[0].text) - - # Product. - elements_code = element.xpath('.//CodiceArticolo') - if elements_code: - for element_code in elements_code: - type_code = element_code.xpath('.//CodiceTipo')[0] - code = element_code.xpath('.//CodiceValore')[0] - if type_code.text == 'EAN': - product = self.env['product.product'].search([('barcode', '=', code.text)]) - if product: - invoice_line_form.product_id = product - break - if partner: - product_supplier = self.env['product.supplierinfo'].search([('partner_id', '=', partner.id), ('product_code', '=', code.text)], limit=2) - if product_supplier and len(product_supplier) == 1 and product_supplier.product_id: - invoice_line_form.product_id = product_supplier.product_id - break - if not invoice_line_form.product_id: - for element_code in elements_code: - code = element_code.xpath('.//CodiceValore')[0] - product = self.env['product.product'].search([('default_code', '=', code.text)], limit=2) - if product and len(product) == 1: - invoice_line_form.product_id = product - break - - # Label. - line_elements = element.xpath('.//Descrizione') - if line_elements: - invoice_line_form.name = " ".join(line_elements[0].text.split()) - - # Quantity. - line_elements = element.xpath('.//Quantita') - if line_elements: - invoice_line_form.quantity = float(line_elements[0].text) - else: - invoice_line_form.quantity = 1 - - # Taxes - percentage = None - price_subtotal = 0 - if not extra_info['simplified']: - tax_element = element.xpath('.//AliquotaIVA') - if tax_element and tax_element[0].text: - percentage = float(tax_element[0].text) - else: - amount_element = element.xpath('.//Importo') - if amount_element and amount_element[0].text: - amount = float(amount_element[0].text) - tax_element = element.xpath('.//Aliquota') - if tax_element and tax_element[0].text: - percentage = float(tax_element[0].text) - price_subtotal = amount / (1 + percentage / 100) - else: - tax_element = element.xpath('.//Imposta') - if tax_element and tax_element[0].text: - tax_amount = float(tax_element[0].text) - price_subtotal = amount - tax_amount - percentage = round(tax_amount / price_subtotal * 100) - - natura_element = element.xpath('.//Natura') - invoice_line_form.tax_ids = [] - if percentage is not None: - l10n_it_kind_exoneration = bool(natura_element) and natura_element[0].text - conditions = ( - l10n_it_kind_exoneration and [('l10n_it_kind_exoneration', '=', l10n_it_kind_exoneration)] - or [('l10n_it_has_exoneration', '=', False)] - ) - tax = self._l10n_it_edi_search_tax_for_import(company, percentage, conditions) - if tax: - invoice_line_form.tax_ids += tax - else: - message_to_log.append("%s
%s" % ( - _("Tax not found for line with description '%s'", invoice_line_form.name), - self.env['account.move']._compose_info_message(element, '.'), - )) - - # Price Unit. - if not extra_info['simplified']: - line_elements = element.xpath('.//PrezzoUnitario') - if line_elements: - invoice_line_form.price_unit = float(line_elements[0].text) - else: - invoice_line_form.price_unit = price_subtotal - - # Discounts - discount_elements = element.xpath('.//ScontoMaggiorazione') - if discount_elements: - discount_element = discount_elements[0] - discount_percentage = discount_element.xpath('.//Percentuale') - # Special case of only 1 percentage discount - if discount_percentage and len(discount_elements) == 1: - discount_type = discount_element.xpath('.//Tipo') - discount_sign = 1 - if discount_type and discount_type[0].text == 'MG': - discount_sign = -1 - invoice_line_form.discount = discount_sign * float(discount_percentage[0].text) - # Discounts in cascade summarized in 1 percentage - else: - total = float(element.xpath('.//PrezzoTotale')[0].text) - discount = 100 - (100 * total) / (invoice_line_form.quantity * invoice_line_form.price_unit) - invoice_line_form.discount = discount - - return message_to_log - - # ------------------------------------------------------------------------- - # Export - # ------------------------------------------------------------------------- - - def _prepare_invoice_report(self, pdf_writer, edi_document): - self.ensure_one() - if self.code != 'fattura_pa': - return super()._prepare_invoice_report(pdf_writer, edi_document) - attachment = edi_document.sudo().attachment_id - if attachment: - pdf_writer.embed_odoo_attachment(attachment) - - def _is_compatible_with_journal(self, journal): - # OVERRIDE - self.ensure_one() - if self.code != 'fattura_pa': - return super()._is_compatible_with_journal(journal) - return journal.type in ('sale', 'purchase') and journal.country_code == 'IT' - - def _get_move_applicability(self, move): - # OVERRIDE - self.ensure_one() - if self.code != 'fattura_pa': - return super()._get_move_applicability(move) - - is_it_purchase_document = self._l10n_it_edi_is_self_invoice(move) and move.is_purchase_document() - if move.country_code == 'IT' and (move.is_sale_document() or is_it_purchase_document): - return { - 'post': self._post_fattura_pa, - 'post_batching': lambda move: (move.move_type, bool(move.l10n_it_edi_transaction)), - } - - def _l10n_it_edi_export_invoice_as_xml(self, invoice): - ''' Create the xml file content. - :return: The XML content as str. - ''' - template_values = invoice._prepare_fatturapa_export_values() - if not self._l10n_it_is_simplified_document_type(template_values['document_type']): - content = self.env['ir.qweb']._render('l10n_it_edi.account_invoice_it_FatturaPA_export', template_values) - else: - content = self.env['ir.qweb']._render('l10n_it_edi.account_invoice_it_simplified_FatturaPA_export', template_values) - invoice.message_post(body=_( - "A simplified invoice was created instead of an ordinary one. This is because the invoice \ - is a domestic invoice with a total amount of less than or equal to 400€ and the customer's address is incomplete." - )) - return content - - def _check_move_configuration(self, move): - # OVERRIDE - res = super()._check_move_configuration(move) - if self.code != 'fattura_pa': - return res - - res.extend(self._l10n_it_edi_check_invoice_configuration(move)) - - if not self.env['account_edi_proxy_client.user']._get_proxy_users(move.company_id, 'l10n_it_edi'): - res.append(_("You must accept the terms and conditions in the settings to use FatturaPA.")) - - return res - - def _needs_web_services(self): - self.ensure_one() - return self.code == 'fattura_pa' or super()._needs_web_services() - - def _l10n_it_post_invoices_step_1(self, invoices): - ''' Send the invoices to the proxy. - ''' - to_return = {} - - to_send = {} - for invoice in invoices: - xml = "" + str(self._l10n_it_edi_export_invoice_as_xml(invoice)) - filename = self._l10n_it_edi_generate_electronic_invoice_filename(invoice) - attachment = self.env['ir.attachment'].create({ - 'name': filename, - 'res_id': invoice.id, - 'res_model': invoice._name, - 'raw': xml.encode(), - 'description': _('Italian invoice: %s', invoice.move_type), - 'type': 'binary', - }) - invoice.l10n_it_edi_attachment_id = attachment - - if invoice._is_commercial_partner_pa(): - invoice.message_post( - body=(_("Invoices for PA are not managed by Odoo, you can download the document and send it on your own.")) - ) - to_return[invoice] = {'attachment': attachment, 'success': True} - else: - to_send[filename] = { - 'invoice': invoice, - 'data': {'filename': filename, 'xml': base64.b64encode(xml.encode()).decode()}} - - company = invoices.company_id - proxy_user = self.env['account_edi_proxy_client.user']._get_proxy_users(company, 'l10n_it_edi') - if not proxy_user: # proxy user should exist, because there is a check in _check_move_configuration - return {invoice: { - 'error': _("You must accept the terms and conditions in the settings to use FatturaPA."), - 'blocking_level': 'error'} for invoice in invoices} - - responses = {} - if proxy_user.edi_mode == 'demo': - responses = {i['data']['filename']: {'id_transaction': 'demo'} for i in to_send.values()} - else: - try: - responses = self._l10n_it_edi_upload([i['data'] for i in to_send.values()], proxy_user) - except AccountEdiProxyError as e: - return {invoice: {'error': e.message, 'blocking_level': 'error'} for invoice in invoices} - - for filename, response in responses.items(): - invoice = to_send[filename]['invoice'] - to_return[invoice] = response - if 'id_transaction' in response: - invoice.l10n_it_edi_transaction = response['id_transaction'] - to_return[invoice].update({ - 'error': _('The invoice was sent to FatturaPA, but we are still awaiting a response. Click the link above to check for an update.'), - 'blocking_level': 'info'}) - return to_return - - def _l10n_it_post_invoices_step_2(self, invoices): - ''' Check if the sent invoices have been processed by FatturaPA. - ''' - to_check = {i.l10n_it_edi_transaction: i for i in invoices} - to_return = {} - company = invoices.company_id - - proxy_user = self.env['account_edi_proxy_client.user']._get_proxy_users(company, 'l10n_it_edi') - if not proxy_user: # proxy user should exist, because there is a check in _check_move_configuration - return {invoice: { - 'error': _("You must accept the terms and conditions in the settings to use FatturaPA."), - 'blocking_level': 'error'} for invoice in invoices} - - if proxy_user.edi_mode == 'demo': - # simulate success and bypass ack - return {invoice: {'attachment': invoice.l10n_it_edi_attachment_id} for invoice in invoices} - else: - try: - responses = proxy_user._make_request(proxy_user._get_server_url() + '/api/l10n_it_edi/1/in/TrasmissioneFatture', - params={'ids_transaction': list(to_check.keys())}) - except AccountEdiProxyError as e: - return {invoice: {'error': e.message, 'blocking_level': 'error'} for invoice in invoices} - - proxy_acks = [] - for id_transaction, response in responses.items(): - invoice = to_check[id_transaction] - if 'error' in response: - to_return[invoice] = response - continue - - state = response['state'] - if state == 'awaiting_outcome': - to_return[invoice] = { - 'error': _('The invoice was sent to FatturaPA, but we are still awaiting a response. Click the link above to check for an update.'), - 'blocking_level': 'info'} - - elif state == 'not_found': - # Invoice does not exist on proxy. Either it does not belong to this proxy_user or it was not created correctly when - # it was sent to the proxy. - to_return[invoice] = {'error': _('You are not allowed to check the status of this invoice.'), 'blocking_level': 'error'} - - elif state == 'ricevutaConsegna': - if invoice._is_commercial_partner_pa(): - to_return[invoice] = {'error': _('The invoice has been succesfully transmitted. The addressee has 15 days to accept or reject it.')} - else: - to_return[invoice] = {'attachment': invoice.l10n_it_edi_attachment_id, 'success': True} - proxy_acks.append(id_transaction) - - elif state == 'notificaMancataConsegna': - if invoice._is_commercial_partner_pa(): - to_return[invoice] = {'error': _( - 'The invoice has been issued, but the delivery to the Public Administration' - ' has failed. The Exchange System will contact them to report the problem' - ' and request that they provide a solution.' - ' During the following 10 days, the Exchange System will try to forward the' - ' FatturaPA file to the Public Administration in question again.' - ' Should this also fail, the System will notify Odoo of the failed delivery,' - ' and you will be required to send the invoice to the Administration' - ' through another channel, outside of the Exchange System.')} - else: - to_return[invoice] = {'success': True, 'attachment': invoice.l10n_it_edi_attachment_id} - invoice._message_log(body=_( - 'The invoice has been issued, but the delivery to the Addressee has' - ' failed. You will be required to send a courtesy copy of the invoice' - ' to your customer through another channel, outside of the Exchange' - ' System, and promptly notify him that the original is deposited' - ' in his personal area on the portal "Invoices and Fees" of the' - ' Revenue Agency.')) - proxy_acks.append(id_transaction) - - elif state == 'NotificaDecorrenzaTermini': - # This condition is part of the Public Administration flow - invoice._message_log(body=_( - 'The invoice has been correctly issued. The Public Administration recipient' - ' had 15 days to either accept or refused this document, but they did not reply,' - ' so from now on we consider it accepted.')) - to_return[invoice] = {'attachment': invoice.l10n_it_edi_attachment_id, 'success': True} - proxy_acks.append(id_transaction) - - # In the transaction states above, we don't need to read the attachment. - # In the following cases instead we need to read the information inside - # about the notification itself, i.e. the error message in case of rejection. - else: - attachment_file = response.get('file') - if not attachment_file: # It means there is no status update, so we can skip it - document = invoice.edi_document_ids.filtered(lambda d: d.edi_format_id.code == 'fattura_pa') - to_return[invoice] = {'error': document.error, 'blocking_level': document.blocking_level} - continue - - xml = proxy_user._decrypt_data(attachment_file, response['key']) - response_tree = etree.fromstring(xml) - - if state == 'notificaScarto': - elements = response_tree.xpath('//Errore') - error_codes = [element.find('Codice').text for element in elements] - errors = [element.find('Descrizione').text for element in elements] - # Duplicated invoice - if '00404' in error_codes: - idx = error_codes.index('00404') - invoice.message_post(body=_( - 'This invoice number had already been submitted to the SdI, so it is' - ' set as Sent. Please verify that the system is correctly configured,' - ' because the correct flow does not need to send the same invoice' - ' twice for any reason.\n' - ' Original message from the SDI: %s', errors[idx])) - to_return[invoice] = {'attachment': invoice.l10n_it_edi_attachment_id, 'success': True} - else: - # Add helpful text if duplicated filename error - if '00002' in error_codes: - idx = error_codes.index('00002') - errors[idx] = _( - 'The filename is duplicated. Try again (or adjust the FatturaPA Filename sequence).' - ' Original message from the SDI: %s', [errors[idx]] - ) - to_return[invoice] = {'error': self._format_error_message(_('The invoice has been refused by the Exchange System'), errors), 'blocking_level': 'error'} - invoice.l10n_it_edi_transaction = False - proxy_acks.append(id_transaction) - - elif state == 'notificaEsito': - outcome = response_tree.find('Esito').text - if outcome == 'EC01': - to_return[invoice] = {'attachment': invoice.l10n_it_edi_attachment_id, 'success': True} - else: # ECO2 - to_return[invoice] = {'error': _('The invoice was refused by the addressee.'), 'blocking_level': 'error'} - proxy_acks.append(id_transaction) - - if proxy_acks: - try: - proxy_user._make_request(proxy_user._get_server_url() + '/api/l10n_it_edi/1/ack', - params={'transaction_ids': proxy_acks}) - except AccountEdiProxyError as e: - # Will be ignored and acked again next time. - _logger.error('Error while acking file to SdiCoop: %s', e) - - return to_return - - def _post_fattura_pa(self, invoice): - if not invoice[0].l10n_it_edi_transaction: - return self._l10n_it_post_invoices_step_1(invoice) - else: - return self._l10n_it_post_invoices_step_2(invoice) - - def _post_invoice_edi(self, invoices): - # OVERRIDE - self.ensure_one() - edi_result = super()._post_invoice_edi(invoices) - if self.code != 'fattura_pa': - return edi_result - - return self._post_fattura_pa(invoices) - - # ------------------------------------------------------------------------- - # Proxy methods - # ------------------------------------------------------------------------- - def _get_proxy_identification(self, company): - if not company.l10n_it_codice_fiscale: - raise UserError(_('Please fill your codice fiscale to be able to receive invoices from FatturaPA')) - - return self.env['res.partner']._l10n_it_edi_normalized_codice_fiscale(company.l10n_it_codice_fiscale) - - def _l10n_it_edi_upload(self, files, proxy_user): - '''Upload files to fatturapa. - - :param files: A list of dictionary {filename, base64_xml}. - :returns: A dictionary. - * message: Message from fatturapa. - * transactionId: The fatturapa ID of this request. - * error: An eventual error. - * error_level: Info, warning, error. - ''' - ERRORS = { - 'EI01': {'error': _lt('Attached file is empty'), 'blocking_level': 'error'}, - 'EI02': {'error': _lt('Service momentarily unavailable'), 'blocking_level': 'warning'}, - 'EI03': {'error': _lt('Unauthorized user'), 'blocking_level': 'error'}, - } - - if not files: - return {} - - result = proxy_user._make_request(proxy_user._get_server_url() + '/api/l10n_it_edi/1/out/SdiRiceviFile', params={'files': files}) - - # Translate the errors. - for filename in result.keys(): - if 'error' in result[filename]: - result[filename] = ERRORS.get(result[filename]['error'], {'error': result[filename]['error'], 'blocking_level': 'error'}) - - return result diff --git a/addons/l10n_it_edi/models/account_edi_proxy_user.py b/addons/l10n_it_edi/models/account_edi_proxy_user.py index dbeef9059a7..95cf6016b00 100644 --- a/addons/l10n_it_edi/models/account_edi_proxy_user.py +++ b/addons/l10n_it_edi/models/account_edi_proxy_user.py @@ -1,7 +1,11 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import logging + from odoo import _, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) class AccountEdiProxyClientUser(models.Model): @@ -18,16 +22,9 @@ class AccountEdiProxyClientUser(models.Model): } return urls - def _compute_proxy_type(self): - # Extends account_edi_proxy_client - super()._compute_proxy_type() - for user in self: - if user.company_id.country_code == 'IT': - user.proxy_type = 'l10n_it_edi' - def _get_proxy_identification(self, company, proxy_type): if proxy_type == 'l10n_it_edi': if not company.l10n_it_codice_fiscale: raise UserError(_('Please fill your codice fiscale to be able to receive invoices from FatturaPA')) - return self.env['res.partner']._l10n_it_edi_normalized_codice_fiscale(company.l10n_it_codice_fiscale) + return company.partner_id._l10n_it_edi_normalized_codice_fiscale() return super()._get_proxy_identification(company, proxy_type) diff --git a/addons/l10n_it_edi/models/account_invoice.py b/addons/l10n_it_edi/models/account_invoice.py deleted file mode 100644 index 69b39c8b2e2..00000000000 --- a/addons/l10n_it_edi/models/account_invoice.py +++ /dev/null @@ -1,458 +0,0 @@ -# -*- coding:utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -import base64 -from functools import reduce -import logging -import re - -from datetime import datetime -from markupsafe import escape - -from odoo import api, fields, models, _ -from odoo.tools import float_repr, float_compare -from odoo.exceptions import UserError, ValidationError - - -_logger = logging.getLogger(__name__) - -DEFAULT_FACTUR_ITALIAN_DATE_FORMAT = '%Y-%m-%d' - - -class AccountMove(models.Model): - _inherit = 'account.move' - - l10n_it_edi_transaction = fields.Char(copy=False, string="FatturaPA Transaction") - l10n_it_edi_attachment_id = fields.Many2one('ir.attachment', copy=False, string="FatturaPA Attachment", ondelete="restrict") - l10n_it_stamp_duty = fields.Float(default=0, string="Dati Bollo") - l10n_it_ddt_id = fields.Many2one('l10n_it.ddt', string='DDT', copy=False) - l10n_it_einvoice_name = fields.Char(compute='_compute_l10n_it_einvoice') - l10n_it_einvoice_id = fields.Many2one('ir.attachment', string="Electronic invoice", compute='_compute_l10n_it_einvoice') - - def _get_l10n_it_amount_split_payment(self): - self.ensure_one() - amount = 0.0 - if self.is_invoice(True): - for line in [line for line in self.line_ids if line.tax_line_id]: - if line.tax_line_id._l10n_it_is_split_payment() and line.credit > 0.0: - amount += line.credit - return amount - - @api.depends('edi_document_ids', 'edi_document_ids.attachment_id') - def _compute_l10n_it_einvoice(self): - fattura_pa = self.env.ref('l10n_it_edi.edi_fatturaPA') - for invoice in self: - einvoice = invoice.edi_document_ids.filtered(lambda d: d.edi_format_id == fattura_pa).sudo() - invoice.l10n_it_einvoice_id = einvoice.attachment_id - invoice.l10n_it_einvoice_name = einvoice.attachment_id.name - - @api.depends('l10n_it_edi_transaction') - def _compute_show_reset_to_draft_button(self): - super(AccountMove, self)._compute_show_reset_to_draft_button() - for move in self.filtered(lambda m: m.l10n_it_edi_transaction): - move.show_reset_to_draft_button = False - - def invoice_generate_xml(self): - self.ensure_one() - report_name = self.env['account.edi.format']._l10n_it_edi_generate_electronic_invoice_filename(self) - - data = "" + str(self._l10n_it_edi_export_invoice_as_xml()) - description = _('Italian invoice: %s', self.move_type) - attachment = self.env['ir.attachment'].create({ - 'name': report_name, - 'res_id': self.id, - 'res_model': self._name, - 'raw': data.encode(), - 'description': description, - 'type': 'binary', - }) - - self.message_post( - body=(_("E-Invoice is generated on %s by %s") % (fields.Datetime.now(), self.env.user.display_name)) - ) - return {'attachment': attachment} - - def _is_commercial_partner_pa(self): - """ - Returns True if the destination of the FatturaPA belongs to the Public Administration. - """ - return len(self.commercial_partner_id.l10n_it_pa_index or '') == 6 - - def _l10n_it_edi_prepare_fatturapa_line_details(self, reverse_charge_refund=False, is_downpayment=False, convert_to_euros=True): - """ Returns a list of dictionaries passed to the template for the invoice lines (DettaglioLinee) - """ - invoice_lines = [] - lines = self.invoice_line_ids.filtered(lambda l: not l.display_type in ('line_note', 'line_section')) - for num, line in enumerate(lines): - sign = -1 if line.move_id.is_inbound() else 1 - price_subtotal = (line.balance * sign) if convert_to_euros else line.price_subtotal - # The price_subtotal should be inverted when the line is a reverse charge refund. - if reverse_charge_refund: - price_subtotal = -price_subtotal - - # Unit price - price_unit = 0 - if line.quantity and line.discount != 100.0: - price_unit = price_subtotal / ((1 - (line.discount or 0.0) / 100.0) * abs(line.quantity)) - else: - price_unit = line.price_unit - - description = line.name - - # Down payment lines: - # If there was a down paid amount that has been deducted from this move, - # we need to put a reference to the down payment invoice in the DatiFattureCollegate tag - downpayment_moves = self.env['account.move'] - if not is_downpayment and line.price_subtotal < 0: - downpayment_moves = line._get_downpayment_lines().mapped("move_id") - if downpayment_moves: - downpayment_moves_description = ', '.join([m.name for m in downpayment_moves]) - sep = ', ' if description else '' - description = f"{description}{sep}{downpayment_moves_description}" - - vat_tax = line.tax_ids.flatten_taxes_hierarchy().filtered(lambda t: t._l10n_it_filter_kind('vat') and t.amount >= 0) - invoice_lines.append({ - 'line': line, - 'line_number': num + 1, - 'description': description or 'NO NAME', - 'unit_price': price_unit, - 'subtotal_price': price_subtotal, - 'vat_tax': vat_tax, - 'downpayment_moves': downpayment_moves, - }) - return invoice_lines - - def _l10n_it_edi_prepare_fatturapa_tax_details(self, tax_details): - """ Returns a list of dictionaries passed to the template for the invoice lines (DatiRiepilogo) - """ - tax_lines = [] - for _tax_name, tax_dict in tax_details['tax_details'].items(): - # The assumption is that the company currency is EUR. - tax = tax_dict['tax'] - base_amount = tax_dict['base_amount'] - tax_amount = tax_dict['tax_amount'] - tax_rate = tax.amount - tax_exigibility_code = ( - 'S' if tax._l10n_it_is_split_payment() - else 'D' if tax.tax_exigibility == 'on_payment' - else 'I' if tax.tax_exigibility == 'on_invoice' - else False - ) - expected_base_amount = tax_amount * 100 / tax_rate if tax_rate else False - # Constraints within the edi make local rounding on price included taxes a problem. - # To solve this there is a or 'rounding' field, such that: - # taxable base = sum(taxable base for each unit) + Arrotondamento - if tax.price_include and tax.amount_type == 'percent': - if expected_base_amount and float_compare(base_amount, expected_base_amount, 2): - tax_dict['rounding'] = base_amount - (tax_amount * 100 / tax_rate) - tax_dict['base_amount'] = base_amount - tax_dict['rounding'] - - tax_line_dict = { - 'tax': tax, - 'rounding': tax_dict.get('rounding', False), - 'base_amount': tax_dict['base_amount'], - 'tax_amount': tax_dict['tax_amount'], - 'exigibility_code': tax_exigibility_code, - } - tax_lines.append(tax_line_dict) - return tax_lines - - def _l10n_it_edi_filter_fatturapa_tax_details(self, line, tax_values): - """Filters tax details to only include the positive amounted lines regarding VAT taxes.""" - repartition_line = tax_values['tax_repartition_line'] - return (repartition_line.factor_percent >= 0 and repartition_line.tax_id.amount >= 0) - - def _prepare_fatturapa_export_values(self): - self.ensure_one() - - def format_date(dt): - # Format the date in the italian standard. - dt = dt or datetime.now() - return dt.strftime(DEFAULT_FACTUR_ITALIAN_DATE_FORMAT) - - def format_monetary(number, currency): - # Format the monetary values to avoid trailing decimals (e.g. 90.85000000000001). - return float_repr(number, min(2, currency.decimal_places)) - - def format_numbers(number): - #format number to str with between 2 and 8 decimals (event if it's .00) - number_splited = str(number).split('.') - if len(number_splited) == 1: - return "%.02f" % number - - cents = number_splited[1] - if len(cents) > 8: - return "%.08f" % number - return float_repr(number, max(2, len(cents))) - - def format_numbers_two(number): - #format number to str with 2 (event if it's .00) - return "%.02f" % number - - def discount_type(discount): - return 'SC' if discount > 0 else 'MG' - - def format_alphanumeric(text, maxlen=None): - if not text: - return False - text = text.encode('latin-1', 'replace').decode('latin-1') - if maxlen and maxlen > 0: - text = text[:maxlen] - elif maxlen and maxlen < 0: - text = text[maxlen:] - return text - - def format_phone(number): - if not number: - return False - number = number.replace(' ', '').replace('/', '').replace('.', '') - if len(number) > 4 and len(number) < 13: - return format_alphanumeric(number) - return False - - def format_address(street, street2, maxlen=60): - street, street2 = street or '', street2 or '' - if street and len(street) >= maxlen: - street2 = '' - sep = ' ' if street and street2 else '' - return format_alphanumeric(f"{street}{sep}{street2}", maxlen) - - formato_trasmissione = "FPA12" if self._is_commercial_partner_pa() else "FPR12" - - # Flags - is_self_invoice = self.env['account.edi.format']._l10n_it_edi_is_self_invoice(self) - document_type = self.env['account.edi.format']._l10n_it_get_document_type(self) - if self.env['account.edi.format']._l10n_it_is_simplified_document_type(document_type): - formato_trasmissione = "FSM10" - - # Represent if the document is a reverse charge refund in a single variable - reverse_charge = document_type in ['TD17', 'TD18', 'TD19'] - is_downpayment = document_type in ['TD02'] - reverse_charge_refund = self.move_type == 'in_refund' and reverse_charge - convert_to_euros = self.currency_id.name != 'EUR' - - # b64encode returns a bytestring, the template tries to turn it to string, - # but only gets the repr(pdf) --> "b''" - pdf = self.env['ir.actions.report']._render_qweb_pdf("account.account_invoices", self.id)[0] - pdf = base64.b64encode(pdf).decode() - pdf_name = re.sub(r'\W+', '', self.name) + '.pdf' - - tax_details = self._prepare_edi_tax_details(filter_to_apply=self._l10n_it_edi_filter_fatturapa_tax_details) - - company = self.company_id - partner = self.commercial_partner_id - sender = company - buyer = partner if not is_self_invoice else company - seller = company if not is_self_invoice else partner - sender_info_values = company.partner_id._l10n_it_edi_get_values() - buyer_info_values = (partner if not is_self_invoice else company.partner_id)._l10n_it_edi_get_values() - seller_info_values = (company.partner_id if not is_self_invoice else partner)._l10n_it_edi_get_values() - representative_info_values = company.l10n_it_tax_representative_partner_id._l10n_it_edi_get_values() - - # Self-invoices are technically -100%/+100% repartitioned - # but functionally need to be exported as 100% - document_total = self.amount_total - if is_self_invoice: - document_total += sum([abs(v['tax_amount_currency']) for k, v in tax_details['tax_details'].items()]) - if reverse_charge_refund: - document_total = -abs(document_total) - split_payment_amount = self._get_l10n_it_amount_split_payment() - if split_payment_amount: - document_total += split_payment_amount - - # Reference line for finding the conversion rate used in the document - conversion_line = self.invoice_line_ids.sorted(lambda l: abs(l.balance), reverse=True)[0] if self.invoice_line_ids else None - conversion_rate = float_repr( - abs(conversion_line.balance / conversion_line.amount_currency), precision_digits=5, - ) if convert_to_euros and conversion_line else None - - invoice_lines = self._l10n_it_edi_prepare_fatturapa_line_details(reverse_charge_refund, is_downpayment, convert_to_euros) - tax_lines = self._l10n_it_edi_prepare_fatturapa_tax_details(tax_details) - - # Reduce downpayment views to a single recordset - downpayment_moves = [l.get('downpayment_moves', self.env['account.move']) for l in invoice_lines] - downpayment_moves = self.browse(move.id for moves in downpayment_moves for move in moves) - - # Create file content. - template_values = { - 'record': self, - 'company': company, - 'partner': partner, - 'sender': sender, - 'buyer': buyer, - 'seller': seller, - 'representative': company.l10n_it_tax_representative_partner_id, - 'sender_info': sender_info_values, - 'buyer_info': buyer_info_values, - 'seller_info': seller_info_values, - 'representative_info': representative_info_values, - 'origin_document_type': False, # see module l10n_it_edi_pa, will be merged in master - 'currency': self.currency_id or self.company_currency_id if not convert_to_euros else self.env.ref('base.EUR'), - 'document_total': document_total, - 'regime_fiscale': company.l10n_it_tax_system if not is_self_invoice else 'RF18', - 'is_self_invoice': is_self_invoice, - 'partner_bank': self.partner_bank_id, - 'discount_type': discount_type, - 'formato_trasmissione': formato_trasmissione, - 'document_type': document_type, - 'pdf': pdf, - 'pdf_name': pdf_name, - 'tax_details': tax_details, - 'downpayment_moves': downpayment_moves, - 'rc_refund': reverse_charge_refund, - 'invoice_lines': invoice_lines, - 'tax_lines': tax_lines, - 'conversion_rate': conversion_rate, - 'balance_multiplicator': -1 if self.is_inbound() else 1, - 'abs': abs, - 'format_date': format_date, - 'format_monetary': format_monetary, - 'format_numbers': format_numbers, - 'format_numbers_two': format_numbers_two, - 'format_phone': format_phone, - 'format_alphanumeric': format_alphanumeric, - 'format_address': format_address, - } - return template_values - - def _post(self, soft=True): - # OVERRIDE - posted = super()._post(soft=soft) - return posted - - def _compose_info_message(self, tree, element_tags): - output_str = "" - elements = tree.xpath(element_tags) - for element in elements: - output_str += "
    " - for line in element.iter(): - if line.text: - text = " ".join(line.text.split()) - if text: - output_str += "
  • %s: %s
  • " % (line.tag, text) - output_str += "
" - return output_str - - def _compose_multi_info_message(self, tree, element_tags): - output_str = "
    " - - for element_tag in element_tags: - elements = tree.xpath(element_tag) - if not elements: - continue - for element in elements: - text = " ".join(element.text.split()) - if text: - output_str += "
  • %s: %s
  • " % (element.tag, text) - return output_str + "
" - - # ------------------------------------------------------------------------- - # Import invoice - # ------------------------------------------------------------------------- - - @api.model - def _import_invoice_fattura_pa(self, invoice, file_data, new=False): - tree_list = file_data['xml_tree'].xpath('//FatturaElettronicaBody') - if not tree_list: - return - - self.env['account.edi.format']._import_fattura_pa(tree_list[0], invoice) - - # there might be other invoices inside the EDI. - for tree in tree_list[1:]: - other_invoice = invoice.create({ - 'journal_id': invoice.journal_id.id, - }) - with other_invoice._get_edi_creation() as other_invoice: - self.env['account.edi.format']._import_fattura_pa(tree, other_invoice) - other_invoice.message_post(body=escape(_("Created from attachment in %s")) % invoice._get_html_link()) - - return True - - def _get_edi_decoder(self, file_data, new=False): - # EXTENDS 'account' - def is_fattura_pa(filename): # TODO: maybe check on etree? - return re.search('[A-Z]{2}[A-Za-z0-9]{2,28}_[A-Za-z0-9]{0,5}.((?i:xml.p7m|xml))', filename) - - if file_data['type'] in ('xml', 'xml_p7m') and is_fattura_pa(file_data['filename']): - return self._import_invoice_fattura_pa - - return super()._get_edi_decoder(file_data, new=new) - -class AccountTax(models.Model): - _name = "account.tax" - _inherit = "account.tax" - - l10n_it_vat_due_date = fields.Selection([ - ("I", "[I] IVA ad esigibilità immediata"), - ("D", "[D] IVA ad esigibilità differita"), - ("S", "[S] Scissione dei pagamenti")], default="I", string="VAT due date") - - l10n_it_has_exoneration = fields.Boolean(string="Has exoneration of tax (Italy)", help="Tax has a tax exoneration.") - l10n_it_kind_exoneration = fields.Selection(selection=[ - ("N1", "[N1] Escluse ex art. 15"), - ("N2", "[N2] Non soggette"), - ("N2.1", "[N2.1] Non soggette ad IVA ai sensi degli artt. Da 7 a 7-septies del DPR 633/72"), - ("N2.2", "[N2.2] Non soggette – altri casi"), - ("N3", "[N3] Non imponibili"), - ("N3.1", "[N3.1] Non imponibili – esportazioni"), - ("N3.2", "[N3.2] Non imponibili – cessioni intracomunitarie"), - ("N3.3", "[N3.3] Non imponibili – cessioni verso San Marino"), - ("N3.4", "[N3.4] Non imponibili – operazioni assimilate alle cessioni all’esportazione"), - ("N3.5", "[N3.5] Non imponibili – a seguito di dichiarazioni d’intento"), - ("N3.6", "[N3.6] Non imponibili – altre operazioni che non concorrono alla formazione del plafond"), - ("N4", "[N4] Esenti"), - ("N5", "[N5] Regime del margine / IVA non esposta in fattura"), - ("N6", "[N6] Inversione contabile (per le operazioni in reverse charge ovvero nei casi di autofatturazione per acquisti extra UE di servizi ovvero per importazioni di beni nei soli casi previsti)"), - ("N6.1", "[N6.1] Inversione contabile – cessione di rottami e altri materiali di recupero"), - ("N6.2", "[N6.2] Inversione contabile – cessione di oro e argento puro"), - ("N6.3", "[N6.3] Inversione contabile – subappalto nel settore edile"), - ("N6.4", "[N6.4] Inversione contabile – cessione di fabbricati"), - ("N6.5", "[N6.5] Inversione contabile – cessione di telefoni cellulari"), - ("N6.6", "[N6.6] Inversione contabile – cessione di prodotti elettronici"), - ("N6.7", "[N6.7] Inversione contabile – prestazioni comparto edile esettori connessi"), - ("N6.8", "[N6.8] Inversione contabile – operazioni settore energetico"), - ("N6.9", "[N6.9] Inversione contabile – altri casi"), - ("N7", "[N7] IVA assolta in altro stato UE (prestazione di servizi di telecomunicazioni, tele-radiodiffusione ed elettronici ex art. 7-octies, comma 1 lett. a, b, art. 74-sexies DPR 633/72)")], - string="Exoneration", - help="Exoneration type", - default="N1") - l10n_it_law_reference = fields.Char(string="Law Reference", size=100) - - @api.constrains('l10n_it_has_exoneration', - 'l10n_it_kind_exoneration', - 'l10n_it_law_reference', - 'amount', - 'invoice_repartition_line_ids', - 'refund_repartition_line_ids') - def _check_exoneration_with_no_tax(self): - for tax in self: - if tax.l10n_it_has_exoneration: - if not tax.l10n_it_kind_exoneration or not tax.l10n_it_law_reference or tax.amount != 0: - raise ValidationError(_("If the tax has exoneration, you must enter a kind of exoneration, a law reference and the amount of the tax must be 0.0.")) - if tax.l10n_it_kind_exoneration == 'N6' and tax._l10n_it_is_split_payment(): - raise UserError(_("Split Payment is not compatible with exoneration of kind 'N6'")) - - def _l10n_it_filter_kind(self, kind): - """ This can be overridden by l10n_it_edi_withholding for different kind of taxes (withholding, pension_fund).""" - return self if kind == 'vat' else self.env['account.tax'] - - def _l10n_it_is_split_payment(self): - """ Split payment means that the Public Administration buyer will pay VAT - to the tax agency instead of the vendor - """ - self.ensure_one() - - tax_tags = self.get_tax_tags(is_refund=False, repartition_type='tax') - if not tax_tags: - return False - - it_tax_report_ve38_lines = self.env['account.report.line'].search([ - ('report_id.country_id.code', '=', 'IT'), - ('code', '=', 'VE38'), - ]) - if not it_tax_report_ve38_lines: - return False - - ve38_lines_tags = it_tax_report_ve38_lines.expression_ids._get_matching_tags() - return bool(tax_tags & ve38_lines_tags) diff --git a/addons/l10n_it_edi/models/account_move.py b/addons/l10n_it_edi/models/account_move.py new file mode 100644 index 00000000000..ed107069206 --- /dev/null +++ b/addons/l10n_it_edi/models/account_move.py @@ -0,0 +1,1479 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from base64 import b64encode +from datetime import datetime +import logging +from lxml import etree +from markupsafe import escape +import uuid + +from odoo import _, api, Command, fields, models +from odoo.addons.base.models.ir_qweb_fields import Markup, nl2br, nl2br_enclose +from odoo.addons.account_edi_proxy_client.models.account_edi_proxy_user import AccountEdiProxyError +from odoo.exceptions import UserError +from odoo.tools import float_compare, float_repr, cleanup_xml_node + +_logger = logging.getLogger(__name__) + + +WAITING_STATES = ('processing', 'forward_attempt') + + +# ------------------------------------------------------------------------- +# XML tool functions +# ------------------------------------------------------------------------- + +def get_text(tree, xpath, many=False): + texts = [el.text.strip() for el in tree.xpath(xpath) if el.text] + return texts if many else texts[0] if texts else '' + +def get_float(tree, xpath): + try: + return float(get_text(tree, xpath)) + except ValueError: + return 0.0 + +def get_date(tree, xpath): + """ Dates in FatturaPA are ISO 8601 date format, pattern '[-]CCYY-MM-DD[Z|(+|-)hh:mm]' """ + dt = get_datetime(tree, xpath) + return dt.date() if dt else False + +def get_datetime(tree, xpath): + """ Datetimes in FatturaPA are ISO 8601 date format, pattern '[-]CCYY-MM-DDThh:mm:ss[Z|(+|-)hh:mm]' + Python 3.7 -> 3.11 doesn't support 'Z'. + """ + if (datetime_str := get_text(tree, xpath)): + try: + return datetime.fromisoformat(datetime_str.replace('Z', '+00:00')) + except (ValueError, TypeError): + return False + return False + + +class AccountMove(models.Model): + _inherit = 'account.move' + + l10n_it_edi_state = fields.Selection( + string="SDI State", + selection=[ + ('being_sent', 'Being Sent To SdI'), + ('requires_user_signature', 'Requires user signature'), + ('processing', 'SdI Processing'), + ('rejected', 'SdI Rejected'), + ('forwarded', 'SdI Accepted, Forwarded to Partner'), + ('forward_failed', 'SdI Accepted, Forward to Partner Failed'), + ('forward_attempt', 'SdI Accepted, Forwarding to Partner'), + ('accepted_by_pa_partner', 'SdI Accepted, Accepted by the PA Partner'), + ('rejected_by_pa_partner', 'SdI Accepted, Rejected by the PA Partner'), + ('accepted_by_pa_partner_after_expiry', 'SdI Accepted, PA Partner Expired Terms'), + ], + copy=False, tracking=True, + help="This state is updated by default, but you can force the value. ", + ) + l10n_it_edi_header = fields.Html( + help='User description of the current state, with hints to make the flow progress', + readonly=True, + copy=False, + ) + l10n_it_edi_transaction = fields.Char(copy=False, string="FatturaPA Transaction") + l10n_it_edi_attachment_file = fields.Binary(copy=False, attachment=True) + l10n_it_edi_attachment_id = fields.Many2one( + comodel_name='ir.attachment', + string="FatturaPA Attachment", + compute=lambda self: self._compute_linked_attachment_id('l10n_it_edi_attachment_id', 'l10n_it_edi_attachment_file'), + depends=['l10n_it_edi_attachment_file'], + ) + l10n_it_edi_is_self_invoice = fields.Boolean(compute="_compute_l10n_it_edi_is_self_invoice") + l10n_it_stamp_duty = fields.Float(default=0, string="Dati Bollo") + l10n_it_ddt_id = fields.Many2one('l10n_it.ddt', string='DDT', copy=False) + + # ------------------------------------------------------------------------- + # Computes + # ------------------------------------------------------------------------- + + @api.depends('move_type', 'line_ids.tax_tag_ids') + def _compute_l10n_it_edi_is_self_invoice(self): + """ + Italian EDI requires Vendor bills coming from EU countries to be sent as self-invoices. + We recognize these cases based on the taxes that target the VJ tax grids, which imply + the use of VAT External Reverse Charge. + """ + it_tax_report_vj_lines = self.env['account.report.line'].search([ + ('report_id.country_id.code', '=', 'IT'), + ('code', 'like', 'VJ%'), + ]) + vj_lines_tags = it_tax_report_vj_lines.expression_ids._get_matching_tags() + for move in self: + if not move.is_purchase_document(): + move.l10n_it_edi_is_self_invoice = False + continue + invoice_lines_tags = move.line_ids.tax_tag_ids + ids_intersection = set(invoice_lines_tags.ids) & set(vj_lines_tags.ids) + move.l10n_it_edi_is_self_invoice = bool(ids_intersection) + + @api.depends('l10n_it_edi_transaction') + def _compute_need_cancel_request(self): + super()._compute_need_cancel_request() + + # ------------------------------------------------------------------------- + # Overrides + # ------------------------------------------------------------------------- + + def _need_cancel_request(self): + # EXTENDS 'account' + return super()._need_cancel_request() or self.l10n_it_edi_transaction + + def _get_edi_decoder(self, file_data, new=False): + # EXTENDS 'account' + if file_data['type'] == 'l10n_it_edi': + return self._l10n_it_edi_import_invoice + return super()._get_edi_decoder(file_data, new=new) + + def _post(self, soft=True): + # EXTENDS 'account' + self.write({'l10n_it_edi_header': False}) + return super()._post(soft) + + # ------------------------------------------------------------------------- + # Business actions + # ------------------------------------------------------------------------- + + def action_l10n_it_edi_send(self): + """ Checks that the invoice data is coherent. + Attaches the XML file to the invoice. + Sends the invoice to the SdI. + """ + self.ensure_one() + + if (errors := self._l10n_it_edi_export_data_check()): + message = _("Errors occured while creating the e-invoice file.") + message += "\n- " + "\n- ".join(errors) + raise UserError(message) + + attachment_vals = self._l10n_it_edi_get_attachment_values(pdf_values=None) + self.env['ir.attachment'].create(attachment_vals) + self.invalidate_recordset(fnames=['l10n_it_edi_attachment_id', 'l10n_it_edi_attachment_file']) + self.message_post(attachment_ids=self.l10n_it_edi_attachment_id.ids) + self._l10n_it_edi_send({self: attachment_vals}) + self.is_move_sent = True + + def action_check_l10n_it_edi(self): + self.ensure_one() + if not self.l10n_it_edi_transaction and self.l10n_it_edi_state not in WAITING_STATES: + raise UserError(_("This move is not waiting for updates from the SdI.")) + self._l10n_it_edi_update_send_state() + + # ------------------------------------------------------------------------- + # Helpers + # ------------------------------------------------------------------------- + + def _l10n_it_edi_get_line_values(self, reverse_charge_refund=False, is_downpayment=False, convert_to_euros=True): + """ Returns a list of dictionaries passed to the template for the invoice lines (DettaglioLinee) + """ + invoice_lines = [] + lines = self.invoice_line_ids.filtered(lambda l: l.display_type not in ('line_note', 'line_section')) + for num, line in enumerate(lines): + sign = -1 if line.move_id.is_inbound() else 1 + price_subtotal = (line.balance * sign) if convert_to_euros else line.price_subtotal + # The price_subtotal should be inverted when the line is a reverse charge refund. + if reverse_charge_refund: + price_subtotal = -price_subtotal + + # Unit price + price_unit = 0 + if line.quantity and line.discount != 100.0: + price_unit = price_subtotal / ((1 - (line.discount or 0.0) / 100.0) * abs(line.quantity)) + else: + price_unit = line.price_unit + + description = line.name + + # Down payment lines: + # If there was a down paid amount that has been deducted from this move, + # we need to put a reference to the down payment invoice in the DatiFattureCollegate tag + downpayment_moves = self.env['account.move'] + if not is_downpayment and line.price_subtotal < 0: + downpayment_moves = line._get_downpayment_lines().mapped("move_id") + if downpayment_moves: + downpayment_moves_description = ', '.join(m.name for m in downpayment_moves) + sep = ', ' if description else '' + description = f"{description}{sep}{downpayment_moves_description}" + + invoice_lines.append({ + 'line': line, + 'line_number': num + 1, + 'description': description or 'NO NAME', + 'unit_price': price_unit, + 'subtotal_price': price_subtotal, + 'vat_tax': line.tax_ids.flatten_taxes_hierarchy().filtered(lambda t: t._l10n_it_filter_kind('vat') and t.amount >= 0), + 'downpayment_moves': downpayment_moves, + 'discount_type': ( + 'SC' if line.discount > 0 + else 'MG' if line.discount < 0 + else False + ) + }) + return invoice_lines + + def _l10n_it_edi_get_tax_values(self, tax_details): + """ Returns a list of dictionaries passed to the template for the invoice lines (DatiRiepilogo) + """ + tax_lines = [] + for _tax_name, tax_dict in tax_details['tax_details'].items(): + # The assumption is that the company currency is EUR. + base_amount = tax_dict['base_amount'] + tax_amount = tax_dict['tax_amount'] + tax = tax_dict['tax'] + tax_rate = tax.amount + tax_exigibility_code = ( + 'S' if tax._l10n_it_is_split_payment() + else 'D' if tax.tax_exigibility == 'on_payment' + else 'I' if tax.tax_exigibility == 'on_invoice' + else False + ) + expected_base_amount = tax_amount * 100 / tax_rate if tax_rate else False + tax = tax_dict['tax'] + # Constraints within the edi make local rounding on price included taxes a problem. + # To solve this there is a or 'rounding' field, such that: + # taxable base = sum(taxable base for each unit) + Arrotondamento + if tax.price_include and tax.amount_type == 'percent': + if expected_base_amount and float_compare(base_amount, expected_base_amount, 2): + tax_dict['rounding'] = base_amount - (tax_amount * 100 / tax_rate) + tax_dict['base_amount'] = base_amount - tax_dict['rounding'] + + tax_line_dict = { + 'tax': tax, + 'rounding': tax_dict.get('rounding', False), + 'base_amount': tax_dict['base_amount'], + 'tax_amount': tax_dict['tax_amount'], + 'exigibility_code': tax_exigibility_code, + } + tax_lines.append(tax_line_dict) + return tax_lines + + def _l10n_it_edi_filter_tax_details(self, line, tax_values): + """Filters tax details to only include the positive amounted lines regarding VAT taxes.""" + repartition_line = tax_values['tax_repartition_line'] + return (repartition_line.factor_percent >= 0 and repartition_line.tax_id.amount >= 0) + + def _get_l10n_it_amount_split_payment(self): + self.ensure_one() + amount = 0.0 + if self.is_invoice(True): + for line in [line for line in self.line_ids if line.tax_line_id]: + if line.tax_line_id._l10n_it_is_split_payment() and line.credit > 0.0: + amount += line.credit + return amount + + def _l10n_it_edi_get_values(self, pdf_values=None): + self.ensure_one() + + # Flags + is_self_invoice = self.l10n_it_edi_is_self_invoice + document_type = self._l10n_it_edi_get_document_type() + + # Represent if the document is a reverse charge refund in a single variable + reverse_charge = document_type in ['TD17', 'TD18', 'TD19'] + is_downpayment = document_type in ['TD02'] + reverse_charge_refund = self.move_type == 'in_refund' and reverse_charge + convert_to_euros = self.currency_id.name != 'EUR' + + tax_details = self._prepare_invoice_aggregated_taxes(filter_tax_values_to_apply=self._l10n_it_edi_filter_tax_details) + + company = self.company_id + partner = self.commercial_partner_id + sender = company + buyer = partner if not is_self_invoice else company + seller = company if not is_self_invoice else partner + sender_info_values = company.partner_id._l10n_it_edi_get_values() + buyer_info_values = (partner if not is_self_invoice else company.partner_id)._l10n_it_edi_get_values() + seller_info_values = (company.partner_id if not is_self_invoice else partner)._l10n_it_edi_get_values() + representative_info_values = company.l10n_it_tax_representative_partner_id._l10n_it_edi_get_values() + + if self._l10n_it_edi_is_simplified_document_type(document_type): + formato_trasmissione = "FSM10" + elif partner._l10n_it_edi_is_public_administration(): + formato_trasmissione = "FPA12" + else: + formato_trasmissione = "FPR12" + + # Self-invoices are technically -100%/+100% repartitioned + # but functionally need to be exported as 100% + document_total = self.amount_total + if is_self_invoice: + document_total += sum([abs(v['tax_amount_currency']) for k, v in tax_details['tax_details'].items()]) + if reverse_charge_refund: + document_total = -abs(document_total) + + split_payment_amount = self._get_l10n_it_amount_split_payment() + if split_payment_amount: + document_total += split_payment_amount + + # Reference line for finding the conversion rate used in the document + conversion_rate = float_repr( + abs(self.amount_total / self.amount_total_signed), precision_digits=5, + ) if convert_to_euros and self.invoice_line_ids else None + + invoice_lines = self._l10n_it_edi_get_line_values(reverse_charge_refund, is_downpayment, convert_to_euros) + tax_lines = self._l10n_it_edi_get_tax_values(tax_details) + + # Reduce downpayment views to a single recordset + downpayment_moves = [l.get('downpayment_moves', self.env['account.move']) for l in invoice_lines] + downpayment_moves = self.browse(move.id for moves in downpayment_moves for move in moves) + + return { + 'record': self, + 'company': company, + 'partner': partner, + 'sender': sender, + 'buyer': buyer, + 'seller': seller, + 'representative': company.l10n_it_tax_representative_partner_id, + 'sender_info': sender_info_values, + 'buyer_info': buyer_info_values, + 'seller_info': seller_info_values, + 'representative_info': representative_info_values, + 'origin_document_type': False, # see module l10n_it_edi_pa, will be merged in master + 'currency': self.currency_id or self.company_currency_id if not convert_to_euros else self.env.ref('base.EUR'), + 'document_total': document_total, + 'regime_fiscale': company.l10n_it_tax_system if not is_self_invoice else 'RF18', + 'is_self_invoice': is_self_invoice, + 'partner_bank': self.partner_bank_id, + 'formato_trasmissione': formato_trasmissione, + 'document_type': document_type, + 'tax_details': tax_details, + 'downpayment_moves': downpayment_moves, + 'rc_refund': reverse_charge_refund, + 'invoice_lines': invoice_lines, + 'tax_lines': tax_lines, + 'conversion_rate': conversion_rate, + 'balance_multiplicator': -1 if self.is_inbound() else 1, + 'abs': abs, + 'pdf_name': pdf_values['name'] if pdf_values else False, + 'pdf': b64encode(pdf_values['raw']).decode() if pdf_values else False, + } + + def _l10n_it_edi_services_or_goods(self): + """ + Services and goods have different tax grids when VAT is Reverse Charged, and they can't + be mixed in the same invoice, because the TipoDocumento depends on which which kind + of product is bought and it's unambiguous. + """ + self.ensure_one() + scopes = [] + for line in self.invoice_line_ids.filtered(lambda l: l.display_type not in ('line_note', 'line_section')): + tax_ids_with_tax_scope = line.tax_ids.filtered(lambda x: x.tax_scope) + if tax_ids_with_tax_scope: + scopes += tax_ids_with_tax_scope.mapped('tax_scope') + else: + scopes.append(line.product_id and line.product_id.type or 'consu') + + if set(scopes) == {'consu', 'service'}: + return "both" + return scopes and scopes.pop() + + def _l10n_it_edi_goods_in_italy(self): + """ + There is a specific TipoDocumento (Document Type TD19) and tax grid (VJ3) for goods + that are phisically in Italy but are in a VAT deposit, meaning that the goods + have not passed customs. + """ + self.ensure_one() + invoice_lines_tags = self.line_ids.tax_tag_ids + it_tax_report_vj3_lines = self.env['account.report.line'].search([ + ('report_id.country_id.code', '=', 'IT'), + ('code', '=', 'VJ3'), + ]) + vj3_lines_tags = it_tax_report_vj3_lines.expression_ids._get_matching_tags() + return bool(invoice_lines_tags & vj3_lines_tags) + + def _l10n_it_edi_is_simplified(self): + """ + Simplified Invoices are a way for the invoice issuer to create an invoice with limited data. + Example: a consultant goes to the restaurant and wants the invoice instead of the receipt, + to be able to deduct the expense from his Taxes. The Italian State allows the restaurant + to issue a Simplified Invoice with the VAT number only, to speed up times, instead of + requiring the address and other informations about the buyer. + Only invoices under the threshold of 400 Euroes are allowed, to avoid this tool + be abused for bigger transactions, that would enable less transparency to tax institutions. + """ + self.ensure_one() + template_reference = self.env.ref('l10n_it_edi.account_invoice_it_simplified_FatturaPA_export', raise_if_not_found=False) + buyer = self.commercial_partner_id + return bool( + template_reference + and not self.l10n_it_edi_is_self_invoice + and self._l10n_it_edi_export_buyer_data_check() + and (not buyer.country_id or buyer.country_id.code == 'IT') + and (buyer.l10n_it_codice_fiscale or (buyer.vat and (buyer.vat[:2].upper() == 'IT' or buyer.vat[:2].isdecimal()))) + and self.amount_total <= 400 + ) + + def _l10n_it_edi_features_for_document_type_selection(self): + """ Returns a dictionary of features to be compared with the TDxx FatturaPA + document type requirements. """ + partner_values = self.commercial_partner_id._l10n_it_edi_get_values() + services_or_goods = self._l10n_it_edi_services_or_goods() + return { + 'move_types': self.move_type, + 'partner_in_eu': partner_values.get('in_eu', False), + 'partner_country_code': partner_values.get('country_code', False), + 'simplified': self._l10n_it_edi_is_simplified(), + 'self_invoice': self.l10n_it_edi_is_self_invoice, + 'downpayment': self._is_downpayment(), + 'services_or_goods': services_or_goods, + 'goods_in_italy': services_or_goods == 'consu' and self._l10n_it_edi_goods_in_italy(), + } + + def _l10n_it_edi_document_type_mapping(self): + """ Returns a dictionary with the required features for every TDxx FatturaPA document type """ + return { + 'TD01': {'move_types': ['out_invoice'], + 'import_type': 'in_invoice', + 'self_invoice': False, + 'simplified': False, + 'downpayment': False}, + 'TD02': {'move_types': ['out_invoice'], + 'import_type': 'in_invoice', + 'self_invoice': False, + 'simplified': False, + 'downpayment': True}, + 'TD04': {'move_types': ['out_refund'], + 'import_type': 'in_refund', + 'self_invoice': False, + 'simplified': False}, + 'TD07': {'move_types': ['out_invoice'], + 'import_type': 'in_invoice', + 'self_invoice': False, + 'simplified': True}, + 'TD08': {'move_types': ['out_refund'], + 'import_type': 'in_refund', + 'self_invoice': False, + 'simplified': True}, + 'TD09': {'move_types': ['out_invoice'], + 'import_type': 'in_invoice', + 'self_invoice': False, + 'simplified': True}, + 'TD28': {'move_types': ['in_invoice', 'in_refund'], + 'import_type': 'in_invoice', + 'simplified': False, + 'self_invoice': True, + 'partner_country_code': "SM"}, + 'TD17': {'move_types': ['in_invoice', 'in_refund'], + 'import_type': 'in_invoice', + 'simplified': False, + 'self_invoice': True, + 'services_or_goods': "service"}, + 'TD18': {'move_types': ['in_invoice', 'in_refund'], + 'import_type': 'in_invoice', + 'simplified': False, + 'self_invoice': True, + 'services_or_goods': "consu", + 'goods_in_italy': False, + 'partner_in_eu': True}, + 'TD19': {'move_types': ['in_invoice', 'in_refund'], + 'import_type': 'in_invoice', + 'simplified': False, + 'self_invoice': True, + 'services_or_goods': "consu", + 'goods_in_italy': True}, + } + + def _l10n_it_edi_get_document_type(self): + """ Compare the features of the invoice to the requirements of each TDxx FatturaPA + document type until you find a valid one. """ + invoice_features = self._l10n_it_edi_features_for_document_type_selection() + for code, document_type_features in self._l10n_it_edi_document_type_mapping().items(): + comparisons = [] + for key, invoice_feature in invoice_features.items(): + if key not in document_type_features: + continue + document_type_feature = document_type_features.get(key) + if isinstance(document_type_feature, list): + comparisons.append(invoice_feature in document_type_feature) + else: + comparisons.append(invoice_feature == document_type_feature) + if all(comparisons): + return code + return False + + def _l10n_it_edi_is_simplified_document_type(self, document_type): + mapping = self._l10n_it_edi_document_type_mapping() + return mapping.get(document_type, {}).get('simplified', False) + + # ------------------------------------------------------------------------- + # EDI: Import + # ------------------------------------------------------------------------- + + def cron_l10n_it_edi_download_and_update(self): + """ Crons run with sudo(), with empty recordset. Remember that. """ + retrigger = False + for proxy_user in self.env['account_edi_proxy_client.user'].search([('proxy_type', '=', 'l10n_it_edi')]): + proxy_user = proxy_user.with_company(proxy_user.company_id) + if proxy_user.edi_mode != 'demo': + moves_to_check = self.search([ + ('company_id', '=', proxy_user.company_id.id), + ('l10n_it_edi_transaction', '!=', False), + ('l10n_it_edi_state', 'in', WAITING_STATES) + ]) + if moves_to_check: + moves_to_check._l10n_it_edi_update_send_state() + retrigger = retrigger or self._l10n_it_edi_download_invoices(proxy_user) + + # Retrigger download if there are still some on the server + if retrigger: + _logger.info('Retriggering "Receive invoices from the SdI"...') + self.env.ref('l10n_it_edi.ir_cron_l10n_it_edi_download_and_update')._trigger() + + def _l10n_it_edi_download_invoices(self, proxy_user): + """ Check the proxy for incoming invoices for a specified proxy user. + :return: True if there remain some invoices on the server to be downloaded, False otherwise. + """ + server_url = proxy_user._get_server_url() + + # Download invoices + invoices_data = {} + try: + invoices_data = proxy_user._make_request(f'{server_url}/api/l10n_it_edi/1/in/RicezioneInvoice', + params={'recipient_codice_fiscale': proxy_user.company_id.l10n_it_codice_fiscale}) + except AccountEdiProxyError as e: + _logger.error('Error while receiving invoices from the SdI: %s', e) + return False + + # Process the downloaded invoices + processed = self._l10n_it_edi_process_downloads(invoices_data, proxy_user) + if processed['proxy_acks']: + try: + proxy_user._make_request( + f'{server_url}/api/l10n_it_edi/1/ack', + params={'transaction_ids': processed['proxy_acks']}) + except AccountEdiProxyError as e: + _logger.error('Error while receiving file from the SdI: %s', e) + + return processed['retrigger'] + + def _l10n_it_edi_process_downloads(self, invoices_data, proxy_user): + """ Every attachment will be committed if stored succesfully. + Also moves will be committed one by one, even if imported incorrectly. + """ + proxy_acks = [] + retrigger = False + moves = self.env['account.move'] + + for id_transaction, invoice_data in invoices_data.items(): + + # The IAP server has a maximum number of documents it can send. + # If that maximum is reached, then we search for more + # by re-triggering the download cron, avoiding the timeout. + current_num = invoice_data.get('current_num', 0) + max_num = invoice_data.get('max_num', 0) + retrigger = retrigger or current_num == max_num > 0 + + # `_l10n_it_edi_create_move_from_attachment` will create an empty move + # then try and fill it with the content imported from the attachment. + # Should the import fail, thanks to try..except and savepoint, + # we will anyway end up with an empty `in_invoice` with the attachment posted on it. + if move := self.with_company(self.company_id)._l10n_it_edi_create_move_with_attachment( + invoice_data['filename'], + invoice_data['file'], + invoice_data['key'], + proxy_user, + ): + self.env.cr.commit() + moves |= move + proxy_acks.append(id_transaction) + + # Extend created moves with the related attachments and commit + for move in moves: + move._extend_with_attachments(move.l10n_it_edi_attachment_id, new=True) + self.env.cr.commit() + + return {"retrigger": retrigger, "proxy_acks": proxy_acks} + + def _l10n_it_edi_create_move_with_attachment(self, filename, content, key, proxy_user): + """ Creates a move and save an incoming file from the SdI as its attachment. + + :param filename: name of the file to be saved. + :param content: encrypted content of the file to be saved. + :param key: key to decrypt the file. + :param proxy_user: the AccountEdiProxyClientUser to use for decrypting the file + """ + + # Name should be unique, the invoice already exists + Attachment = self.env['ir.attachment'] + if Attachment.search_count([ + ('name', '=', filename), + ('res_model', '=', 'account.move'), + ('res_field', '=', 'l10n_it_edi_attachment_file'), + ], limit=1): + _logger.warning('E-invoice already exists: %s', filename) + return False + + # Decrypt with the server key + try: + decrypted_content = proxy_user._decrypt_data(content, key) + except Exception as e: # noqa: BLE001 + _logger.warning("Cannot decrypt e-invoice: %s, %s", filename, e) + return False + + # Create the attachment, an empty move, then attach the two and commit + move = self.create({}) + attachment = Attachment.create({ + 'name': filename, + 'raw': decrypted_content, + 'type': 'binary', + 'res_model': 'account.move', + 'res_id': move.id, + 'res_field': 'l10n_it_edi_attachment_file' + }) + move.with_context( + account_predictive_bills_disable_prediction=True, + no_new_invoice=True, + ).message_post(attachment_ids=attachment.ids) + + return move + + def _l10n_it_edi_search_partner(self, company, vat, codice_fiscale, email): + for domain in [vat and [('vat', 'ilike', vat)], + codice_fiscale and [('l10n_it_codice_fiscale', 'in', ('IT' + codice_fiscale, codice_fiscale))], + email and ['|', ('email', '=', email), ('l10n_it_pec_email', '=', email)]]: + if domain and (partner := self.env['res.partner'].search( + domain + self.env['res.partner']._check_company_domain(company), limit=1)): + return partner + return self.env['res.partner'] + + def _l10n_it_edi_search_tax_for_import(self, company, percentage, extra_domain=None): + """ Returns the VAT, Withholding or Pension Fund tax that suits the conditions given + and matches the percentage found in the XML for the company. """ + domain = [ + *self.env['account.tax']._check_company_domain(company), + ('amount', '=', percentage), + ('amount_type', '=', 'percent'), + ('type_tax_use', '=', 'purchase'), + ] + (extra_domain or []) + + # As we're importing vendor bills, we're excluding Reverse Charge Taxes + # which have a [100.0, 100.0, -100.0] repartition lines factor_percent distribution. + # We only allow for taxes that have all positive repartition lines factor_percent distribution. + taxes = self.env['account.tax'].search(domain).filtered( + lambda tax: all(rep_line.factor_percent >= 0 for rep_line in tax.invoice_repartition_line_ids)) + + return taxes[0] if taxes else taxes + + def _l10n_it_edi_get_extra_info(self, company, document_type, body_tree): + """ This function is meant to collect other information that has to be inserted on the invoice lines by submodules. + :return extra_info, messages_to_log""" + return {'simplified': self.env['account.move']._l10n_it_edi_is_simplified_document_type(document_type)}, [] + + def _l10n_it_edi_import_invoice(self, invoice, data, is_new): + """ Decodes a l10n_it_edi move into an Odoo move. + + :param data: the dictionary with the content to be imported + keys: 'filename', 'content', 'xml_tree', 'type', 'sort_weight' + :param is_new: whether the move is newly created or to be updated + :returns: the imported move + """ + tree = data['xml_tree'] + company = self.company_id + + # For unsupported document types, just assume in_invoice, and log that the type is unsupported + document_type = get_text(tree, '//DatiGeneraliDocumento/TipoDocumento') + move_type = self._l10n_it_edi_document_type_mapping().get(document_type, {}).get('import_type') + if not move_type: + move_type = "in_invoice" + _logger.info('Document type not managed: %s. Invoice type is set by default.', document_type) + + self.move_type = move_type + + # Collect extra info from the XML that may be used by submodules to further put information on the invoice lines + extra_info, message_to_log = self._l10n_it_edi_get_extra_info(company, document_type, tree) + + # Partner + vat = get_text(tree, '//CedentePrestatore//IdCodice') + codice_fiscale = get_text(tree, '//CedentePrestatore//CodiceFiscale') + email = get_text(tree, '//DatiTrasmissione//Email') + if partner := self._l10n_it_edi_search_partner(company, vat, codice_fiscale, email): + self.partner_id = partner + else: + message = Markup("
").join(( + _("Vendor not found, useful informations from XML file:"), + self._compose_info_message(tree, './/CedentePrestatore') + )) + message_to_log.append(message) + + # Numbering attributed by the transmitter + if progressive_id := get_text(tree, '//ProgressivoInvio'): + self.payment_reference = progressive_id + + # Document Number + if number := get_text(tree, './/DatiGeneraliDocumento//Numero'): + self.ref = number + + # Currency + if currency_str := get_text(tree, './/DatiGeneraliDocumento/Divisa'): + currency = self.env.ref('base.%s' % currency_str.upper(), raise_if_not_found=False) + if currency != self.env.company.currency_id and currency.active: + self.currency_id = currency + + # Date + if document_date := get_date(tree, './/DatiGeneraliDocumento/Data'): + self.invoice_date = document_date + else: + message_to_log.append(_("Document date invalid in XML file: %s", document_date)) + + # Stamp Duty + if stamp_duty := get_text(tree, './/DatiGeneraliDocumento/DatiBollo/ImportoBollo'): + self.l10n_it_stamp_duty = float(stamp_duty) + + # Comment + for narration in get_text(tree, './/DatiGeneraliDocumento//Causale', many=True): + self.narration = '%s%s
' % (self.narration or '', narration) + + # Informations relative to the purchase order, the contract, the agreement, + # the reception phase or invoices previously transmitted + # <2.1.2> - <2.1.6> + for document_type in ['DatiOrdineAcquisto', 'DatiContratto', 'DatiConvenzione', 'DatiRicezione', 'DatiFattureCollegate']: + for element in tree.xpath('.//DatiGenerali/' + document_type): + message = Markup("{} {}
{}").format(document_type, _("from XML file:"), self._compose_info_message(element, '.')) + message_to_log.append(message) + + # Dati DDT. <2.1.8> + if elements := tree.xpath('.//DatiGenerali/DatiDDT'): + message = Markup("
").join(( + _("Transport informations from XML file:"), + self._compose_info_message(tree, './/DatiGenerali/DatiDDT') + )) + message_to_log.append(message) + + # Due date. <2.4.2.5> + if due_date := get_date(tree, './/DatiPagamento/DettaglioPagamento/DataScadenzaPagamento'): + self.invoice_date_due = fields.Date.to_string(due_date) + else: + message_to_log.append(_("Payment due date invalid in XML file: %s", str(due_date))) + + # Information related to the purchase order <2.1.2> + if (po_refs := get_text(tree, '//DatiGenerali/DatiOrdineAcquisto/IdDocumento', many=True)): + self.invoice_origin = ", ".join(po_refs) + + # Total amount. <2.4.2.6> + if amount_total := sum([float(x) for x in get_text(tree, './/ImportoPagamento', many=True) if x]): + message_to_log.append(_("Total amount from the XML File: %s", amount_total)) + + # Bank account. <2.4.2.13> + if self.move_type not in ('out_invoice', 'in_refund'): + if acc_number := get_text(tree, './/DatiPagamento/DettaglioPagamento/IBAN'): + if self.partner_id and self.partner_id.commercial_partner_id: + bank = self.env['res.partner.bank'].search([ + ('acc_number', '=', acc_number), + ('partner_id', '=', self.partner_id.commercial_partner_id.id), + ('company_id', 'in', [self.company_id.id, False]) + ], order='company_id', limit=1) + else: + bank = self.env['res.partner.bank'].search([ + ('acc_number', '=', acc_number), + ('company_id', 'in', [self.company_id.id, False]) + ], order='company_id', limit=1) + if bank: + self.partner_bank_id = bank + else: + message = Markup("
").join(( + _("Bank account not found, useful informations from XML file:"), + self._compose_info_message(tree, [ + './/DatiPagamento//Beneficiario', + './/DatiPagamento//IstitutoFinanziario', + './/DatiPagamento//IBAN', + './/DatiPagamento//ABI', + './/DatiPagamento//CAB', + './/DatiPagamento//BIC', + './/DatiPagamento//ModalitaPagamento' + ]) + )) + message_to_log.append(message) + elif elements := tree.xpath('.//DatiPagamento/DettaglioPagamento'): + message = Markup("
").join(( + _("Bank account not found, useful informations from XML file:"), + self._compose_info_message(tree, './/DatiPagamento') + )) + message_to_log.append(message) + + # Invoice lines. <2.2.1> + tag_name = './/DettaglioLinee' if not extra_info['simplified'] else './/DatiBeniServizi' + for element in tree.xpath(tag_name): + move_line = self.invoice_line_ids.create({ + 'move_id': self.id, + 'tax_ids': [fields.Command.clear()]}) + if move_line: + message_to_log += self._l10n_it_edi_import_line(element, move_line, extra_info) + + # Global discount summarized in 1 amount + if discount_elements := tree.xpath('.//DatiGeneraliDocumento/ScontoMaggiorazione'): + taxable_amount = float(self.tax_totals['amount_untaxed']) + discounted_amount = taxable_amount + for discount_element in discount_elements: + discount_sign = 1 + if (discount_type := discount_element.xpath('.//Tipo')) and discount_type[0].text == 'MG': + discount_sign = -1 + if discount_amount := get_text(discount_element, './/Importo'): + discounted_amount -= discount_sign * float(discount_amount) + continue + if discount_percentage := get_text(discount_element, './/Percentuale'): + discounted_amount *= 1 - discount_sign * float(discount_percentage) / 100 + + general_discount = discounted_amount - taxable_amount + sequence = len(elements) + 1 + + self.invoice_line_ids = [Command.create({ + 'sequence': sequence, + 'name': 'SCONTO' if general_discount < 0 else 'MAGGIORAZIONE', + 'price_unit': general_discount, + })] + + for element in tree.xpath('.//Allegati'): + attachment_64 = self.env['ir.attachment'].create({ + 'name': get_text(element, './/NomeAttachment'), + 'datas': str.encode(get_text(element, './/Attachment')), + 'type': 'binary', + 'res_model': 'account.move', + 'res_id': self.id, + }) + + # no_new_invoice to prevent from looping on the.message_post that would create a new invoice without it + self.with_context(no_new_invoice=True).sudo().message_post( + body=(_("Attachment from XML")), + attachment_ids=[attachment_64.id], + ) + + for message in message_to_log: + self.sudo().message_post(body=message) + return self + + def _l10n_it_edi_import_line(self, element, move_line, extra_info=None): + extra_info = extra_info or {} + company = move_line.company_id + partner = move_line.partner_id + message_to_log = [] + + # Sequence. + line_elements = element.xpath('.//NumeroLinea') + if line_elements: + move_line.sequence = int(line_elements[0].text) + + # Product. + if elements_code := element.xpath('.//CodiceArticolo'): + for element_code in elements_code: + type_code = element_code.xpath('.//CodiceTipo')[0] + code = element_code.xpath('.//CodiceValore')[0] + product = self.env['product.product'].search([('barcode', '=', code.text)]) + if (product and type_code.text == 'EAN'): + move_line.product_id = product + break + if partner: + product_supplier = self.env['product.supplierinfo'].search([('partner_id', '=', partner.id), ('product_code', '=', code.text)], limit=2) + if product_supplier and len(product_supplier) == 1 and product_supplier.product_id: + move_line.product_id = product_supplier.product_id + break + if not move_line.product_id: + for element_code in elements_code: + code = element_code.xpath('.//CodiceValore')[0] + product = self.env['product.product'].search([('default_code', '=', code.text)], limit=2) + if product and len(product) == 1: + move_line.product_id = product + break + + # Name and Quantity. + move_line.name = " ".join(get_text(element, './/Descrizione').split()) + move_line.quantity = float(get_text(element, './/Quantita') or '1') + + # Taxes + percentage = None + if not extra_info['simplified']: + percentage = get_float(element, './/AliquotaIVA') + if price_unit := get_float(element, './/PrezzoUnitario'): + move_line.price_unit = price_unit + elif amount := get_float(element, './/Importo'): + percentage = get_float(element, './/Aliquota') + if not percentage and (tax_amount := get_float(element, './/Imposta')): + percentage = round(tax_amount / (amount - tax_amount) * 100) + move_line.price_unit = amount / (1 + percentage / 100) + + move_line.tax_ids = [] + if percentage is not None: + conditions = [('l10n_it_has_exoneration', '=', False)] + if l10n_it_kind_exoneration := get_text(element, './/Natura'): + conditions = [('l10n_it_kind_exoneration', '=', l10n_it_kind_exoneration)] + if tax := self._l10n_it_edi_search_tax_for_import(company, percentage, conditions): + move_line.tax_ids += tax + else: + message = Markup("
").join(( + _("Tax not found for line with description '%s'", move_line.name), + self._compose_info_message(element, '.') + )) + message_to_log.append(message) + + # Discounts + if elements := element.xpath('.//ScontoMaggiorazione'): + element = elements[0] + # Special case of only 1 percentage discount + if len(elements) == 1: + if discount_percentage := get_float(element, './/Percentuale'): + discount_type = get_text(element, './/Tipo') + discount_sign = -1 if discount_type == 'MG' else 1 + move_line.discount = discount_sign * discount_percentage + # Discounts in cascade summarized in 1 percentage + else: + total = get_float(element, './/PrezzoTotale') + discount = 100 - (100 * total) / (move_line.quantity * move_line.price_unit) + move_line.discount = discount + + return message_to_log + + def _l10n_it_edi_format_errors(self, header, errors): + return Markup('{}
    {}
').format( + nl2br_enclose(header, 'span') if header else '', + Markup().join(nl2br_enclose(' '.join(error.split()), 'li') for error in errors) + ) + + def _compose_info_message(self, tree, tags): + result = "" + for tag in tags if isinstance(tags, list) else [tags]: + for el in tree.xpath(tag): + result += self._l10n_it_edi_format_errors("", [f'{subel.tag}: {subel.text}' for subel in el.iter()]) + return result + + # ------------------------------------------------------------------------- + # EDI: Export + # ------------------------------------------------------------------------- + + def _l10n_it_edi_export_data_check(self): + errors = self._l10n_it_edi_base_export_data_check() + if not self._l10n_it_edi_is_simplified(): + errors += self._l10n_it_edi_export_buyer_data_check() + return errors + + def _l10n_it_edi_format_export_data_errors(self): + messages = ( + self._l10n_it_edi_format_errors(move.name + ":" if len(self) > 1 else False, move_warnings) + for move in self if (move_warnings := move._l10n_it_edi_export_data_check()) + ) + return Markup("
").join(messages) or False + + def _l10n_it_edi_base_export_data_check(self): + errors = [] + seller = self.company_id + buyer = self.commercial_partner_id + is_self_invoice = self.l10n_it_edi_is_self_invoice + if is_self_invoice: + seller, buyer = buyer, seller + + # <1.1.1.1> + if not seller.country_id: + errors.append(_("%s must have a country", seller.display_name)) + + # <1.1.1.2> + if not self.company_id.vat: + errors.append(_("%s must have a VAT number", seller.display_name)) + if seller.vat and len(seller.vat) > 30: + errors.append(_("The maximum length for VAT number is 30. %s have a VAT number too long: %s.", seller.display_name, seller.vat)) + + # <1.2.1.2> + if not is_self_invoice and not seller.l10n_it_codice_fiscale: + errors.append(_("%s must have a codice fiscale number", seller.display_name)) + + # <1.2.1.8> + if not is_self_invoice and not seller.l10n_it_tax_system: + errors.append(_("The seller's company must have a tax system.")) + + # <1.2.2> + if not seller.street and not seller.street2: + errors.append(_("%s must have a street.", seller.display_name)) + if not seller.zip: + errors.append(_("%s must have a post code.", seller.display_name)) + elif len(seller.zip) != 5 and seller.country_id.code == 'IT': + errors.append(_("%s must have a post code of length 5.", seller.display_name)) + if not seller.city: + errors.append(_("%s must have a city.", seller.display_name)) + if not seller.country_id: + errors.append(_("%s must have a country.", seller.display_name)) + + if not is_self_invoice and seller.l10n_it_has_tax_representative and not seller.l10n_it_tax_representative_partner_id.vat: + errors.append(_("Tax representative partner %s of %s must have a tax number.", seller.l10n_it_tax_representative_partner_id.display_name, seller.display_name)) + + # <1.4.1> + if not buyer.vat and not buyer.l10n_it_codice_fiscale and buyer.country_id.code == 'IT': + errors.append(_("The buyer, %s, or his company must have a VAT number and/or a tax code (Codice Fiscale).", buyer.display_name)) + + if is_self_invoice and self._l10n_it_edi_services_or_goods() == 'both': + errors.append(_("Cannot apply Reverse Charge to a bill which contains both services and goods.")) + + if is_self_invoice and not buyer.partner_id.l10n_it_pa_index: + errors.append(_("Vendor bills sent as self-invoices to the SdI require a valid PA Index (Codice Destinatario) on the company's contact.")) + + for tax_line in self.line_ids.filtered(lambda line: line.tax_line_id): + if not tax_line.tax_line_id.l10n_it_kind_exoneration and tax_line.tax_line_id.amount == 0: + errors.append(_("%s has an amount of 0.0, you must indicate the kind of exoneration.", tax_line.name)) + + errors += self._l10n_it_edi_export_taxes_data_check() + + return errors + + def _l10n_it_edi_export_taxes_data_check(self): + """ + Can be overridden by submodules like l10n_it_edi_withholding, which also allows for withholding and pension_fund taxes. + """ + errors = [] + for invoice_line in self.invoice_line_ids.filtered(lambda x: x.display_type == 'product'): + all_taxes = invoice_line.tax_ids.flatten_taxes_hierarchy() + vat_taxes = all_taxes.filtered(lambda t: t.amount_type == 'percent' and t.amount >= 0) + if len(vat_taxes) != 1: + errors.append(_("In line %s, you must select one and only one VAT tax.", invoice_line.name)) + return errors + + def _l10n_it_edi_export_buyer_data_check(self): + errors = [] + buyer = self.commercial_partner_id + + # <1.4.2> + if not buyer.street and not buyer.street2: + errors.append(_("%s must have a street.", buyer.display_name)) + if not buyer.country_id: + errors.append(_("%s must have a country.", buyer.display_name)) + if not buyer.zip: + errors.append(_("%s must have a post code.", buyer.display_name)) + elif len(buyer.zip) != 5 and buyer.country_id.code == 'IT': + errors.append(_("%s must have a post code of length 5.", buyer.display_name)) + if not buyer.city: + errors.append(_("%s must have a city.", buyer.display_name)) + + for tax_line in self.line_ids.filtered(lambda line: line.tax_line_id): + if not tax_line.tax_line_id.l10n_it_kind_exoneration and tax_line.tax_line_id.amount == 0: + errors.append(_("%s has an amount of 0.0, you must indicate the kind of exoneration.", tax_line.name)) + + return errors + + def _l10n_it_edi_get_formatters(self): + def format_alphanumeric(text, maxlen=None): + if not text: + return False + text = text.encode('latin-1', 'replace').decode('latin-1') + if maxlen and maxlen > 0: + text = text[:maxlen] + elif maxlen and maxlen < 0: + text = text[maxlen:] + return text + + def format_date(dt): + # Format the date in the italian standard. + dt = dt or datetime.now() + return dt.strftime('%Y-%m-%d') + + def format_monetary(number, currency): + # Format the monetary values to avoid trailing decimals (e.g. 90.85000000000001). + return float_repr(number, min(2, currency.decimal_places)) + + def format_numbers(number): + #format number to str with between 2 and 8 decimals (event if it's .00) + number_splited = str(number).split('.') + if len(number_splited) == 1: + return "%.02f" % number + + cents = number_splited[1] + if len(cents) > 8: + return "%.08f" % number + return float_repr(number, max(2, len(cents))) + + def format_numbers_two(number): + #format number to str with 2 (event if it's .00) + return "%.02f" % number + + def format_phone(number): + if not number: + return False + number = number.replace(' ', '').replace('/', '').replace('.', '') + if len(number) > 4 and len(number) < 13: + return format_alphanumeric(number) + return False + + def format_address(street, street2, maxlen=60): + street, street2 = street or '', street2 or '' + if street and len(street) >= maxlen: + street2 = '' + sep = ' ' if street and street2 else '' + return format_alphanumeric(f"{street}{sep}{street2}", maxlen) + + return { + 'format_date': format_date, + 'format_monetary': format_monetary, + 'format_numbers': format_numbers, + 'format_numbers_two': format_numbers_two, + 'format_phone': format_phone, + 'format_alphanumeric': format_alphanumeric, + 'format_address': format_address, + } + + def _l10n_it_edi_render_xml(self, pdf_values=None): + ''' Create the xml file content. + :return: The XML content as bytestring. + ''' + qweb_template_name = ( + 'l10n_it_edi.account_invoice_it_FatturaPA_export' if not self._l10n_it_edi_is_simplified() + else 'l10n_it_edi.account_invoice_it_simplified_FatturaPA_export') + xml_content = self.env['ir.qweb']._render(qweb_template_name, { + **self._l10n_it_edi_get_values(pdf_values), + **self._l10n_it_edi_get_formatters()}) + xml_node = cleanup_xml_node(xml_content, remove_blank_nodes=False) + return etree.tostring(xml_node, xml_declaration=True, encoding='UTF-8') + + def _l10n_it_edi_get_attachment_values(self, pdf_values=None): + self.ensure_one() + return { + 'name': self._l10n_it_edi_generate_filename(), + 'type': 'binary', + 'mimetype': 'application/xml', + 'description': _('IT EDI e-move: %s', self.move_type), + 'company_id': self.company_id.id, + 'res_id': self.id, + 'res_model': self._name, + 'res_field': 'l10n_it_edi_attachment_file', + 'raw': self._l10n_it_edi_render_xml(pdf_values=pdf_values), + } + + def _l10n_it_edi_generate_filename(self): + '''Returns a name conform to the Fattura pa Specifications: + See ES documentation 2.2 + ''' + a = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + # Each company should have its own filename sequence. If it does not exist, create it + n = self.env['ir.sequence'].with_company(self.company_id).next_by_code('l10n_it_edi.fattura_filename') + if not n: + # The offset is used to avoid conflicts with existing filenames + offset = 62 ** 4 + sequence = self.env['ir.sequence'].sudo().create({ + 'name': 'FatturaPA Filename Sequence', + 'code': 'l10n_it_edi.fattura_filename', + 'company_id': self.company_id.id, + 'number_next': offset, + }) + n = sequence._next() + # The n is returned as a string, but we require an int + n = int(''.join(filter(lambda c: c.isdecimal(), n))) + + progressive_number = "" + while n: + (n, m) = divmod(n, len(a)) + progressive_number = a[m] + progressive_number + + return '%(country_code)s%(codice)s_%(progressive_number)s.xml' % { + 'country_code': self.company_id.country_id.code, + 'codice': self.company_id.partner_id._l10n_it_edi_normalized_codice_fiscale(), + 'progressive_number': progressive_number.zfill(5), + } + + def _l10n_it_edi_send(self, attachments_vals): + files_to_upload = [] + filename_move = {} + + # Setup moves for sending + for move in self: + attachment_vals = attachments_vals[move] + filename = attachment_vals['name'] + content = b64encode(attachment_vals['raw']).decode() + move.l10n_it_edi_header = False + if move.commercial_partner_id._l10n_it_edi_is_public_administration(): + move.l10n_it_edi_state = 'requires_user_signature' + move.l10n_it_edi_transaction = False + move.sudo().message_post(body=nl2br(escape(_( + "Sending invoices to Public Administration partners is not supported.\n" + "The IT EDI XML file is generated, please sign the document and upload it " + "through the 'Fatture e Corrispettivi' portal of the Tax Agency." + )))) + else: + move.l10n_it_edi_state = 'being_sent' + files_to_upload.append({'filename': filename, 'xml': content}) + filename_move[filename] = move + + # Upload files + try: + results = self._l10n_it_edi_upload(files_to_upload) + except AccountEdiProxyError as e: + messages_to_log = [] + for filename in filename_move: + unsent_move = filename_move[filename] + unsent_move.l10n_it_edi_state = False + text_message = _("Error uploading the e-invoice file %s.\n%s", filename, e.message) + html_message = nl2br(escape(text_message)) + unsent_move.l10n_it_edi_header = text_message + unsent_move.sudo().message_post(body=html_message) + messages_to_log.append(text_message) + raise UserError("\n".join(messages_to_log)) from e + + # Handle results + for filename, vals in results.items(): + sent_move = filename_move[filename] + if 'error' in vals: + sent_move.l10n_it_edi_state = False + sent_move.l10n_it_edi_transaction = False + message = nl2br(escape(_("Error uploading the e-invoice file %s.\n%s", filename, vals['error']))) + else: + is_demo = vals['id_transaction'] == 'demo' + sent_move.l10n_it_edi_state = 'processing' + sent_move.l10n_it_edi_transaction = vals['id_transaction'] + message = ( + _("We are simulating the sending of the e-invoice file %s, as we are in demo mode.", filename) + if is_demo else _("The e-invoice file %s was sent to the SdI for processing.", filename)) + sent_move.l10n_it_edi_header = message + sent_move.sudo().message_post(body=message) + + def _l10n_it_edi_upload(self, files): + '''Upload files to the SdI. + + :param files: A list of dictionary {filename, base64_xml}. + :returns: A dictionary. + * message: Message from fatturapa. + * transactionId: The fatturapa ID of this request. + * error: An eventual error. + ''' + if not files: + return {} + proxy_user = self.company_id.l10n_it_edi_proxy_user_id + proxy_user.ensure_one() + if proxy_user.edi_mode == 'demo': + return {file_data['filename']: {'id_transaction': 'demo'} for file_data in files} + + ERRORS = {'EI01': _('Attached file is empty'), + 'EI02': _('Service momentarily unavailable'), + 'EI03': _('Unauthorized user')} + + server_url = proxy_user._get_server_url() + results = proxy_user._make_request( + f'{server_url}/api/l10n_it_edi/1/out/SdiRiceviFile', + params={'files': files}) + + for filename, vals in results.items(): + if 'error' in vals: + results[filename]['error'] = ERRORS.get(vals.get('error'), _("Unknown error")) + + return results + + # ------------------------------------------------------------------------- + # EDI: Update notifications + # ------------------------------------------------------------------------- + + def _l10n_it_edi_update_send_state(self): + ''' Check if the current invoices have been processed by the SdI. ''' + proxy_user = self.company_id.l10n_it_edi_proxy_user_id + if proxy_user.edi_mode == 'demo': + for move in self: + filename = move.l10n_it_edi_attachment_id and move.l10n_it_edi_attachment_id.name or '???' + self._l10n_it_edi_write_send_state( + transformed_notification={ + 'l10n_it_edi_state': 'forwarded', + 'l10n_it_edi_transaction': f'demo_{uuid.uuid4()}', + 'send_ack_to_edi_proxy': False, + 'date': fields.Date.today(), + 'filename': filename}, + message=_("The e-invoice file %s has been sent in Demo EDI mode.", filename)) + return + + server_url = proxy_user._get_server_url() + try: + notifications = proxy_user._make_request( + f'{server_url}/api/l10n_it_edi/1/in/TrasmissioneFatture', + params={'ids_transaction': self.mapped("l10n_it_edi_transaction")}) + except AccountEdiProxyError as pe: + raise UserError(_("An error occurred while downloading updates from the Proxy Server: (%s) %s", pe.code, pe.message)) from pe + + for _id_transaction, notification in notifications.items(): + encrypted_update_content = notification.get('file') + encryption_key = notification.get('key') + if (encrypted_update_content and encryption_key): + notification['xml_content'] = proxy_user._decrypt_data(encrypted_update_content, encryption_key) + + acks = {'transaction_ids': [], 'states': []} + for move in self: + notification = notifications[move.l10n_it_edi_transaction] + parsed_notification = move._l10n_it_edi_parse_notification(notification) + transformed_notification = move._l10n_it_edi_transform_notification(parsed_notification) + message = move._l10n_it_edi_get_message(transformed_notification) + move._l10n_it_edi_write_send_state(transformed_notification, message) + if ( + transformed_notification.get('send_ack_to_edi_proxy') + and (id_transaction_to_ack := transformed_notification.get('l10n_it_edi_transaction')) + and (ack_state := transformed_notification.get('l10n_it_edi_state')) + ): + acks['transaction_ids'].append(id_transaction_to_ack) + acks['states'].append(ack_state) + + if acks: + transaction_ids = acks['transaction_ids'] + states = acks['states'] + try: + proxy_user._make_request( + f'{server_url}/api/l10n_it_edi/1/ack', + params={'transaction_ids': transaction_ids, 'states': states}) + except AccountEdiProxyError as pe: + raise UserError(_("An error occurred while downloading updates from the Proxy Server: (%s) %s", pe.code, pe.message)) from pe + + def _l10n_it_edi_parse_notification(self, notification): + sdi_state = notification.get('state', '') + if not (xml_content := notification.get('xml_content')): + return {'sdi_state': sdi_state} + + decrypted_update_content = etree.fromstring(xml_content) + outcome = get_text(decrypted_update_content, './/Esito') + date_arrival = get_datetime(decrypted_update_content, './/DataOraRicezione') or fields.Date.today() + errors = [( + get_text(error_element, '//Codice'), + get_text(error_element, '//Descrizione'), + ) for error_element in decrypted_update_content.xpath('//Errore')] + filename = get_text(decrypted_update_content, './/NomeFile') + + return { + 'sdi_state': sdi_state, + 'errors': errors, + 'outcome': outcome, + 'date': date_arrival, + 'filename': filename, + } + + def _l10n_it_edi_transform_notification(self, parsed_notification): + """ Reads the notification XML coming from the EDI Proxy Server + Recovers information about the new state. + Computes whether the EDI Proxy Server is to be acked, + and whether the id_transaction has to be reset. + """ + self.ensure_one() + state_map = { + 'not_found': False, + 'awaiting_outcome': 'processing', + 'notificaScarto': 'rejected', + 'ricevutaConsegna': 'forwarded', + 'forward_attempt': 'forward_attempt', + 'notificaMancataConsegna': 'forward_failed', + ('notificaEsito', 'EC01'): 'accepted_by_pa_partner', + ('notificaEsito', 'EC02'): 'rejected_by_pa_partner', + 'notificaDecorrenzaTermini': 'accepted_by_pa_partner_after_expiry', + } + sdi_state = parsed_notification['sdi_state'] + filename = parsed_notification.get('filename') + errors = parsed_notification.get('errors', []) + date = parsed_notification.get('date', fields.Date.today()) + if not filename and self.l10n_it_edi_attachment_id: + filename = self.l10n_it_edi_attachment_id.name + outcome = parsed_notification.get('outcome', False) + if not outcome: + new_state = state_map.get(sdi_state, False) + else: + new_state = state_map.get((sdi_state, outcome), False) + + parsed_notification.update({ + 'l10n_it_edi_state': new_state, + 'l10n_it_edi_transaction': False if new_state in (False, 'rejected') else self.l10n_it_edi_transaction, + 'send_ack_to_edi_proxy': bool(new_state), + 'date': date, + 'errors': errors, + 'filename': filename, + }) + return parsed_notification + + def _l10n_it_edi_write_send_state(self, transformed_notification, message): + """ Update the record with the data coming from the IAP server. + Eventually post the message. + Commit the transaction. + """ + self.ensure_one() + old_state = self.l10n_it_edi_state + new_state = transformed_notification['l10n_it_edi_state'] + self.write({ + 'l10n_it_edi_state': new_state, + 'l10n_it_edi_transaction': transformed_notification['l10n_it_edi_transaction'], + 'l10n_it_edi_header': message or False, + }) + + if message and old_state != new_state: + self.with_context(no_new_invoice=True).sudo().message_post(body=message) + + if new_state == 'rejected': + self.l10n_it_edi_attachment_file = False + + self.env.cr.commit() + + def _l10n_it_edi_get_message(self, transformed_notification): + """ The status change will be notified in the chatter of the move. + Compute the message from the notification information coming from the EDI Proxy Server + """ + self.ensure_one() + partner = self.commercial_partner_id + partner_name = partner.display_name + filename = transformed_notification['filename'] + new_state = transformed_notification['l10n_it_edi_state'] + if new_state == 'rejected': + DUPLICATE_MOVE = '00404' + DUPLICATE_FILENAME = '00002' + error_descriptions = [] + for error_code, error_description in transformed_notification['errors']: + error_description_copy = error_description + if error_code == DUPLICATE_MOVE: + error_description_copy = _( + "The e-invoice file %s is duplicated.\n" + "Original message from the SdI: %s", + filename, error_description_copy) + elif error_code == DUPLICATE_FILENAME: + error_description_copy = _( + "The e-invoice filename %s is duplicated. Please check the FatturaPA Filename sequence.\n" + "Original message from the SdI: %s", + filename, error_description_copy) + error_descriptions.append(error_description_copy) + + return self._l10n_it_edi_format_errors(_('The e-invoice has been refused by the SdI.'), error_descriptions) + + elif partner._l10n_it_edi_is_public_administration(): + pa_specific_map = { + 'forwarded': nl2br(escape(_( + "The e-invoice file %s was succesfully sent to the SdI.\n" + "%s has 15 days to accept or reject it.", + filename, partner_name))), + 'forward_attempt': nl2br(escape(_( + "The e-invoice file %s can't be forward to %s (Public Administration) by the SdI at the moment.\n" + "It will try again for 10 days, after which it will be considered accepted, but " + "you will still have to send it by post or e-mail.", + filename, partner_name))), + 'accepted_by_pa_partner_after_expiry': nl2br(escape(_( + "The e-invoice file %s is succesfully sent to the SdI. The invoice is now considered fiscally relevant.\n" + "The %s (Public Administration) had 15 days to either accept or refused this document," + "but since they did not reply, it's now considered accepted.", + filename, partner_name))), + 'rejected_by_pa_partner': nl2br(escape(_( + "The e-invoice file %s has been refused by %s (Public Administration).\n" + "You have 5 days from now to issue a full refund for this invoice, " + "then contact the PA partner to create a new one according to their " + "requests and submit it.", + filename, partner_name))), + 'accepted_by_pa_partner': _( + "The e-invoice file %s has been accepted by %s (Public Administration), a payment will be issued soon", + filename, partner_name), + } + if pa_specific_message := pa_specific_map.get(new_state): + return pa_specific_message + + new_state_messages_map = { + False: _( + "The e-invoice file %s has not been found on the EDI Proxy server.", filename), + 'processing': nl2br(escape(_( + "The e-invoice file %s was sent to the SdI for validation.\n" + "It is not yet considered accepted, please wait further notifications.", + filename))), + 'forwarded': _( + "The e-invoice file %s was accepted and succesfully forwarded it to %s by the SdI.", + filename, partner_name), + 'forward_attempt': nl2br(escape(_( + "The e-invoice file %s has been accepted by the SdI.\n" + "The SdI is trying to forward it to %s.\n" + "It will try for up to 2 days, after which you'll eventually " + "need to send it the invoice to the partner by post or e-mail.", + filename, partner_name))), + 'forward_failed': nl2br(escape(_( + "The e-invoice file %s couldn't be forwarded to %s.\n" + "Please remember to send it via post or e-mail.", + filename, partner_name))) + } + return new_state_messages_map.get(new_state) diff --git a/addons/l10n_it_edi/models/account_tax.py b/addons/l10n_it_edi/models/account_tax.py new file mode 100644 index 00000000000..9f6ae34d1f3 --- /dev/null +++ b/addons/l10n_it_edi/models/account_tax.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError, UserError + +class AccountTax(models.Model): + _inherit = "account.tax" + + l10n_it_vat_due_date = fields.Selection( + selection=[ + ("I", "[I] IVA ad esigibilità immediata"), + ("D", "[D] IVA ad esigibilità differita"), + ("S", "[S] Scissione dei pagamenti")], + default="I", + string="VAT due date", + ) + l10n_it_has_exoneration = fields.Boolean(string="Has exoneration of tax (Italy)", help="Tax has a tax exoneration.") + l10n_it_kind_exoneration = fields.Selection( + selection=[ + ("N1", "[N1] Escluse ex art. 15"), + ("N2", "[N2] Non soggette"), + ("N2.1", "[N2.1] Non soggette ad IVA ai sensi degli artt. Da 7 a 7-septies del DPR 633/72"), + ("N2.2", "[N2.2] Non soggette - altri casi"), + ("N3", "[N3] Non imponibili"), + ("N3.1", "[N3.1] Non imponibili - esportazioni"), + ("N3.2", "[N3.2] Non imponibili - cessioni intracomunitarie"), + ("N3.3", "[N3.3] Non imponibili - cessioni verso San Marino"), + ("N3.4", "[N3.4] Non imponibili - operazioni assimilate alle cessioni all'esportazione"), + ("N3.5", "[N3.5] Non imponibili - a seguito di dichiarazioni d'intento"), + ("N3.6", "[N3.6] Non imponibili - altre operazioni che non concorrono alla formazione del plafond"), + ("N4", "[N4] Esenti"), + ("N5", "[N5] Regime del margine / IVA non esposta in fattura"), + ("N6", "[N6] Inversione contabile (per le operazioni in reverse charge ovvero nei casi di autofatturazione per acquisti extra UE di servizi ovvero per importazioni di beni nei soli casi previsti)"), + ("N6.1", "[N6.1] Inversione contabile - cessione di rottami e altri materiali di recupero"), + ("N6.2", "[N6.2] Inversione contabile - cessione di oro e argento puro"), + ("N6.3", "[N6.3] Inversione contabile - subappalto nel settore edile"), + ("N6.4", "[N6.4] Inversione contabile - cessione di fabbricati"), + ("N6.5", "[N6.5] Inversione contabile - cessione di telefoni cellulari"), + ("N6.6", "[N6.6] Inversione contabile - cessione di prodotti elettronici"), + ("N6.7", "[N6.7] Inversione contabile - prestazioni comparto edile esettori connessi"), + ("N6.8", "[N6.8] Inversione contabile - operazioni settore energetico"), + ("N6.9", "[N6.9] Inversione contabile - altri casi"), + ("N7", "[N7] IVA assolta in altro stato UE (prestazione di servizi di telecomunicazioni, tele-radiodiffusione ed elettronici ex art. 7-octies, comma 1 lett. a, b, art. 74-sexies DPR 633/72)") + ], + string="Exoneration", + help="Exoneration type", + default="N1", + ) + l10n_it_law_reference = fields.Char(string="Law Reference", size=100) + + @api.constrains('l10n_it_has_exoneration', + 'l10n_it_kind_exoneration', + 'l10n_it_law_reference', + 'amount', + 'invoice_repartition_line_ids', + 'refund_repartition_line_ids') + def _l10n_it_edi_check_exoneration_with_no_tax(self): + for tax in self: + if tax.l10n_it_has_exoneration: + if not tax.l10n_it_kind_exoneration or not tax.l10n_it_law_reference or tax.amount != 0: + raise ValidationError(_("If the tax has exoneration, you must enter a kind of exoneration, a law reference and the amount of the tax must be 0.0.")) + if tax.l10n_it_kind_exoneration == 'N6' and tax._l10n_it_is_split_payment(): + raise UserError(_("Split Payment is not compatible with exoneration of kind 'N6'")) + + def _l10n_it_filter_kind(self, kind): + """ This can be overridden by l10n_it_edi_withholding for different kind of taxes (withholding, pension_fund).""" + return self if kind == 'vat' else self.env['account.tax'] + + def _l10n_it_is_split_payment(self): + """ Split payment means that the Public Administration buyer will pay VAT + to the tax agency instead of the vendor + """ + self.ensure_one() + + tax_tags = self.get_tax_tags(is_refund=False, repartition_type='tax') + if not tax_tags: + return False + + it_tax_report_ve38_lines = self.env['account.report.line'].search([ + ('report_id.country_id.code', '=', 'IT'), + ('code', '=', 'VE38'), + ]) + if not it_tax_report_ve38_lines: + return False + + ve38_lines_tags = it_tax_report_ve38_lines.expression_ids._get_matching_tags() + return bool(tax_tags & ve38_lines_tags) diff --git a/addons/l10n_it_edi/models/ir_attachment.py b/addons/l10n_it_edi/models/ir_attachment.py index 331d23fc3b0..212ac57a75f 100644 --- a/addons/l10n_it_edi/models/ir_attachment.py +++ b/addons/l10n_it_edi/models/ir_attachment.py @@ -1,49 +1,60 @@ # -*- coding: utf-8 -*- + from odoo import api, models from odoo.addons.l10n_it_edi.tools.remove_signature import remove_signature from lxml import etree import logging +import re _logger = logging.getLogger(__name__) +FATTURAPA_FILENAME_RE = "[A-Z]{2}[A-Za-z0-9]{2,28}_[A-Za-z0-9]{0,5}.((?i:xml.p7m|xml))" + class IrAttachment(models.Model): _inherit = 'ir.attachment' - @api.model - def _decode_edi_xml_p7m(self, filename, content): - """Decodes an xml.p7m into a list of one dictionary representing an attachment. - :returns: A list with a dictionary. + def _decode_edi_l10n_it_edi(self, name, content): + """ Decodes a into a list of one dictionary representing an attachment. + :returns: A list with a dictionary. """ - decoded_content = remove_signature(content) - if not decoded_content: - return + if not (decoded_content := remove_signature(content)): + return [] + parser = etree.XMLParser(recover=True, resolve_entities=False) try: - parser = etree.XMLParser(recover=True) xml_tree = etree.fromstring(decoded_content, parser) - except Exception as e: + except etree.ParseError as e: _logger.exception("Error when converting the xml content to etree: %s", e) return [] - to_process = [] - if xml_tree is not None: - to_process.append({ - 'filename': filename, - 'content': content, - 'xml_tree': xml_tree, - 'type': 'xml_p7m', - 'sort_weight': 11, - }) - return to_process + return [{ + 'filename': name, + 'content': content, + 'xml_tree': xml_move_tree, + 'type': 'l10n_it_edi', + 'sort_weight': 11, + } for xml_move_tree in xml_tree.xpath('//FatturaElettronicaBody')] + + def _is_l10n_it_edi_import_file(self): + is_xml = ( + self.name.endswith('.xml') + or self.mimetype.endswith('/xml') + or 'text/plain' in self.mimetype + and self.raw + and self.raw.startswith(b'{y}" for x, y in xpaths.items()]) - ) - def _assert_import_invoice(self, filename, expected_values_list, xml_to_apply=None): path = f'{self.module}/tests/import_xmls/{filename}' with tools.file_open(path, mode='rb') as fd: @@ -138,9 +128,8 @@ class TestItEdi(AccountEdiTestCommon): 'name': filename, 'raw': import_content, }) - invoices = self.company_data_2['default_journal_purchase']\ - .with_context(default_move_type='in_invoice')\ - ._create_document_from_attachment(attachment.ids) + purchase_journal = self.company_data_2['default_journal_purchase'].with_context(default_move_type='in_invoice') + invoices = purchase_journal._create_document_from_attachment(attachment.ids) expected_invoice_values_list = [] expected_invoice_line_ids_values_list = [] diff --git a/addons/l10n_it_edi/tests/export_xmls/bill_reverse_charge.xml b/addons/l10n_it_edi/tests/export_xmls/bill_reverse_charge.xml index bce9af29a7b..3b0a1b986dc 100644 --- a/addons/l10n_it_edi/tests/export_xmls/bill_reverse_charge.xml +++ b/addons/l10n_it_edi/tests/export_xmls/bill_reverse_charge.xml @@ -106,10 +106,5 @@ 1600.80 - - BILL2022030001.pdf - PDF - ___ignore___ - diff --git a/addons/l10n_it_edi/tests/export_xmls/bill_reverse_charge_2.xml b/addons/l10n_it_edi/tests/export_xmls/bill_reverse_charge_2.xml index cf77da88da7..aee789a8ff9 100644 --- a/addons/l10n_it_edi/tests/export_xmls/bill_reverse_charge_2.xml +++ b/addons/l10n_it_edi/tests/export_xmls/bill_reverse_charge_2.xml @@ -102,10 +102,5 @@ 1600.80 - - BILL2022030001.pdf - PDF - ___ignore___ - diff --git a/addons/l10n_it_edi/tests/export_xmls/bill_reverse_charge_san_marino.xml b/addons/l10n_it_edi/tests/export_xmls/bill_reverse_charge_san_marino.xml index 71f0c6a076c..ed025cdb69b 100644 --- a/addons/l10n_it_edi/tests/export_xmls/bill_reverse_charge_san_marino.xml +++ b/addons/l10n_it_edi/tests/export_xmls/bill_reverse_charge_san_marino.xml @@ -102,10 +102,5 @@ 1600.80 - - BILL2022030001.pdf - PDF - ___ignore___ - diff --git a/addons/l10n_it_edi/tests/export_xmls/credit_note_negative_price.xml b/addons/l10n_it_edi/tests/export_xmls/credit_note_negative_price.xml index 1cf0c81c38a..e371e10fffb 100644 --- a/addons/l10n_it_edi/tests/export_xmls/credit_note_negative_price.xml +++ b/addons/l10n_it_edi/tests/export_xmls/credit_note_negative_price.xml @@ -107,10 +107,5 @@ I - - RINV202200001.pdf - PDF - ___ignore___ - diff --git a/addons/l10n_it_edi/tests/export_xmls/credit_note_reverse_charge.xml b/addons/l10n_it_edi/tests/export_xmls/credit_note_reverse_charge.xml index fe00f458ac9..62f78a7cfbd 100644 --- a/addons/l10n_it_edi/tests/export_xmls/credit_note_reverse_charge.xml +++ b/addons/l10n_it_edi/tests/export_xmls/credit_note_reverse_charge.xml @@ -106,10 +106,5 @@ 1600.80 - - RBILL2022030001.pdf - PDF - ___ignore___ - diff --git a/addons/l10n_it_edi/tests/export_xmls/invoice_below_400_codice_simplified.xml b/addons/l10n_it_edi/tests/export_xmls/invoice_below_400_codice_simplified.xml index 9a21a46bf74..d402f6128df 100644 --- a/addons/l10n_it_edi/tests/export_xmls/invoice_below_400_codice_simplified.xml +++ b/addons/l10n_it_edi/tests/export_xmls/invoice_below_400_codice_simplified.xml @@ -63,10 +63,5 @@ 4.40 - - INV202200001.pdf - PDF - ___ignore___ - diff --git a/addons/l10n_it_edi/tests/export_xmls/invoice_fully_discounted.xml b/addons/l10n_it_edi/tests/export_xmls/invoice_fully_discounted.xml index 39b3a6a650a..97457016780 100644 --- a/addons/l10n_it_edi/tests/export_xmls/invoice_fully_discounted.xml +++ b/addons/l10n_it_edi/tests/export_xmls/invoice_fully_discounted.xml @@ -94,10 +94,5 @@ INV/2022/00001 - - INV202200001.pdf - PDF - ___ignore___ - diff --git a/addons/l10n_it_edi/tests/export_xmls/invoice_negative_price.xml b/addons/l10n_it_edi/tests/export_xmls/invoice_negative_price.xml index 31076ca3622..d016a39dbb2 100644 --- a/addons/l10n_it_edi/tests/export_xmls/invoice_negative_price.xml +++ b/addons/l10n_it_edi/tests/export_xmls/invoice_negative_price.xml @@ -112,10 +112,5 @@ INV/2022/00001 - - INV202200001.pdf - PDF - ___ignore___ - diff --git a/addons/l10n_it_edi/tests/export_xmls/invoice_non_latin_and_latin.xml b/addons/l10n_it_edi/tests/export_xmls/invoice_non_latin_and_latin.xml index da582e01c5c..36f8d53536b 100644 --- a/addons/l10n_it_edi/tests/export_xmls/invoice_non_latin_and_latin.xml +++ b/addons/l10n_it_edi/tests/export_xmls/invoice_non_latin_and_latin.xml @@ -76,7 +76,7 @@ 2 - ?- + -- 1.00 800.400000 800.40 @@ -106,10 +106,5 @@ INV/2022/00001 - - INV202200001.pdf - PDF - ___ignore___ - diff --git a/addons/l10n_it_edi/tests/export_xmls/invoice_partially_discounted.xml b/addons/l10n_it_edi/tests/export_xmls/invoice_partially_discounted.xml index 8f753cd43ea..41681a7beb9 100644 --- a/addons/l10n_it_edi/tests/export_xmls/invoice_partially_discounted.xml +++ b/addons/l10n_it_edi/tests/export_xmls/invoice_partially_discounted.xml @@ -114,10 +114,5 @@ INV/2022/00001 - - INV202200001.pdf - PDF - ___ignore___ - diff --git a/addons/l10n_it_edi/tests/export_xmls/invoice_price_included_taxes.xml b/addons/l10n_it_edi/tests/export_xmls/invoice_price_included_taxes.xml index c4584e232a9..2e3c1ec5ca7 100644 --- a/addons/l10n_it_edi/tests/export_xmls/invoice_price_included_taxes.xml +++ b/addons/l10n_it_edi/tests/export_xmls/invoice_price_included_taxes.xml @@ -113,10 +113,5 @@ INV/2022/00001 - - INV202200001.pdf - PDF - ___ignore___ - diff --git a/addons/l10n_it_edi/tests/export_xmls/invoice_reverse_charge.xml b/addons/l10n_it_edi/tests/export_xmls/invoice_reverse_charge.xml index ee2376076ec..3fe79adcff4 100644 --- a/addons/l10n_it_edi/tests/export_xmls/invoice_reverse_charge.xml +++ b/addons/l10n_it_edi/tests/export_xmls/invoice_reverse_charge.xml @@ -101,10 +101,5 @@ ___ignore___ - - INV202200001.pdf - PDF - ___ignore___ - diff --git a/addons/l10n_it_edi/tests/export_xmls/invoice_total_400_VAT_simplified.xml b/addons/l10n_it_edi/tests/export_xmls/invoice_total_400_VAT_simplified.xml index 917156dc742..297d0337491 100644 --- a/addons/l10n_it_edi/tests/export_xmls/invoice_total_400_VAT_simplified.xml +++ b/addons/l10n_it_edi/tests/export_xmls/invoice_total_400_VAT_simplified.xml @@ -53,10 +53,5 @@ 72.13 - - INV202200001.pdf - PDF - ___ignore___ - diff --git a/addons/l10n_it_edi/tests/export_xmls/invoice_zero_percent_taxes.xml b/addons/l10n_it_edi/tests/export_xmls/invoice_zero_percent_taxes.xml index cf98abe1a56..565d99b01ca 100644 --- a/addons/l10n_it_edi/tests/export_xmls/invoice_zero_percent_taxes.xml +++ b/addons/l10n_it_edi/tests/export_xmls/invoice_zero_percent_taxes.xml @@ -102,10 +102,5 @@ INV/2022/00001 - - INV202200001.pdf - PDF - ___ignore___ - diff --git a/addons/l10n_it_edi/tests/test_edi_export.py b/addons/l10n_it_edi/tests/test_edi_export.py index d4e19bfe845..dbe2a48af93 100644 --- a/addons/l10n_it_edi/tests/test_edi_export.py +++ b/addons/l10n_it_edi/tests/test_edi_export.py @@ -1,12 +1,8 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from lxml import etree - from odoo import Command from odoo.tests import tagged from odoo.addons.l10n_it_edi.tests.common import TestItEdi -from odoo.exceptions import UserError @tagged('post_install_l10n', 'post_install', '-at_install') @@ -145,7 +141,7 @@ class TestItEdiExport(TestItEdi): 'tax_ids': [Command.set(self.default_tax.ids)], }), Command.create({ - 'name': '–-', + 'name': '--', 'price_unit': 800.40, 'tax_ids': [Command.set(self.default_tax.ids)], }), @@ -214,8 +210,9 @@ class TestItEdiExport(TestItEdi): ], }) - with self.assertRaises(UserError): - invoice.action_post() + expected = ['Alessi must have a street.', 'Alessi must have a country.', 'Alessi must have a post code.', 'Alessi must have a city.'] + actual = invoice._l10n_it_edi_export_data_check() + self.assertEqual(expected, actual) def test_invoice_non_domestic_simplified(self): invoice = self.env['account.move'].with_company(self.company).create({ @@ -231,9 +228,9 @@ class TestItEdiExport(TestItEdi): }), ], }) - - with self.assertRaises(UserError): - invoice.action_post() + expected = ['Alessi must have a street.', 'Alessi must have a post code.', 'Alessi must have a city.'] + actual = invoice._l10n_it_edi_export_data_check() + self.assertEqual(expected, actual) def test_invoice_zero_percent_taxes(self): tax_zero_percent_hundred_percent_repartition = self.env['account.tax'].with_company(self.company).create({ diff --git a/addons/l10n_it_edi/tests/test_edi_import.py b/addons/l10n_it_edi/tests/test_edi_import.py index 8b64cd00a73..e53fe8d58ac 100644 --- a/addons/l10n_it_edi/tests/test_edi_import.py +++ b/addons/l10n_it_edi/tests/test_edi_import.py @@ -1,9 +1,10 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from unittest.mock import MagicMock, patch +import uuid +from unittest.mock import patch -from odoo import fields, sql_db +from odoo import fields, sql_db, tools from odoo.tests import tagged from odoo.addons.l10n_it_edi.tests.common import TestItEdi @@ -73,28 +74,40 @@ class TestItEdiImport(TestItEdi): def test_receive_same_vendor_bill_twice(self): """ Test that the second time we are receiving an SdiCoop invoice, the second is discarded """ - fattura_pa = self.env.ref('l10n_it_edi.edi_fatturaPA') - content = self.fake_test_content.encode() - # Our test content is not encrypted - proxy_user = MagicMock() - proxy_user.company_id = self.company - proxy_user._decrypt_data.return_value = content + ProxyUser = self.env['account_edi_proxy_client.user'] + proxy_user = ProxyUser.create({ + 'company_id': self.company.id, + 'proxy_type': 'l10n_it_edi', + 'id_client': str(uuid.uuid4()), + 'edi_identification': ProxyUser._get_proxy_identification(self.company, 'l10n_it_edi'), + 'private_key': str(uuid.uuid4()), + }) + + filename = 'IT01234567890_FPR02.xml' def mock_commit(self): pass - with patch.object(sql_db.Cursor, "commit", mock_commit): + with (patch.object(proxy_user.__class__, '_decrypt_data', return_value=self.fake_test_content), + patch.object(sql_db.Cursor, "commit", mock_commit), + tools.mute_logger("odoo.addons.l10n_it_edi.models.account_move")): for dummy in range(2): - fattura_pa._save_incoming_attachment_fattura_pa( - proxy_user=proxy_user, - id_transaction='9999999999', - filename='IT01234567890_FPR02.xml', - content=content, - key=None) + self.env['account.move']._l10n_it_edi_process_downloads({ + '999999999': { + 'filename': filename, + 'file': self.fake_test_content, + 'key': str(uuid.uuid4()), + }}, + proxy_user, + ) # There should be one attachement with this filename - attachments = self.env['ir.attachment'].search([('name', '=', 'IT01234567890_FPR02.xml')]) + attachments = self.env['ir.attachment'].search([ + ('name', '=', 'IT01234567890_FPR02.xml'), + ('res_model', '=', 'account.move'), + ('res_field', '=', 'l10n_it_edi_attachment_file'), + ]) self.assertEqual(len(attachments), 1) invoices = self.env['account.move'].search([('payment_reference', '=', 'TWICE_TEST')]) self.assertEqual(len(invoices), 1) diff --git a/addons/l10n_it_edi/tools/remove_signature.py b/addons/l10n_it_edi/tools/remove_signature.py index 69ce31993a6..33d9f87d940 100644 --- a/addons/l10n_it_edi/tools/remove_signature.py +++ b/addons/l10n_it_edi/tools/remove_signature.py @@ -39,7 +39,7 @@ def remove_signature(content): try: loaded_data = ssl_crypto.load_pkcs7_data(ssl_crypto.FILETYPE_ASN1, content) except ssl_crypto.Error: - _logger.warning("Error reading the content, PKCS#7 signature missing or invalid. Content will be tentatively used as it is.") + _logger.debug("PKCS#7 signature missing or invalid. Content will be tentatively used as plain text.") return content # Verify the signature diff --git a/addons/l10n_it_edi/views/l10n_it_view.xml b/addons/l10n_it_edi/views/l10n_it_view.xml index 8e8db6a7bc3..f3a16f4d3e8 100644 --- a/addons/l10n_it_edi/views/l10n_it_view.xml +++ b/addons/l10n_it_edi/views/l10n_it_view.xml @@ -91,6 +91,7 @@ +
@@ -98,48 +99,12 @@ account.invoice.select.inherit account.move - + - - - - - - account.move.tree.inherit - account.move - - - - - - - - - - - - account.move.tree.inherit - account.move - - - - - - - - - - - account.invoice.select.inherit - account.move - - - - - + @@ -150,23 +115,56 @@ 20 - - - - + + +