Files
enterprise-addons/l10n_co_dian/xml_utils.py
T

218 lines
9.7 KiB
Python

from lxml import etree
import requests
import io
import zipfile
from base64 import b64encode
from copy import deepcopy
from datetime import timedelta
import hashlib
import logging
import uuid
from odoo.exceptions import UserError, ValidationError
from odoo import fields, _
_logger = logging.getLogger(__name__)
NS_MAP = {'ds': "http://www.w3.org/2000/09/xmldsig#"}
TEST_ENDPOINT = "https://vpfe-hab.dian.gov.co/WcfDianCustomerServices.svc?wsdl"
ENDPOINT = "https://vpfe.dian.gov.co/WcfDianCustomerServices.svc?wsdl"
FORCED_PROD_SERVICES = {'GetAcquirer'}
def _canonicalize_node(node, **kwargs):
"""
Returns the canonical representation of node.
Specified in: https://www.w3.org/TR/2001/REC-xml-c14n-20010315
Required for computing digests and signatures.
Returns an UTF-8 encoded bytes string.
"""
return etree.tostring(node, method="c14n", with_comments=False, **kwargs)
def _get_uri(uri, reference, base_uri=""):
"""
Returns the content within `reference` that is identified by `uri`.
Canonicalization is used to convert node reference to an octet stream.
- URIs starting with # are same-document references
https://www.w3.org/TR/xmldsig-core/#sec-URI
- Empty URIs point to the whole document tree, without the signature
https://www.w3.org/TR/xmldsig-core/#sec-EnvelopedSignature
Returns an UTF-8 encoded bytes string.
"""
transform_nodes = reference.findall(".//{*}Transform")
# handle exclusive canonization
exc_c14n = bool(transform_nodes) and transform_nodes[0].attrib.get('Algorithm') == 'http://www.w3.org/2001/10/xml-exc-c14n#'
prefix_list = []
if exc_c14n:
inclusive_ns_node = transform_nodes[0].find(".//{*}InclusiveNamespaces")
if inclusive_ns_node is not None and inclusive_ns_node.attrib.get('PrefixList'):
prefix_list = inclusive_ns_node.attrib.get('PrefixList').split(' ')
node = deepcopy(reference.getroottree().getroot())
if uri == base_uri:
# Base URI: whole document, without signature (default is empty URI)
for signature in node.findall('.//ds:Signature', namespaces=NS_MAP):
if signature.tail:
# move the tail to the previous node or to the parent
if (previous := signature.getprevious()) is not None:
previous.tail = "".join([previous.tail or "", signature.tail or ""])
else:
signature.getparent().text = "".join([signature.getparent().text or "", signature.tail or ""])
signature.getparent().remove(signature) # we can only remove a node from its direct parent
return _canonicalize_node(node, exclusive=exc_c14n, inclusive_ns_prefixes=prefix_list)
if uri.startswith("#"):
path = "//*[@*[local-name() = '{}' ]=$uri]"
results = node.xpath(path.format("Id"), uri=uri.lstrip("#")) # case-sensitive 'Id'
if len(results) == 1:
return _canonicalize_node(results[0], exclusive=exc_c14n, inclusive_ns_prefixes=prefix_list)
if len(results) > 1:
raise UserError(
_("Ambiguous reference URI %(uri)s resolved to %(count)d nodes", uri=uri, count=len(results))
)
raise UserError(_("URI %(uri)s not found", uri=uri))
def _reference_digests(node, base_uri=""):
"""
Processes the references from node and computes their digest values as specified in
https://www.w3.org/TR/xmldsig-core/#sec-DigestMethod
https://www.w3.org/TR/xmldsig-core/#sec-DigestValue
"""
for reference in node.findall("ds:Reference", namespaces=NS_MAP):
ref_node = _get_uri(reference.get("URI", ""), reference, base_uri=base_uri)
lib = hashlib.new("sha256", ref_node)
reference.find("ds:DigestValue", namespaces=NS_MAP).text = b64encode(lib.digest())
def _fill_signature(node, certificate):
"""
Uses certificate to sign the SignedInfo sub-node of `node`, as specified in:
https://www.w3.org/TR/xmldsig-core/#sec-SignatureValue
https://www.w3.org/TR/xmldsig-core/#sec-SignedInfo
"""
signed_info_xml = node.find("ds:SignedInfo", namespaces=NS_MAP)
exc_c14n = signed_info_xml.find(".//{*}CanonicalizationMethod").attrib.get('Algorithm') == 'http://www.w3.org/2001/10/xml-exc-c14n#'
prefix_list = []
if exc_c14n:
inclusive_ns_node = signed_info_xml.find(".//{*}CanonicalizationMethod").find(".//{*}InclusiveNamespaces")
if inclusive_ns_node is not None and inclusive_ns_node.attrib.get('PrefixList'):
prefix_list = inclusive_ns_node.attrib.get('PrefixList').split(' ')
signature = certificate._sign(
_canonicalize_node(signed_info_xml, exclusive=exc_c14n, inclusive_ns_prefixes=prefix_list),
)
node.find("ds:SignatureValue", namespaces=NS_MAP).text = signature.decode()
def _remove_tail_and_text_in_hierarchy(node):
""" Recursively remove the tail of all nodes in hierarchy and remove the text of all non-leaf nodes. """
node.tail = None
if list(node):
node.text = None
for child in node:
_remove_tail_and_text_in_hierarchy(child)
def _uuid1():
return uuid.uuid1()
def _build_and_send_request(self, payload, service, company):
cert_sudo = company.sudo().l10n_co_dian_certificate_ids[-1:]
if not cert_sudo:
raise ValidationError(_('Certificate is not available'))
dt_now = fields.Datetime.now()
vals = {
'creation_time': dt_now.isoformat(timespec='milliseconds') + "Z",
'expiration_time': (dt_now + timedelta(seconds=60000)).isoformat(timespec='milliseconds') + "Z",
'binary_security_token_id': "X509-" + str(_uuid1()),
'binary_security_token': cert_sudo._get_der_certificate_bytes(formatting='base64').decode(),
'wsa_node_id': "id-" + str(_uuid1()),
'action': f"http://wcf.dian.colombia/IWcfDianCustomerServices/{service}",
**payload,
}
envelope = etree.fromstring(self.env['ir.qweb']._render('l10n_co_dian.soap_request_dian', vals))
_remove_tail_and_text_in_hierarchy(envelope)
# Hash and sign
_reference_digests(envelope.find(".//ds:SignedInfo", {'ds': 'http://www.w3.org/2000/09/xmldsig#'}))
_fill_signature(envelope.find(".//ds:Signature", {'ds': 'http://www.w3.org/2000/09/xmldsig#'}), cert_sudo)
# Send the request
try:
response = requests.post(
url=TEST_ENDPOINT if company.l10n_co_dian_test_environment and service not in FORCED_PROD_SERVICES else ENDPOINT,
data=etree.tostring(envelope),
timeout=3,
headers={"Content-Type": f'application/soap+xml;charset=UTF-8;action="http://wcf.dian.colombia/IWcfDianCustomerServices/{service}"'},
)
except requests.exceptions.ReadTimeout:
return {'response': '', 'status_code': ''}
if response.status_code != 200:
_logger.info("DIAN server returned code %s\n%s", response.status_code, response.text)
return {'response': response.text, 'status_code': response.status_code}
def _get_qr_code_value(root, currency, is_support_document=False):
""" Returns the value to be embedded inside the QR Code on the PDF report.
For Support Documents, see section 12.2 ('Anexo-Tecnico-Documento-Soporte[...].pdf').
Otherwise, see section 11.7 ('Anexo-Tecnico-[...]-1-9.pdf').
"""
nsmap = {k: v for k, v in root.nsmap.items() if k} # empty namespace prefix is not supported for XPaths
supplier_company_id = root.findtext('./cac:AccountingSupplierParty/cac:Party/cac:PartyTaxScheme/cbc:CompanyID', namespaces=nsmap)
customer_company_id = root.findtext('./cac:AccountingCustomerParty/cac:Party/cac:PartyTaxScheme/cbc:CompanyID', namespaces=nsmap)
line_extension_amount = root.findtext('./cac:LegalMonetaryTotal/cbc:LineExtensionAmount', namespaces=nsmap)
tax_amount_01 = currency.round(sum(float(x) for x in root.xpath('./cac:TaxTotal[.//cbc:ID/text()="01"]/cbc:TaxAmount/text()', namespaces=nsmap)))
payable_amount = root.findtext('./cac:LegalMonetaryTotal/cbc:PayableAmount', namespaces=nsmap)
identifier = root.findtext('./cbc:UUID', namespaces=nsmap)
qr_code = root.findtext('./ext:UBLExtensions/ext:UBLExtension/ext:ExtensionContent/sts:DianExtensions/sts:QRCode', namespaces=nsmap)
vals = {
'NumDS': root.findtext('./cbc:ID', namespaces=nsmap),
'FecFD': root.findtext('./cbc:IssueDate', namespaces=nsmap),
'HorDS': root.findtext('./cbc:IssueTime', namespaces=nsmap),
}
if is_support_document:
vals.update({
'NumSNO': supplier_company_id,
'DocABS': customer_company_id,
'ValDS': line_extension_amount,
'ValIva': tax_amount_01,
'ValTolDS': payable_amount,
'CUDS': identifier,
'QRCode': qr_code,
})
else:
vals.update({
'NitFac': supplier_company_id,
'DocAdq': customer_company_id,
'ValFac': line_extension_amount,
'ValIva': tax_amount_01,
'ValOtroIm': currency.round(sum(float(x) for x in root.xpath('./cac:TaxTotal[.//cbc:ID/text()!="01"]/cbc:TaxAmount/text()', namespaces=nsmap))),
'ValTolFac': payable_amount,
'CUFE': identifier,
'QRCode': qr_code,
})
return "\n".join(f"{k}: {v}" for k, v in vals.items())
def _zip_xml(filename, xml):
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, 'w', compression=zipfile.ZIP_DEFLATED) as zipfile_obj:
for att in [{'name': f'{filename}.xml', 'content': xml}]:
zipfile_obj.writestr(att['name'], att['content'])
return buffer.getvalue()
def _unzip(raw):
with io.BytesIO(raw) as buffer:
zipfile_obj = zipfile.ZipFile(buffer)
filename = zipfile_obj.namelist()[0] # there is only ever 1 file in the zipped file
return zipfile_obj.read(filename)