account_edi_ubl_cii/models/account_edi_xml_cii_facturx.py

411 lines
22 KiB
Python

# -*- coding: utf-8 -*-
from odoo import models, _
from odoo.tools import DEFAULT_SERVER_DATE_FORMAT, float_repr, is_html_empty, html2plaintext, cleanup_xml_node
from lxml import etree
from datetime import datetime
import logging
_logger = logging.getLogger(__name__)
DEFAULT_FACTURX_DATE_FORMAT = '%Y%m%d'
class AccountEdiXmlCII(models.AbstractModel):
_name = "account.edi.xml.cii"
_inherit = 'account.edi.common'
_description = "Factur-x/XRechnung CII 2.2.0"
def _export_invoice_filename(self, invoice):
return "factur-x.xml"
def _export_invoice_ecosio_schematrons(self):
return {
'invoice': 'de.xrechnung:cii:2.2.0',
'credit_note': 'de.xrechnung:cii:2.2.0',
}
def _export_invoice_constraints(self, invoice, vals):
constraints = self._invoice_constraints_common(invoice)
constraints.update({
# [BR-08]-An Invoice shall contain the Seller postal address (BG-5).
# [BR-09]-The Seller postal address (BG-5) shall contain a Seller country code (BT-40).
'seller_postal_address': self._check_required_fields(
vals['record']['company_id']['partner_id']['commercial_partner_id'], 'country_id'
),
# [BR-DE-9] The element "Buyer post code" (BT-53) must be transmitted. (only mandatory in Germany ?)
'buyer_postal_address': self._check_required_fields(
vals['record']['commercial_partner_id'], 'zip'
),
# [BR-DE-4] The element "Seller post code" (BT-38) must be transmitted. (only mandatory in Germany ?)
'seller_post_code': self._check_required_fields(
vals['record']['company_id']['partner_id']['commercial_partner_id'], 'zip'
),
# [BR-CO-26]-In order for the buyer to automatically identify a supplier, the Seller identifier (BT-29),
# the Seller legal registration identifier (BT-30) and/or the Seller VAT identifier (BT-31) shall be present.
'seller_identifier': self._check_required_fields(
vals['record']['company_id'], ['vat'] # 'siret'
),
# [BR-DE-1] An Invoice must contain information on "PAYMENT INSTRUCTIONS" (BG-16)
# first check that a partner_bank_id exists, then check that there is an account number
'seller_payment_instructions_1': self._check_required_fields(
vals['record'], 'partner_bank_id'
),
'seller_payment_instructions_2': self._check_required_fields(
vals['record']['partner_bank_id'], 'sanitized_acc_number',
_("The field 'Sanitized Account Number' is required on the Recipient Bank.")
),
# [BR-DE-6] The element "Seller contact telephone number" (BT-42) must be transmitted.
'seller_phone': self._check_required_fields(
vals['record']['company_id']['partner_id']['commercial_partner_id'], ['phone', 'mobile'],
),
# [BR-DE-7] The element "Seller contact email address" (BT-43) must be transmitted.
'seller_email': self._check_required_fields(
vals['record']['company_id'], 'email'
),
# [BR-CO-04]-Each Invoice line (BG-25) shall be categorized with an Invoiced item VAT category code (BT-151).
'tax_invoice_line': self._check_required_tax(vals),
# [BR-IC-02]-An Invoice that contains an Invoice line (BG-25) where the Invoiced item VAT category code (BT-151)
# is "Intra-community supply" shall contain the Seller VAT Identifier (BT-31) or the Seller tax representative
# VAT identifier (BT-63) and the Buyer VAT identifier (BT-48).
'intracom_seller_vat': self._check_required_fields(vals['record']['company_id'], 'vat') if vals['intracom_delivery'] else None,
'intracom_buyer_vat': self._check_required_fields(vals['record']['commercial_partner_id'], 'vat') if vals['intracom_delivery'] else None,
# [BR-IG-05]-In an Invoice line (BG-25) where the Invoiced item VAT category code (BT-151) is "IGIC" the
# invoiced item VAT rate (BT-152) shall be greater than 0 (zero).
'igic_tax_rate': self._check_non_0_rate_tax(vals)
if vals['record']['commercial_partner_id']['country_id']['code'] == 'ES'
and vals['record']['commercial_partner_id']['zip']
and vals['record']['commercial_partner_id']['zip'][:2] in ['35', '38'] else None,
})
return constraints
def _check_required_tax(self, vals):
for line_vals in vals['invoice_line_vals_list']:
line = line_vals['line']
if not vals['tax_details']['tax_details_per_record'][line]['tax_details']:
return _("You should include at least one tax per invoice line. [BR-CO-04]-Each Invoice line (BG-25) "
"shall be categorized with an Invoiced item VAT category code (BT-151).")
def _check_non_0_rate_tax(self, vals):
for line_vals in vals['tax_details']['tax_details_per_record']:
tax_rate_list = line_vals.tax_ids.flatten_taxes_hierarchy().mapped("amount")
if not any([rate > 0 for rate in tax_rate_list]):
return _("When the Canary Island General Indirect Tax (IGIC) applies, the tax rate on "
"each invoice line should be greater than 0.")
def _get_scheduled_delivery_time(self, invoice):
# don't create a bridge only to get line.sale_line_ids.order_id.picking_ids.date_done
# line.sale_line_ids.order_id.picking_ids.scheduled_date or line.sale_line_ids.order_id.commitment_date
return invoice.invoice_date
def _get_invoicing_period(self, invoice):
# get the Invoicing period (BG-14): a list of dates covered by the invoice
# don't create a bridge to get the date range from the timesheet_ids
return [invoice.invoice_date]
def _get_exchanged_document_vals(self, invoice):
return {
'id': invoice.name,
'type_code': '380' if invoice.move_type == 'out_invoice' else '381',
'issue_date_time': invoice.invoice_date,
'included_note': html2plaintext(invoice.narration) if invoice.narration else "",
}
def _export_invoice_vals(self, invoice):
def format_date(dt):
# Format the date in the Factur-x standard.
dt = dt or datetime.now()
return dt.strftime(DEFAULT_FACTURX_DATE_FORMAT)
def format_monetary(number, decimal_places=2):
# Facturx requires the monetary values to be rounded to 2 decimal values
return float_repr(number, decimal_places)
def grouping_key_generator(base_line, tax_values):
tax = tax_values['tax_repartition_line'].tax_id
grouping_key = {
**self._get_tax_unece_codes(invoice, tax),
'amount': tax.amount,
'amount_type': tax.amount_type,
}
# If the tax is fixed, we want to have one group per tax
# s.t. when the invoice is imported, we can try to guess the fixed taxes
if tax.amount_type == 'fixed':
grouping_key['tax_name'] = tax.name
return grouping_key
# Validate the structure of the taxes
self._validate_taxes(invoice)
# Create file content.
tax_details = invoice._prepare_invoice_aggregated_taxes(grouping_key_generator=grouping_key_generator)
# Fixed Taxes: filter them on the document level, and adapt the totals
# Fixed taxes are not supposed to be taxes in real live. However, this is the way in Odoo to manage recupel
# taxes in Belgium. Since only one tax is allowed, the fixed tax is removed from totals of lines but added
# as an extra charge/allowance.
fixed_taxes_keys = [k for k in tax_details['tax_details'] if k['amount_type'] == 'fixed']
for key in fixed_taxes_keys:
fixed_tax_details = tax_details['tax_details'].pop(key)
tax_details['tax_amount_currency'] -= fixed_tax_details['tax_amount_currency']
tax_details['tax_amount'] -= fixed_tax_details['tax_amount']
tax_details['base_amount_currency'] += fixed_tax_details['tax_amount_currency']
tax_details['base_amount'] += fixed_tax_details['tax_amount']
if 'siret' in invoice.company_id._fields and invoice.company_id.siret:
seller_siret = invoice.company_id.siret
else:
seller_siret = invoice.company_id.company_registry
buyer_siret = False
if 'siret' in invoice.commercial_partner_id._fields and invoice.commercial_partner_id.siret:
buyer_siret = invoice.commercial_partner_id.siret
template_values = {
**invoice._prepare_edi_vals_to_export(),
'tax_details': tax_details,
'format_date': format_date,
'format_monetary': format_monetary,
'is_html_empty': is_html_empty,
'scheduled_delivery_time': self._get_scheduled_delivery_time(invoice),
'intracom_delivery': False,
'ExchangedDocument_vals': self._get_exchanged_document_vals(invoice),
'seller_specified_legal_organization': seller_siret,
'buyer_specified_legal_organization': buyer_siret,
'ship_to_trade_party': invoice.partner_shipping_id if 'partner_shipping_id' in invoice._fields and invoice.partner_shipping_id
else invoice.commercial_partner_id,
# Chorus Pro fields
'buyer_reference': invoice.buyer_reference if 'buyer_reference' in invoice._fields
and invoice.buyer_reference else invoice.commercial_partner_id.ref,
'purchase_order_reference': invoice.purchase_order_reference if 'purchase_order_reference' in invoice._fields
and invoice.purchase_order_reference else invoice.ref or invoice.name,
'contract_reference': invoice.contract_reference if 'contract_reference' in invoice._fields and invoice.contract_reference else '',
}
# data used for IncludedSupplyChainTradeLineItem / SpecifiedLineTradeSettlement
for line_vals in template_values['invoice_line_vals_list']:
line = line_vals['line']
line_vals['unece_uom_code'] = self._get_uom_unece_code(line)
# data used for ApplicableHeaderTradeSettlement / ApplicableTradeTax (at the end of the xml)
for tax_detail_vals in template_values['tax_details']['tax_details'].values():
# /!\ -0.0 == 0.0 in python but not in XSLT, so it can raise a fatal error when validating the XML
# if 0.0 is expected and -0.0 is given.
amount_currency = tax_detail_vals['tax_amount_currency']
tax_detail_vals['calculated_amount'] = amount_currency if not invoice.currency_id.is_zero(amount_currency) else 0
if tax_detail_vals.get('tax_category_code') == 'K':
template_values['intracom_delivery'] = True
# [BR - IC - 11] - In an Invoice with a VAT breakdown (BG-23) where the VAT category code (BT-118) is
# "Intra-community supply" the Actual delivery date (BT-72) or the Invoicing period (BG-14) shall not be blank.
if tax_detail_vals.get('tax_category_code') == 'K' and not template_values['scheduled_delivery_time']:
date_range = self._get_invoicing_period(invoice)
template_values['billing_start'] = min(date_range)
template_values['billing_end'] = max(date_range)
# One of the difference between XRechnung and Facturx is the following. Submitting a Facturx to XRechnung
# validator raises a warning, but submitting a XRechnung to Facturx raises an error.
supplier = invoice.company_id.partner_id.commercial_partner_id
if supplier.country_id.code == 'DE':
template_values['document_context_id'] = "urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_2.2"
else:
template_values['document_context_id'] = "urn:cen.eu:en16931:2017#conformant#urn:factur-x.eu:1p0:extended"
# Fixed taxes: add them as charges on the invoice lines
for line_vals in template_values['invoice_line_vals_list']:
line_vals['allowance_charge_vals_list'] = []
for grouping_key, tax_detail in tax_details['tax_details_per_record'][line_vals['line']]['tax_details'].items():
if grouping_key['amount_type'] == 'fixed':
line_vals['allowance_charge_vals_list'].append({
'indicator': 'true',
'reason': tax_detail['tax_name'],
'reason_code': 'AEO',
'amount': tax_detail['tax_amount_currency'],
})
sum_fixed_taxes = sum(x['amount'] for x in line_vals['allowance_charge_vals_list'])
line_vals['line_total_amount'] = line_vals['line'].price_subtotal + sum_fixed_taxes
# Fixed taxes: set the total adjusted amounts on the document level
template_values['tax_basis_total_amount'] = tax_details['base_amount_currency']
template_values['tax_total_amount'] = tax_details['tax_amount_currency']
return template_values
def _export_invoice(self, invoice):
vals = self._export_invoice_vals(invoice)
errors = [constraint for constraint in self._export_invoice_constraints(invoice, vals).values() if constraint]
xml_content = self.env['ir.qweb']._render('account_edi_ubl_cii.account_invoice_facturx_export_22', vals)
return etree.tostring(cleanup_xml_node(xml_content), xml_declaration=True, encoding='UTF-8'), set(errors)
# -------------------------------------------------------------------------
# IMPORT
# -------------------------------------------------------------------------
def _import_fill_invoice_form(self, invoice, tree, qty_factor):
logs = []
if qty_factor == -1:
logs.append(_("The invoice has been converted into a credit note and the quantities have been reverted."))
# ==== partner_id ====
role = invoice.journal_id.type == 'purchase' and 'SellerTradeParty' or 'BuyerTradeParty'
name = self._find_value(f"//ram:{role}/ram:Name", tree)
mail = self._find_value(f"//ram:{role}//ram:URIID[@schemeID='SMTP']", tree)
vat = self._find_value(f"//ram:{role}/ram:SpecifiedTaxRegistration/ram:ID[string-length(text()) > 5]", tree)
phone = self._find_value(f"//ram:{role}/ram:DefinedTradeContact/ram:TelephoneUniversalCommunication/ram:CompleteNumber", tree)
country_code = self._find_value(f'//ram:{role}/ram:PostalTradeAddress//ram:CountryID', tree)
self._import_retrieve_and_fill_partner(invoice, name=name, phone=phone, mail=mail, vat=vat, country_code=country_code)
# ==== currency_id ====
currency_code_node = tree.find('.//{*}InvoiceCurrencyCode')
if currency_code_node is not None:
currency = self.env['res.currency'].with_context(active_test=False).search([
('name', '=', currency_code_node.text),
], limit=1)
if currency:
if not currency.active:
logs.append(_("The currency '%s' is not active.", currency.name))
invoice.currency_id = currency
else:
logs.append(_("Could not retrieve currency: %s. Did you enable the multicurrency option and "
"activate the currency?", currency_code_node.text))
# ==== Bank Details ====
bank_detail_nodes = tree.findall('.//{*}SpecifiedTradeSettlementPaymentMeans')
bank_details = [
bank_detail_node.findtext('{*}PayeePartyCreditorFinancialAccount/{*}IBANID')
or bank_detail_node.findtext('{*}PayeePartyCreditorFinancialAccount/{*}ProprietaryID')
for bank_detail_node in bank_detail_nodes
]
if bank_details:
self._import_retrieve_and_fill_partner_bank_details(invoice, bank_details=bank_details)
# ==== Reference ====
ref_node = tree.find('./{*}ExchangedDocument/{*}ID')
if ref_node is not None:
invoice.ref = ref_node.text
# ==== Invoice origin ====
invoice_origin_node = tree.find('./{*}OrderReference/{*}ID')
if invoice_origin_node is not None:
invoice.invoice_origin = invoice_origin_node.text
# === Note/narration ====
narration = ""
note_node = tree.find('./{*}ExchangedDocument/{*}IncludedNote/{*}Content')
if note_node is not None and note_node.text:
narration += note_node.text + "\n"
payment_terms_node = tree.find('.//{*}SpecifiedTradePaymentTerms/{*}Description')
if payment_terms_node is not None and payment_terms_node.text:
narration += payment_terms_node.text + "\n"
invoice.narration = narration
# ==== payment_reference ====
payment_reference_node = tree.find('.//{*}BuyerOrderReferencedDocument/{*}IssuerAssignedID')
if payment_reference_node is not None:
invoice.payment_reference = payment_reference_node.text
# ==== invoice_date ====
invoice_date_node = tree.find('./{*}ExchangedDocument/{*}IssueDateTime/{*}DateTimeString')
if invoice_date_node is not None and invoice_date_node.text:
date_str = invoice_date_node.text.strip()
date_obj = datetime.strptime(date_str, DEFAULT_FACTURX_DATE_FORMAT)
invoice.invoice_date = date_obj.strftime(DEFAULT_SERVER_DATE_FORMAT)
# ==== invoice_date_due ====
invoice_date_due_node = tree.find('.//{*}SpecifiedTradePaymentTerms/{*}DueDateDateTime/{*}DateTimeString')
if invoice_date_due_node is not None and invoice_date_due_node.text:
date_str = invoice_date_due_node.text.strip()
date_obj = datetime.strptime(date_str, DEFAULT_FACTURX_DATE_FORMAT)
invoice.invoice_date_due = date_obj.strftime(DEFAULT_SERVER_DATE_FORMAT)
# ==== invoice_line_ids: AllowanceCharge (document level) ====
logs += self._import_fill_invoice_allowance_charge(tree, invoice, qty_factor)
# ==== Prepaid amount ====
prepaid_node = tree.find('.//{*}ApplicableHeaderTradeSettlement/'
'{*}SpecifiedTradeSettlementHeaderMonetarySummation/{*}TotalPrepaidAmount')
logs += self._import_log_prepaid_amount(invoice, prepaid_node, qty_factor)
# ==== invoice_line_ids ====
line_nodes = tree.findall('./{*}SupplyChainTradeTransaction/{*}IncludedSupplyChainTradeLineItem')
if line_nodes is not None:
for invl_el in line_nodes:
invoice_line = invoice.invoice_line_ids.create({'move_id': invoice.id})
invl_logs = self._import_fill_invoice_line_form(invoice.journal_id, invl_el, invoice, invoice_line, qty_factor)
logs += invl_logs
return logs
def _import_fill_invoice_line_form(self, journal, tree, invoice_form, invoice_line, qty_factor):
logs = []
# Product.
name = self._find_value('.//ram:SpecifiedTradeProduct/ram:Name', tree)
invoice_line.product_id = self.env['product.product']._retrieve_product(
default_code=self._find_value('.//ram:SpecifiedTradeProduct/ram:SellerAssignedID', tree),
name=name,
barcode=self._find_value('.//ram:SpecifiedTradeProduct/ram:GlobalID', tree)
)
# force original line description instead of the one copied from product's Sales Description
if name:
invoice_line.name = name
xpath_dict = {
'basis_qty': [
'./{*}SpecifiedLineTradeAgreement/{*}GrossPriceProductTradePrice/{*}BasisQuantity',
'./{*}SpecifiedLineTradeAgreement/{*}NetPriceProductTradePrice/{*}BasisQuantity'
],
'gross_price_unit': './{*}SpecifiedLineTradeAgreement/{*}GrossPriceProductTradePrice/{*}ChargeAmount',
'rebate': './{*}SpecifiedLineTradeAgreement/{*}GrossPriceProductTradePrice/{*}AppliedTradeAllowanceCharge/{*}ActualAmount',
'net_price_unit': './{*}SpecifiedLineTradeAgreement/{*}NetPriceProductTradePrice/{*}ChargeAmount',
'billed_qty': './{*}SpecifiedLineTradeDelivery/{*}BilledQuantity',
'allowance_charge': './/{*}SpecifiedLineTradeSettlement/{*}SpecifiedTradeAllowanceCharge',
'allowance_charge_indicator': './{*}ChargeIndicator/{*}Indicator',
'allowance_charge_amount': './{*}ActualAmount',
'allowance_charge_reason': './{*}Reason',
'allowance_charge_reason_code': './{*}ReasonCode',
'line_total_amount': './{*}SpecifiedLineTradeSettlement/{*}SpecifiedTradeSettlementLineMonetarySummation/{*}LineTotalAmount',
}
inv_line_vals = self._import_fill_invoice_line_values(tree, xpath_dict, invoice_line, qty_factor)
# retrieve tax nodes
tax_nodes = tree.findall('.//{*}ApplicableTradeTax/{*}RateApplicablePercent')
return self._import_fill_invoice_line_taxes(tax_nodes, invoice_line, inv_line_vals, logs)
# -------------------------------------------------------------------------
# IMPORT : helpers
# -------------------------------------------------------------------------
def _get_import_document_amount_sign(self, tree):
"""
In factur-x, an invoice has code 380 and a credit note has code 381. However, a credit note can be expressed
as an invoice with negative amounts. For this case, we need a factor to take the opposite of each quantity
in the invoice.
"""
move_type_code = tree.find('.//{*}ExchangedDocument/{*}TypeCode')
if move_type_code is None:
return None, None
if move_type_code.text == '381':
return 'refund', 1
if move_type_code.text == '380':
amount_node = tree.find('.//{*}SpecifiedTradeSettlementHeaderMonetarySummation/{*}TaxBasisTotalAmount')
if amount_node is not None and float(amount_node.text) < 0:
return 'refund', -1
return 'invoice', 1