# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from datetime import datetime from functools import partial from lxml import etree from markupsafe import Markup, escape from odoo import _, models from odoo.addons.l10n_ec_edi.models.account_move import L10N_EC_VAT_SUBTAXES from odoo.addons.l10n_ec_edi.models.ir_attachment import L10N_EC_XSD_INFOS from odoo.exceptions import UserError from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT as DTF from odoo.tools import float_compare, float_is_zero, float_repr, float_round, html_escape from odoo.tools.xml_utils import cleanup_xml_node, validate_xml_from_attachment from pytz import timezone from requests.exceptions import RequestException from odoo.tools.zeep import Client from odoo.tools.zeep.exceptions import Error as ZeepError TEST_URL = { 'reception': 'https://celcer.sri.gob.ec/comprobantes-electronicos-ws/RecepcionComprobantesOffline?wsdl', 'authorization': 'https://celcer.sri.gob.ec/comprobantes-electronicos-ws/AutorizacionComprobantesOffline?wsdl', } PRODUCTION_URL = { 'reception': 'https://cel.sri.gob.ec/comprobantes-electronicos-ws/RecepcionComprobantesOffline?wsdl', 'authorization': 'https://cel.sri.gob.ec/comprobantes-electronicos-ws/AutorizacionComprobantesOffline?wsdl', } DEFAULT_TIMEOUT_WS = 20 class AccountEdiFormat(models.Model): _inherit = 'account.edi.format' def _is_compatible_with_journal(self, journal): # EXTENDS account.edi.format # For Ecuador include the journals for sales invoices, purchase liquidations and purchase withholds if self.code != 'ecuadorian_edi': return super()._is_compatible_with_journal(journal) return journal.country_code == 'EC' and ((journal.type == 'sale' and journal.l10n_latam_use_documents) or (journal.type == 'general' and journal.l10n_ec_withhold_type == 'in_withhold') or (journal.type == 'purchase' and journal.l10n_ec_is_purchase_liquidation)) def _needs_web_services(self): # EXTENDS account.edi.format return self.code == 'ecuadorian_edi' or super(AccountEdiFormat, self)._needs_web_services() def _get_move_applicability(self, move): # EXTENDS account.edi.format self.ensure_one() if self.code != 'ecuadorian_edi' or move.country_code != 'EC': return super()._get_move_applicability(move) internal_type = move.l10n_latam_document_type_id.internal_type if move.move_type in ('out_invoice', 'out_refund') or internal_type == 'purchase_liquidation': return { 'post': self._post_invoice_edi, 'cancel': self._cancel_invoice_edi, 'edi_content': self._get_invoice_edi_content, } elif move.journal_id.l10n_ec_withhold_type == 'in_withhold': return { 'post': self._post_withhold_edi, 'cancel': self._cancel_withhold_edi, 'edi_content': self._get_withhold_edi_content, } def _check_move_configuration(self, move): # EXTENDS account.edi.format errors = super()._check_move_configuration(move) if self.code != 'ecuadorian_edi' or move.country_code != 'EC': return errors if (move.move_type in ('out_invoice', 'out_refund') or move.l10n_latam_document_type_id.internal_type == 'purchase_liquidation' or move.journal_id.l10n_ec_withhold_type == 'in_withhold'): journal = move.journal_id address = journal.l10n_ec_emission_address_id if not move.company_id.vat: errors.append(_("You must set a VAT number for company %s", move.company_id.display_name)) if not address: errors.append(_("You must set an emission address on journal %s", journal.display_name)) if address and not address.street: errors.append(_("You must set an address on contact %s, field Street must be filled", address.display_name)) if address and not address.commercial_partner_id.street: errors.append(_( "You must set a headquarter address on contact %s, field Street must be filled", address.commercial_partner_id.display_name )) if not move.commercial_partner_id.vat: errors.append(_("You must set a VAT number for partner %s", move.commercial_partner_id.display_name)) if not move.l10n_ec_sri_payment_id and move.move_type in ['out_invoice', 'in_invoice']: # needed for documents excluding credit notes errors.append(_("You must set the Payment Method SRI on document %s", move.display_name)) if not move.l10n_latam_document_number: errors.append(_("You must set the Document Number on document %s", move.display_name)) if move._l10n_ec_is_withholding(): for line in move.l10n_ec_withhold_line_ids: if not line.l10n_ec_withhold_invoice_id.l10n_ec_sri_payment_id: errors.append(_( "You must set the Payment Method SRI on document %s", line.l10n_ec_withhold_invoice_id.name )) if not line.l10n_ec_withhold_invoice_id: errors.append(_("Please use the wizard on the invoice to generate the withholding.")) code = move._l10n_ec_wth_map_tax_code(line) if not code: errors.append(_("Wrong tax (%s) for document %s", line.tax_ids[0].name, move.display_name)) else: unsupported_tax_types = set() vat_subtaxes = (lambda l: L10N_EC_VAT_SUBTAXES[l.tax_group_id.l10n_ec_type]) tax_groups = self.env['account.move']._l10n_ec_map_tax_groups for line in move.line_ids.filtered(lambda l: l.tax_group_id.l10n_ec_type): if not (vat_subtaxes(line) and tax_groups(line)): unsupported_tax_types.add(line.tax_group_id.l10n_ec_type) for tax_type in unsupported_tax_types: errors.append(_("Tax type not supported: %s", tax_type)) if not move.company_id.sudo().l10n_ec_edi_certificate_id and not move.company_id._l10n_ec_is_demo_environment(): errors.append(_("You must select a valid certificate in the settings for company %s", move.company_id.name)) if not move.company_id.l10n_ec_legal_name: errors.append(_("You must define a legal name in the settings for company %s", move.company_id.name)) if not move.commercial_partner_id.country_id: errors.append(_("You must set a Country for Partner: %s", move.commercial_partner_id.name)) if move.move_type == "out_refund" and not move.reversed_entry_id: errors.append(_( "Credit Note %s must have an original invoice related, try to 'Add Credit Note' from invoice", move.display_name )) if move.l10n_latam_document_type_id.internal_type == 'debit_note' and not move.debit_origin_id: errors.append(_( "Debit Note %s must have an original invoice related, try to 'Add Debit Note' from invoice", move.display_name )) return errors # ===== Post & Cancel methods ===== def _l10n_ec_post_move_edi(self, moves): res = {} for move in moves: xml_string, errors = self._l10n_ec_generate_xml(move) # Error management if errors: blocking_level = 'error' attachment = None else: errors, blocking_level, attachment = self._l10n_ec_send_xml_to_authorize(move, xml_string) res.update({ move: { 'success': not errors, 'error': '
'.join([html_escape(e) for e in errors]), 'attachment': attachment, 'blocking_level': blocking_level, }} ) return res def _post_withhold_edi(self, withholds): return self._l10n_ec_post_move_edi(withholds) def _post_invoice_edi(self, invoices): if self.code != 'ecuadorian_edi': return super(AccountEdiFormat, self)._post_invoice_edi(invoices) return self._l10n_ec_post_move_edi(invoices) def _l10n_ec_cancel_move_edi(self, moves): res = {} for move in moves: if not move.company_id.l10n_ec_production_env: # In test environment, act as if invoice had already been cancelled for the govt auth_num, auth_date, errors, warnings = False, False, [], [] move.with_context(no_new_invoice=True).message_post( body=escape( _( "{}This is a DEMO environment, for which SRI has no portal.{}" "For the purpose of testing all flows, we act as if the document had been cancelled for the government.{}" "In a production environment, you will first have to use the SRI portal to cancel the invoice.", ) ).format(Markup(''), Markup('
'), Markup('
')), ) else: _auth_state, auth_num, auth_date, errors, warnings = self._l10n_ec_get_authorization_status(move) if auth_num: errors.append( _("You cannot cancel a document that is still authorized (%s, %s), check the SRI portal", auth_num, auth_date) ) if not errors: move.l10n_ec_authorization_date = False # unset upon cancelling res[move] = { 'success': not errors, 'error': '
'.join([html_escape(e) for e in (errors or warnings)]), 'blocking_level': 'error' if errors else 'warning', } return res def _cancel_withhold_edi(self, withholds): return self._l10n_ec_cancel_move_edi(withholds) def _cancel_invoice_edi(self, invoices): if self.code != 'ecuadorian_edi': return super(AccountEdiFormat, self)._cancel_invoice_edi(invoices) return self._l10n_ec_cancel_move_edi(invoices) # ===== XML generation methods ===== def _get_invoice_edi_content(self, invoice): # EXTENDS account_edi if self.code != 'ecuadorian_edi': return super()._get_invoice_edi_content(invoice) return self._l10n_ec_generate_xml(invoice)[0].encode() def _get_withhold_edi_content(self, withhold): # EXTENDS account_edi return self._l10n_ec_generate_xml(withhold)[0].encode() def _l10n_ec_get_xml_common_values(self, move): internal_type = move.l10n_latam_document_type_id.internal_type return { 'move': move, 'sequential': move.name.split('-')[2].rjust(9, '0'), 'company': move.company_id, 'journal': move.journal_id, 'partner': move.commercial_partner_id, 'partner_sri_code': move.partner_id._get_sri_code_for_partner().value, 'is_cnote': internal_type == 'credit_note', 'is_dnote': internal_type == 'debit_note', 'is_liquidation': internal_type == 'purchase_liquidation', 'is_invoice': internal_type == 'invoice', 'is_withhold': move.journal_id.l10n_ec_withhold_type == 'in_withhold', 'format_num_2': self._l10n_ec_format_number, 'format_num_6': partial(self._l10n_ec_format_number, decimals=6), 'currency_round': move.company_currency_id.round, 'clean_str': self._l10n_ec_remove_newlines, 'strftime': partial(datetime.strftime, format='%d/%m/%Y'), } def l10n_ec_merge_negative_and_positive_line(self, negative_line_tax_data, tax_data, precision_digits): def merge_tax_datas(tax_data_to_add, tax_data_to_nullify): keys_to_merge = ['base_amount_currency', 'base_amount', 'tax_amount_currency', 'tax_amount'] for key in keys_to_merge: tax_data_to_add[key] += tax_data_to_nullify[key] tax_data_to_nullify[key] = 0.0 if tax_data['base_amount'] > abs(negative_line_tax_data['base_amount']): merge_tax_datas(tax_data, negative_line_tax_data) for tax in tax_data['tax_details']: merge_tax_datas(tax_data['tax_details'][tax], negative_line_tax_data['tax_details'][tax]) else: merge_tax_datas(negative_line_tax_data, tax_data) for tax in tax_data['tax_details']: merge_tax_datas(negative_line_tax_data['tax_details'][tax], tax_data['tax_details'][tax]) def _l10n_ec_dispatch_negative_line_into_discounts(self, negative_line_tax_data, positive_tax_details_sorted, precision_digits): def is_same_taxes(taxes_1, taxes_2): def tax_dict_to_tuple(tax_dict): return (tax_dict['code'], tax_dict['code_percentage'], tax_dict['rate'], tax_dict['tax_group_id']) return sorted(taxes_1, key=tax_dict_to_tuple) == sorted(taxes_2, key=tax_dict_to_tuple) for tax_data in positive_tax_details_sorted: if ( not float_is_zero(tax_data['base_amount'], precision_digits=precision_digits) and is_same_taxes(negative_line_tax_data['tax_details'].keys(), tax_data['tax_details'].keys()) ): self.l10n_ec_merge_negative_and_positive_line(negative_line_tax_data, tax_data, precision_digits) if float_is_zero(negative_line_tax_data['base_amount'], precision_digits=precision_digits): continue if not float_is_zero(negative_line_tax_data['base_amount'], precision_digits=precision_digits): return [_("Failed to dispatch negative lines into discounts.")] def _l10n_ec_remove_negative_lines_from_move_info(self, move_info): precision_digits = move_info['move'].company_id.currency_id.decimal_places tax_details_per_line = move_info['taxes_data']['tax_details_per_record'] negative_lines = [line for line, tax_data in tax_details_per_line.items() if float_compare(tax_data['base_amount'], 0.0, precision_digits=precision_digits) == -1] if not negative_lines: return [] # To remove in master: Check to see if the template is updated, as it's required to use the negative lines dispatching template_to_be_updated = self.env['ir.qweb']._load('l10n_ec_edi.common_details_info_template')[0] total_price_el = template_to_be_updated.find('.//detalles/detalle/precioTotalSinImpuesto') if total_price_el is None or total_price_el.get('t-out') != "format_num_2(abs(line_items[1]['base_amount']))": return [_("Please upgrade the \"Ecuadorian Accounting EDI\" module in order to process invoices with negative lines.")] negative_amount_total = sum(tax_details_per_line[line]['base_amount'] for line in negative_lines) move_info['discount_total'] += abs(negative_amount_total) positive_tax_details_sorted = sorted( [value for key, value in tax_details_per_line.items() if key not in negative_lines], key=lambda tax_data: tax_data['base_amount'], reverse=True ) for negative_line in negative_lines: error = self._l10n_ec_dispatch_negative_line_into_discounts(tax_details_per_line[negative_line], positive_tax_details_sorted, precision_digits) if error: return error tax_details_per_line.pop(negative_line) return [] def _l10n_ec_generate_xml(self, move): # Gather XML values move_info = self._l10n_ec_get_xml_common_values(move) if move.journal_id.l10n_ec_withhold_type: # withholds doc_type = 'withhold' template = 'l10n_ec_edi.withhold_template' move_info.update(move._l10n_ec_get_withhold_edi_data()) else: # invoices doc_type = move.l10n_latam_document_type_id.internal_type template = { 'credit_note': 'l10n_ec_edi.credit_note_template', 'debit_note': 'l10n_ec_edi.debit_note_template', 'invoice': 'l10n_ec_edi.invoice_template', 'purchase_liquidation': 'l10n_ec_edi.purchase_liquidation_template', }[doc_type] move_info.update(move._l10n_ec_get_invoice_edi_data()) # Generate XML document errors = [] if move_info.get('taxes_data'): errors += self._l10n_ec_remove_negative_lines_from_move_info(move_info) xml_content = self.env['ir.qweb']._render(template, move_info) xml_content = cleanup_xml_node(xml_content) errors += self._l10n_ec_validate_with_xsd(xml_content, doc_type) # Sign the document if move.company_id._l10n_ec_is_demo_environment(): # unless we're in a test environment without certificate xml_signed = etree.tostring(xml_content, encoding='unicode') else: xml_signed = move.company_id.sudo().l10n_ec_edi_certificate_id._action_sign(xml_content) xml_signed = '' + xml_signed return xml_signed, errors def _l10n_ec_generate_demo_xml_attachment(self, move, xml_string): """ Generates an xml attachment to simulate a response from the SRI without the need for a digital signature. """ move.l10n_ec_authorization_date = datetime.now(tz=timezone('America/Guayaquil')).date() attachment = self.env['ir.attachment'].create({ 'name': move.display_name + '_demo.xml', 'res_id': move.id, 'res_model': move._name, 'type': 'binary', 'raw': self._l10n_ec_create_authorization_file( move, xml_string, move.l10n_ec_authorization_number, move.l10n_ec_authorization_date), 'mimetype': 'application/xml', 'description': f"Ecuadorian electronic document generated for document {move.display_name}." }) move.with_context(no_new_invoice=True).message_post( body=escape( _( "{}This is a DEMO response, which means this document was not sent to the SRI.{}If you want your document to be processed by the SRI, please set an {}Electronic Certificate File{} in the settings.{}Demo electronic document.{}Authorization num:{}%s{}Authorization date:{}%s", move.l10n_ec_authorization_number, move.l10n_ec_authorization_date ) ).format(Markup(''), Markup('
'), Markup(''), Markup(''), Markup('

