Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion account_invoice_facturx/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
207 changes: 153 additions & 54 deletions account_invoice_facturx/models/account_move.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -223,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"
Expand Down Expand Up @@ -270,6 +276,52 @@ 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.

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.delivery_date or 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(
Expand All @@ -283,6 +335,23 @@ 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()
# 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"
)
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):
Expand Down Expand Up @@ -320,23 +389,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
Expand Down Expand Up @@ -762,20 +846,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"
)
Expand Down Expand Up @@ -821,20 +915,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,
Expand Down
73 changes: 73 additions & 0 deletions account_invoice_facturx/readme/DEVELOP.md
Original file line number Diff line number Diff line change
@@ -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.
52 changes: 52 additions & 0 deletions account_invoice_facturx/readme/HISTORY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
## 17.0.1.3.0 (2026-05-11)

### 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

- `_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),
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.
Loading
Loading