oca-edi/odoo-bringout-oca-edi-account_invoice_facturx/account_invoice_facturx/models/account_move.py
2025-08-29 15:43:05 +02:00

1009 lines
44 KiB
Python

# Copyright 2016-2022 Akretion France (http://www.akretion.com)
# @author: Alexis de Lattre <alexis.delattre@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
import logging
from lxml import etree
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.tools import (
float_compare,
float_is_zero,
float_round,
html2plaintext,
is_html_empty,
)
from odoo.tools.misc import format_date
logger = logging.getLogger(__name__)
try:
from facturx import generate_from_file, xml_check_xsd
except ImportError:
logger.debug("Cannot import facturx")
FACTURX_FILENAME = "factur-x.xml"
DIRECT_DEBIT_CODES = ("49", "59")
CREDIT_TRF_CODES = ("30", "31", "42")
PROFILES_EN_UP = ["en16931", "extended"]
class AccountMove(models.Model):
_name = "account.move"
_inherit = ["account.move", "base.facturx"]
@api.model
def _cii_add_address_block(self, partner, parent_node, ns):
address = etree.SubElement(parent_node, ns["ram"] + "PostalTradeAddress")
if ns["level"] != "minimum":
if partner.zip:
address_zip = etree.SubElement(address, ns["ram"] + "PostcodeCode")
address_zip.text = partner.zip
if partner.street:
address_street = etree.SubElement(address, ns["ram"] + "LineOne")
address_street.text = partner.street
if partner.street2:
address_street2 = etree.SubElement(address, ns["ram"] + "LineTwo")
address_street2.text = partner.street2
if hasattr(partner, "street3") and partner.street3:
address_street3 = etree.SubElement(address, ns["ram"] + "LineThree")
address_street3.text = partner.street3
if partner.city:
address_city = etree.SubElement(address, ns["ram"] + "CityName")
address_city.text = partner.city
if not partner.country_id:
raise UserError(
_(
"Country is not set on partner '%s'. In the Factur-X "
"standard, the country is required for buyer and seller."
)
% partner.display_name
)
address_country = etree.SubElement(address, ns["ram"] + "CountryID")
address_country.text = partner.country_id.code
if ns["level"] != "minimum" and partner.state_id:
address_state = etree.SubElement(
address, ns["ram"] + "CountrySubDivisionName"
)
address_state.text = partner.state_id.name
def _cii_trade_contact_department_name(self, partner):
return False
@api.model
def _cii_add_trade_contact_block(self, partner, parent_node, ns):
trade_contact = etree.SubElement(parent_node, ns["ram"] + "DefinedTradeContact")
contact_name = etree.SubElement(trade_contact, ns["ram"] + "PersonName")
contact_name.text = partner.name
department = self._cii_trade_contact_department_name(partner)
if department:
department_name = etree.SubElement(
trade_contact, ns["ram"] + "DepartmentName"
)
department_name.text = department
phone = partner.phone or partner.mobile
if phone:
phone_node = etree.SubElement(
trade_contact, ns["ram"] + "TelephoneUniversalCommunication"
)
phone_number = etree.SubElement(phone_node, ns["ram"] + "CompleteNumber")
phone_number.text = phone
if partner.email:
email_node = etree.SubElement(
trade_contact, ns["ram"] + "EmailURIUniversalCommunication"
)
email_uriid = etree.SubElement(
email_node, ns["ram"] + "URIID", schemeID="SMTP"
)
email_uriid.text = partner.email
@api.model
def _cii_add_date(
self, node_name, date_datetime, parent_node, ns, date_ns_type="udt"
):
date_node = etree.SubElement(parent_node, ns["ram"] + node_name)
date_node_str = etree.SubElement(
date_node, ns[date_ns_type] + "DateTimeString", format="102"
)
# 102 = format YYYYMMDD
date_node_str.text = date_datetime.strftime("%Y%m%d")
def _cii_add_document_context_block(self, root, ns):
self.ensure_one()
doc_ctx = etree.SubElement(root, ns["rsm"] + "ExchangedDocumentContext")
ctx_param = etree.SubElement(
doc_ctx, ns["ram"] + "GuidelineSpecifiedDocumentContextParameter"
)
ctx_param_id = etree.SubElement(ctx_param, ns["ram"] + "ID")
if ns["level"] == "en16931":
urn = "urn:cen.eu:en16931:2017"
elif ns["level"] == "basic":
urn = "urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:basic"
elif ns["level"] == "extended":
urn = "urn:cen.eu:en16931:2017#conformant#" "urn:factur-x.eu:1p0:extended"
else:
urn = "urn:factur-x.eu:1p0:%s" % ns["level"]
ctx_param_id.text = urn
def _cii_add_header_block(self, root, ns):
self.ensure_one()
header_doc = etree.SubElement(root, ns["rsm"] + "ExchangedDocument")
header_doc_id = etree.SubElement(header_doc, ns["ram"] + "ID")
if self.state == "posted":
header_doc_id.text = self.name
else:
header_doc_id.text = self._fields["state"].convert_to_export(
self.state, self
)
header_doc_typecode = etree.SubElement(header_doc, ns["ram"] + "TypeCode")
if self.move_type == "out_invoice":
header_doc_typecode.text = "380"
elif self.move_type == "out_refund":
header_doc_typecode.text = ns["refund_type"]
# 2 options allowed in Factur-X :
# a) invoice and refunds -> 380 ; negative amounts if refunds
# b) invoice -> 380 refunds -> 381, with positive amounts
# In ZUGFeRD samples, they use option a)
# For Chorus, they impose option b)
# Until August 2017, I was using option a), now I use option b)
# Starting from November 2017, it's a config option !
invoice_date_dt = self.invoice_date or fields.Date.context_today(self)
self._cii_add_date("IssueDateTime", invoice_date_dt, header_doc, ns)
if not is_html_empty(self.narration) and ns["level"] != "minimum":
note = etree.SubElement(header_doc, ns["ram"] + "IncludedNote")
content_note = etree.SubElement(note, ns["ram"] + "Content")
content_note.text = html2plaintext(self.narration)
@api.model
def _cii_get_party_identification(self, commercial_partner):
"""This method is designed to be inherited in localisation modules
Should return a dict with key=SchemeName, value=Identifier"""
return {}
@api.model
def _cii_add_party_identification(self, commercial_partner, parent_node, ns):
id_dict = self._cii_get_party_identification(commercial_partner)
if id_dict:
party_identification = etree.SubElement(
parent_node, ns["ram"] + "SpecifiedLegalOrganization"
)
for scheme_name, party_id_text in id_dict.items():
party_identification_id = etree.SubElement(
party_identification, ns["ram"] + "ID", schemeID=scheme_name
)
party_identification_id.text = party_id_text
return
def _cii_trade_agreement_buyer_ref(self, partner):
return False
def _cii_add_trade_agreement_block(self, trade_transaction, ns):
self.ensure_one()
company = self.company_id
trade_agreement = etree.SubElement(
trade_transaction, ns["ram"] + "ApplicableHeaderTradeAgreement"
)
buyer_ref = self._cii_trade_agreement_buyer_ref(self.partner_id)
if buyer_ref:
buyer_reference = etree.SubElement(
trade_agreement, ns["ram"] + "BuyerReference"
)
buyer_reference.text = buyer_ref
seller = etree.SubElement(trade_agreement, ns["ram"] + "SellerTradeParty")
seller_name = etree.SubElement(seller, ns["ram"] + "Name")
seller_name.text = company.name
self._cii_add_party_identification(company.partner_id, seller, ns)
if ns["level"] in PROFILES_EN_UP:
self._cii_add_trade_contact_block(
self.invoice_user_id.partner_id or company.partner_id, seller, ns
)
self._cii_add_address_block(company.partner_id, seller, ns)
if company.vat:
seller_tax_reg = etree.SubElement(
seller, ns["ram"] + "SpecifiedTaxRegistration"
)
seller_tax_reg_id = etree.SubElement(
seller_tax_reg, ns["ram"] + "ID", schemeID="VA"
)
seller_tax_reg_id.text = company.vat
buyer = etree.SubElement(trade_agreement, ns["ram"] + "BuyerTradeParty")
if ns["level"] != "minimum" and self.commercial_partner_id.ref:
buyer_id = etree.SubElement(buyer, ns["ram"] + "ID")
buyer_id.text = self.commercial_partner_id.ref
buyer_name = etree.SubElement(buyer, ns["ram"] + "Name")
buyer_name.text = self.commercial_partner_id.name
self._cii_add_party_identification(self.commercial_partner_id, buyer, ns)
if (
ns["level"] in PROFILES_EN_UP
and self.commercial_partner_id != self.partner_id
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
if ns["level"] == "extended" and self.invoice_incoterm_id:
delivery_terms = etree.SubElement(
trade_agreement, ns["ram"] + "ApplicableTradeDeliveryTerms"
)
delivery_code = etree.SubElement(
delivery_terms, ns["ram"] + "DeliveryTypeCode"
)
delivery_code.text = self.invoice_incoterm_id.code
self._cii_add_buyer_order_reference(trade_agreement, ns)
self._cii_add_contract_reference(trade_agreement, ns)
def _cii_add_buyer_order_reference(self, trade_agreement, ns):
self.ensure_one()
if self.ref:
buyer_order_ref = etree.SubElement(
trade_agreement, ns["ram"] + "BuyerOrderReferencedDocument"
)
buyer_order_id = etree.SubElement(
buyer_order_ref, ns["ram"] + "IssuerAssignedID"
)
buyer_order_id.text = self.ref
def _cii_add_contract_reference(self, trade_agreement, ns):
self.ensure_one()
contract_code = self._get_contract_code()
if ns["level"] != "minimum" and contract_code:
contract_ref = etree.SubElement(
trade_agreement, ns["ram"] + "ContractReferencedDocument"
)
contract_id = etree.SubElement(contract_ref, ns["ram"] + "IssuerAssignedID")
contract_id.text = contract_code
def _get_contract_code(self):
"""This method is designed to be inherited
There are so many different ways to handle a contract in Odoo!
So it's difficult to have a common datamodel for it"""
return False
def _cii_add_trade_delivery_block(self, trade_transaction, ns):
self.ensure_one()
trade_agreement = etree.SubElement(
trade_transaction, ns["ram"] + "ApplicableHeaderTradeDelivery"
)
# partner_shipping_id is provided by the sale module
if (
ns["level"] in PROFILES_EN_UP
and hasattr(self, "partner_shipping_id")
and self.partner_shipping_id
):
shipto_trade_party = etree.SubElement(
trade_agreement, ns["ram"] + "ShipToTradeParty"
)
self._cii_add_address_block(
self.partner_shipping_id, shipto_trade_party, ns
)
return trade_agreement
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"
)
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
)
else:
payment_means_code.text = "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")
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"
)
iban = 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
# Field mandate_id provided by the OCA module account_banking_mandate
elif (
payment_means_code.text 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"
and self.mandate_id.partner_bank_id.sanitized_acc_number
):
debtor_acc = etree.SubElement(
payment_means, ns["ram"] + "PayerPartyDebtorFinancialAccount"
)
debtor_acc_iban = etree.SubElement(debtor_acc, ns["ram"] + "IBANID")
debtor_acc_iban.text = self.mandate_id.partner_bank_id.sanitized_acc_number
def _cii_trade_payment_terms_block(self, trade_settlement, ns):
trade_payment_term = etree.SubElement(
trade_settlement, ns["ram"] + "SpecifiedTradePaymentTerms"
)
if ns["level"] in PROFILES_EN_UP:
trade_payment_term_desc = etree.SubElement(
trade_payment_term, ns["ram"] + "Description"
)
# The 'Description' field of SpecifiedTradePaymentTerms
# is a required field, so we must always give a value
if self.invoice_payment_term_id:
trade_payment_term_desc.text = self.invoice_payment_term_id.name
else:
trade_payment_term_desc.text = _("No specific payment term selected")
if self.invoice_date_due:
self._cii_add_date(
"DueDateDateTime", self.invoice_date_due, trade_payment_term, ns
)
# Direct debit Mandate
if (
self.payment_mode_id.payment_method_id.unece_code in DIRECT_DEBIT_CODES
and hasattr(self, "mandate_id")
and self.mandate_id.unique_mandate_reference
):
mandate = etree.SubElement(
trade_payment_term, ns["ram"] + "DirectDebitMandateID"
)
mandate.text = self.mandate_id.unique_mandate_reference
def _cii_check_tax_required_info(self, tax_dict):
if not tax_dict:
# Hack when there is NO tax at all
# ApplicableTradeTax is a required field, both on line and total
tax_dict.update(
{
"unece_type_code": "VAT",
"unece_categ_code": "E",
"amount": 0,
"amount_type": "percent",
"display_name": "Empty virtual tax",
}
)
if not tax_dict["unece_type_code"]:
raise UserError(
_("Missing UNECE Tax Type on tax '%s'") % tax_dict["display_name"]
)
if not tax_dict["unece_categ_code"]:
raise UserError(
_("Missing UNECE Tax Category on tax '%s'") % tax_dict["display_name"]
)
def _cii_line_applicable_trade_tax_block(
self, tax_recordset, parent_node, ns, allowance=False
):
tax = {}
if tax_recordset:
tax = ns["tax_speeddict"][tax_recordset.id]
self._cii_check_tax_required_info(tax)
if allowance:
node_name = "CategoryTradeTax"
else:
node_name = "ApplicableTradeTax"
trade_tax = etree.SubElement(parent_node, ns["ram"] + node_name)
trade_tax_typecode = etree.SubElement(trade_tax, ns["ram"] + "TypeCode")
trade_tax_typecode.text = tax["unece_type_code"]
trade_tax_categcode = etree.SubElement(trade_tax, ns["ram"] + "CategoryCode")
trade_tax_categcode.text = tax["unece_categ_code"]
# No 'DueDateTypeCode' on lines
if tax.get("amount_type") == "percent":
trade_tax_percent = etree.SubElement(
trade_tax, ns["ram"] + "RateApplicablePercent"
)
trade_tax_percent.text = "%0.*f" % (2, tax["amount"])
def _cii_total_applicable_trade_tax_block(
self, tax_recordset, tax_amount, base_amount, parent_node, ns
):
if ns["level"] == "minimum":
return
tax = {}
if tax_recordset:
tax = ns["tax_speeddict"][tax_recordset.id]
self._cii_check_tax_required_info(tax)
trade_tax = etree.SubElement(parent_node, ns["ram"] + "ApplicableTradeTax")
amount = etree.SubElement(trade_tax, ns["ram"] + "CalculatedAmount")
amount.text = "%0.*f" % (ns["cur_prec"], tax_amount * ns["sign"])
tax_type = etree.SubElement(trade_tax, ns["ram"] + "TypeCode")
tax_type.text = tax["unece_type_code"]
if (
tax["unece_categ_code"] != "S"
and float_is_zero(tax_amount, precision_digits=ns["cur_prec"])
and self.fiscal_position_id
and ns["fp_speeddict"][self.fiscal_position_id.id]["note"]
):
exemption_reason = etree.SubElement(
trade_tax, ns["ram"] + "ExemptionReason"
)
exemption_reason.text = ns["fp_speeddict"][self.fiscal_position_id.id][
"note"
]
base = etree.SubElement(trade_tax, ns["ram"] + "BasisAmount")
base.text = "%0.*f" % (ns["cur_prec"], base_amount * ns["sign"])
tax_categ_code = etree.SubElement(trade_tax, ns["ram"] + "CategoryCode")
tax_categ_code.text = tax["unece_categ_code"]
due_date_type_code = self._get_unece_due_date_type_code() or tax.get(
"unece_due_date_code"
)
if due_date_type_code:
trade_tax_due_date = etree.SubElement(
trade_tax, ns["ram"] + "DueDateTypeCode"
)
trade_tax_due_date.text = due_date_type_code
# Field tax_exigibility is not required, so no error if missing
if tax.get("amount_type") == "percent":
percent = etree.SubElement(trade_tax, ns["ram"] + "RateApplicablePercent")
percent.text = "%0.*f" % (2, tax["amount"])
def _cii_add_trade_settlement_block(self, trade_transaction, allowance_ilines, ns):
self.ensure_one()
trade_settlement = etree.SubElement(
trade_transaction, ns["ram"] + "ApplicableHeaderTradeSettlement"
)
# ICS, provided by the OCA module account_banking_sepa_direct_debit
if (
ns["level"] != "minimum"
and self.payment_mode_id.payment_method_id.unece_code in DIRECT_DEBIT_CODES
and hasattr(self.company_id, "sepa_creditor_identifier")
and self.company_id.sepa_creditor_identifier
):
ics = etree.SubElement(trade_settlement, ns["ram"] + "CreditorReferenceID")
ics.text = self.company_id.sepa_creditor_identifier
if ns["level"] != "minimum":
payment_ref = etree.SubElement(
trade_settlement, ns["ram"] + "PaymentReference"
)
payment_ref.text = self.name or self.state
invoice_currency = etree.SubElement(
trade_settlement, ns["ram"] + "InvoiceCurrencyCode"
)
invoice_currency.text = ns["currency"]
if (
self.payment_mode_id
and not self.payment_mode_id.payment_method_id.unece_code
):
raise UserError(
_("Missing UNECE code on payment method '%s'")
% self.payment_mode_id.payment_method_id.display_name
)
if ns["level"] != "minimum" and not (
self.move_type == "out_refund"
and self.payment_mode_id
and self.payment_mode_id.payment_method_id.unece_code in CREDIT_TRF_CODES
):
self._cii_add_trade_settlement_payment_means_block(trade_settlement, ns)
at_least_one_tax = False
# move_type == 'out_invoice': tline.amount_currency < 0
# move_type == 'out_refund': tline.amount_currency > 0
tax_amount_sign = self.move_type == "out_invoice" and -1 or 1
for tline in self.line_ids.filtered(lambda x: x.tax_line_id):
tax_base_amount = tline.tax_base_amount
tax_amount = tline.amount_currency * tax_amount_sign
self._cii_total_applicable_trade_tax_block(
tline.tax_line_id,
tax_amount,
tax_base_amount,
trade_settlement,
ns,
)
at_least_one_tax = True
tax_zero_amount = {} # key = tax recordset, value = base
for line in self.line_ids:
for tax in line.tax_ids.filtered(
lambda t: float_is_zero(t.amount, precision_digits=ns["cur_prec"])
):
tax_zero_amount.setdefault(tax, 0.0)
tax_zero_amount[tax] += line.price_subtotal
for tax, tax_base_amount in tax_zero_amount.items():
self._cii_total_applicable_trade_tax_block(
tax, 0, tax_base_amount, trade_settlement, ns
)
at_least_one_tax = True
if not at_least_one_tax:
self._cii_total_applicable_trade_tax_block(None, 0, 0, trade_settlement, ns)
# Global Allowance lines = invoice lines with negative price
for allowance_iline in allowance_ilines:
self._cii_allowance_line(allowance_iline, trade_settlement, ns)
if ns["level"] != "minimum":
self._cii_trade_payment_terms_block(trade_settlement, ns)
self._cii_monetary_summation_block(trade_settlement, ns)
# When you create a full refund from an invoice, Odoo will
# set the field reversed_entry_id
if self.reversed_entry_id and self.reversed_entry_id.state == "posted":
inv_ref_doc = etree.SubElement(
trade_settlement, ns["ram"] + "InvoiceReferencedDocument"
)
inv_ref_doc_num = etree.SubElement(
inv_ref_doc, ns["ram"] + "IssuerAssignedID"
)
inv_ref_doc_num.text = self.reversed_entry_id.name
self._cii_add_date(
"FormattedIssueDateTime",
self.reversed_entry_id.invoice_date,
inv_ref_doc,
ns,
date_ns_type="qdt",
)
def _cii_allowance_line(self, iline, trade_settlement, ns):
allowance_line = etree.SubElement(
trade_settlement, ns["ram"] + "SpecifiedTradeAllowanceCharge"
)
charge_indic = etree.SubElement(allowance_line, ns["ram"] + "ChargeIndicator")
indicator = etree.SubElement(charge_indic, ns["udt"] + "Indicator")
indicator.text = "false"
if not float_is_zero(iline.discount, ns["disc_prec"]):
calculation_percent = etree.SubElement(
allowance_line, ns["ram"] + "CalculationPercent"
)
calculation_percent.text = "%0.*f" % (ns["disc_prec"], iline.discount)
basis_amount = etree.SubElement(allowance_line, ns["ram"] + "BasisAmount")
basis_amount.text = "%0.*f" % (
ns["price_prec"],
iline.price_unit * iline.quantity * -1,
)
actual_amount = iline.price_subtotal * -1
ns["allowance_total_amount"] += actual_amount
actual_amount_node = etree.SubElement(
allowance_line, ns["ram"] + "ActualAmount"
)
actual_amount_node.text = "%0.*f" % (ns["cur_prec"], actual_amount)
reason = etree.SubElement(allowance_line, ns["ram"] + "Reason")
reason.text = (
iline.name
or (iline.product_id and iline.product_id.display_name)
or _("Discount")
)
self._cii_invoice_line_taxes(iline, allowance_line, ns, allowance=True)
def _cii_monetary_summation_block(self, trade_settlement, ns):
sums = etree.SubElement(
trade_settlement,
ns["ram"] + "SpecifiedTradeSettlementHeaderMonetarySummation",
)
if ns["level"] != "minimum":
line_total = etree.SubElement(sums, ns["ram"] + "LineTotalAmount")
line_total.text = "%0.*f" % (
ns["cur_prec"],
(self.amount_untaxed - ns["allowance_total_amount"]) * ns["sign"],
)
# We don't want to generate charge total, because we don't have the
# notion of charge in Odoo. We only support allowance:
# an allowance is an invoice line with a negative price
# Warning: the allowance amount is positive (but has negative meaning)
if not self.currency_id.is_zero(ns["allowance_total_amount"]):
allowance_total = etree.SubElement(
sums, ns["ram"] + "AllowanceTotalAmount"
)
allowance_total.text = "%0.*f" % (
ns["cur_prec"],
ns["allowance_total_amount"],
)
tax_basis_total_amt = etree.SubElement(sums, ns["ram"] + "TaxBasisTotalAmount")
tax_basis_total_amt.text = "%0.*f" % (
ns["cur_prec"],
self.amount_untaxed * ns["sign"],
)
tax_total = etree.SubElement(
sums, ns["ram"] + "TaxTotalAmount", currencyID=ns["currency"]
)
tax_total.text = "%0.*f" % (ns["cur_prec"], self.amount_tax * ns["sign"])
total = etree.SubElement(sums, ns["ram"] + "GrandTotalAmount")
total.text = "%0.*f" % (ns["cur_prec"], self.amount_total * ns["sign"])
if ns["level"] != "minimum":
prepaid = etree.SubElement(sums, ns["ram"] + "TotalPrepaidAmount")
prepaid.text = "%0.*f" % (
ns["cur_prec"],
(self.amount_total - self.amount_residual) * ns["sign"],
)
residual = etree.SubElement(sums, ns["ram"] + "DuePayableAmount")
residual.text = "%0.*f" % (ns["cur_prec"], self.amount_residual * ns["sign"])
def _set_iline_product_information(self, iline, trade_product, ns):
if iline.product_id:
if iline.product_id.barcode:
barcode = etree.SubElement(
trade_product, ns["ram"] + "GlobalID", schemeID="0160"
)
# 0160 = GS1 Global Trade Item Number (GTIN, EAN)
barcode.text = iline.product_id.barcode
if ns["level"] in PROFILES_EN_UP and iline.product_id.default_code:
product_code = etree.SubElement(
trade_product, ns["ram"] + "SellerAssignedID"
)
product_code.text = iline.product_id.default_code
product_name = etree.SubElement(trade_product, ns["ram"] + "Name")
product_name.text = iline.name or _("No invoice line label")
if (
ns["level"] in PROFILES_EN_UP
and iline.product_id
and iline.product_id.description_sale
):
product_desc = etree.SubElement(trade_product, ns["ram"] + "Description")
product_desc.text = iline.product_id.description_sale
def _set_iline_product_attributes(self, iline, trade_product, ns):
if iline.product_id and ns["level"] in PROFILES_EN_UP:
product = iline.product_id
for attrib_val in product.product_template_attribute_value_ids:
attrib_value_rec = attrib_val.product_attribute_value_id
attrib_value = attrib_value_rec.name
attribute_name = attrib_value_rec.attribute_id.name
product_charact = etree.SubElement(
trade_product, ns["ram"] + "ApplicableProductCharacteristic"
)
product_charact_desc = etree.SubElement(
product_charact, ns["ram"] + "Description"
)
product_charact_desc.text = attribute_name
product_charact_value = etree.SubElement(
product_charact, ns["ram"] + "Value"
)
product_charact_value.text = attrib_value
if hasattr(product, "hs_code_id") and product.type in ("product", "consu"):
hs_code = product.get_hs_code_recursively()
if hs_code:
product_classification = etree.SubElement(
trade_product, ns["ram"] + "DesignatedProductClassification"
)
product_classification_code = etree.SubElement(
product_classification, ns["ram"] + "ClassCode", listID="HS"
)
product_classification_code.text = hs_code.local_code
# origin_country_id and hs_code_id are provided
# by the OCA module product_harmonized_system
if (
hasattr(product, "origin_country_id")
and product.type in ("product", "consu")
and product.origin_country_id
):
origin_trade_country = etree.SubElement(
trade_product, ns["ram"] + "OriginTradeCountry"
)
origin_trade_country_code = etree.SubElement(
origin_trade_country, ns["ram"] + "ID"
)
origin_trade_country_code.text = product.origin_country_id.code
def _cii_add_invoice_line_block(self, trade_transaction, iline, line_number, ns):
self.ensure_one()
line_item = etree.SubElement(
trade_transaction, ns["ram"] + "IncludedSupplyChainTradeLineItem"
)
line_doc = etree.SubElement(
line_item, ns["ram"] + "AssociatedDocumentLineDocument"
)
etree.SubElement(line_doc, ns["ram"] + "LineID").text = str(line_number)
trade_product = etree.SubElement(line_item, ns["ram"] + "SpecifiedTradeProduct")
self._set_iline_product_information(iline, trade_product, ns)
self._set_iline_product_attributes(iline, trade_product, ns)
line_trade_agreement = etree.SubElement(
line_item, ns["ram"] + "SpecifiedLineTradeAgreement"
)
# convert gross price_unit to tax_excluded value
taxres = iline.tax_ids.compute_all(iline.price_unit)
gross_price_val = float_round(
taxres["total_excluded"], precision_digits=ns["price_prec"]
)
# Use oline.price_subtotal/qty to compute net unit price to be sure
# to get a *tax_excluded* net unit price
if float_is_zero(iline.quantity, precision_digits=ns["qty_prec"]):
net_price_val = 0.0
else:
net_price_val = float_round(
iline.price_subtotal / float(iline.quantity),
precision_digits=ns["price_prec"],
)
if ns["level"] in PROFILES_EN_UP:
gross_price = etree.SubElement(
line_trade_agreement, ns["ram"] + "GrossPriceProductTradePrice"
)
gross_price_amount = etree.SubElement(
gross_price, ns["ram"] + "ChargeAmount"
)
gross_price_amount.text = "%0.*f" % (ns["price_prec"], gross_price_val)
fc_discount = float_compare(
iline.discount, 0.0, precision_digits=ns["disc_prec"]
)
if fc_discount in [-1, 1]:
trade_allowance = etree.SubElement(
gross_price, ns["ram"] + "AppliedTradeAllowanceCharge"
)
charge_indic = etree.SubElement(
trade_allowance, ns["ram"] + "ChargeIndicator"
)
indicator = etree.SubElement(charge_indic, ns["udt"] + "Indicator")
if fc_discount == 1:
indicator.text = "false"
ac_sign = 1
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,
)
actual_amount = etree.SubElement(
trade_allowance, ns["ram"] + "ActualAmount"
)
actual_amount_val = float_round(
ac_sign
* ((iline.price_unit * iline.quantity) - iline.price_subtotal),
precision_digits=ns["price_prec"],
)
actual_amount.text = "%0.*f" % (
ns["price_prec"],
actual_amount_val * ns["sign"],
)
net_price = etree.SubElement(
line_trade_agreement, ns["ram"] + "NetPriceProductTradePrice"
)
net_price_amount = etree.SubElement(net_price, ns["ram"] + "ChargeAmount")
net_price_amount.text = "%0.*f" % (ns["price_prec"], net_price_val)
line_trade_delivery = etree.SubElement(
line_item, ns["ram"] + "SpecifiedLineTradeDelivery"
)
if iline.product_uom_id and iline.product_uom_id.unece_code:
unitCode = iline.product_uom_id.unece_code
else:
unitCode = "C62"
if not iline.product_uom_id:
logger.warning(
"No unit of measure on invoice line '%s', "
"using C62 (piece) as fallback",
iline.name,
)
else:
logger.warning(
"Missing UNECE Code on unit of measure %s, "
"using C62 (piece) as fallback",
iline.product_uom_id.name,
)
billed_qty = etree.SubElement(
line_trade_delivery, ns["ram"] + "BilledQuantity", unitCode=unitCode
)
billed_qty.text = "%0.*f" % (ns["qty_prec"], iline.quantity * ns["sign"])
line_trade_settlement = etree.SubElement(
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)
subtotal = etree.SubElement(
line_trade_settlement,
ns["ram"] + "SpecifiedTradeSettlementLineMonetarySummation",
)
subtotal_amount = etree.SubElement(subtotal, ns["ram"] + "LineTotalAmount")
subtotal_amount.text = "%0.*f" % (
ns["cur_prec"],
iline.price_subtotal * ns["sign"],
)
def _cii_invoice_line_taxes(self, iline, parent_node, ns, allowance=False):
if iline.tax_ids:
for tax in iline.tax_ids:
self._cii_line_applicable_trade_tax_block(
tax, parent_node, ns, allowance=allowance
)
else:
self._cii_line_applicable_trade_tax_block(
None, parent_node, ns, allowance=allowance
)
def generate_facturx_xml(self):
self.ensure_one()
assert self.move_type in (
"out_invoice",
"out_refund",
), "only works for customer invoice and refunds"
dpo = self.env["decimal.precision"]
level = self.company_id.facturx_level or "en16931"
refund_type = self.company_id.facturx_refund_type or "381"
sign = 1
if self.move_type == "out_refund" and refund_type == "380":
sign = -1
lang = self.partner_id.lang or self.env.user.lang or "en_US"
tax_speeddict = self.company_id._get_tax_unece_speeddict()
fp_speeddict = self.company_id._get_fiscal_position_speeddict(lang=lang)
self = self.with_context(lang=lang)
nsmap = {
"xsi": "http://www.w3.org/2001/XMLSchema-instance",
"rsm": "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100",
"ram": "urn:un:unece:uncefact:data:standard:"
"ReusableAggregateBusinessInformationEntity:100",
"qdt": "urn:un:unece:uncefact:data:standard:QualifiedDataType:100",
"udt": "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100",
}
ns = {
"rsm": "{urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100}",
"ram": "{urn:un:unece:uncefact:data:standard:"
"ReusableAggregateBusinessInformationEntity:100}",
"qdt": "{urn:un:unece:uncefact:data:standard:QualifiedDataType:100}",
"udt": "{urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100}",
"level": level,
"refund_type": refund_type,
"sign": sign,
"currency": self.currency_id.name,
"cur_prec": self.currency_id.decimal_places,
"price_prec": dpo.precision_get("Product Price"),
"disc_prec": dpo.precision_get("Discount"),
"qty_prec": dpo.precision_get("Product Unit of Measure"),
"lang": lang,
"tax_speeddict": tax_speeddict,
"fp_speeddict": fp_speeddict,
"allowance_total_amount": 0.0,
}
root = etree.Element(ns["rsm"] + "CrossIndustryInvoice", nsmap=nsmap)
self._cii_add_document_context_block(root, ns)
self._cii_add_header_block(root, ns)
trade_transaction = etree.SubElement(
root, ns["rsm"] + "SupplyChainTradeTransaction"
)
allowance_ilines = self.env["account.move.line"]
if ns["level"] in ("extended", "en16931", "basic"):
line_number = 0
for iline in self.invoice_line_ids.filtered(
lambda x: x.display_type == "product"
):
price_compare = float_compare(
iline.price_unit, 0, precision_digits=ns["price_prec"]
)
if price_compare >= 0:
line_number += 1
self._cii_add_invoice_line_block(
trade_transaction, iline, line_number, ns
)
else:
# global allowance
allowance_ilines |= iline
self._cii_add_trade_agreement_block(trade_transaction, ns)
self._cii_add_trade_delivery_block(trade_transaction, ns)
self._cii_add_trade_settlement_block(trade_transaction, allowance_ilines, ns)
xml_byte = etree.tostring(
root, pretty_print=True, encoding="UTF-8", xml_declaration=True
)
logger.debug("Factur-X XML file generated for invoice ID %d", self.id)
logger.debug(xml_byte.decode("utf-8"))
try:
xml_check_xsd(xml_byte, flavor="factur-x", level=ns["level"])
except Exception as e:
raise UserError(str(e)) from e
return (xml_byte, level)
def _prepare_pdf_metadata(self):
self.ensure_one()
inv_type = self.move_type == "out_refund" and _("Refund") or _("Invoice")
if self.invoice_date:
invoice_date = format_date(
self.env, self.invoice_date, lang_code=self.partner_id.lang
)
else:
invoice_date = _("(no date)")
if self.state == "posted":
invoice_number = self.name
else:
invoice_number = self._fields["state"].convert_to_export(self.state, self)
format_vals = {
"company_name": self.company_id.name,
"invoice_type": inv_type,
"invoice_number": invoice_number,
"invoice_date": invoice_date,
}
pdf_metadata = {
"author": format_vals["company_name"],
"keywords": ", ".join([inv_type, _("Factur-X")]),
"title": _(
"{company_name}: {invoice_type} {invoice_number} dated {invoice_date}"
).format(**format_vals),
"subject": _(
"Factur-X {invoice_type} {invoice_number} dated {invoice_date} "
"issued by {company_name}"
).format(**format_vals),
}
return pdf_metadata
def _prepare_facturx_attachments(self):
# This method is designed to be inherited in other modules
self.ensure_one()
return {}
def regular_pdf_invoice_to_facturx_invoice(self, pdf_bytesio):
self.ensure_one()
assert pdf_bytesio, "Missing pdf_bytesio"
if self.move_type in ("out_invoice", "out_refund"):
facturx_xml_bytes, level = self.generate_facturx_xml()
pdf_metadata = self._prepare_pdf_metadata()
lang = (
self.partner_id.lang and self.partner_id.lang.replace("_", "-") or None
)
# Generate a new PDF with XML file as attachment
attachments = self._prepare_facturx_attachments()
generate_from_file(
pdf_bytesio,
facturx_xml_bytes,
flavor="factur-x",
level=level,
check_xsd=False,
pdf_metadata=pdf_metadata,
lang=lang,
attachments=attachments,
)
logger.info("%s file added to PDF invoice", FACTURX_FILENAME)