summaryrefslogtreecommitdiff
path: root/addons/l10n_it_edi
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/l10n_it_edi
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/l10n_it_edi')
-rw-r--r--addons/l10n_it_edi/__init__.py5
-rw-r--r--addons/l10n_it_edi/__manifest__.py28
-rw-r--r--addons/l10n_it_edi/data/account_edi_data.xml11
-rw-r--r--addons/l10n_it_edi/data/account_invoice_demo.xml33
-rw-r--r--addons/l10n_it_edi/data/invoice_it_template.xml210
-rw-r--r--addons/l10n_it_edi/models/__init__.py9
-rw-r--r--addons/l10n_it_edi/models/account_edi_format.py606
-rw-r--r--addons/l10n_it_edi/models/account_invoice.py317
-rw-r--r--addons/l10n_it_edi/models/ddt.py18
-rw-r--r--addons/l10n_it_edi/models/ir_mail_server.py434
-rw-r--r--addons/l10n_it_edi/models/res_company.py110
-rw-r--r--addons/l10n_it_edi/models/res_partner.py49
-rw-r--r--addons/l10n_it_edi/security/ir.model.access.csv2
-rw-r--r--addons/l10n_it_edi/tests/__init__.py4
-rw-r--r--addons/l10n_it_edi/tests/expected_xmls/IT01234567890_FPR01.xml113
-rw-r--r--addons/l10n_it_edi/tests/expected_xmls/IT01234567890_FPR01.xml.p7mbin0 -> 7083 bytes
-rw-r--r--addons/l10n_it_edi/tests/expected_xmls/IT01234567890_FPR01_DT_001.xml71
-rw-r--r--addons/l10n_it_edi/tests/expected_xmls/IT01234567890_FPR01_RC_001.xml72
-rw-r--r--addons/l10n_it_edi/tests/test_ir_mail_server.py160
-rw-r--r--addons/l10n_it_edi/tools/__init__.py4
-rw-r--r--addons/l10n_it_edi/tools/remove_signature.py47
-rw-r--r--addons/l10n_it_edi/views/l10n_it_view.xml241
22 files changed, 2544 insertions, 0 deletions
diff --git a/addons/l10n_it_edi/__init__.py b/addons/l10n_it_edi/__init__.py
new file mode 100644
index 00000000..54a1e867
--- /dev/null
+++ b/addons/l10n_it_edi/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import models
+from . import tools
diff --git a/addons/l10n_it_edi/__manifest__.py b/addons/l10n_it_edi/__manifest__.py
new file mode 100644
index 00000000..1e2cfeaf
--- /dev/null
+++ b/addons/l10n_it_edi/__manifest__.py
@@ -0,0 +1,28 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+{
+ 'name': 'Italy - E-invoicing',
+ 'version': '0.3',
+ 'depends': [
+ 'l10n_it',
+ 'fetchmail',
+ 'account_edi'
+ ],
+ 'author': 'Odoo',
+ 'description': """
+E-invoice implementation
+ """,
+ 'category': 'Accounting/Localizations/EDI',
+ 'website': 'http://www.odoo.com/',
+ 'data': [
+ 'security/ir.model.access.csv',
+ 'data/account_edi_data.xml',
+ 'data/invoice_it_template.xml',
+ 'views/l10n_it_view.xml',
+ ],
+ 'demo': [
+ 'data/account_invoice_demo.xml',
+ ],
+ 'license': 'LGPL-3',
+}
diff --git a/addons/l10n_it_edi/data/account_edi_data.xml b/addons/l10n_it_edi/data/account_edi_data.xml
new file mode 100644
index 00000000..fb454fff
--- /dev/null
+++ b/addons/l10n_it_edi/data/account_edi_data.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <data>
+
+ <record id="edi_fatturaPA" model="account.edi.format">
+ <field name="name">Fattura PA (IT)</field>
+ <field name="code">fattura_pa</field>
+ </record>
+
+ </data>
+</odoo> \ No newline at end of file
diff --git a/addons/l10n_it_edi/data/account_invoice_demo.xml b/addons/l10n_it_edi/data/account_invoice_demo.xml
new file mode 100644
index 00000000..e298f08c
--- /dev/null
+++ b/addons/l10n_it_edi/data/account_invoice_demo.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <data noupdate="1">
+ <!-- add VAT, codice fiscal and tax system for main company -->
+ <record id="l10n_it.demo_company_it" model="res.company">
+ <field name="l10n_it_codice_fiscale">09814700101</field>
+ <field name="l10n_it_tax_system">RF01</field>
+ <field name="zip">12345</field>
+ </record>
+
+ <record id="partner_demo_it" model="res.partner">
+ <field name="name">Palazzo dell'Arte</field>
+ <field name="vat">IT00000010215</field>
+ <field name="street">Piazza Marconi 5</field>
+ <field name="city">Cremona</field>
+ <field name="country_id" ref="base.it"/>
+ <field name="state_id" ref="base.state_it_cr"/>
+ <field name="zip">26000</field>
+ <field name="email">info@partner.itexample.com</field>
+ <field name="website">www.itexample.com</field>
+ </record>
+
+ <record id="base.res_partner_2" model="res.partner">
+ <field name="vat">IT00079760328</field>
+ <field name="l10n_it_pa_index">XS00001</field>
+ </record>
+ <record id="base.res_partner_12" model="res.partner">
+ <field name="vat">IT00140390501</field>
+ <field name="l10n_it_pa_index">XS00001</field>
+ </record>
+
+ </data>
+</odoo>
diff --git a/addons/l10n_it_edi/data/invoice_it_template.xml b/addons/l10n_it_edi/data/invoice_it_template.xml
new file mode 100644
index 00000000..d151aee6
--- /dev/null
+++ b/addons/l10n_it_edi/data/invoice_it_template.xml
@@ -0,0 +1,210 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <data>
+ <template id="account_invoice_line_it_FatturaPA">
+ <t t-set="taxes" t-value="line.tax_ids.compute_all(line.price_unit)"/>
+ <DettaglioLinee>
+ <NumeroLinea t-esc="line_counter"/>
+ <CodiceArticolo t-if="line.product_id.barcode">
+ <!--2.2.1.3-->
+ <CodiceTipo>EAN</CodiceTipo>
+ <CodiceValore t-esc="line.product_id.barcode"/>
+ </CodiceArticolo>
+ <CodiceArticolo t-if="line.product_id.default_code">
+ <CodiceTipo>INTERNAL</CodiceTipo>
+ <CodiceValore t-esc="line.product_id.default_code"/>
+ </CodiceArticolo>
+ <Descrizione>
+ <t t-esc="line.name[:1000]"/>
+ <t t-if="not line.name" t-esc="'NO NAME'"/>
+ </Descrizione>
+ <Quantita t-esc="format_numbers(line.quantity)"/>
+ <UnitaMisura t-if="line.product_uom_id.category_id != env.ref('uom.product_uom_categ_unit')" t-esc="line.product_uom_id.name"/>
+ <PrezzoUnitario t-esc="format_monetary(taxes['total_excluded'], currency)"/>
+ <ScontoMaggiorazione t-if="line.discount != 0">
+ <!-- [2.2.1.10] -->
+ <Tipo t-esc="discount_type(line.discount)"/>
+ <Percentuale t-esc="format_numbers(abs(line.discount))"/>
+ </ScontoMaggiorazione>
+ <PrezzoTotale t-esc="format_monetary(line.price_subtotal, currency)"/>
+ <!-- without tax, must include any discounts and any extra charge-->
+ <AliquotaIVA t-if="line.tax_ids.amount_type == 'percent'" t-esc="format_numbers(line.tax_ids.amount)"/>
+ <AliquotaIVA t-if="line.tax_ids.amount_type != 'percent'" t-esc="'0.00'"/>
+ <Natura t-if="line.tax_ids.l10n_it_has_exoneration" t-esc="line.tax_ids.l10n_it_kind_exoneration"/>
+ </DettaglioLinee>
+ </template>
+
+ <template id="account_invoice_it_FatturaPA_sede">
+ <Sede>
+ <Indirizzo><t t-if="partner.street" t-esc="partner.street"/> <t t-if="partner.street2" t-esc="partner.street2"/></Indirizzo>
+ <CAP><t t-if="partner.country_id.code != 'IT'" t-esc="'00000'"/><t t-else="" t-esc="partner.zip"/></CAP>
+ <Comune t-esc="partner.city"/>
+ <Provincia t-if="partner.country_id.code == 'IT'" t-esc="partner.state_id.code"/>
+ <Nazione t-esc="partner.country_id.code"/>
+ </Sede>
+ </template>
+
+ <template id="account_invoice_it_FatturaPA_export">
+ <t t-set="currency" t-value="record.currency_id or record.company_currency_id"/>
+ <t t-set="bank" t-value="record.partner_bank_id"/>
+ <p:FatturaElettronica t-att-versione="formato_trasmissione" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:p="http://ivaservizi.agenziaentrate.gov.it/docs/xsd/fatture/v1.2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://ivaservizi.agenziaentrate.gov.it/docs/xsd/fatture/v1.2 http://www.fatturapa.gov.it/export/fatturazione/sdi/fatturapa/v1.2/Schema_del_file_xml_FatturaPA_versione_1.2.xsd">
+ <FatturaElettronicaHeader>
+ <DatiTrasmissione>
+ <IdTrasmittente>
+ <IdPaese t-esc="get_vat_country(record.company_id.vat)"/>
+ <IdCodice t-esc="get_vat_number(record.company_id.vat)"/>
+ </IdTrasmittente>
+ <ProgressivoInvio t-esc="record.name.replace('/','')[-10:]"/>
+ <FormatoTrasmissione t-esc="formato_trasmissione"/>
+ <CodiceDestinatario t-if="record.commercial_partner_id.l10n_it_pa_index and record.commercial_partner_id.country_id.code == 'IT'" t-esc="record.commercial_partner_id.l10n_it_pa_index.upper()"/>
+ <CodiceDestinatario t-if="not record.commercial_partner_id.l10n_it_pa_index and record.commercial_partner_id.country_id.code == 'IT'" t-esc="'0000000'"/>
+ <CodiceDestinatario t-if="record.commercial_partner_id.country_id.code != 'IT'" t-esc="'XXXXXXX'"/>
+ <ContattiTrasmittente>
+ <Telefono t-if="format_phone(record.company_id.partner_id.phone)" t-esc="format_phone(record.company_id.partner_id.phone)"/>
+ <Telefono t-if="not format_phone(record.company_id.partner_id.phone) and format_phone(record.company_id.partner_id.mobile)" t-esc="format_phone(record.company_id.partner_id.mobile)"/>
+ <Email t-if="record.company_id.email" t-esc="record.company_id.email"/>
+ </ContattiTrasmittente>
+ <PECDestinatario t-if="record.commercial_partner_id.l10n_it_pec_email" t-esc="record.commercial_partner_id.l10n_it_pec_email"/>
+ </DatiTrasmissione>
+ <CedentePrestatore>
+ <DatiAnagrafici>
+ <IdFiscaleIVA>
+ <IdPaese t-esc="get_vat_country(record.company_id.vat)"/>
+ <IdCodice t-esc="get_vat_number(record.company_id.vat)"/>
+ </IdFiscaleIVA>
+ <CodiceFiscale t-if="record.company_id.l10n_it_codice_fiscale and not test_mode" t-esc="record.company_id.l10n_it_codice_fiscale"/>
+ <Anagrafica>
+ <Denominazione t-esc="record.company_id.partner_id.display_name"/>
+ </Anagrafica>
+ <RegimeFiscale t-esc="record.company_id.l10n_it_tax_system"/>
+ </DatiAnagrafici>
+ <t t-call="l10n_it_edi.account_invoice_it_FatturaPA_sede">
+ <t t-set="partner" t-value="record.company_id.partner_id"/>
+ </t>
+ <IscrizioneREA t-if="record.company_id.l10n_it_has_eco_index">
+ <!--1.2.4-->
+ <Ufficio t-esc="record.company_id.l10n_it_eco_index_office.code"/>
+ <NumeroREA t-esc="record.company_id.l10n_it_eco_index_number"/>
+ <CapitaleSociale t-if="record.company_id.l10n_it_eco_index_share_capital != 0" t-esc="format_numbers_two(record.company_id.l10n_it_eco_index_share_capital)"/>
+ <SocioUnico t-if="record.company_id.l10n_it_eco_index_sole_shareholder != 'NO'" t-esc="record.company_id.l10n_it_eco_index_sole_shareholder"/>
+ <StatoLiquidazione t-esc="record.company_id.l10n_it_eco_index_liquidation_state"/>
+ </IscrizioneREA>
+ </CedentePrestatore>
+ <RappresentanteFiscale t-if="record.company_id.l10n_it_has_tax_representative">
+ <!--1.3-->
+ <DatiAnagrafici>
+ <IdFiscaleIVA>
+ <IdPaese t-esc="get_vat_country(record.company_id.l10n_it_tax_representative_partner_id.vat)"/>
+ <IdCodice t-esc="get_vat_number(record.company_id.l10n_it_tax_representative_partner_id.vat)"/>
+ </IdFiscaleIVA>
+ <CodiceFiscale t-if="record.company_id.l10n_it_tax_representative_partner_id.l10n_it_codice_fiscale" t-esc="record.company_id.l10n_it_tax_representative_partner_id.l10n_it_codice_fiscale"/>
+ <Anagrafica>
+ <Denominazione t-if="record.company_id.l10n_it_tax_representative_partner_id.is_company" t-esc="record.company_id.l10n_it_tax_representative_partner_id.display_name"/>
+ <Nome t-if="not record.company_id.l10n_it_tax_representative_partner_id.is_company" t-esc="' '.join(record.company_id.l10n_it_tax_representative_partner_id.name.split()[:1])"/>
+ <Cognome t-if="not record.company_id.l10n_it_tax_representative_partner_id.is_company" t-esc="' '.join(record.company_id.l10n_it_tax_representative_partner_id.name.split()[1:])"/>
+ </Anagrafica>
+ </DatiAnagrafici>
+ </RappresentanteFiscale>
+ <CessionarioCommittente>
+ <DatiAnagrafici>
+ <IdFiscaleIVA t-if="record.commercial_partner_id.vat and in_eu(record.commercial_partner_id)">
+ <IdPaese t-esc="get_vat_country(record.commercial_partner_id.vat)"/>
+ <IdCodice t-esc="get_vat_number(record.commercial_partner_id.vat)"/>
+ </IdFiscaleIVA>
+ <IdFiscaleIVA t-if="record.commercial_partner_id.vat and not in_eu(record.commercial_partner_id)">
+ <IdPaese t-esc="record.commercial_partner_id.country_id.code"/>
+ <IdCodice t-esc="'OO99999999999'"/>
+ </IdFiscaleIVA>
+ <IdFiscaleIVA t-if="not record.commercial_partner_id.vat and record.commercial_partner_id.country_id.code != 'IT'">
+ <IdCodice t-esc="'0000000'"/>
+ </IdFiscaleIVA>
+ <CodiceFiscale t-if="not record.commercial_partner_id.vat" t-esc="record.commercial_partner_id.l10n_it_codice_fiscale"/>
+ <CodiceFiscale t-if="not record.commercial_partner_id.vat and not record.commercial_partner_id.l10n_it_codice_fiscale" t-esc="99999999999"/>
+ <Anagrafica>
+ <Denominazione t-if="record.commercial_partner_id.is_company" t-esc="record.commercial_partner_id.display_name"/>
+ <Nome t-if="not record.commercial_partner_id.is_company" t-esc="' '.join(record.commercial_partner_id.name.split()[:1])"/>
+ <Cognome t-if="not record.commercial_partner_id.is_company" t-esc="' '.join(record.commercial_partner_id.name.split()[1:])"/>
+ </Anagrafica>
+ </DatiAnagrafici>
+ <t t-call="l10n_it_edi.account_invoice_it_FatturaPA_sede">
+ <t t-set="partner" t-value="record.commercial_partner_id"/>
+ </t>
+ </CessionarioCommittente>
+ </FatturaElettronicaHeader>
+ <FatturaElettronicaBody>
+ <DatiGenerali>
+ <DatiGeneraliDocumento>
+ <!--2.1.1-->
+ <TipoDocumento t-esc="document_type"/>
+ <Divisa t-esc="currency.name"/>
+ <Data t-esc="format_date(record.invoice_date)"/>
+ <Numero t-esc="record.name[-20:]"/>
+ <DatiBollo t-if="record.l10n_it_stamp_duty">
+ <!--2.1.1.6-->
+ <BolloVirtuale>SI</BolloVirtuale>
+ <ImportoBollo t-esc="format_numbers(record.l10n_it_stamp_duty)"/>
+ </DatiBollo>
+ </DatiGeneraliDocumento>
+ <DatiOrdineAcquisto t-if="record.ref">
+ <IdDocumento t-esc="record.ref" />
+ </DatiOrdineAcquisto>
+ <DatiDDT t-if="record.l10n_it_ddt_id">
+ <!--2.1.8-->
+ <NumeroDDT t-esc="record.l10n_it_ddt_id.name"/>
+ <DataDDT t-esc="format_date(record.l10n_it_ddt_id.date)"/>
+ </DatiDDT>
+ </DatiGenerali>
+ <DatiBeniServizi>
+ <!-- Invoice lines. -->
+ <t t-set="line_counter" t-value="0"/>
+ <t t-foreach="record.invoice_line_ids.filtered(lambda l: not l.display_type)" t-as="line">
+ <t t-set="line_counter" t-value="line_counter + 1"/>
+ <t t-call="l10n_it_edi.account_invoice_line_it_FatturaPA"/>
+ </t>
+ <t t-foreach="record.line_ids.filtered(lambda line: line.tax_line_id)" t-as="tax_line">
+ <DatiRiepilogo>
+ <!--2.2.2-->
+ <AliquotaIVA t-esc="format_numbers(tax_line.tax_line_id.amount)"/>
+ <Natura t-if="tax_line.tax_line_id.l10n_it_has_exoneration" t-esc="tax_line.tax_line_id.l10n_it_kind_exoneration"/>
+ <ImponibileImporto t-esc="format_monetary(tax_line.tax_base_amount, currency)"/>
+ <Imposta t-esc="format_monetary(tax_line.price_unit, currency)"/>
+ <EsigibilitaIVA t-if="not tax_line.tax_line_id.l10n_it_has_exoneration or tax_line.tax_line_id.l10n_it_kind_exoneration=='N6'" t-esc="tax_line.tax_line_id.l10n_it_vat_due_date"/>
+ <RiferimentoNormativo t-if="tax_line.tax_line_id.l10n_it_has_exoneration" t-esc="tax_line.tax_line_id.l10n_it_law_reference"/>
+ </DatiRiepilogo>
+ </t>
+ <!-- 0% tax lines -->
+ <t t-foreach="tax_map" t-as="tax">
+ <DatiRiepilogo>
+ <AliquotaIVA t-esc="format_numbers(tax.amount)"/>
+ <Natura t-if="tax.l10n_it_has_exoneration" t-esc="tax.l10n_it_kind_exoneration"/>
+ <ImponibileImporto t-esc="format_monetary(tax_map[tax], currency)"/>
+ <Imposta t-esc="format_monetary(0.00, currency)"/>
+ <EsigibilitaIVA t-if="not tax.l10n_it_has_exoneration or tax.l10n_it_kind_exoneration=='N6'" t-esc="tax.l10n_it_vat_due_date"/>
+ <RiferimentoNormativo t-if="tax.l10n_it_has_exoneration" t-esc="tax.l10n_it_law_reference"/>
+ </DatiRiepilogo>
+ </t>
+ </DatiBeniServizi>
+ <DatiPagamento>
+ <t t-set="payments" t-value="record.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable'))"/>
+ <CondizioniPagamento><t t-if="len(payments) == 1">TP02</t><t t-else="">TP01</t></CondizioniPagamento>
+ <DettaglioPagamento>
+ <t t-set="company_bank_account" t-value="record.partner_bank_id"/>
+ <ModalitaPagamento t-if="company_bank_account">MP05</ModalitaPagamento>
+ <DataScadenzaPagamento t-esc="format_date(record.invoice_date_due)"/>
+ <ImportoPagamento t-esc="format_numbers_two(record.amount_total)"/>
+ <IstitutoFinanziario t-if="company_bank_account.bank_id" t-esc="company_bank_account.bank_id.name[:80]"/>
+ <IBAN t-if="company_bank_account.acc_type == 'iban'" t-esc="company_bank_account.sanitized_acc_number"/>
+ <BIC t-if="company_bank_account.acc_type == 'bank' and company_bank_account.bank_id.bic" t-esc="company_bank_account.bank_id.bic"/>
+ <CodicePagamento t-esc="record.payment_reference[:60]"/>
+ </DettaglioPagamento>
+ </DatiPagamento>
+ <Allegati t-if="pdf">
+ <NomeAttachment t-esc="pdf_name"/>
+ <FormatoAttachment>PDF</FormatoAttachment>
+ <Attachment t-esc="pdf"/>
+ </Allegati>
+ </FatturaElettronicaBody>
+ </p:FatturaElettronica>
+ </template>
+ </data>
+</odoo>
diff --git a/addons/l10n_it_edi/models/__init__.py b/addons/l10n_it_edi/models/__init__.py
new file mode 100644
index 00000000..e2f3f697
--- /dev/null
+++ b/addons/l10n_it_edi/models/__init__.py
@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import res_partner
+from . import res_company
+from . import account_invoice
+from . import account_edi_format
+from . import ir_mail_server
+from . import ddt
diff --git a/addons/l10n_it_edi/models/account_edi_format.py b/addons/l10n_it_edi/models/account_edi_format.py
new file mode 100644
index 00000000..24149cbd
--- /dev/null
+++ b/addons/l10n_it_edi/models/account_edi_format.py
@@ -0,0 +1,606 @@
+# -*- coding:utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, models, fields, _
+from odoo.tests.common import Form
+from odoo.exceptions import UserError
+from odoo.addons.l10n_it_edi.tools.remove_signature import remove_signature
+from odoo.osv.expression import OR, AND
+
+from lxml import etree
+from datetime import datetime
+import re
+import logging
+
+
+_logger = logging.getLogger(__name__)
+
+DEFAULT_FACTUR_ITALIAN_DATE_FORMAT = '%Y-%m-%d'
+
+
+class AccountEdiFormat(models.Model):
+ _inherit = 'account.edi.format'
+
+ # -------------------------------------------------------------------------
+ # Helpers
+ # -------------------------------------------------------------------------
+
+ @api.model
+ def _l10n_it_edi_generate_electronic_invoice_filename(self, invoice):
+ '''Returns a name conform to the Fattura pa Specifications:
+ See ES documentation 2.2
+ '''
+ a = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ n = invoice.id
+ 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': invoice.company_id.country_id.code,
+ 'codice': invoice.company_id.l10n_it_codice_fiscale.replace(' ', ''),
+ 'progressive_number': progressive_number.zfill(5),
+ }
+
+ def _l10n_it_edi_check_invoice_configuration(self, invoice):
+ errors = []
+ seller = invoice.company_id
+ buyer = invoice.commercial_partner_id
+
+ # <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 seller.vat:
+ errors.append(_("%s must have a VAT number", seller.display_name))
+ elif 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 seller.l10n_it_codice_fiscale:
+ errors.append(_("%s must have a codice fiscale number", seller.display_name))
+
+ # <1.2.1.8>
+ if 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 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 either a VAT number either a tax code (Codice Fiscale).", buyer.display_name))
+
+ # <1.4.2>
+ if not buyer.street and not buyer.street2:
+ errors.append(_("%s must have a street.", 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))
+ if not buyer.country_id:
+ errors.append(_("%s must have a country.", buyer.display_name))
+
+ # <2.2.1>
+ for invoice_line in invoice.invoice_line_ids:
+ if not invoice_line.display_type and len(invoice_line.tax_ids) != 1:
+ raise UserError(_("You must select one and only one tax by line."))
+
+ 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))
+
+ if not invoice.partner_bank_id:
+ errors.append(_("The seller must have a bank account."))
+
+ return errors
+
+ # -------------------------------------------------------------------------
+ # Export
+ # -------------------------------------------------------------------------
+
+ def _is_embedding_to_invoice_pdf_needed(self):
+ # OVERRIDE
+ self.ensure_one()
+ return True if self.code == 'fattura_pa' else super()._is_embedding_to_invoice_pdf_needed()
+
+ 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 == 'sale' and journal.country_code == 'IT'
+
+ def _l10n_it_edi_is_required_for_invoice(self, invoice):
+ """ Is the edi required for this invoice based on the method (here: PEC mail)
+ Deprecated: in future release PEC mail will be removed.
+ TO OVERRIDE
+ """
+ return invoice.is_sale_document() and invoice.l10n_it_send_state not in ('sent', 'delivered', 'delivered_accepted') and invoice.country_code == 'IT'
+
+ def _is_required_for_invoice(self, invoice):
+ # OVERRIDE
+ self.ensure_one()
+ if self.code != 'fattura_pa':
+ return super()._is_required_for_invoice(invoice)
+
+ return self._l10n_it_edi_is_required_for_invoice(invoice)
+
+ def _post_fattura_pa(self, invoices):
+ # TO OVERRIDE
+ invoice = invoices # no batching ensure that we only have one invoice
+ invoice.l10n_it_send_state = 'other'
+ invoice._check_before_xml_exporting()
+ if invoice.l10n_it_einvoice_id and invoice.l10n_it_send_state not in ['invalid', 'to_send']:
+ return {'error': _("You can't regenerate an E-Invoice when the first one is sent and there are no errors")}
+ if invoice.l10n_it_einvoice_id:
+ invoice.l10n_it_einvoice_id.unlink()
+ res = invoice.invoice_generate_xml()
+ if len(invoice.commercial_partner_id.l10n_it_pa_index or '') == 6:
+ invoice.message_post(
+ body=(_("Invoices for PA are not managed by Odoo, you can download the document and send it on your own."))
+ )
+ else:
+ invoice.l10n_it_send_state = 'to_send'
+ return {invoice: res}
+
+ def _post_invoice_edi(self, invoices, test_mode=False):
+ # 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)
+
+ # -------------------------------------------------------------------------
+ # Import
+ # -------------------------------------------------------------------------
+
+ 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}.(xml.p7m|xml))", filename)
+
+ def _is_fattura_pa(self, filename, tree):
+ return self.code == 'fattura_pa' and self._check_filename_is_fattura_pa(filename)
+
+ def _create_invoice_from_xml_tree(self, filename, tree):
+ self.ensure_one()
+ if self._is_fattura_pa(filename, tree):
+ return self._import_fattura_pa(tree, self.env['account.move'])
+ return super()._create_invoice_from_xml_tree(filename, tree)
+
+ def _update_invoice_from_xml_tree(self, filename, tree, invoice):
+ self.ensure_one()
+ if self._is_fattura_pa(filename, tree):
+ if len(tree.xpath('//FatturaElettronicaBody')) > 1:
+ invoice.message_post(body='The attachment contains multiple invoices, this invoice was not updated from it.',
+ message_type='comment',
+ subtype_xmlid='mail.mt_note',
+ author_id=self.env.ref('base.partner_root').id)
+ else:
+ return self._import_fattura_pa(tree, invoice)
+ return super()._update_invoice_from_xml_tree(filename, tree, invoice)
+
+ def _decode_p7m_to_xml(self, filename, content):
+ decoded_content = remove_signature(content)
+ if not decoded_content:
+ return None
+
+ try:
+ # Some malformed XML are accepted by FatturaPA, this expends compatibility
+ parser = etree.XMLParser(recover=True)
+ xml_tree = etree.fromstring(decoded_content, parser)
+ except Exception as e:
+ _logger.exception("Error when converting the xml content to etree: %s", e)
+ return None
+ if not len(xml_tree):
+ return None
+
+ return xml_tree
+
+ def _create_invoice_from_binary(self, filename, content, extension):
+ self.ensure_one()
+ if extension.lower() == '.xml.p7m':
+ decoded_content = self._decode_p7m_to_xml(filename, content)
+ if decoded_content is not None and self._is_fattura_pa(filename, decoded_content):
+ return self._import_fattura_pa(decoded_content, self.env['account.move'])
+ return super()._create_invoice_from_binary(filename, content, extension)
+
+ def _update_invoice_from_binary(self, filename, content, extension, invoice):
+ self.ensure_one()
+ if extension.lower() == '.xml.p7m':
+ decoded_content = self._decode_p7m_to_xml(filename, content)
+ if decoded_content is not None and self._is_fattura_pa(filename, decoded_content):
+ return self._import_fattura_pa(decoded_content, invoice)
+ return super()._update_invoice_from_binary(filename, content, extension, invoice)
+
+ 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.
+ """
+ invoices = self.env['account.move']
+ first_run = True
+
+ # possible to have multiple invoices in the case of an invoice batch, the batch itself is repeated for every invoice of the batch
+ for body_tree in tree.xpath('//FatturaElettronicaBody'):
+ if not first_run or not invoice:
+ # make sure all the iterations create a new invoice record (except the first which could have already created one)
+ invoice = self.env['account.move']
+ first_run = False
+
+ # Type must be present in the context to get the right behavior of the _default_journal method (account.move).
+ # journal_id must be present in the context to get the right behavior of the _default_account method (account.move.line).
+ elements = tree.xpath('//CessionarioCommittente//IdCodice')
+ company = elements and self.env['res.company'].search([('vat', 'ilike', elements[0].text)], limit=1)
+ if not company:
+ elements = tree.xpath('//CessionarioCommittente//CodiceFiscale')
+ company = elements and self.env['res.company'].search([('l10n_it_codice_fiscale', 'ilike', elements[0].text)], limit=1)
+ if not company:
+ # Only invoices with a correct VAT or Codice Fiscale can be imported
+ _logger.warning('No company found with VAT or Codice Fiscale like %r.', elements[0].text)
+ continue
+
+ # Refund type.
+ # TD01 == invoice
+ # TD02 == advance/down payment on invoice
+ # TD03 == advance/down payment on fee
+ # TD04 == credit note
+ # TD05 == debit note
+ # TD06 == fee
+ # For unsupported document types, just assume in_invoice, and log that the type is unsupported
+ elements = tree.xpath('//DatiGeneraliDocumento/TipoDocumento')
+ move_type = 'in_invoice'
+ if elements and elements[0].text and elements[0].text == 'TD04':
+ move_type = 'in_refund'
+ elif elements and elements[0].text and elements[0].text != 'TD01':
+ _logger.info('Document type not managed: %s. Invoice type is set by default.', elements[0].text)
+
+ # Setup the context for the Invoice Form
+ invoice_ctx = invoice.with_company(company) \
+ .with_context(default_move_type=move_type,
+ account_predictive_bills_disable_prediction=True)
+
+ # move could be a single record (editing) or be empty (new).
+ with Form(invoice_ctx) as invoice_form:
+ message_to_log = []
+
+ # 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(['&', ('vat', 'ilike', elements[0].text), '|', ('company_id', '=', company.id), ('company_id', '=', False)], 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_normalize_codice_fiscale(codice))])
+ partner = elements and self.env['res.partner'].search(
+ AND([OR(domains), OR([[('company_id', '=', company.id)], [('company_id', '=', False)]])]), limit=1)
+ if not partner:
+ elements = tree.xpath('//DatiTrasmissione//Email')
+ partner = elements and self.env['res.partner'].search(['&', '|', ('email', '=', elements[0].text), ('l10n_it_pec_email', '=', elements[0].text), '|', ('company_id', '=', company.id), ('company_id', '=', False)], limit=1)
+ if partner:
+ invoice_form.partner_id = partner
+ else:
+ message_to_log.append("%s<br/>%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_form.payment_reference = elements[0].text
+
+ elements = body_tree.xpath('.//DatiGeneraliDocumento//Numero')
+ if elements:
+ invoice_form.ref = elements[0].text
+
+ # Currency. <2.1.1.2>
+ elements = body_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_form.currency_id = currency
+
+ # Date. <2.1.1.3>
+ elements = body_tree.xpath('.//DatiGeneraliDocumento/Data')
+ if elements:
+ date_str = elements[0].text
+ date_obj = datetime.strptime(date_str, DEFAULT_FACTUR_ITALIAN_DATE_FORMAT)
+ invoice_form.invoice_date = date_obj
+
+ # Dati Bollo. <2.1.1.6>
+ elements = body_tree.xpath('.//DatiGeneraliDocumento/DatiBollo/ImportoBollo')
+ if elements:
+ invoice_form.l10n_it_stamp_duty = float(elements[0].text)
+
+ # List of all amount discount (will be add after all article to avoid to have a negative sum)
+ discount_list = []
+ percentage_global_discount = 1.0
+
+ # Global discount. <2.1.1.8>
+ discount_elements = body_tree.xpath('.//DatiGeneraliDocumento/ScontoMaggiorazione')
+ total_discount_amount = 0.0
+ if discount_elements:
+ for discount_element in discount_elements:
+ discount_line = discount_element.xpath('.//Tipo')
+ discount_sign = -1
+ if discount_line and discount_line[0].text == 'SC':
+ discount_sign = 1
+ discount_percentage = discount_element.xpath('.//Percentuale')
+ if discount_percentage and discount_percentage[0].text:
+ percentage_global_discount *= 1 - float(discount_percentage[0].text)/100 * discount_sign
+
+ discount_amount_text = discount_element.xpath('.//Importo')
+ if discount_amount_text and discount_amount_text[0].text:
+ discount_amount = float(discount_amount_text[0].text) * discount_sign * -1
+ discount = {}
+ discount["seq"] = 0
+
+ if discount_amount < 0:
+ discount["name"] = _('GLOBAL DISCOUNT')
+ else:
+ discount["name"] = _('GLOBAL EXTRA CHARGE')
+ discount["amount"] = discount_amount
+ discount["tax"] = []
+ discount_list.append(discount)
+
+ # Comment. <2.1.1.11>
+ elements = body_tree.xpath('.//DatiGeneraliDocumento//Causale')
+ for element in elements:
+ invoice_form.narration = '%s%s\n' % (invoice_form.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 = body_tree.xpath('.//DatiGenerali/' + document_type)
+ if elements:
+ for element in elements:
+ message_to_log.append("%s %s<br/>%s" % (document_type, _("from XML file:"),
+ invoice._compose_info_message(element, '.')))
+
+ # Dati DDT. <2.1.8>
+ elements = body_tree.xpath('.//DatiGenerali/DatiDDT')
+ if elements:
+ message_to_log.append("%s<br/>%s" % (
+ _("Transport informations from XML file:"),
+ invoice._compose_info_message(body_tree, './/DatiGenerali/DatiDDT')))
+
+ # Due date. <2.4.2.5>
+ elements = body_tree.xpath('.//DatiPagamento/DettaglioPagamento/DataScadenzaPagamento')
+ if elements:
+ date_str = elements[0].text
+ date_obj = datetime.strptime(date_str, DEFAULT_FACTUR_ITALIAN_DATE_FORMAT)
+ invoice_form.invoice_date_due = fields.Date.to_string(date_obj)
+
+ # Total amount. <2.4.2.6>
+ elements = body_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_form.move_type not in ('out_invoice', 'in_refund'):
+ elements = body_tree.xpath('.//DatiPagamento/DettaglioPagamento/IBAN')
+ if elements:
+ if invoice_form.partner_id and invoice_form.partner_id.commercial_partner_id:
+ bank = self.env['res.partner.bank'].search([
+ ('acc_number', '=', elements[0].text),
+ ('partner_id.id', '=', invoice_form.partner_id.commercial_partner_id.id)
+ ])
+ else:
+ bank = self.env['res.partner.bank'].search([('acc_number', '=', elements[0].text)])
+ if bank:
+ invoice_form.partner_bank_id = bank
+ else:
+ message_to_log.append("%s<br/>%s" % (
+ _("Bank account not found, useful informations from XML file:"),
+ invoice._compose_multi_info_message(
+ body_tree, ['.//DatiPagamento//Beneficiario',
+ './/DatiPagamento//IstitutoFinanziario',
+ './/DatiPagamento//IBAN',
+ './/DatiPagamento//ABI',
+ './/DatiPagamento//CAB',
+ './/DatiPagamento//BIC',
+ './/DatiPagamento//ModalitaPagamento'])))
+ else:
+ elements = body_tree.xpath('.//DatiPagamento/DettaglioPagamento')
+ if elements:
+ message_to_log.append("%s<br/>%s" % (
+ _("Bank account not found, useful informations from XML file:"),
+ invoice._compose_info_message(body_tree, './/DatiPagamento')))
+
+ # Invoice lines. <2.2.1>
+ elements = body_tree.xpath('.//DettaglioLinee')
+ if elements:
+ for element in elements:
+ with invoice_form.invoice_line_ids.new() as invoice_line_form:
+
+ # Sequence.
+ line_elements = element.xpath('.//NumeroLinea')
+ if line_elements:
+ invoice_line_form.sequence = int(line_elements[0].text) * 2
+
+ # Product.
+ line_elements = element.xpath('.//Descrizione')
+ if line_elements:
+ invoice_line_form.name = " ".join(line_elements[0].text.split())
+
+ 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([('name', '=', partner.id), ('product_code', '=', code.text)])
+ if product_supplier 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)])
+ if product:
+ invoice_line_form.product_id = product
+ break
+
+ # Price Unit.
+ line_elements = element.xpath('.//PrezzoUnitario')
+ if line_elements:
+ invoice_line_form.price_unit = float(line_elements[0].text)
+
+ # 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
+ tax_element = element.xpath('.//AliquotaIVA')
+ natura_element = element.xpath('.//Natura')
+ invoice_line_form.tax_ids.clear()
+ if tax_element and tax_element[0].text:
+ percentage = float(tax_element[0].text)
+ if natura_element and natura_element[0].text:
+ l10n_it_kind_exoneration = natura_element[0].text
+ tax = self.env['account.tax'].search([
+ ('company_id', '=', invoice_form.company_id.id),
+ ('amount_type', '=', 'percent'),
+ ('type_tax_use', '=', 'purchase'),
+ ('amount', '=', percentage),
+ ('l10n_it_kind_exoneration', '=', l10n_it_kind_exoneration),
+ ], limit=1)
+ else:
+ tax = self.env['account.tax'].search([
+ ('company_id', '=', invoice_form.company_id.id),
+ ('amount_type', '=', 'percent'),
+ ('type_tax_use', '=', 'purchase'),
+ ('amount', '=', percentage),
+ ], limit=1)
+ l10n_it_kind_exoneration = ''
+
+ if tax:
+ invoice_line_form.tax_ids.add(tax)
+ else:
+ if l10n_it_kind_exoneration:
+ message_to_log.append(_("Tax not found with percentage: %s and exoneration %s for the article: %s") % (
+ percentage,
+ l10n_it_kind_exoneration,
+ invoice_line_form.name))
+ else:
+ message_to_log.append(_("Tax not found with percentage: %s for the article: %s") % (
+ percentage,
+ invoice_line_form.name))
+
+ # Discount in cascade mode.
+ # if 3 discounts : -10% -50€ -20%
+ # the result must be : (((price -10%)-50€) -20%)
+ # Generic form : (((price -P1%)-A1€) -P2%)
+ # It will be split in two parts: fix amount and pourcent amount
+ # example: (((((price - A1€) -P2%) -A3€) -A4€) -P5€)
+ # pourcent: 1-(1-P2)*(1-P5)
+ # fix amount: A1*(1-P2)*(1-P5)+A3*(1-P5)+A4*(1-P5) (we must take account of all
+ # percentage present after the fix amount)
+ line_elements = element.xpath('.//ScontoMaggiorazione')
+ total_discount_amount = 0.0
+ total_discount_percentage = percentage_global_discount
+ if line_elements:
+ for line_element in line_elements:
+ discount_line = line_element.xpath('.//Tipo')
+ discount_sign = -1
+ if discount_line and discount_line[0].text == 'SC':
+ discount_sign = 1
+ discount_percentage = line_element.xpath('.//Percentuale')
+ if discount_percentage and discount_percentage[0].text:
+ pourcentage_actual = 1 - float(discount_percentage[0].text)/100 * discount_sign
+ total_discount_percentage *= pourcentage_actual
+ total_discount_amount *= pourcentage_actual
+
+ discount_amount = line_element.xpath('.//Importo')
+ if discount_amount and discount_amount[0].text:
+ total_discount_amount += float(discount_amount[0].text) * discount_sign * -1
+
+ # Save amount discount.
+ if total_discount_amount != 0:
+ discount = {}
+ discount["seq"] = invoice_line_form.sequence + 1
+
+ if total_discount_amount < 0:
+ discount["name"] = _('DISCOUNT: %s', invoice_line_form.name)
+ else:
+ discount["name"] = _('EXTRA CHARGE: %s', invoice_line_form.name)
+ discount["amount"] = total_discount_amount
+ discount["tax"] = []
+ for tax in invoice_line_form.tax_ids:
+ discount["tax"].append(tax)
+ discount_list.append(discount)
+ invoice_line_form.discount = (1 - total_discount_percentage) * 100
+
+ # Apply amount discount.
+ for discount in discount_list:
+ with invoice_form.invoice_line_ids.new() as invoice_line_form_discount:
+ invoice_line_form_discount.tax_ids.clear()
+ invoice_line_form_discount.sequence = discount["seq"]
+ invoice_line_form_discount.name = discount["name"]
+ invoice_line_form_discount.price_unit = discount["amount"]
+
+ new_invoice = invoice_form.save()
+ new_invoice.l10n_it_send_state = "other"
+
+ elements = body_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',
+ })
+
+ # default_res_id is had to context to avoid facturx to import his content
+ # no_new_invoice to prevent from looping on the message_post that would create a new invoice without it
+ new_invoice.with_context(no_new_invoice=True, default_res_id=new_invoice.id).message_post(
+ body=(_("Attachment from XML")),
+ attachment_ids=[attachment_64.id]
+ )
+
+ for message in message_to_log:
+ new_invoice.message_post(body=message)
+
+ invoices += new_invoice
+
+ return invoices
diff --git a/addons/l10n_it_edi/models/account_invoice.py b/addons/l10n_it_edi/models/account_invoice.py
new file mode 100644
index 00000000..df578e5d
--- /dev/null
+++ b/addons/l10n_it_edi/models/account_invoice.py
@@ -0,0 +1,317 @@
+# -*- coding:utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import base64
+import zipfile
+import io
+import logging
+import re
+
+from datetime import date, datetime
+from lxml import etree
+
+from odoo import api, fields, models, _
+from odoo.tools import float_repr
+from odoo.exceptions import UserError, ValidationError
+from odoo.addons.base.models.ir_mail_server import MailDeliveryException
+from odoo.tests.common import Form
+
+
+_logger = logging.getLogger(__name__)
+
+DEFAULT_FACTUR_ITALIAN_DATE_FORMAT = '%Y-%m-%d'
+
+
+class AccountMove(models.Model):
+ _inherit = 'account.move'
+
+ l10n_it_send_state = fields.Selection([
+ ('new', 'New'),
+ ('other', 'Other'),
+ ('to_send', 'Not yet send'),
+ ('sent', 'Sent, waiting for response'),
+ ('invalid', 'Sent, but invalid'),
+ ('delivered', 'This invoice is delivered'),
+ ('delivered_accepted', 'This invoice is delivered and accepted by destinatory'),
+ ('delivered_refused', 'This invoice is delivered and refused by destinatory'),
+ ('delivered_expired', 'This invoice is delivered and expired (expiry of the maximum term for communication of acceptance/refusal)'),
+ ('failed_delivery', 'Delivery impossible, ES certify that it has received the invoice and that the file \
+ could not be delivered to the addressee') # ok we must do nothing
+ ], default='to_send', copy=False)
+
+ l10n_it_stamp_duty = fields.Float(default=0, string="Dati Bollo", readonly=True, states={'draft': [('readonly', False)]})
+
+ l10n_it_ddt_id = fields.Many2one('l10n_it.ddt', string='DDT', readonly=True, states={'draft': [('readonly', False)]}, 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')
+
+ @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)
+ invoice.l10n_it_einvoice_id = einvoice.attachment_id
+ invoice.l10n_it_einvoice_name = einvoice.attachment_id.name
+
+ def _check_before_xml_exporting(self):
+ # DEPRECATED use AccountEdiFormat._l10n_it_edi_check_invoice_configuration instead
+ errors = self.env['account.edi.format']._l10n_it_edi_check_invoice_configuration(self)
+ if errors:
+ raise UserError(self.env['account.edi.format']._format_error_message(_("Invalid configuration:"), errors))
+
+ def invoice_generate_xml(self):
+ self.ensure_one()
+ report_name = self.env['account.edi.format']._l10n_it_edi_generate_electronic_invoice_filename(self)
+
+ data = b"<?xml version='1.0' encoding='UTF-8'?>" + self._export_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,
+ 'datas': base64.encodebytes(data),
+ '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 _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_phone(number):
+ if not number:
+ return False
+ number = number.replace(' ', '').replace('/', '').replace('.', '')
+ if len(number) > 4 and len(number) < 13:
+ return number
+ return False
+
+ def get_vat_number(vat):
+ return vat[2:].replace(' ', '')
+
+ def get_vat_country(vat):
+ return vat[:2].upper()
+
+ def in_eu(partner):
+ europe = self.env.ref('base.europe', raise_if_not_found=False)
+ country = partner.country_id
+ if not europe or not country or country in europe.country_ids:
+ return True
+ return False
+
+ formato_trasmissione = "FPR12"
+ if len(self.commercial_partner_id.l10n_it_pa_index or '1') == 6:
+ formato_trasmissione = "FPA12"
+
+ if self.move_type == 'out_invoice':
+ document_type = 'TD01'
+ elif self.move_type == 'out_refund':
+ document_type = 'TD04'
+ else:
+ document_type = 'TD0X'
+
+ pdf = self.env.ref('account.account_invoices')._render_qweb_pdf(self.id)[0]
+ pdf = base64.b64encode(pdf)
+ pdf_name = re.sub(r'\W+', '', self.name) + '.pdf'
+
+ # tax map for 0% taxes which have no tax_line_id
+ tax_map = dict()
+ for line in self.line_ids:
+ for tax in line.tax_ids:
+ if tax.amount == 0.0:
+ tax_map[tax] = tax_map.get(tax, 0.0) + line.price_subtotal
+
+ # Create file content.
+ template_values = {
+ 'record': self,
+ 'format_date': format_date,
+ 'format_monetary': format_monetary,
+ 'format_numbers': format_numbers,
+ 'format_numbers_two': format_numbers_two,
+ 'format_phone': format_phone,
+ 'discount_type': discount_type,
+ 'get_vat_number': get_vat_number,
+ 'get_vat_country': get_vat_country,
+ 'in_eu': in_eu,
+ 'abs': abs,
+ 'formato_trasmissione': formato_trasmissione,
+ 'document_type': document_type,
+ 'pdf': pdf,
+ 'pdf_name': pdf_name,
+ 'tax_map': tax_map,
+ }
+ return template_values
+
+ def _export_as_xml(self):
+ '''DEPRECATED : this will be moved to AccountEdiFormat in a future version.
+ Create the xml file content.
+ :return: The XML content as str.
+ '''
+ template_values = self._prepare_fatturapa_export_values()
+ content = self.env.ref('l10n_it_edi.account_invoice_it_FatturaPA_export')._render(template_values)
+ return content
+
+ def _post(self, soft=True):
+ # OVERRIDE
+ posted = super()._post(soft=soft)
+
+ for move in posted.filtered(lambda m: m.l10n_it_send_state == 'to_send' and m.move_type == 'out_invoice' and m.company_id.country_id.code == 'IT'):
+ move.send_pec_mail()
+
+ return posted
+
+ def send_pec_mail(self):
+ self.ensure_one()
+ allowed_state = ['to_send', 'invalid']
+
+ if (
+ not self.company_id.l10n_it_mail_pec_server_id
+ or not self.company_id.l10n_it_mail_pec_server_id.active
+ or not self.company_id.l10n_it_address_send_fatturapa
+ ):
+ self.message_post(
+ body=(_("Error when sending mail with E-Invoice: Your company must have a mail PEC server and must indicate the mail PEC that will send electronic invoice."))
+ )
+ self.l10n_it_send_state = 'invalid'
+ return
+
+ if self.l10n_it_send_state not in allowed_state:
+ raise UserError(_("%s isn't in a right state. It must be in a 'Not yet send' or 'Invalid' state.") % (self.display_name))
+
+ message = self.env['mail.message'].create({
+ 'subject': _('Sending file: %s') % (self.l10n_it_einvoice_name),
+ 'body': _('Sending file: %s to ES: %s') % (self.l10n_it_einvoice_name, self.env.company.l10n_it_address_recipient_fatturapa),
+ 'author_id': self.env.user.partner_id.id,
+ 'email_from': self.env.company.l10n_it_address_send_fatturapa,
+ 'reply_to': self.env.company.l10n_it_address_send_fatturapa,
+ 'mail_server_id': self.env.company.l10n_it_mail_pec_server_id.id,
+ 'attachment_ids': [(6, 0, self.l10n_it_einvoice_id.ids)],
+ })
+
+ mail_fattura = self.env['mail.mail'].sudo().with_context(wo_bounce_return_path=True).create({
+ 'mail_message_id': message.id,
+ 'email_to': self.env.company.l10n_it_address_recipient_fatturapa,
+ })
+ try:
+ mail_fattura.send(raise_exception=True)
+ self.message_post(
+ body=(_("Mail sent on %s by %s") % (fields.Datetime.now(), self.env.user.display_name))
+ )
+ self.l10n_it_send_state = 'sent'
+ except MailDeliveryException as error:
+ self.message_post(
+ body=(_("Error when sending mail with E-Invoice: %s") % (error.args[0]))
+ )
+ self.l10n_it_send_state = 'invalid'
+
+ def _compose_info_message(self, tree, element_tags):
+ output_str = ""
+ elements = tree.xpath(element_tags)
+ for element in elements:
+ output_str += "<ul>"
+ for line in element.iter():
+ if line.text:
+ text = " ".join(line.text.split())
+ if text:
+ output_str += "<li>%s: %s</li>" % (line.tag, text)
+ output_str += "</ul>"
+ return output_str
+
+ def _compose_multi_info_message(self, tree, element_tags):
+ output_str = "<ul>"
+
+ 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 += "<li>%s: %s</li>" % (element.tag, text)
+ return output_str + "</ul>"
+
+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 (vendite a distanza ex art. 40 c. 3 e 4 e art. 41 c. 1 lett. b, DL 331/93; prestazione di servizi di telecomunicazioni, tele-radiodiffusione ed elettronici ex art. 7-sexies lett. f, g, 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',
+ 'l10n_it_vat_due_date')
+ 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_vat_due_date == 'S':
+ raise UserError(_("'Scissione dei pagamenti' is not compatible with exoneration of kind 'N6'"))
diff --git a/addons/l10n_it_edi/models/ddt.py b/addons/l10n_it_edi/models/ddt.py
new file mode 100644
index 00000000..1047e823
--- /dev/null
+++ b/addons/l10n_it_edi/models/ddt.py
@@ -0,0 +1,18 @@
+# -*- coding:utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models, api
+
+class L10nItDdt(models.Model):
+ _name = 'l10n_it.ddt'
+ _description = 'Transport Document'
+
+ invoice_id = fields.One2many('account.move', 'l10n_it_ddt_id', string='Invoice Reference')
+ name = fields.Char(string="Numero DDT", size=20, help="Transport document number", required=True)
+ date = fields.Date(string="Data DDT", help="Transport document date", required=True)
+
+ def name_get(self):
+ res = []
+ for ddt in self:
+ res.append((ddt.id, ("%s (%s)") % (ddt.name, ddt.date)))
+ return res
diff --git a/addons/l10n_it_edi/models/ir_mail_server.py b/addons/l10n_it_edi/models/ir_mail_server.py
new file mode 100644
index 00000000..e74e6467
--- /dev/null
+++ b/addons/l10n_it_edi/models/ir_mail_server.py
@@ -0,0 +1,434 @@
+# -*- coding:utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import zipfile
+import io
+import re
+import logging
+import email
+import email.policy
+import dateutil
+import pytz
+
+from lxml import etree
+from datetime import datetime
+from xmlrpc import client as xmlrpclib
+
+from odoo import api, fields, models, tools, _
+from odoo.exceptions import ValidationError, UserError
+from odoo.addons.l10n_it_edi.tools.remove_signature import remove_signature
+
+
+_logger = logging.getLogger(__name__)
+
+class FetchmailServer(models.Model):
+ _name = 'fetchmail.server'
+ _inherit = 'fetchmail.server'
+
+ l10n_it_is_pec = fields.Boolean('PEC server', help="If PEC Server, only mail from '...@pec.fatturapa.it' will be processed.")
+ l10n_it_last_uid = fields.Integer(string='Last message UID', default=1)
+
+ def _search_edi_invoice(self, att_name, send_state=False):
+ """ Search sent l10n_it_edi fatturaPA invoices """
+
+ conditions = [
+ ('move_id', "!=", False),
+ ('edi_format_id.code', '=', 'fattura_pa'),
+ ('attachment_id.name', '=', att_name),
+ ]
+ if send_state:
+ conditions.append(('move_id.l10n_it_send_state', '=', send_state))
+
+ return self.env['account.edi.document'].search(conditions, limit=1).move_id
+
+ @api.constrains('l10n_it_is_pec', 'server_type')
+ def _check_pec(self):
+ for record in self:
+ if record.l10n_it_is_pec and record.server_type != 'imap':
+ raise ValidationError(_("PEC mail server must be of type IMAP."))
+
+ def fetch_mail(self):
+ """ WARNING: meant for cron usage only - will commit() after each email! """
+
+ MailThread = self.env['mail.thread']
+ for server in self.filtered(lambda s: s.l10n_it_is_pec):
+ _logger.info('start checking for new emails on %s PEC server %s', server.server_type, server.name)
+
+ count, failed = 0, 0
+ imap_server = None
+ try:
+ imap_server = server.connect()
+ imap_server.select()
+
+ # Only download new emails
+ email_filter = ['(UID %s:*)' % (server.l10n_it_last_uid)]
+
+ # The l10n_it_edi.fatturapa_bypass_incoming_address_filter prevents the sender address check on incoming email.
+ bypass_incoming_address_filter = self.env['ir.config_parameter'].get_param('l10n_it_edi.bypass_incoming_address_filter', False)
+ if not bypass_incoming_address_filter:
+ email_filter.append('(FROM "@pec.fatturapa.it")')
+
+ data = imap_server.uid('search', None, *email_filter)[1]
+
+ new_max_uid = server.l10n_it_last_uid
+ for uid in data[0].split():
+ if int(uid) <= server.l10n_it_last_uid:
+ # We get always minimum 1 message. If no new message, we receive the newest already managed.
+ continue
+
+ result, data = imap_server.uid('fetch', uid, '(RFC822)')
+
+ if not data[0]:
+ continue
+ message = data[0][1]
+
+ # To leave the mail in the state in which they were.
+ if "Seen" not in data[1].decode("utf-8"):
+ imap_server.uid('STORE', uid, '+FLAGS', '\\Seen')
+ else:
+ imap_server.uid('STORE', uid, '-FLAGS', '\\Seen')
+
+ # See details in message_process() in mail_thread.py
+ if isinstance(message, xmlrpclib.Binary):
+ message = bytes(message.data)
+ if isinstance(message, str):
+ message = message.encode('utf-8')
+ msg_txt = email.message_from_bytes(message, policy=email.policy.SMTP)
+
+ try:
+ self._attachment_invoice(msg_txt)
+ new_max_uid = max(new_max_uid, int(uid))
+ except Exception:
+ _logger.info('Failed to process mail from %s server %s.', server.server_type, server.name, exc_info=True)
+ failed += 1
+ self._cr.commit()
+ count += 1
+ server.write({'l10n_it_last_uid': new_max_uid})
+ _logger.info("Fetched %d email(s) on %s server %s; %d succeeded, %d failed.", count, server.server_type, server.name, (count - failed), failed)
+ except Exception:
+ _logger.info("General failure when trying to fetch mail from %s server %s.", server.server_type, server.name, exc_info=True)
+ finally:
+ if imap_server:
+ imap_server.close()
+ imap_server.logout()
+ server.write({'date': fields.Datetime.now()})
+ return super(FetchmailServer, self.filtered(lambda s: not s.l10n_it_is_pec)).fetch_mail()
+
+ def _attachment_invoice(self, msg_txt):
+ parsed_values = self.env['mail.thread']._message_parse_extract_payload(msg_txt)
+ body, attachments = parsed_values['body'], parsed_values['attachments']
+ from_address = msg_txt.get('from')
+ for attachment in attachments:
+ split_attachment = attachment.fname.rpartition('.')
+ if len(split_attachment) < 3:
+ _logger.info('E-invoice filename not compliant: %s', attachment.fname)
+ continue
+ attachment_name = split_attachment[0]
+ attachment_ext = split_attachment[2]
+ split_underscore = attachment_name.rsplit('_', 2)
+ if len(split_underscore) < 2:
+ _logger.info('E-invoice filename not compliant: %s', attachment.fname)
+ continue
+
+ if attachment_ext != 'zip':
+ if split_underscore[1] in ['RC', 'NS', 'MC', 'MT', 'EC', 'SE', 'NE', 'DT']:
+ # we have a receipt
+ self._message_receipt_invoice(split_underscore[1], attachment)
+ else:
+ att_filename = attachment.fname
+ match = re.search("([A-Z]{2}[A-Za-z0-9]{2,28}_[A-Za-z0-9]{0,5}.(xml.p7m|xml))", att_filename)
+ # If match, we have an invoice.
+ if match:
+ # If it's signed, the content has a bytes type and we just remove the signature's envelope
+ if match.groups()[1] == 'xml.p7m':
+ att_content_data = remove_signature(attachment.content)
+ # If the envelope cannot be removed, the remove_signature returns None, so we skip
+ if not att_content_data:
+ _logger.warning("E-invoice couldn't be read: %s", att_filename)
+ continue
+ att_filename = att_filename.replace('.xml.p7m', '.xml')
+ else:
+ # Otherwise, it should be an utf-8 encoded XML string
+ att_content_data = attachment.content.encode()
+ self._create_invoice_from_mail(att_content_data, att_filename, from_address)
+ else:
+ if split_underscore[1] == 'AT':
+ # Attestazione di avvenuta trasmissione della fattura con impossibilità di recapito
+ self._message_AT_invoice(attachment)
+ else:
+ _logger.info('New E-invoice in zip file: %s', attachment.fname)
+ self._create_invoice_from_mail_with_zip(attachment, from_address)
+
+ def _create_invoice_from_mail(self, att_content_data, att_name, from_address):
+ """ Creates an invoice from the content of an email present in ir.attachments
+
+ :param att_content_data: The 'utf-8' encoded bytes string representing the content of the attachment.
+ :param att_name: The attachment's file name.
+ :param from_address: The sender address of the email.
+ """
+
+ invoices = self.env['account.move']
+
+ # Check if we already imported the email as an attachment
+ existing = self.env['ir.attachment'].search([('name', '=', att_name), ('res_model', '=', 'account.move')])
+ if existing:
+ _logger.info('E-invoice already exist: %s', att_name)
+ return invoices
+
+ # Create the new attachment for the file
+ attachment = self.env['ir.attachment'].create({
+ 'name': att_name,
+ 'raw': att_content_data,
+ 'res_model': 'account.move',
+ 'type': 'binary'})
+
+ # Decode the file.
+ try:
+ tree = etree.fromstring(att_content_data)
+ except Exception:
+ _logger.info('The xml file is badly formatted: %s', att_name)
+ return invoices
+
+ invoices = self.env.ref('l10n_it_edi.edi_fatturaPA')._create_invoice_from_xml_tree(att_name, tree)
+ if not invoices:
+ _logger.info('E-invoice not found in file: %s', att_name)
+ return invoices
+ invoices.l10n_it_send_state = "new"
+ invoices.invoice_source_email = from_address
+ for invoice in invoices:
+ invoice.with_context(no_new_invoice=True, default_res_id=invoice.id) \
+ .message_post(body=(_("Original E-invoice XML file")), attachment_ids=[attachment.id])
+
+ self._cr.commit()
+
+ _logger.info('New E-invoices (%s), ids: %s', att_name, [x.id for x in invoices])
+ return invoices
+
+ def _create_invoice_from_mail_with_zip(self, attachment_zip, from_address):
+ with zipfile.ZipFile(io.BytesIO(attachment_zip.content)) as z:
+ for att_name in z.namelist():
+ existing = self.env['ir.attachment'].search([('name', '=', att_name), ('res_model', '=', 'account.move')])
+ if existing:
+ # invoice already exist
+ _logger.info('E-invoice in zip file (%s) already exist: %s', attachment_zip.fname, att_name)
+ continue
+ att_content = z.open(att_name).read()
+
+ self._create_invoice_from_mail(att_content, att_name, from_address)
+
+ def _message_AT_invoice(self, attachment_zip):
+ with zipfile.ZipFile(io.BytesIO(attachment_zip.content)) as z:
+ for attachment_name in z.namelist():
+ split_name_attachment = attachment_name.rpartition('.')
+ if len(split_name_attachment) < 3:
+ continue
+ split_underscore = split_name_attachment[0].rsplit('_', 2)
+ if len(split_underscore) < 2:
+ continue
+ if split_underscore[1] == 'AT':
+ attachment = z.open(attachment_name).read()
+ _logger.info('New AT receipt for: %s', split_underscore[0])
+ try:
+ tree = etree.fromstring(attachment)
+ except:
+ _logger.info('Error in decoding new receipt file: %s', attachment_name)
+ return
+
+ elements = tree.xpath('//NomeFile')
+ if elements and elements[0].text:
+ filename = elements[0].text
+ else:
+ return
+
+ related_invoice = self._search_edi_invoice(filename)
+ if not related_invoice:
+ _logger.info('Error: invoice not found for receipt file: %s', filename)
+ return
+
+ related_invoice.l10n_it_send_state = 'failed_delivery'
+ info = self._return_multi_line_xml(tree, ['//IdentificativoSdI', '//DataOraRicezione', '//MessageId', '//PecMessageId', '//Note'])
+ related_invoice.message_post(
+ body=(_("ES certify that it has received the invoice and that the file \
+ could not be delivered to the addressee. <br/>%s") % (info))
+ )
+
+ def _message_receipt_invoice(self, receipt_type, attachment):
+
+ try:
+ tree = etree.fromstring(attachment.content.encode())
+ except:
+ _logger.info('Error in decoding new receipt file: %s', attachment.fname)
+ return {}
+
+ elements = tree.xpath('//NomeFile')
+ if elements and elements[0].text:
+ filename = elements[0].text
+ else:
+ return {}
+
+ if receipt_type == 'RC':
+ # Delivery receipt
+ # This is the receipt sent by the ES to the transmitting subject to communicate
+ # delivery of the file to the addressee
+ related_invoice = self._search_edi_invoice(filename, 'sent')
+ if not related_invoice:
+ _logger.info('Error: invoice not found for receipt file: %s', attachment.fname)
+ return
+ related_invoice.l10n_it_send_state = 'delivered'
+ info = self._return_multi_line_xml(tree, ['//IdentificativoSdI', '//DataOraRicezione', '//DataOraConsegna', '//Note'])
+ related_invoice.message_post(
+ body=(_("E-Invoice is delivery to the destinatory:<br/>%s") % (info))
+ )
+
+ elif receipt_type == 'NS':
+ # Rejection notice
+ # This is the receipt sent by the ES to the transmitting subject if one or more of
+ # the checks carried out by the ES on the file received do not have a successful result.
+ related_invoice = self._search_edi_invoice(filename, 'sent')
+ if not related_invoice:
+ _logger.info('Error: invoice not found for receipt file: %s', attachment.fname)
+ return
+ related_invoice.l10n_it_send_state = 'invalid'
+ error = self._return_error_xml(tree)
+ related_invoice.message_post(
+ body=(_("Errors in the E-Invoice :<br/>%s") % (error))
+ )
+ related_invoice.activity_schedule(
+ 'mail.mail_activity_data_todo',
+ summary='Rejection notice',
+ user_id=related_invoice.invoice_user_id.id if related_invoice.invoice_user_id else self.env.user.id)
+
+ elif receipt_type == 'MC':
+ # Failed delivery notice
+ # This is the receipt sent by the ES to the transmitting subject if the file is not
+ # delivered to the addressee.
+ related_invoice = self._search_edi_invoice(filename, 'sent')
+ if not related_invoice:
+ _logger.info('Error: invoice not found for receipt file: %s', attachment.fname)
+ return
+ info = self._return_multi_line_xml(tree, [
+ '//IdentificativoSdI',
+ '//DataOraRicezione',
+ '//Descrizione',
+ '//MessageId',
+ '//Note'])
+ related_invoice.message_post(
+ body=(_("The E-invoice is not delivered to the addressee. The Exchange System is\
+ unable to deliver the file to the Public Administration. The Exchange System will\
+ contact the PA to report the problem and request that they provide a solution. \
+ During the following 15 days, the Exchange System will try to forward the FatturaPA\
+ file to the Administration in question again. More information:<br/>%s") % (info))
+ )
+
+ elif receipt_type == 'NE':
+ # Outcome notice
+ # This is the receipt sent by the ES to the invoice sender to communicate the result
+ # (acceptance or refusal of the invoice) of the checks carried out on the document by
+ # the addressee.
+ related_invoice = self._search_edi_invoice(filename, 'delivered')
+ if not related_invoice:
+ _logger.info('Error: invoice not found for receipt file: %s', attachment.fname)
+ return
+ elements = tree.xpath('//Esito')
+ if elements and elements[0].text:
+ if elements[0].text == 'EC01':
+ related_invoice.l10n_it_send_state = 'delivered_accepted'
+ elif elements[0].text == 'EC02':
+ related_invoice.l10n_it_send_state = 'delivered_refused'
+
+ info = self._return_multi_line_xml(tree,
+ ['//Esito',
+ '//Descrizione',
+ '//IdentificativoSdI',
+ '//DataOraRicezione',
+ '//DataOraConsegna',
+ '//Note'
+ ])
+ related_invoice.message_post(
+ body=(_("Outcome notice: %s<br/>%s") % (related_invoice.l10n_it_send_state, info))
+ )
+ if related_invoice.l10n_it_send_state == 'delivered_refused':
+ related_invoice.activity_schedule(
+ 'mail.mail_activity_todo',
+ user_id=related_invoice.invoice_user_id.id if related_invoice.invoice_user_id else self.env.user.id,
+ summary='Outcome notice: Refused')
+
+ # elif receipt_type == 'MT':
+ # Metadata file
+ # This is the file sent by the ES to the addressee together with the invoice file,
+ # containing the main reference data of the file useful for processing, including
+ # the IdentificativoSDI.
+ # Useless for Odoo
+
+ elif receipt_type == 'DT':
+ # Deadline passed notice
+ # This is the receipt sent by the ES to both the invoice sender and the invoice
+ # addressee to communicate the expiry of the maximum term for communication of
+ # acceptance/refusal.
+ related_invoice = self._search_edi_invoice(filename, 'delivered')
+ if not related_invoice:
+ _logger.info('Error: invoice not found for receipt file: %s', attachment.fname)
+ return
+ related_invoice.l10n_it_send_state = 'delivered_expired'
+ info = self._return_multi_line_xml(tree, [
+ '//Descrizione',
+ '//IdentificativoSdI',
+ '//Note'])
+ related_invoice.message_post(
+ body=(_("Expiration of the maximum term for communication of acceptance/refusal:\
+ %s<br/>%s") % (filename, info))
+ )
+
+ def _return_multi_line_xml(self, tree, element_tags):
+ output_str = "<ul>"
+
+ for element_tag in element_tags:
+ elements = tree.xpath(element_tag)
+ if not elements:
+ continue
+ for element in elements:
+ if element.text:
+ text = " ".join(element.text.split())
+ output_str += "<li>%s: %s</li>" % (element.tag, text)
+ return output_str + "</ul>"
+
+ def _return_error_xml(self, tree):
+ output_str = "<ul>"
+
+ elements = tree.xpath('//Errore')
+ if not elements:
+ return
+ for element in elements:
+ descrizione = " ".join(element[1].text.split())
+ if descrizione:
+ output_str += "<li>Errore %s: %s</li>" % (element[0].text, descrizione)
+ return output_str + "</ul>"
+
+class IrMailServer(models.Model):
+ _name = "ir.mail_server"
+ _inherit = "ir.mail_server"
+
+ def _get_test_email_addresses(self):
+ self.ensure_one()
+
+ company = self.env["res.company"].search([("l10n_it_mail_pec_server_id", "=", self.id)], limit=1)
+ if not company:
+ # it's not a PEC server
+ return super()._get_test_email_addresses()
+ email_from = self.smtp_user
+ if not email_from:
+ raise UserError(_('Please configure Username for this Server PEC'))
+ email_to = company.l10n_it_address_recipient_fatturapa
+ if not email_to:
+ raise UserError(_('Please configure Government PEC-mail in company settings'))
+ return email_from, email_to
+
+ def build_email(self, email_from, email_to, subject, body, email_cc=None, email_bcc=None, reply_to=False,
+ attachments=None, message_id=None, references=None, object_id=False, subtype='plain', headers=None,
+ body_alternative=None, subtype_alternative='plain'):
+
+ if self.env.context.get('wo_bounce_return_path') and headers:
+ headers['Return-Path'] = email_from
+ return super(IrMailServer, self).build_email(email_from, email_to, subject, body, email_cc=email_cc, email_bcc=email_bcc, reply_to=reply_to,
+ attachments=attachments, message_id=message_id, references=references, object_id=object_id, subtype=subtype, headers=headers,
+ body_alternative=body_alternative, subtype_alternative=subtype_alternative)
diff --git a/addons/l10n_it_edi/models/res_company.py b/addons/l10n_it_edi/models/res_company.py
new file mode 100644
index 00000000..71c651f8
--- /dev/null
+++ b/addons/l10n_it_edi/models/res_company.py
@@ -0,0 +1,110 @@
+# -*- 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
+
+TAX_SYSTEM = [
+ ("RF01", "[RF01] Ordinario"),
+ ("RF02", "[RF02] Contribuenti minimi (art.1, c.96-117, L. 244/07)"),
+ ("RF04", "[RF04] Agricoltura e attività connesse e pesca (artt.34 e 34-bis, DPR 633/72)"),
+ ("RF05", "[RF05] Vendita sali e tabacchi (art.74, c.1, DPR. 633/72)"),
+ ("RF06", "[RF06] Commercio fiammiferi (art.74, c.1, DPR 633/72)"),
+ ("RF07", "[RF07] Editoria (art.74, c.1, DPR 633/72)"),
+ ("RF08", "[RF08] Gestione servizi telefonia pubblica (art.74, c.1, DPR 633/72)"),
+ ("RF09", "[RF09] Rivendita documenti di trasporto pubblico e di sosta (art.74, c.1, DPR 633/72)"),
+ ("RF10", "[RF10] Intrattenimenti, giochi e altre attività di cui alla tariffa allegata al DPR 640/72 (art.74, c.6, DPR 633/72)"),
+ ("RF11", "[RF11] Agenzie viaggi e turismo (art.74-ter, DPR 633/72)"),
+ ("RF12", "[RF12] Agriturismo (art.5, c.2, L. 413/91)"),
+ ("RF13", "[RF13] Vendite a domicilio (art.25-bis, c.6, DPR 600/73)"),
+ ("RF14", "[RF14] Rivendita beni usati, oggetti d’arte, d’antiquariato o da collezione (art.36, DL 41/95)"),
+ ("RF15", "[RF15] Agenzie di vendite all’asta di oggetti d’arte, antiquariato o da collezione (art.40-bis, DL 41/95)"),
+ ("RF16", "[RF16] IVA per cassa P.A. (art.6, c.5, DPR 633/72)"),
+ ("RF17", "[RF17] IVA per cassa (art. 32-bis, DL 83/2012)"),
+ ("RF18", "[RF18] Altro"),
+ ("RF19", "[RF19] Regime forfettario (art.1, c.54-89, L. 190/2014)"),
+]
+
+class ResCompany(models.Model):
+ _name = 'res.company'
+ _inherit = 'res.company'
+
+ l10n_it_codice_fiscale = fields.Char(string="Codice Fiscale", size=16, related='partner_id.l10n_it_codice_fiscale',
+ store=True, readonly=False, help="Fiscal code of your company")
+ l10n_it_tax_system = fields.Selection(selection=TAX_SYSTEM, string="Tax System",
+ help="Please select the Tax system to which you are subjected.")
+
+ # PEC server
+ l10n_it_mail_pec_server_id = fields.Many2one('ir.mail_server', string="Server PEC",
+ help="Configure your PEC-mail server to send electronic invoices.")
+ l10n_it_address_recipient_fatturapa = fields.Char(string="Government PEC-mail",
+ help="Enter Government PEC-mail address. Ex: sdi01@pec.fatturapa.it")
+ l10n_it_address_send_fatturapa = fields.Char(string="Company PEC-mail",
+ help="Enter your company PEC-mail address. Ex: yourcompany@pec.mail.it")
+
+
+ # Economic and Administrative Index
+ l10n_it_has_eco_index = fields.Boolean(default=False,
+ help="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)")
+ l10n_it_eco_index_office = fields.Many2one('res.country.state', domain="[('country_id','=','IT')]",
+ string="Province of the register-of-companies office")
+ l10n_it_eco_index_number = fields.Char(string="Number in register of companies", size=20,
+ help="This field must contain the number under which the\
+ seller/provider is listed on the register of companies.")
+ l10n_it_eco_index_share_capital = fields.Float(default=0.0, string="Share capital actually paid up",
+ help="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")
+ l10n_it_eco_index_sole_shareholder = fields.Selection(
+ [
+ ("NO", "Not a limited liability company"),
+ ("SU", "Socio unico"),
+ ("SM", "Più soci")],
+ string="Shareholder")
+ l10n_it_eco_index_liquidation_state = fields.Selection(
+ [
+ ("LS", "The company is in a state of liquidation"),
+ ("LN", "The company is not in a state of liquidation")],
+ string="Liquidation state")
+
+
+ # Tax representative
+ l10n_it_has_tax_representative = fields.Boolean(default=False,
+ help="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")
+ l10n_it_tax_representative_partner_id = fields.Many2one('res.partner', string='Tax representative partner')
+
+ @api.constrains('l10n_it_has_eco_index',
+ 'l10n_it_eco_index_office',
+ 'l10n_it_eco_index_number',
+ 'l10n_it_eco_index_share_capital',
+ 'l10n_it_eco_index_sole_shareholder',
+ 'l10n_it_eco_index_liquidation_state')
+ def _check_eco_admin_index(self):
+ for record in self:
+ if not record.l10n_it_has_eco_index:
+ continue
+ if not record.l10n_it_eco_index_office\
+ or not record.l10n_it_eco_index_number\
+ or not record.l10n_it_eco_index_share_capital\
+ or not record.l10n_it_eco_index_sole_shareholder\
+ or not record.l10n_it_eco_index_liquidation_state:
+ raise ValidationError(_("All fields about the Economic and Administrative Index must be completed."))
+
+ @api.constrains('l10n_it_has_tax_representative',
+ 'l10n_it_tax_representative_partner_id')
+ def _check_tax_representative(self):
+ for record in self:
+ if not record.l10n_it_has_tax_representative:
+ continue
+ if not record.l10n_it_tax_representative_partner_id:
+ raise ValidationError(_("You must select a tax representative."))
+ if not record.l10n_it_tax_representative_partner_id.vat:
+ raise ValidationError(_("Your tax representative partner must have a tax number."))
+ if not record.l10n_it_tax_representative_partner_id.country_id:
+ raise ValidationError(_("Your tax representative partner must have a country."))
diff --git a/addons/l10n_it_edi/models/res_partner.py b/addons/l10n_it_edi/models/res_partner.py
new file mode 100644
index 00000000..3a1d8d71
--- /dev/null
+++ b/addons/l10n_it_edi/models/res_partner.py
@@ -0,0 +1,49 @@
+# -*- 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 UserError, ValidationError
+
+import re
+
+
+class ResPartner(models.Model):
+ _name = 'res.partner'
+ _inherit = 'res.partner'
+
+ l10n_it_pec_email = fields.Char(string="PEC e-mail")
+ l10n_it_codice_fiscale = fields.Char(string="Codice Fiscale", size=16)
+ l10n_it_pa_index = fields.Char(string="PA index",
+ size=7,
+ help="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.")
+
+ _sql_constraints = [
+ ('l10n_it_codice_fiscale',
+ "CHECK(l10n_it_codice_fiscale IS NULL OR l10n_it_codice_fiscale = '' OR LENGTH(l10n_it_codice_fiscale) >= 11)",
+ "Codice fiscale must have between 11 and 16 characters."),
+
+ ('l10n_it_pa_index',
+ "CHECK(l10n_it_pa_index IS NULL OR l10n_it_pa_index = '' OR LENGTH(l10n_it_pa_index) >= 6)",
+ "PA index must have between 6 and 7 characters."),
+ ]
+
+ @api.model
+ def _l10n_it_normalize_codice_fiscale(self, codice):
+ if codice and re.match(r'^IT[0-9]{11}$', codice):
+ return codice[2:13]
+ return codice
+
+ @api.onchange('vat')
+ def _l10n_it_onchange_vat(self):
+ if not self.l10n_it_codice_fiscale:
+ self.l10n_it_codice_fiscale = self._l10n_it_normalize_codice_fiscale(self.vat)
+
+ @api.constrains('l10n_it_codice_fiscale')
+ def validate_codice_fiscale(self):
+ p = re.compile(r'^([A-Za-z]{6}[0-9]{2}[A-Za-z]{1}[0-9]{2}[A-Za-z]{1}[0-9]{3}[A-Za-z]{1}$)|([0-9]{11})|(IT[0-9]{11})$')
+ for record in self:
+ if record.l10n_it_codice_fiscale and not p.match(record.l10n_it_codice_fiscale):
+ raise UserError(_("Invalid Codice Fiscale '%s': should be like 'MRTMTT91D08F205J' for physical person and '12345678901' or 'IT12345678901' for businesses.", record.l10n_it_codice_fiscale))
diff --git a/addons/l10n_it_edi/security/ir.model.access.csv b/addons/l10n_it_edi/security/ir.model.access.csv
new file mode 100644
index 00000000..22c0da9c
--- /dev/null
+++ b/addons/l10n_it_edi/security/ir.model.access.csv
@@ -0,0 +1,2 @@
+"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
+"access_it_ddt_manager","it_ddt manager","model_l10n_it_ddt","account.group_account_invoice",1,1,1,1 \ No newline at end of file
diff --git a/addons/l10n_it_edi/tests/__init__.py b/addons/l10n_it_edi/tests/__init__.py
new file mode 100644
index 00000000..c2bcfc21
--- /dev/null
+++ b/addons/l10n_it_edi/tests/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import test_ir_mail_server
diff --git a/addons/l10n_it_edi/tests/expected_xmls/IT01234567890_FPR01.xml b/addons/l10n_it_edi/tests/expected_xmls/IT01234567890_FPR01.xml
new file mode 100644
index 00000000..936b6d4a
--- /dev/null
+++ b/addons/l10n_it_edi/tests/expected_xmls/IT01234567890_FPR01.xml
@@ -0,0 +1,113 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<p:FatturaElettronica versione="FPR12" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
+xmlns:p="http://ivaservizi.agenziaentrate.gov.it/docs/xsd/fatture/v1.2"
+xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+xsi:schemaLocation="http://ivaservizi.agenziaentrate.gov.it/docs/xsd/fatture/v1.2 http://www.fatturapa.gov.it/export/fatturazione/sdi/fatturapa/v1.2/Schema_del_file_xml_FatturaPA_versione_1.2.xsd">
+ <FatturaElettronicaHeader>
+ <DatiTrasmissione>
+ <IdTrasmittente>
+ <IdPaese>IT</IdPaese>
+ <IdCodice>01234560157</IdCodice>
+ </IdTrasmittente>
+ <ProgressivoInvio>00001</ProgressivoInvio>
+ <FormatoTrasmissione>FPR12</FormatoTrasmissione>
+ <CodiceDestinatario>ABC1234</CodiceDestinatario>
+ <ContattiTrasmittente/>
+ </DatiTrasmissione>
+ <CedentePrestatore>
+ <DatiAnagrafici>
+ <IdFiscaleIVA>
+ <IdPaese>IT</IdPaese>
+ <IdCodice>01234560157</IdCodice>
+ </IdFiscaleIVA>
+ <Anagrafica>
+ <Denominazione>SOCIETA' ALPHA SRL</Denominazione>
+ </Anagrafica>
+ <RegimeFiscale>RF19</RegimeFiscale>
+ </DatiAnagrafici>
+ <Sede>
+ <Indirizzo>VIALE ROMA 543</Indirizzo>
+ <CAP>07100</CAP>
+ <Comune>SASSARI</Comune>
+ <Provincia>SS</Provincia>
+ <Nazione>IT</Nazione>
+ </Sede>
+ </CedentePrestatore>
+ <CessionarioCommittente>
+ <DatiAnagrafici>
+ <CodiceFiscale>01234560157</CodiceFiscale>
+ <Anagrafica>
+ <Denominazione>DITTA BETA</Denominazione>
+ </Anagrafica>
+ </DatiAnagrafici>
+ <Sede>
+ <Indirizzo>VIA TORINO 38-B</Indirizzo>
+ <CAP>00145</CAP>
+ <Comune>ROMA</Comune>
+ <Provincia>RM</Provincia>
+ <Nazione>IT</Nazione>
+ </Sede>
+ </CessionarioCommittente>
+ </FatturaElettronicaHeader>
+ <FatturaElettronicaBody>
+ <DatiGenerali>
+ <DatiGeneraliDocumento>
+ <TipoDocumento>TD01</TipoDocumento>
+ <Divisa>EUR</Divisa>
+ <Data>2014-12-18</Data>
+ <Numero>01234567890</Numero>
+ <Causale>LA FATTURA FA RIFERIMENTO AD UNA OPERAZIONE AAAA BBBBBBBBBBBBBBBBBB CCC DDDDDDDDDDDDDDD E FFFFFFFFFFFFFFFFFFFF GGGGGGGGGG HHHHHHH II LLLLLLLLLLLLLLLLL MMM NNNNN OO PPPPPPPPPPP QQQQ RRRR SSSSSSSSSSSSSS</Causale>
+ <Causale>SEGUE DESCRIZIONE CAUSALE NEL CASO IN CUI NON SIANO STATI SUFFICIENTI 200 CARATTERI AAAAAAAAAAA BBBBBBBBBBBBBBBBB</Causale>
+ </DatiGeneraliDocumento>
+ <DatiOrdineAcquisto>
+ <RiferimentoNumeroLinea>1</RiferimentoNumeroLinea>
+ <IdDocumento>66685</IdDocumento>
+ <NumItem>1</NumItem>
+ </DatiOrdineAcquisto>
+ <DatiContratto>
+ <RiferimentoNumeroLinea>1</RiferimentoNumeroLinea>
+ <IdDocumento>01234567890</IdDocumento>
+ <Data>2012-09-01</Data>
+ <NumItem>5</NumItem>
+ <CodiceCUP>01234567890abc</CodiceCUP>
+ <CodiceCIG>456def</CodiceCIG>
+ </DatiContratto>
+ <DatiTrasporto>
+ <DatiAnagraficiVettore>
+ <IdFiscaleIVA>
+ <IdPaese>IT</IdPaese>
+ <IdCodice>24681012141</IdCodice>
+ </IdFiscaleIVA>
+ <Anagrafica>
+ <Denominazione>Trasporto spa</Denominazione>
+ </Anagrafica>
+ </DatiAnagraficiVettore>
+ <DataOraConsegna>2012-10-22T16:46:12.000+02:00</DataOraConsegna>
+ </DatiTrasporto>
+ </DatiGenerali>
+ <DatiBeniServizi>
+ <DettaglioLinee>
+ <NumeroLinea>1</NumeroLinea>
+ <Descrizione>DESCRIZIONE DELLA FORNITURA</Descrizione>
+ <Quantita>5.00</Quantita>
+ <PrezzoUnitario>1.00</PrezzoUnitario>
+ <PrezzoTotale>5.00</PrezzoTotale>
+ <AliquotaIVA>22.00</AliquotaIVA>
+ </DettaglioLinee>
+ <DatiRiepilogo>
+ <AliquotaIVA>22.00</AliquotaIVA>
+ <ImponibileImporto>5.00</ImponibileImporto>
+ <Imposta>1.10</Imposta>
+ <EsigibilitaIVA>I</EsigibilitaIVA>
+ </DatiRiepilogo>
+ </DatiBeniServizi>
+ <DatiPagamento>
+ <CondizioniPagamento>TP01</CondizioniPagamento>
+ <DettaglioPagamento>
+ <ModalitaPagamento>MP01</ModalitaPagamento>
+ <DataScadenzaPagamento>2015-01-30</DataScadenzaPagamento>
+ <ImportoPagamento>6.10</ImportoPagamento>
+ </DettaglioPagamento>
+ </DatiPagamento>
+ </FatturaElettronicaBody>
+</p:FatturaElettronica>
diff --git a/addons/l10n_it_edi/tests/expected_xmls/IT01234567890_FPR01.xml.p7m b/addons/l10n_it_edi/tests/expected_xmls/IT01234567890_FPR01.xml.p7m
new file mode 100644
index 00000000..ecb48417
--- /dev/null
+++ b/addons/l10n_it_edi/tests/expected_xmls/IT01234567890_FPR01.xml.p7m
Binary files differ
diff --git a/addons/l10n_it_edi/tests/expected_xmls/IT01234567890_FPR01_DT_001.xml b/addons/l10n_it_edi/tests/expected_xmls/IT01234567890_FPR01_DT_001.xml
new file mode 100644
index 00000000..10788771
--- /dev/null
+++ b/addons/l10n_it_edi/tests/expected_xmls/IT01234567890_FPR01_DT_001.xml
@@ -0,0 +1,71 @@
+<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet type="text/xsl" href="DT_v1.0.xsl"?>
+<types:NotificaDecorrenzaTermini xmlns:types="http://www.fatturapa.gov.it/sdi/messaggi/v1.0" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" IntermediarioConDupliceRuolo="Si" versione="1.0" xsi:schemaLocation="http://www.fatturapa.gov.it/sdi/messaggi/v1.0 MessaggiTypes_v1.0.xsd http://www.w3.org/2000/09/xmldsig# xmldsig-core-schema.xsd">
+ <IdentificativoSdI>111</IdentificativoSdI>
+ <NomeFile>IT01234567890_FPR01.xml</NomeFile>
+ <Descrizione>Notifica di esempio</Descrizione>
+ <MessageId>123456</MessageId>
+ <Note>Esempio</Note>
+ <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Id="Signature1">
+ <ds:SignedInfo>
+ <ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
+ <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
+ <ds:Reference Id="reference-document" URI="">
+ <ds:Transforms>
+ <ds:Transform Algorithm="http://www.w3.org/2002/06/xmldsig-filter2">
+ <XPath xmlns="http://www.w3.org/2002/06/xmldsig-filter2" Filter="subtract">/descendant::ds:Signature</XPath>
+ </ds:Transform>
+ </ds:Transforms>
+ <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
+ <ds:DigestValue>g6h8KnGd+Y4DCdnGk5oIUbBwjJB3MMGlyizaFyCqH7I=</ds:DigestValue>
+ </ds:Reference>
+ <ds:Reference Id="reference-signedpropeties" Type="http://uri.etsi.org/01903#SignedProperties" URI="#SignedProperties_1">
+ <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
+ <ds:DigestValue>LkOlfB97QK/evb7mYg+KkxW3BSiZre63y3Qeh/rV28E=</ds:DigestValue>
+ </ds:Reference>
+ <ds:Reference Id="reference-keyinfo" URI="#KeyInfoId">
+ <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
+ <ds:DigestValue>BaZyFTXyxM8aIJhtiemem1lEwKR75ksXb33lsMqD89w=</ds:DigestValue>
+ </ds:Reference>
+ </ds:SignedInfo>
+ <ds:SignatureValue Id="SignatureValue1">
+Z8/Kt/ZF/syaHxYr6/qoTz+nTJe3IV1m9Hj3WPOl1CZ/p5intUORW0IinpMum4rvPkLYpKPVbi39
+WCJujEqVOVFw5xezZlwmrRghmUeyTyKazK7mKEEMXCad+FGCZj2Gz1nkqi5aNyNX/lN7m9Ix7rZ8
+br3Fi3bi3nNMdyUmwog=
+</ds:SignatureValue>
+ <ds:KeyInfo Id="KeyInfoId">
+ <ds:X509Data>
+ <ds:X509Certificate>
+MIIEYDCCA0igAwIBAgIDEIgbMA0GCSqGSIb3DQEBBQUAMG0xCzAJBgNVBAYTAklUMR4wHAYDVQQK
+ExVBZ2VuemlhIGRlbGxlIEVudHJhdGUxGzAZBgNVBAsTElNlcnZpemkgVGVsZW1hdGljaTEhMB8G
+A1UEAxMYQ0EgQWdlbnppYSBkZWxsZSBFbnRyYXRlMB4XDTExMDcwNDEzMTkyNFoXDTE0MDcwNDEz
+MTkyM1owdDELMAkGA1UEBhMCSVQxHjAcBgNVBAoTFUFnZW56aWEgZGVsbGUgRW50cmF0ZTEbMBkG
+A1UECxMSU2Vydml6aSBUZWxlbWF0aWNpMSgwJgYDVQQDEx9TaXN0ZW1hIEludGVyc2NhbWJpbyBG
+YXR0dXJhIFBBMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDMxOQj1dj6xgQBwB/S5naHvVqP
+FL25Y3GnAulrcaeO8ZFFK5fWKPgiBwfyJ7qdlzn/RF7y+w92XLgh9zROmNlIjsJcp3rRwsAiKjuW
+CkqwVXE0/Qtvxpo2Eovk1SV4+rf+7WKSHtabjmWXbM2FVccyN2AOvfR4WAdpr4hHkoEIiwIDAQAB
+o4IBhDCCAYAwDgYDVR0PAQH/BAQDAgZAMIGZBgNVHSMEgZEwgY6AFOpEPx8Z4zc+q6qUgqWf6/wW
+un+1oXGkbzBtMQswCQYDVQQGEwJJVDEeMBwGA1UEChMVQWdlbnppYSBkZWxsZSBFbnRyYXRlMRsw
+GQYDVQQLExJTZXJ2aXppIFRlbGVtYXRpY2kxITAfBgNVBAMTGENBIEFnZW56aWEgZGVsbGUgRW50
+cmF0ZYIDEGJwMIGyBgNVHR8EgaowgacwgaSggaGggZ6GgZtsZGFwOi8vY2Fkcy5lbnRyYXRlLmZp
+bmFuemUuaXQvY24lM2RDQSUyMEFnZW56aWElMjBkZWxsZSUyMEVudHJhdGUsb3UlM2RTZXJ2aXpp
+JTIwVGVsZW1hdGljaSxvJTNkQWdlbnppYSUyMGRlbGxlJTIwRW50cmF0ZSxjJTNkaXQ/Y2VydGlm
+aWNhdGVSZXZvY2F0aW9uTGlzdDAdBgNVHQ4EFgQUn+JY07NI6xlrCUXERiHoFFN66dkwDQYJKoZI
+hvcNAQEFBQADggEBALZ0po2uLhLyZ8uiVfQUCAQd8s5o8ZJw2mcgZc/iaoNmDfcslZnTLWeuT6Gr
+UFgG0uc1rY0UwWx/R1UOyc0ZesRo7Z6+kFmVubT1tbjLMuLjjUIyt4zWeNjf4PwNS0+s6Y6eC8tx
+fOJmQNGQIbujWhAejoIteG01ciGeeII6AMnGK8KvbCA0UZmWl3Bou49zWajiEjtHFGkq/WNfDwRa
+Fd4UWjR+UWS3rLahV7iOfh/+Yy7h1F0RzQuPJk7TCm7iHyc9QtgwxHHCmknRyNXMv6DeTOfK8ciq
+uFWd6DasmblXLUm+uqhsWVRIkj2Bz63bpjuJU+8ptRfxHrVnzyCr9M4=
+</ds:X509Certificate>
+ </ds:X509Data>
+ </ds:KeyInfo>
+ <ds:Object>
+ <xades:QualifyingProperties xmlns:xades="http://uri.etsi.org/01903/v1.3.2#" Target="#Signature1">
+ <xades:SignedProperties Id="SignedProperties_1">
+ <xades:SignedSignatureProperties>
+ <xades:SigningTime>2014-06-05T14:27:51Z</xades:SigningTime>
+ </xades:SignedSignatureProperties>
+ </xades:SignedProperties>
+ </xades:QualifyingProperties>
+ </ds:Object>
+ </ds:Signature>
+</types:NotificaDecorrenzaTermini>
diff --git a/addons/l10n_it_edi/tests/expected_xmls/IT01234567890_FPR01_RC_001.xml b/addons/l10n_it_edi/tests/expected_xmls/IT01234567890_FPR01_RC_001.xml
new file mode 100644
index 00000000..ab0725e4
--- /dev/null
+++ b/addons/l10n_it_edi/tests/expected_xmls/IT01234567890_FPR01_RC_001.xml
@@ -0,0 +1,72 @@
+<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet type="text/xsl" href="RC_v1.0.xsl"?>
+<types:RicevutaConsegna xmlns:types="http://www.fatturapa.gov.it/sdi/messaggi/v1.0" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" IntermediarioConDupliceRuolo="Si" versione="1.0" xsi:schemaLocation="http://www.fatturapa.gov.it/sdi/messaggi/v1.0 MessaggiTypes_v1.0.xsd ">
+ <IdentificativoSdI>111</IdentificativoSdI>
+ <NomeFile>IT01234567890_FPR01.xml</NomeFile>
+ <DataOraRicezione>2013-06-06T12:00:00Z</DataOraRicezione>
+ <DataOraConsegna>2013-06-06T12:01:00Z</DataOraConsegna>
+ <Destinatario>
+ <Codice>AAA111</Codice>
+ <Descrizione>Amministrazione di prova</Descrizione>
+ </Destinatario>
+ <MessageId>123456</MessageId>
+ <Note>Esempio</Note>
+ <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Id="Signature1">
+ <ds:SignedInfo>
+ <ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
+ <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
+ <ds:Reference Id="reference-document" URI="">
+ <ds:Transforms>
+ <ds:Transform Algorithm="http://www.w3.org/2002/06/xmldsig-filter2">
+ <XPath xmlns="http://www.w3.org/2002/06/xmldsig-filter2" Filter="subtract">/descendant::ds:Signature</XPath>
+ </ds:Transform>
+ </ds:Transforms>
+ <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
+ <ds:DigestValue>c+5ntDV6t4+PxIKEU6rbCUGu3ne9RMxoADu4yK4XIak=</ds:DigestValue>
+ </ds:Reference>
+ <ds:Reference Id="reference-signedpropeties" Type="http://uri.etsi.org/01903#SignedProperties" URI="#SignedProperties_1">
+ <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
+ <ds:DigestValue>AhiGZ+LPENybg4dQwMwjg0Nxdxzu+3M5i0w+UI6X89E=</ds:DigestValue>
+ </ds:Reference>
+ <ds:Reference Id="reference-keyinfo" URI="#KeyInfoId">
+ <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
+ <ds:DigestValue>BaZyFTXyxM8aIJhtiemem1lEwKR75ksXb33lsMqD89w=</ds:DigestValue>
+ </ds:Reference>
+ </ds:SignedInfo>
+ <ds:SignatureValue Id="SignatureValue1">G0FOBC+E8JKtJ5K2C+LBlvv3oarzkub7w2q5U1UQZnobWmFBbZ4WzgBNTMKUjdi2ZLkUpOSEwedf
+VLgl5SyhaKYY6TizDNbxededjUpqKhyIgaWBLc/iDI6H//x+3axnLU4WwFzdr3AwqPQjPuugGX07
+gOcjBHtbr7ie2Wr//o8=</ds:SignatureValue>
+ <ds:KeyInfo Id="KeyInfoId">
+ <ds:X509Data>
+ <ds:X509Certificate>MIIEYDCCA0igAwIBAgIDEIgbMA0GCSqGSIb3DQEBBQUAMG0xCzAJBgNVBAYTAklUMR4wHAYDVQQK
+ExVBZ2VuemlhIGRlbGxlIEVudHJhdGUxGzAZBgNVBAsTElNlcnZpemkgVGVsZW1hdGljaTEhMB8G
+A1UEAxMYQ0EgQWdlbnppYSBkZWxsZSBFbnRyYXRlMB4XDTExMDcwNDEzMTkyNFoXDTE0MDcwNDEz
+MTkyM1owdDELMAkGA1UEBhMCSVQxHjAcBgNVBAoTFUFnZW56aWEgZGVsbGUgRW50cmF0ZTEbMBkG
+A1UECxMSU2Vydml6aSBUZWxlbWF0aWNpMSgwJgYDVQQDEx9TaXN0ZW1hIEludGVyc2NhbWJpbyBG
+YXR0dXJhIFBBMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDMxOQj1dj6xgQBwB/S5naHvVqP
+FL25Y3GnAulrcaeO8ZFFK5fWKPgiBwfyJ7qdlzn/RF7y+w92XLgh9zROmNlIjsJcp3rRwsAiKjuW
+CkqwVXE0/Qtvxpo2Eovk1SV4+rf+7WKSHtabjmWXbM2FVccyN2AOvfR4WAdpr4hHkoEIiwIDAQAB
+o4IBhDCCAYAwDgYDVR0PAQH/BAQDAgZAMIGZBgNVHSMEgZEwgY6AFOpEPx8Z4zc+q6qUgqWf6/wW
+un+1oXGkbzBtMQswCQYDVQQGEwJJVDEeMBwGA1UEChMVQWdlbnppYSBkZWxsZSBFbnRyYXRlMRsw
+GQYDVQQLExJTZXJ2aXppIFRlbGVtYXRpY2kxITAfBgNVBAMTGENBIEFnZW56aWEgZGVsbGUgRW50
+cmF0ZYIDEGJwMIGyBgNVHR8EgaowgacwgaSggaGggZ6GgZtsZGFwOi8vY2Fkcy5lbnRyYXRlLmZp
+bmFuemUuaXQvY24lM2RDQSUyMEFnZW56aWElMjBkZWxsZSUyMEVudHJhdGUsb3UlM2RTZXJ2aXpp
+JTIwVGVsZW1hdGljaSxvJTNkQWdlbnppYSUyMGRlbGxlJTIwRW50cmF0ZSxjJTNkaXQ/Y2VydGlm
+aWNhdGVSZXZvY2F0aW9uTGlzdDAdBgNVHQ4EFgQUn+JY07NI6xlrCUXERiHoFFN66dkwDQYJKoZI
+hvcNAQEFBQADggEBALZ0po2uLhLyZ8uiVfQUCAQd8s5o8ZJw2mcgZc/iaoNmDfcslZnTLWeuT6Gr
+UFgG0uc1rY0UwWx/R1UOyc0ZesRo7Z6+kFmVubT1tbjLMuLjjUIyt4zWeNjf4PwNS0+s6Y6eC8tx
+fOJmQNGQIbujWhAejoIteG01ciGeeII6AMnGK8KvbCA0UZmWl3Bou49zWajiEjtHFGkq/WNfDwRa
+Fd4UWjR+UWS3rLahV7iOfh/+Yy7h1F0RzQuPJk7TCm7iHyc9QtgwxHHCmknRyNXMv6DeTOfK8ciq
+uFWd6DasmblXLUm+uqhsWVRIkj2Bz63bpjuJU+8ptRfxHrVnzyCr9M4=</ds:X509Certificate>
+ </ds:X509Data>
+ </ds:KeyInfo>
+ <ds:Object>
+ <xades:QualifyingProperties xmlns:xades="http://uri.etsi.org/01903/v1.3.2#" Target="#Signature1">
+ <xades:SignedProperties Id="SignedProperties_1">
+ <xades:SignedSignatureProperties>
+ <xades:SigningTime>2014-06-05T14:24:28Z</xades:SigningTime>
+ </xades:SignedSignatureProperties>
+ </xades:SignedProperties>
+ </xades:QualifyingProperties>
+ </ds:Object>
+ </ds:Signature>
+</types:RicevutaConsegna>
diff --git a/addons/l10n_it_edi/tests/test_ir_mail_server.py b/addons/l10n_it_edi/tests/test_ir_mail_server.py
new file mode 100644
index 00000000..734c7246
--- /dev/null
+++ b/addons/l10n_it_edi/tests/test_ir_mail_server.py
@@ -0,0 +1,160 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import datetime
+import logging
+from collections import namedtuple
+from unittest.mock import patch
+import freezegun
+
+from odoo import tools
+from odoo.addons.account_edi.tests.common import AccountEdiTestCommon
+from odoo.addons.l10n_it_edi.tools.remove_signature import remove_signature
+
+_logger = logging.getLogger(__name__)
+
+
+class PecMailServerTests(AccountEdiTestCommon):
+ """ Main test class for the l10n_it_edi vendor bills XML import from a PEC mail account"""
+
+ fake_test_content = """<?xml version="1.0" encoding="UTF-8"?>
+ <p:FatturaElettronica versione="FPR12" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
+ xmlns:p="http://ivaservizi.agenziaentrate.gov.it/docs/xsd/fatture/v1.2"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://ivaservizi.agenziaentrate.gov.it/docs/xsd/fatture/v1.2 http://www.fatturapa.gov.it/export/fatturazione/sdi/fatturapa/v1.2/Schema_del_file_xml_FatturaPA_versione_1.2.xsd">
+ <FatturaElettronicaHeader>
+ <CessionarioCommittente>
+ <DatiAnagrafici>
+ <CodiceFiscale>01234560157</CodiceFiscale>
+ </DatiAnagrafici>
+ </CessionarioCommittente>
+ </FatturaElettronicaHeader>
+ <FatturaElettronicaBody>
+ <DatiGenerali>
+ <DatiGeneraliDocumento>
+ <TipoDocumento>TD02</TipoDocumento>
+ </DatiGeneraliDocumento>
+ </DatiGenerali>
+ </FatturaElettronicaBody>
+ </p:FatturaElettronica>"""
+
+ @classmethod
+ def setUpClass(cls):
+ """ Setup the test class with a PEC mail server and a fake fatturaPA content """
+
+ super().setUpClass(chart_template_ref='l10n_it.l10n_it_chart_template_generic',
+ edi_format_ref='l10n_it_edi.edi_fatturaPA')
+
+ # Use the company_data_2 to test that the e-invoice is imported for the right company
+ cls.company = cls.company_data_2['company']
+
+ # Initialize the company's codice fiscale
+ cls.company.l10n_it_codice_fiscale = 'IT01234560157'
+
+ # Build test data.
+ # invoice_filename1 is used for vendor bill receipts tests
+ # invoice_filename2 is used for vendor bill tests
+ cls.invoice_filename1 = 'IT01234567890_FPR01.xml'
+ cls.invoice_filename2 = 'IT01234567890_FPR02.xml'
+ cls.signed_invoice_filename = 'IT01234567890_FPR01.xml.p7m'
+ cls.invoice_content = cls._get_test_file_content(cls.invoice_filename1)
+ cls.signed_invoice_content = cls._get_test_file_content(cls.signed_invoice_filename)
+ cls.invoice = cls.env['account.move'].create({
+ 'move_type': 'in_invoice',
+ 'ref': '01234567890'
+ })
+ cls.attachment = cls.env['ir.attachment'].create({
+ 'name': cls.invoice_filename1,
+ 'raw': cls.invoice_content,
+ 'res_id': cls.invoice.id,
+ 'res_model': 'account.move',
+ })
+ cls.edi_document = cls.env['account.edi.document'].create({
+ 'edi_format_id': cls.edi_format.id,
+ 'move_id': cls.invoice.id,
+ 'attachment_id': cls.attachment.id,
+ 'state': 'sent'
+ })
+
+ # Initialize the fetchmail server that has to be tested
+ cls.server = cls.env['fetchmail.server'].sudo().create({
+ 'name': 'test_server',
+ 'server_type': 'imap',
+ 'l10n_it_is_pec': True})
+
+ @classmethod
+ def _get_test_file_content(cls, filename):
+ """ Get the content of a test file inside this module """
+ path = 'l10n_it_edi/tests/expected_xmls/' + filename
+ with tools.file_open(path, mode='rb') as test_file:
+ return test_file.read()
+
+ def _create_invoice(self, content, filename):
+ """ Create an invoice from given attachment content """
+ with patch.object(self.server._cr, 'commit', return_value=None):
+ if filename.endswith(".p7m"):
+ content = remove_signature(content)
+ return self.server._create_invoice_from_mail(content, filename, 'fake@address.be')
+
+ # -----------------------------
+ #
+ # Vendor bills
+ #
+ # -----------------------------
+
+ def test_receive_vendor_bill(self):
+ """ Test a sample e-invoice file from https://www.fatturapa.gov.it/export/documenti/fatturapa/v1.2/IT01234567890_FPR01.xml """
+ invoices = self._create_invoice(self.invoice_content, self.invoice_filename2)
+ self.assertTrue(bool(invoices))
+
+ def test_receive_signed_vendor_bill(self):
+ """ Test a signed (P7M) sample e-invoice file from https://www.fatturapa.gov.it/export/documenti/fatturapa/v1.2/IT01234567890_FPR01.xml """
+ with freezegun.freeze_time('2020-04-06'):
+ invoices = self._create_invoice(self.signed_invoice_content, self.signed_invoice_filename)
+ self.assertRecordValues(invoices, [{
+ 'company_id': self.company.id,
+ 'name': 'BILL/2014/12/0001',
+ 'invoice_date': datetime.date(2014, 12, 18),
+ 'ref': '01234567890',
+ }])
+
+ def test_receive_same_vendor_bill_twice(self):
+ """ Test that the second time we are receiving a PEC mail with the same attachment, the second is discarded """
+ content = self.fake_test_content.encode()
+ for result in [True, False]:
+ invoice = self._create_invoice(content, self.invoice_filename2)
+ self.assertEqual(result, bool(invoice))
+
+ # -----------------------------
+ #
+ # Receipts
+ #
+ # -----------------------------
+
+ def _test_receipt(self, receipt_type, source_state, destination_state):
+ """ Test a receipt from the ones in the module's test files """
+
+ # Simulate the 'sent' state of the move, even if we didn't actually send an email in this test
+ self.invoice.l10n_it_send_state = source_state
+
+ # Create a fake receipt from the test file
+ receipt_filename = 'IT01234567890_FPR01_%s_001.xml' % receipt_type
+ receipt_content = self._get_test_file_content(receipt_filename).decode()
+
+ create_mail_attachment = namedtuple('Attachment', ('fname', 'content', 'info'))
+ receipt_mail_attachment = create_mail_attachment(receipt_filename, receipt_content, {})
+
+ # Simulate the arrival of the receipt
+ with patch.object(self.server._cr, 'commit', return_value=None):
+ self.server._message_receipt_invoice(receipt_type, receipt_mail_attachment)
+
+ # Check the Destination state of the edi_document
+ self.assertTrue(destination_state, self.edi_document.state)
+
+ def test_ricevuta_consegna(self):
+ """ Test a receipt adapted from https://www.fatturapa.gov.it/export/documenti/messaggi/v1.0/IT01234567890_11111_RC_001.xml """
+ self._test_receipt('RC', 'sent', 'delivered')
+
+ def test_decorrenza_termini(self):
+ """ Test a receipt adapted from https://www.fatturapa.gov.it/export/documenti/messaggi/v1.0/IT01234567890_11111_DT_001.xml """
+ self._test_receipt('DT', 'delivered', 'delivered_expired')
diff --git a/addons/l10n_it_edi/tools/__init__.py b/addons/l10n_it_edi/tools/__init__.py
new file mode 100644
index 00000000..8fee2c73
--- /dev/null
+++ b/addons/l10n_it_edi/tools/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import remove_signature
diff --git a/addons/l10n_it_edi/tools/remove_signature.py b/addons/l10n_it_edi/tools/remove_signature.py
new file mode 100644
index 00000000..1816b7fd
--- /dev/null
+++ b/addons/l10n_it_edi/tools/remove_signature.py
@@ -0,0 +1,47 @@
+# -*- coding:utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+import warnings
+
+_logger = logging.getLogger(__name__)
+
+try:
+ from OpenSSL import crypto as ssl_crypto
+ import OpenSSL._util as ssl_util
+except ImportError:
+ ssl_crypto = None
+ _logger.warning("Cannot import library 'OpenSSL' for PKCS#7 envelope extraction.")
+
+
+def remove_signature(content):
+ """ Remove the PKCS#7 envelope from given content, making a '.xml.p7m' file content readable as it was '.xml'.
+ As OpenSSL may not be installed, in that case a warning is issued and None is returned. """
+
+ # Prevent using the library if it had import errors
+ if not ssl_crypto:
+ _logger.warning("Error reading the content, check if the OpenSSL library is installed for for PKCS#7 envelope extraction.")
+ return None
+
+ # Load some tools from the library
+ null = ssl_util.ffi.NULL
+ verify = ssl_util.lib.PKCS7_verify
+
+ # By default ignore the validity of the certificates, just validate the structure
+ flags = ssl_util.lib.PKCS7_NOVERIFY | ssl_util.lib.PKCS7_NOSIGS
+
+ # Read the signed data fron the content
+ out_buffer = ssl_crypto._new_mem_buf()
+
+ # This method is deprecated, but there are actually no alternatives
+ with warnings.catch_warnings():
+ warnings.filterwarnings("ignore", category=DeprecationWarning)
+ loaded_data = ssl_crypto.load_pkcs7_data(ssl_crypto.FILETYPE_ASN1, content)
+
+ # Verify the signature
+ if verify(loaded_data._pkcs7, null, null, null, out_buffer, flags) != 1:
+ ssl_crypto._raise_current_error()
+
+ # Get the content as a byte-string
+ decoded_content = ssl_crypto._bio_to_string(out_buffer)
+ return decoded_content
diff --git a/addons/l10n_it_edi/views/l10n_it_view.xml b/addons/l10n_it_edi/views/l10n_it_view.xml
new file mode 100644
index 00000000..95e78ace
--- /dev/null
+++ b/addons/l10n_it_edi/views/l10n_it_view.xml
@@ -0,0 +1,241 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <record id="fetchmail_server_form_l10n_it" model="ir.ui.view">
+ <field name="name">fetchmail.server.form.l10n.it</field>
+ <field name="model">fetchmail.server</field>
+ <field name="priority">20</field>
+ <field name="inherit_id" ref="fetchmail.view_email_server_form"/>
+ <field name="arch" type="xml">
+ <data>
+ <xpath expr="//field[@name='date']" position="after">
+ <field name="l10n_it_is_pec"/>
+ </xpath>
+ </data>
+ </field>
+ </record>
+
+ <record id="account_tax_form_l10n_it" model="ir.ui.view">
+ <field name="name">account.tax.form.l10n.it</field>
+ <field name="model">account.tax</field>
+ <field name="priority">20</field>
+ <field name="inherit_id" ref="account.view_tax_form"/>
+ <field name="arch" type="xml">
+ <data>
+ <xpath expr="//page" position="inside">
+ <group>
+ <field name="l10n_it_vat_due_date"/>
+ <field name="l10n_it_has_exoneration" readonly="False"/>
+ <field name="l10n_it_kind_exoneration" attrs="{'invisible': [('l10n_it_has_exoneration', '=', False)]}"/>
+ <field name="l10n_it_law_reference" attrs="{'invisible': [('l10n_it_has_exoneration', '=', False)]}"/>
+ </group>
+ </xpath>
+ </data>
+ </field>
+ </record>
+
+ <record id="res_partner_form_l10n_it" model="ir.ui.view">
+ <field name="name">res.partner.form.l10n.it</field>
+ <field name="model">res.partner</field>
+ <field name="priority">20</field>
+ <field name="inherit_id" ref="base.view_partner_form"/>
+ <field name="arch" type="xml">
+ <data>
+ <xpath expr="//field[@name='category_id']" position="after">
+ <field name="l10n_it_pec_email" attrs="{'invisible': [('parent_id', '!=', False)]}"/>
+ <field name="l10n_it_codice_fiscale" attrs="{'invisible': [('parent_id', '!=', False)]}"/>
+ <field name="l10n_it_pa_index" attrs="{'invisible': [('parent_id', '!=', False)]}"/>
+ </xpath>
+ </data>
+ </field>
+ </record>
+
+ <record id="res_company_form_l10n_it" model="ir.ui.view">
+ <field name="name">res.company.form.l10n.it</field>
+ <field name="model">res.company</field>
+ <field name="priority">20</field>
+ <field name="inherit_id" ref="base.view_company_form"/>
+ <field name="arch" type="xml">
+ <data>
+ <xpath expr="//div[hasclass('o_address_format')]" position="after">
+ <field name="l10n_it_mail_pec_server_id" attrs="{'invisible': [('country_code', '!=', 'IT')]}"/>
+ <field name="l10n_it_address_send_fatturapa" attrs="{'invisible': [('country_code', '!=', 'IT')]}"/>
+ <field name="l10n_it_address_recipient_fatturapa" attrs="{'invisible': [('country_code', '!=', 'IT')]}"/>
+ </xpath>
+ <xpath expr="//field[@name='vat']" position="after">
+ <field name="l10n_it_codice_fiscale" attrs="{'invisible': [('country_code', '!=', 'IT')]}"/>
+ <field name="l10n_it_tax_system" attrs="{'invisible': [('country_code', '!=', 'IT')]}"/>
+ </xpath>
+ <xpath expr="//page" position="after">
+ <page string="Electronic Invoicing" name="electronic_invoicing" attrs="{'invisible': [('country_code', '!=', 'IT')]}">
+ <group>
+ <separator string="Economic and Administrative Index" colspan="4"/>
+ <div colspan="4">
+ 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)
+ </div>
+ <group>
+ <field name="l10n_it_has_eco_index" string="Company listed on the register of companies"/>
+ </group>
+ <group attrs="{'invisible': [('l10n_it_has_eco_index', '=', False)]}">
+ <field name="l10n_it_eco_index_office"/>
+ <field name="l10n_it_eco_index_number"/>
+ <field name="l10n_it_eco_index_share_capital"/>
+ <field name="l10n_it_eco_index_sole_shareholder"/>
+ <field name="l10n_it_eco_index_liquidation_state"/>
+ </group>
+ </group>
+ <group>
+ <separator string="Tax representative" colspan="4"/>
+ <div colspan="4">
+ 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
+ </div>
+ <group>
+ <field name="l10n_it_has_tax_representative" string="Company have a tax representative"/>
+ </group>
+ <group attrs="{'invisible': [('l10n_it_has_tax_representative', '=', False)]}">
+ <field name="l10n_it_tax_representative_partner_id"/>
+ </group>
+ </group>
+ </page>
+ </xpath>
+ </data>
+ </field>
+ </record>
+
+ <record id="invoice_supplier_tree_l10n_it" model="ir.ui.view">
+ <field name="name">account.invoice.supplier.tree.l10n.it</field>
+ <field name="model">account.move</field>
+ <field name="priority">20</field>
+ <field name="inherit_id" ref="account.view_move_tree"/>
+ <field name="arch" type="xml">
+ <data>
+ <xpath expr="//field[@name='currency_id']" position="after">
+ <field name="l10n_it_send_state" invisible="1" widget="label_selection" options="{'classes': {'to_send': 'default', 'invalid': 'danger', 'sent': 'warning',
+ 'delivered': 'success', 'delivered_accepted': 'success', 'delivered_refused': 'success', 'delivered_expired': 'success', 'failed_delivery': 'success'}}"/>
+
+ <button icon="fa-paper-plane-o" class="btn-outline-warning disabled" aria-label="Sent" title="Sent" attrs="{'invisible': [('l10n_it_send_state', '!=', 'sent')]}"/>
+ <button icon="fa-exclamation-triangle" class="btn-outline-danger disabled" aria-label="Error" title="Error" attrs="{'invisible': [('l10n_it_send_state', '!=', 'invalid')]}"/>
+ <button icon="fa-check" class="btn-outline-success disabled" aria-label="Delivered" title="Delivered" attrs="{'invisible': [('l10n_it_send_state', 'not in', ['delivered', 'delivered_accepted', 'delivered_refused', 'delivered_expired', 'failed_delivery'])]}"/>
+ </xpath>
+ </data>
+ </field>
+ </record>
+
+ <record id="invoice_kanban_l10n_it" model="ir.ui.view">
+ <field name="name">account.invoice.kanban.l10n.it</field>
+ <field name="model">account.move</field>
+ <field name="priority">20</field>
+ <field name="inherit_id" ref="account.view_account_move_kanban"/>
+ <field name="arch" type="xml">
+ <data>
+ <xpath expr="//field[@name='currency_id']" position="after">
+ <field name="l10n_it_send_state"/>
+ </xpath>
+
+ <xpath expr="//div[hasclass('o_kanban_record_headings')]" position="inside">
+ <i class="text-success fa fa-plus-circle" aria-label="New" t-if="record.l10n_it_send_state.raw_value == 'new'"/>
+ <i class="text-warning fa fa-paper-plane-o" aria-label="Sent, waiting for response" t-if="record.l10n_it_send_state.raw_value == 'sent'"/>
+ <i class="text-danger fa fa-exclamation-triangle" aria-label="Sent, but invalid" t-if="record.l10n_it_send_state.raw_value == 'invalid'"/>
+ <i class="text-success fa fa-check" aria-label="Delivered Invoice" t-if="['delivered', 'delivered_accepted', 'delivered_refused', 'delivered_expired', 'failed_delivery'].indexOf(record.l10n_it_send_state.raw_value) >= 0" />
+ </xpath>
+ </data>
+ </field>
+ </record>
+
+ <record id="account_invoice_form_l10n_it" model="ir.ui.view">
+ <field name="name">account.move.form.l10n.it</field>
+ <field name="model">account.move</field>
+ <field name="priority">20</field>
+ <field name="inherit_id" ref="account.view_move_form"/>
+ <field name="arch" type="xml">
+ <data>
+ <xpath expr="//field[@name='move_type']" position="before">
+ <div class="alert alert-success" role="alert"
+ attrs="{'invisible': ['|', ('move_type', 'not in', ('out_invoice', 'out_refund')), ('l10n_it_send_state', 'not in', ['delivered', 'delivered_accepted', 'delivered_refused', 'delivered_expired', 'failed_delivery'])]}">
+ <i class="fa fa-check" aria-label="Delivered" title="Delivered"></i> <field name="l10n_it_send_state" readonly="1"/>
+ </div>
+ <div class="alert alert-warning" role="alert"
+ attrs="{'invisible': ['|', ('move_type', 'not in', ('out_invoice', 'out_refund')), ('l10n_it_send_state', '!=', 'sent')]}">
+ <i class="fa fa-paper-plane-o"/> E-Invoice sent, waiting for a response
+ </div>
+ <div class="alert alert-danger" role="alert"
+ attrs="{'invisible': ['|', ('move_type', 'not in', ('out_invoice', 'out_refund')), ('l10n_it_send_state', '!=', 'invalid')]}">
+ <i class="fa fa-exclamation-triangle"/> E-Invoice check failed. You can modify the invoice, and resend it.
+ </div>
+ </xpath>
+ <xpath expr="//page[@name='other_info']" position="after">
+ <page string="Electronic Invoicing"
+ name="electronic_invoicing"
+ attrs="{'invisible': [('move_type', 'not in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund'))]}">
+ <group>
+ <group>
+ <field name="l10n_it_stamp_duty"/>
+ <field name="l10n_it_ddt_id"
+ attrs="{'invisible': [('move_type', 'not in', ('out_invoice', 'out_refund'))]}"/>
+ </group>
+ </group>
+ </page>
+ </xpath>
+ </data>
+ </field>
+ </record>
+
+ <record id="view_account_invoice_filter_l10n_it" model="ir.ui.view">
+ <field name="name">account.invoice.select.l10n.it</field>
+ <field name="model">account.move</field>
+ <field name="inherit_id" ref="account.view_account_invoice_filter"/>
+ <field name="arch" type="xml">
+ <xpath expr="//filter[@name='late']" position="after">
+ <separator/>
+ <filter name="error" string="E-invoice error" domain="[('l10n_it_send_state', '=', 'invalid')]"/>
+ <filter name="sent" string="E-invoice sent" domain="[('l10n_it_send_state', '=', 'sent')]"/>
+ <filter name="no_sent" string="E-invoice to send" domain="[('l10n_it_send_state', 'in', ['to_send',False])]"/>
+ <filter name="delivered" string="E-invoice delivered" domain="[('l10n_it_send_state', 'in', ['delivered', 'delivered_accepted', 'delivered_refused', 'delivered_expired', 'failed_delivery'])]"/>
+ </xpath>
+ <xpath expr="//filter[@name='status']" position="after">
+ <filter name="send_status" string="Send status" context="{'group_by':'l10n_it_send_state'}"/>
+ </xpath>
+ </field>
+ </record>
+
+ <record id="l10n_it_ddt" model="ir.ui.view">
+ <field name="name">ddt.form.l10n.it</field>
+ <field name="model">l10n_it.ddt</field>
+ <field name="arch" type="xml">
+ <form>
+ <group>
+ <field name="name"/>
+ <field name="date"/>
+ </group>
+ </form>
+ </field>
+ </record>
+
+ <record id="l10n_it_ddt_list_view" model="ir.ui.view">
+ <field name="name">l10n_it.ddt.list.view</field>
+ <field name="model">l10n_it.ddt</field>
+ <field name="arch" type="xml">
+ <tree>
+ <field name="name"/>
+ <field name="date"/>
+ </tree>
+ </field>
+ </record>
+
+ <record id="action_ddt_account" model="ir.actions.act_window">
+ <field name="name">Transport Document</field>
+ <field name="res_model">l10n_it.ddt</field>
+ <field name="view_mode">tree,form</field>
+ <field name="view_id" ref="l10n_it_ddt_list_view"/>
+ </record>
+
+ <menuitem
+ name="DDT"
+ parent="account.account_account_menu"
+ action="action_ddt_account"
+ id="menu_action_ddt_account"
+ sequence="15"
+ groups="base.group_no_one"/>
+</odoo>