From bd7508fe6b45c181ba8faa1703d717235b87c6b0 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Wed, 16 Dec 2020 16:04:32 -0600 Subject: [PATCH 01/22] Issue #415 --- CHANGELOG.md | 6 ++++++ README.md | 7 ++++--- VERSION | 2 +- requirements.txt | 6 ++++++ source/app/controllers/util.py | 18 ++++++++++++++---- source/app/models/main.py | 3 ++- source/app/settings.py | 2 +- source/templates/plantilla_factura.ods | Bin 29653 -> 29474 bytes 8 files changed, 34 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3aecb8d..5fd5b75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +v 1.40.0 [20-12-2020] +---------------------- + - Error: Al parsear XML en Python 3.9+ + - Mejora: Agregar versión de Empresa Libre a plantilla. + + v 1.39.1 [17-sep-2020] ---------------------- - Error: Esquema para complemento IEDU diff --git a/README.md b/README.md index 826f193..e9fde91 100644 --- a/README.md +++ b/README.md @@ -10,16 +10,17 @@ Este proyecto está en continuo desarrollo, contratar un esquema de soporte, nos ayuda a continuar su desarrollo. Ponte en contacto con nosotros para contratar: administracion ARROBA empresalibre.net -#### Ahora también puede aportar con Bitcoin Cash (BCH): +#### Ahora también puede aportar con criptomonedas: -`pq763fj7kxxf2wtf360lfsy5ydw84yz72q76hanhxq` +BCH: `qztd3l00xle5tffdqvh2snvadkuau2ml0uqm4n875d` +BTC: `3FhiXcXmAesmQzrNEngjHFnvaJRhU1AGWV` ### Requerimientos: * Servidor web, recomendado Nginx * uwsgi -* python3.6+ +* python3.7+ * xsltproc * openssl * xmlsec diff --git a/VERSION b/VERSION index 0c11aad..32b7211 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.39.1 +1.40.0 diff --git a/requirements.txt b/requirements.txt index d33642b..a007210 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,3 +13,9 @@ pypng reportlab psycopg2-binary cryptography + +# escpos +# pyusb +# pyserial +# qrcode + diff --git a/source/app/controllers/util.py b/source/app/controllers/util.py index aade77a..0163de6 100644 --- a/source/app/controllers/util.py +++ b/source/app/controllers/util.py @@ -1339,9 +1339,15 @@ class LIBO(object): self._leyendas(data.get('leyendas', '')) self._cancelado(data['cancelada']) + self._others_values(data) self._clean() return + def _others_values(self, data): + version = data['version'] + self._set_cell('{version}', version) + return + def pdf(self, path, data, ods=False): options = {'AsTemplate': True, 'Hidden': True} log.debug('Abrir plantilla...') @@ -1818,7 +1824,7 @@ def _get_relacionados(doc, version): if node is None: return '' - uuids = ['UUID: {}'.format(n.attrib['UUID']) for n in node.getchildren()] + uuids = ['UUID: {}'.format(n.attrib['UUID']) for n in list(node)] return '\n'.join(uuids) @@ -1933,7 +1939,8 @@ def _conceptos(doc, version, options): data = [] conceptos = doc.find('{}Conceptos'.format(PRE[version])) - for c in conceptos.getchildren(): + # ~ for c in conceptos.getchildren(): + for c in list(conceptos): values = CaseInsensitiveDict(c.attrib.copy()) if is_nomina: values['noidentificacion'] = values['ClaveProdServ'] @@ -2002,7 +2009,8 @@ def _totales(doc, cfdi, version): node = imp.find('{}Traslados'.format(PRE[version])) if node is not None: - for n in node.getchildren(): + # ~ for n in node.getchildren(): + for n in list(node): tmp = CaseInsensitiveDict(n.attrib.copy()) if version == '3.3': tasa = round(float(tmp['tasaocuota']), DECIMALES) @@ -2013,7 +2021,8 @@ def _totales(doc, cfdi, version): node = imp.find('{}Retenciones'.format(PRE[version])) if node is not None: - for n in node.getchildren(): + # ~ for n in node.getchildren(): + for n in list(node): tmp = CaseInsensitiveDict(n.attrib.copy()) if version == '3.3': title = 'Retención {} {}'.format( @@ -2196,6 +2205,7 @@ def get_data_from_xml(invoice, values): if data['pagos']: data['pays'] = _cfdipays(doc, data, version) data['pakings'] = values.get('pakings', []) + data['version'] = values['version'] return data diff --git a/source/app/models/main.py b/source/app/models/main.py index 464633b..e1f9c1d 100644 --- a/source/app/models/main.py +++ b/source/app/models/main.py @@ -49,6 +49,7 @@ from settings import ( PATHS, URL, VALUES_PDF, + VERSION, RFCS, ) @@ -3965,7 +3966,7 @@ class Facturas(BaseModel): def _get_not_in_xml(self, invoice, emisor): pdf_from = Configuracion.get_('make_pdf_from') or '1' - values = {} + values = {'version': VERSION} values['notas'] = invoice.notas values['fechadof'] = str(emisor.fecha_dof) diff --git a/source/app/settings.py b/source/app/settings.py index 9346afe..72ced22 100644 --- a/source/app/settings.py +++ b/source/app/settings.py @@ -47,7 +47,7 @@ except ImportError: DEBUG = DEBUG -VERSION = '1.39.1' +VERSION = '1.40.0' EMAIL_SUPPORT = ('soporte@empresalibre.mx',) TITLE_APP = '{} v{}'.format(TITLE_APP, VERSION) diff --git a/source/templates/plantilla_factura.ods b/source/templates/plantilla_factura.ods index 050517590f0c361df9193e55008222ebf3cea516..d54d86d1dfc2bb8a1d1812d5db7fa65264667268 100644 GIT binary patch delta 21544 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 delta 21678 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 From f02b6782be89fee5b8942966668e7b940e2b0011 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Mon, 28 Dec 2020 22:14:30 -0600 Subject: [PATCH 02/22] Move configuration for PACs --- source/app/models/main.py | 42 +++++++++++- source/static/js/controller/admin.js | 97 +++++++++++++++++++--------- source/static/js/ui/admin.js | 30 ++++++--- 3 files changed, 126 insertions(+), 43 deletions(-) diff --git a/source/app/models/main.py b/source/app/models/main.py index e1f9c1d..69b3bbd 100644 --- a/source/app/models/main.py +++ b/source/app/models/main.py @@ -501,6 +501,21 @@ class Configuracion(BaseModel): value = data[0].valor return value + def _get_pac(cls, pac): + user_field = f'user_timbrado_{pac}' + token_field = f'token_timbrado_{pac}' + fields = (user_field, token_field) + data = (Configuracion + .select() + .where(Configuracion.clave.in_(fields)) + ) + data = {r.clave: r.valor for r in data} + values = { + 'user_timbrado': data.get(user_field, ''), + 'token_timbrado': data.get(token_field, ''), + } + return values + @classmethod def get_(cls, keys): if isinstance(keys, str): @@ -524,6 +539,9 @@ class Configuracion(BaseModel): if opt in options: return getattr(cls, '_get_{}'.format(opt))(cls) + if opt == 'pac': + return cls._get_pac(cls, keys['pac']) + if keys['fields'] == 'configtemplates': try: emisor = Emisor.select()[0] @@ -572,7 +590,7 @@ class Configuracion(BaseModel): ) values = {r.clave: util.get_bool(r.valor) for r in data} fields = ( - ('lst_pac', 'default'), + ('lst_pac', 'comercio'), ) for k, d in fields: values[k] = Configuracion.get_value(k, d) @@ -617,6 +635,24 @@ class Configuracion(BaseModel): values = {r.clave: r.valor for r in data} return values + def _save_pac(cls, values): + pac = values['lst_pac'] + user = values['user_timbrado'] + token = values['token_timbrado'] + + data = { + 'lst_pac': pac, + f'user_timbrado_{pac}': user, + f'token_timbrado_{pac}': token, + } + + for k, v in data.items(): + obj, _ = Configuracion.get_or_create(clave=k) + obj.valor = v + obj.save() + + return {'ok': True} + @classmethod def add(cls, values): opt = values.pop('opt', '') @@ -1004,8 +1040,8 @@ class Emisor(BaseModel): 'ong_autorizacion': obj.autorizacion, 'ong_fecha': obj.fecha_autorizacion, 'ong_fecha_dof': obj.fecha_dof, - 'correo_timbrado': obj.correo_timbrado, - 'token_timbrado': obj.token_timbrado, + # ~ 'correo_timbrado': obj.correo_timbrado, + # ~ 'token_timbrado': obj.token_timbrado, 'token_soporte': obj.token_soporte, 'emisor_registro_patronal': obj.registro_patronal, 'regimenes': [row.id for row in obj.regimenes] diff --git a/source/static/js/controller/admin.js b/source/static/js/controller/admin.js index 398dd04..956faa4 100644 --- a/source/static/js/controller/admin.js +++ b/source/static/js/controller/admin.js @@ -135,6 +135,7 @@ var controllers = { $$('chk_ticket_user_show_doc').attachEvent('onItemClick', chk_config_item_click) $$('txt_ticket_printer').attachEvent('onKeyPress', txt_ticket_printer_key_press) $$('lst_pac').attachEvent('onChange', lst_pac_on_change) + $$('cmd_save_pac').attachEvent('onItemClick', cmd_save_pac_click) $$('cmd_subir_bdfl').attachEvent('onItemClick', cmd_subir_bdfl_click) $$('cmd_subir_cfdixml').attachEvent('onItemClick', cmd_subir_cfdixml_click) @@ -480,7 +481,7 @@ function get_config_values(opt){ var values = data.json() Object.keys(values).forEach(function(key){ if(key=='lst_pac'){ - set_value(key, values[key]) + $$('lst_pac').setValue(values[key]) }else{ $$(key).setValue(values[key]) if(key=='chk_config_leyendas_fiscales'){ @@ -2558,37 +2559,6 @@ function opt_make_pdf_from_on_change(new_value, old_value){ } -function lst_pac_on_change(nv, ov){ - if(nv=='default'){ - webix.ajax().del('/config', {id: 'lst_pac'}, function(text, xml, xhr){ - var msg = 'PAC predeterminado establecido correctamente' - if(xhr.status == 200){ - msg_ok(msg) - }else{ - msg = 'No se pudo eliminar' - msg_error(msg) - } - }) - }else{ - webix.ajax().post('/config', {'lst_pac': nv}, { - error: function(text, data, xhr) { - msg = 'Error al guardar la configuración' - msg_error(msg) - }, - success: function(text, data, xhr) { - var values = data.json(); - if (values.ok){ - msg = 'PAC establecido correctamente' - msg_ok(msg) - }else{ - msg_error(values.msg) - } - } - }) - } -} - - function admin_config_other_options(id){ if(id=='chk_config_leyendas_fiscales'){ var value = Boolean($$(id).getValue()) @@ -2693,3 +2663,66 @@ function delete_leyenda_fiscal(id){ } }) } + + +function lst_pac_on_change(nv, ov){ + webix.ajax().get('/config', {'fields': 'pac', 'pac': nv}, { + error: function(text, data, xhr) { + msg = 'Error al consultar' + msg_error(msg) + }, + success: function(text, data, xhr) { + var values = data.json() + Object.keys(values).forEach(function(key){ + set_value(key, values[key]) + }) + } + }) + +} + + +function cmd_save_pac_click(){ + var pac = $$('lst_pac').getValue() + var user = $$('user_timbrado').getValue() + var token = $$('token_timbrado').getValue() + + if(!pac.trim()){ + msg = 'Selecciona un PAC' + msg_error(msg) + return + } + if(!user.trim()){ + msg = 'El Usuario es requerido' + msg_error(msg) + return + } + if(!token.trim()){ + msg = 'El Token es requerido' + msg_error(msg) + return + } + + var values = { + opt: 'save_pac', + lst_pac: pac, + user_timbrado: user, + token_timbrado: token, + } + + webix.ajax().post('/config', values, { + error: function(text, data, xhr) { + msg = 'Error al guardar el PAC' + msg_error(msg) + }, + success: function(text, data, xhr) { + var values = data.json(); + if (values.ok){ + msg = 'PAC guardado correctamente' + msg_ok(msg) + }else{ + msg_error(values.msg) + } + } + }) +} diff --git a/source/static/js/ui/admin.js b/source/static/js/ui/admin.js index b0de215..2805483 100644 --- a/source/static/js/ui/admin.js +++ b/source/static/js/ui/admin.js @@ -238,11 +238,11 @@ var emisor_otros_datos= [ {cols: [{view: 'datepicker', id: 'ong_fecha_dof', name: 'ong_fecha_dof', label: 'Fecha de DOF: ', disabled: true, format: '%d-%M-%Y', placeholder: 'Fecha de publicación en el DOF'}, {}]}, - {template: 'Timbrado y Soporte', type: 'section'}, - {view: 'text', id: 'correo_timbrado', - name: 'correo_timbrado', label: 'Usuario para Timbrado: '}, - {view: 'text', id: 'token_timbrado', - name: 'token_timbrado', label: 'Token de Timbrado: '}, + {template: 'Soporte', type: 'section'}, + //~ {view: 'text', id: 'correo_timbrado', + //~ name: 'correo_timbrado', label: 'Usuario para Timbrado: '}, + //~ {view: 'text', id: 'token_timbrado', + //~ name: 'token_timbrado', label: 'Token de Timbrado: '}, {view: 'text', id: 'token_soporte', name: 'token_soporte', label: 'Token de Soporte: '}, ] @@ -644,7 +644,7 @@ var options_templates = [ var options_pac = [ - {id: 'default', value: 'Predeterminado'}, + {id: 'finkok', value: 'Finkok'}, {id: 'comercio', value: 'Comercio Digital'}, ] @@ -690,12 +690,26 @@ var options_admin_otros = [ {}, ]}, {maxHeight: 15}, + + {template: 'Timbrado', type: 'section'}, {cols: [{maxWidth: 15}, {view: 'richselect', id: 'lst_pac', name: 'lst_pac', width: 300, - label: 'PAC: ', value: 'default', required: false, - options: options_pac}, {view: 'label', label: 'NO cambies este valor, a menos que se te haya indicado'}, + label: 'PAC: ', value: '', required: true, + labelAlign: 'right', options: options_pac}, {view: 'label', + label: ' NO cambies este valor, a menos que se te haya indicado'}, + ]}, + {cols: [{maxWidth: 15}, + {view: 'text', id: 'user_timbrado', name: 'user_timbrado', + label: 'Usuario: ', labelAlign: 'right', required: true}, + {view: 'text', id: 'token_timbrado', name: 'token_timbrado', + label: 'Token: ', labelAlign: 'right', required: true}, + ]}, + {cols: [{maxWidth: 15}, {}, + {view: 'button', id: 'cmd_save_pac', label: 'Guardar', + autowidth: true, type: 'form'}, {}, ]}, {maxHeight: 20}, + {template: 'Ayudas varias', type: 'section'}, {cols: [{maxWidth: 15}, {view: 'checkbox', id: 'chk_config_anticipo', labelWidth: 0, From 75e4f2e1c0e3f76260028ee259d9d25d132e84a5 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Mon, 28 Dec 2020 22:22:36 -0600 Subject: [PATCH 03/22] Fix version in XSLT --- source/xslt/servicioconstruccion.xslt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/xslt/servicioconstruccion.xslt b/source/xslt/servicioconstruccion.xslt index 4abc2d5..0ac87d9 100644 --- a/source/xslt/servicioconstruccion.xslt +++ b/source/xslt/servicioconstruccion.xslt @@ -1,5 +1,5 @@  - + From 95399798f8ee70bc25791a07b0030d46eaaf7ecc Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Mon, 28 Dec 2020 22:28:52 -0600 Subject: [PATCH 04/22] Stamp by Pac --- source/app/controllers/utils.py | 3 ++- source/app/models/main.py | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/source/app/controllers/utils.py b/source/app/controllers/utils.py index cceda98..496cd71 100644 --- a/source/app/controllers/utils.py +++ b/source/app/controllers/utils.py @@ -52,6 +52,7 @@ import seafileapi from settings import DEBUG, DB_COMPANIES, PATHS from .comercio import PACComercioDigital +from .pac import Finkok as PACFinkok # ~ from .finkok import PACFinkok @@ -74,7 +75,7 @@ if DEBUG: PSQL = 'psql -h localhost -U postgres' PACS = { - # ~ '': PACFinkok, + 'finkok': PACFinkok, 'comercio': PACComercioDigital, } diff --git a/source/app/models/main.py b/source/app/models/main.py index 69b3bbd..a3cc98c 100644 --- a/source/app/models/main.py +++ b/source/app/models/main.py @@ -5206,10 +5206,10 @@ class Facturas(BaseModel): anticipo = False msg = 'Factura timbrada correctamente' - if pac: - result = utils.xml_stamp(obj.xml, auth, pac) - else: - result = util.timbra_xml(obj.xml, auth) + # ~ if pac: + result = utils.xml_stamp(obj.xml, auth, pac) + # ~ else: + # ~ result = util.timbra_xml(obj.xml, auth) if result['ok']: obj.xml = result['xml'] From aae856bb7416fe1ac7fc0f1db667213bf034b75d Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Tue, 29 Dec 2020 21:53:51 -0600 Subject: [PATCH 05/22] New class for Pac Comercio Digital --- source/app/controllers/pacs/__init__.py | 3 + .../pacs/comerciodigital/__init__.py | 3 + .../pacs/comerciodigital/comercio.py | 377 ++++++++++++++++++ .../pacs/comerciodigital/conf.py.example | 37 ++ source/app/controllers/util.py | 36 +- source/app/controllers/utils.py | 16 +- source/app/models/main.py | 48 ++- 7 files changed, 474 insertions(+), 46 deletions(-) create mode 100644 source/app/controllers/pacs/__init__.py create mode 100644 source/app/controllers/pacs/comerciodigital/__init__.py create mode 100644 source/app/controllers/pacs/comerciodigital/comercio.py create mode 100644 source/app/controllers/pacs/comerciodigital/conf.py.example 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') From 56e52782f4dbbf9b3f3cedb3cad25e6ea591b8a1 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Tue, 29 Dec 2020 22:05:02 -0600 Subject: [PATCH 06/22] Refactory generate files --- source/app/controllers/util.py | 2 +- source/app/models/main.py | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/source/app/controllers/util.py b/source/app/controllers/util.py index de35711..26e24d0 100644 --- a/source/app/controllers/util.py +++ b/source/app/controllers/util.py @@ -2752,7 +2752,7 @@ def sync_files(files, auth={}): return -def sync_cfdi(rfc, files): +def sync_cfdi(files): local_copy(files) if DEBUG: diff --git a/source/app/models/main.py b/source/app/models/main.py index 434f738..798dc7e 100644 --- a/source/app/models/main.py +++ b/source/app/models/main.py @@ -521,7 +521,6 @@ class Configuracion(BaseModel): 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 @@ -4268,23 +4267,22 @@ class Facturas(BaseModel): @util.run_in_thread def _sync_pdf(self, pdf, name_pdf, target): - auth = Emisor.get_auth() + # ~ auth = Emisor.get_auth() files = ( (pdf, name_pdf, target), ) - util.sync_cfdi(auth, files) + util.sync_cfdi(files) return @util.run_in_thread def _sync_xml(self, obj): emisor = Emisor.select()[0] - auth = Emisor.get_auth() name_xml = '{}{}_{}.xml'.format(obj.serie, obj.folio, obj.cliente.rfc) target = emisor.rfc + '/' + str(obj.fecha)[:7].replace('-', '/') files = ( (obj.xml, name_xml, target), ) - util.sync_cfdi(auth, files) + util.sync_cfdi(files) return @util.run_in_thread @@ -4377,7 +4375,7 @@ class Facturas(BaseModel): (obj.xml, name_xml, target), (pdf, name_pdf, target), ) - util.sync_cfdi(rfc, files) + util.sync_cfdi(files) return def _get_filter_folios(self, values): @@ -5783,7 +5781,7 @@ class PreFacturas(BaseModel): files = ( (doc, name, target), ) - util.sync_cfdi({'REPO': False}, files) + util.sync_cfdi(files) return @classmethod From 85c5a37798e4983bba04f2d07babe7e6dca39dc9 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Wed, 30 Dec 2020 22:45:57 -0600 Subject: [PATCH 07/22] Refactory class certificates --- requirements.txt | 1 + source/app/controllers/cfdi_cert.py | 256 +++++++++++++ source/app/controllers/comercio/__init__.py | 3 - source/app/controllers/comercio/comercio.py | 342 ------------------ .../app/controllers/comercio/conf.py.example | 40 -- source/app/controllers/configpac.py | 30 +- source/app/controllers/main.py | 16 + source/app/controllers/util.py | 2 +- source/app/controllers/utils.py | 49 ++- source/app/main.py | 3 +- source/app/models/db.py | 7 + source/app/models/main.py | 32 +- source/app/seafileapi/__init__.py | 5 - source/app/seafileapi/admin.py | 7 - source/app/seafileapi/client.py | 77 ---- source/app/seafileapi/exceptions.py | 25 -- source/app/seafileapi/files.py | 250 ------------- source/app/seafileapi/group.py | 22 -- source/app/seafileapi/repo.py | 99 ----- source/app/seafileapi/repos.py | 26 -- source/app/seafileapi/utils.py | 57 --- source/app/settings.py | 5 - source/static/js/controller/admin.js | 280 ++++++++------ source/static/js/ui/admin.js | 7 +- 24 files changed, 513 insertions(+), 1128 deletions(-) create mode 100644 source/app/controllers/cfdi_cert.py delete mode 100644 source/app/controllers/comercio/__init__.py delete mode 100644 source/app/controllers/comercio/comercio.py delete mode 100644 source/app/controllers/comercio/conf.py.example delete mode 100644 source/app/seafileapi/__init__.py delete mode 100644 source/app/seafileapi/admin.py delete mode 100644 source/app/seafileapi/client.py delete mode 100644 source/app/seafileapi/exceptions.py delete mode 100644 source/app/seafileapi/files.py delete mode 100644 source/app/seafileapi/group.py delete mode 100644 source/app/seafileapi/repo.py delete mode 100644 source/app/seafileapi/repos.py delete mode 100644 source/app/seafileapi/utils.py diff --git a/requirements.txt b/requirements.txt index a007210..389a78d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ pypng reportlab psycopg2-binary cryptography +xmlsec # escpos # pyusb diff --git a/source/app/controllers/cfdi_cert.py b/source/app/controllers/cfdi_cert.py new file mode 100644 index 0000000..12b19c4 --- /dev/null +++ b/source/app/controllers/cfdi_cert.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 + +import argparse +import base64 +import datetime +import getpass +from pathlib import Path + +import xmlsec +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +from cryptography import x509 +from cryptography.x509.oid import NameOID +from cryptography.x509.oid import ExtensionOID +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding + + +from .conf import TOKEN + + +class SATCertificate(object): + + def __init__(self, cer=b'', key=b'', password=''): + self._error = '' + self._init_values() + self._get_data_cer(cer) + self._get_data_key(key, password) + + def _init_values(self): + self._rfc = '' + self._serial_number = '' + self._not_before = None + self._not_after = None + self._is_fiel = False + self._are_couple = False + self._is_valid_time = False + self._cer_pem = '' + self._cer_txt = '' + self._key_enc = b'' + self._p12 = b'' + self._cer_modulus = 0 + self._key_modulus = 0 + return + + def __str__(self): + msg = '\tRFC: {}\n'.format(self.rfc) + msg += '\tNo de Serie: {}\n'.format(self.serial_number) + msg += '\tVálido desde: {}\n'.format(self.not_before) + msg += '\tVálido hasta: {}\n'.format(self.not_after) + msg += '\tEs vigente: {}\n'.format(self.is_valid_time) + msg += '\tSon pareja: {}\n'.format(self.are_couple) + msg += '\tEs FIEL: {}\n'.format(self.is_fiel) + return msg + + def __bool__(self): + return self.is_valid + + def _get_hash(self): + digest = hashes.Hash(hashes.SHA512(), default_backend()) + digest.update(self._rfc.encode()) + digest.update(self._serial_number.encode()) + digest.update(TOKEN.encode()) + return digest.finalize() + + def _get_data_cer(self, cer): + obj = x509.load_der_x509_certificate(cer, default_backend()) + self._rfc = obj.subject.get_attributes_for_oid( + NameOID.X500_UNIQUE_IDENTIFIER)[0].value.split(' ')[0] + self._serial_number = '{0:x}'.format(obj.serial_number)[1::2] + self._not_before = obj.not_valid_before + self._not_after = obj.not_valid_after + now = datetime.datetime.utcnow() + self._is_valid_time = (now > self.not_before) and (now < self.not_after) + if not self._is_valid_time: + msg = 'El certificado no es vigente' + self._error = msg + + self._is_fiel = obj.extensions.get_extension_for_oid( + ExtensionOID.KEY_USAGE).value.key_agreement + + self._cer_pem = obj.public_bytes(serialization.Encoding.PEM).decode() + self._cer_txt = ''.join(self._cer_pem.split('\n')[1:-2]) + self._cer_modulus = obj.public_key().public_numbers().n + return + + def _get_data_key(self, key, password): + self._key_enc = key + if not key or not password: + return + + try: + obj = serialization.load_der_private_key( + key, password.encode(), default_backend()) + except ValueError: + msg = 'La contraseña es incorrecta' + self._error = msg + return + + p = self._get_hash() + self._key_enc = obj.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.BestAvailableEncryption(p) + ) + + self._key_modulus = obj.public_key().public_numbers().n + self._are_couple = self._cer_modulus == self._key_modulus + if not self._are_couple: + msg = 'El CER y el KEY no son pareja' + self._error = msg + return + + def _get_key(self, password): + if not password: + password = self._get_hash() + private_key = serialization.load_pem_private_key( + self._key_enc, password=password, backend=default_backend()) + return private_key + + def _get_key_pem(self): + obj = self._get_key('') + key_pem = obj.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption() + ) + return key_pem + + # Not work + def _get_p12(self): + obj = serialization.pkcs12.serialize_key_and_certificates('test', + self.key_pem, self.cer_pem, None, + encryption_algorithm=serialization.NoEncryption() + ) + return obj + + def sign(self, data, password=''): + private_key = self._get_key(password) + firma = private_key.sign(data, padding.PKCS1v15(), hashes.SHA256()) + return base64.b64encode(firma).decode() + + def sign_xml(self, tree): + node = xmlsec.tree.find_node(tree, xmlsec.constants.NodeSignature) + ctx = xmlsec.SignatureContext() + key = xmlsec.Key.from_memory(self.key_pem, xmlsec.constants.KeyDataFormatPem) + ctx.key = key + ctx.sign(node) + node = xmlsec.tree.find_node(tree, 'X509Certificate') + node.text = self.cer_txt + return tree + + @property + def rfc(self): + return self._rfc + + @property + def serial_number(self): + return self._serial_number + + @property + def not_before(self): + return self._not_before + + @property + def not_after(self): + return self._not_after + + @property + def is_fiel(self): + return self._is_fiel + + @property + def are_couple(self): + return self._are_couple + + @property + def is_valid(self): + return not bool(self.error) + + @property + def is_valid_time(self): + return self._is_valid_time + + @property + def cer_pem(self): + return self._cer_pem.encode() + + @property + def cer_txt(self): + return self._cer_txt + + @property + def key_pem(self): + return self._get_key_pem() + + @property + def key_enc(self): + return self._key_enc + + @property + def p12(self): + return self._get_p12() + + @property + def error(self): + return self._error + + +def main(args): + # ~ contra = getpass.getpass('Introduce la contraseña del archivo KEY: ') + contra = '12345678a' + if not contra.strip(): + msg = 'La contraseña es requerida' + print(msg) + return + + path_cer = Path(args.cer) + path_key = Path(args.key) + + if not path_cer.is_file(): + msg = 'El archivo CER es necesario' + print(msg) + return + + if not path_key.is_file(): + msg = 'El archivo KEY es necesario' + print(msg) + return + + cer = path_cer.read_bytes() + key = path_key.read_bytes() + cert = SATCertificate(cer, key, contra) + + if cert.error: + print(cert.error) + else: + print(cert) + return + + +def _process_command_line_arguments(): + parser = argparse.ArgumentParser(description='CFDI Certificados') + + help = 'Archivo CER' + parser.add_argument('-c', '--cer', help=help, default='') + help = 'Archivo KEY' + parser.add_argument('-k', '--key', help=help, default='') + + args = parser.parse_args() + return args + + +if __name__ == '__main__': + args = _process_command_line_arguments() + main(args) diff --git a/source/app/controllers/comercio/__init__.py b/source/app/controllers/comercio/__init__.py deleted file mode 100644 index 195aadd..0000000 --- a/source/app/controllers/comercio/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env python3 - -from .comercio import PACComercioDigital diff --git a/source/app/controllers/comercio/comercio.py b/source/app/controllers/comercio/comercio.py deleted file mode 100644 index 8836156..0000000 --- a/source/app/controllers/comercio/comercio.py +++ /dev/null @@ -1,342 +0,0 @@ -#!/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 - - -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) - - -try: - from .conf import DEBUG, AUTH -except ImportError: - DEBUG = False - log.debug('Need make conf.py') - - -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'), - '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', - } - 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/{}' - URL = { - 'timbra': ws.format('timbre/timbrarV5.aspx'), - 'cancel': ws.format('cancela3/cancelarUuid'), - 'cancelxml': ws.format('cancela3/cancelarXml'), - '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) - self.cfdi_uuid = tree.xpath( - 'string(//cfdi:Complemento/tdf:TimbreFiscalDigital/@UUID)', - namespaces=self.NS_CFDI) - self.date_stamped = tree.xpath( - 'string(//cfdi:Complemento/tdf:TimbreFiscalDigital/@FechaTimbrado)', - namespaces=self.NS_CFDI) - - return xml.decode() - - 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) - 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.content - - 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) - tipo = 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': tipo, - } - 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.content - - 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', - } - 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 '' - - 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/comercio/conf.py.example b/source/app/controllers/comercio/conf.py.example deleted file mode 100644 index de81efb..0000000 --- a/source/app/controllers/comercio/conf.py.example +++ /dev/null @@ -1,40 +0,0 @@ -#!/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 Finkok -# ~ AUTH = Puedes usar credenciales genericas para timbrar, o exclusivas para -# ~ cada emisor -# ~ RESELLER = Algunos procesos como agregar emisores, solo pueden ser usadas -# ~ con una cuenta de reseller - - -DEBUG = False - - -AUTH = { - 'user': '', - 'pass': '', -} - - -if DEBUG: - AUTH = { - 'user': 'AAA010101AAA', - 'pass': 'PWD', - } diff --git a/source/app/controllers/configpac.py b/source/app/controllers/configpac.py index 814fefa..d4c70a0 100644 --- a/source/app/controllers/configpac.py +++ b/source/app/controllers/configpac.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 -from .conf import DEBUG, ID_INTEGRADOR, FINKOK +from .conf import DEBUG, FINKOK DEBUG = DEBUG TIMEOUT = 10 @@ -11,34 +11,6 @@ TIMEOUT = 10 PAC = 'finkok' -def ecodex(debug): - NEW_SERVER = True - auth = {'ID': ID_INTEGRADOR} - if debug: - #~ No cambies este ID de pruebas - auth = {'ID': '2b3a8764-d586-4543-9b7e-82834443f219'} - - base_url = 'https://servicios.ecodex.com.mx:4043/Servicio{}.svc?wsdl' - if NEW_SERVER: - base_url = 'https://serviciosnominas.ecodex.com.mx:4043/Servicio{}.svc?wsdl' - base_api = 'https://api.ecodex.com.mx/{}' - if debug: - base_url = 'https://wsdev.ecodex.com.mx:2045/Servicio{}.svc?wsdl' - base_api = 'https://pruebasapi.ecodex.com.mx/{}' - url = { - 'seguridad': base_url.format('Seguridad'), - 'clients': base_url.format('Clientes'), - 'timbra': base_url.format('Timbrado'), - 'token': base_api.format('token?version=2'), - 'docs': base_api.format('api/documentos'), - 'hash': base_api.format('api/Documentos/{}'), - 'codes': { - 'HASH': 'DUPLICIDAD EN HASH', - } - } - return auth, url - - #~ IMPORTANTE: Si quieres hacer pruebas, con tu propio correo de usuario y #~ contraseña, ponte en contacto con Finkok para que te asignen tus datos de #~ acceso, consulta su documentación para ver las diferentes opciones de acceso. diff --git a/source/app/controllers/main.py b/source/app/controllers/main.py index 3efcfc2..81441f1 100644 --- a/source/app/controllers/main.py +++ b/source/app/controllers/main.py @@ -632,3 +632,19 @@ class AppSociosCuentasBanco(object): req.context['result'] = self._db.partners_accounts_bank(values) resp.status = falcon.HTTP_200 + +class AppCert(object): + + def __init__(self, db): + self._db = db + + def on_get(self, req, resp): + values = req.params + req.context['result'] = self._db.cert_get(values) + resp.status = falcon.HTTP_200 + + def on_post(self, req, resp): + values = req.params + req.context['result'] = self._db.cert_post(values) + resp.status = falcon.HTTP_200 + diff --git a/source/app/controllers/util.py b/source/app/controllers/util.py index 26e24d0..4465cfa 100644 --- a/source/app/controllers/util.py +++ b/source/app/controllers/util.py @@ -68,7 +68,7 @@ from settings import DEBUG, MV, log, template_lookup, COMPANIES, DB_SAT, \ PATH_XSLT, PATH_XSLTPROC, PATH_OPENSSL, PATH_TEMPLATES, PATH_MEDIA, PRE, \ PATH_XMLSEC, TEMPLATE_CANCEL, DEFAULT_SAT_PRODUCTO, DECIMALES, DIR_FACTURAS -from settings import SEAFILE_SERVER, USAR_TOKEN, API, DECIMALES_TAX +from settings import USAR_TOKEN, API, DECIMALES_TAX from .configpac import AUTH diff --git a/source/app/controllers/utils.py b/source/app/controllers/utils.py index 0c498ab..6ba662b 100644 --- a/source/app/controllers/utils.py +++ b/source/app/controllers/utils.py @@ -48,9 +48,9 @@ from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes from dateutil import parser -import seafileapi - from settings import DEBUG, DB_COMPANIES, PATHS + +from .cfdi_cert import SATCertificate from .pacs import PACComercioDigital from .pac import Finkok as PACFinkok # ~ from .finkok import PACFinkok @@ -492,29 +492,6 @@ def _backup_db(rfc, is_mv, url_seafile): shutil.copy(path, path_target) else: log.error('\tNo existe la carpeta compartida...') - - # ~ sql = 'select correo_timbrado, token_soporte from emisor;' - # ~ args = 'psql -U postgres -d {} -Atc "{}"'.format(data['name'], sql) - # ~ result = _call(args) - # ~ if not result: - # ~ log.error('\tSin datos para backup remoto') - # ~ return - - # ~ data = result.strip().split('|') - # ~ if not data[1]: - # ~ log.error('\tSin token de soporte') - # ~ return - - # ~ email = data[0] - # ~ uuid = data[1] - # ~ email = 'hola@elmau.net' - # ~ uuid = 'cc42c591-cf66-499a-ae70-c09df5646be9' - - # ~ log.debug(url_seafile, email, _get_pass(rfc)) - # ~ client = seafileapi.connect(url_seafile, email, _get_pass(rfc)) - # ~ repo = client.repos.get_repo(uuid) - # ~ print(repo) - return @@ -629,14 +606,30 @@ def xml_cancel(xml, auth, cert, name): return data, result -def get_client_balance(auth, name): +def get_client_balance(auth): if DEBUG: return '-d' - pac = PACS[name]() - auth = {'usr': auth['USER'], 'pwd': auth['PASS']} + pac = PACS[auth['pac']]() balance = pac.client_balance(auth) if pac.error: balance = '-e' return balance + + +def get_cert(args): + p1 = '/home/mau/Desktop/Pruebas_EKU9003173C9/file.cer' + cer = args['cer'] + # ~ cer = cer.encode() + # ~ cer = base64.b64decode(args['cer'].encode()) + with open(p1, 'w') as f: + f.write(cer) + # ~ cer = base64.b64decode(args['cer'].encode()) + print('TYPE', type(cer)) + # ~ print(cer) + # ~ key = base64.b64decode(args['key'].encode()) + # ~ cert = SATCertificate(cer, key, args['contra']) + return + + diff --git a/source/app/main.py b/source/app/main.py index c03b858..cb28630 100644 --- a/source/app/main.py +++ b/source/app/main.py @@ -17,7 +17,7 @@ from controllers.main import (AppEmpresas, AppDocumentos, AppFiles, AppPreInvoices, AppCuentasBanco, AppMovimientosBanco, AppTickets, AppStudents, AppEmployees, AppNomina, AppInvoicePay, AppCfdiPay, AppSATBancos, AppSociosCuentasBanco, - AppSATFormaPago, AppSATLeyendaFiscales + AppSATFormaPago, AppSATLeyendaFiscales, AppCert ) @@ -62,6 +62,7 @@ api.add_route('/satbancos', AppSATBancos(db)) api.add_route('/satformapago', AppSATFormaPago(db)) api.add_route('/socioscb', AppSociosCuentasBanco(db)) api.add_route('/leyendasfiscales', AppSATLeyendaFiscales(db)) +api.add_route('/cert', AppCert(db)) session_options = { diff --git a/source/app/models/db.py b/source/app/models/db.py index 7d50926..2219a2d 100644 --- a/source/app/models/db.py +++ b/source/app/models/db.py @@ -471,6 +471,13 @@ class StorageEngine(object): def sat_leyendas_fiscales_delete(self, values): return main.SATLeyendasFiscales.remove(values) + # ~ v2 + def cert_get(self, values): + return main.Certificado.get_data(values) + + def cert_post(self, values): + return main.Certificado.post(values) + # Companies only in MV def _get_empresas(self, values): return main.companies_get() diff --git a/source/app/models/main.py b/source/app/models/main.py index 798dc7e..3fdcb54 100644 --- a/source/app/models/main.py +++ b/source/app/models/main.py @@ -1165,19 +1165,35 @@ class Certificado(BaseModel): return self.serie @classmethod - def get_cert(cls, is_fiel=False): - return Certificado.get(Certificado.es_fiel==is_fiel) - - @classmethod - def get_data(cls): - obj = cls.get_(cls) - row = { + def _get_cert(cls, args): + obj = Certificado.get(Certificado.es_fiel==False) + data = { 'cert_rfc': obj.rfc, 'cert_serie': obj.serie, 'cert_desde': obj.desde, 'cert_hasta': obj.hasta, } - return row + return data + + @classmethod + def get_data(cls, values): + opt = values['opt'] + return getattr(cls, f'_get_{opt}')(values) + + @classmethod + def _validate(cls, args): + cert = utils.get_cert(args) + print(cert) + return {'ok': False, 'msg': 'error', 'data': {}} + + @classmethod + def post(cls, values): + opt = values['opt'] + return getattr(cls, f'_{opt}')(values) + + # ~ @classmethod + # ~ def get_cert(cls, is_fiel=False): + # ~ return Certificado.get(Certificado.es_fiel==is_fiel) def get_(cls): return Certificado.select()[0] diff --git a/source/app/seafileapi/__init__.py b/source/app/seafileapi/__init__.py deleted file mode 100644 index d6c3b8d..0000000 --- a/source/app/seafileapi/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from seafileapi.client import SeafileApiClient - -def connect(server, username, password): - client = SeafileApiClient(server, username, password) - return client diff --git a/source/app/seafileapi/admin.py b/source/app/seafileapi/admin.py deleted file mode 100644 index 08fc2c4..0000000 --- a/source/app/seafileapi/admin.py +++ /dev/null @@ -1,7 +0,0 @@ - -class SeafileAdmin(object): - def lists_users(self, maxcount=100): - pass - - def list_user_repos(self, username): - pass diff --git a/source/app/seafileapi/client.py b/source/app/seafileapi/client.py deleted file mode 100644 index 52a6ea6..0000000 --- a/source/app/seafileapi/client.py +++ /dev/null @@ -1,77 +0,0 @@ -import requests -from seafileapi.utils import urljoin -from seafileapi.exceptions import ClientHttpError -from seafileapi.repos import Repos - -class SeafileApiClient(object): - """Wraps seafile web api""" - def __init__(self, server, username=None, password=None, token=None): - """Wraps various basic operations to interact with seahub http api. - """ - self.server = server - self.username = username - self.password = password - self._token = token - - self.repos = Repos(self) - self.groups = Groups(self) - - if token is None: - self._get_token() - - def _get_token(self): - data = { - 'username': self.username, - 'password': self.password, - } - url = urljoin(self.server, '/api2/auth-token/') - res = requests.post(url, data=data) - if res.status_code != 200: - raise ClientHttpError(res.status_code, res.content) - token = res.json()['token'] - assert len(token) == 40, 'The length of seahub api auth token should be 40' - self._token = token - - def __str__(self): - return 'SeafileApiClient[server=%s, user=%s]' % (self.server, self.username) - - __repr__ = __str__ - - def get(self, *args, **kwargs): - return self._send_request('GET', *args, **kwargs) - - def post(self, *args, **kwargs): - return self._send_request('POST', *args, **kwargs) - - def put(self, *args, **kwargs): - return self._send_request('PUT', *args, **kwargs) - - def delete(self, *args, **kwargs): - return self._send_request('delete', *args, **kwargs) - - def _send_request(self, method, url, *args, **kwargs): - if not url.startswith('http'): - url = urljoin(self.server, url) - - headers = kwargs.get('headers', {}) - headers.setdefault('Authorization', 'Token ' + self._token) - kwargs['headers'] = headers - - expected = kwargs.pop('expected', 200) - if not hasattr(expected, '__iter__'): - expected = (expected, ) - resp = requests.request(method, url, *args, **kwargs) - if resp.status_code not in expected: - msg = 'Expected %s, but get %s' % \ - (' or '.join(map(str, expected)), resp.status_code) - raise ClientHttpError(resp.status_code, msg) - - return resp - - -class Groups(object): - def __init__(self, client): - pass - - def create_group(self, name): - pass diff --git a/source/app/seafileapi/exceptions.py b/source/app/seafileapi/exceptions.py deleted file mode 100644 index b11498d..0000000 --- a/source/app/seafileapi/exceptions.py +++ /dev/null @@ -1,25 +0,0 @@ - -class ClientHttpError(Exception): - """This exception is raised if the returned http response is not as - expected""" - def __init__(self, code, message): - super(ClientHttpError, self).__init__() - self.code = code - self.message = message - - def __str__(self): - return 'ClientHttpError[%s: %s]' % (self.code, self.message) - -class OperationError(Exception): - """Expcetion to raise when an opeartion is failed""" - pass - - -class DoesNotExist(Exception): - """Raised when not matching resource can be found.""" - def __init__(self, msg): - super(DoesNotExist, self).__init__() - self.msg = msg - - def __str__(self): - return 'DoesNotExist: %s' % self.msg diff --git a/source/app/seafileapi/files.py b/source/app/seafileapi/files.py deleted file mode 100644 index ed01e64..0000000 --- a/source/app/seafileapi/files.py +++ /dev/null @@ -1,250 +0,0 @@ -import io -import os -import posixpath -import re -from seafileapi.utils import querystr - -ZERO_OBJ_ID = '0000000000000000000000000000000000000000' - -class _SeafDirentBase(object): - """Base class for :class:`SeafFile` and :class:`SeafDir`. - - It provides implementation of their common operations. - """ - isdir = None - - def __init__(self, repo, path, object_id, size=0): - """ - :param:`path` the full path of this entry within its repo, like - "/documents/example.md" - - :param:`size` The size of a file. It should be zero for a dir. - """ - self.client = repo.client - self.repo = repo - self.path = path - self.id = object_id - self.size = size - - @property - def name(self): - return posixpath.basename(self.path) - - def list_revisions(self): - pass - - def delete(self): - suffix = 'dir' if self.isdir else 'file' - url = '/api2/repos/%s/%s/' % (self.repo.id, suffix) + querystr(p=self.path) - resp = self.client.delete(url) - return resp - - def rename(self, newname): - """Change file/folder name to newname - """ - suffix = 'dir' if self.isdir else 'file' - url = '/api2/repos/%s/%s/' % (self.repo.id, suffix) + querystr(p=self.path, reloaddir='true') - postdata = {'operation': 'rename', 'newname': newname} - resp = self.client.post(url, data=postdata) - succeeded = resp.status_code == 200 - if succeeded: - if self.isdir: - new_dirent = self.repo.get_dir(os.path.join(os.path.dirname(self.path), newname)) - else: - new_dirent = self.repo.get_file(os.path.join(os.path.dirname(self.path), newname)) - for key in list(self.__dict__.keys()): - self.__dict__[key] = new_dirent.__dict__[key] - return succeeded - - def _copy_move_task(self, operation, dirent_type, dst_dir, dst_repo_id=None): - url = '/api/v2.1/copy-move-task/' - src_repo_id = self.repo.id - src_parent_dir = os.path.dirname(self.path) - src_dirent_name = os.path.basename(self.path) - dst_repo_id = dst_repo_id - dst_parent_dir = dst_dir - operation = operation - dirent_type = dirent_type - postdata = {'src_repo_id': src_repo_id, 'src_parent_dir': src_parent_dir, - 'src_dirent_name': src_dirent_name, 'dst_repo_id': dst_repo_id, - 'dst_parent_dir': dst_parent_dir, 'operation': operation, - 'dirent_type': dirent_type} - return self.client.post(url, data=postdata) - - def copyTo(self, dst_dir, dst_repo_id=None): - """Copy file/folder to other directory (also to a different repo) - """ - if dst_repo_id is None: - dst_repo_id = self.repo.id - - dirent_type = 'dir' if self.isdir else 'file' - resp = self._copy_move_task('copy', dirent_type, dst_dir, dst_repo_id) - return resp.status_code == 200 - - def moveTo(self, dst_dir, dst_repo_id=None): - """Move file/folder to other directory (also to a different repo) - """ - if dst_repo_id is None: - dst_repo_id = self.repo.id - - dirent_type = 'dir' if self.isdir else 'file' - resp = self._copy_move_task('move', dirent_type, dst_dir, dst_repo_id) - succeeded = resp.status_code == 200 - if succeeded: - new_repo = self.client.repos.get_repo(dst_repo_id) - dst_path = os.path.join(dst_dir, os.path.basename(self.path)) - if self.isdir: - new_dirent = new_repo.get_dir(dst_path) - else: - new_dirent = new_repo.get_file(dst_path) - for key in list(self.__dict__.keys()): - self.__dict__[key] = new_dirent.__dict__[key] - return succeeded - - def get_share_link(self): - pass - -class SeafDir(_SeafDirentBase): - isdir = True - - def __init__(self, *args, **kwargs): - super(SeafDir, self).__init__(*args, **kwargs) - self.entries = None - self.entries = kwargs.pop('entries', None) - - def ls(self, force_refresh=False): - """List the entries in this dir. - - Return a list of objects of class :class:`SeafFile` or :class:`SeafDir`. - """ - if self.entries is None or force_refresh: - self.load_entries() - - return self.entries - - def share_to_user(self, email, permission): - url = '/api2/repos/%s/dir/shared_items/' % self.repo.id + querystr(p=self.path) - putdata = { - 'share_type': 'user', - 'username': email, - 'permission': permission - } - resp = self.client.put(url, data=putdata) - return resp.status_code == 200 - - def create_empty_file(self, name): - """Create a new empty file in this dir. - Return a :class:`SeafFile` object of the newly created file. - """ - # TODO: file name validation - path = posixpath.join(self.path, name) - url = '/api2/repos/%s/file/' % self.repo.id + querystr(p=path, reloaddir='true') - postdata = {'operation': 'create'} - resp = self.client.post(url, data=postdata) - self.id = resp.headers['oid'] - self.load_entries(resp.json()) - return SeafFile(self.repo, path, ZERO_OBJ_ID, 0) - - def mkdir(self, name): - """Create a new sub folder right under this dir. - - Return a :class:`SeafDir` object of the newly created sub folder. - """ - path = posixpath.join(self.path, name) - url = '/api2/repos/%s/dir/' % self.repo.id + querystr(p=path, reloaddir='true') - postdata = {'operation': 'mkdir'} - resp = self.client.post(url, data=postdata) - self.id = resp.headers['oid'] - self.load_entries(resp.json()) - return SeafDir(self.repo, path, ZERO_OBJ_ID) - - def upload(self, fileobj, filename): - """Upload a file to this folder. - - :param:fileobj :class:`File` like object - :param:filename The name of the file - - Return a :class:`SeafFile` object of the newly uploaded file. - """ - if isinstance(fileobj, str): - fileobj = io.BytesIO(fileobj) - upload_url = self._get_upload_link() - files = { - 'file': (filename, fileobj), - 'parent_dir': self.path, - } - self.client.post(upload_url, files=files) - return self.repo.get_file(posixpath.join(self.path, filename)) - - def upload_local_file(self, filepath, name=None): - """Upload a file to this folder. - - :param:filepath The path to the local file - :param:name The name of this new file. If None, the name of the local file would be used. - - Return a :class:`SeafFile` object of the newly uploaded file. - """ - name = name or os.path.basename(filepath) - with open(filepath, 'r') as fp: - return self.upload(fp, name) - - def _get_upload_link(self): - url = '/api2/repos/%s/upload-link/' % self.repo.id - resp = self.client.get(url) - return re.match(r'"(.*)"', resp.text).group(1) - - def get_uploadable_sharelink(self): - """Generate a uploadable shared link to this dir. - - Return the url of this link. - """ - pass - - def load_entries(self, dirents_json=None): - if dirents_json is None: - url = '/api2/repos/%s/dir/' % self.repo.id + querystr(p=self.path) - dirents_json = self.client.get(url).json() - - self.entries = [self._load_dirent(entry_json) for entry_json in dirents_json] - - def _load_dirent(self, dirent_json): - path = posixpath.join(self.path, dirent_json['name']) - if dirent_json['type'] == 'file': - return SeafFile(self.repo, path, dirent_json['id'], dirent_json['size']) - else: - return SeafDir(self.repo, path, dirent_json['id'], 0) - - @property - def num_entries(self): - if self.entries is None: - self.load_entries() - return len(self.entries) if self.entries is not None else 0 - - def __str__(self): - return 'SeafDir[repo=%s,path=%s,entries=%s]' % \ - (self.repo.id[:6], self.path, self.num_entries) - - __repr__ = __str__ - -class SeafFile(_SeafDirentBase): - isdir = False - - def update(self, fileobj): - """Update the content of this file""" - pass - - def __str__(self): - return 'SeafFile[repo=%s,path=%s,size=%s]' % \ - (self.repo.id[:6], self.path, self.size) - - def _get_download_link(self): - url = '/api2/repos/%s/file/' % self.repo.id + querystr(p=self.path) - resp = self.client.get(url) - return re.match(r'"(.*)"', resp.text).group(1) - - def get_content(self): - """Get the content of the file""" - url = self._get_download_link() - return self.client.get(url).content - - __repr__ = __str__ diff --git a/source/app/seafileapi/group.py b/source/app/seafileapi/group.py deleted file mode 100644 index 731d7ef..0000000 --- a/source/app/seafileapi/group.py +++ /dev/null @@ -1,22 +0,0 @@ - - -class Group(object): - def __init__(self, client, group_id, group_name): - self.client = client - self.group_id = group_id - self.group_name = group_name - - def list_memebers(self): - pass - - def delete(self): - pass - - def add_member(self, username): - pass - - def remove_member(self, username): - pass - - def list_group_repos(self): - pass diff --git a/source/app/seafileapi/repo.py b/source/app/seafileapi/repo.py deleted file mode 100644 index 01811a2..0000000 --- a/source/app/seafileapi/repo.py +++ /dev/null @@ -1,99 +0,0 @@ -from urllib.parse import urlencode -from seafileapi.files import SeafDir, SeafFile -from seafileapi.utils import raise_does_not_exist - -class Repo(object): - """ - A seafile library - """ - def __init__(self, client, repo_id, repo_name, - encrypted, owner, perm): - self.client = client - self.id = repo_id - self.name = repo_name - self.encrypted = encrypted - self.owner = owner - self.perm = perm - - @classmethod - def from_json(cls, client, repo_json): - - repo_id = repo_json['id'] - repo_name = repo_json['name'] - encrypted = repo_json['encrypted'] - perm = repo_json['permission'] - owner = repo_json['owner'] - - return cls(client, repo_id, repo_name, encrypted, owner, perm) - - def is_readonly(self): - return 'w' not in self.perm - - @raise_does_not_exist('The requested file does not exist') - def get_file(self, path): - """Get the file object located in `path` in this repo. - - Return a :class:`SeafFile` object - """ - assert path.startswith('/') - url = '/api2/repos/%s/file/detail/' % self.id - query = '?' + urlencode(dict(p=path)) - file_json = self.client.get(url + query).json() - - return SeafFile(self, path, file_json['id'], file_json['size']) - - @raise_does_not_exist('The requested dir does not exist') - def get_dir(self, path): - """Get the dir object located in `path` in this repo. - - Return a :class:`SeafDir` object - """ - assert path.startswith('/') - url = '/api2/repos/%s/dir/' % self.id - query = '?' + urlencode(dict(p=path)) - resp = self.client.get(url + query) - dir_id = resp.headers['oid'] - dir_json = resp.json() - dir = SeafDir(self, path, dir_id) - dir.load_entries(dir_json) - return dir - - def delete(self): - """Remove this repo. Only the repo owner can do this""" - self.client.delete('/api2/repos/' + self.id) - - def list_history(self): - """List the history of this repo - - Returns a list of :class:`RepoRevision` object. - """ - pass - - ## Operations only the repo owner can do: - - def update(self, name=None): - """Update the name of this repo. Only the repo owner can do - this. - """ - pass - - def get_settings(self): - """Get the settings of this repo. Returns a dict containing the following - keys: - - `history_limit`: How many days of repo history to keep. - """ - pass - - def restore(self, commit_id): - pass - -class RepoRevision(object): - def __init__(self, client, repo, commit_id): - self.client = client - self.repo = repo - self.commit_id = commit_id - - def restore(self): - """Restore the repo to this revision""" - self.repo.revert(self.commit_id) diff --git a/source/app/seafileapi/repos.py b/source/app/seafileapi/repos.py deleted file mode 100644 index 70a8fa7..0000000 --- a/source/app/seafileapi/repos.py +++ /dev/null @@ -1,26 +0,0 @@ -from seafileapi.repo import Repo -from seafileapi.utils import raise_does_not_exist - -class Repos(object): - def __init__(self, client): - self.client = client - - def create_repo(self, name, password=None): - data = {'name': name} - if password: - data['passwd'] = password - repo_json = self.client.post('/api2/repos/', data=data).json() - return self.get_repo(repo_json['repo_id']) - - @raise_does_not_exist('The requested library does not exist') - def get_repo(self, repo_id): - """Get the repo which has the id `repo_id`. - - Raises :exc:`DoesNotExist` if no such repo exists. - """ - repo_json = self.client.get('/api2/repos/' + repo_id).json() - return Repo.from_json(self.client, repo_json) - - def list_repos(self): - repos_json = self.client.get('/api2/repos/').json() - return [Repo.from_json(self.client, j) for j in repos_json] diff --git a/source/app/seafileapi/utils.py b/source/app/seafileapi/utils.py deleted file mode 100644 index 7903414..0000000 --- a/source/app/seafileapi/utils.py +++ /dev/null @@ -1,57 +0,0 @@ -import string -import random -from functools import wraps -from urllib.parse import urlencode -from seafileapi.exceptions import ClientHttpError, DoesNotExist - -def randstring(length=0): - if length == 0: - length = random.randint(1, 30) - return ''.join(random.choice(string.lowercase) for i in range(length)) - -def urljoin(base, *args): - url = base - if url[-1] != '/': - url += '/' - for arg in args: - arg = arg.strip('/') - url += arg + '/' - if '?' in url: - url = url[:-1] - return url - -def raise_does_not_exist(msg): - """Decorator to turn a function that get a http 404 response to a - :exc:`DoesNotExist` exception.""" - def decorator(func): - @wraps(func) - def wrapped(*args, **kwargs): - try: - return func(*args, **kwargs) - except ClientHttpError as e: - if e.code == 404: - raise DoesNotExist(msg) - else: - raise - return wrapped - return decorator - -def to_utf8(obj): - if isinstance(obj, str): - return obj.encode('utf-8') - return obj - -def querystr(**kwargs): - return '?' + urlencode(kwargs) - -def utf8lize(obj): - if isinstance(obj, dict): - return {k: to_utf8(v) for k, v in obj.items()} - - if isinstance(obj, list): - return [to_utf8(x) for x in ob] - - if instance(obj, str): - return obj.encode('utf-8') - - return obj diff --git a/source/app/settings.py b/source/app/settings.py index 72ced22..ed389e4 100644 --- a/source/app/settings.py +++ b/source/app/settings.py @@ -30,11 +30,6 @@ try: except ImportError: DEFAULT_PASSWORD = 'salgueiro3.3' -try: - from conf import SEAFILE_SERVER -except ImportError: - SEAFILE_SERVER = {} - try: from conf import TITLE_APP except ImportError: diff --git a/source/static/js/controller/admin.js b/source/static/js/controller/admin.js index 956faa4..80a3b82 100644 --- a/source/static/js/controller/admin.js +++ b/source/static/js/controller/admin.js @@ -19,6 +19,9 @@ var msg = '' var tb_options = null var tb_sat = null +var file_cer = null +var file_key = null + var controllers = { init: function(){ @@ -32,7 +35,9 @@ var controllers = { $$('chk_escuela').attachEvent('onChange', chk_escuela_change) $$('chk_ong').attachEvent('onChange', chk_ong_change) $$('cmd_subir_certificado').attachEvent('onItemClick', cmd_subir_certificado_click) - $$('up_cert').attachEvent('onUploadComplete', up_cert_upload_complete) + //~ $$('up_cert').attachEvent('onUploadComplete', up_cert_upload_complete) + //~ $$('up_cert').attachEvent('onAfterFileAdd', up_cert_after_file_add) + $$('up_cert').attachEvent('onBeforeFileAdd', up_cert_before_file_add) $$('cmd_agregar_serie').attachEvent('onItemClick', cmd_agregar_serie_click) $$('grid_folios').attachEvent('onItemClick', grid_folios_click) $$('chk_folio_custom').attachEvent('onItemClick', chk_config_item_click) @@ -280,7 +285,7 @@ function get_emisor(){ function get_certificado(){ var form = $$('form_cert') - webix.ajax().get("/values/cert", {}, { + webix.ajax().get("/cert", {'opt': 'cert'}, { error: function(text, data, xhr) { msg = 'Error al consultar' msg_error(msg) @@ -602,105 +607,6 @@ function chk_ong_change(new_value, old_value){ } -function cmd_subir_certificado_click(){ - var form = $$('form_upload') - - if (!form.validate()){ - msg = 'Valores inválidos' - msg_error(msg) - return - } - - var values = form.getValues() - - if(!values.contra.trim()){ - msg = 'La contraseña no puede estar vacía' - msg_error(msg) - return - } - - if($$('lst_cert').count() < 2){ - msg = 'Selecciona al menos dos archivos: CER y KEY del certificado.' - msg_error(msg) - return - } - - if($$('lst_cert').count() > 2){ - msg = 'Selecciona solo dos archivos: CER y KEY del certificado.' - msg_error(msg) - return - } - - var fo1 = $$('up_cert').files.getItem($$('up_cert').files.getFirstId()) - var fo2 = $$('up_cert').files.getItem($$('up_cert').files.getLastId()) - - var ext = ['key', 'cer'] - if(ext.indexOf(fo1.type.toLowerCase()) == -1 || ext.indexOf(fo2.type.toLowerCase()) == -1){ - msg = 'Archivos inválidos, se requiere un archivo CER y un KEY.' - msg_error(msg) - return - } - - if(fo1.type == fo2.type && fo1.size == fo2.size){ - msg = 'Selecciona archivos diferentes: un archivo CER y un KEY.' - msg_error(msg) - return - } - - var serie = $$('form_cert').getValues()['cert_serie'] - - if(serie){ - msg = 'Ya existe un certificado guardado

