diff --git a/source/app/controllers/pacs/__init__.py b/source/app/controllers/pacs/__init__.py new file mode 100644 index 0000000..afe806b --- /dev/null +++ b/source/app/controllers/pacs/__init__.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python + +from .comerciodigital import PACComercioDigital diff --git a/source/app/controllers/pacs/comerciodigital/__init__.py b/source/app/controllers/pacs/comerciodigital/__init__.py new file mode 100644 index 0000000..195aadd --- /dev/null +++ b/source/app/controllers/pacs/comerciodigital/__init__.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 + +from .comercio import PACComercioDigital diff --git a/source/app/controllers/pacs/comerciodigital/comercio.py b/source/app/controllers/pacs/comerciodigital/comercio.py new file mode 100644 index 0000000..bcca148 --- /dev/null +++ b/source/app/controllers/pacs/comerciodigital/comercio.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python +# ~ +# ~ PAC +# ~ Copyright (C) 2018-2019 Mauricio Baeza Servin - public [AT] elmau [DOT] net +# ~ +# ~ This program is free software: you can redistribute it and/or modify +# ~ it under the terms of the GNU General Public License as published by +# ~ the Free Software Foundation, either version 3 of the License, or +# ~ (at your option) any later version. +# ~ +# ~ This program is distributed in the hope that it will be useful, +# ~ but WITHOUT ANY WARRANTY; without even the implied warranty of +# ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# ~ GNU General Public License for more details. +# ~ +# ~ You should have received a copy of the GNU General Public License +# ~ along with this program. If not, see . + + +import logging + +import lxml.etree as ET +import requests +from requests.exceptions import ConnectionError + +from .conf import DEBUG, AUTH + + +LOG_FORMAT = '%(asctime)s - %(levelname)s - %(message)s' +LOG_DATE = '%d/%m/%Y %H:%M:%S' +logging.addLevelName(logging.ERROR, '\033[1;41mERROR\033[1;0m') +logging.addLevelName(logging.DEBUG, '\x1b[33mDEBUG\033[1;0m') +logging.addLevelName(logging.INFO, '\x1b[32mINFO\033[1;0m') +logging.basicConfig(level=logging.DEBUG, format=LOG_FORMAT, datefmt=LOG_DATE) +log = logging.getLogger(__name__) + +logging.getLogger('requests').setLevel(logging.ERROR) + + +TIMEOUT = 10 + + +class PACComercioDigital(object): + ws = 'https://{}.comercio-digital.mx/{}' + api = 'https://app2.comercio-digital.mx/{}' + URL = { + 'timbra': ws.format('ws', 'timbre/timbrarV5.aspx'), + 'cancel': ws.format('cancela', 'cancela3/cancelarUuid'), + 'cancelxml': ws.format('cancela', 'cancela3/cancelarXml'), + 'status': ws.format('cancela', 'arws/consultaEstatus'), + 'client': api.format('x3/altaEmpresa'), + 'saldo': api.format('x3/saldo'), + 'timbres': api.format('x3/altaTimbres'), + } + CODES = { + '000': '000 Exitoso', + '004': '004 RFC {} ya esta dado de alta con Estatus=A', + '704': '704 Usuario Invalido', + '702': '702 Error rfc/empresa invalido', + } + NS_CFDI = { + 'cfdi': 'http://www.sat.gob.mx/cfd/3', + 'tdf': 'http://www.sat.gob.mx/TimbreFiscalDigital', + } + + if DEBUG: + ws = 'https://pruebas.comercio-digital.mx/{}' + ws6 = 'https://pruebas6.comercio-digital.mx/arws/{}' + URL = { + 'timbra': ws.format('timbre/timbrarV5.aspx'), + 'cancel': ws.format('cancela3/cancelarUuid'), + 'cancelxml': ws.format('cancela3/cancelarXml'), + 'status': ws6.format('consultaEstatus'), + 'client': api.format('x3/altaEmpresa'), + 'saldo': api.format('x3/saldo'), + 'timbres': api.format('x3/altaTimbres'), + } + + def __init__(self): + self.error = '' + # ~ self.cfdi_uuid = '' + # ~ self.date_stamped = '' + + def _error(self, msg): + self.error = str(msg) + log.error(msg) + return + + def _post(self, url, data, headers={}): + result = None + headers['host'] = url.split('/')[2] + headers['Content-type'] = 'text/plain' + headers['Connection'] = 'Keep-Alive' + + try: + result = requests.post(url, data=data, headers=headers, timeout=TIMEOUT) + except ConnectionError as e: + self._error(e) + + return result + + def _validate_cfdi(self, xml): + """ + Comercio Digital solo soporta la declaraciĆ³n con doble comilla + """ + tree = ET.fromstring(xml.encode()) + xml = ET.tostring(tree, + pretty_print=True, doctype='') + return xml + + def stamp(self, cfdi, auth={}): + if DEBUG or not auth: + auth = AUTH + + url = self.URL['timbra'] + headers = { + 'usrws': auth['user'], + 'pwdws': auth['pass'], + 'tipo': 'XML', + } + cfdi = self._validate_cfdi(cfdi) + result = self._post(url, cfdi, headers) + + if result is None: + return '' + + if result.status_code != 200: + return '' + + if 'errmsg' in result.headers: + self._error(result.headers['errmsg']) + return '' + + xml = result.content + tree = ET.fromstring(xml) + cfdi_uuid = tree.xpath( + 'string(//cfdi:Complemento/tdf:TimbreFiscalDigital/@UUID)', + namespaces=self.NS_CFDI) + date_stamped = tree.xpath( + 'string(//cfdi:Complemento/tdf:TimbreFiscalDigital/@FechaTimbrado)', + namespaces=self.NS_CFDI) + + data = { + 'xml': xml.decode(), + 'uuid': cfdi_uuid, + 'date': date_stamped, + } + return data + + def _get_data_cancel(self, cfdi, info, auth): + NS_CFDI = { + 'cfdi': 'http://www.sat.gob.mx/cfd/3', + 'tdf': 'http://www.sat.gob.mx/TimbreFiscalDigital', + } + tree = ET.fromstring(cfdi.encode()) + tipo = tree.xpath( + 'string(//cfdi:Comprobante/@TipoDeComprobante)', + namespaces=NS_CFDI) + total = tree.xpath( + 'string(//cfdi:Comprobante/@Total)', + namespaces=NS_CFDI) + rfc_emisor = tree.xpath( + 'string(//cfdi:Comprobante/cfdi:Emisor/@Rfc)', + namespaces=NS_CFDI) + rfc_receptor = tree.xpath( + 'string(//cfdi:Comprobante/cfdi:Receptor/@Rfc)', + namespaces=NS_CFDI) + uid = tree.xpath( + 'string(//cfdi:Complemento/tdf:TimbreFiscalDigital/@UUID)', + namespaces=NS_CFDI) + data = ( + f"USER={auth['user']}", + f"PWDW={auth['pass']}", + f"RFCE={rfc_emisor}", + f"UUID={uid}", + f"PWDK={info['pass']}", + f"KEYF={info['key']}", + f"CERT={info['cer']}", + f"TIPO={info['tipo']}", + f"ACUS=SI", + f"RFCR={rfc_receptor}", + f"TIPOC={tipo}", + f"TOTAL={total}", + ) + return '\n'.join(data) + + def cancel(self, cfdi, info, auth={}): + if not auth: + auth = AUTH + url = self.URL['cancel'] + data = self._get_data_cancel(cfdi, info, auth) + + result = self._post(url, data) + + if result is None: + return '' + + if result.status_code != 200: + return '' + + if result.headers['codigo'] != '000': + self._error(result.headers['errmsg']) + return '' + + return result.text + + def _get_headers_cancel_xml(self, cfdi, info, auth): + NS_CFDI = { + 'cfdi': 'http://www.sat.gob.mx/cfd/3', + 'tdf': 'http://www.sat.gob.mx/TimbreFiscalDigital', + } + tree = ET.fromstring(cfdi.encode()) + tipocfdi = tree.xpath( + 'string(//cfdi:Comprobante/@TipoDeComprobante)', + namespaces=NS_CFDI) + total = tree.xpath( + 'string(//cfdi:Comprobante/@Total)', + namespaces=NS_CFDI) + rfc_receptor = tree.xpath( + 'string(//cfdi:Comprobante/cfdi:Receptor/@Rfc)', + namespaces=NS_CFDI) + + headers = { + 'usrws': auth['user'], + 'pwdws': auth['pass'], + 'rfcr': rfc_receptor, + 'total': total, + 'tipocfdi': tipocfdi, + } + headers.update(info) + + return headers + + def cancel_xml(self, cfdi, xml, info, auth={}): + if not auth: + auth = AUTH + url = self.URL['cancelxml'] + headers = self._get_headers_cancel_xml(cfdi, info, auth) + result = self._post(url, xml, headers) + + if result is None: + return '' + + if result.status_code != 200: + return '' + + if result.headers['codigo'] != '000': + self._error(result.headers['errmsg']) + return '' + + return result.text + + def status(self, data, auth={}): + if not auth: + auth = AUTH + url = self.URL['status'] + + data = ( + f"USER={auth['user']}", + f"PWDW={auth['pass']}", + f"RFCR={data['rfc_receptor']}", + f"RFCE={data['rfc_emisor']}", + f"TOTAL={data['total']}", + f"UUID={data['uuid']}", + ) + data = '\n'.join(data) + result = self._post(url, data) + + if result is None: + return '' + + if result.status_code != 200: + self._error(result.status_code) + return self.error + + return result.text + + def _get_data_client(self, auth, values): + data = [f"usr_ws={auth['user']}", f"pwd_ws={auth['pass']}"] + fields = ( + 'rfc_contribuyente', + 'nombre_contribuyente', + 'calle', + 'noExterior', + 'noInterior', + 'colonia', + 'localidad', + 'municipio', + 'estado', + 'pais', + 'cp', + 'contacto', + 'telefono', + 'email', + 'rep_nom', + 'rep_rfc', + 'email_fact', + 'pwd_asignado', + ) + data += [f"{k}={values[k]}" for k in fields] + + return '\n'.join(data) + + def client_add(self, data): + auth = AUTH + url = self.URL['client'] + data = self._get_data_client(auth, data) + + result = self._post(url, data) + + if result is None: + return False + + if result.status_code != 200: + self._error(f'Code: {result.status_code}') + return False + + if result.text != self.CODES['000']: + self._error(result.text) + return False + + return True + + def client_balance(self, data): + url = self.URL['saldo'] + host = url.split('/')[2] + headers = { + 'Content-type': 'text/plain', + 'Host': host, + 'Connection' : 'Keep-Alive', + } + data = {'usr': data['rfc'], 'pwd': data['password']} + try: + result = requests.get(url, params=data, headers=headers, timeout=TIMEOUT) + except ConnectionError as e: + self._error(e) + return '' + + if result.status_code != 200: + return '' + + if result.text == self.CODES['704']: + self._error(result.text) + return '' + + if result.text == self.CODES['702']: + self._error(result.text) + return '' + + return result.text + + def client_add_timbres(self, data, auth={}): + if not auth: + auth = AUTH + url = self.URL['timbres'] + data = '\n'.join(( + f"usr_ws={auth['user']}", + f"pwd_ws={auth['pass']}", + f"rfc_recibir={data['rfc']}", + f"num_timbres={data['timbres']}" + )) + + result = self._post(url, data) + + if result is None: + return False + + if result.status_code != 200: + self._error(f'Code: {result.status_code}') + return False + + if result.text != self.CODES['000']: + self._error(result.text) + return False + + return True + diff --git a/source/app/controllers/pacs/comerciodigital/conf.py.example b/source/app/controllers/pacs/comerciodigital/conf.py.example new file mode 100644 index 0000000..6006207 --- /dev/null +++ b/source/app/controllers/pacs/comerciodigital/conf.py.example @@ -0,0 +1,37 @@ +#!/usr/bin/env python +# ~ +# ~ PAC +# ~ Copyright (C) 2018-2019 Mauricio Baeza Servin - public [AT] elmau [DOT] net +# ~ +# ~ This program is free software: you can redistribute it and/or modify +# ~ it under the terms of the GNU General Public License as published by +# ~ the Free Software Foundation, either version 3 of the License, or +# ~ (at your option) any later version. +# ~ +# ~ This program is distributed in the hope that it will be useful, +# ~ but WITHOUT ANY WARRANTY; without even the implied warranty of +# ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# ~ GNU General Public License for more details. +# ~ +# ~ You should have received a copy of the GNU General Public License +# ~ along with this program. If not, see . + + +# ~ Siempre consulta la documentaciĆ³n de PAC +# ~ AUTH = Las credenciales de timbrado proporcionadas por el PAC +# ~ NO cambies las credenciales de prueba + +DEBUG = True + + +AUTH = { + 'user': '', + 'pass': '', +} + + +if DEBUG: + AUTH = { + 'user': 'AAA010101AAA', + 'pass': 'PWD', + } diff --git a/source/app/controllers/util.py b/source/app/controllers/util.py index 0163de6..de35711 100644 --- a/source/app/controllers/util.py +++ b/source/app/controllers/util.py @@ -546,14 +546,14 @@ class Certificado(object): return data -def make_xml(data, certificado, auth): +def make_xml(data, certificado): from .cfdi_xml import CFDI token = _get_md5(certificado.rfc) - if USAR_TOKEN: - token = auth['PASS'] - if AUTH['DEBUG']: - token = AUTH['PASS'] + # ~ if USAR_TOKEN: + # ~ token = auth['PASS'] + # ~ if AUTH['DEBUG']: + # ~ token = AUTH['PASS'] if DEBUG: data['emisor']['Rfc'] = certificado.rfc @@ -2702,12 +2702,12 @@ def local_copy(files): log.error(msg) return - args = 'df -P {} | tail -1 | cut -d" " -f 1'.format(path_bk) - try: - result = _call(args) + # ~ args = 'df -P {} | tail -1 | cut -d" " -f 1'.format(path_bk) + # ~ try: + # ~ result = _call(args) # ~ log.info(result) - except: - pass + # ~ except: + # ~ pass # ~ if result != 'empresalibre\n': # ~ log.info(result) # ~ msg = 'Asegurate de que exista la carpeta para sincronizar' @@ -2752,20 +2752,20 @@ def sync_files(files, auth={}): return -def sync_cfdi(auth, files): +def sync_cfdi(rfc, files): local_copy(files) if DEBUG: return - if not auth['REPO'] or not SEAFILE_SERVER: - return + # ~ if not auth['REPO'] or not SEAFILE_SERVER: + # ~ return - seafile = SeaFileAPI(SEAFILE_SERVER['URL'], auth['USER'], auth['PASS']) - if seafile.is_connect: - for f in files: - seafile.update_file( - f, auth['REPO'], 'Facturas/{}/'.format(f[2]), auth['PASS']) + # ~ seafile = SeaFileAPI(SEAFILE_SERVER['URL'], auth['USER'], auth['PASS']) + # ~ if seafile.is_connect: + # ~ for f in files: + # ~ seafile.update_file( + # ~ f, auth['REPO'], 'Facturas/{}/'.format(f[2]), auth['PASS']) return diff --git a/source/app/controllers/utils.py b/source/app/controllers/utils.py index 496cd71..0c498ab 100644 --- a/source/app/controllers/utils.py +++ b/source/app/controllers/utils.py @@ -51,7 +51,7 @@ from dateutil import parser import seafileapi from settings import DEBUG, DB_COMPANIES, PATHS -from .comercio import PACComercioDigital +from .pacs import PACComercioDigital from .pac import Finkok as PACFinkok # ~ from .finkok import PACFinkok @@ -584,26 +584,24 @@ def get_pass(): return True, password -def xml_stamp(xml, auth, name): +def xml_stamp(xml, auth): if not DEBUG and not auth: msg = 'Sin datos para timbrar' result = {'ok': False, 'error': msg} return result result = {'ok': True, 'error': ''} - auth = {'user': auth['USER'], 'pass': auth['PASS']} - pac = PACS[name]() - xml_stamped = pac.stamp(xml, auth) + pac = PACS[auth['pac']]() + response = pac.stamp(xml, auth) - if not xml_stamped: + if not response: result['ok'] = False result['error'] = pac.error return result - result['xml'] = xml_stamped - result['uuid'] = pac.cfdi_uuid - result['fecha'] = pac.date_stamped + result.update(response) + return result diff --git a/source/app/models/main.py b/source/app/models/main.py index a3cc98c..434f738 100644 --- a/source/app/models/main.py +++ b/source/app/models/main.py @@ -516,6 +516,18 @@ class Configuracion(BaseModel): } return values + def _get_pac_auth(cls): + pac = cls.get_('lst_pac').lower() + user = cls.get_(f'user_timbrado_{pac}') + token = cls.get_(f'token_timbrado_{pac}') + data = {} + print(1, pac, user, token) + if pac and user and token: + data['pac'] = pac + data['user'] = user + data['pass'] = token + return data + @classmethod def get_(cls, keys): if isinstance(keys, str): @@ -534,6 +546,7 @@ class Configuracion(BaseModel): 'folios', 'correo', 'admin_config_users', + 'pac_auth', ) opt = keys['fields'] if opt in options: @@ -4250,8 +4263,8 @@ class Facturas(BaseModel): return Facturas.send(id, rfc) @util.run_in_thread - def _sync(self, id, auth): - return Facturas.sync(id, auth) + def _sync(self, id, rfc): + return Facturas.sync(id, rfc) @util.run_in_thread def _sync_pdf(self, pdf, name_pdf, target): @@ -4350,21 +4363,21 @@ class Facturas(BaseModel): return {'ok': True, 'msg': msg} @classmethod - def sync(cls, id, auth): + def sync(cls, id, rfc): obj = Facturas.get(Facturas.id==id) if obj.uuid is None: msg = 'La factura no esta timbrada' return - emisor = Emisor.select()[0] - pdf, name_pdf = cls.get_pdf(id, auth['RFC'], False) + # ~ emisor = Emisor.select()[0] + pdf, name_pdf = cls.get_pdf(id, rfc, False) name_xml = '{}{}_{}.xml'.format(obj.serie, obj.folio, obj.cliente.rfc) - target = emisor.rfc + '/' + str(obj.fecha)[:7].replace('-', '/') + target = rfc + '/' + str(obj.fecha)[:7].replace('-', '/') files = ( (obj.xml, name_xml, target), (pdf, name_pdf, target), ) - util.sync_cfdi(auth, files) + util.sync_cfdi(rfc, files) return def _get_filter_folios(self, values): @@ -4841,7 +4854,7 @@ class Facturas(BaseModel): FacturasComplementos.create(**data) return - def _make_xml(self, invoice, auth): + def _make_xml(self, invoice): tax_decimals = Configuracion.get_bool('chk_config_tax_decimals') decimales_precios = Configuracion.get_bool('chk_config_decimales_precios') invoice_by_ticket = Configuracion.get_bool('chk_config_invoice_by_ticket') @@ -5118,7 +5131,7 @@ class Facturas(BaseModel): 'complementos': complementos, } - return util.make_xml(data, certificado, auth) + return util.make_xml(data, certificado) @classmethod def get_status_sat(cls, id): @@ -5195,38 +5208,35 @@ class Facturas(BaseModel): id = int(values['id']) update = util.loads(values.get('update', 'true')) - auth = Emisor.get_auth() + rfc = Emisor.select()[0].rfc obj = Facturas.get(Facturas.id == id) - obj.xml = cls._make_xml(cls, obj, auth) + obj.xml = cls._make_xml(cls, obj) obj.estatus = 'Generada' obj.save() enviar_correo = util.get_bool(Configuracion.get_('correo_directo')) - pac = Configuracion.get_('lst_pac').lower() + auth = Configuracion.get_({'fields': 'pac_auth'}) anticipo = False msg = 'Factura timbrada correctamente' - # ~ if pac: - result = utils.xml_stamp(obj.xml, auth, pac) - # ~ else: - # ~ result = util.timbra_xml(obj.xml, auth) + result = utils.xml_stamp(obj.xml, auth) if result['ok']: obj.xml = result['xml'] obj.uuid = result['uuid'] - obj.fecha_timbrado = result['fecha'] + obj.fecha_timbrado = result['date'] obj.estatus = 'Timbrada' obj.error = '' obj.save() row = {'uuid': obj.uuid, 'estatus': 'Timbrada'} if enviar_correo: - cls._send(cls, id, auth['RFC']) + cls._send(cls, id, rfc) if obj.tipo_comprobante == 'I' and obj.tipo_relacion == '07': anticipo = True cls._actualizar_saldo_cliente(cls, obj) if update: cls._update_inventory(cls, obj) - cls._sync(cls, id, auth) + cls._sync(cls, id, rfc) m = 'T {}'.format(obj.id) _save_log(user.usuario, m, 'F')