forked from Mapan/odoo17e
213 lines
10 KiB
Python
213 lines
10 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
from odoo import fields, models, api, _
|
|
from odoo.exceptions import UserError
|
|
from cryptography.hazmat.primitives import hashes
|
|
from cryptography.hazmat.primitives.serialization import Encoding, load_pem_private_key
|
|
from cryptography.hazmat.primitives.serialization.pkcs7 import (
|
|
PKCS7Options,
|
|
PKCS7SignatureBuilder,
|
|
)
|
|
from cryptography.x509 import load_pem_x509_certificate
|
|
from lxml import builder
|
|
from lxml import etree
|
|
from requests.adapters import HTTPAdapter
|
|
from requests.exceptions import HTTPError
|
|
from urllib3.util.ssl_ import create_urllib3_context
|
|
|
|
import time
|
|
import datetime
|
|
import base64
|
|
import logging
|
|
|
|
from odoo.tools.zeep import Client, Transport
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
# Exclude some ciphers in order avoid failure on servers where the DH key is too small
|
|
AFIP_CIPHERS = "DEFAULT:!DH"
|
|
|
|
|
|
class L10nArHTTPAdapter(HTTPAdapter):
|
|
""" An adapter to block DH ciphers which may not work for *.afip.gov.ar """
|
|
|
|
def init_poolmanager(self, *args, **kwargs):
|
|
context = create_urllib3_context(ciphers=AFIP_CIPHERS)
|
|
kwargs["ssl_context"] = context
|
|
return super().init_poolmanager(*args, **kwargs)
|
|
|
|
|
|
class ARTransport(Transport):
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.session.mount('https://', L10nArHTTPAdapter()) # block DH ciphers for AFIP servers
|
|
|
|
def post(self, address, message, headers):
|
|
""" We overwrite this method only to be able to save the xml request and response.
|
|
This will only affect to the connections that are made n this field and it do not extend the original
|
|
Transport class of zeep package.
|
|
|
|
NOTE: we try using the HistoryPlugin to save the xml request/reponse but seems this one could have problems when using with multi thread/workers"""
|
|
response = super().post(address, message, headers)
|
|
self.xml_request = etree.tostring(
|
|
etree.fromstring(message), pretty_print=True).decode('utf-8')
|
|
self.xml_response = etree.tostring(
|
|
etree.fromstring(response.content), pretty_print=True).decode('utf-8')
|
|
return response
|
|
|
|
|
|
class SimpleTransport:
|
|
|
|
def __init__(self, transport):
|
|
self.__obj = transport
|
|
|
|
@property
|
|
def xml_request(self):
|
|
return self.__obj.xml_request
|
|
|
|
@property
|
|
def xml_response(self):
|
|
return self.__obj.xml_response
|
|
|
|
|
|
class L10nArAfipwsConnection(models.Model):
|
|
|
|
_name = "l10n_ar.afipws.connection"
|
|
_description = "AFIP Webservice Connection"
|
|
_rec_name = "l10n_ar_afip_ws"
|
|
_order = "expiration_time desc"
|
|
|
|
company_id = fields.Many2one('res.company', required=True, index=True, auto_join=True)
|
|
uniqueid = fields.Char('Unique ID', readonly=True)
|
|
token = fields.Text(readonly=True)
|
|
sign = fields.Text(readonly=True)
|
|
generation_time = fields.Datetime(readonly=True)
|
|
expiration_time = fields.Datetime(readonly=True)
|
|
type = fields.Selection(
|
|
[('production', 'Production'), ('testing', 'Testing')], readonly=True,
|
|
required=True, help='This field is not configure by the user, is extracted from the environment configured in'
|
|
' the company when the connection was created. It\'s needed because if you change from environment to do quick'
|
|
' tests we could avoid using the last connection and use the one that matches with the new environment')
|
|
|
|
l10n_ar_afip_ws = fields.Selection(selection='_get_l10n_ar_afip_ws', string='AFIP WS', required=True)
|
|
|
|
def _get_l10n_ar_afip_ws(self):
|
|
""" Return the list of values of the selection field. """
|
|
return [('wscdc', _('Verification of Invoices (WSCDC)'))] + self.env['account.journal']._get_l10n_ar_afip_ws()
|
|
|
|
@api.model
|
|
def _l10n_ar_get_afip_ws_url(self, afip_ws, environment_type):
|
|
""" Function to be inherited on each module that adds a new webservice """
|
|
ws_data = {'wsfe': {'production': "https://servicios1.afip.gov.ar/wsfev1/service.asmx?WSDL",
|
|
'testing': "https://wswhomo.afip.gov.ar/wsfev1/service.asmx?WSDL"},
|
|
'wsfex': {'production': "https://servicios1.afip.gov.ar/wsfexv1/service.asmx?WSDL",
|
|
'testing': "https://wswhomo.afip.gov.ar/wsfexv1/service.asmx?WSDL"},
|
|
'wsbfe': {'production': "https://servicios1.afip.gov.ar/wsbfev1/service.asmx?WSDL",
|
|
'testing': "https://wswhomo.afip.gov.ar/wsbfev1/service.asmx?WSDL"},
|
|
'wscdc': {'production': "https://servicios1.afip.gov.ar/WSCDC/service.asmx?WSDL",
|
|
'testing': "https://wswhomo.afip.gov.ar/WSCDC/service.asmx?WSDL"}}
|
|
return ws_data.get(afip_ws, {}).get(environment_type)
|
|
|
|
def _get_client(self, return_transport=False):
|
|
""" Get zeep client to connect to the webservice """
|
|
wsdl = self._l10n_ar_get_afip_ws_url(self.l10n_ar_afip_ws, self.type)
|
|
auth = {'Token': self.token, 'Sign': self.sign, 'Cuit': self.company_id.partner_id.ensure_vat()}
|
|
try:
|
|
transport = ARTransport(operation_timeout=60, timeout=60)
|
|
client = Client(wsdl, transport=transport)
|
|
except Exception as error:
|
|
self._l10n_ar_process_connection_error(error, self.type, self.l10n_ar_afip_ws)
|
|
if return_transport:
|
|
return client, auth, SimpleTransport(transport)
|
|
return client, auth
|
|
|
|
@api.model
|
|
def _l10n_ar_process_connection_error(self, error, env_type, afip_ws):
|
|
""" Review the type of exception received and show a useful message """
|
|
if hasattr(error, 'name'):
|
|
error_name = error.name
|
|
elif hasattr(error, 'message'):
|
|
error_name = error.message
|
|
else:
|
|
error_name = repr(error)
|
|
|
|
error_msg = _('There was a problem with the connection to the %s webservice: %s', afip_ws, error_name)
|
|
|
|
# Find HINT for error message
|
|
certificate_expired = _('It seems like the certificate has expired. Please renew your AFIP certificate')
|
|
token_in_use = 'El CEE ya posee un TA valido para el acceso al WSN solicitado'
|
|
data = {
|
|
'Computador no autorizado a acceder al servicio': _(
|
|
'The certificate is not authorized (delegated) to work with this web service'),
|
|
"ns1:cms.sign.invalid: Firma inválida o algoritmo no soportado": certificate_expired,
|
|
"ns1:cms.cert.expired: Certificado expirado": certificate_expired,
|
|
'500 Server Error: Internal Server': _('Webservice is down'),
|
|
token_in_use: _(
|
|
'Are you invoicing from another computer or system? This error could happen when a access token'
|
|
' that is requested to AFIP has been requested multiple times and the last one requested is still valid.'
|
|
' You will need to wait 12 hours to generate a new token and be able to connect to AFIP'
|
|
'\n\n If not, then could be a overload of AFIP service, please wait some time and try again'),
|
|
'No se puede decodificar el BASE64': _('The certificate and private key do not match'),
|
|
}
|
|
hint_msg = next((value for item, value in data.items() if item in error_name), None)
|
|
|
|
if token_in_use in error_name and env_type == 'testing':
|
|
hint_msg = _(
|
|
'The testing certificate is been used for another person, you can wait 10 minutes and'
|
|
' try again or you can change the testing certificate. If this message persist you can:\n\n'
|
|
' 1) Configure another of the demo certificates pre loaded in demo data'
|
|
' (On Settings click the ⇒ "Set another demo certificate" button).\n'
|
|
' 2) Configure your own testing certificates')
|
|
if hint_msg:
|
|
error_msg += '\n\nPISTA: ' + hint_msg
|
|
else:
|
|
if isinstance(error, HTTPError) and error.response.status_code == 503:
|
|
error_msg += '\n\n' + _('The AFIP electronic billing webservice is not available. Wait a few minutes for it to reset and try to validate the action again.')
|
|
else:
|
|
error_msg += '\n\n' + _('Please report this error to your Odoo provider')
|
|
raise UserError(error_msg)
|
|
|
|
def _l10n_ar_get_token_data(self, company, afip_ws):
|
|
""" Call AFIP Authentication webservice to get token & sign data """
|
|
private_key, certificate = company.sudo()._get_key_and_certificate()
|
|
environment_type = company._get_environment_type()
|
|
generation_time = fields.Datetime.now()
|
|
expiration_time = fields.Datetime.add(generation_time, hours=12)
|
|
uniqueId = str(int(time.mktime(datetime.datetime.now().timetuple())))
|
|
request_xml = (builder.E.loginTicketRequest({
|
|
'version': '1.0'},
|
|
builder.E.header(builder.E.uniqueId(uniqueId),
|
|
builder.E.generationTime(generation_time.strftime('%Y-%m-%dT%H:%M:%S-00:00')),
|
|
builder.E.expirationTime(expiration_time.strftime('%Y-%m-%dT%H:%M:%S-00:00'))),
|
|
builder.E.service(afip_ws)))
|
|
request = etree.tostring(request_xml, pretty_print=True)
|
|
|
|
# sign request
|
|
pkey = load_pem_private_key(private_key, None)
|
|
signcert = load_pem_x509_certificate(certificate)
|
|
signed_request = (
|
|
PKCS7SignatureBuilder()
|
|
.set_data(request)
|
|
.add_signer(signcert, pkey, hashes.SHA256())
|
|
.sign(Encoding.DER, [PKCS7Options.Binary])
|
|
)
|
|
|
|
wsdl = {'production': "https://wsaa.afip.gov.ar/ws/services/LoginCms?WSDL",
|
|
'testing': "https://wsaahomo.afip.gov.ar/ws/services/LoginCms?WSDL"}.get(environment_type)
|
|
|
|
try:
|
|
_logger.info('Connect to AFIP to get token: %s %s %s' % (afip_ws, company.l10n_ar_afip_ws_crt_fname, company.name))
|
|
transport = ARTransport(operation_timeout=60, timeout=60)
|
|
client = Client(wsdl, transport=transport)
|
|
response = client.service.loginCms(base64.b64encode(signed_request).decode())
|
|
except Exception as error:
|
|
return self._l10n_ar_process_connection_error(error, environment_type, afip_ws)
|
|
response = etree.fromstring(response.encode('utf-8'))
|
|
|
|
return {'uniqueid': uniqueId,
|
|
'generation_time': generation_time,
|
|
'expiration_time': expiration_time,
|
|
'token': response.xpath('/loginTicketResponse/credentials/token')[0].text,
|
|
'sign': response.xpath('/loginTicketResponse/credentials/sign')[0].text}
|