¿Deseas reemplazarlo?' - webix.confirm({ - title: 'Certificado Existente', - ok: 'Si', - cancel: 'No', - type: 'confirm-error', - text: msg, - callback:function(result){ - if(result){ - $$('up_cert').send() - } - } - }) - }else{ - $$('up_cert').send() - } -} - - -function up_cert_upload_complete(response){ - if(response.status != 'server'){ - msg = 'Ocurrio un error al subir los archivos' - msg_error(msg) - return - } - - msg = 'Archivos subidos correctamente. Esperando validación' - msg_ok(msg) - - var values = $$('form_upload').getValues() - $$('form_upload').setValues({}) - $$('up_cert').files.data.clearAll() - - webix.ajax().post('/values/cert', values, { - error:function(text, data, XmlHttpRequest){ - msg = 'Ocurrio un error, consulta a soporte técnico' - msg_error(msg) - }, - success:function(text, data, XmlHttpRequest){ - var values = data.json() - if(values.ok){ - $$('form_cert').setValues(values.data) - msg_ok(values.msg) - }else{ - msg_error(values.msg) - } - } - }) -} - - function cmd_agregar_serie_click(){ var form = $$('form_folios') var grid = $$('grid_folios') @@ -2726,3 +2632,175 @@ function cmd_save_pac_click(){ } }) } + + +function cmd_subir_certificado_click(){ + var form = $$('form_upload') + + if (!form.validate()){ + msg = 'Valores inválidos' + msg_error(msg) + return + } + + var values = form.getValues() + + if(!values.contra.trim()){ + msg = 'La contraseña no puede estar vacía' + msg_error(msg) + return + } + + //~ if($$('lst_cert').count() < 2){ + //~ msg = 'Selecciona al menos dos archivos: CER y KEY del certificado.' + //~ msg_error(msg) + //~ return + //~ } + + //~ if($$('lst_cert').count() > 2){ + //~ msg = 'Selecciona solo dos archivos: CER y KEY del certificado.' + //~ msg_error(msg) + //~ return + //~ } + + //~ var fo1 = $$('up_cert').files.getItem($$('up_cert').files.getFirstId()) + //~ var fo2 = $$('up_cert').files.getItem($$('up_cert').files.getLastId()) + + //~ var ext = ['key', 'cer'] + //~ if(ext.indexOf(fo1.type.toLowerCase()) == -1 || ext.indexOf(fo2.type.toLowerCase()) == -1){ + //~ msg = 'Archivos inválidos, se requiere un archivo CER y un KEY.' + //~ msg_error(msg) + //~ return + //~ } + + //~ if(fo1.type == fo2.type && fo1.size == fo2.size){ + //~ msg = 'Selecciona archivos diferentes: un archivo CER y un KEY.' + //~ msg_error(msg) + //~ return + //~ } + + var serie = $$('form_cert').getValues()['cert_serie'] + + if(serie){ + msg = 'Ya existe un certificado guardado

