diff --git a/addons/l10n_es_edi_facturae/models/account_move.py b/addons/l10n_es_edi_facturae/models/account_move.py
index c2c40afa927..282d801f735 100644
--- a/addons/l10n_es_edi_facturae/models/account_move.py
+++ b/addons/l10n_es_edi_facturae/models/account_move.py
@@ -1,9 +1,12 @@
-from collections import defaultdict
+import re
-from odoo import fields, models, api, _
+from collections import defaultdict
+from markupsafe import escape, Markup
+
+from odoo import fields, models, api, _, Command
from odoo.exceptions import UserError
from odoo.tools import float_repr, date_utils
-from odoo.tools.xml_utils import cleanup_xml_node
+from odoo.tools.xml_utils import cleanup_xml_node, find_xml_value
PHONE_CLEAN_TABLE = str.maketrans({" ": None, "-": None, "(": None, ")": None, "+": None})
COUNTRY_CODE_MAP = {
@@ -379,3 +382,233 @@ class AccountMove(models.Model):
def _get_edi_doc_attachments_to_export(self):
# EXTENDS 'account'
return super()._get_edi_doc_attachments_to_export() + self.l10n_es_edi_facturae_xml_id
+
+ # -------------------------------------------------------------------------
+ # IMPORT
+ # -------------------------------------------------------------------------
+
+ def _get_edi_decoder(self, file_data, new=False):
+ def is_facturae(tree):
+ return tree.tag == '{http://www.facturae.es/Facturae/2014/v3.2.1/Facturae}Facturae'
+
+ if file_data['type'] == 'xml' and is_facturae(file_data['xml_tree']):
+ return self._import_invoice_facturae
+
+ return super()._get_edi_decoder(file_data, new=new)
+
+ def _import_invoice_facturae(self, invoice, file_data, new=False):
+ tree = file_data['xml_tree']
+ is_bill = invoice.move_type.startswith('in_')
+ partner = self._import_get_partner(tree, is_bill)
+ self._import_invoice_facturae_invoices(invoice, partner, tree)
+
+ def _import_get_partner(self, tree, is_bill):
+ # If we're dealing with a vendor bill, then the partner is the seller party, if an invoice then it's the buyer.
+ party = tree.xpath('//SellerParty') if is_bill else tree.xpath('//BuyerParty')
+ if party:
+ partner_vals = self._import_extract_partner_values(party[0])
+ return self._import_create_or_retrieve_partner(partner_vals)
+ return None
+
+ def _import_extract_partner_values(self, party_node):
+ name = find_xml_value('.//CorporateName|.//Name', party_node)
+ first_surname = find_xml_value('.//FirstSurname', party_node)
+ second_surname = find_xml_value('.//SecondSurname', party_node)
+ phone = find_xml_value('.//Telephone', party_node)
+ mail = find_xml_value('.//ElectronicMail', party_node)
+ country_code = find_xml_value('.//CountryCode', party_node)
+ vat = find_xml_value('.//TaxIdentificationNumber', party_node)
+
+ full_name = ' '.join(part for part in [name, first_surname, second_surname] if part)
+
+ return {'name': full_name, 'vat': vat, 'phone': phone, 'email': mail, 'country_code': country_code}
+
+ def _import_create_or_retrieve_partner(self, partner_vals):
+ name = partner_vals['name']
+ vat = partner_vals['vat']
+ phone = partner_vals['phone']
+ mail = partner_vals['email']
+ country_code = partner_vals['country_code']
+
+ partner = self.env['res.partner']._retrieve_partner(name=name, vat=vat, phone=phone, mail=mail)
+
+ if not partner and name:
+ partner_vals = {'name': name, 'email': mail, 'phone': phone}
+ country = self.env['res.country'].search([('code', '=', country_code.lower())]) if country_code else False
+ if country:
+ partner_vals['country_id'] = country.id
+ partner = self.env['res.partner'].create(partner_vals)
+ if vat and self.env['res.partner']._run_vat_test(vat, country):
+ partner.vat = vat
+
+ return partner
+
+ def _import_invoice_facturae_invoices(self, invoice, partner, tree):
+ invoices = tree.xpath('//Invoice')
+ if not invoices:
+ return
+
+ self._import_invoice_facturae_invoice(invoice, partner, invoices[0])
+
+ # There might be other invoices inside the facturae.
+ for node in invoices[1:]:
+ other_invoice = invoice.create({
+ 'journal_id': invoice.journal_id.id,
+ 'move_type': invoice.move_type
+ })
+ with other_invoice._get_edi_creation():
+ self._import_invoice_facturae_invoice(other_invoice, partner, node)
+ other_invoice.message_post(body=escape(_("Created from attachment in %s")) % invoice._get_html_link())
+
+ def _import_invoice_facturae_invoice(self, invoice, partner, tree):
+ logs = []
+
+ # ==== move_type ====
+ invoice_total = find_xml_value('.//InvoiceTotal', tree)
+ is_refund = float(invoice_total) < 0 if invoice_total else False
+ if is_refund:
+ invoice.move_type = "in_refund" if invoice.move_type.startswith("in_") else "out_refund"
+ ref_multiplier = -1.0 if is_refund else 1.0
+
+ # ==== partner_id ====
+ if partner:
+ invoice.partner_id = partner
+ else:
+ logs.append(_("Customer/Vendor could not be found and could not be created due to missing data in the XML."))
+
+ # ==== currency_id ====
+ invoice_currency_code = find_xml_value('.//InvoiceCurrencyCode', tree)
+ if invoice_currency_code:
+ currency = self.env['res.currency'].search([('name', '=', invoice_currency_code)], limit=1)
+ if currency:
+ invoice.currency_id = currency
+ else:
+ logs.append(_("Could not retrieve currency: %s. Did you enable the multicurrency option "
+ "and activate the currency?", invoice_currency_code))
+
+ # ==== invoice date ====
+ if issue_date := find_xml_value('.//IssueDate', tree):
+ invoice.invoice_date = issue_date
+
+ # ==== invoice_date_due ====
+ if end_date := find_xml_value('.//InstallmentDueDate', tree):
+ invoice.invoice_date_due = end_date
+
+ # ==== ref ====
+ if invoice_number := find_xml_value('.//InvoiceNumber', tree):
+ invoice.ref = invoice_number
+
+ # ==== narration ====
+ invoice.narration = "\n".join(
+ ref.text
+ for ref in tree.xpath('.//LegalReference')
+ if ref.text
+ )
+
+ # === invoice_line_ids ===
+ logs += self._import_invoice_fill_lines(invoice, tree, ref_multiplier)
+
+ body = Markup("%s") % _("Invoice imported from Factura-E XML file.")
+
+ if logs:
+ body += Markup("
") \
+ % Markup().join(Markup("%s") % log for log in logs)
+
+ invoice.message_post(body=body)
+
+ return logs
+
+ def _import_invoice_fill_lines(self, invoice, tree, ref_multiplier):
+ lines = tree.xpath('.//InvoiceLine')
+ logs = []
+ vals_list = []
+ for line in lines:
+ line_vals = {'move_id': invoice.id}
+
+ # ==== name ====
+ if item_description := find_xml_value('.//ItemDescription', line):
+ product = self._search_product_for_import(item_description)
+ if product:
+ line_vals['product_id'] = product.id
+ else:
+ logs.append(_("The product '%s' could not be found.", item_description))
+ line_vals['name'] = item_description
+
+ # ==== quantity ====
+ line_vals['quantity'] = find_xml_value('.//Quantity', line) or 1
+
+ # ==== price_unit ====
+ price_unit = find_xml_value('.//UnitPriceWithoutTax', line)
+ line_vals['price_unit'] = ref_multiplier * float(price_unit) if price_unit else 1.0
+
+ # ==== discount ====
+ discounts = line.xpath('.//DiscountRate')
+ discount_rate = 0.0
+ for discount in discounts:
+ discount_rate += float(discount.text)
+
+ charges = line.xpath('.//ChargeRate')
+ charge_rate = 0.0
+ for charge in charges:
+ charge_rate += float(charge.text)
+
+ discount_rate -= charge_rate
+ line_vals['discount'] = discount_rate
+
+ # ==== tax_ids ====
+ taxes_withheld_nodes = line.xpath('.//TaxesWithheld/Tax')
+ taxes_outputs_nodes = line.xpath('.//TaxesOutputs/Tax')
+ is_purchase = invoice.move_type.startswith('in')
+ tax_ids = []
+ logs += self._import_fill_invoice_line_taxes(invoice, line_vals, tax_ids, taxes_outputs_nodes, False, is_purchase)
+ logs += self._import_fill_invoice_line_taxes(invoice, line_vals, tax_ids, taxes_withheld_nodes, True, is_purchase)
+ line_vals['tax_ids'] = [Command.set(tax_ids)]
+ vals_list.append(line_vals)
+
+ invoice.invoice_line_ids = self.env['account.move.line'].create(vals_list)
+ return logs
+
+ def _import_fill_invoice_line_taxes(self, invoice, line_vals, tax_ids, tax_nodes, is_withheld, is_purchase):
+ logs = []
+ for tax_node in tax_nodes:
+ tax_rate = find_xml_value('.//TaxRate', tax_node)
+ if tax_rate:
+ # Since the 'TaxRate' node isn't guaranteed to be a percentage, we can find out by
+ # applying the tax rate on the taxable base, and if it's equal to the tax amount
+ # then we can say this is a percentage, otherwise a fixed amount.
+ taxable_base = find_xml_value('.//TaxableBase/TotalAmount', tax_node)
+ tax_amount = find_xml_value('.//TaxAmount/TotalAmount', tax_node)
+ is_fixed = False
+
+ if taxable_base and tax_amount and invoice.currency_id.compare_amounts(float(taxable_base) * (float(tax_rate) / 100), float(tax_amount)) != 0:
+ is_fixed = True
+
+ tax_excl = self._search_tax_for_import(invoice.company_id, float(tax_rate), is_fixed, is_withheld, is_purchase, price_included=False)
+
+ if tax_excl:
+ tax_ids.append(tax_excl.id)
+ elif tax_incl := self._search_tax_for_import(invoice.company_id, float(tax_rate), is_fixed, is_withheld, is_purchase, price_included=True):
+ tax_ids.append(tax_incl)
+ line_vals['price_unit'] *= (1.0 + float(tax_rate) / 100.0)
+ else:
+ logs.append(_("Could not retrieve the tax: %s %% for line '%s'.", tax_rate, line_vals.get('name', "")))
+
+ return logs
+
+ def _search_tax_for_import(self, company, amount, is_fixed, is_withheld, is_purchase, price_included):
+ taxes = self.env['account.tax'].search([
+ ('company_id', '=', company.id),
+ ('amount', '=', -1.0 * amount if is_withheld else amount),
+ ('amount_type', '=', 'fixed' if is_fixed else 'percent'),
+ ('type_tax_use', '=', 'purchase' if is_purchase else 'sale'),
+ ('price_include', '=', price_included),
+ ], limit=1)
+
+ return taxes
+
+ def _search_product_for_import(self, item_description):
+ # Exported Odoo XML will have item_description = "[default_code] name".
+ # We can check if it follows the same format and search for the product with the default code and the name.
+ code_and_name = re.match(r"(\[(?P.*?)\]\s)?(?P.*)", item_description).groupdict()
+ product = self.env['product.product']._retrieve_product(**code_and_name)
+ return product
diff --git a/addons/l10n_es_edi_facturae/tests/data/import_multiple_invoices.xml b/addons/l10n_es_edi_facturae/tests/data/import_multiple_invoices.xml
new file mode 100644
index 00000000000..7f556bc6d47
--- /dev/null
+++ b/addons/l10n_es_edi_facturae/tests/data/import_multiple_invoices.xml
@@ -0,0 +1,338 @@
+
+
+
+ 3.2.1
+ I
+ EM
+
+ INV/2023/00005
+ 1
+
+ 2186.20
+
+
+ 2186.20
+
+
+ 2186.20
+
+ EUR
+
+
+
+
+
+ F
+ R
+ ES59962470K
+
+
+
+ A
+ 22591
+ Candasnos
+ Zaragoza
+ ESP
+
+
+ http://www.esexample.com
+ info@company.esexample.com
+
+
+
+
+
+ J
+ E
+ US12345677
+
+
+ Azure Interior
+ Azure Interior
+
+ 4557 De Silva St
+ 94538 Fremont
+ California
+ USA
+
+
+ 8709310505
+ http://www.azure-interior.com
+ azure.Interior24@example.com
+
+
+
+
+
+
+
+ INV/2023/00005
+ FC
+ OO
+
+
+ 2023-08-01
+ EUR
+ EUR
+ en
+
+
+
+ 01
+ 21.000
+
+ 320.00
+
+
+ 67.20
+
+
+
+ 01
+ 0.000
+
+ 1799.00
+
+
+ 0.00
+
+
+
+
+ 2119.00
+ 2119.00
+ 67.20
+ 0.00
+ 2186.20
+ 2186.20
+ 2186.20
+
+
+
+ [E-COM07] Large Cabinet
+ 1.0
+ 01
+ 320.00
+ 320.00
+ 320.00
+
+
+ 01
+ 21.000
+
+ 320.00
+
+
+ 67.20
+
+
+
+
+
+ [E-COM09] Large Desk
+ 1.0
+ 01
+ 1799.00
+ 1799.00
+ 1799.00
+
+
+ 01
+ 0.000
+
+ 1799.00
+
+
+ 0.00
+
+
+
+
+
+
+ Terms and conditions.
+
+
+
+ 2023-08-31
+
+
+
+
+
+ INV/2023/00006
+ FC
+ OO
+
+
+ 2023-07-01
+ EUR
+ EUR
+ en
+
+
+
+ 01
+ 21.000
+
+ 960.00
+
+
+ 201.60
+
+
+
+
+ 960.00
+ 960.00
+ 201.60
+ 0.00
+ 1161.60
+ 1161.60
+ 1161.60
+
+
+
+ [E-COM07] Large Cabinet
+ 3.0
+ 01
+ 320.00
+ 960.00
+ 960.00
+
+
+ 01
+ 21.000
+
+ 960.00
+
+
+ 201.60
+
+
+
+
+
+
+ Legal References.
+
+
+
+ 2023-07-31
+
+
+
+
+
+
+
+
+
+
+
+
+
+ cElLMko1b3H9dDrcO0nLhBA0DM7NWpAVuFDk7r+HMxM=
+
+
+
+ 1fyEQGjgpXKz9ie50e2WwFRk5kXklJNr10YHVMWBHeo=
+
+
+
+ rUjANojsduwjEzBt8hX6sFsG9F1mwQ1X/rJhxY1MOGI=
+
+
+ ecCZUOChRyxRpMRo8PaqBDKsuiWf8IOavpwLDSaD1+ZjEKF20D7GUfMoDbMTmotpw+qybmuQQZVQ
+CRpl+7efRdcIGNjKwI1hxdbwFd41SCU3HXZKJ5B6mOdJjW4uBnXce157lI4U/liyHmdRyW1K3WZc
+RCh4GrJ5M7N662JfhVOLhZaKSXFI7+s517HD5mJ4tw84UG7j3OgAfVqH3BZe+oM8boTJAUtFyNlO
+o8YWWWgvV6LNkqbKkP5J2dcrwQmaUuISeQ7+/BU0T7OfdbpFGtvz1PrI2d9YTFHrH+aWU5gZ96kX
+cmr2Ab2uGYRNXqIpHEA9DcY0pkapd6CNgPv5sg==
+
+
+
+ MIIEMTCCAxmgAwIBAgIUXMxVyBDULPbIaeKVK9bJwftsQdswDQYJKoZIhvcNAQELBQAwgacxCzAJ
+BgNVBAYTAkJFMREwDwYDVQQIDAhXYWxsb25pYTEgMB4GA1UEBwwXR3JhbmQtUm9zaWVyZS1Ib3R0
+b21vbnQxFTATBgNVBAoMDFRlc3QgT2RvbyBTQTEXMBUGA1UECwwOUiZEIEFjY291bnRpbmcxFzAV
+BgNVBAMMDlImRCBBY2NvdW50aW5nMRowGAYJKoZIhvcNAQkBFgtyZEBvZG9vLmNvbTAeFw0yMzAy
+MDkwODU1NDhaFw0zMzAyMDYwODU1NDhaMIGnMQswCQYDVQQGEwJCRTERMA8GA1UECAwIV2FsbG9u
+aWExIDAeBgNVBAcMF0dyYW5kLVJvc2llcmUtSG90dG9tb250MRUwEwYDVQQKDAxUZXN0IE9kb28g
+U0ExFzAVBgNVBAsMDlImRCBBY2NvdW50aW5nMRcwFQYDVQQDDA5SJkQgQWNjb3VudGluZzEaMBgG
+CSqGSIb3DQEJARYLcmRAb2Rvby5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCx
+eId7+OTIUl/GWhSo+y+E+8yclmgdgWope+fyq4jYACSID2rrxcScAgmGcGfvcNJ7e4aUoqrGg7Ob
+FIuDbGjVg5RFUjDljzvV97em7KuPf/qHVWAVXrKkD6JgV7KRp38RLyBLdgbwZqgP0QRBM6HLl1nC
+H6TMrVtTOskjeJ9jd1VACBlMWFpaTQl4dcnM6W8o7e5OYSru6u9xuqyQQim06YyjwAuxfQEMO1OV
+n3cQlrqpbOI5SNu3vi6eb0z4n1KEw8t2zeF+QrMSNzykb3CJVOaLI8uyYcZXow7RcSy0HZNu4TZS
+nQRgODbxrFlcfSHz2hSqxWAXW4/He594Gbw9AgMBAAGjUzBRMB0GA1UdDgQWBBSjLlcJeL3Yg2aL
+L7aElSBMhQqLXjAfBgNVHSMEGDAWgBSjLlcJeL3Yg2aLL7aElSBMhQqLXjAPBgNVHRMBAf8EBTAD
+AQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCf9DCON7ehvD+x/EXLIDhJUzsWxyICj0T+cMw7BBrJVQ94
+6RJlR9m3sNwIy0VoYQKNJ1JGda15YVpppyJTVSj11Ig8rG6/l06ysNnzJ6+aA2ba1Z0ycFFkX+Cl
+By7sIqAn+HbAJ6M4sAzQKvoWoaFA4uSbZGLLf2NKMNGIsTBO3rtSEt8zqJBTVuX03Sxp9mJIjIXi
+HfrCCDyBtXxnnBpTnMFy74mHg2Xbu5si85nxnNzYC0RTbd7qxEO0OEBQGz5AMH/kyzNnyu9WvLlg
+lfRpb8ioiJ9ZFLNIsZtRA60R5NWDpACwADa9WnfqzH8lHnEaIXwsziU+SSAIz8kSdCwQ
+
+
+
+
+ sXiHe/jkyFJfxloUqPsvhPvMnJZoHYFqKXvn8quI2AAkiA9q68XEnAIJhnBn73DSe3uGlKKqxoOz
+mxSLg2xo1YOURVIw5Y871fe3puyrj3/6h1VgFV6ypA+iYFeykad/ES8gS3YG8GaoD9EEQTOhy5dZ
+wh+kzK1bUzrJI3ifY3dVQAgZTFhaWk0JeHXJzOlvKO3uTmEq7urvcbqskEIptOmMo8ALsX0BDDtT
+lZ93EJa6qWziOUjbt74unm9M+J9ShMPLds3hfkKzEjc8pG9wiVTmiyPLsmHGV6MO0XEstB2TbuE2
+Up0EYDg28axZXH0h89oUqsVgF1uPx3ufeBm8PQ==
+
+ AQAB
+
+
+
+
+
+
+
+
+ 2023-08-31T09:51:34.576445
+
+
+
+
+ cP4cRSOphs7/CsqPwdGujvo7xkSkh8CfvFPAsQf/zRE=
+
+
+ C=BE, CN=R&D Accounting, L=Grand-Rosiere-Hottomont, O=Test Odoo SA, OU=R&D Accounting, ST=Wallonia
+ 529783975559440394003723764779526236797615751643
+
+
+
+
+
+
+ http://www.facturae.es/politica_de_firma_formato_facturae/politica_de_firma_formato_facturae_v3_1.pdf
+ Política de firma electrónica para facturación electrónica con formato Facturae
+
+
+
+ a/vDoueJq9zxWGCFwdsXdlXtkFSEek4XVtmlQCEDaE8=
+
+
+
+ http://www.facturae.es/politica_de_firma_formato_facturae/politica_de_firma_formato_facturae_v3_1.pdf
+
+
+
+
+
+
+
+
+
+ urn:oid:1.2.840.10003.5.109.10
+
+
+ text/xml
+
+
+
+
+
+
+
+
diff --git a/addons/l10n_es_edi_facturae/tests/data/import_withholding_invoice.xml b/addons/l10n_es_edi_facturae/tests/data/import_withholding_invoice.xml
new file mode 100644
index 00000000000..d6e94e9b0b2
--- /dev/null
+++ b/addons/l10n_es_edi_facturae/tests/data/import_withholding_invoice.xml
@@ -0,0 +1,260 @@
+
+
+
+ 3.2.1
+ I
+ EM
+
+ INV/2023/00009
+ 1
+
+ 323.20
+
+
+ 323.20
+
+
+ 323.20
+
+ EUR
+
+
+
+
+
+ F
+ R
+ ES59962470K
+
+
+
+ A
+ 22591
+ Candasnos
+ Zaragoza
+ ESP
+
+
+ http://www.esexample.com
+ info@company.esexample.com
+
+
+
+
+
+ J
+ E
+ US12345677
+
+
+ Azure Interior
+ Azure Interior
+
+ 4557 De Silva St
+ 94538 Fremont
+ California
+ USA
+
+
+ 8709310505
+ http://www.azure-interior.com
+ azure.Interior24@example.com
+
+
+
+
+
+
+
+ INV/2023/00009
+ FC
+ OO
+
+
+ 2023-09-01
+ EUR
+ EUR
+ en
+
+
+
+ 01
+ 21.000
+
+ 320.00
+
+
+ 67.20
+
+
+
+
+
+ 01
+ 20.000
+
+ 320.00
+
+
+ 64.00
+
+
+
+
+ 320.00
+ 320.00
+ 67.20
+ 64.00
+ 323.20
+ 323.20
+ 323.20
+
+
+
+ [E-COM07] Large Cabinet
+ 1.0
+ 01
+ 320.00
+ 320.00
+ 320.00
+
+
+ 01
+ 21.000
+
+ 320.00
+
+
+ 67.20
+
+
+
+
+
+ 01
+ 20.000
+
+ 320.00
+
+
+ 64.00
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ y+DiG9thnFpt6SvkhDhV/8PYAzfhj0KKTXnZjbCbfa8=
+
+
+
+ jx6wLH7wWnh3HQD4ixOqAWULf2lT9BAxbbWDzxloxo4=
+
+
+
+ pti8uQOjoaRhHT1DyGg8Z0BKxxhgQMfyrHMbdiHzS/M=
+
+
+ qSZAhTS36ed1Vl0hPHZHDrY1tJtr0gcjY7sgqZgw163wdqZYiV5WWmM8l6ttc1voNrrw1XCENXMz
+Sur4lCKCQdIA400uvRM1tY8LoptM5GJswZoLnfZS2lkK8xAC8vDoBAKEaIVjuBd8Gi+Oztz6OiJG
++EIT8mMPmZ9jCt3XExMn8dZRaTeB6XwTH8GGyvgWjXVxEbSTD+BiRthORMeNwAdl3KiCrzgrYbNu
+QaozjiZQGfpj2hJt2+zKvhpEML1nWRQUVrM2sjBxhhewQTmxavzG1xL3MHBzT5//YZYfsSNFJbE0
+imJThhtOwrZvWqgN/B8wfhrS2eZkue9w0cX2cg==
+
+
+
+ MIIEMTCCAxmgAwIBAgIUXMxVyBDULPbIaeKVK9bJwftsQdswDQYJKoZIhvcNAQELBQAwgacxCzAJ
+BgNVBAYTAkJFMREwDwYDVQQIDAhXYWxsb25pYTEgMB4GA1UEBwwXR3JhbmQtUm9zaWVyZS1Ib3R0
+b21vbnQxFTATBgNVBAoMDFRlc3QgT2RvbyBTQTEXMBUGA1UECwwOUiZEIEFjY291bnRpbmcxFzAV
+BgNVBAMMDlImRCBBY2NvdW50aW5nMRowGAYJKoZIhvcNAQkBFgtyZEBvZG9vLmNvbTAeFw0yMzAy
+MDkwODU1NDhaFw0zMzAyMDYwODU1NDhaMIGnMQswCQYDVQQGEwJCRTERMA8GA1UECAwIV2FsbG9u
+aWExIDAeBgNVBAcMF0dyYW5kLVJvc2llcmUtSG90dG9tb250MRUwEwYDVQQKDAxUZXN0IE9kb28g
+U0ExFzAVBgNVBAsMDlImRCBBY2NvdW50aW5nMRcwFQYDVQQDDA5SJkQgQWNjb3VudGluZzEaMBgG
+CSqGSIb3DQEJARYLcmRAb2Rvby5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCx
+eId7+OTIUl/GWhSo+y+E+8yclmgdgWope+fyq4jYACSID2rrxcScAgmGcGfvcNJ7e4aUoqrGg7Ob
+FIuDbGjVg5RFUjDljzvV97em7KuPf/qHVWAVXrKkD6JgV7KRp38RLyBLdgbwZqgP0QRBM6HLl1nC
+H6TMrVtTOskjeJ9jd1VACBlMWFpaTQl4dcnM6W8o7e5OYSru6u9xuqyQQim06YyjwAuxfQEMO1OV
+n3cQlrqpbOI5SNu3vi6eb0z4n1KEw8t2zeF+QrMSNzykb3CJVOaLI8uyYcZXow7RcSy0HZNu4TZS
+nQRgODbxrFlcfSHz2hSqxWAXW4/He594Gbw9AgMBAAGjUzBRMB0GA1UdDgQWBBSjLlcJeL3Yg2aL
+L7aElSBMhQqLXjAfBgNVHSMEGDAWgBSjLlcJeL3Yg2aLL7aElSBMhQqLXjAPBgNVHRMBAf8EBTAD
+AQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCf9DCON7ehvD+x/EXLIDhJUzsWxyICj0T+cMw7BBrJVQ94
+6RJlR9m3sNwIy0VoYQKNJ1JGda15YVpppyJTVSj11Ig8rG6/l06ysNnzJ6+aA2ba1Z0ycFFkX+Cl
+By7sIqAn+HbAJ6M4sAzQKvoWoaFA4uSbZGLLf2NKMNGIsTBO3rtSEt8zqJBTVuX03Sxp9mJIjIXi
+HfrCCDyBtXxnnBpTnMFy74mHg2Xbu5si85nxnNzYC0RTbd7qxEO0OEBQGz5AMH/kyzNnyu9WvLlg
+lfRpb8ioiJ9ZFLNIsZtRA60R5NWDpACwADa9WnfqzH8lHnEaIXwsziU+SSAIz8kSdCwQ
+
+
+
+
+ sXiHe/jkyFJfxloUqPsvhPvMnJZoHYFqKXvn8quI2AAkiA9q68XEnAIJhnBn73DSe3uGlKKqxoOz
+mxSLg2xo1YOURVIw5Y871fe3puyrj3/6h1VgFV6ypA+iYFeykad/ES8gS3YG8GaoD9EEQTOhy5dZ
+wh+kzK1bUzrJI3ifY3dVQAgZTFhaWk0JeHXJzOlvKO3uTmEq7urvcbqskEIptOmMo8ALsX0BDDtT
+lZ93EJa6qWziOUjbt74unm9M+J9ShMPLds3hfkKzEjc8pG9wiVTmiyPLsmHGV6MO0XEstB2TbuE2
+Up0EYDg28axZXH0h89oUqsVgF1uPx3ufeBm8PQ==
+
+ AQAB
+
+
+
+
+
+
+
+
+ 2023-09-01T12:16:38.095681
+
+
+
+
+ cP4cRSOphs7/CsqPwdGujvo7xkSkh8CfvFPAsQf/zRE=
+
+
+ C=BE, CN=R&D Accounting, L=Grand-Rosiere-Hottomont, O=Test Odoo SA, OU=R&D Accounting, ST=Wallonia
+ 529783975559440394003723764779526236797615751643
+
+
+
+
+
+
+ http://www.facturae.es/politica_de_firma_formato_facturae/politica_de_firma_formato_facturae_v3_1.pdf
+ Política de firma electrónica para facturación electrónica con formato Facturae
+
+
+
+ a/vDoueJq9zxWGCFwdsXdlXtkFSEek4XVtmlQCEDaE8=
+
+
+
+ http://www.facturae.es/politica_de_firma_formato_facturae/politica_de_firma_formato_facturae_v3_1.pdf
+
+
+
+
+
+
+
+
+
+ urn:oid:1.2.840.10003.5.109.10
+
+
+ text/xml
+
+
+
+
+
+
+
+
diff --git a/addons/l10n_es_edi_facturae/tests/test_edi_xml.py b/addons/l10n_es_edi_facturae/tests/test_edi_xml.py
index 3f8dc943f81..7b3973dd2f1 100644
--- a/addons/l10n_es_edi_facturae/tests/test_edi_xml.py
+++ b/addons/l10n_es_edi_facturae/tests/test_edi_xml.py
@@ -8,7 +8,7 @@ from unittest.mock import patch
import lxml
from freezegun import freeze_time
-from odoo import Command
+from odoo import Command, fields
from odoo.addons.account_edi.tests.common import AccountEdiTestCommon
from odoo.exceptions import UserError
from odoo.tests import tagged
@@ -251,3 +251,94 @@ class TestEdiFacturaeXmls(AccountEdiTestCommon):
self.assertEqual(result['type'], 'ir.actions.act_url')
self.assertEqual(invoice.invoice_line_ids[0].price_subtotal, 0.0)
+
+ def test_import_multiple_invoices(self):
+ with file_open("l10n_es_edi_facturae/tests/data/import_multiple_invoices.xml", "rt") as f:
+ imported_xml = lxml.etree.fromstring(f.read().encode())
+
+ moves = self.env['account.move'].create({'move_type': 'out_invoice'})
+ moves._import_invoice_facturae(moves, {'xml_tree': imported_xml})
+
+ moves += self.env['account.move'].search([('ref', '=', 'INV/2023/00006'), ('company_id', '=', self.company_data['company'].id)], limit=1)
+
+ partner = self.env['res.partner'].search([
+ ('name', '=', 'Azure Interior'),
+ ('email', '=', 'azure.Interior24@example.com'),
+ ])
+ currency = self.env['res.currency'].search([('name', '=', 'EUR')])
+
+ self.assertRecordValues(moves, [
+ {
+ 'partner_id': partner.id,
+ 'amount_total': 2186.20,
+ 'amount_untaxed': 2119.0,
+ 'amount_tax': 67.2,
+ 'move_type': 'out_invoice',
+ 'currency_id': currency.id,
+ 'invoice_date': fields.Date.from_string('2023-08-01'),
+ 'invoice_date_due': fields.Date.from_string('2023-08-31'),
+ 'ref': 'INV/2023/00005',
+ 'narration': 'Terms and conditions.
',
+ },
+ {
+ 'partner_id': partner.id,
+ 'amount_total': 1161.60,
+ 'amount_untaxed': 960.0,
+ 'amount_tax': 201.60,
+ 'move_type': 'out_invoice',
+ 'currency_id': currency.id,
+ 'invoice_date': fields.Date.from_string('2023-07-01'),
+ 'invoice_date_due': fields.Date.from_string('2023-07-31'),
+ 'ref': 'INV/2023/00006',
+ 'narration': 'Legal References.
',
+ },
+ ])
+
+ # Check first invoice's lines.
+ self.assertRecordValues(moves[0].invoice_line_ids, [
+ {
+ 'name': '[E-COM07] Large Cabinet',
+ 'price_unit': 320.0,
+ 'quantity': 1.0,
+ 'price_total': 387.2,
+ 'discount': 0.0,
+ },
+ {
+ 'name': '[E-COM09] Large Desk',
+ 'price_unit': 1799.0,
+ 'quantity': 1.0,
+ 'price_total': 1799,
+ 'discount': 0.0,
+ }
+ ])
+
+ # Check second invoice's lines.
+ self.assertRecordValues(moves[1].invoice_line_ids, [
+ {
+ 'name': '[E-COM07] Large Cabinet',
+ 'price_unit': 320.0,
+ 'quantity': 3.0,
+ 'price_total': 1161.60,
+ 'discount': 0.0,
+ },
+ ])
+
+ def test_import_withheld_taxes(self):
+ with file_open("l10n_es_edi_facturae/tests/data/import_withholding_invoice.xml", "rt") as f:
+ imported_xml = lxml.etree.fromstring(f.read().encode())
+
+ move = self.env['account.move'].create({'move_type': 'out_invoice'})
+ move._import_invoice_facturae(move, {'xml_tree': imported_xml})
+
+ self.assertRecordValues(move, [
+ {
+ 'amount_total': 323.2,
+ 'amount_untaxed': 320.0,
+ 'amount_tax': 3.2,
+ },
+ ])
+
+ tax_amounts = [tax.amount for tax in move.invoice_line_ids.tax_ids]
+
+ # Check first invoice's lines.
+ self.assertEqual(tax_amounts, [21.0, -20.0])