diff --git a/addons/l10n_it_edi/__manifest__.py b/addons/l10n_it_edi/__manifest__.py index d4d558654f6..03254cc845c 100644 --- a/addons/l10n_it_edi/__manifest__.py +++ b/addons/l10n_it_edi/__manifest__.py @@ -21,6 +21,7 @@ E-invoice implementation 'security/ir.model.access.csv', 'data/account_edi_data.xml', 'data/invoice_it_template.xml', + 'data/invoice_it_simplified_template.xml', 'views/l10n_it_view.xml', ], 'demo': [ diff --git a/addons/l10n_it_edi/data/invoice_it_simplified_template.xml b/addons/l10n_it_edi/data/invoice_it_simplified_template.xml new file mode 100644 index 00000000000..0c1a8428333 --- /dev/null +++ b/addons/l10n_it_edi/data/invoice_it_simplified_template.xml @@ -0,0 +1,102 @@ + + + + + + + + \ No newline at end of file diff --git a/addons/l10n_it_edi/models/account_edi_format.py b/addons/l10n_it_edi/models/account_edi_format.py index 4e7659dc791..76379eb4d00 100644 --- a/addons/l10n_it_edi/models/account_edi_format.py +++ b/addons/l10n_it_edi/models/account_edi_format.py @@ -59,6 +59,14 @@ class AccountEdiFormat(models.Model): } def _l10n_it_edi_check_invoice_configuration(self, invoice): + errors = self._l10n_it_edi_check_ordinary_invoice_configuration(invoice) + + if not errors: + errors = self._l10n_it_edi_check_simplified_invoice_configuration(invoice) + + return errors + + def _l10n_it_edi_check_ordinary_invoice_configuration(self, invoice): errors = [] seller = invoice.company_id buyer = invoice.commercial_partner_id @@ -98,7 +106,35 @@ class AccountEdiFormat(models.Model): # <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)) + errors.append(_("The buyer, %s, or his company must have a VAT number and/or a tax code (Codice Fiscale).", 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)) + + return errors + + def _l10n_it_edi_check_simplified_invoice_configuration(self, invoice): + errors = self._l10n_it_edi_check_buyer_invoice_configuration(invoice) + template = self.env.ref('l10n_it_edi.account_invoice_it_simplified_FatturaPA_export', raise_if_not_found=False) + + if errors and template: + buyer = invoice.commercial_partner_id + if ((not buyer.country_id or buyer.country_id.code == 'IT') + and (buyer.l10n_it_codice_fiscale or (buyer.vat and (buyer.vat[:2].upper() == 'IT' or buyer.vat[:2].isdecimal()))) + and invoice.amount_total <= 400): + return [] + + return errors + + def _l10n_it_edi_check_buyer_invoice_configuration(self, invoice): + errors = [] + buyer = invoice.commercial_partner_id # <1.4.2> if not buyer.street and not buyer.street2: @@ -112,11 +148,6 @@ class AccountEdiFormat(models.Model): 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.")) - if any(line.quantity < 0 for line in invoice.invoice_line_ids): errors.append(_("All quantities should be positive.")) @@ -126,6 +157,22 @@ class AccountEdiFormat(models.Model): return errors + def _l10n_it_get_document_type(self, invoice): + errors_buyer = self._l10n_it_edi_check_buyer_invoice_configuration(invoice) + if errors_buyer: # Simplified invoice case + if invoice.move_type == 'out_invoice': + return 'TD07' + elif invoice.move_type == 'out_refund': + return 'TD08' + + if invoice.move_type == 'out_invoice': + return 'TD01' + elif invoice.move_type == 'out_refund': + return 'TD04' + + def _l10n_it_is_simplified_document_type(self, document_type): + return document_type in ['TD07', 'TD08', 'TD09'] + # ------------------------------------------------------------------------- # Export # ------------------------------------------------------------------------- @@ -285,14 +332,20 @@ class AccountEdiFormat(models.Model): # TD04 == credit note # TD05 == debit note # TD06 == fee + # TD07 == simplified invoice + # TD08 == simplified credit note + # TD09 == simplified debit note # For unsupported document types, just assume in_invoice, and log that the type is unsupported elements = tree.xpath('//DatiGeneraliDocumento/TipoDocumento') move_type = 'in_invoice' - if elements and elements[0].text and elements[0].text == 'TD04': + document_type = elements[0].text if elements else '' + if document_type and document_type in ['TD04', 'TD08']: move_type = 'in_refund' - elif elements and elements[0].text and elements[0].text != 'TD01': + elif document_type and document_type not in ['TD01', 'TD07']: _logger.info('Document type not managed: %s. Invoice type is set by default.', elements[0].text) + simplified = self._l10n_it_is_simplified_document_type(document_type) + # Setup the context for the Invoice Form invoice_ctx = invoice.with_company(company) \ .with_context(default_move_type=move_type) @@ -431,7 +484,11 @@ class AccountEdiFormat(models.Model): invoice._compose_info_message(body_tree, './/DatiPagamento'))) # Invoice lines. <2.2.1> - elements = body_tree.xpath('.//DettaglioLinee') + if not simplified: + elements = body_tree.xpath('.//DettaglioLinee') + else: + elements = body_tree.xpath('.//DatiBeniServizi') + if elements: for element in elements: with invoice_form.invoice_line_ids.new() as invoice_line_form: @@ -470,11 +527,6 @@ class AccountEdiFormat(models.Model): if line_elements: invoice_line_form.name = " ".join(line_elements[0].text.split()) - # 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: @@ -483,11 +535,30 @@ class AccountEdiFormat(models.Model): invoice_line_form.quantity = 1 # Taxes - tax_element = element.xpath('.//AliquotaIVA') + percentage = None + price_subtotal = 0 + if not simplified: + tax_element = element.xpath('.//AliquotaIVA') + if tax_element and tax_element[0].text: + percentage = float(tax_element[0].text) + else: + amount_element = element.xpath('.//Importo') + if amount_element and amount_element[0].text: + amount = float(amount_element[0].text) + tax_element = element.xpath('.//Aliquota') + if tax_element and tax_element[0].text: + percentage = float(tax_element[0].text) + price_subtotal = amount / (1 + percentage / 100) + else: + tax_element = element.xpath('.//Imposta') + if tax_element and tax_element[0].text: + tax_amount = float(tax_element[0].text) + price_subtotal = amount - tax_amount + percentage = round(tax_amount / price_subtotal * 100) + natura_element = element.xpath('.//Natura') invoice_line_form.tax_ids.clear() - if tax_element and tax_element[0].text: - percentage = float(tax_element[0].text) + if percentage is not None: if natura_element and natura_element[0].text: l10n_it_kind_exoneration = natura_element[0].text tax = self.env['account.tax'].search([ @@ -519,6 +590,14 @@ class AccountEdiFormat(models.Model): percentage, invoice_line_form.name)) + # Price Unit. + if not simplified: + line_elements = element.xpath('.//PrezzoUnitario') + if line_elements: + invoice_line_form.price_unit = float(line_elements[0].text) + else: + invoice_line_form.price_unit = price_subtotal + # Discounts discount_elements = element.xpath('.//ScontoMaggiorazione') if discount_elements: diff --git a/addons/l10n_it_edi/models/account_invoice.py b/addons/l10n_it_edi/models/account_invoice.py index 6f9412a513a..6bcdaeafa06 100644 --- a/addons/l10n_it_edi/models/account_invoice.py +++ b/addons/l10n_it_edi/models/account_invoice.py @@ -147,12 +147,9 @@ class AccountMove(models.Model): formato_trasmissione = "FPA12" if self._is_commercial_partner_pa() else "FPR12" - if self.move_type == 'out_invoice': - document_type = 'TD01' - elif self.move_type == 'out_refund': - document_type = 'TD04' - else: - document_type = 'TD0X' + document_type = self.env['account.edi.format']._l10n_it_get_document_type(self) + if self.env['account.edi.format']._l10n_it_is_simplified_document_type(document_type): + formato_trasmissione = "FSM10" # b64encode returns a bytestring, the template tries to turn it to string, # but only gets the repr(pdf) --> "b''" @@ -202,7 +199,12 @@ class AccountMove(models.Model): :return: The XML content as str. ''' template_values = self._prepare_fatturapa_export_values() - content = self.env['ir.qweb']._render('l10n_it_edi.account_invoice_it_FatturaPA_export', template_values) + if not self.env['account.edi.format']._l10n_it_is_simplified_document_type(template_values['document_type']): + content = self.env['ir.qweb']._render('l10n_it_edi.account_invoice_it_FatturaPA_export', template_values) + else: + content = self.env['ir.qweb']._render('l10n_it_edi.account_invoice_it_simplified_FatturaPA_export', template_values) + self.message_post(body=_("A simplified invoice was created instead of an ordinary one. This is because the invoice \ + is a domestic invoice with a total amount of less than or equal to 400€ and the customer's address is incomplete.")) return content def _post(self, soft=True): diff --git a/addons/l10n_it_edi_sdicoop/tests/expected_xmls/IT00470550013_simpl.xml b/addons/l10n_it_edi_sdicoop/tests/expected_xmls/IT00470550013_simpl.xml new file mode 100644 index 00000000000..806871032f0 --- /dev/null +++ b/addons/l10n_it_edi_sdicoop/tests/expected_xmls/IT00470550013_simpl.xml @@ -0,0 +1,42 @@ + + + + + IT + 01234560157 + + ___ignore___ + FSM10 + 0000000 + + + + IT + 01234560157 + + 01234560157 + company_2_data + + 1234 Test Street + 12345 + Prova + IT + + RF01 + + + + + + + + TD07 + EUR + 2022-03-24 + ___ignore___ + + + + + + diff --git a/addons/l10n_it_edi_sdicoop/tests/test_edi_xml.py b/addons/l10n_it_edi_sdicoop/tests/test_edi_xml.py index f70f7ff57a0..5a87ac1fbb6 100644 --- a/addons/l10n_it_edi_sdicoop/tests/test_edi_xml.py +++ b/addons/l10n_it_edi_sdicoop/tests/test_edi_xml.py @@ -9,6 +9,7 @@ from freezegun import freeze_time from odoo import tools from odoo.tests import tagged from odoo.addons.account_edi.tests.common import AccountEdiTestCommon +from odoo.exceptions import UserError _logger = logging.getLogger(__name__) @@ -58,6 +59,25 @@ class TestItEdi(AccountEdiTestCommon): 'is_company': True, }) + cls.italian_partner_no_address_codice = cls.env['res.partner'].create({ + 'name': 'Alessi', + 'l10n_it_codice_fiscale': '00465840031', + 'is_company': True, + }) + + cls.italian_partner_no_address_VAT = cls.env['res.partner'].create({ + 'name': 'Alessi', + 'vat': 'IT00465840031', + 'is_company': True, + }) + + cls.american_partner = cls.env['res.partner'].create({ + 'name': 'Alessi', + 'vat': '00465840031', + 'country_id': cls.env.ref('base.us').id, + 'is_company': True, + }) + cls.standard_line = { 'name': 'standard_line', 'quantity': 1, @@ -65,6 +85,20 @@ class TestItEdi(AccountEdiTestCommon): 'tax_ids': [(6, 0, [cls.company.account_sale_tax_id.id])] } + cls.standard_line_below_400 = { + 'name': 'cheap_line', + 'quantity': 1, + 'price_unit': 100.00, + 'tax_ids': [(6, 0, [cls.company.account_sale_tax_id.id])] + } + + cls.standard_line_400 = { + 'name': '400_line', + 'quantity': 1, + 'price_unit': 327.87, + 'tax_ids': [(6, 0, [cls.company.account_sale_tax_id.id])] + } + cls.price_included_invoice = cls.env['account.move'].with_company(cls.company).create({ 'move_type': 'out_invoice', 'invoice_date': datetime.date(2022, 3, 24), @@ -146,6 +180,56 @@ class TestItEdi(AccountEdiTestCommon): ], }) + cls.below_400_codice_simplified_invoice = cls.env['account.move'].with_company(cls.company).create({ + 'move_type': 'out_invoice', + 'invoice_date': datetime.date(2022, 3, 24), + 'partner_id': cls.italian_partner_no_address_codice.id, + 'invoice_line_ids': [ + (0, 0, { + **cls.standard_line_below_400, + }), + (0, 0, { + **cls.standard_line_below_400, + 'name': 'cheap_line_2', + 'quantity': 2, + 'price_unit': 10.0, + }), + ], + }) + + cls.total_400_VAT_simplified_invoice = cls.env['account.move'].with_company(cls.company).create({ + 'move_type': 'out_invoice', + 'invoice_date': datetime.date(2022, 3, 24), + 'partner_id': cls.italian_partner_no_address_VAT.id, + 'invoice_line_ids': [ + (0, 0, { + **cls.standard_line_400, + }), + ], + }) + + cls.more_400_simplified_invoice = cls.env['account.move'].with_company(cls.company).create({ + 'move_type': 'out_invoice', + 'invoice_date': datetime.date(2022, 3, 24), + 'partner_id': cls.italian_partner_no_address_codice.id, + 'invoice_line_ids': [ + (0, 0, { + **cls.standard_line, + }), + ], + }) + + cls.non_domestic_simplified_invoice = cls.env['account.move'].with_company(cls.company).create({ + 'move_type': 'out_invoice', + 'invoice_date': datetime.date(2022, 3, 24), + 'partner_id': cls.american_partner.id, + 'invoice_line_ids': [ + (0, 0, { + **cls.standard_line_below_400, + }), + ], + }) + # We create this because we are unable to post without a proxy user existing cls.proxy_user = cls.env['account_edi_proxy_client.user'].create({ 'id_client': 'l10n_it_edi_sdicoop_test', @@ -160,8 +244,11 @@ class TestItEdi(AccountEdiTestCommon): cls.partial_discount_invoice._post() cls.full_discount_invoice._post() cls.non_latin_and_latin_invoice._post() + cls.below_400_codice_simplified_invoice._post() + cls.total_400_VAT_simplified_invoice._post() cls.edi_basis_xml = cls._get_test_file_content('IT00470550013_basis.xml') + cls.edi_simplified_basis_xml = cls._get_test_file_content('IT00470550013_simpl.xml') @classmethod def _get_test_file_content(cls, filename): @@ -170,7 +257,6 @@ class TestItEdi(AccountEdiTestCommon): with tools.file_open(path, mode='rb') as test_file: return test_file.read() - @freeze_time('2020-03-24') def test_price_included_taxes(self): """ When the tax is price included, there should be a rounding value added to the xml, if the sum(subtotals) * tax_rate is not equal to taxable base * tax rate (there is a constraint in the edi where taxable base * tax rate = tax amount, but also @@ -247,7 +333,6 @@ class TestItEdi(AccountEdiTestCommon): invoice_etree = self.with_applied_xpath(invoice_etree, "") self.assertXmlTreeEqual(invoice_etree, expected_etree) - @freeze_time('2020-03-24') def test_partially_discounted_invoice(self): # The EDI can account for discounts, but a line with, for example, a 100% discount should still have # a corresponding tax with a base amount of 0 @@ -305,7 +390,6 @@ class TestItEdi(AccountEdiTestCommon): invoice_etree = self.with_applied_xpath(invoice_etree, "") self.assertXmlTreeEqual(invoice_etree, expected_etree) - @freeze_time('2020-03-24') def test_fully_discounted_inovice(self): invoice_etree = etree.fromstring(self.full_discount_invoice._export_as_xml()) expected_etree = self.with_applied_xpath( @@ -340,7 +424,6 @@ class TestItEdi(AccountEdiTestCommon): invoice_etree = self.with_applied_xpath(invoice_etree, "") self.assertXmlTreeEqual(invoice_etree, expected_etree) - @freeze_time('2020-03-24') def test_non_latin_and_latin_inovice(self): invoice_etree = etree.fromstring(self.non_latin_and_latin_invoice._export_as_xml()) expected_etree = self.with_applied_xpath( @@ -386,3 +469,67 @@ class TestItEdi(AccountEdiTestCommon): ''') invoice_etree = self.with_applied_xpath(invoice_etree, "") self.assertXmlTreeEqual(invoice_etree, expected_etree) + + def test_below_400_codice_simplified_invoice(self): + invoice_etree = etree.fromstring(self.below_400_codice_simplified_invoice._export_as_xml()) + expected_etree = self.with_applied_xpath( + etree.fromstring(self.edi_simplified_basis_xml), + ''' + + + 00465840031 + + + + + cheap_line + 122.00 + + 22.00 + + + + cheap_line_2 + 24.40 + + 4.40 + + + + ''') + invoice_etree = self.with_applied_xpath(invoice_etree, "") + self.assertXmlTreeEqual(invoice_etree, expected_etree) + + def test_total_400_VAT_simplified_invoice(self): + invoice_etree = etree.fromstring(self.total_400_VAT_simplified_invoice._export_as_xml()) + expected_etree = self.with_applied_xpath( + etree.fromstring(self.edi_simplified_basis_xml), + ''' + + + + IT + 00465840031 + + + + + + 400_line + 400.00 + + 72.13 + + + + ''') + invoice_etree = self.with_applied_xpath(invoice_etree, "") + self.assertXmlTreeEqual(invoice_etree, expected_etree) + + def test_more_400_simplified_invoice(self): + with self.assertRaises(UserError): + self.more_400_simplified_invoice._post() + + def test_non_domestic_simplified_invoice(self): + with self.assertRaises(UserError): + self.non_domestic_simplified_invoice._post()