# coding: utf-8 from odoo import _ from odoo.tools import html_escape import re import base64 import logging import zipfile import io import socket import requests from lxml import etree from datetime import datetime from hashlib import sha256 from odoo.tools.zeep import Client, Plugin from odoo.tools.zeep.exceptions import Fault from odoo.tools.zeep.wsse.username import UsernameToken _logger = logging.getLogger(__name__) # uncomment to enable logging of Zeep requests and responses # logging.getLogger('zeep.transports').setLevel(logging.DEBUG) class CarvajalPlugin(Plugin): def egress(self, envelope, http_headers, operation, binding_options): self.log(envelope, 'carvajal_request') return envelope, http_headers def ingress(self, envelope, http_headers, operation): self.log(envelope, 'carvajal_response') return envelope, http_headers def log(self, xml, func): _logger.debug('%s with\n%s' % (func, etree.tostring(xml, encoding='utf-8', xml_declaration=True, pretty_print=True))) class CarvajalUsernameToken(UsernameToken): def _create_password_digest(self): """Carvajal expects a password hashed with sha256 with the PasswordText type, together with a Nonce and Created element. To do so we can manually specify a password_digest (instead of password) to avoid the standard sha1 hashing and we can set use_digest=True to add the Nonce and Created. The only problem with this approach is that the password will have the PasswordDigest type, which Carvajal doesn't accept for some reason. This replaces it with PasswordText, which is commonly used for non-sha1 hashed passwords. """ res = super(CarvajalUsernameToken, self)._create_password_digest() res[0].attrib['Type'] = res[0].attrib['Type'].replace('PasswordDigest', 'PasswordText') return res class CarvajalRequest(): def __init__(self, move_type, company): l10n_co_edi_account = company.sudo().l10n_co_edi_account or '' if move_type in ('in_refund', 'in_invoice') and len(l10n_co_edi_account.split('_')) == 2: l10n_co_edi_account = l10n_co_edi_account.split('_')[0] + "_DS" + l10n_co_edi_account.split('_')[1] self.username = company.sudo().l10n_co_edi_username or '' self.password = company.sudo().l10n_co_edi_password or '' self.co_id_company = company.l10n_co_edi_company or '' self.account = l10n_co_edi_account self.test_mode = company.l10n_co_edi_test_mode self.wsdl = 'https://wscenf%s.cen.biz/isows/InvoiceService?wsdl' % ('lab' if self.test_mode else '') @property def client(self): if not hasattr(self, '_client'): token = self._create_wsse_header(self.username, self.password) self._client = Client(self.wsdl, plugins=[CarvajalPlugin()], wsse=token, operation_timeout=10) return self._client def _handle_exception(self, e): '''Handles an exception from Carvajal :returns: A dictionary. * error: The message of the error. * blocking_level: Info, warning, error. ''' _logger.error(e) if isinstance(e, socket.timeout): return {'error': _('Connection to Carvajal timed out.'), 'blocking_level': 'warning'} elif isinstance(e, requests.HTTPError) and 499 < e.response.status_code < 600: return {'error': _('Carvajal service not available.'), 'blocking_level': 'warning'} elif isinstance(e, Fault): return {'error': e.message} else: return {'error': ('Electronic invoice submission to Carvajal failed.'), 'blocking_level': 'warning'} def _create_wsse_header(self, username, password): created = datetime.now() token = CarvajalUsernameToken(username=username, password_digest=sha256(password.encode()).hexdigest(), use_digest=True, created=created) return token def upload(self, filename, xml): '''Upload an XML to carvajal. :returns: A dictionary. * message: Message from carvajal. * transactionId: The Carvajal ID of this request. * error: An eventual error. * blocking_level: Info, warning, error. ''' try: response = self.client.service.Upload(fileName=filename, fileData=base64.b64encode(xml).decode(), companyId=self.co_id_company, accountId=self.account) except Exception as e: return self._handle_exception(e) return { 'message': html_escape(response.status), 'transactionId': response.transactionId, } def _download(self, invoice): '''Downloads a ZIP containing an official XML and signed PDF document. This will only be available for invoices that have been successfully validated by Carvajal and the government. Method called by the user to download the response from the processing of the invoice by the DIAN and also get the CUFE signature out of that file. :returns: A dictionary. * file_name: The name of the signed XML. * content: The content of the signed XML. * attachments: The documents (xml and pdf) received by Carvajal. * l10n_co_edi_cufe_cude_ref: The CUFE unique ID of the signed XML. * error: An eventual error. * blocking_level: Info, warning, error. ''' carvajal_type = False if invoice.move_type == 'out_refund': carvajal_type = 'NC' elif invoice.move_type == 'out_invoice': if invoice.journal_id.l10n_co_edi_debit_note or invoice.l10n_co_edi_operation_type in ['30', '32', '33']: carvajal_type = 'ND' else: odoo_type_to_carvajal_type = { '1': 'FV', '2': 'FE', '3': 'FC', '4': 'FC', } carvajal_type = odoo_type_to_carvajal_type[invoice.l10n_co_edi_type] elif invoice.move_type == 'in_invoice': carvajal_type = 'DS' elif invoice.move_type == 'in_refund': carvajal_type = 'NS' prefix = invoice.sequence_prefix if invoice.move_type == 'out_invoice' and invoice.journal_id.l10n_co_edi_debit_note: prefix = 'ND' try: response = self.client.service.Download(documentPrefix=prefix, documentNumber=invoice.name, documentType=carvajal_type, resourceType='PDF,SIGNED_XML', companyId=self.co_id_company, accountId=self.account) except Exception as e: return self._handle_exception(e) else: filename = re.sub(r'[^\w\s-]', '', invoice.name.lower()) filename = '%s.zip' % re.sub(r'[-\s]+', '-', filename).strip('-_') data = base64.b64decode(response.downloadData) zip_ref = zipfile.ZipFile(io.BytesIO(data)) xml_filenames = [f for f in zip_ref.namelist() if f.endswith('.xml')] if xml_filenames: xml_file = zip_ref.read(xml_filenames[0]) content = etree.fromstring(xml_file) ref_elem = content.find(".//{*}UUID") return { 'filename': xml_filenames[0], 'xml_file': xml_file, 'attachments': [(filename, data)], 'message': _('The invoice was succesfully signed.
Message from Carvajal: %s', html_escape(response['status'])), 'l10n_co_edi_cufe_cude_ref': ref_elem.text, } return {'error': _('The invoice was accepted by Carvajal but unexpected response was received.'), 'blocking_level': 'warning'} def check_status(self, invoice): '''Checks the status of an already sent invoice, and if the invoice has been accepted, downloads the signed invoice. :returns: A dictionary. * file_name: The name of the signed XML. * content: The content of the signed XML. * attachments: The documents (xml and pdf) received by Carvajal. * l10n_co_edi_cufe_cude_ref: The CUFE unique ID of the signed XML. * message: The message from the government * error: An eventual error. * blocking_level: Info, warning, error. ''' try: response = self.client.service.DocumentStatus(transactionId=invoice.l10n_co_edi_transaction, companyId=self.co_id_company, accountId=self.account) except Exception as e: return self._handle_exception(e) processStatus = response.processStatus if hasattr(response, 'processStatus') else None processName = response.processName if hasattr(response, 'processName') else None legalStatus = response.legalStatus if hasattr(response, 'legalStatus') else None if processStatus == 'OK' and \ processName in ('PDF_CREATION', 'ISSUANCE_CHECK_DELIVERY', 'SEND_TO_RECEIVER', 'SEND_TO_SENDER', 'SEND_NOTIFICATION') and \ legalStatus == 'ACCEPTED': return self._download(invoice) elif processStatus == 'PROCESSING' or \ (processStatus == 'OK' and legalStatus != 'REJECTED') or (processStatus == 'FAIL' and legalStatus == 'RETRY'): return {'error': _('The invoice is still processing by Carvajal.'), 'blocking_level': 'info'} else: # legalStatus == 'REJECTED' or (processStatus == 'FAIL' and legalStatus != 'RETRY') if hasattr(response, 'errorMessage') and response['errorMessage']: errorMsg = ('Validation error from DIAN. Please refer to the Carvajal Platform for more details.' if response['errorMessage'] == 'DIAN_RESULT' else html_escape(response['errorMessage']).replace('\n', '
')) msg = _('The invoice was rejected by Carvajal: %s', errorMsg) else: msg = _('The invoice was rejected by Carvajal but no error message was received.') return {'error': msg, 'blocking_level': 'error'}