From 5e73d59c7e56abd8e9773441f347ef23826fac11 Mon Sep 17 00:00:00 2001 From: Davan CHIEM DAO Date: Fri, 6 May 2022 09:55:51 +0000 Subject: [PATCH] [ADD] l10n_it_edi: Simplified invoice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This provides the possibility to import/export simplified invoices. Currently, the import of simplified invoices would not work and the export of invoice without customer address would be blocked. Simplified invoice will be able to be imported and exported if the customer address is incomplete, it's a domestic invoice and the total amount is below 400€ freeze_time was removed as it was unnecesarry and posting invoice would not be done directly as the invoicing date is in the future wrt the frozen time. closes odoo/odoo#91215 Task: 2800967 X-original-commit: 8dc17c29c7e032d94f45ec5e2e82f9dd871e4952 Signed-off-by: Josse Colpaert --- addons/l10n_it_edi/__manifest__.py | 1 + .../data/invoice_it_simplified_template.xml | 102 ++++++++++++ .../l10n_it_edi/models/account_edi_format.py | 113 +++++++++++-- addons/l10n_it_edi/models/account_invoice.py | 16 +- .../expected_xmls/IT00470550013_simpl.xml | 42 +++++ .../l10n_it_edi_sdicoop/tests/test_edi_xml.py | 155 +++++++++++++++++- 6 files changed, 401 insertions(+), 28 deletions(-) create mode 100644 addons/l10n_it_edi/data/invoice_it_simplified_template.xml create mode 100644 addons/l10n_it_edi_sdicoop/tests/expected_xmls/IT00470550013_simpl.xml 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()