# -*- 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]