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.4.0",
"category": "Invoicing Management",
"license": "AGPL-3",
"summary": "Generate Factur-X/ZUGFeRD customer invoices",
Expand Down
289 changes: 209 additions & 80 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,63 +335,125 @@ 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_get_payee_partner_bank(self):
"""Resolve the recipient (payee) bank account for BT-84.

Designed to be inherited. Returns the ``res.partner.bank`` the
customer should pay to: the invoice ``partner_bank_id`` field
("Recipient Bank"), falling back to the bank account of the
payment mode's fixed journal when the OCA module
``account_payment_mode`` pins one. Returns an empty recordset
when no account is available.
"""
self.ensure_one()
partner_bank = self.partner_bank_id
if (
not partner_bank
and self.payment_mode_id
and self.payment_mode_id.bank_account_link == "fixed"
and self.payment_mode_id.fixed_journal_id
):
partner_bank = self.payment_mode_id.fixed_journal_id.bank_account_id
return partner_bank

def _cii_add_trade_settlement_payment_means_block(self, trade_settlement, ns):
payment_means = etree.SubElement(
trade_settlement, ns["ram"] + "SpecifiedTradeSettlementPaymentMeans"
)
payment_means_code = etree.SubElement(payment_means, ns["ram"] + "TypeCode")
if ns["level"] in PROFILES_EN_UP:
payment_means_info = etree.SubElement(
payment_means, ns["ram"] + "Information"
)
# Resolve the payment means type code (BT-81) and the optional
# human readable information (BT-82) *before* creating any XML
# node, so we can decide whether the whole (optional, 0..1)
# SpecifiedTradeSettlementPaymentMeans block — the EN16931 BG-16
# "PAYMENT INSTRUCTIONS" group — should be emitted at all.
if self.payment_mode_id:
payment_means_code.text = self.payment_mode_id.payment_method_id.unece_code
if ns["level"] in PROFILES_EN_UP:
payment_means_info.text = (
self.payment_mode_id.note or self.payment_mode_id.name
)
type_code = self.payment_mode_id.payment_method_id.unece_code
info_text = self.payment_mode_id.note or self.payment_mode_id.name
else:
payment_means_code.text = "30" # use 30 and not 31,
type_code = "30" # use 30 and not 31,
# for wire transfer, according to Factur-X CIUS
if ns["level"] in PROFILES_EN_UP:
payment_means_info.text = _("Wire transfer")
info_text = _("Wire transfer")
logger.warning(
"Missing payment mode on invoice ID %d. "
"Using 30 (wire transfer) as UNECE code as fallback "
"for payment mean",
self.id,
)
if payment_means_code.text in CREDIT_TRF_CODES:
partner_bank = self.partner_bank_id
if (
not partner_bank
and self.payment_mode_id
and self.payment_mode_id.bank_account_link == "fixed"
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"

partner_bank = self.env["res.partner.bank"]
if type_code in CREDIT_TRF_CODES:
partner_bank = self._cii_get_payee_partner_bank()
if not partner_bank or not partner_bank.sanitized_acc_number:
# BT-84 (Payment account identifier) is optional in
# EN16931 (cardinality 0..1). It only becomes mandatory
# *once* a credit transfer payment means is declared:
# the schematron rules BR-50, BR-61 and BR-CO-27 are all
# anchored on a SpecifiedTradeSettlementPaymentMeans with
# TypeCode 30/58 (and, for BR-50/BR-61, its
# PayeePartyCreditorFinancialAccount child). When the
# invoice carries no recipient bank account we therefore
# skip the entire optional BG-16 block instead of raising
# — this keeps the document schematron-valid without
# forcing a payee IBAN/account we simply do not have.
logger.warning(
"No recipient bank account on invoice ID %d "
"(partner_bank_id / 'Recipient Bank' is empty); "
"skipping the Factur-X payment means block. BT-84 is "
"optional, so the invoice stays valid.",
self.id,
)
iban = etree.SubElement(
return

payment_means = etree.SubElement(
trade_settlement, ns["ram"] + "SpecifiedTradeSettlementPaymentMeans"
)
payment_means_code = etree.SubElement(payment_means, ns["ram"] + "TypeCode")
payment_means_code.text = type_code
if ns["level"] in PROFILES_EN_UP:
payment_means_info = etree.SubElement(
payment_means, ns["ram"] + "Information"
)
payment_means_info.text = info_text
if type_code in CREDIT_TRF_CODES:
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
type_code in DIRECT_DEBIT_CODES
and hasattr(self, "mandate_id")
and self.mandate_id.partner_bank_id
and self.mandate_id.partner_bank_id.acc_type == "iban"
Expand Down Expand Up @@ -762,20 +876,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 +945,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
Loading
Loading