From 6a436c426d29fb898ad58abb6800fb29e8281552 Mon Sep 17 00:00:00 2001 From: tendil Date: Tue, 7 Apr 2026 18:00:32 +0000 Subject: [PATCH 1/2] [FIX] account_invoice_facturx: fix Factur-X 4.x validation Prevent Factur-X invoices from failing schematron validation with factur-x 4.x. The generated XML must stay compliant with newer validation rules, otherwise valid Odoo invoices cannot be exported as Factur-X documents. This change removes the invalid email URI attribute, exports a proprietary account identifier when IBAN is not available, and updates tests to cover the new compliant output and the missing-account error. Task: 5347 --- .../models/account_move.py | 47 +++++++----- .../tests/test_facturx_invoice.py | 75 +++++++++++++++++++ 2 files changed, 105 insertions(+), 17 deletions(-) diff --git a/account_invoice_facturx/models/account_move.py b/account_invoice_facturx/models/account_move.py index f79438655f..9f9e291129 100644 --- a/account_invoice_facturx/models/account_move.py +++ b/account_invoice_facturx/models/account_move.py @@ -96,9 +96,7 @@ def _cii_add_trade_contact_block(self, partner, parent_node, ns): email_node = etree.SubElement( trade_contact, ns["ram"] + "EmailURIUniversalCommunication" ) - email_uriid = etree.SubElement( - email_node, ns["ram"] + "URIID", schemeID="SMTP" - ) + email_uriid = etree.SubElement(email_node, ns["ram"] + "URIID") email_uriid.text = partner.email @api.model @@ -320,23 +318,38 @@ def _cii_add_trade_settlement_payment_means_block(self, trade_settlement, ns): and self.payment_mode_id.fixed_journal_id ): partner_bank = self.payment_mode_id.fixed_journal_id.bank_account_id - if partner_bank and partner_bank.acc_type == "iban": - payment_means_bank_account = etree.SubElement( - payment_means, ns["ram"] + "PayeePartyCreditorFinancialAccount" + if not partner_bank or not partner_bank.sanitized_acc_number: + raise UserError( + _( + "Missing bank account identifier on invoice '%s'. " + "Factur-X requires either an IBAN or a proprietary " + "account identifier (BT-84) for credit transfer " + "payment means." + ) + % (self.display_name or self.name) ) - iban = etree.SubElement( + + payment_means_bank_account = etree.SubElement( + payment_means, ns["ram"] + "PayeePartyCreditorFinancialAccount" + ) + if partner_bank.acc_type == "iban": + account_identifier = etree.SubElement( payment_means_bank_account, ns["ram"] + "IBANID" ) - iban.text = partner_bank.sanitized_acc_number - if ns["level"] in PROFILES_EN_UP and partner_bank.bank_bic: - payment_means_bank = etree.SubElement( - payment_means, - ns["ram"] + "PayeeSpecifiedCreditorFinancialInstitution", - ) - payment_means_bic = etree.SubElement( - payment_means_bank, ns["ram"] + "BICID" - ) - payment_means_bic.text = partner_bank.bank_bic + else: + account_identifier = etree.SubElement( + payment_means_bank_account, ns["ram"] + "ProprietaryID" + ) + account_identifier.text = partner_bank.sanitized_acc_number + if ns["level"] in PROFILES_EN_UP and partner_bank.bank_bic: + payment_means_bank = etree.SubElement( + payment_means, + ns["ram"] + "PayeeSpecifiedCreditorFinancialInstitution", + ) + payment_means_bic = etree.SubElement( + payment_means_bank, ns["ram"] + "BICID" + ) + payment_means_bic.text = partner_bank.bank_bic # Field mandate_id provided by the OCA module account_banking_mandate elif ( payment_means_code.text in DIRECT_DEBIT_CODES diff --git a/account_invoice_facturx/tests/test_facturx_invoice.py b/account_invoice_facturx/tests/test_facturx_invoice.py index f66271c46b..e1856f3948 100644 --- a/account_invoice_facturx/tests/test_facturx_invoice.py +++ b/account_invoice_facturx/tests/test_facturx_invoice.py @@ -2,11 +2,20 @@ # @author: Alexis de Lattre # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from unittest.mock import patch + from facturx import get_facturx_level from lxml import etree +from odoo.exceptions import UserError from odoo.tests.common import TransactionCase +RAM_NS = ( + "urn:un:unece:uncefact:data:standard:" + "ReusableAggregateBusinessInformationEntity:100" +) +NSMAP = {"ram": RAM_NS} + class TestFacturXInvoice(TransactionCase): @classmethod @@ -16,6 +25,14 @@ def setUpClass(cls): cls.company = cls.env.ref("base.main_company") cls.product1 = cls.env.ref("product.product_product_4") cls.product2 = cls.env.ref("product.product_product_1") + cls.env.user.partner_id.email = "billing@example.com" + cls.proprietary_bank = cls.env["res.partner.bank"].create( + { + "partner_id": cls.company.partner_id.id, + "acc_number": "ACC-FACTURX-0001", + "acc_type": "bank", + } + ) sale_taxes = cls.env["account.tax"].search( [ ("company_id", "=", cls.company.id), @@ -37,6 +54,7 @@ def setUpClass(cls): "move_type": "out_invoice", "partner_id": cls.env.ref("base.res_partner_2").id, "currency_id": cls.company.currency_id.id, + "partner_bank_id": cls.proprietary_bank.id, "invoice_line_ids": [ ( 0, @@ -60,6 +78,14 @@ def setUpClass(cls): } ) cls.invoice.action_post() + cls.invoice.partner_bank_id = cls.proprietary_bank + + def _generate_xml_root(self, invoice=None, level="en16931"): + invoice = invoice or self.invoice + self.company.write({"facturx_level": level}) + xml_bytes, fx_level = invoice.generate_facturx_xml() + self.assertEqual(fx_level, level) + return etree.fromstring(xml_bytes) def test_deep_customer_invoice(self): # Bug in Basic XSD: missing CountrySubDivisionName @@ -91,3 +117,52 @@ def test_deep_customer_invoice(self): xml_root = etree.fromstring(xml_bytes) facturx_level = get_facturx_level(xml_root) self.assertEqual(facturx_level, level) + + def test_email_uriid_has_no_schemeid(self): + xml_root = self._generate_xml_root(level="en16931") + uriid_nodes = xml_root.xpath( + "//ram:DefinedTradeContact/" + "ram:EmailURIUniversalCommunication/" + "ram:URIID", + namespaces=NSMAP, + ) + self.assertTrue(uriid_nodes, "Expected seller email URIID in EN16931 XML") + self.assertEqual(uriid_nodes[0].text, "billing@example.com") + self.assertNotIn("schemeID", uriid_nodes[0].attrib) + + def test_credit_transfer_uses_proprietary_id_for_non_iban_account(self): + xml_root = self._generate_xml_root(level="en16931") + proprietary_nodes = xml_root.xpath( + "//ram:SpecifiedTradeSettlementPaymentMeans/" + "ram:PayeePartyCreditorFinancialAccount/" + "ram:ProprietaryID", + namespaces=NSMAP, + ) + iban_nodes = xml_root.xpath( + "//ram:SpecifiedTradeSettlementPaymentMeans/" + "ram:PayeePartyCreditorFinancialAccount/" + "ram:IBANID", + namespaces=NSMAP, + ) + + self.assertTrue( + proprietary_nodes, + "Expected ProprietaryID for non-IBAN creditor account", + ) + self.assertEqual( + proprietary_nodes[0].text, + self.proprietary_bank.sanitized_acc_number, + ) + self.assertFalse(iban_nodes, "IBANID should not be generated for non-IBAN bank") + + def test_credit_transfer_requires_account_identifier(self): + invoice = self.invoice.copy(default={"partner_bank_id": False}) + invoice.action_post() + + self.company.write({"facturx_level": "en16931"}) + with patch( + "odoo.addons.account_invoice_facturx.models.account_move.xml_check_xsd" + ) as xml_check_xsd: + with self.assertRaises(UserError): + invoice.generate_facturx_xml() + xml_check_xsd.assert_not_called() From 7479b80dbf0ffe19a80cb95251f144278594886e Mon Sep 17 00:00:00 2001 From: tendil Date: Fri, 10 Apr 2026 09:48:42 +0000 Subject: [PATCH 2/2] [FIX] account_invoice_facturx: add delivery date to Factur-X XML Some invoices already have a delivery/service date on the Odoo side, but this date was not exported to the generated Factur-X XML. As a result, the XML could fail validation because neither a header delivery date nor a billing period was present. This patch adds the delivery date to the header trade delivery block by exporting `ActualDeliverySupplyChainEvent/OccurrenceDateTime`, using the invoice date as the default delivery/service date. The XML node order is also kept XSD-compliant. Task: 5347 --- account_invoice_facturx/models/account_move.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/account_invoice_facturx/models/account_move.py b/account_invoice_facturx/models/account_move.py index 9f9e291129..81bb61f0d3 100644 --- a/account_invoice_facturx/models/account_move.py +++ b/account_invoice_facturx/models/account_move.py @@ -268,6 +268,15 @@ def _get_contract_code(self): So it's difficult to have a common datamodel for it""" return False + def _cii_get_delivery_date(self): + """Return the delivery/service date to export in Factur-X XML. + + Designed to be inherited by modules that store a dedicated delivery + or service date on invoices. + """ + self.ensure_one() + return self.invoice_date + def _cii_add_trade_delivery_block(self, trade_transaction, ns): self.ensure_one() trade_agreement = etree.SubElement( @@ -281,6 +290,12 @@ def _cii_add_trade_delivery_block(self, trade_transaction, ns): self._cii_add_address_block( self.partner_shipping_id, shipto_trade_party, ns ) + delivery_date = self._cii_get_delivery_date() + if ns["level"] in PROFILES_EN_UP and delivery_date: + delivery_event = etree.SubElement( + trade_agreement, ns["ram"] + "ActualDeliverySupplyChainEvent" + ) + self._cii_add_date("OccurrenceDateTime", delivery_date, delivery_event, ns) return trade_agreement def _cii_add_trade_settlement_payment_means_block(self, trade_settlement, ns):