From 6a436c426d29fb898ad58abb6800fb29e8281552 Mon Sep 17 00:00:00 2001 From: tendil Date: Tue, 7 Apr 2026 18:00:32 +0000 Subject: [PATCH 1/4] [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/4] [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): From 69043257d04ca93d9099cbb71117ec4ea56dd03f Mon Sep 17 00:00:00 2001 From: Pawel Kazakow Date: Sun, 10 May 2026 23:11:39 +0200 Subject: [PATCH 3/4] [IMP] account_invoice_facturx: schematron-clean output for all 5 profiles + BG-26 line period This change brings the Factur-X / ZUGFeRD generator to a state where the produced XML validates cleanly against the bundled factur-x schematrons for all five profiles (MINIMUM, BASICWL, BASIC, EN16931, EXTENDED), and adds a generic extension hook for the EN 16931 invoice line period (BG-26) so subscription / recurring billing modules can plug in their own date fields without patching the line generator. Schematron fixes ---------------- * MINIMUM: do not emit BuyerTradeParty/PostalTradeAddress and BuyerTradeParty/SpecifiedTaxRegistration. Both are marked as "not used" by the MINIMUM schematron. The corresponding SellerTradeParty children are kept because MINIMUM does require BT-31 (Seller VAT identifier). * BASIC: emit a non-empty ApplicableHeaderTradeDelivery block with ActualDeliverySupplyChainEvent/OccurrenceDateTime (BT-72), fixing both PEPPOL-EN16931-R008 ("document MUST not contain empty elements") and BR-FX-EN-04 ("Each invoice must contain a delivery date or invoicing period"). The delivery date is read through the existing _cii_get_delivery_date() hook so the source field is not re-decided here. * EN16931: do not emit CalculationPercent and BasisAmount inside GrossPriceProductTradePrice/AppliedTradeAllowanceCharge. Both are marked as "not used" in EN16931. The EXTENDED profile keeps them. BG-26 line period ----------------- * Add _cii_get_line_period(iline) hook on account.move that returns a (start_date, end_date) tuple. Default implementation reads, in order of precedence: - account.move.line.deferred_start_date / deferred_end_date (Odoo Enterprise account_accountant module, detected at runtime via _fields so this OCA module gains no hard dependency on Enterprise code), or - account.move.line.start_date / end_date (OCA module account_invoice_start_end_dates). * The line generator emits BillingSpecifiedPeriod with StartDateTime and EndDateTime children whenever the hook returns at least one date, gated to the EN16931 and EXTENDED profiles per the BG-26 schematron. Tests ----- * Add a schematron-based test suite that exercises all five Factur-X profiles for two scenarios (default invoice, invoice with line-level discount), plus a third subscription scenario covering BG-26. The subscription test guards itself with account.move.line._fields so it skips gracefully on Community installations without account_accountant. Documentation ------------- * Add readme/DEVELOP.md describing both extension hooks (_cii_get_delivery_date and _cii_get_line_period) with EN 16931 business term mapping (BT-72, BG-26), the corresponding XPath locations and a worked example for downstream subscription modules. * Add readme/HISTORY.md with an "Unreleased" section listing the schematron fixes and the BG-26 hook. Co-authored-by: tendil Signed-off-by: Pawel Kazakow --- .../models/account_move.py | 138 ++++++++++----- account_invoice_facturx/readme/DEVELOP.md | 73 ++++++++ account_invoice_facturx/readme/HISTORY.md | 42 +++++ .../tests/test_facturx_invoice.py | 157 +++++++++++++++++- 4 files changed, 371 insertions(+), 39 deletions(-) create mode 100644 account_invoice_facturx/readme/DEVELOP.md create mode 100644 account_invoice_facturx/readme/HISTORY.md diff --git a/account_invoice_facturx/models/account_move.py b/account_invoice_facturx/models/account_move.py index 81bb61f0d3..c6676d95a1 100644 --- a/account_invoice_facturx/models/account_move.py +++ b/account_invoice_facturx/models/account_move.py @@ -221,15 +221,23 @@ def _cii_add_trade_agreement_block(self, trade_transaction, ns): and self.partner_id.name ): self._cii_add_trade_contact_block(self.partner_id, buyer, ns) - self._cii_add_address_block(self.partner_id, buyer, ns) - if self.commercial_partner_id.vat: - buyer_tax_reg = etree.SubElement( - buyer, ns["ram"] + "SpecifiedTaxRegistration" - ) - buyer_tax_reg_id = etree.SubElement( - buyer_tax_reg, ns["ram"] + "ID", schemeID="VA" - ) - buyer_tax_reg_id.text = self.commercial_partner_id.vat + # The MINIMUM profile restricts BuyerTradeParty to Name (and + # SpecifiedLegalOrganization). Both ram:PostalTradeAddress and + # ram:SpecifiedTaxRegistration are flagged "marked as not used + # in the given context" by the factur-x MINIMUM schematron. The + # seller side is intentionally not gated here: MINIMUM allows + # SellerTradeParty/PostalTradeAddress and the seller's VAT + # (BT-31) is mandatory at MINIMUM. + if ns["level"] != "minimum": + self._cii_add_address_block(self.partner_id, buyer, ns) + if self.commercial_partner_id.vat: + buyer_tax_reg = etree.SubElement( + buyer, ns["ram"] + "SpecifiedTaxRegistration" + ) + buyer_tax_reg_id = etree.SubElement( + buyer_tax_reg, ns["ram"] + "ID", schemeID="VA" + ) + buyer_tax_reg_id.text = self.commercial_partner_id.vat if ns["level"] == "extended" and self.invoice_incoterm_id: delivery_terms = etree.SubElement( trade_agreement, ns["ram"] + "ApplicableTradeDeliveryTerms" @@ -277,6 +285,34 @@ def _cii_get_delivery_date(self): self.ensure_one() return self.invoice_date + def _cii_get_line_period(self, iline): + """Return ``(start_date, end_date)`` for BG-26 ("Invoice line period"). + + Default sources, in order of precedence: + + 1. ``deferred_start_date`` / ``deferred_end_date`` on + ``account.move.line`` (Odoo Enterprise ``account_accountant`` + module). Detected at runtime via ``_fields`` so the OCA module + does not gain a hard dependency on Enterprise code. + 2. ``start_date`` / ``end_date`` on ``account.move.line`` (OCA + module ``account_invoice_start_end_dates``). + + Returns ``(False, False)`` when no service period is available, in + which case the caller must skip emitting ``BillingSpecifiedPeriod``. + + Designed to be inherited by subscription / billing modules that + store the service period in dedicated fields. + """ + self.ensure_one() + fields = iline._fields + if "deferred_start_date" in fields and "deferred_end_date" in fields: + if iline.deferred_start_date or iline.deferred_end_date: + return iline.deferred_start_date, iline.deferred_end_date + if "start_date" in fields and "end_date" in fields: + if iline.start_date or iline.end_date: + return iline.start_date, iline.end_date + return False, False + def _cii_add_trade_delivery_block(self, trade_transaction, ns): self.ensure_one() trade_agreement = etree.SubElement( @@ -291,7 +327,18 @@ def _cii_add_trade_delivery_block(self, trade_transaction, ns): 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: + # PR #1320 added the OccurrenceDateTime emission for EN16931 and + # EXTENDED. The BASIC profile also requires either BT-72 (Actual + # delivery date), BG-14 (Invoicing period) or BG-26 (Invoice + # line period) -- otherwise schematron rule BR-FX-EN-04 fails, + # and the bare ApplicableHeaderTradeDelivery wrapper + # additionally trips PEPPOL-EN16931-R008 ("Document MUST not + # contain empty elements") in BASIC. MINIMUM and BASICWL do not + # assert against the empty wrapper, so we keep them unchanged. + if ( + ns["level"] in ("basic", "en16931", "extended") + and delivery_date + ): delivery_event = etree.SubElement( trade_agreement, ns["ram"] + "ActualDeliverySupplyChainEvent" ) @@ -790,20 +837,30 @@ def _cii_add_invoice_line_block(self, trade_transaction, iline, line_number, ns) else: indicator.text = "true" ac_sign = -1 - calculation_percent = etree.SubElement( - trade_allowance, ns["ram"] + "CalculationPercent" - ) - calculation_percent.text = "%0.*f" % ( - ns["disc_prec"], - iline.discount * ac_sign, - ) - basis_amount = etree.SubElement( - trade_allowance, ns["ram"] + "BasisAmount" - ) - basis_amount.text = "%0.*f" % ( - ns["price_prec"], - iline.price_unit * iline.quantity, - ) + # CalculationPercent (BT-X-32) and BasisAmount (BT-X-33) + # are only allowed inside the line-level + # GrossPriceProductTradePrice/AppliedTradeAllowanceCharge + # block in the EXTENDED profile. The EN16931 schematron + # marks both elements as "not used in the given context"; + # BASIC/BASICWL/MINIMUM never reach this branch because + # the surrounding GrossPriceProductTradePrice itself is + # gated by PROFILES_EN_UP. Only ChargeIndicator and + # ActualAmount are required by EN16931 here. + if ns["level"] == "extended": + calculation_percent = etree.SubElement( + trade_allowance, ns["ram"] + "CalculationPercent" + ) + calculation_percent.text = "%0.*f" % ( + ns["disc_prec"], + iline.discount * ac_sign, + ) + basis_amount = etree.SubElement( + trade_allowance, ns["ram"] + "BasisAmount" + ) + basis_amount.text = "%0.*f" % ( + ns["price_prec"], + iline.price_unit * iline.quantity, + ) actual_amount = etree.SubElement( trade_allowance, ns["ram"] + "ActualAmount" ) @@ -849,20 +906,25 @@ def _cii_add_invoice_line_block(self, trade_transaction, iline, line_number, ns) line_item, ns["ram"] + "SpecifiedLineTradeSettlement" ) self._cii_invoice_line_taxes(iline, line_trade_settlement, ns) - # Fields start_date and end_date are provided by the OCA - # module account_invoice_start_end_dates - if ( - ns["level"] in PROFILES_EN_UP - and hasattr(iline, "start_date") - and hasattr(iline, "end_date") - and iline.start_date - and iline.end_date - ): - bill_period = etree.SubElement( - line_trade_settlement, ns["ram"] + "BillingSpecifiedPeriod" - ) - self._cii_add_date("StartDateTime", iline.start_date, bill_period, ns) - self._cii_add_date("EndDateTime", iline.end_date, bill_period, ns) + # BG-26 "Invoice line period". The actual service period is pulled + # via the _cii_get_line_period() hook, which knows about both + # Odoo Enterprise (deferred_start_date / deferred_end_date) and + # the OCA module account_invoice_start_end_dates (start_date / + # end_date), and which downstream subscription modules can + # override to plug in their own date fields. BG-26 is restricted + # to EN16931 and EXTENDED profiles (PROFILES_EN_UP). + if ns["level"] in PROFILES_EN_UP: + line_start, line_end = self._cii_get_line_period(iline) + if line_start or line_end: + bill_period = etree.SubElement( + line_trade_settlement, ns["ram"] + "BillingSpecifiedPeriod" + ) + if line_start: + self._cii_add_date( + "StartDateTime", line_start, bill_period, ns + ) + if line_end: + self._cii_add_date("EndDateTime", line_end, bill_period, ns) subtotal = etree.SubElement( line_trade_settlement, diff --git a/account_invoice_facturx/readme/DEVELOP.md b/account_invoice_facturx/readme/DEVELOP.md new file mode 100644 index 0000000000..e379e608b3 --- /dev/null +++ b/account_invoice_facturx/readme/DEVELOP.md @@ -0,0 +1,73 @@ +## Extension hooks + +The XML generator exposes two pure methods on `account.move` that are +specifically intended to be inherited by other modules. Both return data +that controls the content of the produced Factur-X / ZUGFeRD XML, never +the XML structure itself, so overrides do not have to know the schema. + +### `_cii_get_delivery_date(self)` + +Returns the date that is exported as the actual delivery / service date +of the invoice (BT-72 in the EN 16931 vocabulary, mapped to XPath +`/CrossIndustryInvoice/SupplyChainTradeTransaction/ApplicableHeaderTradeDelivery/ActualDeliverySupplyChainEvent/OccurrenceDateTime`, +namespace prefixes elided as is conventional in EN 16931 documentation). + +Default implementation returns `self.invoice_date`. Override in modules +that store a dedicated delivery / service date on the invoice header +(for example a Goods Issue date from a Stock module, or a service +completion date from a custom workflow). + +The element is emitted in the `BASIC`, `EN16931` and `EXTENDED` +profiles only (the `MINIMUM` and `BASICWL` profiles do not allow it). + +### `_cii_get_line_period(self, iline)` + +Returns a `(start_date, end_date)` tuple representing the service period +of a single invoice line (BG-26 "Invoice line period", mapped to XPath +`IncludedSupplyChainTradeLineItem/SpecifiedLineTradeSettlement/BillingSpecifiedPeriod` +with `StartDateTime` and `EndDateTime` children, relative to the +`SupplyChainTradeTransaction` root). + +Default implementation looks for two well-known field sets on +`account.move.line`, in order of precedence: + +1. `deferred_start_date` / `deferred_end_date`, provided by Odoo + Enterprise's `account_accountant` module. The check is performed at + runtime via `account.move.line._fields`, so this OCA module does not + gain a hard dependency on Enterprise code: it simply exposes the + service period when those fields are available. +2. `start_date` / `end_date`, provided by the OCA module + `account_invoice_start_end_dates`. + +If neither source yields a value, the method returns `(False, False)` +and the caller skips the `BillingSpecifiedPeriod` element entirely. + +Override in subscription, recurring billing or contract modules that +store the service period in dedicated fields. Example: + +```python +from odoo import models + + +class AccountMove(models.Model): + _inherit = "account.move" + + def _cii_get_line_period(self, iline): + if iline.my_subscription_period_id: + return ( + iline.my_subscription_period_id.date_start, + iline.my_subscription_period_id.date_end, + ) + return super()._cii_get_line_period(iline) +``` + +The element is emitted in the `EN16931` and `EXTENDED` profiles only, +matching what BG-26 allows in the Factur-X schematron. + +## Profile constants + +The module defines `PROFILES_EN_UP = ("en16931", "extended")` to gate +elements that are reserved to EN 16931 and EXTENDED. All five profiles +(`MINIMUM`, `BASICWL`, `BASIC`, `EN16931`, `EXTENDED`) are exercised by +the test suite so contributors can rely on the constant rather than +re-introducing per-profile branches by hand. diff --git a/account_invoice_facturx/readme/HISTORY.md b/account_invoice_facturx/readme/HISTORY.md new file mode 100644 index 0000000000..315339b9ca --- /dev/null +++ b/account_invoice_facturx/readme/HISTORY.md @@ -0,0 +1,42 @@ +## Unreleased + +### Features + +- Add `_cii_get_line_period(iline)` extension hook on `account.move`, + used to populate the line-level `BillingSpecifiedPeriod` block (BG-26, + "Invoice line period") for the `EN16931` and `EXTENDED` profiles. The + default implementation transparently picks up either + `deferred_start_date` / `deferred_end_date` from Odoo Enterprise's + `account_accountant` module (detected at runtime via `_fields`, no + hard dependency added) or `start_date` / `end_date` from the OCA + module `account_invoice_start_end_dates`. Subscription and recurring + billing modules can override the hook to inject their own date + fields without patching the line generator. +- Add a Schematron-based test suite that exercises all five Factur-X + profiles (`MINIMUM`, `BASICWL`, `BASIC`, `EN16931`, `EXTENDED`) + through the bundled `factur-x` library. Two scenarios (default + invoice, invoice with line-level discount) plus the BG-26 + subscription scenario above ensure that the produced XML stays + schematron-clean across all profiles. + +### Fixes + +- The `BASIC` profile now emits a non-empty + `ApplicableHeaderTradeDelivery` block with an + `ActualDeliverySupplyChainEvent/OccurrenceDateTime` child (BT-72), + fixing both `PEPPOL-EN16931-R008` ("document MUST not contain empty + elements") and `BR-FX-EN-04` ("Each invoice must contain a delivery + date or invoicing period"). The delivery date is read through the + existing `_cii_get_delivery_date()` hook so the source field is not + re-decided here. +- The `MINIMUM` profile no longer emits + `BuyerTradeParty/PostalTradeAddress` and + `BuyerTradeParty/SpecifiedTaxRegistration`. Those elements are marked + as "not used" by the `MINIMUM` schematron, while the corresponding + `SellerTradeParty` blocks are kept because `MINIMUM` does require BT-31 + (Seller VAT identifier). +- The line-level `GrossPriceProductTradePrice/AppliedTradeAllowanceCharge` + block no longer emits `CalculationPercent` and `BasisAmount` in the + `EN16931` profile. Those two children are explicitly marked as "not + used" by the EN 16931 schematron and were causing two failures per + discounted line. The `EXTENDED` profile keeps emitting them. diff --git a/account_invoice_facturx/tests/test_facturx_invoice.py b/account_invoice_facturx/tests/test_facturx_invoice.py index e1856f3948..dabc18d346 100644 --- a/account_invoice_facturx/tests/test_facturx_invoice.py +++ b/account_invoice_facturx/tests/test_facturx_invoice.py @@ -2,14 +2,17 @@ # @author: Alexis de Lattre # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from datetime import timedelta from unittest.mock import patch -from facturx import get_facturx_level +from facturx import get_facturx_level, xml_check_schematron from lxml import etree from odoo.exceptions import UserError from odoo.tests.common import TransactionCase +FACTURX_LEVELS = ("minimum", "basicwl", "basic", "en16931", "extended") + RAM_NS = ( "urn:un:unece:uncefact:data:standard:" "ReusableAggregateBusinessInformationEntity:100" @@ -26,6 +29,28 @@ def setUpClass(cls): 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" + # Force DE country and checksum-valid VAT identifiers on + # seller and buyer so the schematron does not flag BR-CO-26 + # ("seller identifier required") and the cascading BR-S-02 + # rules whenever invoice lines use VAT category 'S'. Both + # values pass the python-stdnum mod-11 checksum that + # base_vat.check_vat enforces. DE123456788 is the canonical + # placeholder Odoo itself shows in its validation error + # message; DE129273398 is Siemens AG's real public UStId. + de_country = cls.env.ref("base.de") + cls.company.partner_id.write( + { + "country_id": de_country.id, + "vat": "DE123456788", + } + ) + buyer = cls.env.ref("base.res_partner_2") + buyer.write( + { + "country_id": de_country.id, + "vat": "DE129273398", + } + ) cls.proprietary_bank = cls.env["res.partner.bank"].create( { "partner_id": cls.company.partner_id.id, @@ -166,3 +191,133 @@ def test_credit_transfer_requires_account_identifier(self): with self.assertRaises(UserError): invoice.generate_facturx_xml() xml_check_xsd.assert_not_called() + + # ------------------------------------------------------------------ + # Schematron meta-test (silver bullet) + # ------------------------------------------------------------------ + # Runs the official factur-x schematron over the generated XML for + # every profile. The XSD-only check does not catch business-rule + # violations such as BR-FX-EN-04 (delivery date) or BR-CO-27 + # (IBAN/ProprietaryID). The schematron does. Each fixture below + # stresses a different combination known to trigger schematron + # errors with the current OCA module against factur-x 4.x. + + def _assert_schematron_passes(self, invoice, levels=FACTURX_LEVELS): + """Run xml_check_schematron over all requested profiles. + + Collects per-profile failures so that one test run reports + every broken profile at once instead of stopping at the first. + """ + failures = [] + for level in levels: + self.company.write({"facturx_level": level}) + xml_bytes, fx_level = invoice.generate_facturx_xml() + self.assertEqual(fx_level, level) + try: + xml_check_schematron( + xml_bytes, flavor="factur-x", level=level + ) + except Exception as exc: + failures.append((level, str(exc))) + if failures: + report = "\n\n".join( + f"--- {lvl.upper()} ---\n{msg}" for lvl, msg in failures + ) + self.fail( + f"Schematron failed for {len(failures)} profile(s):\n\n" + f"{report}" + ) + + def test_schematron_default_invoice_de_de(self): + """Default fixture (2 lines, no discount, ProprietaryID account). + + Should pass on every profile after the schematron-related + fixes are in place. Currently expected to fail at least with + BR-FX-EN-04 because the fixture has no delivery_date set and + the upstream PR #1320 fix uses invoice_date as fallback only + when delivery_date is not available — which it is not for this + fixture. + """ + self._assert_schematron_passes(self.invoice) + + def test_schematron_invoice_with_line_discount(self): + """Lines with discount > 0 trigger the GrossPrice/AppliedTradeAllowanceCharge + block. EN16931 and BASIC schematron mark CalculationPercent and + BasisAmount as 'not used' inside that block; only ChargeIndicator + and ActualAmount are allowed there. EXTENDED permits them. + + Currently expected to fail on BASIC and EN16931. + """ + # account.move.partner_bank_id is a stored compute with + # @api.depends('partner_id', 'company_id') and copy=False. + # On out_invoice the compute fills the field from + # partner_id.commercial_partner_id.bank_ids, which is empty + # for our customer, so it overwrites both copy() and any + # default={...} we pass in. We therefore mirror the same + # workaround setUpClass already uses for cls.invoice: + # re-assign the proprietary bank explicitly after copy() and + # again after action_post() (state changes can re-fire the + # compute). Without this, the module raises a UserError + # before the schematron is ever invoked, masking the actual + # discount bug we want to assert against. + invoice = self.invoice.copy() + invoice.partner_bank_id = self.proprietary_bank + for line in invoice.invoice_line_ids: + line.discount = 10.0 + invoice.action_post() + invoice.partner_bank_id = self.proprietary_bank + self._assert_schematron_passes(invoice) + + def test_subscription_line_period_emits_billing_specified_period(self): + """When invoice lines carry deferred_start_date / deferred_end_date + (Enterprise feature representing a subscription / service period) + the line-level XML must contain a ram:BillingSpecifiedPeriod block + with ram:StartDateTime and ram:EndDateTime (BG-26, Invoice line + period). Without this mapping the deferred-revenue dates are + silently dropped from the Factur-X export, even though the + schematron still passes via BT-72 (header delivery date). + + Currently expected to fail because BillingSpecifiedPeriod is not + emitted from deferred_*-fields by the module. + """ + if "deferred_start_date" not in self.env["account.move.line"]._fields: + self.skipTest( + "deferred_start_date / deferred_end_date are not available " + "in this database (requires Enterprise account_accountant)." + ) + invoice = self.invoice.copy() + invoice.partner_bank_id = self.proprietary_bank + # invoice.copy() returns a draft with invoice_date == False; + # take the source invoice's posted date as a deterministic + # reference for the synthetic service period. + invoice.invoice_date = self.invoice.invoice_date + start = invoice.invoice_date.replace(day=1) + end = start + timedelta(days=29) + for line in invoice.invoice_line_ids: + line.write( + { + "deferred_start_date": start, + "deferred_end_date": end, + } + ) + invoice.action_post() + invoice.partner_bank_id = self.proprietary_bank + # Schematron must still pass; BG-26 is structural enrichment, not + # a validation gate (BR-FX-EN-04 is already satisfied via BT-72). + self._assert_schematron_passes(invoice) + # And the actual XML must contain the period block, one per line. + root = self._generate_xml_root(invoice=invoice, level="en16931") + period_nodes = root.findall(".//{%s}BillingSpecifiedPeriod" % RAM_NS) + self.assertEqual( + len(period_nodes), + len(invoice.invoice_line_ids), + "Expected one BillingSpecifiedPeriod per subscription line " + "(%d lines), got %d nodes." + % (len(invoice.invoice_line_ids), len(period_nodes)), + ) + # Every period node must carry both Start and End DateTime. + for period in period_nodes: + start_nodes = period.findall("{%s}StartDateTime" % RAM_NS) + end_nodes = period.findall("{%s}EndDateTime" % RAM_NS) + self.assertEqual(len(start_nodes), 1) + self.assertEqual(len(end_nodes), 1) From 5055d41b7cbb74eb8660ca42dd8ae3d9995b11b4 Mon Sep 17 00:00:00 2001 From: Pawel Kazakow Date: Tue, 12 May 2026 22:23:56 +0200 Subject: [PATCH 4/4] [FIX] account_invoice_facturx: honour delivery_date in BT-72 _cii_get_delivery_date() returned self.invoice_date unconditionally, so a value set in the standard Odoo account.move.delivery_date field (manually, or via upstream modules like subscription engines or account_invoice_delivery_date_from_period) was silently dropped from ActualDeliverySupplyChainEvent/OccurrenceDateTime (BT-72) across all five Factur-X profiles. The hook now reads self.delivery_date and falls back to invoice_date only when it is unset. Add an integration test that posts a draft with two subscription lines and asserts that the auto-derived delivery_date lands in BT-72. Bumps version to 17.0.1.3.0. --- account_invoice_facturx/__manifest__.py | 2 +- .../models/account_move.py | 15 +- account_invoice_facturx/readme/HISTORY.md | 12 +- .../tests/test_facturx_invoice.py | 164 ++++++++++++++++++ 4 files changed, 188 insertions(+), 5 deletions(-) diff --git a/account_invoice_facturx/__manifest__.py b/account_invoice_facturx/__manifest__.py index ec98266a45..8c289ece90 100644 --- a/account_invoice_facturx/__manifest__.py +++ b/account_invoice_facturx/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Account Invoice Factur-X", - "version": "17.0.1.2.1", + "version": "17.0.1.3.0", "category": "Invoicing Management", "license": "AGPL-3", "summary": "Generate Factur-X/ZUGFeRD customer invoices", diff --git a/account_invoice_facturx/models/account_move.py b/account_invoice_facturx/models/account_move.py index c6676d95a1..f71cbcf5d6 100644 --- a/account_invoice_facturx/models/account_move.py +++ b/account_invoice_facturx/models/account_move.py @@ -279,11 +279,20 @@ def _get_contract_code(self): 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. + Reads the standard Odoo ``account.move.delivery_date`` field and + falls back to ``invoice_date`` when it is unset. The Odoo standard + UI exposes ``delivery_date`` explicitly as the date of supply, so + when a user (or an upstream module — e.g. a subscription engine, + or ``account_invoice_delivery_date_from_period`` deriving it from + line periods) populates it, the value MUST land in BT-72 + (``ActualDeliverySupplyChainEvent/OccurrenceDateTime``) rather + than being silently dropped in favour of ``invoice_date``. + + Designed to be further inherited by modules that compute a + dedicated delivery / service date on invoices. """ self.ensure_one() - return self.invoice_date + return self.delivery_date or self.invoice_date def _cii_get_line_period(self, iline): """Return ``(start_date, end_date)`` for BG-26 ("Invoice line period"). diff --git a/account_invoice_facturx/readme/HISTORY.md b/account_invoice_facturx/readme/HISTORY.md index 315339b9ca..79d84a767f 100644 --- a/account_invoice_facturx/readme/HISTORY.md +++ b/account_invoice_facturx/readme/HISTORY.md @@ -1,4 +1,4 @@ -## Unreleased +## 17.0.1.3.0 (2026-05-11) ### Features @@ -21,6 +21,16 @@ ### Fixes +- `_cii_get_delivery_date()` now reads the standard Odoo + `account.move.delivery_date` field and falls back to `invoice_date` + only when it is unset. Previously the hook returned `invoice_date` + unconditionally, so an explicit delivery date set in the standard + Odoo invoice form (or computed by an upstream module such as + `account_invoice_delivery_date_from_period`, which derives it from + the latest period end of the invoice lines for German UStG / DATEV + conformance) was silently dropped from BT-72 + (`ActualDeliverySupplyChainEvent/OccurrenceDateTime`). All five + profiles benefit from the fix. - The `BASIC` profile now emits a non-empty `ApplicableHeaderTradeDelivery` block with an `ActualDeliverySupplyChainEvent/OccurrenceDateTime` child (BT-72), diff --git a/account_invoice_facturx/tests/test_facturx_invoice.py b/account_invoice_facturx/tests/test_facturx_invoice.py index dabc18d346..b6bfc92b9b 100644 --- a/account_invoice_facturx/tests/test_facturx_invoice.py +++ b/account_invoice_facturx/tests/test_facturx_invoice.py @@ -321,3 +321,167 @@ def test_subscription_line_period_emits_billing_specified_period(self): end_nodes = period.findall("{%s}EndDateTime" % RAM_NS) self.assertEqual(len(start_nodes), 1) self.assertEqual(len(end_nodes), 1) + + def test_delivery_date_auto_set_from_period_carried_to_bt72(self): + """End-to-end verkettung: Track 1 + (`account_invoice_delivery_date_from_period`) auto-fills + ``delivery_date`` from the latest line period end, and Track 2 + (the `_cii_get_delivery_date` fix in this module) carries that + value into BT-72. + + This is the only test in the suite that exercises *both* + modules together; the isolated tests in each module cover only + their own half. Skipped if the period-derivation module is not + installed in the database. + """ + if "deferred_start_date" not in self.env["account.move.line"]._fields: + self.skipTest( + "deferred_start_date / deferred_end_date are not available " + "in this database (requires Enterprise account_accountant)." + ) + helper_installed = self.env["ir.module.module"].search( + [ + ("name", "=", "account_invoice_delivery_date_from_period"), + ("state", "=", "installed"), + ], + limit=1, + ) + if not helper_installed: + self.skipTest( + "account_invoice_delivery_date_from_period is not installed " + "in this database; the verkettung Track 1 → Track 2 cannot " + "be exercised." + ) + invoice = self.invoice.copy() + invoice.partner_bank_id = self.proprietary_bank + invoice.invoice_date = self.invoice.invoice_date + # Two subscription lines with distinct period ends — the latest + # one (period_end_late) must win per UStG § 13 (see ADR + # 2026-05-11_adr_delivery-date-aus-periode-ende.md). + period_end_early = invoice.invoice_date + timedelta(days=10) + period_end_late = invoice.invoice_date + timedelta(days=42) + lines = list(invoice.invoice_line_ids) + self.assertGreaterEqual( + len(lines), + 2, + "Test fixture invariant: source invoice must have at least " + "two lines so the max() over period ends is non-trivial.", + ) + lines[0].write( + { + "deferred_start_date": invoice.invoice_date, + "deferred_end_date": period_end_early, + } + ) + lines[1].write( + { + "deferred_start_date": invoice.invoice_date, + "deferred_end_date": period_end_late, + } + ) + # Critical pre-condition: do NOT set delivery_date manually. + # The whole point is that Track 1's _post() override fills it + # from the line periods automatically. + self.assertFalse( + invoice.delivery_date, + "Test pre-condition: delivery_date must be empty before " + "posting so we can prove Track 1 fills it from periods.", + ) + invoice.action_post() + invoice.partner_bank_id = self.proprietary_bank + # Track 1 assertion: _post() override picked the latest end date. + self.assertEqual( + invoice.delivery_date, + period_end_late, + "Track 1: _post() override must auto-set delivery_date to " + "max(deferred_end_date) = %s; got %r." + % (period_end_late, invoice.delivery_date), + ) + # Track 2 assertion: that auto-set value reaches BT-72 in the XML. + self._assert_schematron_passes(invoice) + root = self._generate_xml_root(invoice=invoice, level="en16931") + occurrence = root.find( + ".//{%s}ActualDeliverySupplyChainEvent/{%s}OccurrenceDateTime" + % (RAM_NS, RAM_NS) + ) + self.assertIsNotNone( + occurrence, + "BT-72 OccurrenceDateTime element is missing from the XML.", + ) + bt72_text = list(occurrence)[0].text + self.assertEqual( + bt72_text, + period_end_late.strftime("%Y%m%d"), + "Track 2 (verkettung): BT-72 must carry the auto-set " + "delivery_date (%s, format YYYYMMDD = %s); got %r. " + "Either Track 1 did not write delivery_date (check that " + "account_invoice_delivery_date_from_period._post() ran), " + "or Track 2's _cii_get_delivery_date() is dropping " + "delivery_date again." + % ( + period_end_late, + period_end_late.strftime("%Y%m%d"), + bt72_text, + ), + ) + + def test_delivery_date_carried_to_bt72_when_set_explicitly(self): + """When ``account.move.delivery_date`` is set to a value other + than ``invoice_date`` (e.g. by a subscription module that + derives it from line periods, or by the user), BT-72 + (``ActualDeliverySupplyChainEvent/OccurrenceDateTime``) in the + Factur-X XML must carry the ``delivery_date``, NOT the + ``invoice_date``. + + Currently expected to fail because ``_cii_get_delivery_date()`` + returns ``self.invoice_date`` unconditionally and ignores the + standard Odoo ``delivery_date`` field. + """ + invoice = self.invoice.copy() + invoice.partner_bank_id = self.proprietary_bank + # invoice.copy() returns a draft with invoice_date == False; + # take the source invoice's posted date as a deterministic + # baseline. + invoice.invoice_date = self.invoice.invoice_date + explicit_delivery = invoice.invoice_date + timedelta(days=15) + invoice.delivery_date = explicit_delivery + invoice.action_post() + invoice.partner_bank_id = self.proprietary_bank + # Schematron must still pass; this test is about the SEMANTIC + # content of BT-72, not its presence. + self._assert_schematron_passes(invoice) + # BT-72 lives at + # ApplicableHeaderTradeDelivery/ActualDeliverySupplyChainEvent/ + # OccurrenceDateTime/udt:DateTimeString. The DateTimeString + # element is in the UDT namespace; using "*" wildcards lets us + # ignore that detail and check the actual text content. + root = self._generate_xml_root(invoice=invoice, level="en16931") + occurrence = root.find( + ".//{%s}ActualDeliverySupplyChainEvent/{%s}OccurrenceDateTime" + % (RAM_NS, RAM_NS) + ) + self.assertIsNotNone( + occurrence, + "BT-72 OccurrenceDateTime element is missing from the XML.", + ) + date_string_elements = list(occurrence) + self.assertEqual( + len(date_string_elements), + 1, + "Expected exactly one DateTimeString child under " + "OccurrenceDateTime; got %d." % len(date_string_elements), + ) + bt72_text = date_string_elements[0].text + self.assertEqual( + bt72_text, + explicit_delivery.strftime("%Y%m%d"), + "BT-72 must carry delivery_date (%s, format YYYYMMDD = %s); " + "got %r. The hook _cii_get_delivery_date() is likely still " + "returning self.invoice_date and ignoring the standard " + "Odoo delivery_date field." + % ( + explicit_delivery, + explicit_delivery.strftime("%Y%m%d"), + bt72_text, + ), + )