Files
enterprise-addons/l10n_cl_edi/models/l10n_cl_edi_util.py
T

538 lines
24 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import collections
import hashlib
import logging
import re
import requests
import textwrap
import urllib3
from functools import wraps
from lxml import etree
from markupsafe import Markup
from urllib3.exceptions import NewConnectionError
from requests.exceptions import ConnectionError, HTTPError
from odoo.tools import zeep
from odoo.tools.urls import urljoin as url_join
from odoo.tools.zeep.exceptions import TransportError
from odoo.tools.zeep import Client, Settings
from odoo import models, fields
from odoo.tools import _, LazyTranslate
_lt = LazyTranslate(__name__)
_logger = logging.getLogger(__name__)
MSG_ERROR = {
'token': _lt('Token cannot be generated. Please try again'),
'send': _lt('Sending DTE to SII failed due to: %s'),
'status': _lt('Ask for DTE status to SII failed due to: %s'),
}
REST_SERVER_URL = {
'SIITEST': {
'SEND': 'https://pangal.sii.cl/recursos/v1/boleta.electronica.envio',
'OTHER': 'https://apicert.sii.cl/recursos/v1/'
},
'SII': {
'SEND': 'https://rahue.sii.cl/recursos/v1/boleta.electronica.envio',
'OTHER': 'https://api.sii.cl/recursos/v1/'
},
'SIIDEMO': {
'SEND': 'https://pangal.sii.cl/recursos/v1/boleta.electronica.envio',
'OTHER': 'https://apicert.sii.cl/recursos/v1/'
},
}
SII_DETAIL_STATUS_RESULTS_REST = {
**dict.fromkeys(['DOK'], 'accepted'),
**dict.fromkeys(['RPR', 'RLV'], 'objected'),
**dict.fromkeys(['RCH'], 'rejected'),
}
TIMEOUT_REST = 5
TIMEOUT = 30 # default timeout for all remote operations
pool = urllib3.PoolManager(timeout=TIMEOUT)
NS_MAP = {'': 'http://www.w3.org/2000/09/xmldsig#'}
SERVER_URL = {
'SIITEST': 'https://maullin.sii.cl/DTEWS/',
'SII': 'https://palena.sii.cl/DTEWS/',
}
CLAIM_URL = {
'SIITEST': 'https://ws2.sii.cl/WSREGISTRORECLAMODTECERT/registroreclamodteservice',
'SII': 'https://ws1.sii.cl/WSREGISTRORECLAMODTE/registroreclamodteservice',
}
MAX_RETRIES = 20
def l10n_cl_edi_retry(max_retries=MAX_RETRIES, logger=None, custom_msg=None):
"""
This custom decorator allows to manage retries during connection request to SII.
This is needed because Zeep library cannot manage the parsing of HTML format responses
that sometimes are delivered by SII instead of XML format.
"""
def deco_retry(func):
@wraps(func)
def wrapper_retry(self, *args):
retries = max_retries
while retries > 0:
try:
return func(self, *args)
except (TransportError, NewConnectionError, HTTPError, ConnectionError) as error:
if custom_msg is not None:
logger.error(custom_msg)
if logger is not None:
logger.error(error)
retries -= 1
# DTE acceptation or claim returns a Fault error without status code but 'Error de Autentication:
# Token invalido' as message instead of return 200 with the invalid TOKEN status code in the response
except zeep.exceptions.Fault as error:
if error.message == 'Error de Autenticacion: TOKEN invalido':
raise InvalidToken
self._report_connection_err(error)
break
except Exception as error:
self._report_connection_err(error)
break
msg = _('- It was not possible to get a response after %s retries.', max_retries)
if custom_msg is not None:
msg = custom_msg + "<br/>" + msg
self._report_connection_err(msg)
return wrapper_retry
return deco_retry
class InvalidToken(Exception):
pass
class UnexpectedXMLResponse(Exception):
pass
class L10n_ClEdiUtil(models.AbstractModel):
_name = 'l10n_cl.edi.util'
_description = 'Utility Methods for Chilean Electronic Invoicing'
def _get_seed_rest(self, mode):
url = url_join(REST_SERVER_URL[mode]['OTHER'], 'boleta.electronica.semilla')
response = requests.get(url, timeout=TIMEOUT_REST)
if not response.ok:
return None
return etree.fromstring(response.content).findtext('*/SEMILLA')
def _get_token_rest(self, mode, digital_signature):
if mode == 'SIIDEMO':
return digital_signature.last_token
if digital_signature.last_rest_token:
return digital_signature.last_rest_token
seed = self._get_seed_rest(mode)
if not seed:
return self._connection_exception('exception', _('Not possible to get a seed'))
headers = {'Accept': 'application/xml', 'Content-Type': 'application/xml'}
url = url_join(REST_SERVER_URL[mode]['OTHER'], 'boleta.electronica.token')
signed_token = self._get_signed_token(digital_signature, seed)
response = requests.post(url, headers=headers, data=signed_token, timeout=TIMEOUT_REST)
if not response.ok:
return None
token = etree.fromstring(response.content).findtext('*/TOKEN')
digital_signature.last_rest_token = token
return token
def _send_xml_to_sii_rest(self, mode, company_vat, file_name, xml_message, digital_signature):
token = self._get_token_rest(mode, digital_signature)
if token is None:
self._report_connection_err(MSG_ERROR['token'])
return False
# The attributes must be ordered and the file (archivo key) must be added as data content, otherwise doesn't
# work
data = collections.OrderedDict()
data['rutSender'] = int(digital_signature['subject_serial_number'][:-2])
data['dvSender'] = digital_signature['subject_serial_number'][-1]
data['rutCompany'] = self._l10n_cl_format_vat(company_vat)[:-2]
data['dvCompany'] = self._l10n_cl_format_vat(company_vat)[-1]
data['archivo'] = (file_name, xml_message, 'text/xml')
(content, content_type) = urllib3.filepost.encode_multipart_formdata(data)
# The User-Agent parameter is mandatory. The service is very picky with it so it set as the
# same User-Agent used in the documentation, using other User-Agent like 'python-requests/2.21.0' or
# 'Odoo (http://www.odoo.com/contactus)' or 'Mozilla/4.0 ( compatible; Odoo %s )' % release.series
# causes a "Token not valid" response
# ref: https://www4c.sii.cl/bolcoreinternetui/api/
headers = {
'accept': 'application/json',
'Cookie': f'TOKEN={token}',
'User-Agent': 'Mozilla/4.0 ( compatible; PROG 1.0; Windows NT)',
'Content-Type': content_type,
}
if mode == 'SIIDEMO':
return None
try:
response = requests.post(REST_SERVER_URL[mode]['SEND'], headers=headers, data=content, timeout=TIMEOUT_REST)
except (requests.exceptions.Timeout, requests.exceptions.RequestException) as error:
self._report_connection_err(MSG_ERROR['send'] % error)
return None
if response.status_code == 401:
_logger.info('Token is not valid. Retrying send DTE to SII')
digital_signature.last_rest_token = False
return self._send_xml_to_sii_rest(mode, company_vat, file_name, xml_message, digital_signature)
try:
response.raise_for_status()
except requests.exceptions.HTTPError as error:
self._report_connection_err(MSG_ERROR['send'] % error)
return None
return response.json()
def _get_send_status_rest(self, mode, track_id, company_vat, digital_signature):
token = self._get_token_rest(mode, digital_signature)
if token is None:
self._report_connection_err(MSG_ERROR['token'])
return False
url = url_join(REST_SERVER_URL[mode]['OTHER'], 'boleta.electronica.envio/%s-%s-%s' % (company_vat[:-2], company_vat[-1], track_id))
if mode == 'SIIDEMO':
return None
try:
response = requests.get(url, headers={'Cookie': f'TOKEN={token}'}, timeout=TIMEOUT_REST)
except (requests.exceptions.Timeout, requests.exceptions.RequestException) as error:
self._report_connection_err(MSG_ERROR['status'] % error)
return None
if response.status_code == 401:
_logger.info('Token is not valid. Retrying verify DTE status')
digital_signature.last_rest_token = False
return self._get_send_status_rest(mode, track_id, company_vat, digital_signature)
try:
response.raise_for_status()
except requests.exceptions.HTTPError as error:
self._report_connection_err(MSG_ERROR['status'] % error)
return None
return response.json()
def _analyze_sii_result_rest(self, message):
detail_rep_rech = message.get('detalle_rep_rech', [])
if detail_rep_rech is None:
return 'ask_for_status'
for doc in detail_rep_rech:
detail_status = SII_DETAIL_STATUS_RESULTS_REST.get(doc['estado'])
if doc['folio'] is None and detail_status == 'rejected':
return 'rejected'
matching_folio = doc.get('folio') == int(self.l10n_latam_document_number)
matching_tipo = doc.get('tipo') == int(self.l10n_latam_document_type_id.code)
if detail_status is not None and matching_folio and matching_tipo:
return detail_status
# The message may or may not include data into the 'detalle_rep_rech' section so the number of the DTE
# reported, objected and accepted will be verified from the 'estadistica' section. For now the message will
# only include a DTE.
for summary in message.get('estadistica', []):
if summary['tipo'] != int(self.l10n_latam_document_type_id.code):
continue
if summary['aceptados'] == summary['informados']:
return 'accepted'
if summary['reparos'] >= 1:
return 'objected'
return 'ask_for_status'
def _format_length(self, text, text_len):
return text and text[:text_len] or ''
def _format_uom(self, uom):
if not uom:
return ''
xml_id = uom.get_metadata()[0]['xmlid']
return {
'uom.product_uom_unit': 'U',
'uom.product_uom_dozen': 'DOC',
'uom.product_uom_meter': 'MT',
'uom.product_uom_foot': 'P2',
'uom.product_uom_kgm': 'KN',
'uom.product_uom_litre': 'LT',
'uom.product_uom_gram': 'GN',
}.get(xml_id, uom.name[:4])
def _get_cl_current_datetime(self):
""" Get the current datetime with the Chilean timezone. """
return fields.Datetime.context_timestamp(
self.with_context(tz='America/Santiago'), fields.Datetime.now())
def _get_cl_current_strftime(self, date_format='%Y-%m-%dT%H:%M:%S'):
return self._get_cl_current_datetime().strftime(date_format)
def _l10n_cl_append_sig(self, xml_type, sign, message):
tag_to_replace = {
'doc': Markup('</DTE>'),
'bol': Markup('</EnvioBOLETA>'),
'env': Markup('</EnvioDTE>'),
'recep': Markup('</Recibo>'),
'env_recep': Markup('</EnvioRecibos>'),
'env_resp': Markup('</RespuestaDTE>'),
'consu': Markup('</ConsumoFolios>'),
'token': Markup('</getToken>')
}
tag = tag_to_replace.get(xml_type, Markup('</EnvioBOLETA>'))
return message.replace(tag, sign + tag)
def _l10n_cl_format_vat(self, value, with_zero=False):
if not value or value in ['', 0]:
value = '666666666'
# argument is vat
if '-' not in value:
rut = value[:8] + '-' + value[8:]
else:
rut = value
if not with_zero:
if rut[0] == '0':
rut = rut[1:]
# Argument is other
return rut.replace('.', '')
def _get_sha1_digest(self, data):
return hashlib.new('sha1', data).digest()
def _analyze_sii_result(self, xml_message):
"""
Returns the status of the DTE from the sii_message. The status could be:
- ask_for_status
- accepted
- rejected
"""
result_dict = {
'ask_for_status': ['SDK', 'CRT', 'PDR', '-11', 'SOK'],
'rejected': ['-3', 'PRD', 'RCH', 'RFR', 'RSC', 'RCT', '2', '106', 'DNK', 'RLV', '05'],
}
status = xml_message.find('{http://www.sii.cl/XMLSchema}RESP_HDR/ESTADO')
for key, values in result_dict.items():
if status is not None and status.text in values:
return key
reject = xml_message.findtext('{http://www.sii.cl/XMLSchema}RESP_BODY/RECHAZADOS')
if reject and int(reject) >= 1:
return 'rejected'
accepted = xml_message.findtext('{http://www.sii.cl/XMLSchema}RESP_BODY/ACEPTADOS')
informed = xml_message.findtext('{http://www.sii.cl/XMLSchema}RESP_BODY/INFORMADOS')
objected = xml_message.findtext('{http://www.sii.cl/XMLSchema}RESP_BODY/REPAROS')
if accepted is not None and informed is not None and accepted == informed:
return 'accepted'
if objected and int(objected) >= 1:
return 'objected'
raise UnexpectedXMLResponse()
def _sign_full_xml(
self, message, digital_signature, uri, xml_type, is_doc_type_voucher=False, without_xml_declaration=False):
"""
Signed the xml following the SII documentation:
http://www.sii.cl/factura_electronica/factura_mercado/instructivo_emision.pdf
"""
digest_value = Markup(re.sub(r'\n\s*$', '', message, flags=re.MULTILINE))
digest_value_tree = etree.tostring(etree.fromstring(digest_value)[0])
if xml_type in ['doc', 'recep', 'token']:
signed_info_template = 'l10n_cl_edi.signed_info_template'
else:
signed_info_template = 'l10n_cl_edi.signed_info_template_with_xsi'
signed_info = self.env['ir.qweb']._render(signed_info_template, {
'uri': '#{}'.format(uri),
'digest_value': base64.b64encode(
self._get_sha1_digest(etree.tostring(etree.fromstring(digest_value_tree), method='c14n'))).decode(),
})
signed_info_c14n = Markup(etree.tostring(etree.fromstring(signed_info), method='c14n', exclusive=False,
with_comments=False, inclusive_ns_prefixes=None).decode())
e, n = digital_signature._get_public_key_numbers_bytes(formatting='base64')
signature = self.env['ir.qweb']._render('l10n_cl_edi.signature_template', {
'signed_info': signed_info_c14n,
'signature_value': digital_signature._sign(re.sub(r'\n\s*', '', signed_info_c14n), hashing_algorithm='sha1', formatting='base64').decode(),
'modulus': n.decode(),
'exponent': e.decode(),
'certificate': '\n' + textwrap.fill(digital_signature._get_der_certificate_bytes(formatting='base64').decode(), 64),
})
full_doc = self._l10n_cl_append_sig(xml_type, signature, digest_value)
if without_xml_declaration:
return full_doc
return Markup('<?xml version="1.0" encoding="ISO-8859-1" ?>'
if xml_type != 'token' else '<?xml version="1.0" ?>') + full_doc
def _report_connection_err(self, error):
# raise error
error_msg = str(error)
if not self.env.context.get('cron_skip_connection_errs'):
self.message_post(body=error_msg)
else:
_logger.warning(error_msg)
@l10n_cl_edi_retry(logger=_logger)
def _get_seed_ws(self, mode):
return Client(wsdl=SERVER_URL[mode] + 'CrSeed.jws?WSDL', operation_timeout=TIMEOUT).service.getSeed()
def _get_seed(self, mode):
"""
Request the seed needed to authenticate to the SII with a Digital Certificate
"""
response = self._get_seed_ws(mode)
if response is None:
self._report_connection_err(_('Token cannot be generated. Please try again'))
return False
response_parsed = etree.fromstring(response.encode('utf-8'))
status = response_parsed.xpath('//ESTADO')[0].text
if status == '-1':
self._report_connection_err(_('Error Get Seed: (Message Exception)'))
return False
if status == '-2':
self._report_connection_err(_('Error Get Seed: Retorno'))
return False
return response_parsed.xpath('//SEMILLA')[0].text
def _get_signed_token(self, digital_signature, seed):
token_xml = self.env['ir.qweb']._render('l10n_cl_edi.token_template', {'seed': seed})
return self._sign_full_xml(token_xml, digital_signature, '', 'token')
@l10n_cl_edi_retry(logger=_logger)
def _get_token_ws(self, mode, signed_token):
return Client(wsdl=SERVER_URL[mode] + 'GetTokenFromSeed.jws?WSDL', operation_timeout=TIMEOUT).service.getToken(signed_token)
def _send_xml_to_sii(self, mode, company_website, params, digital_signature, post='/cgi_dte/UPL/DTEUpload'):
"""
The header used here is explicitly stated as is, in SII documentation. See
http://www.sii.cl/factura_electronica/factura_mercado/envio.pdf
it says: as mentioned previously, the client program must include in the request header the following.....
"""
if mode == 'SIIDEMO':
# mocked response
return None
token = self._get_token(mode, digital_signature)
if token is None:
self._report_connection_err(_('No response trying to get a token'))
return False
url = SERVER_URL[mode].replace('/DTEWS/', '')
headers = {
'Accept': 'image/gif, image/x-xbitmap, image/jpeg, image/pjpeg, application/vnd.ms-powerpoint, \
application/ms-excel, application/msword, */*',
'Accept-Language': 'es-cl',
'Accept-Encoding': 'gzip, deflate',
'User-Agent': 'Mozilla/4.0 (compatible; PROG 1.0; Windows NT 5.0; YComp 5.0.2.4)',
'Referer': f'{company_website}',
'Connection': 'Keep-Alive',
'Cache-Control': 'no-cache',
'Cookie': f'TOKEN={token}',
}
multi = urllib3.filepost.encode_multipart_formdata(params)
headers.update({'Content-Length': f'{len(multi[0])}'})
try:
response = pool.request_encode_body('POST', url + post, params, headers)
except Exception as error: # noqa: BLE001
self._report_connection_err(_('Sending DTE to SII failed due to: \n %s', error))
digital_signature.last_token = False
return False
return response.data
# we tried to use requests. The problem is that we need the Content-Lenght and seems that requests
# had the ability to send this provided the file is in binary mode, but did not work.
# response = requests._post(url + post, headers=headers, files=params)
# if response.status_code != 200:
# response.raise_for_status()
# else:
# return response.text
def _connection_exception(self, status, error):
status_msg = {
None: _('There is an unexpected response from SII'),
'exception': _('There is an unexpected response from SII'),
'11': _('Certificate does not exist'),
'-07': _('RUT validation error'),
'12': _('RUT validation error'),
}
self._report_connection_err('%s: %s' % (error, status_msg[status]))
return False
def _get_token(self, mode, digital_signature):
if digital_signature.last_token:
return digital_signature.last_token
seed = self._get_seed(mode)
if not seed:
return self._connection_exception('exception', _('No possible to get a seed'))
signed_token = self._get_signed_token(digital_signature, seed)
response = self._get_token_ws(mode, etree.tostring(
etree.fromstring(signed_token), pretty_print=True, encoding='ISO-8859-1').decode())
try:
response_parsed = etree.fromstring(response.encode('utf-8'))
except (ValueError, AttributeError) as error:
return self._connection_exception('exception', error)
status = response_parsed.findtext('{http://www.sii.cl/XMLSchema}RESP_HDR/ESTADO')
if status is None or status in ['-07', '12', '11']:
error = (_('No response trying to get a token') if status is None else
response_parsed.findtext('{http://www.sii.cl/XMLSchema}RESP_HDR/GLOSA'))
return self._connection_exception(status, error)
digital_signature.last_token = response_parsed[0][0].text
return response_parsed[0][0].text
@l10n_cl_edi_retry(logger=_logger)
def _get_send_status_ws(self, mode, company_vat, track_id, token):
return Client(SERVER_URL[mode] + 'QueryEstUp.jws?WSDL', operation_timeout=TIMEOUT).service.getEstUp(company_vat[:-2], company_vat[-1], track_id, token)
def _get_send_status(self, mode, track_id, company_vat, digital_signature):
"""
Request the status of a DTE file sent to the SII.
"""
if mode == 'SIIDEMO':
return None
token = self._get_token(mode, digital_signature)
if not token:
self._report_connection_err(_('Token cannot be generated. Please try again'))
return False
return self._get_send_status_ws(mode, company_vat, track_id, token)
@l10n_cl_edi_retry(logger=_logger, custom_msg=_lt('Asking for claim status failed due to:'))
def _get_dte_claim_ws(self, mode, settings, company_vat, document_type_code, document_number):
return Client(CLAIM_URL[mode] + '?wsdl', operation_timeout=TIMEOUT, settings=settings).service.listarEventosHistDoc(
self._l10n_cl_format_vat(company_vat)[:-2],
self._l10n_cl_format_vat(company_vat)[-1],
str(document_type_code),
str(document_number),
)
def _get_dte_claim(self, mode, company_vat, digital_signature, document_type_code, document_number):
if mode == 'SIIDEMO':
return None
token = self._get_token(mode, digital_signature)
if not token:
self._report_connection_err(_('Token cannot be generated. Please try again'))
return False
settings = Settings(strict=False, extra_http_headers={'Cookie': 'TOKEN=' + token})
try:
response = self._get_dte_claim_ws(mode, settings, company_vat, document_type_code, document_number)
except InvalidToken:
digital_signature.last_token = False
return False
return response
@l10n_cl_edi_retry(logger=_logger, custom_msg=_lt('Document acceptance or claim failed due to:'))
def _send_sii_claim_response_ws(self, mode, settings, company_vat, document_type_code, document_number, claim_type):
return Client(CLAIM_URL[mode] + '?wsdl', operation_timeout=TIMEOUT, settings=settings).service.ingresarAceptacionReclamoDoc(
self._l10n_cl_format_vat(company_vat)[:-2],
self._l10n_cl_format_vat(company_vat)[-1],
str(document_type_code),
str(document_number),
claim_type)
def _send_sii_claim_response(self, mode, company_vat, digital_signature, document_type_code, document_number, claim_type):
if mode == 'SIIDEMO':
return None
token = self._get_token(mode, digital_signature)
if not token:
self._report_connection_err(_('Token cannot be generated. Please try again'))
return False
settings = Settings(strict=False, extra_http_headers={'Cookie': 'TOKEN=' + token})
return self._send_sii_claim_response_ws(mode, settings, company_vat, document_type_code, document_number, claim_type)