from base64 import b64encode, b64decode from datetime import datetime from lxml import etree from lxml.etree import CDATA from markupsafe import Markup from odoo import api, fields, models, modules from odoo.tools import html_escape, cleanup_xml_node, date_utils from odoo.addons.l10n_co_dian import xml_utils from odoo.exceptions import UserError EVENT_FILE_SEQUENCE_CODE = "l10n_co_dian_file_sequence_code" COMMERCIAL_STATE_SELECTION = [ ('pending', "Pending"), ('received', "030 - Received"), ('goods_received', "032 - Goods Received"), ('claimed', "031 - Claimed"), ('accepted', "033 - Accepted by Customer"), ('accepted_by_issuer', "034 - Accepted by Issuer"), ] class L10n_Co_DianDocument(models.Model): _name = 'l10n_co_dian.document' _description = "Colombian documents used for each interaction with the DIAN" _order = 'datetime DESC, id DESC' # Relational fields attachment_id = fields.Many2one(comodel_name='ir.attachment') move_id = fields.Many2one(comodel_name='account.move') # Business fields identifier = fields.Char(string="CUFE/CUDE/CUDS") zip_key = fields.Char( help="ID returned by the DIAN when sending a document with the certification process activated." ) # ID returned when calling SendTestSetAsync state = fields.Selection(selection=[ ('invoice_sending_failed', "Sending Failed"), # webservice is not reachable ('invoice_pending', "Pending"), # document was sent and the response is not yet known ('invoice_rejected', "Rejected"), ('invoice_accepted', "Accepted"), ]) message_json = fields.Json() message = fields.Html(compute="_compute_message") datetime = fields.Datetime() test_environment = fields.Boolean(help="Indicates whether the test endpoint was used to send this document") certification_process = fields.Boolean( help="Indicates whether we were in the certification process when sending this document", ) commercial_state = fields.Selection( selection=COMMERCIAL_STATE_SELECTION, string="Commercial Status" ) # Buttons show_button_get_status = fields.Boolean(compute="_compute_show_button_get_status") show_button_fetch_attached_document = fields.Boolean(compute='_compute_show_button_fetch_attached_document') @api.depends('zip_key', 'state', 'test_environment', 'certification_process') def _compute_show_button_get_status(self): for doc in self: doc.show_button_get_status = ( doc.zip_key and doc.state not in ('invoice_accepted', 'invoice_rejected') and doc.test_environment and doc.certification_process ) @api.depends('attachment_id', 'move_id.move_type') def _compute_show_button_fetch_attached_document(self): for doc in self: doc.show_button_fetch_attached_document = doc.attachment_id and doc.move_id.move_type not in ('in_invoice', 'in_refund') @api.depends('message_json') def _compute_message(self): for doc in self: msg = html_escape(doc.message_json.get('status', "")) if doc.message_json.get('errors'): msg += Markup("").format( errors=Markup().join( Markup("
  • %s
  • ") % error for error in doc.message_json['errors'] ), ) doc.message = msg def unlink(self): if self.attachment_id: self.attachment_id.unlink() return super().unlink() @api.model def _parse_errors(self, root): """ Returns a list containing the errors/warnings from a DIAN response """ return [node.text for node in root.findall(".//{*}ErrorMessage/{*}string")] @api.model def _build_message(self, root): msg = {'status': False, 'errors': []} fault = root.find('.//{*}Fault/{*}Reason/{*}Text') if fault is not None and fault.text: msg['status'] = fault.text + " (This might be caused by using incorrect certificates)" status = root.find('.//{*}StatusDescription') if status is not None and status.text: msg['status'] = status.text msg['errors'] = self._parse_errors(root) return msg @api.model def _create_document(self, xml, move, state, **kwargs): def resolve_doc_datetime(demo_mode, root, kwargs_dict): doc_datetime = fields.Datetime.context_timestamp(self.with_context(tz='America/Bogota'), fields.Datetime.now()).replace(tzinfo=None) if demo_mode: return doc_datetime if 'datetime' in kwargs_dict: return kwargs_dict.pop('datetime') if root is None: return doc_datetime return date_utils.to_timezone(None)(datetime.fromisoformat(root.find('.//{*}SigningTime').text)) def resolve_identifier(demo_mode, root, kwargs_dict): if demo_mode: return 'DEMO' if 'identifier' in kwargs_dict: return kwargs_dict.pop('identifier') if root is None: return '' return root.find('.//{*}UUID').text move.ensure_one() root = etree.fromstring(xml) if xml else None demo_mode = move.company_id.l10n_co_dian_demo_mode # pop attachment_name here so it is not passed to the document create function attachment_name = kwargs.pop('attachment_name', None) # create document doc = self.create([{ 'move_id': move.id, 'identifier': resolve_identifier(demo_mode, root, kwargs), 'state': state, 'datetime': resolve_doc_datetime(demo_mode, root, kwargs), 'test_environment': move.company_id.l10n_co_dian_test_environment, 'certification_process': move.company_id.l10n_co_dian_certification_process, **kwargs, }]) if state == 'invoice_accepted' and not doc.commercial_state: doc.commercial_state = 'pending' # create attachment if root is not None: doc.attachment_id = self.env['ir.attachment'].create([{ 'raw': xml, 'name': attachment_name or self.env['account.edi.xml.ubl_dian']._export_invoice_filename(move), 'res_id': doc.id if state != 'invoice_accepted' else move.id, 'res_model': doc._name if state != 'invoice_accepted' else move._name, }]) return doc @api.model def _document_already_processed(self, response_element): """ This function checks if a response contains a specific error that DIAN returns when a document has already been sent and processed. :param response_element: etree.Element """ is_valid = response_element.findtext('.//{*}IsValid') == 'true' response_status_code = response_element.findtext('.//{*}StatusCode') if not is_valid and response_status_code == '99': errors = response_element.findall(".//{*}ErrorMessage/{*}string") return len(errors) == 1 and errors[0].text == 'Regla: 90, Rechazo: Documento procesado anteriormente.' return False @api.model def _send_test_set_async(self, zipped_content, move): """ Send the document to the 'SendTestSetAsync' (asynchronous) webservice. NB: later, need to fetch the result by calling the 'GetStatusZip' webservice. """ operation_mode = self.env['account.edi.xml.ubl_dian']._dian_get_operation_mode(move) response = xml_utils._build_and_send_request( self, payload={ 'file_name': "invoice.zip", 'content_file': b64encode(zipped_content).decode(), 'test_set_id': operation_mode.dian_testing_id, 'soap_body_template': "l10n_co_dian.send_test_set_async", }, service="SendTestSetAsync", company=move.company_id, ) if not response['response']: return { 'state': 'invoice_sending_failed', 'message_json': {'status': self.env._("The DIAN server did not respond.")}, } root = etree.fromstring(response['response']) if response['status_code'] != 200: return { 'state': 'invoice_sending_failed', 'message_json': self._build_message(root), } zip_key = root.findtext('.//{*}ZipKey') if zip_key: return { 'state': 'invoice_pending', 'message_json': {'status': self.env._("Invoice is being processed by the DIAN.")}, 'zip_key': zip_key, } return { 'state': 'invoice_rejected', 'message_json': {'errors': [node.text for node in root.findall('.//{*}ProcessedMessage')]}, } @api.model def _send_bill_sync(self, zipped_content, move): """ Send the document to the 'SendBillSync' (synchronous) webservice. """ if move.company_id.l10n_co_dian_demo_mode: return { 'state': 'invoice_accepted', 'message_json': {'status': self.env._("Demo mode response")}, } response = xml_utils._build_and_send_request( self, payload={ 'file_name': "invoice.zip", 'content_file': b64encode(zipped_content).decode(), 'soap_body_template': "l10n_co_dian.send_bill_sync", }, service="SendBillSync", company=move.company_id, ) if not response['response']: return { 'state': 'invoice_sending_failed', 'message_json': {'status': self.env._("The DIAN server did not respond.")}, } root = etree.fromstring(response['response']) if response['status_code'] != 200: return { 'state': 'invoice_sending_failed', 'message_json': self._build_message(root), } is_valid = root.findtext('.//{*}IsValid') == 'true' document_vals = { 'state': 'invoice_accepted' if is_valid else 'invoice_rejected', 'message_json': self._build_message(root), } if self._document_already_processed(root) and (identifier := root.findtext('.//{*}XmlDocumentKey')): # We have to make sure that the identifier is the one associated to this document by fetching the XML from DIAN if xml := self._get_xml_by_document_key(identifier, move): xml_element = etree.fromstring(xml) xml_customer_name = xml_element.findtext('.//{*}AccountingCustomerParty/{*}Party/{*}PartyName/{*}Name') xml_issue_date = xml_element.findtext('./{*}IssueDate') xml_issue_time = xml_element.findtext('./{*}IssueTime') customer_name = move.partner_id.name issue_date = move.l10n_co_dian_post_time.date().isoformat() issue_time = move.l10n_co_dian_post_time.strftime("%H:%M:%S-05:00") # check that the customer name, the issue date and time from the XML on DIAN are the same than those of the move if xml_customer_name == customer_name and xml_issue_date == issue_date and xml_issue_time == issue_time: # Document has already been processed by DIAN -> correctly set the identifier and state so GetStatus is called correctly document_vals |= { 'state': 'invoice_accepted', 'identifier': identifier, } return document_vals @api.model def _send_event_update_status(self, zipped_content, move, next_commercial_state): if move.company_id.l10n_co_dian_demo_mode: return { 'state': 'invoice_accepted', 'commercial_state': next_commercial_state, 'message_json': {'status': self.env._("Demo mode response")}, }, dict() response = xml_utils._build_and_send_request( self, payload={ 'content_file': b64encode(zipped_content).decode(), 'soap_body_template': "l10n_co_dian.send_event_update_status", }, service="SendEventUpdateStatus", company=move.company_id, ) if not response['response']: return { 'state': 'invoice_sending_failed', 'message_json': {'status': self.env._("The DIAN server did not respond.")}, }, None root = etree.fromstring(response['response']) state = 'invoice_rejected' if response['status_code'] != 200: state = 'invoice_sending_failed' elif root.findtext('.//{*}IsValid') == 'true': state = 'invoice_accepted' document_vals = { 'state': state, 'commercial_state': next_commercial_state, 'message_json': self._build_message(root), } if self._document_already_processed(root): # DIAN rejected this call because it already accepted a call with the next commercial state # so we can safely force the state to accepted so the user can continue the commercial event flow document_vals['state'] = 'invoice_accepted' return document_vals, response @api.model def _get_status_event(self, company_id, track_id): response = xml_utils._build_and_send_request( self, payload={ 'track_id': track_id, 'soap_body_template': "l10n_co_dian.get_status_event", }, service="GetStatusEvent", company=company_id, ) if not response['response']: return { 'state': 'invoice_sending_failed', 'message_json': {'status': self.env._("The DIAN server did not respond.")}, }, None root = etree.fromstring(response['response']) state = 'invoice_rejected' if response['status_code'] != 200: state = 'invoice_sending_failed' elif root.findtext('.//{*}IsValid') == 'true': state = 'invoice_accepted' return { 'state': state, 'message_json': self._build_message(root), }, response def _get_status_zip(self): """ Fetch the status of a document sent to 'SendTestSetAsync' using the 'GetStatusZip' webservice. """ self.ensure_one() response = xml_utils._build_and_send_request( self, payload={ 'track_id': self.zip_key, 'soap_body_template': "l10n_co_dian.get_status_zip", }, service="GetStatusZip", company=self._get_company(), ) if response['status_code'] == 200: root = etree.fromstring(response['response']) self.message_json = self._build_message(root) if root.findtext('.//{*}IsValid') == 'true': self.state = 'invoice_accepted' elif not root.findtext('.//{*}StatusCode'): self.state = 'invoice_pending' else: self.state = 'invoice_rejected' elif response['status_code']: raise UserError(self.env._("The DIAN server returned an error (code %s)", response['status_code'])) else: raise UserError(self.env._("The DIAN server did not respond.")) def _get_status(self): return xml_utils._build_and_send_request( self, payload={ 'track_id': self.identifier, 'soap_body_template': "l10n_co_dian.get_status", }, service="GetStatus", company=self._get_company(), ) @api.model def _get_xml_by_document_key(self, identifier, move): """ Fetch the XML linked to the CUFE using the 'GetXmlByDocumentKey' webservice. """ # This check is required because the template is added in stable and it's possible # that it doesn't exist if the module has not been upgraded if self.env.ref('l10n_co_dian.get_xml_by_document_key', raise_if_not_found=False): response = xml_utils._build_and_send_request( self, payload={ 'track_id': identifier, 'soap_body_template': "l10n_co_dian.get_xml_by_document_key", }, service="GetXmlByDocumentKey", company=move.company_id, ) if response['status_code'] == 200: root = etree.fromstring(response['response']) response_code = root.findtext('.//{*}Code') # Code 100 means that the XML has been retrieved correctly if response_code == '100': return b64decode(root.findtext('.//{*}XmlBytesBase64')) return False def _get_attached_document_values(self, original_xml_etree, response_history): values = { 'profile_execution_id': original_xml_etree.findtext('./{*}ProfileExecutionID'), 'id': original_xml_etree.findtext('./{*}ID'), 'uuid': self[-1].identifier, 'uuid_attrs': { 'schemeName': self[-1]._get_identifier_type().upper() + "-SHA384", }, 'issue_date': original_xml_etree.findtext('./{*}IssueDate'), 'issue_time': original_xml_etree.findtext('./{*}IssueTime'), 'document_type': "Contenedor de Factura Electrónica", 'parent_document_id': original_xml_etree.findtext('./{*}ID'), 'parent_documents': [], } for idx, event_xml in enumerate(response_history, start=1): event_tree = etree.fromstring(event_xml) values['parent_documents'].append({ 'id': idx, 'uuid': self[-idx].identifier, 'uuid_attrs': { 'schemeName': self[-idx]._get_identifier_type().upper() + "-SHA384", }, 'document_id': event_tree.findtext('./{*}DocumentResponse/{*}DocumentReference/{*}ID'), 'issue_date': event_tree.findtext('./{*}IssueDate'), 'issue_time': event_tree.findtext('./{*}IssueTime'), 'response_code': event_tree.findtext('.//{*}Response/{*}ResponseCode'), 'validation_date': event_tree.findtext('./{*}IssueDate'), 'validation_time': event_tree.findtext('./{*}IssueTime'), }) return values def _demo_get_attached_document_values(self, original_xml_etree): # Demo mode version: use all values that do not require a DIAN response identifier_type = self[-1]._get_identifier_type().upper() return { 'profile_execution_id': original_xml_etree.findtext('./{*}ProfileExecutionID'), 'id': original_xml_etree.findtext('./{*}ID'), 'uuid': self[-1].identifier, 'uuid_attrs': { 'schemeName': f"{identifier_type}-SHA384", }, 'issue_date': original_xml_etree.findtext('./{*}IssueDate'), 'issue_time': original_xml_etree.findtext('./{*}IssueTime'), 'document_type': "Contenedor de Factura Electrónica", 'parent_document_id': original_xml_etree.findtext('./{*}ID'), 'parent_document': { 'id': original_xml_etree.findtext('./{*}ID'), 'uuid': self[-1].identifier, 'uuid_attrs': { 'schemeName': f"{identifier_type}-SHA384", }, 'document_id': original_xml_etree.findtext('./{*}ID'), 'issue_date': 'Demo', 'issue_time': 'Demo', 'response_code': 'Demo', 'validation_date': 'Demo', 'validation_time': 'Demo', }, } def _get_response_history(self, current_response=None): """ Return a tuple (history, error_msg) where: - history: is the responses of all the documents in 'self' - error_msg: is set when we get a non 200 status code """ if self.move_id.company_id.l10n_co_dian_demo_mode: return [etree.fromstring('')], "" if not current_response: # Should not enter this if statement when handling Commercial Events # call to GetStatus to get the ApplicationResponse current_response = self._get_status() if current_response['status_code'] != 200: return [], self.env._( "Error %(code)s when calling the DIAN server: %(response)s", code=current_response['status_code'], response=current_response['response'], ) current_response_etree = etree.fromstring(current_response['response']) current_response_raw = b64decode(current_response_etree.findtext(".//{*}XmlBase64Bytes")) history = [current_response_raw] # exclude the first and last documents: # last: its xml has already been added to the history in the above section # first: is a dummy document created by Odoo which represents the 'pending' state before # the commercial event flow has started, its xml should therefore not be added to the history for document in self.sorted()[1:-1]: # Unzip attachment -> return the event xml from the AttachedDocument (in the last ParentDocumentLineReference) attached_document = etree.fromstring(xml_utils._unzip(document.attachment_id.raw)) document_line_ref = attached_document.findall('./{*}ParentDocumentLineReference')[-1] document_event_xml = document_line_ref.findtext('.//{*}Description').encode() history.append(document_event_xml) return history, "" def _get_attached_document(self, status_response=None): """ Return a tuple: (the attached document xml, an error message) """ if self.move_id.l10n_co_dian_commercial_state == 'pending': # ensure_one for moves != ('in_invoice', 'in_refund') or when no event has been sent yet self.ensure_one() # all event xml's for every document in self in order response_history, error_msg = self._get_response_history(current_response=status_response) if error_msg: return "", error_msg current_attachment_raw = self.sorted()[:1].attachment_id.raw original_xml_etree = etree.fromstring(current_attachment_raw) if self[0]._get_company().l10n_co_dian_demo_mode: vals = self._demo_get_attached_document_values(original_xml_etree=original_xml_etree) else: vals = self._get_attached_document_values( original_xml_etree=original_xml_etree, response_history=response_history, ) attached_document = self.env['ir.qweb']._render('l10n_co_dian.attached_document', vals) attached_doc_etree = etree.fromstring(attached_document) # copy the Sender and Receiver from the original xml supplier_node = original_xml_etree.find('./{*}AccountingSupplierParty//{*}PartyTaxScheme') if supplier_node is None: supplier_node = original_xml_etree.find('./{*}SenderParty//{*}PartyTaxScheme') customer_node = original_xml_etree.find('./{*}AccountingCustomerParty//{*}PartyTaxScheme') if customer_node is None: customer_node = original_xml_etree.find('./{*}ReceiverParty//{*}PartyTaxScheme') attached_doc_etree.find('./{*}SenderParty').append(supplier_node) attached_doc_etree.find('./{*}ReceiverParty').append(customer_node) # Add the xmls (enclosed in CDATA) attached_doc_etree.find('./{*}Attachment/{*}ExternalReference/{*}Description').text = CDATA(current_attachment_raw.decode()) for idx, event_xml in enumerate(response_history, start=1): document_element = attached_doc_etree.find(f'./{{*}}ParentDocumentLineReference/{{*}}LineID[.="{idx}"]/..') if document_element is not None: # Roundtrip it through etree to remove the XML declaration (