¿Deseas reemplazarlo?' + webix.confirm({ + title: 'Certificado Existente', + ok: 'Si', + cancel: 'No', + type: 'confirm-error', + text: msg, + callback:function(result){ + if(!result){ + return + } + } + }) + } + + //~ if (fo1.type.toLowerCase()=='cer'){ + //~ values['cer'] = fo1.file + //~ values['key'] = fo2.file + //~ } else { + //~ values['key'] = fo1.file + //~ values['cer'] = fo2.file + //~ } + $$('form_upload').setValues({}) + $$('up_cert').files.data.clearAll() + + values['cer'] = file_cer + values['key'] = file_key + validate_cert(values) +} + + +function up_cert_before_file_add(file){ + if (file.type.toLowerCase() != 'cer' && file.type.toLowerCase() != 'key'){ + msg_error('Selecciona un archivo CER o KEY') + return false + } + + var count = $$('lst_cert').count() + if (count > 1){ + msg = 'Selecciona solo dos archivos: CER y KEY del certificado.' + msg_error(msg) + return false + } + + if (count > 0){ + var f = $$('up_cert').files.getItem($$('up_cert').files.getFirstId()) + if (f.type.toLowerCase() == file.type.toLowerCase()){ + msg = 'Selecciona archivos diferentes: un archivo CER y un KEY.' + msg_error(msg) + return false + } + } + + var reader = new FileReader(); + if (file.type.toLowerCase() == 'cer'){ + reader.addEventListener('load', (event) => { + file_cer = event.target.result; + }); + reader.readAsBinaryString(file.file); + } else { + reader.addEventListener('load', (event) => { + file_key = event.target.result; + }); + reader.readAsBinaryString(file.file); + } +} + + +function validate_cert(values){ + msg = 'Archivos recibidos correctamente. Esperando validación' + msg_ok(msg) + + values['opt'] = 'validate' + webix.ajax().post('/cert', values, { + error:function(text, data, XmlHttpRequest){ + msg = 'Ocurrio un error, consulta a soporte técnico' + msg_error(msg) + }, + success:function(text, data, XmlHttpRequest){ + var values = data.json() + if(values.ok){ + $$('form_cert').setValues(values.data) + msg_ok(values.msg) + }else{ + msg_error(values.msg) + } + } + }) +} + + +//~ function up_cert_upload_complete(response){ + //~ if(response.status != 'server'){ + //~ msg = 'Ocurrio un error al subir los archivos' + //~ msg_error(msg) + //~ return + //~ } + + //~ msg = 'Archivos subidos correctamente. Esperando validación' + //~ msg_ok(msg) + + //~ var values = $$('form_upload').getValues() + //~ $$('form_upload').setValues({}) + //~ $$('up_cert').files.data.clearAll() + //~ values['opt'] = 'validate' + + //~ webix.ajax().post('/cert', values, { + //~ error:function(text, data, XmlHttpRequest){ + //~ msg = 'Ocurrio un error, consulta a soporte técnico' + //~ msg_error(msg) + //~ }, + //~ success:function(text, data, XmlHttpRequest){ + //~ var values = data.json() + //~ if(values.ok){ + //~ $$('form_cert').setValues(values.data) + //~ msg_ok(values.msg) + //~ }else{ + //~ msg_error(values.msg) + //~ } + //~ } + //~ }) +//~ } diff --git a/source/static/js/ui/admin.js b/source/static/js/ui/admin.js index 2805483..0e02fbd 100644 --- a/source/static/js/ui/admin.js +++ b/source/static/js/ui/admin.js @@ -278,18 +278,21 @@ var col_fiel = {rows: [ ]} + //~ {view: 'uploader', id: 'up_cert', autosend: false, link: 'lst_cert', + //~ value: 'Seleccionar certificado', upload: '/values/files'}, {}]}, + var emisor_certificado = [ {cols: [col_sello, col_fiel]}, {template: 'Cargar Certificado', type: 'section'}, {view: 'form', id: 'form_upload', rows: [ {cols: [{}, {view: 'uploader', id: 'up_cert', autosend: false, link: 'lst_cert', - value: 'Seleccionar certificado', upload: '/values/files'}, {}]}, + value: 'Seleccionar certificado'}, {}]}, {cols: [{}, {view: 'list', id: 'lst_cert', name: 'certificado', type: 'uploader', autoheight:true, borderless: true}, {}]}, {cols: [{}, - {view: 'text', id: 'txt_contra', name: 'contra', + {view: 'text', id: 'txt_contra', name: 'contra', value: '12345678a', label: 'Contraseña KEY', labelPosition: 'top', labelAlign: 'center', type: 'password', required: true}, {}]}, {cols: [{}, {view: 'button', id: 'cmd_subir_certificado', From d8ecae2c8f9150d9849eb8f80a16ae1f05311615 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Thu, 31 Dec 2020 00:01:41 -0600 Subject: [PATCH 08/22] Refactory upload certificates --- source/app/controllers/cfdi_cert.py | 6 +++ source/app/controllers/utils.py | 16 ++---- source/app/models/main.py | 42 +++++++++++----- source/static/js/controller/admin.js | 73 ++-------------------------- source/static/js/ui/admin.js | 2 +- 5 files changed, 43 insertions(+), 96 deletions(-) diff --git a/source/app/controllers/cfdi_cert.py b/source/app/controllers/cfdi_cert.py index 12b19c4..49b72dd 100644 --- a/source/app/controllers/cfdi_cert.py +++ b/source/app/controllers/cfdi_cert.py @@ -35,6 +35,7 @@ class SATCertificate(object): self._is_fiel = False self._are_couple = False self._is_valid_time = False + self._cer = b'' self._cer_pem = '' self._cer_txt = '' self._key_enc = b'' @@ -64,6 +65,7 @@ class SATCertificate(object): return digest.finalize() def _get_data_cer(self, cer): + self._cer = cer obj = x509.load_der_x509_certificate(cer, default_backend()) self._rfc = obj.subject.get_attributes_for_oid( NameOID.X500_UNIQUE_IDENTIFIER)[0].value.split(' ')[0] @@ -182,6 +184,10 @@ class SATCertificate(object): def is_valid_time(self): return self._is_valid_time + @property + def cer(self): + return self._cer + @property def cer_pem(self): return self._cer_pem.encode() diff --git a/source/app/controllers/utils.py b/source/app/controllers/utils.py index 6ba662b..413df7e 100644 --- a/source/app/controllers/utils.py +++ b/source/app/controllers/utils.py @@ -619,17 +619,9 @@ def get_client_balance(auth): def get_cert(args): - p1 = '/home/mau/Desktop/Pruebas_EKU9003173C9/file.cer' - cer = args['cer'] - # ~ cer = cer.encode() - # ~ cer = base64.b64decode(args['cer'].encode()) - with open(p1, 'w') as f: - f.write(cer) - # ~ cer = base64.b64decode(args['cer'].encode()) - print('TYPE', type(cer)) - # ~ print(cer) - # ~ key = base64.b64decode(args['key'].encode()) - # ~ cert = SATCertificate(cer, key, args['contra']) - return + cer = base64.b64decode(args['cer'].split(',')[1]) + key = base64.b64decode(args['key'].split(',')[1]) + cert = SATCertificate(cer, key, args['contra']) + return cert diff --git a/source/app/models/main.py b/source/app/models/main.py index 3fdcb54..a03c6cd 100644 --- a/source/app/models/main.py +++ b/source/app/models/main.py @@ -1181,10 +1181,36 @@ class Certificado(BaseModel): return getattr(cls, f'_get_{opt}')(values) @classmethod - def _validate(cls, args): + def _validate_cert(cls, args): + msg = 'Certificado guardado correctamente' + result = {'ok': True, 'msg': msg, 'data': {}} cert = utils.get_cert(args) - print(cert) - return {'ok': False, 'msg': 'error', 'data': {}} + if not cert.is_valid: + result['ok'] = False + result['msg'] = cert.error + return result + + obj = Certificado.get(Certificado.es_fiel==False) + if obj.rfc != cert.rfc: + result['ok'] = False + result['msg'] = 'El RFC del certificado no corresponde.' + return result + + obj.key_enc = cert.key_enc + obj.cer = cert.cer + obj.serie = cert.serial_number + obj.desde = cert.not_before + obj.hasta = cert.not_after + obj.save() + + data = { + 'cert_rfc': obj.rfc, + 'cert_serie': obj.serie, + 'cert_desde': obj.desde, + 'cert_hasta': obj.hasta, + } + result['data'] = data + return result @classmethod def post(cls, values): @@ -1198,16 +1224,6 @@ class Certificado(BaseModel): def get_(cls): return Certificado.select()[0] - @classmethod - def add(cls, file_obj): - if file_obj.filename.endswith('key'): - path_key = util.save_temp(file_obj.file.read()) - Configuracion.add({'path_key': path_key}) - elif file_obj.filename.endswith('cer'): - path_cer = util.save_temp(file_obj.file.read()) - Configuracion.add({'path_cer': path_cer}) - return {'status': 'server'} - @classmethod def validate(cls, values, session): row = {} diff --git a/source/static/js/controller/admin.js b/source/static/js/controller/admin.js index 80a3b82..b004781 100644 --- a/source/static/js/controller/admin.js +++ b/source/static/js/controller/admin.js @@ -2651,34 +2651,6 @@ function cmd_subir_certificado_click(){ return } - //~ if($$('lst_cert').count() < 2){ - //~ msg = 'Selecciona al menos dos archivos: CER y KEY del certificado.' - //~ msg_error(msg) - //~ return - //~ } - - //~ if($$('lst_cert').count() > 2){ - //~ msg = 'Selecciona solo dos archivos: CER y KEY del certificado.' - //~ msg_error(msg) - //~ return - //~ } - - //~ var fo1 = $$('up_cert').files.getItem($$('up_cert').files.getFirstId()) - //~ var fo2 = $$('up_cert').files.getItem($$('up_cert').files.getLastId()) - - //~ var ext = ['key', 'cer'] - //~ if(ext.indexOf(fo1.type.toLowerCase()) == -1 || ext.indexOf(fo2.type.toLowerCase()) == -1){ - //~ msg = 'Archivos inválidos, se requiere un archivo CER y un KEY.' - //~ msg_error(msg) - //~ return - //~ } - - //~ if(fo1.type == fo2.type && fo1.size == fo2.size){ - //~ msg = 'Selecciona archivos diferentes: un archivo CER y un KEY.' - //~ msg_error(msg) - //~ return - //~ } - var serie = $$('form_cert').getValues()['cert_serie'] if(serie){ @@ -2697,13 +2669,6 @@ function cmd_subir_certificado_click(){ }) } - //~ if (fo1.type.toLowerCase()=='cer'){ - //~ values['cer'] = fo1.file - //~ values['key'] = fo2.file - //~ } else { - //~ values['key'] = fo1.file - //~ values['cer'] = fo2.file - //~ } $$('form_upload').setValues({}) $$('up_cert').files.data.clearAll() @@ -2740,12 +2705,12 @@ function up_cert_before_file_add(file){ reader.addEventListener('load', (event) => { file_cer = event.target.result; }); - reader.readAsBinaryString(file.file); + reader.readAsDataURL(file.file); } else { reader.addEventListener('load', (event) => { file_key = event.target.result; }); - reader.readAsBinaryString(file.file); + reader.readAsDataURL(file.file); } } @@ -2754,7 +2719,7 @@ function validate_cert(values){ msg = 'Archivos recibidos correctamente. Esperando validación' msg_ok(msg) - values['opt'] = 'validate' + values['opt'] = 'validate_cert' webix.ajax().post('/cert', values, { error:function(text, data, XmlHttpRequest){ msg = 'Ocurrio un error, consulta a soporte técnico' @@ -2772,35 +2737,3 @@ function validate_cert(values){ }) } - -//~ function up_cert_upload_complete(response){ - //~ if(response.status != 'server'){ - //~ msg = 'Ocurrio un error al subir los archivos' - //~ msg_error(msg) - //~ return - //~ } - - //~ msg = 'Archivos subidos correctamente. Esperando validación' - //~ msg_ok(msg) - - //~ var values = $$('form_upload').getValues() - //~ $$('form_upload').setValues({}) - //~ $$('up_cert').files.data.clearAll() - //~ values['opt'] = 'validate' - - //~ webix.ajax().post('/cert', values, { - //~ error:function(text, data, XmlHttpRequest){ - //~ msg = 'Ocurrio un error, consulta a soporte técnico' - //~ msg_error(msg) - //~ }, - //~ success:function(text, data, XmlHttpRequest){ - //~ var values = data.json() - //~ if(values.ok){ - //~ $$('form_cert').setValues(values.data) - //~ msg_ok(values.msg) - //~ }else{ - //~ msg_error(values.msg) - //~ } - //~ } - //~ }) -//~ } diff --git a/source/static/js/ui/admin.js b/source/static/js/ui/admin.js index 0e02fbd..5af4922 100644 --- a/source/static/js/ui/admin.js +++ b/source/static/js/ui/admin.js @@ -292,7 +292,7 @@ var emisor_certificado = [ {view: 'list', id: 'lst_cert', name: 'certificado', type: 'uploader', autoheight:true, borderless: true}, {}]}, {cols: [{}, - {view: 'text', id: 'txt_contra', name: 'contra', value: '12345678a', + {view: 'text', id: 'txt_contra', name: 'contra', label: 'Contraseña KEY', labelPosition: 'top', labelAlign: 'center', type: 'password', required: true}, {}]}, {cols: [{}, {view: 'button', id: 'cmd_subir_certificado', From e0d1f40a1177b064b24eb8be2423ae43aeb04293 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Thu, 31 Dec 2020 00:04:19 -0600 Subject: [PATCH 09/22] Remove old validate cert --- source/app/models/main.py | 37 ------------------------------------- 1 file changed, 37 deletions(-) diff --git a/source/app/models/main.py b/source/app/models/main.py index a03c6cd..414e786 100644 --- a/source/app/models/main.py +++ b/source/app/models/main.py @@ -1217,43 +1217,6 @@ class Certificado(BaseModel): opt = values['opt'] return getattr(cls, f'_{opt}')(values) - # ~ @classmethod - # ~ def get_cert(cls, is_fiel=False): - # ~ return Certificado.get(Certificado.es_fiel==is_fiel) - - def get_(cls): - return Certificado.select()[0] - - @classmethod - def validate(cls, values, session): - row = {} - result = False - - obj = cls.get_(cls) - paths = Configuracion.get_({'fields': 'path_cer'}) - cert = util.Certificado(paths) - auth = Emisor.get_auth() - data = cert.validate(values['contra'], session['rfc'], auth) - if data: - msg = 'Certificado guardado correctamente' - q = Certificado.update(**data).where(Certificado.id==obj.id) - if q.execute(): - result = True - row = { - 'cert_rfc': data['rfc'], - 'cert_serie': data['serie'], - 'cert_desde': data['desde'], - 'cert_hasta': data['hasta'], - } - else: - msg = cert.error - - Configuracion.add({'path_key': ''}) - Configuracion.add({'path_cer': ''}) - - return {'ok': result, 'msg': msg, 'data': row} - - class Folios(BaseModel): serie = TextField(unique=True) From 38c9c676afc16248b3bd9faffd277d0a2851754c Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Thu, 31 Dec 2020 00:06:50 -0600 Subject: [PATCH 10/22] Remove old class cert --- source/app/controllers/util.py | 151 --------------------------------- 1 file changed, 151 deletions(-) diff --git a/source/app/controllers/util.py b/source/app/controllers/util.py index 4465cfa..e6d4505 100644 --- a/source/app/controllers/util.py +++ b/source/app/controllers/util.py @@ -395,157 +395,6 @@ def to_slug(string): return value.replace(' ', '_') -class Certificado(object): - - def __init__(self, paths): - self._path_key = paths['path_key'] - self._path_cer = paths['path_cer'] - self._modulus = '' - self.error = '' - - def _kill(self, path): - try: - os.remove(path) - except: - pass - return - - def _get_info_cer(self, session_rfc): - data = {} - args = 'openssl x509 -inform DER -in {}' - try: - cer_pem = _call(args.format(self._path_cer)) - except Exception as e: - self.error = 'No se pudo convertir el CER en PEM' - return data - - args = 'openssl enc -base64 -in {}' - try: - cer_txt = _call(args.format(self._path_cer)) - except Exception as e: - self.error = 'No se pudo convertir el CER en TXT' - return data - - args = 'openssl x509 -inform DER -in {} -noout -{}' - try: - result = _call(args.format(self._path_cer, 'purpose')).split('\n')[3] - except Exception as e: - self.error = 'No se puede saber si es FIEL' - return data - - if result == 'SSL server : No': - self.error = 'El certificado es FIEL' - return data - - result = _call(args.format(self._path_cer, 'serial')) - serie = result.split('=')[1].split('\n')[0][1::2] - result = _call(args.format(self._path_cer, 'subject')) - #~ Verificar si es por la version de OpenSSL - t1 = 'x500UniqueIdentifier = ' - t2 = 'x500UniqueIdentifier=' - if t1 in result: - rfc = result.split(t1)[1][:13].strip() - elif t2 in result: - rfc = result.split(t2)[1][:13].strip() - else: - self.error = 'No se pudo obtener el RFC del certificado' - print ('\n', result) - return data - - if not DEBUG: - if not rfc == session_rfc: - self.error = 'El RFC del certificado no corresponde.' - return data - - dates = _call(args.format(self._path_cer, 'dates')).split('\n') - desde = parser.parse(dates[0].split('=')[1]) - hasta = parser.parse(dates[1].split('=')[1]) - self._modulus = _call(args.format(self._path_cer, 'modulus')) - - data['cer'] = read_file(self._path_cer) - data['cer_pem'] = cer_pem - data['cer_txt'] = cer_txt.replace('\n', '') - data['serie'] = serie - data['rfc'] = rfc - data['desde'] = desde.replace(tzinfo=None) - data['hasta'] = hasta.replace(tzinfo=None) - return data - - def _get_p12(self, password, rfc, token): - tmp_cer = tempfile.mkstemp()[1] - tmp_key = tempfile.mkstemp()[1] - tmp_p12 = tempfile.mkstemp()[1] - - args = 'openssl x509 -inform DER -in "{}" -out "{}"' - _call(args.format(self._path_cer, tmp_cer)) - args = 'openssl pkcs8 -inform DER -in "{}" -passin pass:"{}" -out "{}"' - _call(args.format(self._path_key, password, tmp_key)) - - args = 'openssl pkcs12 -export -in "{}" -inkey "{}" -name "{}" ' \ - '-passout pass:"{}" -out "{}"' - _call(args.format(tmp_cer, tmp_key, rfc, token, tmp_p12)) - data = read_file(tmp_p12) - - self._kill(tmp_cer) - self._kill(tmp_key) - self._kill(tmp_p12) - - return data - - def _get_info_key(self, password, rfc, token): - data = {} - - args = 'openssl pkcs8 -inform DER -in "{}" -passin pass:"{}"' - try: - result = _call(args.format(self._path_key, password)) - except Exception as e: - self.error = 'Contraseña incorrecta' - return data - - args = 'openssl pkcs8 -inform DER -in "{}" -passin pass:"{}" | ' \ - 'openssl rsa -noout -modulus' - mod_key = _call(args.format(self._path_key, password)) - - if self._modulus != mod_key: - self.error = 'Los archivos no son pareja' - return data - - args = "openssl pkcs8 -inform DER -in '{}' -passin pass:'{}' | " \ - "openssl rsa -des3 -passout pass:'{}'".format( - self._path_key, password, token) - key_enc = _call(args) - - data['key'] = read_file(self._path_key) - data['key_enc'] = key_enc - data['p12'] = self._get_p12(password, rfc, token) - return data - - def validate(self, password, rfc, auth): - token = _get_md5(rfc) - if USAR_TOKEN: - token = auth['PASS'] - if AUTH['DEBUG']: - token = AUTH['PASS'] - - if not self._path_key or not self._path_cer: - self.error = 'Error en las rutas temporales del certificado' - return {} - - data = self._get_info_cer(rfc) - if not data: - return {} - - llave = self._get_info_key(password, rfc, token) - if not llave: - return {} - - data.update(llave) - - self._kill(self._path_key) - self._kill(self._path_cer) - return data - - def make_xml(data, certificado): from .cfdi_xml import CFDI From cb04910a84dbbd67277e1f1c652d796bae44c8fe Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Thu, 31 Dec 2020 12:01:41 -0600 Subject: [PATCH 11/22] Sign in memory --- source/app/controllers/cfdi_xml.py | 3 +- source/app/controllers/util.py | 55 ++++++++++++++---------------- source/app/controllers/utils.py | 21 +++++++++++- source/app/models/main.py | 38 ++++++++++++--------- source/app/settings.py | 1 + 5 files changed, 70 insertions(+), 48 deletions(-) diff --git a/source/app/controllers/cfdi_xml.py b/source/app/controllers/cfdi_xml.py index 0351477..fc5087e 100644 --- a/source/app/controllers/cfdi_xml.py +++ b/source/app/controllers/cfdi_xml.py @@ -139,8 +139,9 @@ class CFDI(object): return self._to_pretty_xml(ET.tostring(self._cfdi, encoding='utf-8')) - def add_sello(self, sello): + def add_sello(self, sello, cert_txt): self._cfdi.attrib['Sello'] = sello + self._cfdi.attrib['Certificado'] = cert_txt return self._to_pretty_xml(ET.tostring(self._cfdi, encoding='utf-8')) def _to_pretty_xml(self, source): diff --git a/source/app/controllers/util.py b/source/app/controllers/util.py index e6d4505..bbcdf90 100644 --- a/source/app/controllers/util.py +++ b/source/app/controllers/util.py @@ -69,10 +69,12 @@ from settings import DEBUG, MV, log, template_lookup, COMPANIES, DB_SAT, \ PATH_XMLSEC, TEMPLATE_CANCEL, DEFAULT_SAT_PRODUCTO, DECIMALES, DIR_FACTURAS from settings import USAR_TOKEN, API, DECIMALES_TAX -from .configpac import AUTH +# ~ from .configpac import AUTH # ~ v2 +from .cfdi_cert import SATCertificate + from settings import ( EXT, MXN, @@ -395,39 +397,34 @@ def to_slug(string): return value.replace(' ', '_') -def make_xml(data, certificado): - from .cfdi_xml import CFDI +# ~ 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'] + # ~ cert = SATCertificate(certificado.cer, certificado.key_enc.encode()) + # ~ if DEBUG: + # ~ data['emisor']['Rfc'] = certificado.rfc + # ~ data['emisor']['RegimenFiscal'] = '603' - if DEBUG: - data['emisor']['Rfc'] = certificado.rfc - data['emisor']['RegimenFiscal'] = '603' + # ~ cfdi = CFDI() + # ~ xml = cfdi.get_xml(data) - cfdi = CFDI() - xml = cfdi.get_xml(data) + # ~ data = { + # ~ 'xsltproc': PATH_XSLTPROC, + # ~ 'xslt': _join(PATH_XSLT, 'cadena.xslt'), + # ~ 'xml': save_temp(xml, 'w'), + # ~ 'openssl': PATH_OPENSSL, + # ~ 'key': save_temp(certificado.key_enc, 'w'), + # ~ 'pass': token, + # ~ } + # ~ args = '"{xsltproc}" "{xslt}" "{xml}" | ' \ + # ~ '"{openssl}" dgst -sha256 -sign "{key}" -passin pass:"{pass}" | ' \ + # ~ '"{openssl}" enc -base64 -A'.format(**data) + # ~ sello = _call(args) - data = { - 'xsltproc': PATH_XSLTPROC, - 'xslt': _join(PATH_XSLT, 'cadena.xslt'), - 'xml': save_temp(xml, 'w'), - 'openssl': PATH_OPENSSL, - 'key': save_temp(certificado.key_enc, 'w'), - 'pass': token, - } - args = '"{xsltproc}" "{xslt}" "{xml}" | ' \ - '"{openssl}" dgst -sha256 -sign "{key}" -passin pass:"{pass}" | ' \ - '"{openssl}" enc -base64 -A'.format(**data) - sello = _call(args) + # ~ _kill(data['xml']) + # ~ _kill(data['key']) - _kill(data['xml']) - _kill(data['key']) - - return cfdi.add_sello(sello) + # ~ return cfdi.add_sello(sello) def timbra_xml(xml, auth): diff --git a/source/app/controllers/utils.py b/source/app/controllers/utils.py index 413df7e..5250796 100644 --- a/source/app/controllers/utils.py +++ b/source/app/controllers/utils.py @@ -48,6 +48,8 @@ from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes from dateutil import parser +from .cfdi_xml import CFDI + from settings import DEBUG, DB_COMPANIES, PATHS from .cfdi_cert import SATCertificate @@ -613,7 +615,7 @@ def get_client_balance(auth): pac = PACS[auth['pac']]() balance = pac.client_balance(auth) if pac.error: - balance = '-e' + balance = 'p/e' return balance @@ -625,3 +627,20 @@ def get_cert(args): return cert +def make_xml(data, certificado): + cert = SATCertificate(certificado.cer, certificado.key_enc.encode()) + if DEBUG: + data['emisor']['Rfc'] = certificado.rfc + data['emisor']['RegimenFiscal'] = '603' + + cfdi = CFDI() + xml = ET.parse(BytesIO(cfdi.get_xml(data).encode())) + + path_xslt = _join(PATHS['xslt'], 'cadena.xslt') + xslt = open(path_xslt, 'rb') + transfor = ET.XSLT(ET.parse(xslt)) + cadena = str(transfor(xml)).encode() + stamp = cert.sign(cadena) + xslt.close() + + return cfdi.add_sello(stamp, cert.cer_txt) diff --git a/source/app/models/main.py b/source/app/models/main.py index 414e786..1a3fd71 100644 --- a/source/app/models/main.py +++ b/source/app/models/main.py @@ -139,21 +139,26 @@ def validar_timbrar(): msg = 'Es necesario configurar un certificado de sellos' try: - obj = Certificado.select()[0] - except IndexError: + obj = Certificado.get(Certificado.es_fiel==False) + except Exception as e: return {'ok': False, 'msg': msg} if not obj.serie: return {'ok': False, 'msg': msg} - dias = obj.hasta - util.now() - if dias.days < 0: + diff = obj.hasta - utils.now() + if diff.days < 0: msg = 'El certificado ha vencido, es necesario cargar uno nuevo' return {'ok': False, 'msg': msg} + auth = Configuracion.get_({'fields': 'pac_auth'}) + if not auth: + msg = 'Es necesario configurar los datos de timbrado del PAC' + return {'ok': False, 'msg': msg} + msg = '' - if dias.days < 15: - msg = 'El certificado vence en: {} días.'.format(dias.days) + if diff.days < 15: + msg = 'El certificado vence en: {} días.'.format(diff.days) return {'ok': True, 'msg': msg} @@ -1079,15 +1084,13 @@ class Emisor(BaseModel): @classmethod def get_timbres(cls): - auth = cls.get_auth() - if not auth: - return 'c/e' + try: + obj = Emisor.select()[0] + except IndexError: + return 's/e' - pac = Configuracion.get_('lst_pac').lower() - if pac: - result = utils.get_client_balance(auth, pac) - else: - result = util.get_timbres(auth) + auth = Configuracion.get_({'fields': 'pac_auth'}) + result = utils.get_client_balance(auth) return result @classmethod @@ -4858,7 +4861,7 @@ class Facturas(BaseModel): frm_vu = FORMAT_PRECIO tmp = 0 emisor = Emisor.select()[0] - certificado = Certificado.select()[0] + certificado = Certificado.get(Certificado.es_fiel==False) is_edu = False comprobante = {} @@ -4888,7 +4891,7 @@ class Facturas(BaseModel): comprobante['Fecha'] = invoice.fecha.isoformat()[:19] comprobante['FormaPago'] = invoice.forma_pago comprobante['NoCertificado'] = certificado.serie - comprobante['Certificado'] = certificado.cer_txt + # ~ comprobante['Certificado'] = certificado.cer_txt comprobante['SubTotal'] = FORMAT.format(invoice.subtotal) comprobante['Moneda'] = invoice.moneda comprobante['TipoCambio'] = '1' @@ -5124,7 +5127,8 @@ class Facturas(BaseModel): 'complementos': complementos, } - return util.make_xml(data, certificado) + # ~ return util.make_xml(data, certificado) + return utils.make_xml(data, certificado) @classmethod def get_status_sat(cls, id): diff --git a/source/app/settings.py b/source/app/settings.py index ed389e4..3889ee2 100644 --- a/source/app/settings.py +++ b/source/app/settings.py @@ -217,6 +217,7 @@ PATHS = { 'BK': path_bk, 'LOCAL': path_local, 'SAT': path_sat, + 'xslt': PATH_XSLT, } VALUES_PDF = { From 0389c0734fb802270112bd305365914c35590689 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Thu, 31 Dec 2020 12:17:26 -0600 Subject: [PATCH 12/22] Clean methods to import from old FacturaLibre --- source/app/controllers/utils.py | 3 +- source/app/models/main.py | 725 ++++++++++++++++---------------- source/app/settings.py | 33 +- 3 files changed, 395 insertions(+), 366 deletions(-) diff --git a/source/app/controllers/utils.py b/source/app/controllers/utils.py index 5250796..475543f 100644 --- a/source/app/controllers/utils.py +++ b/source/app/controllers/utils.py @@ -50,10 +50,11 @@ from dateutil import parser from .cfdi_xml import CFDI -from settings import DEBUG, DB_COMPANIES, PATHS +from settings import DEBUG, DB_COMPANIES, PATHS, TEMPLATE_CANCEL from .cfdi_cert import SATCertificate from .pacs import PACComercioDigital +# ~ from .pacs import PACFinkok from .pac import Finkok as PACFinkok # ~ from .finkok import PACFinkok diff --git a/source/app/models/main.py b/source/app/models/main.py index 1a3fd71..ace7519 100644 --- a/source/app/models/main.py +++ b/source/app/models/main.py @@ -9780,388 +9780,387 @@ def _importar_valores(archivo='', rfc=''): return -def _importar_socios(rows): - log.info('\tImportando Clientes...') - totals = len(rows) - for i, row in enumerate(rows): - msg = '\tGuardando cliente {} de {}'.format(i+1, totals) - log.info(msg) - try: - with database_proxy.atomic() as txn: - Socios.create(**row) - except IntegrityError: - msg = '\tSocio existente: {}'.format(row['nombre']) - log.info(msg) - log.info('\tClientes importados...') - return +# ~ def _importar_socios(rows): + # ~ log.info('\tImportando Clientes...') + # ~ totals = len(rows) + # ~ for i, row in enumerate(rows): + # ~ msg = '\tGuardando cliente {} de {}'.format(i+1, totals) + # ~ log.info(msg) + # ~ try: + # ~ with database_proxy.atomic() as txn: + # ~ Socios.create(**row) + # ~ except IntegrityError: + # ~ msg = '\tSocio existente: {}'.format(row['nombre']) + # ~ log.info(msg) + # ~ log.info('\tClientes importados...') + # ~ return -def _existe_factura(row): - filtro = (Facturas.uuid==row['uuid']) - if row['uuid'] is None: - filtro = ( - (Facturas.serie==row['serie']) & - (Facturas.folio==row['folio']) - ) - return Facturas.select().where(filtro).exists() +# ~ def _existe_factura(row): + # ~ filtro = (Facturas.uuid==row['uuid']) + # ~ if row['uuid'] is None: + # ~ filtro = ( + # ~ (Facturas.serie==row['serie']) & + # ~ (Facturas.folio==row['folio']) + # ~ ) + # ~ return Facturas.select().where(filtro).exists() -def _importar_facturas(rows): - log.info('\tImportando Facturas...') - totals = len(rows) - for i, row in enumerate(rows): - msg = '\tGuardando factura {} de {}'.format(i+1, totals) - log.info(msg) +# ~ def _importar_facturas(rows): + # ~ log.info('\tImportando Facturas...') + # ~ totals = len(rows) + # ~ for i, row in enumerate(rows): + # ~ msg = '\tGuardando factura {} de {}'.format(i+1, totals) + # ~ log.info(msg) - try: - detalles = row.pop('detalles') - impuestos = row.pop('impuestos') - cliente = row.pop('cliente') - row['cliente'] = Socios.get(**cliente) - with database_proxy.atomic() as txn: - if _existe_factura(row): - msg = '\tFactura existente: {}{}'.format( - row['serie'], row['folio']) - log.info(msg) - continue - obj = Facturas.create(**row) - for detalle in detalles: - detalle['factura'] = obj - FacturasDetalle.create(**detalle) - for impuesto in impuestos: - imp = SATImpuestos.get(**impuesto['filtro']) - new = { - 'factura': obj, - 'impuesto': imp, - 'importe': impuesto['importe'], - } - try: - with database_proxy.atomic() as txn: - FacturasImpuestos.create(**new) - except IntegrityError as e: - pass + # ~ try: + # ~ detalles = row.pop('detalles') + # ~ impuestos = row.pop('impuestos') + # ~ cliente = row.pop('cliente') + # ~ row['cliente'] = Socios.get(**cliente) + # ~ with database_proxy.atomic() as txn: + # ~ if _existe_factura(row): + # ~ msg = '\tFactura existente: {}{}'.format( + # ~ row['serie'], row['folio']) + # ~ log.info(msg) + # ~ continue + # ~ obj = Facturas.create(**row) + # ~ for detalle in detalles: + # ~ detalle['factura'] = obj + # ~ FacturasDetalle.create(**detalle) + # ~ for impuesto in impuestos: + # ~ imp = SATImpuestos.get(**impuesto['filtro']) + # ~ new = { + # ~ 'factura': obj, + # ~ 'impuesto': imp, + # ~ 'importe': impuesto['importe'], + # ~ } + # ~ try: + # ~ with database_proxy.atomic() as txn: + # ~ FacturasImpuestos.create(**new) + # ~ except IntegrityError as e: + # ~ pass - except IntegrityError as e: - print (e) - msg = '\tFactura: id: {}'.format(row['serie'] + str(row['folio'])) - log.error(msg) - break - - log.info('\tFacturas importadas...') - return - - -def _importar_categorias(rows): - log.info('\tImportando Categorías...') - for row in rows: - with database_proxy.atomic() as txn: - try: - Categorias.create(**row) - except IntegrityError: - msg = '\tCategoria: ({}) {}'.format(row['padre'], row['categoria']) - log.error(msg) - - log.info('\tCategorías importadas...') - return - - -def _get_id_unidad(unidad): - try: - if 'pieza' in unidad.lower(): - unidad = 'pieza' - if 'metros' in unidad.lower(): - unidad = 'metro' - if 'tramo' in unidad.lower(): - unidad = 'paquete' - if 'juego' in unidad.lower(): - unidad = 'par' - if 'bolsa' in unidad.lower(): - unidad = 'globo' - if unidad.lower() == 'no aplica': - unidad = 'servicio' - - obj = SATUnidades.get(SATUnidades.name.contains(unidad)) - except SATUnidades.DoesNotExist: - msg = '\tNo se encontró la unidad: {}'.format(unidad) - # ~ log.error(msg) - return unidad - - return str(obj.id) - - -def _get_impuestos(impuestos): - lines = '|' - for impuesto in impuestos: - if impuesto['tasa'] == '-2/3': - tasa = str(round(2/3, 6)) - else: - if impuesto['tasa'] == 'EXENTO': - tasa = '0.00' - else: - tasa = str(round(float(impuesto['tasa']) / 100.0, 6)) - - info = ( - IMPUESTOS.get(impuesto['nombre']), - impuesto['nombre'], - impuesto['tipo'][0], - tasa, - ) - lines += '|'.join(info) + '|' - return lines - - -def _generar_archivo_productos(archivo): - rfc = input('Introduce el RFC: ').strip().upper() - if not rfc: - msg = 'El RFC es requerido' - log.error(msg) - return - - args = util.get_con(rfc) - if not args: - return - - conectar(args) - - log.info('Importando datos...') - app = util.ImportFacturaLibre(archivo, rfc) - if not app.is_connect: - log.error('\t{}'.format(app._error)) - return - - rows = app.import_productos() - - p, _, _, _ = util.get_path_info(archivo) - path_txt = util._join(p, 'productos_{}.txt'.format(rfc)) - log.info('\tGenerando archivo: {}'.format(path_txt)) - - fields = ( - 'clave', - 'clave_sat', - 'unidad', - 'categoria', - 'descripcion', - 'valor_unitario', - 'existencia', - 'inventario', - 'codigo_barras', - 'cuenta_predial', - 'ultimo_precio', - 'minimo', - ) - - data = ['|'.join(fields)] - not_units = [] - for row in rows: - impuestos = row.pop('impuestos', ()) - line = [str(row[r]) for r in fields] - if line[10] == 'None': - line[10] = '0.0' - line[2] = _get_id_unidad(line[2]) - try: - int(line[2]) - except ValueError: - if not line[2] in not_units: - not_units.append(line[2]) - msg = 'No se encontró la unidad: {}'.format(line[2]) - log.error(msg) - continue - line = '|'.join(line) + _get_impuestos(impuestos) - data.append(line) - - with open(path_txt, 'w') as fh: - fh.write('\n'.join(data)) - - log.info('\tArchivo generado: {}'.format(path_txt)) - return - - -def importar_bdfl(): - try: - emisor = Emisor.select()[0] - except IndexError: - msg = 'Configura primero al emisor' - return {'ok': False, 'msg': msg} - - name = '{}.sqlite'.format(emisor.rfc.lower()) - path = util._join('/tmp', name) - - log.info('Importando datos...') - app = util.ImportFacturaLibre(path, emisor.rfc) - if not app.is_connect: - msg = app._error - log.error('\t{}'.format(msg)) - return {'ok': False, 'msg': msg} - - data = app.import_data() - - _importar_socios(data['Socios']) - _importar_facturas(data['Facturas']) - _importar_categorias(data['Categorias']) - - msg = 'Importación terminada...' - log.info(msg) - - return {'ok': True, 'msg': msg} - - -def _importar_factura_libre(archivo): - rfc = input('Introduce el RFC: ').strip().upper() - if not rfc: - msg = 'El RFC es requerido' - log.error(msg) - return - - args = util.get_con(rfc) - if not args: - return - - conectar(args) - - log.info('Importando datos...') - app = util.ImportFacturaLibre(archivo, rfc) - if not app.is_connect: - log.error('\t{}'.format(app._error)) - return - - data = app.import_data() - - _importar_socios(data['Socios']) - _importar_facturas(data['Facturas']) - _importar_categorias(data['Categorias']) - - log.info('Importación terminada...') - return - - -def _exist_ticket(row): - filters = ( - (Tickets.serie==row['serie']) & - (Tickets.folio==row['folio']) - ) - return Tickets.select().where(filters).exists() - - -def _import_tickets(rows): - log.info('\tImportando Tickets...') - for row in rows: - try: - details = row.pop('details') - taxes = row.pop('taxes') - with database_proxy.atomic() as txn: - if _exist_ticket(row): - msg = '\tTicket existente: {}{}'.format( - row['serie'], row['folio']) - log.info(msg) - continue - - if not row['factura'] is None and row['factura']: - row['factura'] = Facturas.get( - Facturas.serie==row['factura']['serie'], - Facturas.folio==row['factura']['folio']) - else: - row['factura'] = None - - obj = Tickets.create(**row) - for detail in details: - detail['ticket'] = obj - TicketsDetalle.create(**detail) - for tax in taxes: - imp = SATImpuestos.get(**tax['filter']) - new = { - 'ticket': obj, - 'impuesto': imp, - 'importe': tax['import'], - } - TicketsImpuestos.create(**new) - except IntegrityError as e: + # ~ except IntegrityError as e: # ~ print (e) - msg = '\tTicket: id: {}'.format(row['serie'] + str(row['folio'])) - log.error(msg) + # ~ msg = '\tFactura: id: {}'.format(row['serie'] + str(row['folio'])) + # ~ log.error(msg) + # ~ break - log.info('\tTickets importadas...') - return + # ~ log.info('\tFacturas importadas...') + # ~ return -def _importar_productos(archivo): - rfc = input('Introduce el RFC: ').strip().upper() - if not rfc: - msg = 'El RFC es requerido' - log.error(msg) - return +# ~ def _importar_categorias(rows): + # ~ log.info('\tImportando Categorías...') + # ~ for row in rows: + # ~ with database_proxy.atomic() as txn: + # ~ try: + # ~ Categorias.create(**row) + # ~ except IntegrityError: + # ~ msg = '\tCategoria: ({}) {}'.format(row['padre'], row['categoria']) + # ~ log.error(msg) - args = util.get_con(rfc) - if not args: - return + # ~ log.info('\tCategorías importadas...') + # ~ return - conectar(args) - log.info('Importando productos...') - fields = ( - 'clave', - 'clave_sat', - 'unidad', - 'categoria', - 'descripcion', - 'valor_unitario', - 'existencia', - 'inventario', - 'codigo_barras', - 'cuenta_predial', - 'ultimo_precio', - 'minimo', - ) +# ~ def _get_id_unidad(unidad): + # ~ try: + # ~ if 'pieza' in unidad.lower(): + # ~ unidad = 'pieza' + # ~ if 'metros' in unidad.lower(): + # ~ unidad = 'metro' + # ~ if 'tramo' in unidad.lower(): + # ~ unidad = 'paquete' + # ~ if 'juego' in unidad.lower(): + # ~ unidad = 'par' + # ~ if 'bolsa' in unidad.lower(): + # ~ unidad = 'globo' + # ~ if unidad.lower() == 'no aplica': + # ~ unidad = 'servicio' - rows = util.read_file(archivo, 'r').split('\n') - for i, row in enumerate(rows): - if i == 0: - continue - data = row.split('|') + # ~ obj = SATUnidades.get(SATUnidades.name.contains(unidad)) + # ~ except SATUnidades.DoesNotExist: + # ~ msg = '\tNo se encontró la unidad: {}'.format(unidad) + # ~ return unidad + + # ~ return str(obj.id) + + +# ~ def _get_impuestos(impuestos): + # ~ lines = '|' + # ~ for impuesto in impuestos: + # ~ if impuesto['tasa'] == '-2/3': + # ~ tasa = str(round(2/3, 6)) + # ~ else: + # ~ if impuesto['tasa'] == 'EXENTO': + # ~ tasa = '0.00' + # ~ else: + # ~ tasa = str(round(float(impuesto['tasa']) / 100.0, 6)) + + # ~ info = ( + # ~ IMPUESTOS.get(impuesto['nombre']), + # ~ impuesto['nombre'], + # ~ impuesto['tipo'][0], + # ~ tasa, + # ~ ) + # ~ lines += '|'.join(info) + '|' + # ~ return lines + + +# ~ def _generar_archivo_productos(archivo): + # ~ rfc = input('Introduce el RFC: ').strip().upper() + # ~ if not rfc: + # ~ msg = 'El RFC es requerido' + # ~ log.error(msg) + # ~ return + + # ~ args = util.get_con(rfc) + # ~ if not args: + # ~ return + + # ~ conectar(args) + + # ~ log.info('Importando datos...') + # ~ app = util.ImportFacturaLibre(archivo, rfc) + # ~ if not app.is_connect: + # ~ log.error('\t{}'.format(app._error)) + # ~ return + + # ~ rows = app.import_productos() + + # ~ p, _, _, _ = util.get_path_info(archivo) + # ~ path_txt = util._join(p, 'productos_{}.txt'.format(rfc)) + # ~ log.info('\tGenerando archivo: {}'.format(path_txt)) + + # ~ fields = ( + # ~ 'clave', + # ~ 'clave_sat', + # ~ 'unidad', + # ~ 'categoria', + # ~ 'descripcion', + # ~ 'valor_unitario', + # ~ 'existencia', + # ~ 'inventario', + # ~ 'codigo_barras', + # ~ 'cuenta_predial', + # ~ 'ultimo_precio', + # ~ 'minimo', + # ~ ) + + # ~ data = ['|'.join(fields)] + # ~ not_units = [] + # ~ for row in rows: + # ~ impuestos = row.pop('impuestos', ()) + # ~ line = [str(row[r]) for r in fields] + # ~ if line[10] == 'None': + # ~ line[10] = '0.0' + # ~ line[2] = _get_id_unidad(line[2]) + # ~ try: + # ~ int(line[2]) + # ~ except ValueError: + # ~ if not line[2] in not_units: + # ~ not_units.append(line[2]) + # ~ msg = 'No se encontró la unidad: {}'.format(line[2]) + # ~ log.error(msg) + # ~ continue + # ~ line = '|'.join(line) + _get_impuestos(impuestos) + # ~ data.append(line) + + # ~ with open(path_txt, 'w') as fh: + # ~ fh.write('\n'.join(data)) + + # ~ log.info('\tArchivo generado: {}'.format(path_txt)) + # ~ return + + +# ~ def importar_bdfl(): + # ~ try: + # ~ emisor = Emisor.select()[0] + # ~ except IndexError: + # ~ msg = 'Configura primero al emisor' + # ~ return {'ok': False, 'msg': msg} + + # ~ name = '{}.sqlite'.format(emisor.rfc.lower()) + # ~ path = util._join('/tmp', name) + + # ~ log.info('Importando datos...') + # ~ app = util.ImportFacturaLibre(path, emisor.rfc) + # ~ if not app.is_connect: + # ~ msg = app._error + # ~ log.error('\t{}'.format(msg)) + # ~ return {'ok': False, 'msg': msg} + + # ~ data = app.import_data() + + # ~ _importar_socios(data['Socios']) + # ~ _importar_facturas(data['Facturas']) + # ~ _importar_categorias(data['Categorias']) + + # ~ msg = 'Importación terminada...' + # ~ log.info(msg) + + # ~ return {'ok': True, 'msg': msg} + + +# ~ def _importar_factura_libre(archivo): + # ~ rfc = input('Introduce el RFC: ').strip().upper() + # ~ if not rfc: + # ~ msg = 'El RFC es requerido' + # ~ log.error(msg) + # ~ return + + # ~ args = util.get_con(rfc) + # ~ if not args: + # ~ return + + # ~ conectar(args) + + # ~ log.info('Importando datos...') + # ~ app = util.ImportFacturaLibre(archivo, rfc) + # ~ if not app.is_connect: + # ~ log.error('\t{}'.format(app._error)) + # ~ return + + # ~ data = app.import_data() + + # ~ _importar_socios(data['Socios']) + # ~ _importar_facturas(data['Facturas']) + # ~ _importar_categorias(data['Categorias']) + + # ~ log.info('Importación terminada...') + # ~ return + + +# ~ def _exist_ticket(row): + # ~ filters = ( + # ~ (Tickets.serie==row['serie']) & + # ~ (Tickets.folio==row['folio']) + # ~ ) + # ~ return Tickets.select().where(filters).exists() + + +# ~ def _import_tickets(rows): + # ~ log.info('\tImportando Tickets...') + # ~ for row in rows: + # ~ try: + # ~ details = row.pop('details') + # ~ taxes = row.pop('taxes') + # ~ with database_proxy.atomic() as txn: + # ~ if _exist_ticket(row): + # ~ msg = '\tTicket existente: {}{}'.format( + # ~ row['serie'], row['folio']) + # ~ log.info(msg) + # ~ continue + + # ~ if not row['factura'] is None and row['factura']: + # ~ row['factura'] = Facturas.get( + # ~ Facturas.serie==row['factura']['serie'], + # ~ Facturas.folio==row['factura']['folio']) + # ~ else: + # ~ row['factura'] = None + + # ~ obj = Tickets.create(**row) + # ~ for detail in details: + # ~ detail['ticket'] = obj + # ~ TicketsDetalle.create(**detail) + # ~ for tax in taxes: + # ~ imp = SATImpuestos.get(**tax['filter']) + # ~ new = { + # ~ 'ticket': obj, + # ~ 'impuesto': imp, + # ~ 'importe': tax['import'], + # ~ } + # ~ TicketsImpuestos.create(**new) + # ~ except IntegrityError as e: + # ~ print (e) + # ~ msg = '\tTicket: id: {}'.format(row['serie'] + str(row['folio'])) + # ~ log.error(msg) + + # ~ log.info('\tTickets importadas...') + # ~ return + + +# ~ def _importar_productos(archivo): + # ~ rfc = input('Introduce el RFC: ').strip().upper() + # ~ if not rfc: + # ~ msg = 'El RFC es requerido' + # ~ log.error(msg) + # ~ return + + # ~ args = util.get_con(rfc) + # ~ if not args: + # ~ return + + # ~ conectar(args) + # ~ log.info('Importando productos...') + + # ~ fields = ( + # ~ 'clave', + # ~ 'clave_sat', + # ~ 'unidad', + # ~ 'categoria', + # ~ 'descripcion', + # ~ 'valor_unitario', + # ~ 'existencia', + # ~ 'inventario', + # ~ 'codigo_barras', + # ~ 'cuenta_predial', + # ~ 'ultimo_precio', + # ~ 'minimo', + # ~ ) + + # ~ rows = util.read_file(archivo, 'r').split('\n') + # ~ for i, row in enumerate(rows): + # ~ if i == 0: + # ~ continue + # ~ data = row.split('|') # ~ print (data) - new = {} - for i, f in enumerate(fields): - if not len(data[0]): - continue + # ~ new = {} + # ~ for i, f in enumerate(fields): + # ~ if not len(data[0]): + # ~ continue - if i in (2, 3): - try: - new[f] = int(data[i]) - except ValueError: - continue - elif i in (5, 6, 10, 11): - new[f] = float(data[i]) - elif i == 7: - new[f] = bool(data[i]) - else: - new[f] = data[i] + # ~ if i in (2, 3): + # ~ try: + # ~ new[f] = int(data[i]) + # ~ except ValueError: + # ~ continue + # ~ elif i in (5, 6, 10, 11): + # ~ new[f] = float(data[i]) + # ~ elif i == 7: + # ~ new[f] = bool(data[i]) + # ~ else: + # ~ new[f] = data[i] - impuestos = data[i + 1:-1] - if not impuestos: - taxes = [SATImpuestos.select().where(SATImpuestos.id==6)] - else: - taxes = [] - try: - for i in range(0, len(impuestos), 4): - w = { - 'key': impuestos[i], - 'name': impuestos[i+1], - 'tipo': impuestos[i+2], - 'tasa': float(impuestos[i+3]), - } - taxes.append(SATImpuestos.get_o_crea(w)) - except IndexError: - print ('IE', data) - continue + # ~ impuestos = data[i + 1:-1] + # ~ if not impuestos: + # ~ taxes = [SATImpuestos.select().where(SATImpuestos.id==6)] + # ~ else: + # ~ taxes = [] + # ~ try: + # ~ for i in range(0, len(impuestos), 4): + # ~ w = { + # ~ 'key': impuestos[i], + # ~ 'name': impuestos[i+1], + # ~ 'tipo': impuestos[i+2], + # ~ 'tasa': float(impuestos[i+3]), + # ~ } + # ~ taxes.append(SATImpuestos.get_o_crea(w)) + # ~ except IndexError: + # ~ print ('IE', data) + # ~ continue - with database_proxy.transaction(): - try: - obj = Productos.create(**new) - obj.impuestos = taxes - except IntegrityError as e: - pass + # ~ with database_proxy.transaction(): + # ~ try: + # ~ obj = Productos.create(**new) + # ~ obj.impuestos = taxes + # ~ except IntegrityError as e: + # ~ pass - log.info('Importación terminada...') - return + # ~ log.info('Importación terminada...') + # ~ return def _import_from_folder(path): diff --git a/source/app/settings.py b/source/app/settings.py index 3889ee2..aeda751 100644 --- a/source/app/settings.py +++ b/source/app/settings.py @@ -73,8 +73,8 @@ PATH_SESSIONS = { IV = 'valores_iniciales.json' INIT_VALUES = os.path.abspath(os.path.join(BASE_DIR, '..', 'db', IV)) -CT = 'cancel_template.xml' -TEMPLATE_CANCEL = os.path.abspath(os.path.join(PATH_TEMPLATES, CT)) +# ~ CT = 'cancel_template.xml' +# ~ TEMPLATE_CANCEL = os.path.abspath(os.path.join(PATH_TEMPLATES, CT)) PATH_XSLT = os.path.abspath(os.path.join(BASE_DIR, '..', 'xslt')) PATH_BIN = os.path.abspath(os.path.join(BASE_DIR, '..', 'bin')) @@ -245,3 +245,32 @@ DEFAULT_GLOBAL = { 'descripcion': 'Venta', 'clave_sat': '01010101', } + +TEMPLATE_CANCEL = """ + + {uuid} + + + + + + + + + + + + + + + + + + + + + + + + +""" From a54ad8d760533c7284e291bbb783226b38f26fb5 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Thu, 31 Dec 2020 15:08:49 -0600 Subject: [PATCH 13/22] Cancel sign xml --- .../pacs/comerciodigital/comercio.py | 5 +-- source/app/controllers/utils.py | 27 ++++++++++++++ source/app/models/main.py | 36 ++++++++++++++++--- source/static/js/controller/invoices.js | 1 + 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/source/app/controllers/pacs/comerciodigital/comercio.py b/source/app/controllers/pacs/comerciodigital/comercio.py index bcca148..cc38b90 100644 --- a/source/app/controllers/pacs/comerciodigital/comercio.py +++ b/source/app/controllers/pacs/comerciodigital/comercio.py @@ -231,9 +231,10 @@ class PACComercioDigital(object): return headers - def cancel_xml(self, cfdi, xml, info, auth={}): - if not auth: + def cancel_xml(self, cfdi, xml, auth={}, info={'tipo': 'cfdi3.3'}): + if DEBUG or not auth: auth = AUTH + url = self.URL['cancelxml'] headers = self._get_headers_cancel_xml(cfdi, info, auth) result = self._post(url, xml, headers) diff --git a/source/app/controllers/utils.py b/source/app/controllers/utils.py index 475543f..b73feb9 100644 --- a/source/app/controllers/utils.py +++ b/source/app/controllers/utils.py @@ -645,3 +645,30 @@ def make_xml(data, certificado): xslt.close() return cfdi.add_sello(stamp, cert.cer_txt) + + +def cancel_xml_sign(invoice, auth, certificado): + cert = SATCertificate(certificado.cer, certificado.key_enc.encode()) + pac = PACS[auth['pac']]() + data = { + 'rfc': certificado.rfc, + 'fecha': now().isoformat()[:19], + 'uuid': invoice.uuid, + } + template = TEMPLATE_CANCEL.format(**data) + tree = ET.fromstring(template.encode()) + tree = cert.sign_xml(tree) + sign_xml = ET.tostring(tree).decode() + + result = pac.cancel_xml(invoice.xml, sign_xml, auth) + if pac.error: + result = {'ok': False, 'msg': pac.error, 'row': {}} + return result + + tree = ET.fromstring(result) + date_cancel = tree.xpath('string(//Acuse/@Fecha)')[:19] + + msg = 'Factura cancelada correctamente' + result = {'ok': True, 'msg': '', 'row': {'estatus': 'Cancelada'}, + 'Fecha': date_cancel, 'Acuse': result} + return result diff --git a/source/app/models/main.py b/source/app/models/main.py index ace7519..adeb242 100644 --- a/source/app/models/main.py +++ b/source/app/models/main.py @@ -3845,11 +3845,39 @@ class Facturas(BaseModel): obj.fecha_cancelacion = util.now() obj.save() msg = 'Factura cancelada correctamente' - return {'ok': True, 'msg': msg, 'row': {'estatus': 'Cancelada'}} + return {'ok': True, 'msg': msg, 'row': {'estatus': obj.estatus}} + + # ~ if CANCEL_SIGNATURE: + # ~ return cls._cancel_signature(cls, id) + # ~ return cls._cancel_xml(cls, id) + return cls._cancel_xml_sign(obj) + + @classmethod + def _cancel_xml_sign(cls, invoice): + if invoice.version != '3.3': + msg = 'Solo es posible cancelar CFDI 3.3' + return {'ok': False, 'msg': msg} + + auth = Configuracion.get_({'fields': 'pac_auth'}) + certificado = Certificado.get(Certificado.es_fiel==False) + result = utils.cancel_xml_sign(invoice, auth, certificado) + + if result['ok']: + invoice.estatus = 'Cancelada' + invoice.error = '' + invoice.cancelada = True + invoice.fecha_cancelacion = result['Fecha'] + invoice.acuse = result['Acuse'] or '' + cls._actualizar_saldo_cliente(cls, invoice, True) + cls._update_inventory(cls, invoice, True) + cls._uncancel_tickets(cls, invoice) + else: + invoice.error = result['msg'] + invoice.save() + + data = {'ok': result['ok'], 'msg': result['msg'], 'row': result['row']} + return data - if CANCEL_SIGNATURE: - return cls._cancel_signature(cls, id) - return cls._cancel_xml(cls, id) def _cancel_xml(self, id): msg = 'Factura cancelada correctamente' diff --git a/source/static/js/controller/invoices.js b/source/static/js/controller/invoices.js index 1cbd0a3..c95f30f 100644 --- a/source/static/js/controller/invoices.js +++ b/source/static/js/controller/invoices.js @@ -1366,6 +1366,7 @@ function send_cancel(id){ msg_ok(values.msg) gi.updateItem(id, values.row) }else{ + msg_error('No fue posible cancelar') webix.alert({ title: 'Error al Cancelar', text: values.msg, From 8a8f05384b2d4a14b3cea5befbb3c9f5d0baff30 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Thu, 31 Dec 2020 20:10:32 -0600 Subject: [PATCH 14/22] Stamp nomina --- source/app/controllers/util.py | 3 +- source/app/models/main.py | 53 +++++++++++++++++----------------- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/source/app/controllers/util.py b/source/app/controllers/util.py index bbcdf90..f54a093 100644 --- a/source/app/controllers/util.py +++ b/source/app/controllers/util.py @@ -2051,7 +2051,8 @@ def get_data_from_xml(invoice, values): if data['pagos']: data['pays'] = _cfdipays(doc, data, version) data['pakings'] = values.get('pakings', []) - data['version'] = values['version'] + # ~ data['version'] = values['version'] + data['version'] = version return data diff --git a/source/app/models/main.py b/source/app/models/main.py index adeb242..f4eb719 100644 --- a/source/app/models/main.py +++ b/source/app/models/main.py @@ -224,6 +224,8 @@ def import_invoice(): def get_doc(type_doc, id, rfc): types = { 'xml': 'application/xml', + 'xmlpago': 'application/xml', + 'nomxml': 'application/xml', 'ods': 'application/octet-stream', 'zip': 'application/octet-stream', 'nomlog': 'application/txt', @@ -386,8 +388,8 @@ class Configuracion(BaseModel): .select(Configuracion.valor) .where(Configuracion.clave == key) ) - if data: - return util.get_bool(data[0].valor) + if data and data[0].valor == '1': + return True return False def _get_partners(self): @@ -3847,9 +3849,6 @@ class Facturas(BaseModel): msg = 'Factura cancelada correctamente' return {'ok': True, 'msg': msg, 'row': {'estatus': obj.estatus}} - # ~ if CANCEL_SIGNATURE: - # ~ return cls._cancel_signature(cls, id) - # ~ return cls._cancel_xml(cls, id) return cls._cancel_xml_sign(obj) @classmethod @@ -6669,9 +6668,9 @@ class CfdiPagos(BaseModel): return related - def _generate_xml(self, invoice, auth): + def _generate_xml(self, invoice): emisor = Emisor.select()[0] - cert = Certificado.get_cert() + certificado = Certificado.get(Certificado.es_fiel==False) used_data_bank = Configuracion.get_bool('chk_cfg_pays_data_bank') cfdi = {} @@ -6679,8 +6678,8 @@ class CfdiPagos(BaseModel): cfdi['Serie'] = invoice.serie cfdi['Folio'] = str(invoice.folio) cfdi['Fecha'] = invoice.fecha.isoformat()[:19] - cfdi['NoCertificado'] = cert.serie - cfdi['Certificado'] = cert.cer_txt + cfdi['NoCertificado'] = certificado.serie + # ~ cfdi['Certificado'] = cert.cer_txt cfdi['SubTotal'] = '0' cfdi['Moneda'] = DEFAULT_CFDIPAY['CURRENCY'] cfdi['Total'] = '0' @@ -6760,27 +6759,27 @@ class CfdiPagos(BaseModel): 'edu': False, 'complementos': complementos, } - return util.make_xml(data, cert, auth) + return utils.make_xml(data, certificado) def _stamp(self, values): id_mov = int(values['id_mov']) + send_email = Configuracion.get_bool('correo_directo') + auth = Configuracion.get_({'fields': 'pac_auth'}) - send_email = util.get_bool(Configuracion.get_('correo_directo')) - auth = Emisor.get_auth() filters = ( (CfdiPagos.movimiento==id_mov) & (CfdiPagos.uuid.is_null(True)) ) obj = CfdiPagos.get(filters) - obj.xml = self._generate_xml(self, obj, auth) + obj.xml = self._generate_xml(self, obj) obj.estatus = 'Generada' obj.save() msg = 'Factura timbrada correctamente' - 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 = '' row = {'uuid': obj.uuid, 'estatus': 'Timbrada'} @@ -6833,7 +6832,6 @@ class CfdiPagos(BaseModel): (obj.xml, name, target), ) cls._sync_files(cls, files) - return obj.xml, name def _get_not_in_xml(self, invoice, emisor): @@ -8586,10 +8584,11 @@ class CfdiNomina(BaseModel): return - def _make_xml(self, cfdi, auth): + def _make_xml(self, cfdi): emisor = Emisor.select()[0] empleado = cfdi.empleado - certificado = Certificado.select()[0] + # ~ certificado = Certificado.select()[0] + certificado = Certificado.get(Certificado.es_fiel==False) totals = CfdiNominaTotales.select().where(CfdiNominaTotales.cfdi==cfdi)[0] comprobante = {} @@ -8692,8 +8691,8 @@ class CfdiNomina(BaseModel): ant = 'P{}D'.format(days) nomina_receptor['Antigüedad'] = ant - if empleado.puesto: - if empleado.puesto.departamento: + if empleado.puesto.nombre: + if empleado.puesto.departamento.nombre: nomina_receptor['Departamento'] = empleado.puesto.departamento.nombre nomina_receptor['Puesto'] = empleado.puesto.nombre @@ -8833,21 +8832,24 @@ class CfdiNomina(BaseModel): 'impuestos': {}, 'donativo': {}, } - return util.make_xml(data, certificado, auth) + # ~ return util.make_xml(data, certificado, auth) + return utils.make_xml(data, certificado) def _stamp_id(self, id): - auth = Emisor.get_auth() + # ~ auth = Emisor.get_auth() + auth = Configuracion.get_({'fields': 'pac_auth'}) obj = CfdiNomina.get(CfdiNomina.id==id) - obj.xml = self._make_xml(self, obj, auth) + obj.xml = self._make_xml(self, obj) obj.estatus = 'Generado' obj.save() - result = util.timbra_xml(obj.xml, auth) + # ~ result = util.timbra_xml(obj.xml, auth) + result = utils.xml_stamp(obj.xml, auth) # ~ print (result) if result['ok']: obj.xml = result['xml'] obj.uuid = result['uuid'] - obj.fecha_timbrado = result['fecha'] + obj.fecha_timbrado = result['date'] obj.estatus = 'Timbrado' obj.error = '' obj.save() @@ -8858,7 +8860,6 @@ class CfdiNomina(BaseModel): obj.error = msg obj.save() - return result['ok'], obj.error def _stamp(self): From 8f15961d202570599bf2355480b84bf310cd202c Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Thu, 31 Dec 2020 20:17:00 -0600 Subject: [PATCH 15/22] Fix - replace getchildren form list --- source/app/controllers/util.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/source/app/controllers/util.py b/source/app/controllers/util.py index f54a093..9b6dd25 100644 --- a/source/app/controllers/util.py +++ b/source/app/controllers/util.py @@ -1974,20 +1974,20 @@ def _nomina(doc, data, values, version_cfdi): if not node is None: data['comprobante'].update(CaseInsensitiveDict(node.attrib.copy())) info['percepciones'] = [] - for p in node.getchildren(): + for p in list(node): info['percepciones'].append(CaseInsensitiveDict(p.attrib.copy())) node = node_nomina.find('{}Deducciones'.format(PRE['NOMINA'][version])) if not node is None: data['comprobante'].update(CaseInsensitiveDict(node.attrib.copy())) info['deducciones'] = [] - for d in node.getchildren(): + for d in list(node): info['deducciones'].append(CaseInsensitiveDict(d.attrib.copy())) node = node_nomina.find('{}OtrosPagos'.format(PRE['NOMINA'][version])) if not node is None: info['otrospagos'] = [] - for o in node.getchildren(): + for o in list(node): info['otrospagos'].append(CaseInsensitiveDict(o.attrib.copy())) n = o.find('{}SubsidioAlEmpleo'.format(PRE['NOMINA'][version])) if not n is None: @@ -1996,7 +1996,7 @@ def _nomina(doc, data, values, version_cfdi): node = node_nomina.find('{}Incapacidades'.format(PRE['NOMINA'][version])) if not node is None: info['incapacidades'] = [] - for i in node.getchildren(): + for i in list(node): info['incapacidades'].append(CaseInsensitiveDict(i.attrib.copy())) return info @@ -3015,7 +3015,7 @@ class ImportCFDI(object): def _conceptos(self): data = [] conceptos = self._doc.find('{}Conceptos'.format(self._pre)) - for c in conceptos.getchildren(): + for c in list(conceptos): values = CaseInsensitiveDict(c.attrib.copy()) data.append(values) return data From 90eec635cedfde7376aa360c6f1f4f083ea6f76c Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Thu, 31 Dec 2020 21:00:00 -0600 Subject: [PATCH 16/22] Cancel cfdi of pay with xml signed --- source/app/controllers/utils.py | 2 +- source/app/models/main.py | 17 ++++++++--------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/source/app/controllers/utils.py b/source/app/controllers/utils.py index b73feb9..274635c 100644 --- a/source/app/controllers/utils.py +++ b/source/app/controllers/utils.py @@ -669,6 +669,6 @@ def cancel_xml_sign(invoice, auth, certificado): date_cancel = tree.xpath('string(//Acuse/@Fecha)')[:19] msg = 'Factura cancelada correctamente' - result = {'ok': True, 'msg': '', 'row': {'estatus': 'Cancelada'}, + result = {'ok': True, 'msg': msg, 'row': {'estatus': 'Cancelada'}, 'Fecha': date_cancel, 'Acuse': result} return result diff --git a/source/app/models/main.py b/source/app/models/main.py index f4eb719..6b5bcc2 100644 --- a/source/app/models/main.py +++ b/source/app/models/main.py @@ -6526,21 +6526,21 @@ class CfdiPagos(BaseModel): data = {'ok': False, 'msg': msg} return data - auth = Emisor.get_auth() - cert = Certificado.get_cert() - - data, result = util.cancel_xml(auth, last.uuid, cert) - if data['ok']: + auth = Configuracion.get_({'fields': 'pac_auth'}) + certificado = Certificado.get(Certificado.es_fiel==False) + result = utils.cancel_xml_sign(last, auth, certificado) + # ~ data, result = util.cancel_xml(auth, last.uuid, cert) + if result['ok']: last.estatus = 'Cancelada' last.error = '' last.cancelada = True last.fecha_cancelacion = result['Fecha'] - msg = 'Factura cancelada correctamente' + # ~ msg = 'Factura cancelada correctamente' else: - last.error = msg = data['msg'] + last.error = result['msg'] last.save() - return {'ok': data['ok'], 'msg': msg, 'id': last.id} + return {'ok': result['ok'], 'msg': result['msg'], 'id': last.id} def _get_folio(self, serie): folio = int(Configuracion.get_('txt_config_cfdipay_folio') or '0') @@ -8587,7 +8587,6 @@ class CfdiNomina(BaseModel): def _make_xml(self, cfdi): emisor = Emisor.select()[0] empleado = cfdi.empleado - # ~ certificado = Certificado.select()[0] certificado = Certificado.get(Certificado.es_fiel==False) totals = CfdiNominaTotales.select().where(CfdiNominaTotales.cfdi==cfdi)[0] From 9423aeef6c724f6295ad56e8df845ac51bd76a63 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Thu, 31 Dec 2020 21:11:22 -0600 Subject: [PATCH 17/22] Cancel cfdi of nomina with xml signed --- source/app/models/main.py | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/source/app/models/main.py b/source/app/models/main.py index 6b5bcc2..0d2dcb1 100644 --- a/source/app/models/main.py +++ b/source/app/models/main.py @@ -6529,13 +6529,11 @@ class CfdiPagos(BaseModel): auth = Configuracion.get_({'fields': 'pac_auth'}) certificado = Certificado.get(Certificado.es_fiel==False) result = utils.cancel_xml_sign(last, auth, certificado) - # ~ data, result = util.cancel_xml(auth, last.uuid, cert) if result['ok']: last.estatus = 'Cancelada' last.error = '' last.cancelada = True last.fecha_cancelacion = result['Fecha'] - # ~ msg = 'Factura cancelada correctamente' else: last.error = result['msg'] last.save() @@ -8029,29 +8027,26 @@ class CfdiNomina(BaseModel): def _cancel(self, values, user): id = int(values['id']) - msg = 'Recibo cancelado correctamente' - auth = Emisor.get_auth() - certificado = Certificado.select()[0] obj = CfdiNomina.get(CfdiNomina.id==id) - if obj.uuid is None: msg = 'Solo se pueden cancelar recibos timbrados' return {'ok': False, 'msg': msg} - data, result = util.cancel_xml(auth, obj.uuid, certificado) + auth = Configuracion.get_({'fields': 'pac_auth'}) + certificado = Certificado.get(Certificado.es_fiel==False) + result = utils.cancel_xml_sign(obj, auth, certificado) - if data['ok']: - data['msg'] = 'Recibo cancelado correctamente' - data['row']['estatus'] = 'Cancelado' - obj.estatus = data['row']['estatus'] + if result['ok']: + obj.estatus = 'Cancelado' obj.error = '' obj.cancelada = True obj.fecha_cancelacion = result['Fecha'] obj.acuse = result['Acuse'] or '' else: - obj.error = data['msg'] + obj.error = result['msg'] obj.save() - return data + + return result def _send_mail(self, values, user): id = int(values['id']) From cf189b08fa00bd49844db325d8af2b595071c5d4 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Thu, 31 Dec 2020 21:55:01 -0600 Subject: [PATCH 18/22] Add try in cert --- source/app/controllers/cfdi_cert.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/source/app/controllers/cfdi_cert.py b/source/app/controllers/cfdi_cert.py index 49b72dd..295711b 100644 --- a/source/app/controllers/cfdi_cert.py +++ b/source/app/controllers/cfdi_cert.py @@ -16,7 +16,11 @@ from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import padding -from .conf import TOKEN +try: + from .conf import TOKEN +except ImportError: + TOKEN = '' + print('Agrega el TOKEN al archivo conf.py, obligatorio en v1.41.0') class SATCertificate(object): From 348a7f6ecb67c587f109937a7a147a27b53ddf40 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Sat, 2 Jan 2021 18:16:15 -0600 Subject: [PATCH 19/22] Add pac Finkok refactory --- source/app/controllers/conf.py.example | 13 - source/app/controllers/configpac.py | 62 -- source/app/controllers/pac.py | 755 ------------------ source/app/controllers/pacs/__init__.py | 1 + .../app/controllers/{ => pacs}/cfdi_cert.py | 7 +- source/app/controllers/pacs/conf.py.example | 6 + .../app/controllers/pacs/finkok/__init__.py | 3 + .../controllers/pacs/finkok/conf.py.example | 46 ++ source/app/controllers/pacs/finkok/finkok.py | 549 +++++++++++++ source/app/controllers/util.py | 2 +- source/app/controllers/utils.py | 6 +- source/app/models/main.py | 36 +- 12 files changed, 627 insertions(+), 859 deletions(-) delete mode 100644 source/app/controllers/conf.py.example delete mode 100644 source/app/controllers/configpac.py delete mode 100644 source/app/controllers/pac.py rename source/app/controllers/{ => pacs}/cfdi_cert.py (98%) create mode 100644 source/app/controllers/pacs/conf.py.example create mode 100644 source/app/controllers/pacs/finkok/__init__.py create mode 100644 source/app/controllers/pacs/finkok/conf.py.example create mode 100644 source/app/controllers/pacs/finkok/finkok.py diff --git a/source/app/controllers/conf.py.example b/source/app/controllers/conf.py.example deleted file mode 100644 index 2a015b5..0000000 --- a/source/app/controllers/conf.py.example +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env python3 - - -DEBUG = False - -#~ Ecodex -ID_INTEGRADOR = '' - -#~ Finkok -FINKOK= { - 'USER': '', - 'PASS': '', -} diff --git a/source/app/controllers/configpac.py b/source/app/controllers/configpac.py deleted file mode 100644 index d4c70a0..0000000 --- a/source/app/controllers/configpac.py +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env python3 - - -from .conf import DEBUG, FINKOK - -DEBUG = DEBUG -TIMEOUT = 10 - -#~ PACs que han proporcionado un entorno de pruebas libre y abierto -#~ ecodex, finkok -PAC = 'finkok' - - -#~ IMPORTANTE: Si quieres hacer pruebas, con tu propio correo de usuario y -#~ contraseña, ponte en contacto con Finkok para que te asignen tus datos de -#~ acceso, consulta su documentación para ver las diferentes opciones de acceso. -#~ Si solo estas haciendo pruebas de timbrado y ancelación, con estos datos debería -#~ ser suficiente. -def finkok(debug): - USER = FINKOK['USER'] - PASS = FINKOK['PASS'] - TOKEN = '' - auth = { - 'DEBUG': debug, - 'USER': '', - 'PASS': TOKEN or PASS, - 'RESELLER': {'USER': USER, 'PASS': PASS} - } - if debug: - USER = 'pruebas-finkok@correolibre.net' - PASS = '' - TOKEN = '5c9a88da105bff9a8c430cb713f6d35269f51674bdc5963c1501b7316366' - auth = { - 'DEBUG': debug, - 'USER': USER, - 'PASS': TOKEN or PASS, - 'RESELLER': { - 'USER': '', - 'PASS': '' - } - } - - base_url = 'https://facturacion.finkok.com/servicios/soap/{}.wsdl' - if debug: - base_url = 'http://demo-facturacion.finkok.com/servicios/soap/{}.wsdl' - url = { - 'timbra': base_url.format('stamp'), - 'quick_stamp': False, - 'cancel': base_url.format('cancel'), - 'client': base_url.format('registration'), - 'util': base_url.format('utilities'), - 'codes': { - '200': 'Comprobante timbrado satisfactoriamente', - '307': 'Comprobante timbrado previamente', - '205': 'No Encontrado', - } - } - return auth, url - - -AUTH, URL = globals()[PAC](DEBUG) - diff --git a/source/app/controllers/pac.py b/source/app/controllers/pac.py deleted file mode 100644 index 9242773..0000000 --- a/source/app/controllers/pac.py +++ /dev/null @@ -1,755 +0,0 @@ -#!/usr/bin/env python3 - -#~ import re -#~ from xml.etree import ElementTree as ET -#~ from requests import Request, Session, exceptions -import datetime -import hashlib -import os -import requests -import time -from lxml import etree -from xml.dom.minidom import parseString -from xml.sax.saxutils import escape, unescape -from uuid import UUID - -from logbook import Logger -from zeep import Client -from zeep.plugins import HistoryPlugin -from zeep.cache import SqliteCache -from zeep.transports import Transport -from zeep.exceptions import Fault, TransportError -from requests.exceptions import ConnectionError - - -if __name__ == '__main__': - from configpac import DEBUG, TIMEOUT, AUTH, URL -else: - from .configpac import DEBUG, TIMEOUT, AUTH, URL - - -log = Logger('PAC') -#~ node = client.create_message(client.service, SERVICE, **args) -#~ print(etree.tostring(node, pretty_print=True).decode()) - - -class Ecodex(object): - - def __init__(self, auth, url): - self.auth = auth - self.url = url - self.codes = self.url['codes'] - self.error = '' - self.message = '' - self._transport = Transport(cache=SqliteCache(), timeout=TIMEOUT) - self._plugins = None - self._history = None - if DEBUG: - self._history = HistoryPlugin() - self._plugins = [self._history] - - def _get_token(self, rfc): - client = Client(self.url['seguridad'], - transport=self._transport, plugins=self._plugins) - try: - result = client.service.ObtenerToken(rfc, self._get_epoch()) - except Fault as e: - self.error = str(e) - log.error(self.error) - return '' - - s = '{}|{}'.format(self.auth['ID'], result.Token) - return hashlib.sha1(s.encode()).hexdigest() - - def _get_token_rest(self, rfc): - data = { - 'rfc': rfc, - 'grant_type': 'authorization_token', - } - headers = {'Content-type': 'application/x-www-form-urlencoded'} - result = requests.post(URL['token'], data=data, headers=headers) - data = result.json() - s = '{}|{}'.format(AUTH['ID'], data['service_token']) - return hashlib.sha1(s.encode()).hexdigest(), data['access_token'] - - def _validate_xml(self, xml): - NS_CFDI = {'cfdi': 'http://www.sat.gob.mx/cfd/3'} - if os.path.isfile(xml): - tree = etree.parse(xml).getroot() - else: - tree = etree.fromstring(xml.encode()) - - fecha = tree.get('Fecha') - rfc = tree.xpath('string(//cfdi:Emisor/@Rfc)', namespaces=NS_CFDI) - data = { - 'ComprobanteXML': etree.tostring(tree).decode(), - 'RFC': rfc, - 'Token': self._get_token(rfc), - 'TransaccionID': self._get_epoch(fecha), - } - return data - - def _get_by_hash(self, sh, rfc): - token, access_token = self._get_token_rest(rfc) - url = URL['hash'].format(sh) - headers = { - 'Authorization': 'Bearer {}'.format(access_token), - 'X-Auth-Token': token, - } - result = requests.get(url, headers=headers) - if result.status_code == 200: - print (result.json()) - return - - def timbra_xml(self, xml): - data = self._validate_xml(xml) - client = Client(self.url['timbra'], - transport=self._transport, plugins=self._plugins) - try: - result = client.service.TimbraXML(**data) - except Fault as e: - error = str(e) - if self.codes['HASH'] in error: - sh = error.split(' ')[3] - return self._get_by_hash(sh[:40], data['RFC']) - self.error = error - return '' - - tree = parseString(result.ComprobanteXML.DatosXML) - xml = tree.toprettyxml(encoding='utf-8').decode('utf-8') - return xml - - def _get_epoch(self, date=None): - if isinstance(date, str): - f = '%Y-%m-%dT%H:%M:%S' - e = int(time.mktime(time.strptime(date, f))) - else: - date = datetime.datetime.now() - e = int(time.mktime(date.timetuple())) - return e - - def estatus_cuenta(self, rfc): - #~ Codigos: - #~ 100 = Cuenta encontrada - #~ 101 = RFC no dado de alta en el sistema ECODEX - token = self._get_token(rfc) - if not token: - return {} - - data = { - 'RFC': rfc, - 'Token': token, - 'TransaccionID': self._get_epoch() - } - client = Client(URL['clients'], - transport=self._transport, plugins=self._plugins) - try: - result = client.service.EstatusCuenta(**data) - except Fault as e: - log.error(str(e)) - return - #~ print (result) - return result.Estatus - - -class Finkok(object): - - def __init__(self, auth={}): - self.codes = URL['codes'] - self.error = '' - self.message = '' - self._transport = Transport(cache=SqliteCache(), timeout=TIMEOUT) - self._plugins = None - self._history = None - self.uuid = '' - self.fecha = None - if DEBUG: - self._history = HistoryPlugin() - self._plugins = [self._history] - self._auth = AUTH - else: - self._auth = auth - - def _debug(self): - if not DEBUG: - return - print('SEND', self._history.last_sent) - print('RESULT', self._history.last_received) - return - - def _check_result(self, method, result): - # ~ print ('CODE', result.CodEstatus) - # ~ print ('INCIDENCIAS', result.Incidencias) - self.message = '' - MSG = { - 'OK': 'Comprobante timbrado satisfactoriamente', - '307': 'Comprobante timbrado previamente', - } - status = result.CodEstatus - if status is None and result.Incidencias: - for i in result.Incidencias['Incidencia']: - self.error += 'Error: {}\n{}\n{}'.format( - i['CodigoError'], i['MensajeIncidencia'], i['ExtraInfo']) - return '' - - if method == 'timbra' and status in (MSG['OK'], MSG['307']): - #~ print ('UUID', result.UUID) - #~ print ('FECHA', result.Fecha) - if status == MSG['307']: - self.message = MSG['307'] - tree = parseString(result.xml) - response = tree.toprettyxml(encoding='utf-8').decode('utf-8') - self.uuid = result.UUID - self.fecha = result.Fecha - - return response - - def _load_file(self, path): - try: - with open(path, 'rb') as f: - data = f.read() - except Exception as e: - self.error = str(e) - return - return data - - def _validate_xml(self, file_xml): - if os.path.isfile(file_xml): - try: - with open(file_xml, 'rb') as f: - xml = f.read() - except Exception as e: - self.error = str(e) - return False, '' - else: - xml = file_xml.encode('utf-8') - return True, xml - - def _validate_uuid(self, uuid): - try: - UUID(uuid) - return True - except ValueError: - self.error = 'UUID no válido: {}'.format(uuid) - return False - - def timbra_xml(self, file_xml): - self.error = '' - - if not DEBUG and not self._auth: - self.error = 'Sin datos para timbrar' - return - - method = 'timbra' - ok, xml = self._validate_xml(file_xml) - if not ok: - return '' - client = Client( - URL[method], transport=self._transport, plugins=self._plugins) - - args = { - 'username': self._auth['USER'], - 'password': self._auth['PASS'], - 'xml': xml, - } - if URL['quick_stamp']: - try: - result = client.service.quick_stamp(**args) - except Fault as e: - self.error = str(e) - return - else: - try: - result = client.service.stamp(**args) - except Fault as e: - self.error = str(e) - return - except TransportError as e: - if '413' in str(e): - self.error = '413

