From f6d2e211a65cd346f68bc7aa2b6c7512afeb0e8e Mon Sep 17 00:00:00 2001 From: Josse Colpaert Date: Wed, 29 Nov 2023 12:50:03 +0100 Subject: [PATCH] [IMP] l10n_es_edi_tbai: lroe vendor bill for Batuz From the first of January, Ticketbai becomes required in the region of Batuz (Bizkaia). And we were missing a piece where also the vendor bills need to be sent, similar to SII (but different) For that, they use an LROE structure, which we also used for sending customer invoices, but with the regular Ticketbai XML in there (encoded in base64). For vendor bills, it is however not the regular Ticketbai XML and the chaining and signature are also not needed, but the enveloping structure is built in the same way. So, we activate Ticketbai by default on vendor bill journals, but it will only be sending when the agency is Batuz. Thanks to Moduon and Landoo for the extra push. opw-3628750 closes odoo/odoo#146625 Signed-off-by: Quentin De Paoli --- .../data/template_LROE_bizkaia.xml | 100 +++++++++++++++ .../models/account_edi_format.py | 119 ++++++++++++------ .../l10n_es_edi_tbai/models/account_move.py | 33 ++++- addons/l10n_es_edi_tbai/tests/common.py | 108 ++++++++++++++++ addons/l10n_es_edi_tbai/tests/test_edi_xml.py | 48 ++++++- 5 files changed, 370 insertions(+), 38 deletions(-) diff --git a/addons/l10n_es_edi_tbai/data/template_LROE_bizkaia.xml b/addons/l10n_es_edi_tbai/data/template_LROE_bizkaia.xml index 318862b0dcd..303605d4764 100644 --- a/addons/l10n_es_edi_tbai/data/template_LROE_bizkaia.xml +++ b/addons/l10n_es_edi_tbai/data/template_LROE_bizkaia.xml @@ -34,5 +34,105 @@ + + + + + diff --git a/addons/l10n_es_edi_tbai/models/account_edi_format.py b/addons/l10n_es_edi_tbai/models/account_edi_format.py index 87983fc37fa..05a6c28790b 100644 --- a/addons/l10n_es_edi_tbai/models/account_edi_format.py +++ b/addons/l10n_es_edi_tbai/models/account_edi_format.py @@ -50,14 +50,12 @@ class AccountEdiFormat(models.Model): if self.code != 'es_tbai': return super()._is_compatible_with_journal(journal) - return journal.country_code == 'ES' and journal.type == 'sale' + return journal.country_code == 'ES' and journal.type in ('sale', 'purchase') def _get_move_applicability(self, move): # EXTENDS account_edi self.ensure_one() - if self.code != 'es_tbai' or move.country_code != 'ES' \ - or not move.l10n_es_tbai_is_required \ - or move.move_type not in ('out_invoice', 'out_refund'): + if self.code != 'es_tbai' or move.country_code != 'ES' or not move.l10n_es_tbai_is_required: return super()._get_move_applicability(move) return { @@ -103,25 +101,29 @@ class AccountEdiFormat(models.Model): if self.code != 'es_tbai': return super()._post_invoice_edi(invoice) - # Chain integrity check: chain head must have been REALLY posted (not timeout'ed) - # - If called from a cron, then the re-ordering of jobs should prevent this from triggering - # - If called manually, then the user will see this error pop up when it triggers - chain_head = invoice.company_id._get_l10n_es_tbai_last_posted_invoice() - if chain_head and chain_head != invoice and not chain_head._l10n_es_tbai_is_in_chain(): - raise UserError(f"TicketBAI: Cannot post invoice while chain head ({chain_head.name}) has not been posted") + if invoice.is_purchase_document(): + inv_xml = False # For Ticketbai Batuz vendor bills, we get the values later as it does not need chaining, ... - # Generate the XML values. - inv_dict = self._get_l10n_es_tbai_invoice_xml(invoice) - if 'error' in inv_dict[invoice]: - return inv_dict # XSD validation failed, return result dict + else: + # Chain integrity check: chain head must have been REALLY posted (not timeout'ed) + # - If called from a cron, then the re-ordering of jobs should prevent this from triggering + # - If called manually, then the user will see this error pop up when it triggers + chain_head = invoice.company_id._get_l10n_es_tbai_last_posted_invoice() + if chain_head and chain_head != invoice and not chain_head._l10n_es_tbai_is_in_chain(): + raise UserError(f"TicketBAI: Cannot post invoice while chain head ({chain_head.name}) has not been posted") - # Store the XML as attachment to ensure it is never lost (even in case of timeout error) - inv_xml = inv_dict[invoice]['xml_file'] - invoice._update_l10n_es_tbai_submitted_xml(xml_doc=inv_xml, cancel=False) + # Generate the XML values. + inv_dict = self._get_l10n_es_tbai_invoice_xml(invoice) + if 'error' in inv_dict[invoice]: + return inv_dict # XSD validation failed, return result dict - # Assign unique 'chain index' from dedicated sequence - if not invoice.l10n_es_tbai_chain_index: - invoice.l10n_es_tbai_chain_index = invoice.company_id._get_l10n_es_tbai_next_chain_index() + # Store the XML as attachment to ensure it is never lost (even in case of timeout error) + inv_xml = inv_dict[invoice]['xml_file'] + invoice._update_l10n_es_tbai_submitted_xml(xml_doc=inv_xml, cancel=False) + + # Assign unique 'chain index' from dedicated sequence + if not invoice.l10n_es_tbai_chain_index: + invoice.l10n_es_tbai_chain_index = invoice.company_id._get_l10n_es_tbai_next_chain_index() # Call the web service and get response res = self._l10n_es_tbai_post_to_web_service(invoice, inv_xml) @@ -161,14 +163,17 @@ class AccountEdiFormat(models.Model): if self.code != 'es_tbai': return super()._cancel_invoice_edi(invoice) - # Generate the XML values. - cancel_dict = self._get_l10n_es_tbai_invoice_xml(invoice, cancel=True) - if 'error' in cancel_dict[invoice]: - return cancel_dict # XSD validation failed, return result dict + if invoice.is_purchase_document(): + cancel_xml = False # Batuz specific + else: + # Generate the XML values. + cancel_dict = self._get_l10n_es_tbai_invoice_xml(invoice, cancel=True) + if 'error' in cancel_dict[invoice]: + return cancel_dict # XSD validation failed, return result dict - # Store the XML as attachment to ensure it is never lost (even in case of timeout error) - cancel_xml = cancel_dict[invoice]['xml_file'] - invoice._update_l10n_es_tbai_submitted_xml(xml_doc=cancel_xml, cancel=True) + # Store the XML as attachment to ensure it is never lost (even in case of timeout error) + cancel_xml = cancel_dict[invoice]['xml_file'] + invoice._update_l10n_es_tbai_submitted_xml(xml_doc=cancel_xml, cancel=True) # Call the web service and get response res = self._l10n_es_tbai_post_to_web_service(invoice, cancel_xml, cancel=True) @@ -206,7 +211,6 @@ class AccountEdiFormat(models.Model): def _l10n_es_tbai_validate_xml_with_xsd(self, xml_doc, cancel, tax_agency): xsd_name = get_key(tax_agency, 'xsd_name')['cancel' if cancel else 'post'] - try: validate_xml_from_attachment(self.env, xml_doc, xsd_name, prefix='l10n_es_edi_tbai') except UserError as e: @@ -215,8 +219,13 @@ class AccountEdiFormat(models.Model): def _l10n_es_tbai_get_invoice_content_edi(self, invoice): cancel = invoice.edi_state in ('to_cancel', 'cancelled') - xml_tree = self._get_l10n_es_tbai_invoice_xml(invoice, cancel)[invoice]['xml_file'] - return etree.tostring(xml_tree) + if invoice.is_purchase_document(): + lroe_values = self._l10n_es_tbai_prepare_values_bi(invoice, False, cancel=cancel) + xml_str = self.env['ir.qweb']._render('l10n_es_edi_tbai.template_LROE_240_main_recibidas', lroe_values).encode() + else: + xml_tree = self._get_l10n_es_tbai_invoice_xml(invoice, cancel)[invoice]['xml_file'] + xml_str = etree.tostring(xml_tree) + return xml_str def _get_l10n_es_tbai_invoice_xml(self, invoice, cancel=False): # If previously generated XML was posted and not rejected (success or timeout), reuse it @@ -547,17 +556,55 @@ class AccountEdiFormat(models.Model): return response_success, message, response_xml - def _l10n_es_tbai_prepare_post_params_bi(self, env, agency, invoice, invoice_xml, cancel=False): - """Web service parameters for Bizkaia.""" + def _l10n_es_tbai_get_in_invoice_values_batuz(self, invoice): + """ For the vendor bills for Bizkaia, the structure is different than the regular Ticketbai XML (LROE)""" + values = { + **self._l10n_es_tbai_get_subject_values(invoice, False), + **self._l10n_es_tbai_get_header_values(invoice), + **invoice._get_vendor_bill_tax_values(), + 'invoice': invoice, + 'datetime_now': datetime.now(tz=timezone('Europe/Madrid')), + 'format_date': lambda d: datetime.strftime(d, '%d-%m-%Y'), + 'format_time': lambda d: datetime.strftime(d, '%H:%M:%S'), + 'format_float': lambda f: float_repr(f, precision_digits=2), + } + # Check if intracom + mod_303_10 = self.env.ref('l10n_es.mod_303_casilla_10_balance')._get_matching_tags() + mod_303_11 = self.env.ref('l10n_es.mod_303_casilla_11_balance')._get_matching_tags() + tax_tags = invoice.invoice_line_ids.tax_ids.repartition_line_ids.tag_ids + intracom = bool(tax_tags & (mod_303_10 + mod_303_11)) + values['regime_key'] = ['09'] if intracom else ['01'] + # Credit notes (factura rectificativa) + values['is_refund'] = invoice.move_type == 'in_refund' + if values['is_refund']: + values['credit_note_code'] = invoice.l10n_es_tbai_refund_reason + values['credit_note_invoice'] = invoice.reversed_entry_id + values['tipofactura'] = 'F5' if invoice._l10n_es_is_dua() else 'F1' + return values + + def _l10n_es_tbai_prepare_values_bi(self, invoice, invoice_xml, cancel=False): sender = invoice.company_id lroe_values = { 'is_emission': not cancel, 'sender': sender, 'sender_vat': sender.vat[2:] if sender.vat.startswith('ES') else sender.vat, - 'tbai_b64_list': [b64encode(etree.tostring(invoice_xml, encoding="UTF-8")).decode()], 'fiscal_year': str(invoice.date.year), } - lroe_str = env['ir.qweb']._render('l10n_es_edi_tbai.template_LROE_240_main', lroe_values) + if invoice.is_sale_document(): + lroe_values.update({'tbai_b64_list': [b64encode(etree.tostring(invoice_xml, encoding="UTF-8")).decode()]}) + else: + lroe_values.update(self._l10n_es_tbai_get_in_invoice_values_batuz(invoice)) + return lroe_values + + def _l10n_es_tbai_prepare_post_params_bi(self, env, agency, invoice, invoice_xml, cancel=False): + """Web service parameters for Bizkaia.""" + lroe_values = self._l10n_es_tbai_prepare_values_bi(invoice, invoice_xml, cancel=cancel) + if invoice.is_purchase_document(): + lroe_str = env['ir.qweb']._render('l10n_es_edi_tbai.template_LROE_240_main_recibidas', lroe_values) + invoice.l10n_es_tbai_post_xml = b64encode(lroe_str.encode()) + else: + lroe_str = env['ir.qweb']._render('l10n_es_edi_tbai.template_LROE_240_main', lroe_values) + lroe_xml = cleanup_xml_node(lroe_str) lroe_str = etree.tostring(lroe_xml, encoding="UTF-8") lroe_bytes = gzip.compress(lroe_str) @@ -574,10 +621,10 @@ class AccountEdiFormat(models.Model): 'eus-bizkaia-n3-content-type': 'application/xml', 'eus-bizkaia-n3-data': json.dumps({ 'con': 'LROE', - 'apa': '1.1', + 'apa': '1.1' if invoice.is_sale_document() else '2', 'inte': { 'nif': lroe_values['sender_vat'], - 'nrs': sender.name, + 'nrs': invoice.company_id.name, }, 'drs': { 'mode': '240', diff --git a/addons/l10n_es_edi_tbai/models/account_move.py b/addons/l10n_es_edi_tbai/models/account_move.py index 90bd00b9df7..8dc347ccd6b 100644 --- a/addons/l10n_es_edi_tbai/models/account_move.py +++ b/addons/l10n_es_edi_tbai/models/account_move.py @@ -4,6 +4,7 @@ from base64 import b64decode, b64encode from datetime import datetime from re import sub as regex_sub +from collections import defaultdict from lxml import etree from odoo import _, api, fields, models @@ -80,7 +81,8 @@ class AccountMove(models.Model): @api.depends('move_type', 'company_id') def _compute_l10n_es_tbai_is_required(self): for move in self: - move.l10n_es_tbai_is_required = move.is_sale_document() \ + move.l10n_es_tbai_is_required = (move.is_sale_document() or move.is_purchase_document() and move.company_id.l10n_es_tbai_tax_agency == 'bizkaia' + and not any(t.l10n_es_type == 'ignore' for t in move.invoice_line_ids.tax_ids))\ and move.country_code == 'ES' \ and move.company_id.l10n_es_tbai_tax_agency @@ -221,3 +223,32 @@ class AccountMove(models.Model): self.l10n_es_tbai_cancel_xml = b64_doc else: self.l10n_es_tbai_post_xml = b64_doc + + def _get_vendor_bill_tax_values(self): + self.ensure_one() + results = defaultdict(lambda: {'base_amount': 0.0, 'tax_amount': 0.0}) + amount_total = 0.0 + for line in self.line_ids.filtered(lambda l: l.display_type in ('product', 'tax')): + if any(t.l10n_es_type == 'ignore' for t in line.tax_ids) or line.tax_line_id.l10n_es_type == 'ignore': + continue + if line.tax_line_id.l10n_es_type != 'retencion': + amount_total += line.balance + for tax in line.tax_ids.filtered(lambda t: t.l10n_es_type not in ('recargo', 'retencion')): + results[tax]['base_amount'] += line.balance + + if ((tax := line.tax_line_id) and tax.l10n_es_type not in ('recargo', 'retencion') and + line.tax_repartition_line_id.factor_percent != -100.0): + results[tax]['tax_amount'] += line.balance + iva_values = [] + for tax in results: + code = "C" # Bienes Corrientes + if tax.l10n_es_bien_inversion: + code = "I" # Investment Goods + if tax.tax_scope == 'service': + code = 'G' # Gastos + iva_values.append({'base': results[tax]['base_amount'], + 'code': code, + 'tax': results[tax]['tax_amount'], + 'rec': tax}) + return {'iva_values': iva_values, + 'amount_total': amount_total} diff --git a/addons/l10n_es_edi_tbai/tests/common.py b/addons/l10n_es_edi_tbai/tests/common.py index 8d314a561b4..ef408cc6a11 100644 --- a/addons/l10n_es_edi_tbai/tests/common.py +++ b/addons/l10n_es_edi_tbai/tests/common.py @@ -212,3 +212,111 @@ class TestEsEdiTbaiCommon(AccountEdiTestCommon): ___ignore___ """.encode("utf-8") + + L10N_ES_TBAI_SAMPLE_XML_POST_IN = """ + + + 240 + 2 + A00 + 1.0 + 2022 + + 09760433S + EUS Company + + + + + + + 02 + BE0477472701 + + &@àÁ$£€èêÈÊöÔÇç¡⅛™³ + + + INVTEST + 01 + 01-01-2022 + 01-01-2022 + F1 + + + + + 01 + + + 4840.00 + + + + C + N + 4000.0 + 21.0 + 840.0 + 840.0 + + + + +""" + + + L10N_ES_TBAI_SAMPLE_XML_POST_IN_IC = """ + + + 240 + 2 + A00 + 1.0 + 2022 + + 09760433S + EUS Company + + + + + + F35999705 + partner_b + + + INVTEST + 01 + 01-01-2022 + 01-01-2022 + F1 + + + + + 09 + + + 12000.00 + + + + C + N + 4000.0 + 21.0 + 840.0 + 840.0 + + G + N + 8000.0 + 21.0 + 1680.0 + 1680.0 + + + + + + """ \ No newline at end of file diff --git a/addons/l10n_es_edi_tbai/tests/test_edi_xml.py b/addons/l10n_es_edi_tbai/tests/test_edi_xml.py index 334f9ed6c4e..df7eb041806 100644 --- a/addons/l10n_es_edi_tbai/tests/test_edi_xml.py +++ b/addons/l10n_es_edi_tbai/tests/test_edi_xml.py @@ -33,16 +33,62 @@ class TestEdiTbaiXmls(TestEsEdiTbaiCommon): 'tax_ids': [(6, 0, cls._get_tax_by_xml_id('s_iva21b').ids)], })], }) - cls.edi_format = cls.env.ref('l10n_es_edi_tbai.edi_es_tbai') def test_xml_tree_post(self): + """Test of Customer Invoice XML""" with freeze_time(self.frozen_today): xml_doc = self.edi_format._get_l10n_es_tbai_invoice_xml(self.out_invoice, cancel=False)[self.out_invoice]['xml_file'] xml_doc.remove(xml_doc.find("Signature", namespaces=NS_MAP)) xml_expected = etree.fromstring(super().L10N_ES_TBAI_SAMPLE_XML_POST) self.assertXmlTreeEqual(xml_doc, xml_expected) + def test_xml_tree_in_post(self): + """Test XML of vendor bill for LROE Batuz""" + with freeze_time(self.frozen_today): + self.in_invoice = self.env['account.move'].create({ + 'name': 'INV/01', + 'move_type': 'in_invoice', + 'invoice_date': datetime.now(), + 'partner_id': self.partner_a.id, + 'invoice_line_ids': [(0, 0, { + 'product_id': self.product_a.id, + 'price_unit': 1000.0, + 'quantity': 5, + 'discount': 20.0, + 'tax_ids': [(6, 0, self._get_tax_by_xml_id('p_iva21_bc').ids)], + })], + }) + xml_doc = etree.fromstring(self.edi_format._l10n_es_tbai_get_invoice_content_edi(self.in_invoice)) + xml_expected = etree.fromstring(super().L10N_ES_TBAI_SAMPLE_XML_POST_IN) + self.assertXmlTreeEqual(xml_doc, xml_expected) + + def test_xml_tree_in_ic_post(self): + """Test XML of vendor bill for LROE Batuz intra-community""" + with freeze_time(self.frozen_today): + self.in_invoice = self.env['account.move'].create({ + 'name': 'INV/01', + 'move_type': 'in_invoice', + 'invoice_date': datetime.now(), + 'partner_id': self.partner_b.id, + 'invoice_line_ids': [(0, 0, { + 'product_id': self.product_a.id, + 'price_unit': 1000.0, + 'quantity': 5, + 'discount': 20.0, + 'tax_ids': [(6, 0, self._get_tax_by_xml_id('p_iva21_ic_bc').ids)], + }), (0, 0, { + 'product_id': self.product_b.id, + 'price_unit': 2000.0, + 'quantity': 5, + 'discount': 20.0, + 'tax_ids': [(6, 0, self._get_tax_by_xml_id('p_iva21_sp_in').ids)], + })], + }) + xml_doc = etree.fromstring(self.edi_format._l10n_es_tbai_get_invoice_content_edi(self.in_invoice)) + xml_expected = etree.fromstring(super().L10N_ES_TBAI_SAMPLE_XML_POST_IN_IC) + self.assertXmlTreeEqual(xml_doc, xml_expected) + def test_xml_tree_cancel(self): self.out_invoice.l10n_es_tbai_post_xml = b64encode(b""" 01-01-2022