Odoo18-Base/addons/l10n_es_edi_facturae/models/account_move.py
2025-01-06 10:57:38 +07:00

791 lines
38 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import base64
import re
from collections import defaultdict
from copy import deepcopy
from hashlib import sha1
from lxml import etree
from markupsafe import Markup
from odoo import Command, _, api, fields, models
from odoo.exceptions import UserError
from odoo.tools import float_round, float_repr, date_utils, SQL
from odoo.tools.xml_utils import cleanup_xml_node, find_xml_value
from odoo.addons.l10n_es_edi_facturae.xml_utils import (
NS_MAP,
_canonicalize_node,
_reference_digests,
)
PHONE_CLEAN_TABLE = str.maketrans({" ": None, "-": None, "(": None, ")": None, "+": None})
COUNTRY_CODE_MAP = {
"BD": "BGD", "BE": "BEL", "BF": "BFA", "BG": "BGR", "BA": "BIH", "BB": "BRB", "WF": "WLF", "BL": "BLM", "BM": "BMU",
"BN": "BRN", "BO": "BOL", "BH": "BHR", "BI": "BDI", "BJ": "BEN", "BT": "BTN", "JM": "JAM", "BV": "BVT", "BW": "BWA",
"WS": "WSM", "BQ": "BES", "BR": "BRA", "BS": "BHS", "JE": "JEY", "BY": "BLR", "BZ": "BLZ", "RU": "RUS", "RW": "RWA",
"RS": "SRB", "TL": "TLS", "RE": "REU", "TM": "TKM", "TJ": "TJK", "RO": "ROU", "TK": "TKL", "GW": "GNB", "GU": "GUM",
"GT": "GTM", "GS": "SGS", "GR": "GRC", "GQ": "GNQ", "GP": "GLP", "JP": "JPN", "GY": "GUY", "GG": "GGY", "GF": "GUF",
"GE": "GEO", "GD": "GRD", "GB": "GBR", "GA": "GAB", "SV": "SLV", "GN": "GIN", "GM": "GMB", "GL": "GRL", "GI": "GIB",
"GH": "GHA", "OM": "OMN", "TN": "TUN", "JO": "JOR", "HR": "HRV", "HT": "HTI", "HU": "HUN", "HK": "HKG", "HN": "HND",
"HM": "HMD", "VE": "VEN", "PR": "PRI", "PS": "PSE", "PW": "PLW", "PT": "PRT", "SJ": "SJM", "PY": "PRY", "IQ": "IRQ",
"PA": "PAN", "PF": "PYF", "PG": "PNG", "PE": "PER", "PK": "PAK", "PH": "PHL", "PN": "PCN", "PL": "POL", "PM": "SPM",
"ZM": "ZMB", "EH": "ESH", "EE": "EST", "EG": "EGY", "ZA": "ZAF", "EC": "ECU", "IT": "ITA", "VN": "VNM", "SB": "SLB",
"ET": "ETH", "SO": "SOM", "ZW": "ZWE", "SA": "SAU", "ES": "ESP", "ER": "ERI", "ME": "MNE", "MD": "MDA", "MG": "MDG",
"MF": "MAF", "MA": "MAR", "MC": "MCO", "UZ": "UZB", "MM": "MMR", "ML": "MLI", "MO": "MAC", "MN": "MNG", "MH": "MHL",
"MK": "MKD", "MU": "MUS", "MT": "MLT", "MW": "MWI", "MV": "MDV", "MQ": "MTQ", "MP": "MNP", "MS": "MSR", "MR": "MRT",
"IM": "IMN", "UG": "UGA", "TZ": "TZA", "MY": "MYS", "MX": "MEX", "IL": "ISR", "FR": "FRA", "IO": "IOT", "SH": "SHN",
"FI": "FIN", "FJ": "FJI", "FK": "FLK", "FM": "FSM", "FO": "FRO", "NI": "NIC", "NL": "NLD", "NO": "NOR", "NA": "NAM",
"VU": "VUT", "NC": "NCL", "NE": "NER", "NF": "NFK", "NG": "NGA", "NZ": "NZL", "NP": "NPL", "NR": "NRU", "NU": "NIU",
"CK": "COK", "XK": "XKX", "CI": "CIV", "CH": "CHE", "CO": "COL", "CN": "CHN", "CM": "CMR", "CL": "CHL", "CC": "CCK",
"CA": "CAN", "CG": "COG", "CF": "CAF", "CD": "COD", "CZ": "CZE", "CY": "CYP", "CX": "CXR", "CR": "CRI", "CW": "CUW",
"CV": "CPV", "CU": "CUB", "SZ": "SWZ", "SY": "SYR", "SX": "SXM", "KG": "KGZ", "KE": "KEN", "SS": "SSD", "SR": "SUR",
"KI": "KIR", "KH": "KHM", "KN": "KNA", "KM": "COM", "ST": "STP", "SK": "SVK", "KR": "KOR", "SI": "SVN", "KP": "PRK",
"KW": "KWT", "SN": "SEN", "SM": "SMR", "SL": "SLE", "SC": "SYC", "KZ": "KAZ", "KY": "CYM", "SG": "SGP", "SE": "SWE",
"SD": "SDN", "DO": "DOM", "DM": "DMA", "DJ": "DJI", "DK": "DNK", "VG": "VGB", "DE": "DEU", "YE": "YEM", "DZ": "DZA",
"US": "USA", "UY": "URY", "YT": "MYT", "UM": "UMI", "LB": "LBN", "LC": "LCA", "LA": "LAO", "TV": "TUV", "TW": "TWN",
"TT": "TTO", "TR": "TUR", "LK": "LKA", "LI": "LIE", "LV": "LVA", "TO": "TON", "LT": "LTU", "LU": "LUX", "LR": "LBR",
"LS": "LSO", "TH": "THA", "TF": "ATF", "TG": "TGO", "TD": "TCD", "TC": "TCA", "LY": "LBY", "VA": "VAT", "VC": "VCT",
"AE": "ARE", "AD": "AND", "AG": "ATG", "AF": "AFG", "AI": "AIA", "VI": "VIR", "IS": "ISL", "IR": "IRN", "AM": "ARM",
"AL": "ALB", "AO": "AGO", "AQ": "ATA", "AS": "ASM", "AR": "ARG", "AU": "AUS", "AT": "AUT", "AW": "ABW", "IN": "IND",
"AX": "ALA", "AZ": "AZE", "IE": "IRL", "ID": "IDN", "UA": "UKR", "QA": "QAT", "MZ": "MOZ"
}
REVERSED_COUNTRY_CODE = {v: k for k, v in COUNTRY_CODE_MAP.items()}
class AccountMove(models.Model):
_inherit = 'account.move'
l10n_es_edi_facturae_xml_id = fields.Many2one(
comodel_name='ir.attachment',
string="Facturae Attachment",
compute=lambda self: self._compute_linked_attachment_id('l10n_es_edi_facturae_xml_id', 'l10n_es_edi_facturae_xml_file'),
depends=['l10n_es_edi_facturae_xml_file']
)
l10n_es_edi_facturae_xml_file = fields.Binary(
attachment=True,
string="Facturae File",
copy=False,
)
l10n_es_edi_facturae_reason_code = fields.Selection(
selection=[
('01', "Invoice number"),
('02', "Invoice serial number"),
('03', "Issue date"),
('04', "Name and surnames/Corporate name - Issuer (Sender)"),
('05', "Name and surnames/Corporate name - Receiver"),
('06', "Issuer's Tax Identification Number"),
('07', "Receiver's Tax Identification Number"),
('08', "Issuer's address"),
('09', "Receiver's address"),
('10', "Item line"),
('11', "Applicable Tax Rate"),
('12', "Applicable Tax Amount"),
('13', "Applicable Date/Period"),
('14', "Invoice Class"),
('15', "Legal literals"),
('16', "Taxable Base"),
('80', "Calculation of tax outputs"),
('81', "Calculation of tax inputs"),
('82', "Taxable Base modified due to return of packages and packaging materials"),
('83', "Taxable Base modified due to discounts and rebates"),
('84', "Taxable Base modified due to firm court ruling or administrative decision"),
('85', "Taxable Base modified due to unpaid outputs where there is a judgement opening insolvency proceedings"),
], string='Spanish Facturae EDI Reason Code', default='10')
l10n_es_invoicing_period_start_date = fields.Date(string="Invoice Period Start Date")
l10n_es_invoicing_period_end_date = fields.Date(string="Invoice Period End Date")
l10n_es_payment_means = fields.Selection(
selection=[
('01', "In cash"),
('02', "Direct debit"),
('03', "Receipt"),
('04', "Credit transfer"),
('05', "Accepted bill of exchange"),
('06', "Documentary credit"),
('07', "Contract award"),
('08', "Bill of exchange"),
('09', "Transferable promissory note"),
('10', "Non transferable promissory note"),
('11', "Cheque"),
('12', "Open account reimbursement"),
('13', "Special payment"),
('14', "Set-off by reciprocal credits"),
('15', "Payment by postgiro"),
('16', "Certified cheque"),
('17', "Bankers draft"),
('18', "Cash on delivery"),
('19', "Payment by card"),
], string="Payment Means", default='04')
def _l10n_es_edi_facturae_get_default_enable(self):
self.ensure_one()
return not self.invoice_pdf_report_id \
and not self.l10n_es_edi_facturae_xml_id \
and not self.l10n_es_is_simplified \
and self.is_invoice(include_receipts=True) \
and (self.partner_id.is_company or self.partner_id.vat) \
and self.company_id.country_code == 'ES' \
and self.company_id.currency_id.name == 'EUR' \
and self.company_id.sudo().l10n_es_edi_facturae_certificate_ids # We only enable Facturae if a certificate is valid or has been valid (which will raise an error)
def _l10n_es_edi_facturae_get_filename(self):
self.ensure_one()
return f'{self.name.replace("/", "_")}_facturae_signed.xml'
def _l10n_es_edi_facturae_get_tax_period(self):
self.ensure_one()
if self.env['res.company'].fields_get(['account_tax_periodicity']):
period_start, period_end = self.company_id._get_tax_closing_period_boundaries(self.date, self.env.ref('l10n_es.mod_303'))
else:
period_start = date_utils.start_of(self.date, 'month')
period_end = date_utils.end_of(self.date, 'month')
return {'start':period_start, 'end':period_end}
def _l10n_es_edi_facturae_get_refunded_invoices(self):
self.env['account.partial.reconcile'].flush_model()
invoices_refunded_mapping = {invoice.id: invoice.reversed_entry_id.id for invoice in self}
stored_ids = tuple(self.ids)
queries = []
for source_field, counterpart_field in (
('debit_move_id', 'credit_move_id'),
('credit_move_id', 'debit_move_id'),
):
queries.append(SQL('''
SELECT
source_line.move_id AS source_move_id,
counterpart_line.move_id AS counterpart_move_id
FROM account_partial_reconcile part
JOIN account_move_line source_line ON source_line.id = part.%s
JOIN account_move_line counterpart_line ON counterpart_line.id = part.%s
WHERE source_line.move_id IN %s AND counterpart_line.move_id != source_line.move_id
GROUP BY source_move_id, counterpart_move_id
''', SQL.identifier(source_field), SQL.identifier(counterpart_field), stored_ids))
payment_data = defaultdict(list)
for row in self.env.execute_query_dict(SQL(" UNION ALL ").join(queries)):
payment_data[row['source_move_id']].append(row)
for invoice in self:
if not invoice.move_type.endswith('refund'):
# We only want to map refunds
continue
for move_id in (record_dict['counterpart_move_id'] for record_dict in payment_data.get(invoice.id, [])):
invoices_refunded_mapping[invoice.id] = move_id
return invoices_refunded_mapping
def _l10n_es_edi_facturae_get_corrective_data(self):
self.ensure_one()
if self.move_type.endswith('refund'):
if not self.reversed_entry_id:
raise UserError(_("The credit note/refund appears to have been issued manually. For the purpose of "
"generating a Facturae document, it's necessary that the credit note/refund is created "
"directly from the associated invoice/bill."))
refunded_invoice = self.env['account.move'].browse(self._l10n_es_edi_facturae_get_refunded_invoices()[self.id])
tax_period = refunded_invoice._l10n_es_edi_facturae_get_tax_period()
reason_code = self.l10n_es_edi_facturae_reason_code or '10'
reason_description = [label for code, label in self._fields['l10n_es_edi_facturae_reason_code'].selection
if code == reason_code][0]
return {
'refunded_invoice_record': refunded_invoice,
'ReasonCode': reason_code,
'Reason': reason_description,
'TaxPeriod': {
'StartDate': tax_period.get('start'),
'EndDate': tax_period.get('end'),
}
}
return {}
def _l10n_es_edi_facturae_get_administrative_centers(self, partner):
self.ensure_one()
administrative_centers = []
for ac in partner.child_ids.filtered(lambda p: p.type == 'facturae_ac'):
ac_template = {
'center_code': ac.l10n_es_edi_facturae_ac_center_code,
'name': ac.name,
'partner': ac,
'partner_country_code': COUNTRY_CODE_MAP[ac.country_code],
'partner_phone': ac.phone.translate(PHONE_CLEAN_TABLE) if ac.phone else False,
'physical_gln': ac.l10n_es_edi_facturae_ac_physical_gln,
'logical_operational_point': ac.l10n_es_edi_facturae_ac_logical_operational_point,
}
# An administrative center can have multiple roles, each of which should be reported separately.
for role in ac.l10n_es_edi_facturae_ac_role_type_ids or [self.env['l10n_es_edi_facturae.ac_role_type']]:
administrative_centers.append({
**ac_template,
'role_type_code': role.code,
})
return administrative_centers
def _l10n_es_edi_facturae_get_tax_node_from_tax_data(self, values):
self.ensure_one()
tax = values['grouping_key']
return {
'tax_record': tax,
'TaxRate': f'{abs(tax.amount):.3f}',
'TaxableBase': {
'TotalAmount': self.currency_id.round(values['raw_base_amount_currency']),
'EquivalentInEuros': self.company_currency_id.round(values['raw_base_amount']),
},
'TaxAmount': {
'TotalAmount': self.currency_id.round(abs(values['raw_tax_amount_currency'])),
'EquivalentInEuros': self.company_currency_id.round(abs(values['raw_tax_amount'])),
},
}
def _l10n_es_edi_facturae_convert_payment_terms_to_installments(self):
"""
Convert the payments terms to a list of <Installment> elements to be used in the
<PaymentDetails> node of the Facturae XML generation.
"""
self.ensure_one()
installments = []
if self.is_inbound() and self.partner_bank_id:
for payment_term in self.line_ids.filtered(lambda l: l.display_type == 'payment_term').sorted('date_maturity'):
installments.append({
'InstallmentDueDate': payment_term.date_maturity,
'InstallmentAmount': payment_term.amount_residual_currency,
'PaymentMeans': self.l10n_es_payment_means,
'AccountToBeCredited': {
'IBAN': self.partner_bank_id.sanitized_acc_number,
'BIC': self.partner_bank_id.bank_bic,
},
})
return installments
def _l10n_es_edi_facturae_prepare_inv_line(self, base_line, aggregated_values):
"""
Convert the invoice lines to a list of items required for the Facturae xml generation
:return: A tuple containing the Face items, the taxes and the invoice totals data.
"""
self.ensure_one()
extended_dp = 6 if self.company_id.tax_calculation_rounding_method == 'round_globally' else 2
invoice_ref = self.ref and self.ref[:20]
line = base_line['record']
tax_details = base_line['tax_details']
receiver_transaction_reference = (
line.sale_line_ids.order_id.client_order_ref[:20]
if 'sale_line_ids' in line._fields and line.sale_line_ids.order_id.client_order_ref
else False
)
xml_values = {
'ReceiverTransactionReference': receiver_transaction_reference,
'FileReference': invoice_ref,
'FileDate': fields.Date.context_today(self),
'ItemDescription': line.name,
'Quantity': line.quantity,
'UnitOfMeasure': line.product_uom_id.l10n_es_edi_facturae_uom_code,
'DiscountsAndRebates': [],
'Charges': [],
'GrossAmount': line.price_subtotal,
}
if line.discount == 100.0:
raw_total_cost = line.price_unit * line.quantity
else:
raw_total_cost = tax_details['total_excluded_currency'] / (1 - (line.discount / 100.0))
xml_values['TotalCost'] = line.currency_id.round(raw_total_cost)
if line.quantity:
xml_values['UnitPriceWithoutTax'] = float_round(raw_total_cost / line.quantity, precision_digits=extended_dp)
else:
xml_values['UnitPriceWithoutTax'] = 0.0
raw_discount_amount = xml_values['TotalCost'] - line.price_subtotal
discount_amount = max(raw_discount_amount, 0.0)
if discount_amount:
xml_values['DiscountsAndRebates'].append({
'DiscountReason': '/',
'DiscountRate': f'{line.discount:.2f}',
'DiscountAmount': discount_amount,
})
surcharge_amount = -min(0.0, raw_discount_amount)
if surcharge_amount:
xml_values['Charges'].append({
'ChargeReason': '/',
'ChargeRate': f'{-line.discount:.2f}',
'ChargeAmount': surcharge_amount,
})
xml_values['TaxesOutputs'] = [
self._l10n_es_edi_facturae_get_tax_node_from_tax_data(values)
for values in aggregated_values.values()
if values['grouping_key'] and values['grouping_key'].amount >= 0.0
]
xml_values['TaxesWithheld'] = [
self._l10n_es_edi_facturae_get_tax_node_from_tax_data(values)
for values in aggregated_values.values()
if values['grouping_key'] and values['grouping_key'].amount < 0.0
]
return xml_values
def _l10n_es_edi_facturae_export_facturae(self):
"""
Produce the Facturae XML data for the invoice.
:return: (data needed to render the full template, data needed to render the signature template)
"""
def extract_party_name(party):
name = {'firstname': 'UNKNOWN', 'surname': 'UNKNOWN', 'surname2': ''}
if not party.is_company:
name_split = [part for part in party.name.replace(', ', ' ').split(' ') if part]
if len(name_split) > 2:
name['firstname'] = ' '.join(name_split[:-2])
name['surname'], name['surname2'] = name_split[-2:]
elif len(name_split) == 2:
name['firstname'] = ' '.join(name_split[:-1])
name['surname'] = name_split[-1]
return name
self.ensure_one()
company = self.company_id
partner = self.commercial_partner_id
if not company.vat:
raise UserError(_('The company needs a set tax identification number or VAT number'))
if not partner.vat:
raise UserError(_('The partner needs a set tax identification number or VAT number'))
if not partner.country_id:
raise UserError(_("The partner needs a set country"))
if self.move_type == "entry":
return False
# Multi-currencies.
eur_curr = self.env['res.currency'].search([('name', '=', 'EUR')])
inv_curr = self.currency_id
conversion_needed = inv_curr != eur_curr
# Invoice xml values.
invoice_ref = self.ref and self.ref[:20]
legal_literals = self.narration and self.narration.striptags()
legal_literals = legal_literals.split(";") if legal_literals else False
invoice_values = {
'invoice_record': self,
'invoice_currency': inv_curr,
'InvoiceDocumentType': 'FC',
'InvoiceClass': 'OO',
'Corrective': self._l10n_es_edi_facturae_get_corrective_data(),
'InvoiceIssueData': {
'ExchangeRateDetails': conversion_needed,
'ExchangeRate': f"{round(self.invoice_currency_rate, 4):.4f}",
'LanguageName': self._context.get('lang', 'en_US').split('_')[0],
'InvoicingPeriod': None,
'ReceiverTransactionReference': invoice_ref,
'FileReference': invoice_ref,
'ReceiverContractReference': invoice_ref,
},
'TaxOutputs': [],
'TaxesWithheld': [],
'TotalGrossAmount': 0.0,
'TotalGeneralDiscounts': 0.0,
'TotalGeneralSurcharges': 0.0,
'TotalGrossAmountBeforeTaxes': 0.0,
'TotalTaxOutputs': 0.0,
'TotalTaxesWithheld': 0.0,
'PaymentsOnAccount': [],
'TotalOutstandingAmount': abs(self.amount_total_in_currency_signed),
'InvoiceTotal': abs(self.amount_total_in_currency_signed),
'TotalPaymentsOnAccount': 0.0,
'AmountsWithheld': None,
'TotalExecutableAmount': abs(self.amount_total_in_currency_signed),
'Items': [],
'PaymentDetails': self._l10n_es_edi_facturae_convert_payment_terms_to_installments(),
'LegalLiterals': legal_literals,
}
# Taxes.
AccountTax = self.env['account.tax']
base_amls = self.invoice_line_ids.filtered(lambda line: line.display_type == 'product')
base_lines = [self._prepare_product_base_line_for_taxes_computation(line) for line in base_amls]
AccountTax._add_tax_details_in_base_lines(base_lines, self.company_id)
tax_amls = self.line_ids.filtered('tax_repartition_line_id')
tax_lines = [self._prepare_tax_line_for_taxes_computation(tax_line) for tax_line in tax_amls]
AccountTax._round_base_lines_tax_details(base_lines, self.company_id, tax_lines=tax_lines)
def grouping_function(base_line, tax_data):
return tax_data['tax']
base_lines_aggregated_values = AccountTax._aggregate_base_lines_tax_details(base_lines, grouping_function)
for base_line, aggregated_values in base_lines_aggregated_values:
invoice_line_values = self._l10n_es_edi_facturae_prepare_inv_line(base_line, aggregated_values)
invoice_values['TotalGrossAmount'] += invoice_line_values['GrossAmount']
invoice_values['Items'].append(invoice_line_values)
for values in aggregated_values.values():
tax = values['grouping_key']
if not tax:
continue
tax_data = self._l10n_es_edi_facturae_get_tax_node_from_tax_data(values)
if tax.amount < 0.0:
invoice_values['TaxesWithheld'].append(tax_data)
invoice_values['TotalTaxesWithheld'] += tax_data['TaxAmount']['TotalAmount']
else:
invoice_values['TaxOutputs'].append(tax_data)
invoice_values['TotalTaxOutputs'] += tax_data['TaxAmount']['TotalAmount']
invoice_values['TotalGrossAmountBeforeTaxes'] = (
invoice_values['TotalGrossAmount']
- invoice_values['TotalGeneralDiscounts']
+ invoice_values['TotalGeneralSurcharges']
)
template_values = {
'self_party': company.partner_id,
'self_party_country_code': COUNTRY_CODE_MAP[company.country_id.code],
'self_party_name': extract_party_name(company.partner_id),
'self_party_administrative_centers': self._l10n_es_edi_facturae_get_administrative_centers(company.partner_id),
'other_party': partner,
'other_party_country_code': COUNTRY_CODE_MAP[partner.country_id.code],
'other_party_phone': partner.phone.translate(PHONE_CLEAN_TABLE) if partner.phone else False,
'other_party_name': extract_party_name(partner),
'other_party_administrative_centers': self._l10n_es_edi_facturae_get_administrative_centers(partner),
'is_outstanding': self.move_type.startswith('out_'),
'float_repr': float_repr,
'file_currency': inv_curr,
'eur': eur_curr,
'conversion_needed': conversion_needed,
'refund_multiplier': -1 if self.move_type in ('out_refund', 'in_refund') else 1,
'Modality': 'I',
'BatchIdentifier': self.name,
'InvoicesCount': 1,
'TotalInvoicesAmount': {
'TotalAmount': abs(self.amount_total_in_currency_signed),
'EquivalentInEuros': abs(self.amount_total_signed),
},
'TotalOutstandingAmount': {
'TotalAmount': abs(self.amount_total_in_currency_signed),
'EquivalentInEuros': abs(self.amount_total_signed),
},
'TotalExecutableAmount': {
'TotalAmount': abs(self.amount_total_in_currency_signed),
'EquivalentInEuros': abs(self.amount_total_signed),
},
'InvoiceCurrencyCode': inv_curr.name,
'Invoices': [invoice_values],
}
if self.l10n_es_invoicing_period_start_date and self.l10n_es_invoicing_period_end_date:
template_values['Invoices'][0]['InvoiceIssueData']['InvoicingPeriod'] = {
'StartDate': self.l10n_es_invoicing_period_start_date,
'EndDate': self.l10n_es_invoicing_period_end_date,
}
invoice_issuer_signature_type = 'supplier' if self.move_type == 'out_invoice' else 'customer'
signature_values = {'SigningTime': '', 'SignerRole': invoice_issuer_signature_type}
return template_values, signature_values
def _l10n_es_edi_facturae_render_facturae(self):
"""
Produce the Facturae XML file for the invoice.
:return: rendered xml file string.
:rtype: str
"""
self.ensure_one()
company = self.company_id
template_values, signature_values = self._l10n_es_edi_facturae_export_facturae()
xml_content = cleanup_xml_node(self.env['ir.qweb']._render('l10n_es_edi_facturae.account_invoice_facturae_export', template_values))
errors = []
try:
xml_content = self._l10n_es_facturae_sign_xml(xml_content, signature_values)
except ValueError:
errors.append(_('No valid certificate found for this company, Facturae EDI file will not be signed.\n'))
return xml_content, errors
# -------------------------------------------------------------------------
# IMPORT
# -------------------------------------------------------------------------
def _get_edi_decoder(self, file_data, new=False):
def is_facturae(tree):
return tree.tag in [
'{http://www.facturae.es/Facturae/2014/v3.2.1/Facturae}Facturae',
'{http://www.facturae.gob.es/formato/Versiones/Facturaev3_2_2.xml}Facturae',
]
if file_data['type'] == 'xml' and is_facturae(file_data['xml_tree']):
return self._import_invoice_facturae
return super()._get_edi_decoder(file_data, new=new)
def _import_invoice_facturae(self, invoice, file_data, new=False):
tree = file_data['xml_tree']
is_bill = invoice.move_type.startswith('in_')
partner = self._import_get_partner(tree, is_bill)
self._import_invoice_facturae_invoices(invoice, partner, tree)
def _import_get_partner(self, tree, is_bill):
# If we're dealing with a vendor bill, then the partner is the seller party, if an invoice then it's the buyer.
party = tree.xpath('//SellerParty') if is_bill else tree.xpath('//BuyerParty')
if party:
partner_vals = self._import_extract_partner_values(party[0])
return self._import_create_or_retrieve_partner(partner_vals)
return None
def _import_extract_partner_values(self, party_node):
name = find_xml_value('.//CorporateName|.//Name', party_node)
first_surname = find_xml_value('.//FirstSurname', party_node)
second_surname = find_xml_value('.//SecondSurname', party_node)
phone = find_xml_value('.//Telephone', party_node)
mail = find_xml_value('.//ElectronicMail', party_node)
country_code = find_xml_value('.//CountryCode', party_node)
vat = find_xml_value('.//TaxIdentificationNumber', party_node)
full_name = ' '.join(part for part in [name, first_surname, second_surname] if part)
return {'name': full_name, 'vat': vat, 'phone': phone, 'email': mail, 'country_code': country_code}
def _import_create_or_retrieve_partner(self, partner_vals):
name = partner_vals['name']
vat = partner_vals['vat']
phone = partner_vals['phone']
email = partner_vals['email']
country_code = partner_vals['country_code']
partner = self.env['res.partner']._retrieve_partner(name=name, vat=vat, phone=phone, email=email)
if not partner and name:
partner_vals = {'name': name, 'email': email, 'phone': phone}
country_code = REVERSED_COUNTRY_CODE.get(country_code)
country = self.env['res.country'].search([('code', '=', country_code)]) if country_code else False
if country:
partner_vals['country_id'] = country.id
partner = self.env['res.partner'].create(partner_vals)
if vat and self.env['res.partner']._run_vat_test(vat, country):
partner.vat = vat
return partner
def _import_invoice_facturae_invoices(self, invoice, partner, tree):
invoices = tree.xpath('//Invoice')
if not invoices:
return
self._import_invoice_facturae_invoice(invoice, partner, invoices[0])
# There might be other invoices inside the facturae.
for node in invoices[1:]:
other_invoice = invoice.create({
'journal_id': invoice.journal_id.id,
'move_type': invoice.move_type
})
with other_invoice._get_edi_creation():
self._import_invoice_facturae_invoice(other_invoice, partner, node)
other_invoice.message_post(body=_("Created from attachment in %s", invoice._get_html_link()))
def _import_invoice_facturae_invoice(self, invoice, partner, tree):
logs = []
# ==== move_type ====
invoice_total = find_xml_value('.//InvoiceTotal', tree)
is_refund = float(invoice_total) < 0 if invoice_total else False
if is_refund:
invoice.move_type = "in_refund" if invoice.move_type.startswith("in_") else "out_refund"
ref_multiplier = -1.0 if is_refund else 1.0
# ==== partner_id ====
if partner:
invoice.partner_id = partner
else:
logs.append(_("Customer/Vendor could not be found and could not be created due to missing data in the XML."))
# ==== currency_id ====
invoice_currency_code = find_xml_value('.//InvoiceCurrencyCode', tree)
if invoice_currency_code:
currency = self.env['res.currency'].search([('name', '=', invoice_currency_code)], limit=1)
if currency:
invoice.currency_id = currency
else:
logs.append(_("Could not retrieve currency: %s. Did you enable the multicurrency option "
"and activate the currency?", invoice_currency_code))
# ==== invoice date ====
if issue_date := find_xml_value('.//IssueDate', tree):
invoice.invoice_date = issue_date
# ==== invoice_date_due ====
if end_date := find_xml_value('.//InstallmentDueDate', tree):
invoice.invoice_date_due = end_date
# ==== ref ====
if invoice_number := find_xml_value('.//InvoiceNumber', tree):
invoice.ref = invoice_number
# ==== narration ====
invoice.narration = "\n".join(
ref.text
for ref in tree.xpath('.//LegalReference')
if ref.text
)
# === invoice_line_ids ===
logs += self._import_invoice_fill_lines(invoice, tree, ref_multiplier)
body = Markup("<strong>%s</strong>") % _("Invoice imported from Factura-E XML file.")
if logs:
body += Markup("<ul>%s</ul>") \
% Markup().join(Markup("<li>%s</li>") % log for log in logs)
invoice.message_post(body=body)
return logs
def _import_invoice_fill_lines(self, invoice, tree, ref_multiplier):
lines = tree.xpath('.//InvoiceLine')
logs = []
vals_list = []
for line in lines:
line_vals = {'move_id': invoice.id}
# ==== name ====
if item_description := find_xml_value('.//ItemDescription', line):
product = self._search_product_for_import(item_description)
if product:
line_vals['product_id'] = product.id
else:
logs.append(_("The product '%s' could not be found.", item_description))
line_vals['name'] = item_description
# ==== quantity ====
line_vals['quantity'] = find_xml_value('.//Quantity', line) or 1
# ==== price_unit ====
price_unit = find_xml_value('.//UnitPriceWithoutTax', line)
line_vals['price_unit'] = ref_multiplier * float(price_unit) if price_unit else 1.0
# ==== discount ====
discounts = line.xpath('.//DiscountRate')
discount_rate = 0.0
for discount in discounts:
discount_rate += float(discount.text)
charges = line.xpath('.//ChargeRate')
charge_rate = 0.0
for charge in charges:
charge_rate += float(charge.text)
discount_rate -= charge_rate
line_vals['discount'] = discount_rate
# ==== tax_ids ====
taxes_withheld_nodes = line.xpath('.//TaxesWithheld/Tax')
taxes_outputs_nodes = line.xpath('.//TaxesOutputs/Tax')
is_purchase = invoice.move_type.startswith('in')
tax_ids = []
logs += self._import_fill_invoice_line_taxes(invoice, line_vals, tax_ids, taxes_outputs_nodes, False, is_purchase)
logs += self._import_fill_invoice_line_taxes(invoice, line_vals, tax_ids, taxes_withheld_nodes, True, is_purchase)
line_vals['tax_ids'] = [Command.set(tax_ids)]
vals_list.append(line_vals)
invoice.invoice_line_ids = self.env['account.move.line'].create(vals_list)
return logs
def _import_fill_invoice_line_taxes(self, invoice, line_vals, tax_ids, tax_nodes, is_withheld, is_purchase):
logs = []
for tax_node in tax_nodes:
tax_rate = find_xml_value('.//TaxRate', tax_node)
if tax_rate:
# Since the 'TaxRate' node isn't guaranteed to be a percentage, we can find out by
# applying the tax rate on the taxable base, and if it's equal to the tax amount
# then we can say this is a percentage, otherwise a fixed amount.
taxable_base = find_xml_value('.//TaxableBase/TotalAmount', tax_node)
tax_amount = find_xml_value('.//TaxAmount/TotalAmount', tax_node)
is_fixed = False
if taxable_base and tax_amount and invoice.currency_id.compare_amounts(float(taxable_base) * (float(tax_rate) / 100), float(tax_amount)) != 0:
is_fixed = True
tax_excl = self._search_tax_for_import(invoice.company_id, float(tax_rate), is_fixed, is_withheld, is_purchase, price_included=False)
if tax_excl:
tax_ids.append(tax_excl.id)
elif tax_incl := self._search_tax_for_import(invoice.company_id, float(tax_rate), is_fixed, is_withheld, is_purchase, price_included=True):
tax_ids.append(tax_incl)
line_vals['price_unit'] *= (1.0 + float(tax_rate) / 100.0)
else:
logs.append(_("Could not retrieve the tax: %(tax_rate)s %% for line '%(line)s'.", tax_rate=tax_rate, line=line_vals.get('name', "")))
return logs
def _search_tax_for_import(self, company, amount, is_fixed, is_withheld, is_purchase, price_included):
taxes = self.env['account.tax'].search([
('company_id', '=', company.id),
('amount', '=', -1.0 * amount if is_withheld else amount),
('amount_type', '=', 'fixed' if is_fixed else 'percent'),
('type_tax_use', '=', 'purchase' if is_purchase else 'sale'),
('price_include', '=', price_included),
], limit=1)
return taxes
def _search_product_for_import(self, item_description):
# Exported Odoo XML will have item_description = "[default_code] name".
# We can check if it follows the same format and search for the product with the default code and the name.
code_and_name = re.match(r"(\[(?P<default_code>.*?)\]\s)?(?P<name>.*)", item_description).groupdict()
product = self.env['product.product']._retrieve_product(**code_and_name)
return product
# -------------------------------------------------------------------------
# BUSINESS METHODS #
# -------------------------------------------------------------------------
def _l10n_es_facturae_sign_xml(self, edi_data, signature_data):
"""
Signs the given XML data with the certificate and private key.
:param etree._Element edi_data: The XML data to sign.
:param dict signature_data: The signature data to use.
:return: The signed XML data string.
:rtype: str
"""
self.ensure_one()
certificates_sudo = self.company_id.sudo().l10n_es_edi_facturae_certificate_ids.filtered("is_valid")
if not certificates_sudo:
raise UserError(_('No valid certificate found'))
certificate_sudo = certificates_sudo[0]
root = deepcopy(edi_data)
e, n = certificate_sudo._get_public_key_numbers_bytes()
issuer = certificate_sudo._l10n_es_edi_facturae_get_issuer()
# Identifiers
document_id = f"Document-{sha1(etree.tostring(edi_data)).hexdigest()}"
signature_id = f"Signature-{document_id}"
keyinfo_id = f"KeyInfo-{document_id}"
sigproperties_id = f"SignatureProperties-{document_id}"
signature_data.update({
'document_id': document_id,
'x509_certificate': base64.encodebytes(base64.b64decode(certificate_sudo._get_der_certificate_bytes())).decode(),
'public_modulus': n.decode(),
'public_exponent': e.decode(),
'iso_now': fields.datetime.now().isoformat(),
'keyinfo_id': keyinfo_id,
'signature_id': signature_id,
'sigproperties_id': sigproperties_id,
'reference_uri': f"Reference-{document_id}",
'sigpolicy_url': "http://www.facturae.es/politica_de_firma_formato_facturae/politica_de_firma_formato_facturae_v3_1.pdf",
'sigpolicy_description': "Política de firma electrónica para facturación electrónica con formato Facturae",
'sigcertif_digest': certificate_sudo._get_fingerprint_bytes(formatting='base64').decode(),
'x509_issuer_description': issuer,
'x509_serial_number': int(certificate_sudo.serial_number),
})
signature = self.env['ir.qweb']._render('l10n_es_edi_facturae.template_xades_signature', signature_data)
signature = cleanup_xml_node(signature, remove_blank_nodes=False)
root.append(signature)
_reference_digests(signature.find("ds:SignedInfo", namespaces=NS_MAP))
signed_info_xml = signature.find("ds:SignedInfo", namespaces=NS_MAP)
signature.find("ds:SignatureValue", namespaces=NS_MAP).text = certificate_sudo._sign(_canonicalize_node(signed_info_xml)).decode()
return etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True)