Documento muy grande para timbrar' - else: - self.error = str(e) - return - except ConnectionError as e: - msg = '502 - Error de conexión' - self.error = msg - return - - return self._check_result(method, result) - - def _get_xml(self, uuid): - if not self._validate_uuid(uuid): - return '' - - method = 'util' - client = Client( - URL[method], transport=self._transport, plugins=self._plugins) - - args = { - 'username': self._auth['USER'], - 'password': self._auth['PASS'], - 'uuid': uuid, - 'taxpayer_id': self.rfc, - 'invoice_type': 'I', - } - try: - result = client.service.get_xml(**args) - except Fault as e: - self.error = str(e) - return '' - except TransportError as e: - self.error = str(e) - return '' - - if result.error: - self.error = result.error - return '' - - tree = parseString(result.xml) - xml = tree.toprettyxml(encoding='utf-8').decode('utf-8') - return xml - - def recupera_xml(self, file_xml='', uuid=''): - self.error = '' - if uuid: - return self._get_xml(uuid) - - method = 'timbra' - ok, xml = self._validate_xml(file_xml) - if not ok: - return '' - client = Client( - URL[method], transport=self._transport, plugins=self._plugins) - try: - result = client.service.stamped( - xml, self._auth['user'], self._auth['pass']) - except Fault as e: - self.error = str(e) - return '' - - return self._check_result(method, result) - - def estatus_xml(self, uuid): - method = 'timbra' - if not self._validate_uuid(uuid): - return '' - client = Client( - URL[method], transport=self._transport, plugins=self._plugins) - try: - result = client.service.query_pending( - self._auth['USER'], self._auth['PASS'], uuid) - return result.status - except Fault as e: - self.error = str(e) - return '' - - def cancel_xml(self, rfc, uuid, cer, key): - # ~ for u in uuids: - # ~ if not self._validate_uuid(u): - # ~ return '' - - method = 'cancel' - client = Client( - URL[method], transport=self._transport, plugins=self._plugins) - uuid_type = client.get_type('ns1:UUIDS') - sa = client.get_type('ns0:stringArray') - - args = { - 'UUIDS': uuid_type(uuids=sa(string=uuid)), - 'username': self._auth['USER'], - 'password': self._auth['PASS'], - 'taxpayer_id': rfc, - 'cer': cer, - 'key': key, - 'store_pending': False, - } - try: - result = client.service.cancel(**args) - except Fault as e: - self.error = str(e) - return '' - - if result.CodEstatus and self.codes['205'] in result.CodEstatus: - self.error = result.CodEstatus - return '' - - return result - - def cancel_signature(self, file_xml): - method = 'cancel' - if os.path.isfile(file_xml): - root = etree.parse(file_xml).getroot() - else: - root = etree.fromstring(file_xml.encode()) - - xml = etree.tostring(root) - - client = Client( - URL[method], transport=self._transport, plugins=self._plugins) - - args = { - 'username': self._auth['USER'], - 'password': self._auth['PASS'], - 'xml': xml, - 'store_pending': False, - } - - try: - result = client.service.cancel_signature(**args) - return result - except Fault as e: - self.error = str(e) - return '' - - def get_acuse(self, rfc, uuids, type_acuse='C'): - for u in uuids: - if not self._validate_uuid(u): - return '' - - method = 'cancel' - client = Client( - URL[method], transport=self._transport, plugins=self._plugins) - - args = { - 'username': self._auth['USER'], - 'password': self._auth['PASS'], - 'taxpayer_id': rfc, - 'uuid': '', - 'type': type_acuse, - } - try: - result = [] - for u in uuids: - args['uuid'] = u - r = client.service.get_receipt(**args) - result.append(r) - except Fault as e: - self.error = str(e) - return '' - - return result - - def estatus_cancel(self, uuids): - for u in uuids: - if not self._validate_uuid(u): - return '' - - method = 'cancel' - client = Client( - URL[method], transport=self._transport, plugins=self._plugins) - - args = { - 'username': self._auth['USER'], - 'password': self._auth['PASS'], - 'uuid': '', - } - try: - result = [] - for u in uuids: - args['uuid'] = u - r = client.service.query_pending_cancellation(**args) - result.append(r) - except Fault as e: - self.error = str(e) - return '' - - return result - - def add_token(self, rfc, email): - """Agrega un nuevo token al cliente para timbrado. - Se requiere cuenta de reseller para usar este método - - Args: - rfc (str): El RFC del cliente, ya debe existir - email (str): El correo del cliente, funciona como USER al timbrar - - Returns: - dict - 'username': 'username', - 'status': True or False - 'name': 'name', - 'success': True or False - 'token': 'Token de timbrado', - 'message': None - """ - auth = AUTH['RESELLER'] - - method = 'util' - client = Client( - URL[method], transport=self._transport, plugins=self._plugins) - args = { - 'username': auth['USER'], - 'password': auth['PASS'], - 'name': rfc, - 'token_username': email, - 'taxpayer_id': rfc, - 'status': True, - } - try: - result = client.service.add_token(**args) - except Fault as e: - self.error = str(e) - return '' - - return result - - def get_date(self): - method = 'util' - client = Client( - URL[method], transport=self._transport, plugins=self._plugins) - try: - result = client.service.datetime(AUTH['USER'], AUTH['PASS']) - except Fault as e: - self.error = str(e) - return '' - - if result.error: - self.error = result.error - return - - return result.datetime - - def add_client(self, rfc, type_user=False): - """Agrega un nuevo cliente para timbrado. - Se requiere cuenta de reseller para usar este método - - Args: - rfc (str): El RFC del nuevo cliente - - Kwargs: - type_user (bool): False == 'P' == Prepago or True == 'O' == On demand - - Returns: - dict - 'message': - 'Account Created successfully' - 'Account Already exists' - 'success': True or False - """ - auth = AUTH['RESELLER'] - - tu = {False: 'P', True: 'O'} - method = 'client' - client = Client( - URL[method], transport=self._transport, plugins=self._plugins) - args = { - 'reseller_username': auth['USER'], - 'reseller_password': auth['PASS'], - 'taxpayer_id': rfc, - 'type_user': tu[type_user], - 'added': datetime.datetime.now().isoformat()[:19], - } - try: - result = client.service.add(**args) - except Fault as e: - self.error = str(e) - return '' - - return result - - def edit_client(self, rfc, status=True): - """ - Se requiere cuenta de reseller para usar este método - status = 'A' or 'S' - """ - auth = AUTH['RESELLER'] - - sv = {False: 'S', True: 'A'} - method = 'client' - client = Client( - URL[method], transport=self._transport, plugins=self._plugins) - args = { - 'reseller_username': auth['USER'], - 'reseller_password': auth['PASS'], - 'taxpayer_id': rfc, - 'status': sv[status], - } - try: - result = client.service.edit(**args) - except Fault as e: - self.error = str(e) - return '' - - return result - - def get_client(self, rfc): - """Regresa el estatus del cliente - . - Se requiere cuenta de reseller para usar este método - - Args: - rfc (str): El RFC del emisor - - Returns: - dict - 'message': None, - 'users': { - 'ResellerUser': [ - { - 'status': 'A', - 'counter': 0, - 'taxpayer_id': '', - 'credit': 0 - } - ] - } or None si no existe - """ - auth = AUTH['RESELLER'] - - method = 'client' - client = Client( - URL[method], transport=self._transport, plugins=self._plugins) - args = { - 'reseller_username': auth['USER'], - 'reseller_password': auth['PASS'], - 'taxpayer_id': rfc, - } - - try: - result = client.service.get(**args) - except Fault as e: - self.error = str(e) - return '' - except TransportError as e: - self.error = str(e) - return '' - - return result - - def assign_client(self, rfc, credit): - """Agregar credito a un emisor - - Se requiere cuenta de reseller para usar este método - - Args: - rfc (str): El RFC del emisor, debe existir - credit (int): Cantidad de folios a agregar - - Returns: - dict - 'success': True or False, - 'credit': nuevo credito despues de agregar or None - 'message': - 'Success, added {credit} of credit to {RFC}' - 'RFC no encontrado' - """ - auth = AUTH['RESELLER'] - - method = 'client' - client = Client( - URL[method], transport=self._transport, plugins=self._plugins) - args = { - 'username': auth['USER'], - 'password': auth['PASS'], - 'taxpayer_id': rfc, - 'credit': credit, - } - try: - result = client.service.assign(**args) - except Fault as e: - self.error = str(e) - return '' - - return result - - def client_get_timbres(self, rfc): - method = 'client' - client = Client( - URL[method], transport=self._transport, plugins=self._plugins) - args = { - 'reseller_username': self._auth['USER'], - 'reseller_password': self._auth['PASS'], - 'taxpayer_id': rfc, - } - - try: - self.result = client.service.get(**args) - except Fault as e: - self.error = str(e) - return 0 - except TransportError as e: - self.error = str(e) - return 0 - except ConnectionError: - self.error = 'Verifica la conexión a internet' - return 0 - - success = bool(self.result.users) - if not success: - self.error = self.result.message or 'RFC no existe' - return 0 - - return self.result.users.ResellerUser[0].credit - - -def _get_data_sat(path): - BF = 'string(//*[local-name()="{}"]/@{})' - NS_CFDI = {'cfdi': 'http://www.sat.gob.mx/cfd/3'} - - try: - if os.path.isfile(path): - tree = etree.parse(path).getroot() - else: - tree = etree.fromstring(path.encode()) - - data = {} - emisor = escape( - tree.xpath('string(//cfdi:Emisor/@rfc)', namespaces=NS_CFDI) or - tree.xpath('string(//cfdi:Emisor/@Rfc)', namespaces=NS_CFDI) - ) - receptor = escape( - tree.xpath('string(//cfdi:Receptor/@rfc)', namespaces=NS_CFDI) or - tree.xpath('string(//cfdi:Receptor/@Rfc)', namespaces=NS_CFDI) - ) - data['total'] = tree.get('total') or tree.get('Total') - data['emisor'] = emisor - data['receptor'] = receptor - data['uuid'] = tree.xpath(BF.format('TimbreFiscalDigital', 'UUID')) - except Exception as e: - print (e) - return {} - - return '?re={emisor}&rr={receptor}&tt={total}&id={uuid}'.format(**data) - - -def get_status_sat(xml): - data = _get_data_sat(xml) - if not data: - return 'XML inválido' - - data = """ - - - - - - {} - - - - """.format(data) - headers = { - 'SOAPAction': '"http://tempuri.org/IConsultaCFDIService/Consulta"', - 'Content-type': 'text/xml; charset="UTF-8"' - } - URL = 'https://consultaqr.facturaelectronica.sat.gob.mx/consultacfdiservice.svc' - - try: - result = requests.post(URL, data=data, headers=headers) - tree = etree.fromstring(result.text) - node = tree.xpath("//*[local-name() = 'Estado']")[0] - except Exception as e: - return 'Error: {}'.format(str(e)) - - return node.text - - -def main(): - return - - -if __name__ == '__main__': - main() diff --git a/source/app/controllers/pacs/__init__.py b/source/app/controllers/pacs/__init__.py index afe806b..05445b6 100644 --- a/source/app/controllers/pacs/__init__.py +++ b/source/app/controllers/pacs/__init__.py @@ -1,3 +1,4 @@ #!/usr/bin/env python from .comerciodigital import PACComercioDigital +from .finkok import PACFinkok diff --git a/source/app/controllers/cfdi_cert.py b/source/app/controllers/pacs/cfdi_cert.py similarity index 98% rename from source/app/controllers/cfdi_cert.py rename to source/app/controllers/pacs/cfdi_cert.py index 295711b..a19fe7e 100644 --- a/source/app/controllers/cfdi_cert.py +++ b/source/app/controllers/pacs/cfdi_cert.py @@ -15,12 +15,7 @@ from cryptography.x509.oid import ExtensionOID from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import padding - -try: - from .conf import TOKEN -except ImportError: - TOKEN = '' - print('Agrega el TOKEN al archivo conf.py, obligatorio en v1.41.0') +from .conf import TOKEN class SATCertificate(object): diff --git a/source/app/controllers/pacs/conf.py.example b/source/app/controllers/pacs/conf.py.example new file mode 100644 index 0000000..deb384c --- /dev/null +++ b/source/app/controllers/pacs/conf.py.example @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 + + +DEBUG = False + +TOKEN = '' diff --git a/source/app/controllers/pacs/finkok/__init__.py b/source/app/controllers/pacs/finkok/__init__.py new file mode 100644 index 0000000..1b61fc3 --- /dev/null +++ b/source/app/controllers/pacs/finkok/__init__.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 + +from .finkok import PACFinkok diff --git a/source/app/controllers/pacs/finkok/conf.py.example b/source/app/controllers/pacs/finkok/conf.py.example new file mode 100644 index 0000000..af7b74c --- /dev/null +++ b/source/app/controllers/pacs/finkok/conf.py.example @@ -0,0 +1,46 @@ +#!/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': '', + 'RESELLER': { + 'user': '', + 'pass': '' + } +} + + +if DEBUG: + AUTH = { + 'user': 'pruebas-finkok@correolibre.net', + 'pass': '5c9a88da105bff9a8c430cb713f6d35269f51674bdc5963c1501b7316366', + 'RESELLER': { + 'user': '', + 'pass': '' + } + } diff --git a/source/app/controllers/pacs/finkok/finkok.py b/source/app/controllers/pacs/finkok/finkok.py new file mode 100644 index 0000000..8c7e16b --- /dev/null +++ b/source/app/controllers/pacs/finkok/finkok.py @@ -0,0 +1,549 @@ +#!/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 base64 +import datetime +import logging +import os +import re +from io import BytesIO +from xml.sax.saxutils import unescape + +import lxml.etree as ET +from zeep import Client +from zeep.plugins import Plugin +from zeep.cache import SqliteCache +from zeep.transports import Transport +from zeep.exceptions import Fault, TransportError +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) +logging.getLogger('zeep').setLevel(logging.ERROR) + + +TIMEOUT = 10 +DEBUG_SOAP = True + + +class DebugPlugin(Plugin): + + def _to_string(self, envelope, name): + if DEBUG_SOAP: + data = ET.tostring(envelope, pretty_print=True, encoding='utf-8').decode() + path = f'/tmp/soap_{name}.xml' + with open(path, 'w') as f: + f.write(data) + return + + def egress(self, envelope, http_headers, operation, binding_options): + self._to_string(envelope, 'request') + return envelope, http_headers + + def ingress(self, envelope, http_headers, operation): + self._to_string(envelope, 'response') + return envelope, http_headers + + +class PACFinkok(object): + WS = 'https://facturacion.finkok.com/servicios/soap/{}.wsdl' + if DEBUG: + WS = 'http://demo-facturacion.finkok.com/servicios/soap/{}.wsdl' + URL = { + 'quick_stamp': False, + 'timbra': WS.format('stamp'), + 'cancel': WS.format('cancel'), + 'client': WS.format('registration'), + 'util': WS.format('utilities'), + } + CODE = { + '200': 'Comprobante timbrado satisfactoriamente', + '205': 'No Encontrado', + '307': 'Comprobante timbrado previamente', + '702': 'No se encontro el RFC del emisor', + 'IP': 'Invalid Passphrase', + 'IPMSG': 'Frase de paso inválida', + 'NE': 'No Encontrado', + } + + def __init__(self): + self._error = '' + self._transport = Transport(cache=SqliteCache(), timeout=TIMEOUT) + self._plugins = [DebugPlugin()] + + @property + def error(self): + return self._error + + def _validate_result(self, result): + if hasattr(result, 'CodEstatus'): + ce = result.CodEstatus + if ce is None: + return result + + if ce == self.CODE['IP']: + self._error = self.CODE['IPMSG'] + return {} + + if self.CODE['NE'] in ce: + self._error = 'UUID ' + self.CODE['NE'] + return {} + + if self.CODE['200'] != ce: + log.error('CodEstatus', type(ce), ce) + return result + + if hasattr(result, 'Incidencias'): + fault = result.Incidencias.Incidencia[0] + cod_error = fault.CodigoError.encode('utf-8') + msg_error = fault.MensajeIncidencia.encode('utf-8') + error = 'Error: {}\n{}'.format(cod_error, msg_error) + self._error = self.CODE.get(cod_error, error) + return {} + + return result + + def _get_result(self, client, method, args): + self._error = '' + try: + result = getattr(client.service, method)(**args) + except Fault as e: + self._error = str(e) + return {} + except TransportError as e: + if '413' in str(e): + self._error = '413

Documento muy grande para timbrar' + else: + self._error = str(e) + return {} + except ConnectionError as e: + msg = '502 - Error de conexión' + self._error = msg + return {} + + return self._validate_result(result) + + def _to_string(self, data): + root = ET.parse(BytesIO(data.encode('utf-8'))).getroot() + xml = ET.tostring(root, + pretty_print=True, xml_declaration=True, encoding='utf-8') + return xml.decode('utf-8') + + def stamp(self, cfdi, auth={}): + if DEBUG or not auth: + auth = AUTH + + method = 'timbra' + client = Client(self.URL[method], + transport=self._transport, plugins=self._plugins) + args = { + 'username': auth['user'], + 'password': auth['pass'], + 'xml': cfdi.encode('utf-8'), + } + result = self._get_result(client, 'stamp', args) + if self.error: + log.error(self.error) + return '' + + data = { + 'xml': self._to_string(result.xml), + 'uuid': result.UUID, + 'date': result.Fecha, + } + return data + + def _get_data_cancel(self, cfdi): + NS_CFDI = { + 'cfdi': 'http://www.sat.gob.mx/cfd/3', + 'tdf': 'http://www.sat.gob.mx/TimbreFiscalDigital', + } + tree = ET.fromstring(cfdi.encode()) + rfc_emisor = tree.xpath( + 'string(//cfdi:Comprobante/cfdi:Emisor/@Rfc)', + namespaces=NS_CFDI) + cfdi_uuid = tree.xpath( + 'string(//cfdi:Complemento/tdf:TimbreFiscalDigital/@UUID)', + namespaces=NS_CFDI) + return rfc_emisor, cfdi_uuid + + def cancel(self, cfdi, info, auth={}): + if not auth: + auth = AUTH + + rfc_emisor, cfdi_uuid = self._get_data_cancel(cfdi) + method = 'cancel' + client = Client(self.URL[method], + transport=self._transport, plugins=self._plugins) + uuid_type = client.get_type('ns1:UUIDS') + sa = client.get_type('ns0:stringArray') + + args = { + 'UUIDS': uuid_type(uuids=sa(string=cfdi_uuid)), + 'username': auth['user'], + 'password': auth['pass'], + 'taxpayer_id': rfc_emisor, + 'cer': info['cer'], + 'key': info['key'], + 'store_pending': False, + } + + result = self._get_result(client, 'cancel', args) + if self.error: + log.error(self.error) + return '' + + folio = result['Folios']['Folio'][0] + status = folio['EstatusUUID'] + if status != '201': + log.debug(f'Cancel status: {status} - {cfdi_uuid}') + + data = { + 'acuse': result['Acuse'], + 'date': result['Fecha'], + } + return data + + def cancel_xml(self, xml, auth={}): + if not auth: + auth = AUTH + + method = 'cancel' + client = Client(self.URL[method], + transport=self._transport, plugins=self._plugins) + client.set_ns_prefix('can', 'http://facturacion.finkok.com/cancel') + # ~ xml = f'\n{xml}' + # ~ xml = f'\n{xml}' + args = { + 'xml': xml.encode(), + 'username': auth['user'], + 'password': auth['pass'], + 'store_pending': False, + } + result = self._get_result(client, 'cancel_signature', args) + if self.error: + log.error(self.error) + return '' + + folio = result['Folios']['Folio'][0] + status = folio['EstatusUUID'] + if status != '201': + log.debug(f'Cancel status: {status} -') + + data = { + 'acuse': result['Acuse'], + 'date': result['Fecha'], + } + return data + + def client_add(self, rfc, type_user=False): + """Agrega un nuevo cliente para timbrado. + Se requiere cuenta de reseller para usar este método + + Args: + rfc (str): El RFC del nuevo cliente + + Kwargs: + type_user (bool): + False == 'P' == Prepago + True == 'O' == On demand + + Returns: + True or False + + origin PAC + 'message': + 'Account Created successfully' + 'Account Already exists' + 'success': True or False + """ + auth = AUTH['RESELLER'] + tu = {True: 'O', False: 'P'} + + method = 'client' + client = Client( + self.URL[method], transport=self._transport, plugins=self._plugins) + + args = { + 'reseller_username': auth['user'], + 'reseller_password': auth['pass'], + 'taxpayer_id': rfc, + 'type_user': tu[type_user], + 'added': datetime.datetime.now().isoformat()[:19], + } + + result = self._get_result(client, 'add', args) + if self.error: + return False + + if not result.success: + self.error = result.message + return False + + # ~ PAC success debería ser False + msg = 'Account Already exists' + if result.message == msg: + self.error = msg + return True + + return result.success + + def client_get_token(self, rfc, email): + """Genera un nuevo token al cliente para timbrado. + Se requiere cuenta de reseller para usar este método + + Args: + rfc (str): El RFC del cliente, ya debe existir + email (str): El correo del cliente, funciona como USER al timbrar + + Returns: + token (str): Es la contraseña para timbrar + + origin PAC + dict + 'username': 'username', + 'status': True or False + 'name': 'name', + 'success': True or False + 'token': 'Token de timbrado', + 'message': None + """ + auth = AUTH['RESELLER'] + method = 'util' + client = Client( + self.URL[method], transport=self._transport, plugins=self._plugins) + args = { + 'username': auth['user'], + 'password': auth['pass'], + 'name': rfc, + 'token_username': email, + 'taxpayer_id': rfc, + 'status': True, + } + + result = self._get_result(client, 'add_token', args) + if self.error: + log.error(self.error) + return '' + + if not result.success: + self.error = result.message + log.error(self.error) + return '' + + return result.token + + def client_add_timbres(self, rfc, credit): + """Agregar credito a un emisor + + Se requiere cuenta de reseller + + Args: + rfc (str): El RFC del emisor, debe existir + credit (int): Cantidad de folios a agregar + + Returns: + dict + 'success': True or False, + 'credit': nuevo credito despues de agregar or None + 'message': + 'Success, added {credit} of credit to {RFC}.' + 'RFC no encontrado' + """ + auth = AUTH['RESELLER'] + + method = 'client' + client = Client( + self.URL[method], transport=self._transport, plugins=self._plugins) + args = { + 'username': auth['user'], + 'password': auth['pass'], + 'taxpayer_id': rfc, + 'credit': credit, + } + + result = self._get_result(client, 'assign', args) + if self.error: + log.error(error) + return '' + + if not result.success: + self.error = result.message + return 0 + + return result.credit + + def client_balance(self, auth={}, rfc=''): + """Regresa los timbres restantes del cliente + Se pueden usar las credenciales de relleser o las credenciales del emisor + + Args: + rfc (str): El RFC del emisor + + Kwargs: + auth (dict): Credenciales del emisor + + Returns: + int Cantidad de timbres restantes + """ + + if not auth: + auth = AUTH['RESELLER'] + + method = 'client' + client = Client(self.URL[method], + transport=self._transport, plugins=self._plugins) + args = { + 'reseller_username': auth['user'], + 'reseller_password': auth['pass'], + 'taxpayer_id': rfc, + } + + result = self._get_result(client, 'get', args) + if self.error: + log.error(self.error) + return '' + + success = bool(result.users) + if not success: + self.error = result.message or 'RFC no existe' + return 0 + + return result.users.ResellerUser[0].credit + + def client_set_status(self, rfc, status): + """Edita el estatus (Activo o Suspendido) de un cliente + Se requiere cuenta de reseller para usar este método + + Args: + rfc (str): El RFC del cliente + + Kwargs: + status (bool): + True == 'A' == Activo + False == 'S' == Suspendido + + Returns: + dict + 'message': + 'Account Created successfully' + 'Account Already exists' + 'success': True or False + """ + auth = AUTH['RESELLER'] + ts = {True: 'A', False: 'S'} + method = 'client' + client = Client(self.URL[method], + transport=self._transport, plugins=self._plugins) + + args = { + 'reseller_username': auth['user'], + 'reseller_password': auth['pass'], + 'taxpayer_id': rfc, + 'status': ts[status], + } + result = self._get_result(client, 'edit', args) + + if self.error: + return False + + if not result.success: + self.error = result.message + return False + + return True + + def client_switch(self, rfc, type_user): + """Edita el tipo de timbrado (OnDemand o Prepago) de un cliente + Se requiere cuenta de reseller para usar este método + + Args: + rfc (str): El RFC del cliente + + Kwargs: + status (bool): + True == 'O' == OnDemand + False == 'P' == Prepago + + Returns: + dict + 'message': + 'Account Created successfully' + 'Account Already exists' + 'success': True or False + """ + auth = AUTH['RESELLER'] + tu = {True: 'O', False: 'P'} + method = 'client' + client = Client(self.URL[method], + transport=self._transport, plugins=self._plugins) + + args = { + 'username': auth['user'], + 'password': auth['pass'], + 'taxpayer_id': rfc, + 'type_user': tu[type_user], + } + result = self._get_result(client, 'switch', args) + + if self.error: + return False + + if not result.success: + self.error = result.message + return False + + return True + + def client_report_folios(self, rfc, date_from, date_to, invoice_type='I'): + """Obtiene un reporte del total de facturas timbradas + """ + auth = AUTH['RESELLER'] + + args = { + 'username': auth['user'], + 'password': auth['pass'], + 'taxpayer_id': rfc, + 'date_from': date_from, + 'date_to': date_to, + 'invoice_type': invoice_type, + } + + method = 'util' + client = Client(self.URL[method], + transport=self._transport, plugins=self._plugins) + + result = self._get_result(client, 'report_total', args) + + if result.result is None: + # ~ PAC - Debería regresar RFC inexistente o sin registros + self.error = 'RFC no existe o no tiene registros' + return 0 + + total = result.result.ReportTotal[0].total + + return total diff --git a/source/app/controllers/util.py b/source/app/controllers/util.py index 9b6dd25..af95d32 100644 --- a/source/app/controllers/util.py +++ b/source/app/controllers/util.py @@ -73,7 +73,7 @@ from settings import USAR_TOKEN, API, DECIMALES_TAX # ~ v2 -from .cfdi_cert import SATCertificate +from .pacs.cfdi_cert import SATCertificate from settings import ( EXT, diff --git a/source/app/controllers/utils.py b/source/app/controllers/utils.py index 274635c..76275ff 100644 --- a/source/app/controllers/utils.py +++ b/source/app/controllers/utils.py @@ -52,11 +52,9 @@ from .cfdi_xml import CFDI from settings import DEBUG, DB_COMPANIES, PATHS, TEMPLATE_CANCEL -from .cfdi_cert import SATCertificate +from .pacs.cfdi_cert import SATCertificate from .pacs import PACComercioDigital -# ~ from .pacs import PACFinkok -from .pac import Finkok as PACFinkok -# ~ from .finkok import PACFinkok +from .pacs import PACFinkok LOG_FORMAT = '%(asctime)s - %(levelname)s - %(message)s' diff --git a/source/app/models/main.py b/source/app/models/main.py index 0d2dcb1..255a754 100644 --- a/source/app/models/main.py +++ b/source/app/models/main.py @@ -3917,24 +3917,24 @@ class Facturas(BaseModel): query.execute() return - def _cancel_signature(self, id): - msg = 'Factura cancelada correctamente' - auth = Emisor.get_auth() - certificado = Certificado.select()[0] - obj = Facturas.get(Facturas.id==id) - data, result = util.cancel_signature( - obj.uuid, certificado.p12, certificado.rfc, auth) - if data['ok']: - obj.estatus = 'Cancelada' - obj.error = '' - obj.cancelada = True - obj.fecha_cancelacion = result['Fecha'] - obj.acuse = result['Acuse'] - self._actualizar_saldo_cliente(self, obj, True) - else: - obj.error = data['msg'] - obj.save() - return data + # ~ def _cancel_signature(self, id): + # ~ msg = 'Factura cancelada correctamente' + # ~ auth = Emisor.get_auth() + # ~ certificado = Certificado.select()[0] + # ~ obj = Facturas.get(Facturas.id==id) + # ~ data, result = util.cancel_signature( + # ~ obj.uuid, certificado.p12, certificado.rfc, auth) + # ~ if data['ok']: + # ~ obj.estatus = 'Cancelada' + # ~ obj.error = '' + # ~ obj.cancelada = True + # ~ obj.fecha_cancelacion = result['Fecha'] + # ~ obj.acuse = result['Acuse'] + # ~ self._actualizar_saldo_cliente(self, obj, True) + # ~ else: + # ~ obj.error = data['msg'] + # ~ obj.save() + # ~ return data def _get_filters(self, values): if 'start' in values and 'end' in values: From a7945dba58f754d7efe4636b56f30d1f00653bc4 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Sat, 2 Jan 2021 22:23:33 -0600 Subject: [PATCH 20/22] Cancel by rfc of pac --- CHANGELOG.md | 25 ++++++++++++++- .../pacs/comerciodigital/comercio.py | 2 +- source/app/controllers/pacs/finkok/finkok.py | 10 ++++-- source/app/controllers/utils.py | 16 ++++++++-- source/app/models/main.py | 32 +++++++++++++------ source/app/settings.py | 2 ++ 6 files changed, 71 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fd5b75..991c7a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,30 @@ -v 1.40.0 [20-12-2020] +v 1.40.0 [04-ene-2021] ---------------------- - Error: Al parsear XML en Python 3.9+ - Mejora: Agregar versión de Empresa Libre a plantilla. + - Mejora: Sellado en memoria + - Mejora: Se agrega un segundo PAC y se refactoriza el timbrado. + +* **IMPORTANTE** + +Es necesario seguir una serie de pasos **obligatorios** para migrar a esta +versión, **no continues hasta seguir paso a paso** estas instrucciones. +**Antes** de comenzar ten a la mano tus certificados de sello para timbrar, es +necesario subirlos de nuevo. **NO actualices si no tienes tus certificados** +con su respectiva contraseña, te quedarás sin poder timbrar. + +1. Entra a la parte administrativa y toma de tus credenciales de timbrado en el +menú "Emisor" ficha "Otros Datos", usuario y token de timbrado. +1. Agregar nuevo requerimiento `pip install xmlsec` +1. Actualizar `git pull origin master` +1. Entrar a `app/controllers/pacs` y copiar `conf.py.example` a `conf.py` +1. Reiniciar el servicio: `sudo systemctl restart empresalibre` +1. Sube de nuevo tus certificados en el menú "Emisor" ficha "Certificado". +1. Ve al menú "Opciones", ficha "Otros". +1. Selecciona tu PAC, si tu usuario es un correo electrónico, invariablemente +debes seleccionar Finkok. +1. Establece las credenciales del punto 1. +1. Guarda los datos. v 1.39.1 [17-sep-2020] diff --git a/source/app/controllers/pacs/comerciodigital/comercio.py b/source/app/controllers/pacs/comerciodigital/comercio.py index cc38b90..b9aea73 100644 --- a/source/app/controllers/pacs/comerciodigital/comercio.py +++ b/source/app/controllers/pacs/comerciodigital/comercio.py @@ -231,7 +231,7 @@ class PACComercioDigital(object): return headers - def cancel_xml(self, cfdi, xml, auth={}, info={'tipo': 'cfdi3.3'}): + def cancel_xml(self, xml, auth={}, cfdi='', info={'tipo': 'cfdi3.3'}): if DEBUG or not auth: auth = AUTH diff --git a/source/app/controllers/pacs/finkok/finkok.py b/source/app/controllers/pacs/finkok/finkok.py index 8c7e16b..76c936a 100644 --- a/source/app/controllers/pacs/finkok/finkok.py +++ b/source/app/controllers/pacs/finkok/finkok.py @@ -47,7 +47,7 @@ logging.getLogger('zeep').setLevel(logging.ERROR) TIMEOUT = 10 -DEBUG_SOAP = True +DEBUG_SOAP = False class DebugPlugin(Plugin): @@ -113,6 +113,10 @@ class PACFinkok(object): self._error = 'UUID ' + self.CODE['NE'] return {} + if ce == 'UUID Not Found': + self._error = 'UUID ' + self.CODE['NE'] + return {} + if self.CODE['200'] != ce: log.error('CodEstatus', type(ce), ce) return result @@ -228,8 +232,8 @@ class PACFinkok(object): } return data - def cancel_xml(self, xml, auth={}): - if not auth: + def cancel_xml(self, xml, auth={}, cfdi=''): + if DEBUG or not auth: auth = AUTH method = 'cancel' diff --git a/source/app/controllers/utils.py b/source/app/controllers/utils.py index 76275ff..3300f05 100644 --- a/source/app/controllers/utils.py +++ b/source/app/controllers/utils.py @@ -50,7 +50,7 @@ from dateutil import parser from .cfdi_xml import CFDI -from settings import DEBUG, DB_COMPANIES, PATHS, TEMPLATE_CANCEL +from settings import DEBUG, DB_COMPANIES, PATHS, TEMPLATE_CANCEL, RFCS from .pacs.cfdi_cert import SATCertificate from .pacs import PACComercioDigital @@ -79,6 +79,11 @@ PACS = { 'finkok': PACFinkok, 'comercio': PACComercioDigital, } +NS_CFDI = { + 'cfdi': 'http://www.sat.gob.mx/cfd/3', + 'tdf': 'http://www.sat.gob.mx/TimbreFiscalDigital', +} + #~ https://github.com/kennethreitz/requests/blob/v1.2.3/requests/structures.py#L37 class CaseInsensitiveDict(collections.MutableMapping): @@ -645,6 +650,13 @@ def make_xml(data, certificado): return cfdi.add_sello(stamp, cert.cer_txt) +def get_pac_by_rfc(cfdi): + tree = ET.fromstring(cfdi.encode()) + path = 'string(//cfdi:Complemento/tdf:TimbreFiscalDigital/@RfcProvCertif)' + rfc_pac = tree.xpath(path, namespaces=NS_CFDI) + return RFCS[rfc_pac] + + def cancel_xml_sign(invoice, auth, certificado): cert = SATCertificate(certificado.cer, certificado.key_enc.encode()) pac = PACS[auth['pac']]() @@ -658,7 +670,7 @@ def cancel_xml_sign(invoice, auth, certificado): tree = cert.sign_xml(tree) sign_xml = ET.tostring(tree).decode() - result = pac.cancel_xml(invoice.xml, sign_xml, auth) + result = pac.cancel_xml(sign_xml, auth, invoice.xml) if pac.error: result = {'ok': False, 'msg': pac.error, 'row': {}} return result diff --git a/source/app/models/main.py b/source/app/models/main.py index 255a754..5e5d3db 100644 --- a/source/app/models/main.py +++ b/source/app/models/main.py @@ -404,7 +404,7 @@ class Configuracion(BaseModel): return values - def _get_admin_products(self): + def _get_admin_products(self, args={}): fields = ( 'chk_config_cuenta_predial', 'chk_config_codigo_barras', @@ -419,7 +419,7 @@ class Configuracion(BaseModel): values = {r.clave: util.get_bool(r.valor) for r in data} return values - def _get_main_products(self): + def _get_main_products(self, args={}): fields = ( 'chk_config_cuenta_predial', 'chk_config_codigo_barras', @@ -436,7 +436,7 @@ class Configuracion(BaseModel): values['default_unidad'] = SATUnidades.get_default() return values - def _get_complements(self): + def _get_complements(self, args={}): fields = ( 'chk_config_ine', 'chk_config_edu', @@ -464,7 +464,7 @@ class Configuracion(BaseModel): return values - def _get_folios(self): + def _get_folios(self, args={}): fields = ( 'chk_folio_custom', ) @@ -475,7 +475,7 @@ class Configuracion(BaseModel): values = {r.clave: util.get_bool(r.valor) for r in data} return values - def _get_correo(self): + def _get_correo(self, args={}): fields = ('correo_servidor', 'correo_puerto', 'correo_ssl', 'correo_usuario', 'correo_copia', 'correo_asunto', 'correo_mensaje', 'correo_directo', 'correo_confirmacion') @@ -486,7 +486,7 @@ class Configuracion(BaseModel): values = {r.clave: r.valor for r in data} return values - def _get_admin_config_users(self): + def _get_admin_config_users(self, args={}): fields = ( 'chk_users_notify_access', ) @@ -523,7 +523,7 @@ class Configuracion(BaseModel): } return values - def _get_pac_auth(cls): + def _get_pac_auth(cls, args={}): pac = cls.get_('lst_pac').lower() user = cls.get_(f'user_timbrado_{pac}') token = cls.get_(f'token_timbrado_{pac}') @@ -534,6 +534,17 @@ class Configuracion(BaseModel): data['pass'] = token return data + def _get_auth_by_pac(cls, args): + pac = args['pac'] + user = cls.get_(f'user_timbrado_{pac}') + token = cls.get_(f'token_timbrado_{pac}') + data = {} + 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): @@ -553,10 +564,11 @@ class Configuracion(BaseModel): 'correo', 'admin_config_users', 'pac_auth', + 'auth_by_pac', ) opt = keys['fields'] if opt in options: - return getattr(cls, '_get_{}'.format(opt))(cls) + return getattr(cls, f'_get_{opt}')(cls, keys) if opt == 'pac': return cls._get_pac(cls, keys['pac']) @@ -3857,7 +3869,9 @@ class Facturas(BaseModel): msg = 'Solo es posible cancelar CFDI 3.3' return {'ok': False, 'msg': msg} - auth = Configuracion.get_({'fields': 'pac_auth'}) + pac = utils.get_pac_by_rfc(invoice.xml) + auth = Configuracion.get_({'fields': 'auth_by_pac', 'pac': pac}) + certificado = Certificado.get(Certificado.es_fiel==False) result = utils.cancel_xml_sign(invoice, auth, certificado) diff --git a/source/app/settings.py b/source/app/settings.py index aeda751..9b7af37 100644 --- a/source/app/settings.py +++ b/source/app/settings.py @@ -233,6 +233,8 @@ VALUES_PDF = { RFCS = { 'PUBLIC': 'XAXX010101000', 'FOREIGN': 'XEXX010101000', + 'CVD110412TF6': 'finkok', + 'SCD110105654': 'comercio', } URL = { From 0a04ec6c2637d84f686e2e2dd10c053fb087d2e2 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Sun, 3 Jan 2021 19:44:52 -0600 Subject: [PATCH 21/22] Cancel old with Finkok --- .../pacs/comerciodigital/comercio.py | 9 ++++- source/app/controllers/pacs/finkok/finkok.py | 8 ++++- source/app/controllers/utils.py | 34 ++++++++++++++----- source/app/models/main.py | 6 ++-- 4 files changed, 43 insertions(+), 14 deletions(-) diff --git a/source/app/controllers/pacs/comerciodigital/comercio.py b/source/app/controllers/pacs/comerciodigital/comercio.py index b9aea73..365b47e 100644 --- a/source/app/controllers/pacs/comerciodigital/comercio.py +++ b/source/app/controllers/pacs/comerciodigital/comercio.py @@ -249,7 +249,14 @@ class PACComercioDigital(object): self._error(result.headers['errmsg']) return '' - return result.text + tree = ET.fromstring(result.text) + date_cancel = tree.xpath('string(//Acuse/@Fecha)')[:19] + + data = { + 'acuse': result.text, + 'date': date_cancel, + } + return data def status(self, data, auth={}): if not auth: diff --git a/source/app/controllers/pacs/finkok/finkok.py b/source/app/controllers/pacs/finkok/finkok.py index 76c936a..e9bfa6f 100644 --- a/source/app/controllers/pacs/finkok/finkok.py +++ b/source/app/controllers/pacs/finkok/finkok.py @@ -196,7 +196,7 @@ class PACFinkok(object): return rfc_emisor, cfdi_uuid def cancel(self, cfdi, info, auth={}): - if not auth: + if DEBUG or not auth: auth = AUTH rfc_emisor, cfdi_uuid = self._get_data_cancel(cfdi) @@ -255,6 +255,12 @@ class PACFinkok(object): folio = result['Folios']['Folio'][0] status = folio['EstatusUUID'] + + if status == '708': + self._error = 'Error 708 del SAT, intenta más tarde.' + log.error(self.error) + return '' + if status != '201': log.debug(f'Cancel status: {status} -') diff --git a/source/app/controllers/utils.py b/source/app/controllers/utils.py index 3300f05..232ac37 100644 --- a/source/app/controllers/utils.py +++ b/source/app/controllers/utils.py @@ -657,13 +657,32 @@ def get_pac_by_rfc(cfdi): return RFCS[rfc_pac] +def _cancel_finkok(invoice, auth, certificado): + cert = SATCertificate(certificado.cer, certificado.key_enc.encode()) + pac = PACS[auth['pac']]() + info = {'cer': cert.cer_pem, 'key': cert.key_pem} + + result = pac.cancel(invoice.xml, info, auth) + if pac.error: + data = {'ok': False, 'msg': pac.error, 'row': {}} + return data + + msg = 'Factura cancelada correctamente' + data = {'ok': True, 'msg': msg, 'row': {'estatus': 'Cancelada'}, + 'date': result['date'], 'acuse': result['acuse']} + return data + + def cancel_xml_sign(invoice, auth, certificado): + if auth['pac'] == 'finkok': + return _cancel_finkok(invoice, auth, certificado) + cert = SATCertificate(certificado.cer, certificado.key_enc.encode()) pac = PACS[auth['pac']]() data = { 'rfc': certificado.rfc, 'fecha': now().isoformat()[:19], - 'uuid': invoice.uuid, + 'uuid': str(invoice.uuid).upper(), } template = TEMPLATE_CANCEL.format(**data) tree = ET.fromstring(template.encode()) @@ -672,13 +691,10 @@ def cancel_xml_sign(invoice, auth, certificado): result = pac.cancel_xml(sign_xml, auth, invoice.xml) if pac.error: - result = {'ok': False, 'msg': pac.error, 'row': {}} - return result - - tree = ET.fromstring(result) - date_cancel = tree.xpath('string(//Acuse/@Fecha)')[:19] + data = {'ok': False, 'msg': pac.error, 'row': {}} + return data msg = 'Factura cancelada correctamente' - result = {'ok': True, 'msg': msg, 'row': {'estatus': 'Cancelada'}, - 'Fecha': date_cancel, 'Acuse': result} - return result + data = {'ok': True, 'msg': msg, 'row': {'estatus': 'Cancelada'}, + 'date': result['date'], 'acuse': result['acuse']} + return data diff --git a/source/app/models/main.py b/source/app/models/main.py index 5e5d3db..1679bea 100644 --- a/source/app/models/main.py +++ b/source/app/models/main.py @@ -392,7 +392,7 @@ class Configuracion(BaseModel): return True return False - def _get_partners(self): + def _get_partners(self, args={}): fields = ( 'chk_config_change_balance_partner', ) @@ -3879,8 +3879,8 @@ class Facturas(BaseModel): invoice.estatus = 'Cancelada' invoice.error = '' invoice.cancelada = True - invoice.fecha_cancelacion = result['Fecha'] - invoice.acuse = result['Acuse'] or '' + invoice.fecha_cancelacion = result['date'] + invoice.acuse = result['acuse'] or '' cls._actualizar_saldo_cliente(cls, invoice, True) cls._update_inventory(cls, invoice, True) cls._uncancel_tickets(cls, invoice) From fec1c8523ac0a23184e7433426745f9a53cc8465 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Tue, 5 Jan 2021 17:32:03 -0600 Subject: [PATCH 22/22] Update changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 991c7a4..f3e42af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -v 1.40.0 [04-ene-2021] +v 1.40.0 [05-ene-2021] ---------------------- - Error: Al parsear XML en Python 3.9+ - Mejora: Agregar versión de Empresa Libre a plantilla. @@ -17,7 +17,7 @@ con su respectiva contraseña, te quedarás sin poder timbrar. menú "Emisor" ficha "Otros Datos", usuario y token de timbrado. 1. Agregar nuevo requerimiento `pip install xmlsec` 1. Actualizar `git pull origin master` -1. Entrar a `app/controllers/pacs` y copiar `conf.py.example` a `conf.py` +1. Entrar a `source/app/controllers/pacs` y copiar `conf.py.example` a `conf.py` 1. Reiniciar el servicio: `sudo systemctl restart empresalibre` 1. Sube de nuevo tus certificados en el menú "Emisor" ficha "Certificado". 1. Ve al menú "Opciones", ficha "Otros".