Files
enterprise-addons/l10n_co_dian/models/account_edi_xml_ubl_dian.py
T

1940 lines
97 KiB
Python

import logging
from lxml import etree
from hashlib import sha384
from pytz import timezone
from stdnum.co.nit import compact
from collections import defaultdict
from datetime import datetime, timedelta
import re
from odoo import api, models, fields
from odoo.exceptions import UserError
from odoo.tools import cleanup_xml_node, frozendict
from odoo.addons.account_edi_ubl_cii.models.account_edi_common import FloatFmt
from odoo.addons.account.tools import dict_to_xml
from odoo.addons.l10n_co_dian import xml_utils
from odoo.addons.l10n_co_edi.models.res_partner import FINAL_CONSUMER_VAT
from odoo.addons.l10n_co_edi.models.account_invoice import L10N_CO_EDI_TYPE
_logger = logging.getLogger(__name__)
COUNTRIES_ES = {
"AF": "Afganistán",
"AX": "Åland",
"AL": "Albania",
"DE": "Alemania",
"AD": "Andorra",
"AO": "Angola",
"AI": "Anguila",
"AQ": "Antártida",
"AG": "Antigua y Barbuda",
"SA": "Arabia Saudita",
"DZ": "Argelia",
"AR": "Argentina",
"AM": "Armenia",
"AW": "Aruba",
"AU": "Australia",
"AT": "Austria",
"AZ": "Azerbaiyán",
"BS": "Bahamas",
"BD": "Bangladés",
"BB": "Barbados",
"BH": "Baréin",
"BE": "Bélgica",
"BZ": "Belice",
"BJ": "Benín",
"BM": "Bermudas",
"BY": "Bielorrusia",
"BO": "Bolivia",
"BQ": "Bonaire, San Eustaquio y Saba",
"BA": "Bosnia y Herzegovina",
"BW": "Botsuana",
"BR": "Brasil",
"BN": "Brunéi",
"BG": "Bulgaria",
"BF": "Burkina Faso",
"BI": "Burundi",
"BT": "Bután",
"CV": "Cabo Verde",
"KH": "Camboya",
"CM": "Camerún",
"CA": "Canadá",
"QA": "Catar",
"TD": "Chad",
"CL": "Chile",
"CN": "China",
"CY": "Chipre",
"CO": "Colombia",
"KM": "Comoras",
"KP": "Corea del Norte",
"KR": "Corea del Sur",
"CI": "Costa de Marfil",
"CR": "Costa Rica",
"HR": "Croacia",
"CU": "Cuba",
"CW": "Curazao",
"DK": "Dinamarca",
"DM": "Dominica",
"EC": "Ecuador",
"EG": "Egipto",
"SV": "El Salvador",
"AE": "Emiratos Árabes Unidos",
"ER": "Eritrea",
"SK": "Eslovaquia",
"SI": "Eslovenia",
"ES": "España",
"US": "Estados Unidos",
"EE": "Estonia",
"ET": "Etiopía",
"PH": "Filipinas",
"FI": "Finlandia",
"FJ": "Fiyi",
"FR": "Francia",
"GA": "Gabón",
"GM": "Gambia",
"GE": "Georgia",
"GH": "Ghana",
"GI": "Gibraltar",
"GD": "Granada",
"GR": "Grecia",
"GL": "Groenlandia",
"GP": "Guadalupe",
"GU": "Guam",
"GT": "Guatemala",
"GF": "Guayana Francesa",
"GG": "Guernsey",
"GN": "Guinea",
"GW": "Guinea-Bisáu",
"GQ": "Guinea Ecuatorial",
"GY": "Guyana",
"HT": "Haití",
"HN": "Honduras",
"HK": "Hong Kong",
"HU": "Hungría",
"IN": "India",
"ID": "Indonesia",
"IQ": "Irak",
"IR": "Irán",
"IE": "Irlanda",
"BV": "Isla Bouvet",
"IM": "Isla de Man",
"CX": "Isla de Navidad",
"IS": "Islandia",
"KY": "Islas Caimán",
"CC": "Islas Cocos",
"CK": "Islas Cook",
"FO": "Islas Feroe",
"GS": "Islas Georgias del Sur y Sandwich del Sur",
"HM": "Islas Heard y McDonald",
"FK": "Islas Malvinas",
"MP": "Islas Marianas del Norte",
"MH": "Islas Marshall",
"PN": "Islas Pitcairn",
"SB": "Islas Salomón",
"TC": "Islas Turcas y Caicos",
"UM": "Islas ultramarinas de Estados Unidos",
"VG": "Islas Vírgenes Británicas",
"VI": "Islas Vírgenes de los Estados Unidos",
"IL": "Israel",
"IT": "Italia",
"JM": "Jamaica",
"JP": "Japón",
"JE": "Jersey",
"JO": "Jordania",
"KZ": "Kazajistán",
"KE": "Kenia",
"KG": "Kirguistán",
"KI": "Kiribati",
"XK": "Kosovo",
"KW": "Kuwait",
"LA": "Laos",
"LS": "Lesoto",
"LV": "Letonia",
"LB": "Líbano",
"LR": "Liberia",
"LY": "Libia",
"LI": "Liechtenstein",
"LT": "Lituania",
"LU": "Luxemburgo",
"MO": "Macao",
"MK": "Macedonia",
"MG": "Madagascar",
"MY": "Malasia",
"MW": "Malaui",
"MV": "Maldivas",
"ML": "Malí",
"MT": "Malta",
"MA": "Marruecos",
"MQ": "Martinica",
"MU": "Mauricio",
"MR": "Mauritania",
"YT": "Mayotte",
"MX": "México",
"FM": "Micronesia",
"MD": "Moldavia",
"MC": "Mónaco",
"MN": "Mongolia",
"ME": "Montenegro",
"MS": "Montserrat",
"MZ": "Mozambique",
"MM": "Myanmar",
"NA": "Namibia",
"NR": "Nauru",
"NP": "Nepal",
"NI": "Nicaragua",
"NE": "Níger",
"NG": "Nigeria",
"NU": "Niue",
"NF": "Norfolk",
"NO": "Noruega",
"NC": "Nueva Caledonia",
"NZ": "Nueva Zelanda",
"OM": "Omán",
"NL": "Países Bajos",
"PK": "Pakistán",
"PW": "Palaos",
"PS": "Palestina",
"PA": "Panamá",
"PG": "Papúa Nueva Guinea",
"PY": "Paraguay",
"PE": "Perú",
"PF": "Polinesia Francesa",
"PL": "Polonia",
"PT": "Portugal",
"PR": "Puerto Rico",
"GB": "Reino Unido",
"EH": "República Árabe Saharaui Democrática",
"CF": "República Centroafricana",
"CZ": "República Checa",
"CG": "República del Congo",
"CD": "República Democrática del Congo",
"DO": "República Dominicana",
"RE": "Reunión",
"RW": "Ruanda",
"RO": "Rumania",
"RU": "Rusia",
"WS": "Samoa",
"AS": "Samoa Americana",
"BL": "San Bartolomé",
"KN": "San Cristóbal y Nieves",
"SM": "San Marino",
"MF": "San Martín",
"PM": "San Pedro y Miquelón",
"VC": "San Vicente y las Granadinas",
"SH": "Santa Elena, Ascensión y Tristán de Acuña",
"LC": "Santa Lucía",
"ST": "Santo Tomé y Príncipe",
"SN": "Senegal",
"RS": "Serbia",
"SC": "Seychelles",
"SL": "Sierra Leona",
"SG": "Singapur",
"SX": "Sint Maarten",
"SY": "Siria",
"SO": "Somalia",
"LK": "Sri Lanka",
"SZ": "Suazilandia",
"ZA": "Sudáfrica",
"SD": "Sudán",
"SS": "Sudán del Sur",
"SE": "Suecia",
"CH": "Suiza",
"SR": "Surinam",
"SJ": "Svalbard y Jan Mayen",
"TH": "Tailandia",
"TW": "Taiwán (República de China)",
"TZ": "Tanzania",
"TJ": "Tayikistán",
"IO": "Territorio Británico del Océano Índico",
"TF": "Tierras Australes y Antárticas Francesas",
"TL": "Timor Oriental",
"TG": "Togo",
"TK": "Tokelau",
"TO": "Tonga",
"TT": "Trinidad y Tobago",
"TN": "Túnez",
"TM": "Turkmenistán",
"TR": "Turquía",
"TV": "Tuvalu",
"UA": "Ucrania",
"UG": "Uganda",
"UY": "Uruguay",
"UZ": "Uzbekistán",
"VU": "Vanuatu",
"VA": "Vaticano, Ciudad del",
"VE": "Venezuela",
"VN": "Vietnam",
"WF": "Wallis y Futuna",
"YE": "Yemen",
"DJ": "Yibuti",
"ZM": "Zambia",
"ZW": "Zimbabue",
}
class AccountEdiXmlUbl_Dian(models.AbstractModel):
""" The technical documentation is available on the dian.gov.co website. Latest version is 1.9:
https://www.dian.gov.co/impuestos/factura-electronica/Documents/Anexo-Tecnico-Factura-Electronica-de-Venta-vr-1-9.pdf
"""
_name = 'account.edi.xml.ubl_dian'
_inherit = ['account.edi.xml.ubl_21']
_description = "UBL DIAN"
# -------------------------------------------------------------------------
# EXPORT
# -------------------------------------------------------------------------
def _export_invoice_filename(self, invoice):
# OVERRIDE account.edi.xml.ubl_21
return 'dian_%s.xml' % (re.sub(r'[\W_]', '', invoice.name))
def _export_invoice(self, invoice):
# EXTENDS account.edi.xml.ubl_21
xml, errors = super()._export_invoice(invoice)
certificates_sudo = invoice.company_id.sudo().l10n_co_dian_certificate_ids
if not certificates_sudo:
raise UserError(self.env._("No DIAN certificate is configured on the company."))
cert_sudo = certificates_sudo[-1]
root = etree.fromstring(xml)
self._dian_fill_signed_info_and_signature(root, cert_sudo)
return etree.tostring(root, encoding='UTF-8'), errors
def _get_document_nsmap(self, vals):
nsmap = super()._get_document_nsmap(vals)
nsmap.update({
'ds': "http://www.w3.org/2000/09/xmldsig#",
'sts': self._get_sts_namespace(vals),
'xades': "http://uri.etsi.org/01903/v1.3.2#",
'xades141': "http://uri.etsi.org/01903/v1.4.1#",
'xsi': "http://www.w3.org/2001/XMLSchema-instance",
**self._get_document_extensions_nsmap(vals),
})
return nsmap
def _add_invoice_config_vals(self, vals):
super()._add_invoice_config_vals(vals)
invoice = vals['invoice']
vals.update({
'document_type': 'debit_note' if invoice.l10n_co_edi_debit_note
else 'credit_note' if invoice.move_type in ('out_refund', 'in_refund')
else 'invoice',
'l10n_co_edi_type': invoice.l10n_co_edi_type,
'l10n_co_edi_operation_type': invoice.l10n_co_edi_operation_type,
'l10n_co_dian_identifier_type': invoice.l10n_co_dian_identifier_type,
'l10n_co_edi_is_support_document': invoice.l10n_co_edi_is_support_document,
'name': invoice.name,
'prepayments': invoice._l10n_co_dian_get_invoice_prepayments(),
})
self._add_document_config_vals(vals)
def _add_document_config_vals(self, vals):
vals.update({
# All amounts are reported in company currency (which should be COP)
# If the invoice is in foreign currency, we just provide the exchange rate in PaymentExchangeRate.
'use_company_currency': True,
# Fixed tax (e.g. IBUA) amounts should be accounted not as AllowanceCharges but as taxes
'fixed_taxes_as_allowance_charges': False,
})
mode = 'invoice' if vals['l10n_co_dian_identifier_type'] != 'cuds' else 'bill'
vals['l10n_co_dian_operation_mode'] = vals['company'].l10n_co_dian_operation_mode_ids.filtered(
lambda operation_mode: operation_mode.dian_software_operation_mode == mode
)
def _add_invoice_base_lines_vals(self, vals):
# EXTEND account.edi.xml.ubl_21
super()._add_invoice_base_lines_vals(vals)
for base_line in vals['base_lines']:
self._transform_iva_withholding_base_amount(base_line)
def _transform_iva_withholding_base_amount(self, base_line):
# Taxes with type '05' are retention taxes (15 %) that apply on the *tax amount* of a regular VAT tax
# Hence, the tax "15% RteVAT 19%" is encoded as a -2.85% tax in Odoo
# We now transform those taxes back to have their proper base amounts.
# On invoice line, look at the sibling tax detail '01' and extract its exact tax amount.
# see constraint DSAY05: the Taxable Amount for the taxes with type '05' should be equal to
# the Tax Amount on which the taxes with type '01' were applied
def get_tax_data(l10n_co_edi_type_code):
return next(
(
tax_data
for tax_data in base_line['tax_details']['taxes_data']
if tax_data['tax'].l10n_co_edi_type.code == l10n_co_edi_type_code
),
None,
)
tax_data_05 = get_tax_data('05')
if tax_data_05:
tax_data_01 = get_tax_data('01')
tax_data_05['base_amount'] = tax_data_01['tax_amount'] if tax_data_01 else 0.0
def _add_document_tax_grouping_function_vals(self, vals):
def total_grouping_function(base_line, tax_data):
tax = tax_data and tax_data['tax']
if tax and tax.l10n_co_edi_type.retention:
return None
# For support document, only the taxes IVA (01), ReteICA (05), ReteRenta (06) should be included
if vals['l10n_co_dian_identifier_type'] == 'cuds' and tax and tax.l10n_co_edi_type.code not in {'01', '05', '06'}:
return None
return True
def tax_grouping_function(base_line, tax_data):
""" Group the taxes by colombian type using the (tax.amount, tax.amount_type, tax.l10n_co_edi_type) """
tax = tax_data and tax_data['tax']
if not tax:
return None
# For support document, only the taxes IVA (01), ReteICA (05), ReteRenta (06) should be included
if vals['l10n_co_dian_identifier_type'] == 'cuds' and tax and tax.l10n_co_edi_type.code not in {'01', '05', '06'}:
return None
if tax.l10n_co_edi_type.code == '32':
# ICL (tax on alcoholic beverages) is a tax based on the alcohol percentage in the bottle.
# It is always sent in LTRs according to the specifications listed in the DIAN documentation.
amount = tax.amount / base_line['product_id'].l10n_co_edi_ref_nominal_tax * base_line['quantity']
elif tax.l10n_co_edi_type.code == '34':
# IBUA (tax on sugar beverages) is a tax based on the quantity of sugar per 100mL
# e.g. if the quantity of sugar per 100mL is > 10gr -> tax of 35$ per 100mL
# In Odoo, we use fixed taxes and a field for the volume of the product: l10n_co_edi_ref_nominal_tax
# Hence, we can infer the rate per 100mL of the tax
amount = tax.amount * 100 / base_line['product_id'].l10n_co_edi_ref_nominal_tax
elif tax.l10n_co_edi_type.code == '05':
# Taxes with type '05' are retention taxes (15 %) that apply on the *tax amount* of a regular VAT tax
# Hence, the tax "15% RteVAT 19%" is encoded as a -2.85% tax in Odoo. However, in the UBL, it should appear as 15%.
# Thus, we find the percentage of the IVA tax on the same line, and divide by it.
if iva_tax := next(
(
tax_data['tax']
for tax_data in base_line['tax_details']['taxes_data']
if tax_data['tax'].l10n_co_edi_type.code == '01'
),
None,
):
amount = tax.amount * 100 / iva_tax.amount
else:
amount = tax.amount
return {
'l10n_co_edi_type': tax.l10n_co_edi_type,
'amount_type': tax.amount_type,
'amount': amount,
'is_withholding_tax': tax.l10n_co_edi_type.retention,
}
vals['total_grouping_function'] = total_grouping_function
vals['tax_grouping_function'] = tax_grouping_function
# -------------------------------------------------------------------------
# EXPORT: Helpers
# -------------------------------------------------------------------------
def _add_document_cufe_cude_cuds_vals(self, document_node, vals):
def format_float(amount, precision_digits=vals['currency_dp']):
return self.format_float(amount, precision_digits)
def get_tax_amount(l10n_co_edi_type_code):
""" Get the tax amount associated with a given colombian tax type code. """
def grouping_function(base_line, tax_data):
return tax_data and tax_data['tax'].l10n_co_edi_type.code == l10n_co_edi_type_code
base_lines_aggregated_tax_details = self.env['account.tax']._aggregate_base_lines_tax_details(
vals['base_lines'],
grouping_function
)
aggregated_tax_details = self.env['account.tax']._aggregate_base_lines_aggregated_values(
base_lines_aggregated_tax_details
)
if True in aggregated_tax_details:
return aggregated_tax_details[True]['tax_amount']
return 0.0
if vals['l10n_co_dian_identifier_type'] in ('cude', 'cuds'):
key = vals['l10n_co_dian_operation_mode'].dian_software_security_code
else:
key = vals['journal'].l10n_co_dian_technical_key
monetary_total_tag = 'cac:LegalMonetaryTotal' if vals['document_type'] in {'invoice', 'credit_note'} else 'cac:RequestedMonetaryTotal'
cufe_cude_cuds_vals = {
'invoice_id': document_node['cbc:ID']['_text'],
'issue_date': document_node['cbc:IssueDate']['_text'],
'issue_time': document_node['cbc:IssueTime']['_text'], # invoice time (including tz)
'line_extension_amount': document_node[monetary_total_tag]['cbc:LineExtensionAmount']['_text'],
'tax_code_01': '01',
'ValImp1': format_float(get_tax_amount('01')),
'tax_code_04': '04',
'ValImp2': format_float(get_tax_amount('04')),
'tax_code_03': '03',
'ValImp3': format_float(get_tax_amount('03')),
'ValTotFac': document_node[monetary_total_tag]['cbc:PayableAmount']['_text'],
'supplier_company_id': vals['supplier']._get_vat_without_verification_code(),
'customer_company_id': vals['customer']._get_vat_without_verification_code(),
'key': key or 'missing_key',
'profile_execution_id': document_node['cbc:ProfileExecutionID']['_text'],
}
if vals['l10n_co_dian_identifier_type'] == 'cuds':
[cufe_cude_cuds_vals.pop(k) for k in ('tax_code_04', 'ValImp2', 'tax_code_03', 'ValImp3')]
vals['cufe_cude_cuds'] = "".join(str(res) for res in cufe_cude_cuds_vals.values())
vals['uuid'] = sha384(vals['cufe_cude_cuds'].encode()).hexdigest()
# -------------------------------------------------------------------------
# EXPORT: Templates for invoice header nodes
# -------------------------------------------------------------------------
def _get_invoice_node(self, vals):
document_node = super()._get_invoice_node(vals)
self._add_invoice_payment_exchange_rate_node(document_node, vals)
self._add_document_cufe_cude_cuds_vals(document_node, vals)
self._add_document_uuid_node(document_node, vals)
self._add_document_signature_vals(vals)
self._add_document_extensions_node(document_node, vals)
return document_node
def _add_invoice_header_nodes(self, document_node, vals):
super()._add_invoice_header_nodes(document_node, vals)
self._add_document_header_nodes(document_node, vals)
invoice = vals['invoice']
if vals['l10n_co_dian_identifier_type'] != 'cuds':
customization_id = invoice.l10n_co_edi_operation_type
else:
customization_id = '10' if vals['supplier'].country_code == 'CO' else '11'
document_node.update({
'cbc:CustomizationID': {'_text': customization_id},
'cbc:ProfileID': {'_text': invoice._l10n_co_edi_get_electronic_invoice_type_info()},
'cbc:InvoiceTypeCode': {'_text': self._dian_get_document_type_code(invoice)} if vals['document_type'] == 'invoice' else None,
'cbc:CreditNoteTypeCode': {'_text': self._dian_get_document_type_code(invoice)} if vals['document_type'] == 'credit_note' else None,
'cbc:IssueDate': {'_text': invoice.l10n_co_dian_post_time.date().isoformat()},
'cbc:IssueTime': {'_text': invoice.l10n_co_dian_post_time.strftime("%H:%M:%S-05:00")},
'cac:InvoicePeriod': {
'cbc:StartDate': {'_text': invoice.invoice_date},
'cbc:EndDate': {'_text': invoice.invoice_date},
} if invoice.l10n_co_edi_operation_type in ['22', '32'] else None,
'cac:DiscrepancyResponse': (
{
'cbc:ReferenceID': {'_text': invoice.reversed_entry_id.name},
'cbc:ResponseCode': {'_text': invoice.l10n_co_edi_description_code_credit},
'cbc:Description': {'_text': dict(invoice._fields['l10n_co_edi_description_code_credit'].selection).get(invoice.l10n_co_edi_description_code_credit)}
}
if invoice.l10n_co_edi_operation_type == '20' or invoice.move_type == 'in_refund'
else {
'cbc:ReferenceID': {'_text': invoice.debit_origin_id.name},
'cbc:ResponseCode': {'_text': invoice.l10n_co_edi_description_code_debit},
'cbc:Description': {'_text': dict(invoice._fields['l10n_co_edi_description_code_debit'].selection).get(invoice.l10n_co_edi_description_code_debit)}
}
if invoice.l10n_co_edi_operation_type == '30'
else None
),
'cac:BillingReference': self._get_billing_reference_node(invoice),
'cac:PrepaidPayment': [
{
'cbc:ID': {'_text': p['name']},
'cbc:PaidAmount': {
'_text': self.format_float(p['amount'], invoice.company_currency_id.decimal_places),
'currencyID': invoice.company_currency_id.name
},
'cbc:ReceivedDate': {'_text': p['date']},
}
for p in vals['prepayments']
] if vals['document_type'] != 'credit_note' else None,
})
document_node['cac:OrderReference']['cbc:SalesOrderID'] = None
if vals['document_type'] == 'debit_note':
document_node['cbc:BuyerReference']['_text'] = None
def _add_document_header_nodes(self, document_node, vals):
line_count_numeric = len([base_line for base_line in vals['base_lines'] if not base_line['special_mode'] and not base_line.get('is_tip')])
document_node.update({
'xsi:schemaLocation': {
'invoice': "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2 "
"http://docs.oasis-open.org/ubl/os-UBL-2.1/xsd/maindoc/UBL-Invoice-2.1.xsd",
'credit_note': "urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2 "
"http://docs.oasis-open.org/ubl/os-UBL-2.1/xsd/maindoc/UBL-CreditNote-2.1.xsd",
'debit_note': "urn:oasis:names:specification:ubl:schema:xsd:DebitNote-2 "
"http://docs.oasis-open.org/ubl/os-UBL-2.1/xsd/maindoc/UBL-DebitNote-2.1.xsd"
}[vals['document_type']],
'cbc:UBLVersionID': {'_text': 'UBL 2.1'},
'cbc:ProfileExecutionID': {'_text': '2' if vals['company'].l10n_co_dian_test_environment else '1'},
'cbc:UUID': {
'schemeID': '2' if vals['company'].l10n_co_dian_test_environment else '1',
'schemeName': {
'cude': 'CUDE-SHA384',
'cuds': 'CUDS-SHA384',
}.get(vals['l10n_co_dian_identifier_type'], 'CUFE-SHA384'),
},
'cbc:DocumentCurrencyCode': {
'_text': "COP",
'listAgencyID': "6",
'listAgencyName': "United Nations Economic Commission for Europe",
'listID': "ISO 4217 Alpha",
},
'cbc:LineCountNumeric': {'_text': line_count_numeric},
})
def _add_invoice_accounting_supplier_party_nodes(self, document_node, vals):
super()._add_invoice_accounting_supplier_party_nodes(document_node, vals)
document_node['cac:AccountingSupplierParty']['cbc:AdditionalAccountID'] = {
'_text': vals['supplier']._l10n_co_edi_get_partner_type()
}
def _add_invoice_accounting_customer_party_nodes(self, document_node, vals):
super()._add_invoice_accounting_customer_party_nodes(document_node, vals)
document_node['cac:AccountingCustomerParty']['cbc:AdditionalAccountID'] = {
'_text': vals['customer'].commercial_partner_id._l10n_co_edi_get_partner_type()
}
def _add_invoice_delivery_nodes(self, document_node, vals):
pass
def _add_invoice_payment_means_nodes(self, document_node, vals):
# OVERRIDE account.edi.xml.ubl_21
invoice = vals['invoice']
document_node['cac:PaymentMeans'] = {
'cbc:ID': {'_text': '1' if invoice.l10n_co_edi_is_direct_payment else '2'},
'cbc:PaymentMeansCode': {'_text': invoice.l10n_co_edi_payment_option_id.code},
'cbc:PaymentDueDate': {'_text': invoice.invoice_date_due},
'cbc:PaymentID': {'_text': invoice.payment_reference or invoice.name},
}
def _add_document_uuid_node(self, document_node, vals):
# Add CUFE/CUDE/CUDS
document_node['cbc:UUID']['_text'] = vals['uuid'] # as stated in the "Anexo Tecnico" file, SHA384 must be used
document_node['cbc:Note'] = [
document_node['cbc:Note'],
{'_text': vals['cufe_cude_cuds']}
]
def _add_invoice_payment_exchange_rate_node(self, document_node, vals):
invoice = vals['invoice']
if invoice.currency_id.name != "COP":
document_node['cac:PaymentExchangeRate'] = {
'cbc:SourceCurrencyCode': {'_text': "COP"},
'cbc:SourceCurrencyBaseRate': {'_text': (rate := self.format_float(1 / invoice.invoice_currency_rate, 6))},
'cbc:TargetCurrencyCode': {'_text': invoice.currency_id.name},
'cbc:TargetCurrencyBaseRate': {'_text': "1.00"},
'cbc:CalculationRate': {'_text': rate},
'cbc:Date': {'_text': invoice.invoice_date},
}
def _add_document_extensions_node(self, document_node, vals):
document_node['ext:UBLExtensions'] = self._get_document_extensions_node(vals)
def _get_address_node(self, vals):
partner = vals['partner']
return {
'cbc:ID': {'_text': str(partner.city_id.l10n_co_edi_code).zfill(5)}, # Codigo Municipio
'cbc:CityName': {'_text': partner.city},
'cbc:PostalZone': {'_text': partner.zip},
'cbc:CountrySubentity': {'_text': partner.state_id.name},
'cbc:CountrySubentityCode': {'_text': str(partner.state_id.l10n_co_edi_code).zfill(2)},
'cac:AddressLine': {
'cbc:Line': {'_text': ' '.join(partner[x] for x in ('street', 'street2') if partner[x])}
},
'cac:Country': {
'cbc:IdentificationCode': {'_text': partner.country_id.code},
'cbc:Name': {
'_text': COUNTRIES_ES.get(partner.country_code) if vals.get('use_es_country_name') else partner.country_id.name,
'languageID': 'es' if partner.country_code == 'CO' else 'en',
},
},
}
def _get_party_node(self, vals):
company = vals['company']
partner = vals['partner']
role = vals['role']
commercial_partner = partner.commercial_partner_id
if partner == company.partner_id.commercial_partner_id:
partner_display_name = partner_name = commercial_partner_name = company.root_id.partner_id.commercial_partner_id.display_name
else:
commercial_partner_name = commercial_partner.display_name
partner_display_name = partner.display_name
partner_name = partner.name
vat_without_verification_code = commercial_partner._get_vat_without_verification_code()
vat_verification_code = commercial_partner._get_vat_verification_code()
return {
'cbc:IndustryClassificationCode': {
'_text': vals['company'].l10n_co_edi_header_actividad_economica
} if role == 'supplier' and vals['l10n_co_dian_identifier_type'] != 'cuds' else None,
'cac:PartyIdentification': {
'cbc:ID': {
'_text': vat_without_verification_code,
'schemeName': (partner_code := commercial_partner._l10n_co_edi_get_carvajal_code_for_identification_type()),
# every Colombian NIT (code='rut') comprises a validation digit, it is mandatory to add it here
'schemeID': vat_verification_code if partner_code == '31' else None,
}
} if not commercial_partner.is_company else None,
'cac:PartyName': {
'cbc:Name': {
'_text': partner_display_name,
}
},
'cac:PhysicalLocation': {
'cac:Address': self._get_address_node({'partner': partner, 'use_es_country_name': True})
} if partner.vat != FINAL_CONSUMER_VAT else None,
'cac:PartyTaxScheme': {
'cbc:RegistrationName': {
'_text': commercial_partner_name,
},
'cbc:CompanyID': {
'_text': vat_without_verification_code,
'schemeName': commercial_partner._l10n_co_edi_get_carvajal_code_for_identification_type(),
'schemeAgencyName': "CO, DIAN (Dirección de Impuestos y Aduanas Nacionales)",
'schemeAgencyID': "195",
'schemeID': vat_verification_code
if partner._l10n_co_edi_get_carvajal_code_for_identification_type() == '31' else None,
},
'cbc:TaxLevelCode': {
'_text': ';'.join(commercial_partner.l10n_co_edi_obligation_type_ids.mapped('name'))
},
# 'Consumidor Final' is used in B2C, hence no address should be filled
'cac:RegistrationAddress': self._get_address_node({'partner': commercial_partner}) if commercial_partner.vat != FINAL_CONSUMER_VAT else None,
'cac:TaxScheme': {
'cbc:ID': {
'_text': commercial_partner._l10n_co_edi_get_fiscal_regimen_code()
},
'cbc:Name': {
'_text': 'No aplica' if (name := commercial_partner._l10n_co_edi_get_fiscal_regimen_name()) == 'No Aplica' else name
}
},
},
'cac:PartyLegalEntity': {
'cbc:RegistrationName': {
'_text': commercial_partner_name,
},
'cbc:CompanyID': {
'_text': vat_without_verification_code,
'schemeName': commercial_partner._l10n_co_edi_get_carvajal_code_for_identification_type(),
'schemeAgencyName': "CO, DIAN (Dirección de Impuestos y Aduanas Nacionales)",
'schemeAgencyID': "195",
'schemeID': vat_verification_code,
},
'cac:CorporateRegistrationScheme': {
'cbc:ID': {
'_text': vals['journal'].code
},
'cbc:Name': {
'_text': vals['company'].partner_id._get_vat_without_verification_code()
},
} if role == 'supplier' else None,
} if partner.vat != FINAL_CONSUMER_VAT else None,
'cac:Contact': {
'cbc:Name': {
'_text': partner_name,
},
'cbc:Telephone': {
'_text': partner.phone
},
'cbc:ElectronicMail': {
'_text': partner.email
},
} if partner.vat != FINAL_CONSUMER_VAT else None,
}
def _get_billing_reference_node(self, invoice):
"""Get the BillingReference node for credit/debit notes."""
reference_invoice = None
if invoice.l10n_co_edi_operation_type == '20' or invoice.move_type == 'in_refund':
reference_invoice = invoice.reversed_entry_id
scheme_name = "CUDS" if invoice.move_type == 'in_refund' else "CUFE"
elif invoice.l10n_co_edi_operation_type == '30':
reference_invoice = invoice.debit_origin_id
scheme_name = "CUFE"
if reference_invoice:
return {
'cac:InvoiceDocumentReference': {
'cbc:ID': {'_text': reference_invoice.name},
'cbc:UUID': {
'_text': reference_invoice.l10n_co_edi_cufe_cude_ref,
'schemeName': f"{scheme_name}-SHA384"
},
'cbc:IssueDate': {'_text': reference_invoice.invoice_date.isoformat()},
}
}
return None
# -------------------------------------------------------------------------
# EXPORT: Templates for invoice amount nodes
# -------------------------------------------------------------------------
def _add_document_tax_total_nodes(self, document_node, vals):
# We need multiple tax total nodes, one per l10n_co_edi_type.
base_lines_aggregated_tax_details = {}
aggregated_tax_details = {}
base_unit_measure_by_grouping_key = defaultdict(float)
def grouping_function(base_line, tax_data):
grouping_key = vals['tax_grouping_function'](base_line, tax_data)
if grouping_key is not None and tax_data['tax'].l10n_co_edi_type.code in ['22', '32', '34']:
# Handle INC Bolsas/ICL/IBUA taxes
if tax_data['tax'].l10n_co_edi_type.code == '22':
base_unit_measure_by_grouping_key[frozendict(grouping_key)] = tax_data['tax'].amount
else:
base_unit_measure_by_grouping_key[frozendict(grouping_key)] += (
base_line['product_id'].l10n_co_edi_ref_nominal_tax *
(base_line['quantity'] if tax_data['tax'].l10n_co_edi_type.code == '34' else 1)
)
return grouping_key
base_lines_aggregated_tax_details = self.env['account.tax']._aggregate_base_lines_tax_details(
vals['base_lines'],
grouping_function,
)
aggregated_tax_details = self.env['account.tax']._aggregate_base_lines_aggregated_values(
base_lines_aggregated_tax_details,
)
grouped_aggregated_tax_details_by_l10n_co_edi_type = {'tax': defaultdict(dict), 'withholding_tax': defaultdict(dict)}
for grouping_key, values in aggregated_tax_details.items():
if grouping_key:
l10n_co_edi_type = grouping_key['l10n_co_edi_type']
key = 'withholding_tax' if grouping_key['is_withholding_tax'] else 'tax'
grouped_aggregated_tax_details_by_l10n_co_edi_type[key][l10n_co_edi_type][grouping_key] = values
values['base_unit_measure'] = base_unit_measure_by_grouping_key[grouping_key]
document_node['cac:TaxTotal'] = [
self._get_tax_total_node({**vals, 'aggregated_tax_details': tax_details, 'role': 'document'})
for tax_details in grouped_aggregated_tax_details_by_l10n_co_edi_type['tax'].values()
]
if vals['document_type'] == 'invoice':
document_node['cac:WithholdingTaxTotal'] = [
self._get_tax_total_node({**vals, 'aggregated_tax_details': tax_details, 'role': 'document', 'sign': -1})
for tax_details in grouped_aggregated_tax_details_by_l10n_co_edi_type['withholding_tax'].values()
]
def _add_invoice_monetary_total_nodes(self, document_node, vals):
""" The validator will check that:
* LineExtensionAmount = sum(InvoiceLine/LineExtensionAmount)
* TaxExclusiveAmount = sum(InvoiceLine/TaxTotal/TaxSubtotal/TaxableAmount)
* TaxInclusiveAmount = LineExtensionAmount + sum(Invoice/TaxTotal/TaxAmount)
* ChargeTotalAmount = sum(Invoice/AllowanceCharge[ChargeIndicator='true'] [1]
* AllowanceTotalAmount = sum(Invoice/AllowanceCharge[ChargeIndicator='false'] [1]
* PrepaidAmount = sum(Invoice/PrepaidPayment/PaidAmount)
* PayableAmount = TaxInclusiveAmount - AllowanceTotalAmount + ChargeTotalAmount [2]
[1]: Will always be 0
[2]: PrepaidAmount is not used in the PayableAmount
[3]: Withholdings have no impact in any of these subtotals, they are optionals
"""
# The monetary total should not take into account withholding taxes.
super()._add_invoice_monetary_total_nodes(document_node, vals)
prepaid_amount = sum(p['amount'] for p in vals['prepayments'])
monetary_total_tag = self._get_tags_for_document_type(vals)['monetary_total']
monetary_total_node = document_node[monetary_total_tag]
monetary_total_node.update({
'cbc:PayableAmount': {
'_text': self.format_float(vals['tax_inclusive_amount'], vals['currency_dp']),
'currencyID': vals['currency_name'],
},
'cbc:PrepaidAmount': {
'_text': self.format_float(prepaid_amount, vals['currency_dp']),
'currencyID': vals['currency_name'],
} if prepaid_amount else None,
})
def _add_document_monetary_total_nodes(self, document_node, vals):
super()._add_document_monetary_total_nodes(document_node, vals)
monetary_total_tag = self._get_tags_for_document_type(vals)['monetary_total']
monetary_total_node = document_node[monetary_total_tag]
# TaxExclusiveAmount and TaxInclusiveAmount should not include Allowances or Charges
monetary_total_node.update({
'cbc:TaxExclusiveAmount': {
'_text': self.format_float(vals['total_lines'], vals['currency_dp']),
'currencyID': vals['currency_name'],
},
'cbc:TaxInclusiveAmount': {
'_text': self.format_float(vals['total_lines'] + vals['total_tax_amount'], vals['currency_dp']),
'currencyID': vals['currency_name'],
},
})
# -------------------------------------------------------------------------
# EXPORT: Templates for tax-related nodes
# -------------------------------------------------------------------------
def _get_tax_subtotal_node(self, vals):
tax_details = vals['tax_details']
grouping_key = vals['grouping_key']
if grouping_key['l10n_co_edi_type'].code not in ['22', '32', '34']:
tax_subtotal_node = super()._get_tax_subtotal_node(vals)
tax_subtotal_node['cbc:Percent'] = None
else:
# Special subtotals for INC Bolsas/ICL/IBUA taxes
tax_subtotal_node = {
'cbc:TaxAmount': {
'_text': self.format_float(tax_details['tax_amount'], vals['currency_dp']),
'currencyID': vals['currency_name'],
},
'cbc:BaseUnitMeasure': {
'_text': tax_details['base_unit_measure'],
'unitCode': {
'22': 'NIU',
'32': 'LTR',
'34': 'ML'
}.get(grouping_key['l10n_co_edi_type'].code),
},
'cbc:PerUnitAmount': {
'_text': self.format_float(grouping_key['amount'], 2),
'currencyID': vals['currency_name']
},
'cac:TaxCategory': self._get_tax_category_node(vals),
}
return tax_subtotal_node
def _get_tax_category_node(self, vals):
grouping_key = vals['grouping_key']
return {
# DIAN accepts up to 3 decimals for this node. But it also checks that the type of tax is consistent with the tax amount reported.
# For instance: '19.00' for an 'IVA' tax is allowed, but '19.000' is not
# (it raises: "FAS01b, Rechazo: Tributo IVA (01), INC (04) informado no coincide, revisar Porcentaje, Nombre y ID.").
# The majority of taxes have only 2 decimals, but some have 3 (and they should be reported with all their decimals).
'cbc:Percent': {
'_text': FloatFmt(abs(grouping_key['amount']), 2, 3) # withholding taxes are reported as positives
} if grouping_key['l10n_co_edi_type'].code not in {'22', '32', '34'}
else None, # Don't include Percent for INC Bolsas/ICL/IBUA taxes
'cac:TaxScheme': {
'cbc:ID': {
'_text': grouping_key['l10n_co_edi_type'].code,
},
'cbc:Name': {
'_text': 'No aplica' if grouping_key['l10n_co_edi_type'].name == 'No Aplica' else grouping_key['l10n_co_edi_type'].name
},
}
}
# -------------------------------------------------------------------------
# EXPORT: Templates for invoice line nodes
# -------------------------------------------------------------------------
def _add_document_line_amount_nodes(self, line_node, vals):
super()._add_document_line_amount_nodes(line_node, vals)
base_line = vals['base_line']
# Colombia follows a standard that very much resembles the UNSPSC
uom = base_line['product_uom_id'].l10n_co_edi_ubl or '94'
quantity_tag = self._get_tags_for_document_type(vals)['line_quantity']
line_node[quantity_tag]['unitCode'] = uom
def _add_invoice_line_note_nodes(self, line_node, vals):
invoice = vals['invoice']
base_line = vals['base_line']
if invoice.l10n_co_edi_operation_type == '09' and base_line['product_id']:
line_node['cbc:Note'] = {'_text': f"Contrato de servicios AIU por Concepto de: {base_line['product_id'].name}"}
def _add_invoice_line_period_nodes(self, line_node, vals):
super()._add_invoice_line_period_nodes(line_node, vals)
line = vals['base_line']['record']
# Support documents have a special InvoicePeriod node
if line.move_id.l10n_co_edi_is_support_document:
line_node['cac:InvoicePeriod'] = {
'cbc:StartDate': {'_text': line.move_id.invoice_date},
'cbc:DescriptionCode': {'_text': 1},
'cbc:Description': {'_text': 'Por operación'},
}
def _add_document_line_tax_total_nodes(self, line_node, vals):
# Colombian particularity: there should be one `TaxTotal` per colombian tax type, comprising 1 or more
# `TaxSubtotal` (1 per tax amount). The same applies for `WithholdingTaxTotal`.
base_unit_measure_by_grouping_key = defaultdict(float)
def grouping_function(base_line, tax_data):
grouping_key = vals['tax_grouping_function'](base_line, tax_data)
if grouping_key is not None and tax_data['tax'].l10n_co_edi_type.code in ['22', '32', '34']:
# - INC Bolsas (tax on plastic bags) is a tax based on the number of plastic bags used in the sale.
# It is always sent in NIUs (Number of Items) according to the specifications listed in the DIAN documentation.
# - ICL (tax on alcoholic beverages) is a tax based on the alcohol percentage in the bottle.
# It is always sent in LTRs according to the specifications listed in the DIAN documentation.
# - IBUA (tax on sugar beverages) is a tax based on the quantity of sugar per 100mL
# e.g. if the quantity of sugar per 100mL is > 10gr -> tax of 35$ per 100mL
# In Odoo, we have a field for the volume of the product : l10n_co_edi_ref_nominal_tax
if tax_data['tax'].l10n_co_edi_type.code == '22':
base_unit_measure_by_grouping_key[frozendict(grouping_key)] = tax_data['tax'].amount
else:
base_unit_measure_by_grouping_key[frozendict(grouping_key)] += (
base_line['product_id'].l10n_co_edi_ref_nominal_tax *
(base_line['quantity'] if tax_data['tax'].l10n_co_edi_type.code == '34' else 1)
)
return grouping_key
aggregated_tax_details = self.env['account.tax']._aggregate_base_line_tax_details(
vals['base_line'],
grouping_function,
)
grouped_aggregated_tax_details_by_l10n_co_edi_type = {'tax': defaultdict(dict), 'withholding_tax': defaultdict(dict)}
for grouping_key, values in aggregated_tax_details.items():
if grouping_key:
l10n_co_edi_type = grouping_key['l10n_co_edi_type']
key = 'withholding_tax' if grouping_key['is_withholding_tax'] else 'tax'
grouped_aggregated_tax_details_by_l10n_co_edi_type[key][l10n_co_edi_type][grouping_key] = values
values['base_unit_measure'] = base_unit_measure_by_grouping_key[grouping_key]
line_node['cac:TaxTotal'] = [
self._get_tax_total_node({**vals, 'aggregated_tax_details': tax_details, 'role': 'line'})
for tax_details in grouped_aggregated_tax_details_by_l10n_co_edi_type['tax'].values()
]
if vals['document_type'] == 'invoice':
line_node['cac:WithholdingTaxTotal'] = [
self._get_tax_total_node({**vals, 'aggregated_tax_details': tax_details, 'role': 'line', 'sign': -1})
for tax_details in grouped_aggregated_tax_details_by_l10n_co_edi_type['withholding_tax'].values()
]
def _add_document_line_item_nodes(self, line_node, vals):
super()._add_document_line_item_nodes(line_node, vals)
base_line = vals['base_line']
product = base_line['product_id']
if vals['l10n_co_edi_type'] == L10N_CO_EDI_TYPE['Export Invoice']:
line_node['cac:Item']['cbc:BrandName'] = {'_text': product.l10n_co_edi_brand}
line_node['cac:Item']['cbc:ModelName'] = {'_text': product.l10n_co_edi_customs_code}
l10n_co_product_code, product_code_scheme_id, product_code_scheme_name = self._l10n_co_edi_get_product_code(
product,
vals['l10n_co_edi_type'],
vals['document_type'],
vals['l10n_co_edi_is_support_document'],
)
line_node['cac:Item']['cac:SellersItemIdentification'] = {
'cbc:ID': {'_text': product.code if vals['l10n_co_dian_identifier_type'] != 'cuds' else l10n_co_product_code},
'cbc:ExtendedID': {'_text': l10n_co_product_code} if vals['l10n_co_dian_identifier_type'] == 'cuds' else None,
}
line_node['cac:Item']['cac:StandardItemIdentification'] = {
'cbc:ID': {
'_text': l10n_co_product_code,
'schemeID': product_code_scheme_id,
'schemeName': product_code_scheme_name,
}
}
def _l10n_co_edi_get_product_code(self, product, l10n_co_edi_type, document_type, is_support_document):
if product:
if l10n_co_edi_type == L10N_CO_EDI_TYPE['Export Invoice']:
if not product.l10n_co_edi_customs_code:
raise UserError(self.env._('Exportation invoices require custom code in all the products, please fill in this information before validating the invoice'))
return product.l10n_co_edi_customs_code, '020', 'Partida Alanceraria'
if (
document_type == "credit_note" and
is_support_document and
(code := product.default_code or product.barcode or product.unspsc_code_id.code)
):
return code, '999', 'Estándar de adopción del contribuyente'
elif product.barcode:
return product.barcode, '010', 'GTIN'
elif product.unspsc_code_id:
return product.unspsc_code_id.code, '001', 'UNSPSC'
elif product.default_code:
return product.default_code, '999', 'Estándar de adopción del contribuyente'
return '1010101', '001', ''
def _add_document_line_tax_category_nodes(self, line_node, vals):
# No InvoiceLine/Item/ClassifiedTaxCategory in Colombia
pass
def _get_line_fixed_tax_allowance_charge_nodes(self, vals):
# Fixed taxes (e.g. the IBUA sugar tax) should not be reported as AllowanceCharges.
return []
def _get_line_discount_allowance_charge_node(self, vals):
discount_node = super()._get_line_discount_allowance_charge_node(vals)
if discount_node:
discount_node['cbc:AllowanceChargeReasonCode'] = {'_text': '00'} # unconditional discount
discount_node['cbc:MultiplierFactorNumeric'] = {'_text': vals['base_line']['discount']}
discount_node['cbc:BaseAmount'] = {
'_text': self.format_float(vals['gross_subtotal'], vals['currency_dp']),
'currencyID': vals['currency_name'],
}
return discount_node
def _add_document_line_price_nodes(self, line_node, vals):
super()._add_document_line_price_nodes(line_node, vals)
base_line = vals['base_line']
# Colombia follows a standard that very much resembles the UNSPSC
uom = base_line['product_uom_id'].l10n_co_edi_ubl or '94'
line_node['cac:Price']['cbc:BaseQuantity'] = {
'_text': base_line['quantity'],
'unitCode': uom,
}
# -------------------------------------------------------------------------
# EXPORT: Constraints
# -------------------------------------------------------------------------
def _export_invoice_constraints(self, move, vals):
# EXTENDS account.edi.xml.ubl_21
constraints = super()._export_invoice_constraints(move, vals)
now = fields.Datetime.context_timestamp(self.with_context(tz='America/Bogota'), fields.Datetime.now()).date()
oldest_date = now - timedelta(days=6)
newest_date = now + timedelta(days=6)
if move.invoice_date and not (oldest_date <= move.invoice_date <= newest_date):
constraints['dian_date'] = self.env._("The issue date can not be older than 6 days or more than 6 days in the future.")
# required fields on invoice
if not move.l10n_co_dian_post_time:
constraints['l10n_co_dian_post_time'] = self.env._("A posted time is required to compute the CUFE/CUDE/CUDS.")
if not move.l10n_co_edi_type:
constraints['l10n_co_edi_type'] = self.env._("An Electronic Invoice Type must be selected before sending the invoice.")
# required fields on company
operation_mode = self._dian_get_operation_mode(move)
if not operation_mode:
constraints["dian_operation_modes"] = self.env._("No DIAN Operation Mode Matches")
else:
mandatory_fields = ['dian_software_id', 'dian_software_operation_mode', 'dian_software_security_code']
if move.company_id.l10n_co_dian_test_environment:
mandatory_fields.append('dian_testing_id')
for field in mandatory_fields:
constraints[field] = self._check_required_fields(operation_mode, field)
if move.l10n_co_dian_identifier_type in ('cude', 'cuds') and not operation_mode.dian_software_security_code:
constraints['l10n_co_dian_identifier_type'] = self.env._("The software PIN is required to compute the CUDE/CUDS.")
# required fields on journal
if move.move_type == 'out_invoice' and not move.journal_id.l10n_co_dian_technical_key and not move.company_id.l10n_co_dian_demo_mode:
constraints['l10n_co_dian_technical_key'] = self.env._("A technical key on the journal is required to compute the CUFE.")
for field in (['l10n_co_edi_dian_authorization_number', 'l10n_co_edi_dian_authorization_date',
'l10n_co_edi_dian_authorization_end_date', 'l10n_co_edi_min_range_number',
'l10n_co_edi_max_range_number'] + ['l10n_co_dian_technical_key'] if not move.company_id.l10n_co_dian_demo_mode else []):
constraints[f"dian_{field}"] = self._check_required_fields(move.journal_id, field)
# fields on partners
for role in ('customer', 'supplier'):
commercial_partner = vals[role].commercial_partner_id
constraints.update({
f"dian_vat_{role}": self._check_required_fields(commercial_partner, 'vat'),
f"dian_identification_type_{role}": self._check_required_fields(commercial_partner, 'l10n_latam_identification_type_id'),
f"dian_obligation_type_{role}": self._check_required_fields(commercial_partner, 'l10n_co_edi_obligation_type_ids'),
})
if commercial_partner.l10n_latam_identification_type_id.l10n_co_document_code != 'rut' and commercial_partner.vat and '-' in commercial_partner.vat:
constraints[f"dian_NIT_{role}"] = self.env._("The identification number of %s contains '-' but is not a NIT.", commercial_partner.name)
if vals[role].country_code == 'CO' and commercial_partner.vat != FINAL_CONSUMER_VAT:
constraints[f'dian_country_subentity_{role}'] = self._check_required_fields(vals[role], 'state_id')
constraints[f"dian_city_{role}"] = self._check_required_fields(vals[role], 'city_id')
# fields on lines
for line in move.invoice_line_ids.filtered(lambda l: l.display_type not in ('line_section', 'line_subsection', 'line_note')):
product = line.product_id
constraints[f"product_{product.id}"] = self._check_required_fields(
product, ['default_code', 'barcode', 'unspsc_code_id'])
if move.l10n_co_edi_type == L10N_CO_EDI_TYPE['Export Invoice'] and product:
if not product.l10n_co_edi_customs_code:
constraints['dian_export_product_code'] = self.env._("Every exportation product must have a customs code.")
if not product.l10n_co_edi_brand:
constraints['dian_export_product_brand'] = self.env._("Every exportation product must have a brand.")
if "IBUA" in line.tax_ids.l10n_co_edi_type.mapped('name') and product.l10n_co_edi_ref_nominal_tax == 0:
constraints['dian_sugar'] = self.env._(
"Volume in milliliters should be set on the %(field_description)s field for product: %(product_name)s when using IBUA taxes.",
field_description=product._fields['l10n_co_edi_ref_nominal_tax']._description_string(self.env),
product_name=product.name)
if "ICL" in line.tax_ids.l10n_co_edi_type.mapped('name') and product.l10n_co_edi_ref_nominal_tax == 0:
constraints['dian_alcohol'] = self.env._(
"Alcohol percentage should be set on the %(field_description)s field for product: %(product_name)s when using ICL taxes.",
field_description=product._fields['l10n_co_edi_ref_nominal_tax']._description_string(self.env),
product_name=product.name)
if not self._dian_uom_code(line):
constraints['dian_uom'] = self.env._("There is no Colombian code on the unit of measure: %s", line.product_uom_id.name)
if move.l10n_co_edi_is_support_document and move.currency_id.is_zero(line.price_unit):
constraints['dian_zero_lines'] = self.env._("Every lines should have non zero price units.")
if move.l10n_co_edi_operation_type == '20':
# Credit note with a referenced invoice
if not move.l10n_co_edi_description_code_credit:
constraints['dian_credit_note_missing_reason'] = self.env._("Please set a credit note reason as it is required for this type of transaction.")
if not move.reversed_entry_id:
constraints['dian_credit_note'] = self.env._("There is no invoice linked to this credit note but the operation type is '20'.")
elif not move.reversed_entry_id.l10n_co_edi_cufe_cude_ref:
constraints['dian_credit_note_cufe'] = self.env._("The invoice linked to this credit note has no CUFE.")
if move.move_type == 'in_refund':
# Support Document Credit Note
if not move.reversed_entry_id:
constraints['dian_credit_note'] = self.env._("There is no support document linked to this credit note.")
if not move.reversed_entry_id.l10n_co_edi_cufe_cude_ref:
constraints['dian_credit_note_cufe'] = self.env._("The support document linked to this credit note has no CUDS.")
if move.l10n_co_edi_operation_type == '30':
# Debit note with a referenced invoice
if not move.debit_origin_id:
constraints['dian_debit_note'] = self.env._("There is no original debited invoice but the operation type is '30'.")
elif not move.debit_origin_id.l10n_co_edi_cufe_cude_ref:
constraints['dian_debit_note_cufe'] = self.env._("The original debited invoice has no CUFE.")
if move.l10n_co_edi_operation_type in ('20', '22'):
constraints['dian_concepto_credit_note'] = self._check_required_fields(move, 'l10n_co_edi_description_code_credit')
if move.l10n_co_edi_debit_note:
constraints['dian_concepto_debit_note'] = self._check_required_fields(move, 'l10n_co_edi_description_code_debit')
if move.l10n_co_edi_operation_type == '09':
if set(move.line_ids.mapped('product_id.type')) != {'service'}:
constraints['dian_aiu_products'] = self.env._("All products in an AIU invoice should be a service.")
return constraints
# -------------------------------------------------------------------------
# EXPORT: Commercial Events
# -------------------------------------------------------------------------
def _export_co_send_event_update_status_invoice(self, invoice, next_commercial_state):
vals = {'invoice': invoice, 'l10n_co_dian_commercial_state_next': next_commercial_state}
document_node = self._get_co_invoice_event_update_status_node(vals)
document_nsmap = self._get_co_invoice_event_update_status_nsmap(vals)
xml_content = dict_to_xml(document_node, nsmap=document_nsmap)
self._dian_fill_signed_info_and_signature(xml_content, vals['x509_certificate'])
return etree.tostring(xml_content, encoding='UTF-8'), []
def _get_co_invoice_event_update_status_node(self, vals):
self._add_co_invoice_event_update_status_config_vals(vals)
document_node = {'_tag': 'ApplicationResponse'}
self._add_document_config_vals(vals)
self._add_document_signature_vals(vals)
self._add_document_extensions_node(document_node, vals)
self._add_co_invoice_event_update_status_header_nodes(document_node, vals)
self._add_co_invoice_event_update_status_note_nodes(document_node, vals)
self._add_co_invoice_event_update_status_sender_party_nodes(document_node, vals)
self._add_co_invoice_event_update_status_receiver_party_nodes(document_node, vals)
self._add_co_invoice_event_update_status_document_response_nodes(document_node, vals)
document_node['cbc:UUID']['_text'] = self._dian_calculate_cude_sha384(document_node, vals)
return document_node
def _add_co_invoice_event_update_status_config_vals(self, vals):
invoice = vals['invoice']
commercial_state_values = invoice._fields['l10n_co_dian_commercial_state'].get_values(self.env)
id_prefix = invoice.ref if invoice.is_purchase_document() else invoice.name
vals.update({
'now': datetime.now(tz=timezone('America/Bogota')),
'company': invoice.company_id,
'journal': invoice.journal_id,
'l10n_co_dian_identifier_type': invoice.l10n_co_dian_identifier_type,
'uuid': invoice.l10n_co_edi_cufe_cude_ref,
'sender_partner': invoice.company_id.partner_id,
'receiver_partner': invoice.partner_id,
'is_test_env': invoice.company_id.l10n_co_dian_test_environment,
'tax_types': invoice.line_ids.tax_ids.flatten_taxes_hierarchy().mapped('l10n_co_edi_type'),
'name': f'{id_prefix}{commercial_state_values.index(vals["l10n_co_dian_commercial_state_next"])}',
})
def _add_co_invoice_event_update_status_header_nodes(self, node, vals):
node.update({
'cbc:UBLVersionID': {'_text': 'UBL 2.1'},
'cbc:CustomizationID': {'_text': '1'},
'cbc:ProfileID': {'_text': 'DIAN 2.1: ApplicationResponse de la Factura Electrónica de Venta'},
'cbc:ProfileExecutionID': {'_text': '2' if vals['is_test_env'] else '1'},
'cbc:ID': {'_text': vals['name']},
'cbc:UUID': {
'schemeID': '2' if vals['is_test_env'] else '1',
'schemeName': 'CUDE-SHA384',
},
'cbc:IssueDate': {'_text': vals['now'].date().isoformat()},
'cbc:IssueTime': {'_text': vals['now'].strftime("%H:%M:%S-05:00")},
})
def _add_co_invoice_event_update_status_note_nodes(self, node, vals):
invoice = vals['invoice']
if invoice.move_type in ('out_invoice', 'out_refund') and vals['l10n_co_dian_commercial_state_next'] == 'accepted_by_issuer':
last_event_xml = etree.fromstring(invoice.l10n_co_dian_document_ids.sorted()[0].attachment_id.raw)
last_event_cufe = last_event_xml.findtext('./{*}UUID')
node.update({
'cbc:Note': {
'_text': f"""Manifiesto bajo la gravedad de juramento que transcurridos 3 días hábiles contados desde la creación del Recibo de bienes
y servicios {invoice.name} con CUDE {last_event_cufe}, el adquirente {invoice.partner_id.name} identificado con NIT {invoice.partner_id.vat} no manifestó expresamente
la aceptación o rechazo de la referida factura, ni reclamó en contra de su contenido.""",
}
})
def _add_co_invoice_event_update_status_sender_party_nodes(self, node, vals):
node['cac:SenderParty'] = {
'cac:PartyTaxScheme': self._get_co_invoice_event_update_status_party_tax_scheme_node({**vals, 'partner': vals['sender_partner'], 'role': 'sender'}),
}
def _add_co_invoice_event_update_status_receiver_party_nodes(self, node, vals):
receiver_partner = vals['receiver_partner']
node['cac:ReceiverParty'] = {
'cac:PartyTaxScheme': self._get_co_invoice_event_update_status_party_tax_scheme_node({**vals, 'partner': receiver_partner, 'role': 'receiver'}),
}
if vals['l10n_co_dian_commercial_state_next'] != 'accepted_by_issuer':
node['cac:ReceiverParty']['cac:Contact'] = {
'cbc:ElectronicMail': {'_text': receiver_partner.email},
}
def _get_co_invoice_event_update_status_party_tax_scheme_node(self, vals):
partner = vals['partner']
if vals['role'] == 'sender':
registration_name = partner.display_name
elif vals['l10n_co_dian_commercial_state_next'] != 'accepted_by_issuer':
registration_name = partner.name
else:
registration_name = "Unidad Administrativa Especial Dirección de Impuestos y Aduanas Nacionales"
tax_type = vals['tax_types'][0] if vals['tax_types'] else None
if vals['role'] == 'sender' and vals['l10n_co_dian_commercial_state_next'] == 'accepted_by_issuer':
partner_vat = '800197268' # VAT number of DIAN
else:
partner_vat = partner._get_vat_without_verification_code()
return {
'cbc:RegistrationName': {'_text': registration_name},
'cbc:CompanyID': {
'_text': partner_vat,
'schemeName': partner._l10n_co_edi_get_carvajal_code_for_identification_type(),
'schemeAgencyName': "CO, DIAN (Dirección de Impuestos y Aduanas Nacionales)",
'schemeAgencyID': "195",
'schemeID': partner._get_vat_verification_code(),
'schemeVersionID': "1",
},
'cac:TaxScheme': {
'cbc:ID': {'_text': tax_type.code if tax_type else None},
'cbc:Name': {'_text': tax_type.name if tax_type else None},
},
}
def _add_co_invoice_event_update_status_document_response_nodes(self, node, vals):
node['cac:DocumentResponse'] = {
'cac:Response': self._get_co_invoice_event_update_status_response_node(vals),
'cac:DocumentReference': self._get_co_invoice_event_update_status_document_reference_node(vals),
}
if vals['l10n_co_dian_commercial_state_next'] in ('received', 'goods_received'):
node['cac:DocumentResponse']['cac:IssuerParty'] = self._get_co_invoice_event_update_status_issuer_party_node(vals)
def _get_co_invoice_event_update_status_response_node(self, vals):
invoice = vals['invoice']
match vals['l10n_co_dian_commercial_state_next']:
case 'received':
description = "Acuse de recibo de Factura Electrónica de Venta"
case 'goods_received':
description = "Recibo del bien y/o prestación del servicio"
case 'claimed':
description = "Reclamo de la Factura Electrónica de Venta"
case 'accepted':
description = "Aceptación expresa"
case 'accepted_by_issuer':
description = "Aceptación Tácita"
case unknown_state:
_logger.warning(self.env._("Unknown commercial state %(state)s", state=unknown_state))
description = None
commercial_states = dict(invoice._fields['l10n_co_dian_commercial_state']._description_selection(self.env))
node = {
'cbc:ResponseCode': {
'_text': commercial_states[vals['l10n_co_dian_commercial_state_next']].split(' - ', 1)[0],
},
'cbc:Description': {'_text': description},
}
if vals['l10n_co_dian_commercial_state_next'] == 'claimed':
# DIAN expects the claim reason names in Spanish
claim_reasons = invoice._fields['l10n_co_dian_claim_reason']._description_selection(self.with_context(lang='es_419').env)
node['cbc:ResponseCode'].update({
'listID': invoice.l10n_co_dian_claim_reason,
'name': next(v for k, v in claim_reasons if k == invoice.l10n_co_dian_claim_reason),
})
return node
def _get_co_invoice_event_update_status_document_reference_node(self, vals):
invoice = vals['invoice']
return {
'cbc:ID': {'_text': invoice.name if invoice.move_type == 'out_invoice' else invoice.ref},
'cbc:UUID': {
'_text': invoice.l10n_co_edi_cufe_cude_ref,
'schemeName': 'CUFE-SHA384',
},
'cbc:DocumentTypeCode': {'_text': invoice.l10n_co_edi_type.zfill(2)}
}
def _get_co_invoice_event_update_status_issuer_party_node(self, vals):
partner = vals['invoice'].partner_id
node = {
'cac:Person': {
'cbc:ID': {
'_text': compact(partner.vat),
'schemeName': partner._l10n_co_edi_get_carvajal_code_for_identification_type(),
'schemeID': partner.vat,
},
'cbc:FirstName': {'_text': partner.name},
'cbc:FamilyName': {'_text': partner.name},
}
}
if partner.function:
node['cac:Person'].update({
'cbc:JobTitle': {'_text': partner.function},
'cbc:OrganizationDepartment': {'_text': partner.function},
})
return node
def _get_co_invoice_event_update_status_nsmap(self, vals):
return {
None: 'urn:oasis:names:specification:ubl:schema:xsd:ApplicationResponse-2',
'cac': 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2',
'cbc': 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2',
'ds': 'http://www.w3.org/2000/09/xmldsig#',
'ext': 'urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2',
'sts': 'dian:gov:co:facturaelectronica:Structures-2-1',
'xades': 'http://uri.etsi.org/01903/v1.3.2#',
'xades141': 'http://uri.etsi.org/01903/v1.4.1#',
'xsi': 'http://www.w3.org/2001/XMLSchema-instance',
}
# -------------------------------------------------------------------------
# Utils
# -------------------------------------------------------------------------
def _dian_uom_code(self, line):
_logger.debug('method is deprecated, use _dian_get_co_ubl_code instead')
return self._dian_get_co_ubl_code(line.product_uom_id)
@api.model
def _dian_get_co_ubl_code(self, uom):
""" Colombia follows a standard that very much resembles the UNSPSC """
return uom.l10n_co_edi_ubl or '94'
def _dian_tax_amount_repr(self, tax_amount):
""" Returns a string representation of a float amount to fill the 'TaxSubtotal/TaxCategory/Percent' node.
DIAN accepts up to 3 decimals for this node. But it also checks that the type of tax is consistent with the
tax amount reported.
For instance: '19.00' for an 'IVA' tax is allowed, but '19.000' is not (it raises: "FAS01b, Rechazo: Tributo
IVA (01), INC (04) informado no coincide, revisar Porcentaje, Nombre y ID.").
The majority of taxes have only 2 decimals, but some have 3 (and they should be reported with all their
decimals), hence this weird function.
"""
str_tax_amount = self.format_float(abs(tax_amount), 3) # withholding taxes are reported as positives
return str_tax_amount[:-1] if str_tax_amount.endswith('0') else str_tax_amount
def _dian_tax_totals(self, move, taxes_vals, withholding):
"""
Colombian particularity: there should be one `TaxTotal` per colombian tax type, comprising 1 or more
`TaxSubtotal` (1 per tax amount). The same applies for `WithholdingTaxTotal`.
:returns: [
{
'tax_amount': float,
'currency': res.currency,
'currency_dp': int,
'tax_subtotal_vals': [{
'tax_amount': float,
'taxable_amount': float,
'currency': res.currency,
'currency_dp': int,
'tax_category_vals': {},
}]
},
]
"""
def filter_tax_details(key):
if move.l10n_co_edi_is_support_document:
# For support document, only the taxes IVA (01), ReteICA (05), ReteRenta (06) should be included
return key['tax_co_ret'] == withholding and key['tax_co_type'] in ('01', '05', '06')
return key['tax_co_ret'] == withholding
currency = move.company_id.currency_id
tax_total_dict = defaultdict(lambda: {
'currency': currency,
'currency_dp': self._get_currency_decimal_places(currency),
'tax_amount': 0,
'per_unit_amount': 0,
'tax_subtotal_vals': [],
})
filtered_tax_details = {k: v for k, v in taxes_vals['tax_details'].items() if filter_tax_details(k)}
for grouping_key, vals in filtered_tax_details.items():
tax_co_type = grouping_key['tax_co_type']
tax_total_dict[tax_co_type]['tax_co_type'] = tax_co_type # not used in the xml, used to build the CUFE
tax_subtotal = {
'currency': currency,
'currency_dp': self._get_currency_decimal_places(currency),
'taxable_amount': vals['base_amount'],
'tax_amount': abs(vals['tax_amount']), # abs for withholding taxes
'tax_category_vals': {
'percent': self._dian_tax_amount_repr(float(vals['_tax_category_vals_']['percent'])),
'tax_scheme_vals': vals['_tax_category_vals_']['tax_scheme_vals'],
},
}
if tax_co_type == '22':
# INC Bolsas (tax on plastic bags) is a tax based on the number of plastic bags used in the sale.
# It is always sent in NIUs (Number of Items) according to the specifications listed in the DIAN documentation.
tax_subtotal.pop('taxable_amount')
tax_subtotal['base_unit_measure_attrs'] = {'unitCode': "NIU"}
if 'percent' in tax_subtotal['tax_category_vals']:
tax_subtotal['tax_category_vals'].pop('percent')
# Fixed rate per bag
tax_subtotal['base_unit_measure'] = float(vals['_tax_category_vals_']['percent'])
tax_subtotal['per_unit_amount'] = self.format_float(tax_subtotal['base_unit_measure'], 2)
if tax_co_type == '32':
# ICL (tax on alcoholic beverages) is a tax based on the alcohol percentage in the bottle.
# It is always sent in LTRs according to the specifications listed in the DIAN documentation.
tax_subtotal.pop('taxable_amount')
tax_subtotal['base_unit_measure_attrs'] = {'unitCode': 'LTR'}
if 'percent' in tax_subtotal['tax_category_vals']:
tax_subtotal['tax_category_vals'].pop('percent')
if 'tax_details_per_record' in taxes_vals:
tax_subtotal['base_unit_measure'] = sum(
base_line['product_id'].l10n_co_edi_ref_nominal_tax
for base_line, _taxes_data in vals['base_line_x_taxes_data']
)
else:
base_line = taxes_vals['base_line']
tax_subtotal['base_unit_measure'] = base_line['product_id'].l10n_co_edi_ref_nominal_tax
# Field validation happens after the exporting of values so we default to a sensible rate of 0 if no
# alcohol percent is set.
rate = 0
if tax_subtotal['base_unit_measure']:
rate = vals['tax_amount'] / tax_subtotal['base_unit_measure']
tax_subtotal['per_unit_amount'] = self.format_float(rate, 2)
if tax_co_type == '34':
# IBUA (tax on sugar beverages) is a tax based on the quantity of sugar per 100mL
# e.g. if the quantity of sugar per 100mL is > 10gr -> tax of 35$ per 100mL
# In Odoo, we use fixed taxes and a field for the volume of the product: l10n_co_edi_ref_nominal_tax
# Hence, we can infer the rate per 100mL of the tax
tax_subtotal.pop('taxable_amount')
tax_subtotal['base_unit_measure_attrs'] = {'unitCode': "ML"}
if 'percent' in tax_subtotal['tax_category_vals']:
tax_subtotal['tax_category_vals'].pop('percent')
# Total Volume in mL
if 'tax_details_per_record' in taxes_vals:
tax_subtotal['base_unit_measure'] = sum(
base_line['product_id'].l10n_co_edi_ref_nominal_tax * base_line['quantity']
for base_line, _taxes_data in vals['base_line_x_taxes_data']
)
else:
base_line = taxes_vals['base_line']
tax_subtotal['base_unit_measure'] = base_line['product_id'].l10n_co_edi_ref_nominal_tax * base_line['quantity']
# Infer the rate per 100mL
# Field validation happens after the exporting of values so we default to a sensible rate of 0 if
# no sugar contents are set.
rate = 0
if tax_subtotal['base_unit_measure']:
rate = vals['tax_amount'] * 100 / tax_subtotal['base_unit_measure']
tax_subtotal['per_unit_amount'] = self.format_float(rate, 2)
tax_total_dict[tax_co_type]['tax_amount'] += tax_subtotal['tax_amount'] # abs for withholding taxes
tax_total_dict[tax_co_type]['tax_subtotal_vals'].append(tax_subtotal)
if '05' in tax_total_dict and move.l10n_co_edi_is_support_document and withholding:
# Taxes with type '05' are retention taxes (15 %) that apply on the *tax amount* of a regular VAT tax
# Hence, the tax "15% RteVAT 19%" is encoded as a -2.85% tax in Odoo
if 'tax_details_per_record' in taxes_vals:
# On document level, backtrack the taxable amount based on the tax amount
for subtotal in tax_total_dict['05']['tax_subtotal_vals']:
subtotal['tax_category_vals']['percent'] = '15.00'
subtotal['taxable_amount'] = subtotal['tax_amount'] / 0.15
else:
# On invoice line, look at the sibling tax total node '01' and extract its exact tax amount
# DSAY05: the Taxable Amount for the taxes with type '05' should be equal to the Tax Amount
# on which the taxes with type '01' were applied
sibling_tax_totals = self._dian_tax_totals(move, taxes_vals, withholding=False)
tax_amount_01 = next((tot for tot in sibling_tax_totals if tot['tax_co_type'] == '01'), {'tax_amount': 0})['tax_amount']
for subtotal in tax_total_dict['05']['tax_subtotal_vals']:
subtotal['tax_category_vals']['percent'] = '15.00'
subtotal['taxable_amount'] = tax_amount_01
return [v for k, v in tax_total_dict.items()]
def _dian_get_identifier_vals(self, invoice, invoice_vals):
""" Returns the values used to compute the CUFE/CUDE/CUDS """
operation_mode = self._dian_get_operation_mode(invoice)
def format_float(amount, precision_digits=invoice_vals['vals']['currency_dp']):
return invoice_vals['format_float'](amount, precision_digits)
def get_filtered_tax_amount(co_tax_code):
""" Get the tax amount associated to a given colombian tax type code. """
return sum(ttvals['tax_amount'] for ttvals in invoice_vals['vals']['tax_total_vals'] if ttvals['tax_co_type'] == co_tax_code)
if invoice.l10n_co_dian_identifier_type in ('cude', 'cuds'):
key = operation_mode.dian_software_security_code
else:
key = invoice.journal_id.l10n_co_dian_technical_key
vals = {
'invoice_id': invoice_vals['vals']['id'],
'issue_date': invoice_vals['vals']['issue_date'],
'issue_time': invoice_vals['vals']['issue_time'], # invoice time (including tz)
'line_extension_amount': format_float(invoice_vals['vals']['monetary_total_vals']['line_extension_amount']),
'tax_code_01': '01',
'ValImp1': format_float(get_filtered_tax_amount('01')),
'tax_code_04': '04',
'ValImp2': format_float(get_filtered_tax_amount('04')),
'tax_code_03': '03',
'ValImp3': format_float(get_filtered_tax_amount('03')),
'ValTotFac': format_float(invoice_vals['vals']['monetary_total_vals']['payable_amount']),
'supplier_company_id': invoice_vals['vals']['accounting_supplier_party_vals']['party_vals']['party_tax_scheme_vals'][0]['company_id'],
'customer_company_id': invoice_vals['vals']['accounting_customer_party_vals']['party_vals']['party_tax_scheme_vals'][0]['company_id'],
'key': key or 'missing_key',
'profile_execution_id': invoice_vals['vals']['profile_execution_id'],
}
if invoice.l10n_co_edi_is_support_document:
[vals.pop(k) for k in ('tax_code_04', 'ValImp2', 'tax_code_03', 'ValImp3')]
return vals
def _dian_insert_corporate_registration_scheme_node(self, invoice, xml):
# TODO: Remove in master - this method is not called anywhere
# Create a CorporateRegistrationScheme node
root = etree.fromstring(xml)
nsmap = root.nsmap
corporate_node = etree.Element("{%s}CorporateRegistrationScheme" % nsmap.get('cac'), nsmap=nsmap)
id_node = etree.SubElement(corporate_node, "{%s}ID" % nsmap.get('cbc'), nsmap=nsmap)
id_node.text = invoice.journal_id.code
name_node = etree.SubElement(corporate_node, "{%s}Name" % nsmap.get('cbc'), nsmap=nsmap)
name_node.text = invoice.company_id.partner_id._get_vat_without_verification_code()
# Insert
legal_entity_node = root.find('.//{*}AccountingSupplierParty//{*}PartyLegalEntity')
if legal_entity_node is not None:
legal_entity_node.insert(2, corporate_node)
return etree.tostring(cleanup_xml_node(root))
def _dian_get_qr_code_url(self, invoice, identifier):
""" Returns the value used to fill the sts:DianExtensions/sts:QRCode node """
# TODO: Remove in master - this method is not called anywhere
if invoice.company_id.l10n_co_dian_test_environment:
url = 'https://catalogo-vpfe-hab.dian.gov.co/document/searchqr?documentkey='
else:
url = 'https://catalogo-vpfe.dian.gov.co/document/searchqr?documentkey='
return url + identifier
def _dian_get_security_code(self, operation_mode, document_number):
""" Returns the value for the 'SoftwareSecurityCode' node """
return sha384((
operation_mode.dian_software_id
+ operation_mode.dian_software_security_code
+ document_number
).encode()).hexdigest()
def _dian_get_document_type_code(self, invoice):
""" Returns the document type, used for the 'InvoiceTypeCode'/'CreditNoteTypeCode' node """
if not invoice.l10n_co_edi_is_support_document and invoice.l10n_co_edi_type:
return invoice.l10n_co_edi_type
elif invoice.move_type == 'in_refund':
return '95' # Nota de ajuste al documento soporte
else:
return '05' # Documento soporte
def _dian_get_customization_id(self, invoice):
""" Returns the value used for the 'CustomizationID' node """
# TODO: Remove in master - this method is not called anywhere
if not invoice.l10n_co_edi_is_support_document:
return invoice.l10n_co_edi_operation_type
return '10' if invoice.partner_id.country_code == 'CO' else '11'
def _dian_get_operation_mode(self, invoice):
"""Looks for the desired operation mode record based on the mode type"""
# use 'invoice' for invoices and vendor bills that are not support documents
mode = 'bill' if invoice.journal_id.l10n_co_edi_is_support_document else 'invoice'
return invoice.company_id.l10n_co_dian_operation_mode_ids.filtered(
lambda operation_mode: operation_mode.dian_software_operation_mode == mode
)
def _get_sts_namespace(self, vals):
if vals['document_type'] == 'debit_note':
return "http://www.dian.gov.co/contratos/facturaelectronica/v1/Structures"
else:
return "dian:gov:co:facturaelectronica:Structures-2-1"
@api.model
def _dian_calculate_cude_sha384(self, node, vals):
"""Compute the CUDE for a move
type: 'application_response'
see: Section 11.5 of Anexo-Tecnico-Factura-Electronica-de-Venta-vr-1-9
"""
cude_vals = {
'Num_DE': node['cbc:ID']['_text'],
'Fec_Emi': node['cbc:IssueDate']['_text'],
'Hor_Emi': node['cbc:IssueTime']['_text'],
'NitFe': node['cac:SenderParty']['cac:PartyTaxScheme']['cbc:CompanyID']['_text'],
'DocAdq': node['cac:ReceiverParty']['cac:PartyTaxScheme']['cbc:CompanyID']['_text'],
'ResponseCode': node['cac:DocumentResponse']['cac:Response']['cbc:ResponseCode']['_text'],
'ID': node['cac:DocumentResponse']['cac:DocumentReference']['cbc:ID']['_text'],
'DocumentTypeCode': node['cac:DocumentResponse']['cac:DocumentReference']['cbc:DocumentTypeCode']['_text'],
'Software-PIN': self._dian_get_operation_mode(vals['invoice']).dian_software_security_code,
}
cude = ''.join(str(value) for value in cude_vals.values())
return sha384(cude.encode()).hexdigest()
def _dian_sign_xml(self, xml, invoice):
# TODO: Remove in master - this method is not called anywhere
vals = {'invoice': invoice}
root = etree.fromstring(xml)
vals['uuid'] = root.findtext('./cbc:UUID', namespaces=root.nsmap)
self._add_invoice_config_vals(vals)
self._add_document_signature_vals(vals)
extensions_node = self._get_document_extensions_node(vals)
nsmap = self._get_document_extensions_nsmap(vals)
extensions = dict_to_xml(extensions_node, nsmap=nsmap, render_empty_nodes=True)
root.insert(0, extensions)
xml_utils._remove_tail_and_text_in_hierarchy(root)
self._dian_fill_signed_info_and_signature(root, vals['x509_certificate'])
return etree.tostring(root, encoding='UTF-8'), []
def _dian_fill_signed_info_and_signature(self, root, certificate):
# Hash and sign
xml_utils._reference_digests(root.find(".//ds:SignedInfo", {'ds': 'http://www.w3.org/2000/09/xmldsig#'}))
xml_utils._fill_signature(root.find(".//ds:Signature", {'ds': 'http://www.w3.org/2000/09/xmldsig#'}), certificate)
def _add_document_signature_vals(self, vals):
certificates_sudo = vals['company'].sudo().l10n_co_dian_certificate_ids
if not certificates_sudo:
raise UserError(self.env._("No DIAN certificate is configured on the company."))
if not vals['l10n_co_dian_operation_mode']:
raise UserError(self.env._("No DIAN Operation Mode Matches"))
if vals['company'].l10n_co_dian_test_environment:
qr_code_val = f'https://catalogo-vpfe-hab.dian.gov.co/document/searchqr?documentkey={vals["uuid"]}'
else:
qr_code_val = f'https://catalogo-vpfe.dian.gov.co/document/searchqr?documentkey={vals["uuid"]}'
vals['software_security_code'] = sha384((
vals['l10n_co_dian_operation_mode'].dian_software_id
+ vals['l10n_co_dian_operation_mode'].dian_software_security_code
+ vals['name']
).encode()).hexdigest()
vals.update({
'qr_code_val': qr_code_val,
'document_id': "xmldsig-" + str(xml_utils._uuid1()),
'key_info_id': "xmldsig-" + str(xml_utils._uuid1()) + "-keyinfo",
'x509_certificate': certificates_sudo[-1],
'x509_certificates': certificates_sudo,
'signature_value': 'to be filled later',
# Colombia time (UTC-5): p.556 "Anexo-Tecnico-Resolucion[...].pdf"
'signing_time': datetime.now(tz=timezone('America/Bogota')).isoformat(timespec='milliseconds'),
'claimed_role': "supplier",
})
def _get_document_extensions_nsmap(self, vals):
return {
'cbc': "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2",
'ds': "http://www.w3.org/2000/09/xmldsig#",
'ext': "urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2",
'sts': self._get_sts_namespace(vals),
'xades': "http://uri.etsi.org/01903/v1.3.2#",
}
def _get_document_extensions_node(self, vals):
return {
'_tag': 'ext:UBLExtensions',
'ext:UBLExtension': [
# First UBLExtension for DIAN details
{
'ext:ExtensionContent': {
'sts:DianExtensions': {
'sts:InvoiceControl': {
'sts:InvoiceAuthorization': {
'_text': vals['journal'].l10n_co_edi_dian_authorization_number
},
'sts:AuthorizationPeriod': {
'cbc:StartDate': {'_text': vals['journal'].l10n_co_edi_dian_authorization_date},
'cbc:EndDate': {'_text': vals['journal'].l10n_co_edi_dian_authorization_end_date},
},
'sts:AuthorizedInvoices': {
'sts:Prefix': {'_text': vals['journal'].code},
'sts:From': {'_text': vals['journal'].l10n_co_edi_min_range_number},
'sts:To': {'_text': vals['journal'].l10n_co_edi_max_range_number},
},
},
'sts:InvoiceSource': {
'cbc:IdentificationCode': {
'_text': 'CO',
'listAgencyID': '6',
'listAgencyName': 'United Nations Economic Commission for Europe',
'listSchemeURI': 'urn:oasis:names:specification:ubl:codelist:gc:CountryIdentificationCode-2.1',
}
},
'sts:SoftwareProvider': {
'sts:ProviderID': {
'_text': vals['company'].partner_id._get_vat_without_verification_code(),
'schemeAgencyID': '195',
'schemeAgencyName': 'CO, DIAN (Dirección de Impuestos y Aduanas Nacionales)',
'schemeID': vals['company'].partner_id._get_vat_verification_code(),
'schemeName': '31',
},
'sts:SoftwareID': {
'_text': vals['l10n_co_dian_operation_mode'].dian_software_id,
'schemeAgencyID': '195',
'schemeAgencyName': 'CO, DIAN (Dirección de Impuestos y Aduanas Nacionales)',
},
},
'sts:SoftwareSecurityCode': {
'_text': vals['software_security_code'],
'schemeAgencyID': '195',
'schemeAgencyName': 'CO, DIAN (Dirección de Impuestos y Aduanas Nacionales)',
},
'sts:AuthorizationProvider': {
'sts:AuthorizationProviderID': {
'_text': '800197268',
'schemeAgencyID': '195',
'schemeAgencyName': 'CO, DIAN (Dirección de Impuestos y Aduanas Nacionales)',
'schemeID': '4',
'schemeName': '31',
}
},
'sts:QRCode': {'_text': vals['qr_code_val']},
}
}
},
# Second UBLExtension for Signature
{
'ext:ExtensionContent': {
'ds:Signature': {
'Id': vals['document_id'],
'ds:SignedInfo': {
'ds:CanonicalizationMethod': {'Algorithm': 'http://www.w3.org/TR/2001/REC-xml-c14n-20010315'},
'ds:SignatureMethod': {'Algorithm': 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256'},
'ds:Reference': [
{
'Id': f'{vals["document_id"]}-ref0',
'URI': '',
'ds:Transforms': {
'ds:Transform': {'Algorithm': 'http://www.w3.org/2000/09/xmldsig#enveloped-signature'}
},
'ds:DigestMethod': {'Algorithm': 'http://www.w3.org/2001/04/xmlenc#sha256'},
'ds:DigestValue': {'_text': 'dummy'}, # This should be filled in later
},
{
'URI': f'#{vals["key_info_id"]}',
'ds:DigestMethod': {'Algorithm': 'http://www.w3.org/2001/04/xmlenc#sha256'},
'ds:DigestValue': {'_text': 'dummy'}, # This should be filled in later
},
{
'Type': 'http://uri.etsi.org/01903#SignedProperties',
'URI': f'#{vals["document_id"]}-signedprops',
'ds:DigestMethod': {'Algorithm': 'http://www.w3.org/2001/04/xmlenc#sha256'},
'ds:DigestValue': {'_text': 'dummy'}, # This should be filled in later
}
]
},
'ds:SignatureValue': {
'_text': vals['signature_value'],
'Id': f'{vals["document_id"]}-sigvalue',
},
'ds:KeyInfo': {
'Id': vals['key_info_id'],
'ds:X509Data': {
'ds:X509Certificate': {'_text': vals['x509_certificate']._get_der_certificate_bytes().decode()}
}
},
'ds:Object': {
'xades:QualifyingProperties': {
'Target': f'#{vals["document_id"]}',
'xades:SignedProperties': {
'Id': f'{vals["document_id"]}-signedprops',
'xades:SignedSignatureProperties': {
'xades:SigningTime': {'_text': vals['signing_time']},
'xades:SigningCertificate': {
'xades:Cert': [
{
'xades:CertDigest': {
'ds:DigestMethod': {'Algorithm': 'http://www.w3.org/2001/04/xmlenc#sha256'},
'ds:DigestValue': {'_text': vals['x509_certificate']._get_fingerprint_bytes(formatting='base64').decode()},
},
'xades:IssuerSerial': {
'ds:X509IssuerName': {'_text': cert._get_issuer_string()},
'ds:X509SerialNumber': {'_text': int(cert.serial_number)},
}
} for cert in vals['x509_certificates']
]
},
'xades:SignaturePolicyIdentifier': {
'xades:SignaturePolicyId': {
'xades:SigPolicyId': {
'xades:Identifier': {'_text': 'https:/facturaelectronica.dian.gov.co/politicadefirma/v2/politicadefirmav2.pdf'},
'xades:Description': {'_text': 'Política de firma para facturas electrónicas de la República de Colombia.'}
},
'xades:SigPolicyHash': {
'ds:DigestMethod': {'Algorithm': 'http://www.w3.org/2001/04/xmlenc#sha256'},
'ds:DigestValue': {'_text': 'dMoMvtcG5aIzgYo0tIsSQeVJBDnUnfSOfBpxXrmor0Y='},
}
}
},
'xades:SignerRole': {
'xades:ClaimedRoles': {
'xades:ClaimedRole': {'_text': vals['claimed_role']}
}
}
}
}
}
}
}
}
}
]
}
# -------------------------------------------------------------------------
# IMPORT
# -------------------------------------------------------------------------
def _import_fill_invoice(self, invoice, tree, qty_factor):
# OVERRIDE account.edi.xml.ubl_20
logs = super()._import_fill_invoice(invoice, tree, qty_factor)
cufe = self._find_value("./cbc:UUID[@schemeName='CUFE-SHA384']", tree)
l10n_co_edi_type = self._find_value("./cbc:InvoiceTypeCode", tree)
if cufe:
invoice.l10n_co_edi_cufe_cude_ref = cufe
if l10n_co_edi_type and l10n_co_edi_type in L10N_CO_EDI_TYPE.values():
invoice.l10n_co_edi_type = l10n_co_edi_type
if invoice.is_purchase_document():
self.env['l10n_co_dian.document']._create_document(
etree.tostring(tree, encoding='UTF-8'),
invoice,
'invoice_accepted',
attachment_name=f'dian_{invoice.move_type}_{invoice.ref}.xml',
commercial_state='pending',
message_json={'status': ''},
)
return logs
def _get_basis_qty(self, tree, xpath_dict):
""" OVERRIDE account.edi.common
In Colombia, the DIAN treats PriceAmount as the exact unit price,
so it must not be divided by BaseQuantity."""
return 1.0