'), Markup('
'), Markup('
'), Markup('
'), Markup('
')), attachment_ids=attachment.ids, ) return [], "", attachment def _l10n_ec_send_xml_to_authorize(self, move, xml_string): # === DEMO ENVIRONMENT REPONSE === if move.company_id._l10n_ec_is_demo_environment(): return self._l10n_ec_generate_demo_xml_attachment(move, xml_string) # === STEP 1 === errors, warnings = [], [] if not move.l10n_ec_authorization_date: # Submit the generated XML response, zeep_errors, warnings = self._l10n_ec_get_client_service_response(move, 'reception', xml=xml_string.encode()) if zeep_errors: return zeep_errors, 'error', None try: response_state = response.estado response_checks = response.comprobantes and response.comprobantes.comprobante or [] except AttributeError as err: return warnings or [_("SRI response unexpected: %s", err)], 'warning' if warnings else 'error', None # Parse govt's response for errors or response state if response_state == 'DEVUELTA': for check in response_checks: for msg in check.mensajes.mensaje: if msg.identificador != '43': # 43 means Authorization number already registered errors.append(' - '.join( filter(None, [msg.identificador, msg.informacionAdicional, msg.mensaje, msg.tipo]) )) elif response_state != 'RECIBIDA': errors.append(_("SRI response state: %s", response_state)) # If any errors have been found (other than those indicating already-authorized document) if errors: return errors, 'error', None # === STEP 2 === # Get authorization status, store response & raise any errors attachment = False auth_state, auth_num, auth_date, auth_errors, auth_warnings = self._l10n_ec_get_authorization_status(move) errors.extend(auth_errors) warnings.extend(auth_warnings) if auth_num and auth_date: if move.l10n_ec_authorization_number != auth_num: warnings.append(_("Authorization number %s does not match document's %s", auth_num, move.l10n_ec_authorization_number)) move.l10n_ec_authorization_date = auth_date.replace(tzinfo=None) attachment = self.env['ir.attachment'].create({ 'name': move.display_name + '.xml', 'res_id': move.id, 'res_model': move._name, 'type': 'binary', 'raw': self._l10n_ec_create_authorization_file(move, xml_string, auth_num, auth_date), 'mimetype': 'application/xml', 'description': f"Ecuadorian electronic document generated for document {move.display_name}." }) move.with_context(no_new_invoice=True).message_post( body=escape( _( "Electronic document authorized.{}Authorization num:{}%s{}Authorization date:{}%s", move.l10n_ec_authorization_number, move.l10n_ec_authorization_date ) ).format(Markup('
'), Markup('
'), Markup('
'), Markup('
')), attachment_ids=attachment.ids, ) elif move.edi_state == 'to_cancel' and not move.company_id.l10n_ec_production_env: # In test environment, we act as if invoice had already been cancelled for the govt warnings.append(_("Document with access key %s has been cancelled", move.l10n_ec_authorization_number)) elif not auth_num and auth_state == 'EN PROCESO': # No authorization number means the invoice was no authorized yet warnings.append(_("Document with access key %s received by government and pending authorization", move.l10n_ec_authorization_number)) else: # SRI unexpected error errors.append(_("Document not authorized by SRI, please try again later")) return errors or warnings, 'error' if errors else 'warning', attachment def _l10n_ec_get_authorization_status(self, move): """ Government interaction: retrieves status of previously sent document. """ auth_state, auth_num, auth_date = None, None, None response, zeep_errors, zeep_warnings = self._l10n_ec_get_client_service_response( move, "authorization", claveAccesoComprobante=move.l10n_ec_authorization_number ) if zeep_errors: return auth_state, auth_num, auth_date, zeep_errors, zeep_warnings try: response_auth_list = response.autorizaciones and response.autorizaciones.autorizacion or [] except AttributeError as err: return auth_state, auth_num, auth_date, [_("SRI response unexpected: %s", err)], zeep_warnings errors = [] if not isinstance(response_auth_list, list): response_auth_list = [response_auth_list] for doc in response_auth_list: auth_state = doc.estado if doc.estado == "AUTORIZADO": auth_num = doc.numeroAutorizacion auth_date = doc.fechaAutorizacion else: messages = doc.mensajes if messages: messages_list = messages.mensaje if not isinstance(messages_list, list): messages_list = messages for msg in messages_list: errors.append(' - '.join( filter(None, [msg.identificador, msg.informacionAdicional, msg.mensaje, msg.tipo]) )) return auth_state, auth_num, auth_date, errors, zeep_warnings def _l10n_ec_get_client_service_response(self, move, mode, **kwargs): """ Government interaction: SOAP Transport and Client management. """ if move.company_id.l10n_ec_production_env: wsdl_url = PRODUCTION_URL.get(mode) else: wsdl_url = TEST_URL.get(mode) errors, warnings = [], [] response = None try: client = Client(wsdl=wsdl_url, timeout=DEFAULT_TIMEOUT_WS) if mode == "reception": response = client.service.validarComprobante(**kwargs) elif mode == "authorization": response = client.service.autorizacionComprobante(**kwargs) if not response: errors.append(_("No response received.")) except ZeepError as e: errors.append(_("The SRI service failed with the following error: %s", e)) except RequestException as e: warnings.append(_("The SRI service failed with the following message: %s", e)) return response, errors, warnings # ===== Helper methods ===== def _l10n_ec_create_authorization_file(self, move, xml_string, authorization_number, authorization_date): xml_values = { 'xml_file_content': Markup(xml_string[xml_string.find('?>') + 2:]), # remove header to embed sent xml 'mode': 'PRODUCCION' if move.company_id.l10n_ec_production_env else 'PRUEBAS', 'authorization_number': authorization_number, 'authorization_date': authorization_date.strftime(DTF), } xml_response = self.env['ir.qweb']._render('l10n_ec_edi.authorization_template', xml_values) xml_response = cleanup_xml_node(xml_response) return etree.tostring(xml_response, encoding='unicode') def _l10n_ec_validate_with_xsd(self, xml_doc, doc_type): try: xsd_name = L10N_EC_XSD_INFOS[doc_type]['name'] validate_xml_from_attachment(self.env, xml_doc, xsd_name, prefix='l10n_ec_edi') return [] except UserError as e: return [str(e)] def _l10n_ec_format_number(self, value, decimals=2): return float_repr(float_round(value, decimals), decimals) def _l10n_ec_remove_newlines(self, s, max_len=300): return s.replace('\n', '')[:max_len]