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 @@
+
+
+
+
+
+
+
+
+
+ 240
+ 2
+
+ 1.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ I
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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