diff --git a/.gitignore b/.gitignore index 3f3eb5e..a434b5a 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,6 @@ credenciales.conf *.sqlite *.sql rfc.db +configpac.py + diff --git a/source/app/controllers/cfdi_xml.py b/source/app/controllers/cfdi_xml.py new file mode 100644 index 0000000..6cef536 --- /dev/null +++ b/source/app/controllers/cfdi_xml.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python + +import datetime +from xml.etree import ElementTree as ET +from xml.dom.minidom import parseString + +from logbook import Logger + +#~ from settings import DEBUG + + +log = Logger('XML') +CFDI_ACTUAL = 'cfdi33' +NOMINA_ACTUAL = 'nomina12' + +SAT = { + 'xsi': 'http://www.w3.org/2001/XMLSchema-instance', + 'cfdi32': { + 'version': '3.2', + 'prefix': 'cfdi', + 'xmlns': 'http://www.sat.gob.mx/cfd/3', + 'schema': 'http://www.sat.gob.mx/cfd/3 http://www.sat.gob.mx/sitio_internet/cfd/3/cfdv32.xsd', + }, + 'cfdi33': { + 'version': '3.3', + 'prefix': 'cfdi', + 'xmlns': 'http://www.sat.gob.mx/cfd/3', + 'schema': 'http://www.sat.gob.mx/cfd/3 http://www.sat.gob.mx/sitio_internet/cfd/3/cfdv33.xsd', + }, + 'nomina11': { + 'version': '1.1', + 'prefix': 'nomina', + 'xmlns': 'http://www.sat.gob.mx/nomina', + 'schema': 'http://www.sat.gob.mx/nomina http://www.sat.gob.mx/sitio_internet/cfd/nomina/nomina11.xsd', + }, + 'nomina12': { + 'version': '1.2', + 'prefix': 'nomina', + 'xmlns': 'http://www.sat.gob.mx/nomina12', + 'schema': 'http://www.sat.gob.mx/nomina12 http://www.sat.gob.mx/sitio_internet/cfd/nomina/nomina12.xsd', + }, +} + + +class CFDI(object): + + def __init__(self, version=CFDI_ACTUAL): + self._sat_cfdi = SAT[version] + self._xsi = SAT['xsi'] + self._pre = self._sat_cfdi['prefix'] + self._cfdi = None + self.error = '' + + def _now(self): + return datetime.datetime.now().isoformat()[:19] + + def get_xml(self, datos): + if not self._validate(datos): + return '' + + self._comprobante(datos['comprobante']) + self._emisor(datos['emisor']) + self._receptor(datos['receptor']) + self._conceptos(datos['conceptos']) + self._impuestos(datos['impuestos']) + if 'nomina' in datos: + self._nomina(datos['nomina']) + if 'complementos' in datos: + self._complementos(datos['complementos']) + return self._to_pretty_xml(ET.tostring(self._cfdi, encoding='utf-8')) + + def add_sello(self, sello): + self._cfdi.attrib['Sello'] = sello + return self._to_pretty_xml(ET.tostring(self._cfdi, encoding='utf-8')) + + def _to_pretty_xml(self, source): + tree = parseString(source) + xml = tree.toprettyxml(encoding='utf-8').decode('utf-8') + return xml + + def _validate(self, datos): + if 'nomina' in datos: + return self._validate_nomina(datos) + return True + + def _validate_nomina(self, datos): + comprobante = datos['comprobante'] + + validators = ( + ('MetodoDePago', 'NA'), + ('TipoCambio', '1'), + ('Moneda', 'MXN'), + ('TipoDeComprobante', 'egreso'), + ) + for f, v in validators: + if f in comprobante: + if v != comprobante[f]: + msg = 'El atributo: {}, debe ser: {}'.format(f, v) + self.error = msg + return False + return True + + def _comprobante(self, datos): + attributes = {} + attributes['xmlns:{}'.format(self._pre)] = self._sat_cfdi['xmlns'] + attributes['xmlns:xsi'] = self._xsi + attributes['xsi:schemaLocation'] = self._sat_cfdi['schema'] + attributes.update(datos) + + #~ if DEBUG: + #~ attributes['Fecha'] = self._now() + #~ attributes['NoCertificado'] = CERT_NUM + + if not 'Version' in attributes: + attributes['Version'] = self._sat_cfdi['version'] + if not 'Fecha' in attributes: + attributes['Fecha'] = self._now() + + self._cfdi = ET.Element('{}:Comprobante'.format(self._pre), attributes) + return + + def _emisor(self, datos): + #~ if DEBUG: + #~ datos['Rfc'] = RFC_TEST + + node_name = '{}:Emisor'.format(self._pre) + emisor = ET.SubElement(self._cfdi, node_name, datos) + return + + def _receptor(self, datos): + node_name = '{}:Receptor'.format(self._pre) + emisor = ET.SubElement(self._cfdi, node_name, datos) + return + + def _conceptos(self, datos): + conceptos = ET.SubElement(self._cfdi, '{}:Conceptos'.format(self._pre)) + for row in datos: + complemento = {} + if 'complemento' in row: + complemento = row.pop('complemento') + + taxes = {} + if 'impuestos' in row: + taxes = row.pop('impuestos') + node_name = '{}:Concepto'.format(self._pre) + concepto = ET.SubElement(conceptos, node_name, row) + + if taxes: + node_name = '{}:Impuestos'.format(self._pre) + impuestos = ET.SubElement(concepto, node_name) + if 'traslados' in taxes and taxes['traslados']: + node_name = '{}:Traslados'.format(self._pre) + traslados = ET.SubElement(impuestos, node_name) + for traslado in taxes['traslados']: + ET.SubElement( + traslados, '{}:Traslado'.format(self._pre), traslado) + if 'retenciones' in taxes and taxes['retenciones']: + node_name = '{}:Retenciones'.format(self._pre) + retenciones = ET.SubElement(impuestos, node_name) + for retencion in taxes['retenciones']: + ET.SubElement( + retenciones, '{}:Retencion'.format(self._pre), retencion) + + if 'InformacionAduanera' in row: + for field in fields: + if field in row['InformacionAduanera']: + attributes[field] = row['InformacionAduanera'][field] + if attributes: + node_name = '{}:InformacionAduanera'.format(self._pre) + ET.SubElement(concepto, node_name, attributes) + + if 'CuentaPredial' in row: + attributes = {'numero': row['CuentaPredial']} + node_name = '{}:CuentaPredial'.format(self._pre) + ET.SubElement(concepto, node_name, attributes) + + if 'autRVOE' in row: + fields = ( + 'version', + 'nombreAlumno', + 'CURP', + 'nivelEducativo', + 'autRVOE', + ) + for field in fields: + if field in row['autRVOE']: + attributes[field] = row['autRVOE'][field] + node_name = '{}:ComplementoConcepto'.format(self._pre) + complemento = ET.SubElement(concepto, node_name) + ET.SubElement(complemento, 'iedu:instEducativas', attributes) + return + + def _impuestos(self, datos): + if not datos: + node_name = '{}:Impuestos'.format(self._pre) + ET.SubElement(self._cfdi, node_name) + return + + attributes = {} + fields = ('TotalImpuestosTrasladados', 'TotalImpuestosRetenidos') + for field in fields: + if field in datos: + attributes[field] = datos[field] + node_name = '{}:Impuestos'.format(self._pre) + impuestos = ET.SubElement(self._cfdi, node_name, attributes) + + if 'retenciones' in datos and datos['retenciones']: + retenciones = ET.SubElement(impuestos, '{}:Retenciones'.format(self._pre)) + for row in datos['retenciones']: + ET.SubElement(retenciones, '{}:Retencion'.format(self._pre), row) + + if 'traslados' in datos and datos['traslados']: + traslados = ET.SubElement(impuestos, '{}:Traslados'.format(self._pre)) + for row in datos['traslados']: + ET.SubElement(traslados, '{}:Traslado'.format(self._pre), row) + return + + def _nomina(self, datos): + sat_nomina = SAT[NOMINA_ACTUAL] + pre = sat_nomina['prefix'] + complemento = ET.SubElement(self._cfdi, '{}:Complemento'.format(self._pre)) + + emisor = datos.pop('Emisor', None) + receptor = datos.pop('Receptor', None) + percepciones = datos.pop('Percepciones', None) + deducciones = datos.pop('Deducciones', None) + + attributes = {} + attributes['xmlns:{}'.format(pre)] = sat_nomina['xmlns'] + attributes['xsi:schemaLocation'] = sat_nomina['schema'] + attributes.update(datos) + + if not 'Version' in attributes: + attributes['Version'] = sat_nomina['version'] + + nomina = ET.SubElement(complemento, '{}:Nomina'.format(pre), attributes) + if emisor: + ET.SubElement(nomina, '{}:Emisor'.format(pre), emisor) + if receptor: + ET.SubElement(nomina, '{}:Receptor'.format(pre), receptor) + if percepciones: + detalle = percepciones.pop('detalle', None) + percepciones = ET.SubElement(nomina, '{}:Percepciones'.format(pre), percepciones) + for row in detalle: + ET.SubElement(percepciones, '{}:Percepcion'.format(pre), row) + if deducciones: + detalle = deducciones.pop('detalle', None) + deducciones = ET.SubElement(nomina, '{}:Deducciones'.format(pre), deducciones) + for row in detalle: + ET.SubElement(deducciones, '{}:Deduccion'.format(pre), row) + return + + def _complementos(self, datos): + complemento = ET.SubElement(self._cfdi, '{}:Complemento'.format(self._pre)) + if 'ce' in datos: + pre = 'cce11' + datos = datos.pop('ce') + emisor = datos.pop('emisor') + propietario = datos.pop('propietario') + receptor = datos.pop('receptor') + destinatario = datos.pop('destinatario') + conceptos = datos.pop('conceptos') + + attributes = {} + attributes['xmlns:{}'.format(pre)] = \ + 'http://www.sat.gob.mx/ComercioExterior11' + attributes['xsi:schemaLocation'] = \ + 'http://www.sat.gob.mx/ComercioExterior11 ' \ + 'http://www.sat.gob.mx/sitio_internet/cfd/ComercioExterior11/ComercioExterior11.xsd' + attributes.update(datos) + ce = ET.SubElement( + complemento, '{}:ComercioExterior'.format(pre), attributes) + + attributes = {} + if 'Curp' in emisor: + attributes = {'Curp': emisor.pop('Curp')} + node = ET.SubElement(ce, '{}:Emisor'.format(pre), attributes) + ET.SubElement(node, '{}:Domicilio'.format(pre), emisor) + + if propietario: + ET.SubElement(ce, '{}:Propietario'.format(pre), propietario) + + attributes = {} + if 'NumRegIdTrib' in receptor: + attributes = {'NumRegIdTrib': receptor.pop('NumRegIdTrib')} + node = ET.SubElement(ce, '{}:Receptor'.format(pre), attributes) + ET.SubElement(node, '{}:Domicilio'.format(pre), receptor) + + attributes = {} + if 'NumRegIdTrib' in destinatario: + attributes = {'NumRegIdTrib': destinatario.pop('NumRegIdTrib')} + if 'Nombre' in destinatario: + attributes.update({'Nombre': destinatario.pop('Nombre')}) + node = ET.SubElement(ce, '{}:Destinatario'.format(pre), attributes) + ET.SubElement(node, '{}:Domicilio'.format(pre), destinatario) + + node = ET.SubElement(ce, '{}:Mercancias'.format(pre)) + fields = ('Marca', 'Modelo', 'SubModelo', 'NumeroSerie') + for row in conceptos: + detalle = {} + for f in fields: + if f in row: + detalle[f] = row.pop(f) + concepto = ET.SubElement(node, '{}:Mercancia'.format(pre), row) + if detalle: + ET.SubElement( + concepto, '{}:DescripcionesEspecificas'.format(pre), detalle) + return diff --git a/source/app/controllers/pac.py b/source/app/controllers/pac.py new file mode 100644 index 0000000..49e3cc5 --- /dev/null +++ b/source/app/controllers/pac.py @@ -0,0 +1,609 @@ +#!/usr/bin/env python + +#~ 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 .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): + self.codes = 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(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(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(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): + 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] + + 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{}'.format( + i['CodigoError'], i['MensajeIncidencia']) + 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 = '' + 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': AUTH['USER'], + 'password': 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 + + 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': AUTH['USER'], + 'password': 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, AUTH['USER'], 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(AUTH['USER'], AUTH['PASS'], uuid) + #~ print (result.date) + #~ tree = parseString(unescape(result.xml)) + #~ response = tree.toprettyxml(encoding='utf-8').decode('utf-8') + return result.status + except Fault as e: + self.error = str(e) + return '' + + def cancel_xml(self, rfc, uuids, path_cer, path_key): + for u in uuids: + if not self._validate_uuid(u): + return '' + + cer = self._load_file(path_cer) + key = self._load_file(path_key) + 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=uuids)), + 'username': AUTH['USER'], + 'password': AUTH['PASS'], + 'taxpayer_id': rfc, + 'cer': cer, + 'key': key, + 'store_pending': True, + } + 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) + + xml = etree.tostring(root) + + client = Client( + URL[method], transport=self._transport, plugins=self._plugins) + + args = { + 'username': AUTH['USER'], + 'password': AUTH['PASS'], + 'xml': xml, + 'store_pending': True, + } + + result = client.service.cancel_signature(**args) + return result + + 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': AUTH['USER'], + 'password': 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': AUTH['USER'], + 'password': 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): + """ + Se requiere cuenta de reseller para usar este método + """ + 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, + } + result = client.service.add_token(**args) + 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): + """ + Se requiere cuenta de reseller para usar este método + type_user: False == 'P' == Prepago or True == 'O' == On demand + """ + 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' + """ + 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): + """ + Se requiere cuenta de reseller para usar este método + """ + 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): + """ + Se requiere cuenta de reseller para usar este método + """ + 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 _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) + + 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 + + URL = 'https://consultaqr.facturaelectronica.sat.gob.mx/ConsultaCFDIService.svc?wsdl' + client = Client(URL, transport=Transport(cache=SqliteCache())) + try: + result = client.service.Consulta(expresionImpresa=data) + except Exception as e: + return 'Error: {}'.format(str(e)) + + return result.Estado + + +def main(): + return + + +if __name__ == '__main__': + main() diff --git a/source/app/controllers/util.py b/source/app/controllers/util.py index 4a71ac0..156de65 100644 --- a/source/app/controllers/util.py +++ b/source/app/controllers/util.py @@ -15,7 +15,8 @@ import uuid from dateutil import parser -from settings import DEBUG, log, template_lookup, COMPANIES, DB_SAT +from settings import DEBUG, log, template_lookup, COMPANIES, DB_SAT, \ + PATH_XSLT, PATH_XSLTPROC, PATH_OPENSSL #~ def _get_hash(password): @@ -30,6 +31,10 @@ def _call(args): return subprocess.check_output(args, shell=True).decode() +def _get_md5(data): + return hashlib.md5(data.encode()).hexdigest() + + def _save_temp(data, modo='wb'): path = tempfile.mkstemp()[1] with open(path, modo) as f: @@ -37,6 +42,18 @@ def _save_temp(data, modo='wb'): return path +def _join(*paths): + return os.path.join(*paths) + + +def _kill(path): + try: + os.remove(path) + except: + pass + return + + def get_pass(): password = getpass.getpass('Introduce la contraseña: ') pass2 = getpass.getpass('Confirma la contraseña: ') @@ -312,7 +329,7 @@ class Certificado(object): args = 'openssl pkcs8 -inform DER -in "{}" -passin pass:{} | ' \ 'openssl rsa -des3 -passout pass:{}'.format( - self._path_key, password, hashlib.md5(rfc.encode()).hexdigest()) + self._path_key, password, _get_md5(rfc)) key_enc = _call(args) data['key'] = self._key @@ -327,7 +344,7 @@ class Certificado(object): return {} data = self._get_info_cer(rfc) - llave = self._get_info_key(password, rfc) + llave = self._get_info_key(password, data['rfc']) if not llave: return {} @@ -336,3 +353,49 @@ class Certificado(object): self._kill(self._path_key) self._kill(self._path_cer) return data + + +def make_xml(data, certificado): + from .cfdi_xml import CFDI + + if DEBUG: + data['emisor']['Rfc'] = certificado.rfc + data['emisor']['RegimenFiscal'] = '603' + + 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': _get_md5(certificado.rfc) + } + 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']) + + return cfdi.add_sello(sello) + + +def timbra_xml(xml): + from .pac import Finkok as PAC + + result = {'ok': True, 'error': ''} + pac = PAC() + xml = pac.timbra_xml(xml) + if not xml: + result['ok'] = False + result['error'] = pac.error + return result + + result['xml'] = xml + result['uuid'] = pac.uuid + result['fecha'] = pac.fecha + return result diff --git a/source/app/models/main.py b/source/app/models/main.py index 65a8f5e..c42b8d9 100644 --- a/source/app/models/main.py +++ b/source/app/models/main.py @@ -16,6 +16,8 @@ if __name__ == '__main__': from controllers import util from settings import log, VERSION, PATH_CP +FORMAT = '{0:.2f}' + database_proxy = Proxy() class BaseModel(Model): @@ -947,6 +949,7 @@ class Facturas(BaseModel): obj = Facturas.get(Facturas.id==id) if obj.uuid: return False + q = FacturasDetalle.delete().where(FacturasDetalle.factura==obj) q.execute() q = FacturasImpuestos.delete().where(FacturasImpuestos.factura==obj) @@ -1108,21 +1111,21 @@ class Facturas(BaseModel): receptor = { 'Rfc': invoice.cliente.rfc, - 'Nombre': invoice.cliente.name, + 'Nombre': invoice.cliente.nombre, 'UsoCFDI': invoice.uso_cfdi, } conceptos = [] - rows = Details.select().where(Details.invoice==invoice) + rows = FacturasDetalle.select().where(FacturasDetalle.factura==invoice) for row in rows: concepto = { - 'ClaveProdServ': row.product.key_sat, - 'NoIdentificacion': row.product.key, - 'Cantidad': FORMAT.format(row.cant), - 'ClaveUnidad': row.product.unit.key, - 'Unidad': row.product.unit.name, - 'Descripcion': row.product.description, - 'ValorUnitario': FORMAT.format(row.price), + 'ClaveProdServ': row.producto.clave_sat, + 'NoIdentificacion': row.producto.clave, + 'Cantidad': FORMAT.format(row.cantidad), + 'ClaveUnidad': row.producto.unidad.key, + 'Unidad': row.producto.unidad.name, + 'Descripcion': row.producto.descripcion, + 'ValorUnitario': FORMAT.format(row.valor_unitario), 'Importe': FORMAT.format(row.importe), } @@ -1130,25 +1133,21 @@ class Facturas(BaseModel): traslados = [] retenciones = [] - impuestos = (ProductsTaxes - .select() - .where(ProductsTaxes.product==row.product)) - - for impuesto in impuestos: - if impuesto.tax.tipo == 'E': + for impuesto in row.producto.impuestos: + if impuesto.tipo == 'E': continue - import_tax = round(impuesto.tax.tasa * row.importe, 2) + import_tax = round(impuesto.tasa * row.importe, 2) tipo_factor = 'Tasa' - if impuesto.tax.factor != 'T': + if impuesto.factor != 'T': tipo_factor = 'Cuota' tax = { "Base": FORMAT.format(row.importe), - "Impuesto": impuesto.tax.key, + "Impuesto": impuesto.key, "TipoFactor": tipo_factor, - "TasaOCuota": str(impuesto.tax.tasa), + "TasaOCuota": str(impuesto.tasa), "Importe": FORMAT.format(import_tax), } - if impuesto.tax.tipo == 'T': + if impuesto.tipo == 'T': traslados.append(tax) else: retenciones.append(tax) @@ -1168,24 +1167,24 @@ class Facturas(BaseModel): impuestos['TotalImpuestosRetenidos'] = \ FORMAT.format(invoice.total_retenciones) - taxes = (InvoicesTaxes + taxes = (FacturasImpuestos .select() - .where(InvoicesTaxes.invoice==invoice)) + .where(FacturasImpuestos.factura==invoice)) for tax in taxes: tipo_factor = 'Tasa' - if tax.tax.factor != 'T': + if tax.impuesto.factor != 'T': tipo_factor = 'Cuota' - if tax.tax.tipo == 'T': + if tax.impuesto.tipo == 'T': traslado = { - "Impuesto": tax.tax.key, + "Impuesto": tax.impuesto.key, "TipoFactor": tipo_factor, - "TasaOCuota": str(tax.tax.tasa), + "TasaOCuota": str(tax.impuesto.tasa), "Importe": FORMAT.format(tax.importe), } traslados.append(traslado) else: retencion = { - "Impuesto": tax.tax.key, + "Impuesto": tax.impuesto.key, "Importe": FORMAT.format(tax.importe), } retenciones.append(retencion) @@ -1209,21 +1208,24 @@ class Facturas(BaseModel): obj.estatus = 'Generada' obj.save() - #~ error = False - #~ result = util.timbra_xml(obj.xml) - #~ if result['ok']: - #~ obj.xml = result['xml'] - #~ obj.uuid = result['uuid'] - #~ obj.fecha_timbrado = result['fecha'] - #~ obj.estatus = 'Timbrada' - #~ obj.save() - #~ else: - #~ error = True - #~ msg = result['error'] - #~ obj.estatus = 'Error' - #~ obj.error = msg - #~ obj.save() - return + error = False + msg = 'Factura timbrada correctamente' + result = util.timbra_xml(obj.xml) + if result['ok']: + obj.xml = result['xml'] + obj.uuid = result['uuid'] + obj.fecha_timbrado = result['fecha'] + obj.estatus = 'Timbrada' + obj.save() + row = {'uuid': obj.uuid, 'estatus': 'Timbrada'} + else: + error = True + msg = result['error'] + obj.estatus = 'Error' + obj.error = msg + row = {'estatus': 'Error'} + obj.save() + return {'ok': result['ok'], 'msg': msg, 'row': row} class FacturasDetalle(BaseModel): diff --git a/source/app/settings.py b/source/app/settings.py index 5c1d76d..88b87ff 100644 --- a/source/app/settings.py +++ b/source/app/settings.py @@ -20,6 +20,9 @@ PATH_CP = os.path.abspath(os.path.join(BASE_DIR, '..', 'db', 'cp.db')) COMPANIES = os.path.abspath(os.path.join(BASE_DIR, '..', 'db', 'rfc.db')) DB_SAT = os.path.abspath(os.path.join(BASE_DIR, '..', 'db', 'sat.db')) +PATH_XSLT = os.path.abspath(os.path.join(BASE_DIR, '..', 'xslt')) +PATH_BIN = os.path.abspath(os.path.join(BASE_DIR, '..', 'bin')) + template_lookup = TemplateLookup(directories=[PATH_TEMPLATES], input_encoding='utf-8', output_encoding='utf-8') @@ -50,3 +53,9 @@ else: log = Logger(LOG_NAME) + +PATH_XSLTPROC = 'xsltproc' +PATH_OPENSSL = 'openssl' +if 'win' in sys.platform: + PATH_XSLTPROC = os.path.join(PATH_BIN, 'xsltproc.exe') + PATH_OPENSSL = os.path.join(PATH_BIN, 'openssl.exe') diff --git a/source/static/img/file-email.png b/source/static/img/file-email.png new file mode 100644 index 0000000..b655a0b Binary files /dev/null and b/source/static/img/file-email.png differ diff --git a/source/static/img/file-pdf.png b/source/static/img/file-pdf.png new file mode 100644 index 0000000..e8fbe0e Binary files /dev/null and b/source/static/img/file-pdf.png differ diff --git a/source/static/img/file-xml.png b/source/static/img/file-xml.png index 74dae07..1fa648e 100644 Binary files a/source/static/img/file-xml.png and b/source/static/img/file-xml.png differ diff --git a/source/static/img/file-zip.png b/source/static/img/file-zip.png new file mode 100644 index 0000000..15175ac Binary files /dev/null and b/source/static/img/file-zip.png differ diff --git a/source/static/js/controller/invoices.js b/source/static/js/controller/invoices.js index 5ee58a7..a4c2b98 100644 --- a/source/static/js/controller/invoices.js +++ b/source/static/js/controller/invoices.js @@ -111,7 +111,7 @@ function cmd_edit_invoice_click(id, e, node){ function delete_invoice(id){ webix.ajax().del('/invoices', {id: id}, function(text, xml, xhr){ if(xhr.status == 200){ - $$('grid_invoices').remove(id) + gi.remove(id) msg_sucess('Factura eliminada correctamente') }else{ msg_error('No se pudo eliminar') @@ -120,16 +120,15 @@ function delete_invoice(id){ } - function cmd_delete_invoice_click(id, e, node){ - var row = $$('grid_invoices').getSelectedItem() + var row = gi.getSelectedItem() if (row == undefined){ msg_error('Selecciona una factura') return } - if(!row['uuid']==null){ + if(row.uuid){ msg_error('Solo se pueden eliminar facturas sin timbrar') return } @@ -221,9 +220,9 @@ function validate_invoice(values){ function update_grid_invoices(values){ if(values.new){ - $$('grid_invoices').add(values.row) + gi.add(values.row) }else{ - $$("grid_invoices").updateItem(values.row['id'], values.row) + gi.updateItem(values.row['id'], values.row) } } @@ -232,6 +231,7 @@ function send_timbrar(id){ var values = data.json() if(values.ok){ msg_sucess(values.msg) + gi.updateItem(id, values.row) }else{ webix.alert({ title: 'Error al Timbrar', @@ -256,11 +256,12 @@ function save_invoice(data){ success:function(text, data, XmlHttpRequest){ values = data.json(); if(values.ok){ + msg_sucess('Factura guardada correctamente. Enviando a timbrar') update_grid_invoices(values) send_timbrar(values.row['id']) result = true }else{ - webix.message({type:'error', text:values.msg}) + msg_error(values.msg) } } }) @@ -602,3 +603,40 @@ function grid_details_header_click(id){ } }) } + + +function cmd_refacturar_click(){ + show('Refacturar') +} + + +function cmd_invoice_timbrar_click(){ + if(gi.count() == 0){ + return + } + + var row = gi.getSelectedItem() + if (row == undefined){ + msg_error('Selecciona una factura') + return + } + + if(row.uuid){ + msg_error('La factura ya esta timbrada') + return + } + + msg = '¿Estás seguro de enviar a timbrar esta factura?' + webix.confirm({ + title: 'Timbrar Factura', + ok: 'Si', + cancel: 'No', + type: 'confirm-error', + text: msg, + callback:function(result){ + if(result){ + send_timbrar(row.id) + } + } + }) +} diff --git a/source/static/js/controller/main.js b/source/static/js/controller/main.js index c44d63b..b9e64ba 100644 --- a/source/static/js/controller/main.js +++ b/source/static/js/controller/main.js @@ -1,3 +1,4 @@ +var gi = null var controllers = { @@ -29,9 +30,9 @@ var controllers = { $$("chk_automatica").attachEvent("onChange", chk_automatica_change) $$("valor_unitario").attachEvent("onChange", valor_unitario_change) //~ Invoices - $$("cmd_new_invoice").attachEvent("onItemClick", cmd_new_invoice_click) - $$("cmd_edit_invoice").attachEvent("onItemClick", cmd_edit_invoice_click) - $$("cmd_delete_invoice").attachEvent("onItemClick", cmd_delete_invoice_click) + $$('cmd_new_invoice').attachEvent("onItemClick", cmd_new_invoice_click) + $$('cmd_refacturar').attachEvent("onItemClick", cmd_refacturar_click) + $$('cmd_delete_invoice').attachEvent("onItemClick", cmd_delete_invoice_click) $$('cmd_timbrar').attachEvent('onItemClick', cmd_timbrar_click) $$('cmd_close_invoice').attachEvent('onItemClick', cmd_close_invoice_click) $$('search_client_id').attachEvent('onKeyPress', search_client_id_key_press) @@ -42,6 +43,7 @@ var controllers = { $$('grid_details').attachEvent('onHeaderClick', grid_details_header_click) $$('grid_details').attachEvent('onBeforeEditStart', grid_details_before_edit_start) $$('grid_details').attachEvent('onBeforeEditStop', grid_details_before_edit_stop) + $$('cmd_invoice_timbrar').attachEvent('onItemClick', cmd_invoice_timbrar_click) } } @@ -136,6 +138,7 @@ function multi_change(prevID, nextID){ if(active == 'invoices_home'){ get_invoices() } + gi = $$('grid_invoices') return } diff --git a/source/static/js/ui/invoices.js b/source/static/js/ui/invoices.js index 528e13f..6d93c27 100644 --- a/source/static/js/ui/invoices.js +++ b/source/static/js/ui/invoices.js @@ -3,28 +3,46 @@ var toolbar_invoices = [ {view: "button", id: "cmd_new_invoice", label: "Nueva", type: "iconButton", autowidth: true, icon: "plus"}, - {view: "button", id: "cmd_edit_invoice", label: "Editar", type: "iconButton", + {view: "button", id: "cmd_refacturar", label: "Refacturar", type: "iconButton", autowidth: true, icon: "pencil"}, + {}, {view: "button", id: "cmd_delete_invoice", label: "Eliminar", type: "iconButton", autowidth: true, icon: "minus"}, ] +var toolbar_invoices_util = [ + {view: 'button', id: 'cmd_invoice_timbrar', label: 'Timbrar', + type: 'iconButton', autowidth: true, icon: 'ticket'}, +] + + function doc_xml(obj){ var node = "" return node } +function doc_pdf(obj){ + var node = "" + return node +} + + +function get_icon(tipo){ + var node = "" + return node +} + + var grid_invoices_cols = [ {id: "id", header:"ID", hidden:true}, {id: "serie", header: ["Serie", {content: "selectFilter"}], adjust: "data", sort:"string"}, {id: "folio", header: ["Folio", {content: "numberFilter"}], adjust: "data", sort:"int", css: "cell_right"}, - {id: 'xml', header: '', adjust: 'data', template: doc_xml}, {id: "uuid", header: ["UUID", {content: "textFilter"}], adjust: "data", - sort:"string"}, + sort:"string", hidden:true}, {id: "fecha", header: ["Fecha y Hora"], adjust: "data", sort:"string"}, {id: "tipo_comprobante", header: ["Tipo", {content: "selectFilter"}], adjust: 'header', sort: 'string'}, @@ -34,13 +52,17 @@ var grid_invoices_cols = [ sort: 'int', format: webix.i18n.priceFormat, css: 'right'}, {id: "cliente", header: ["Razón Social", {content: "selectFilter"}], fillspace:true, sort:"string"}, + {id: 'xml', header: 'XML', adjust: 'data', template: get_icon('xml')}, + {id: 'pdf', header: 'PDF', adjust: 'data', template: get_icon('pdf')}, + {id: 'zip', header: 'ZIP', adjust: 'data', template: get_icon('zip')}, + {id: 'email', header: '', adjust: 'data', template: get_icon('email')} ] var grid_invoices = { - view: "datatable", - id: "grid_invoices", - select: "row", + view: 'datatable', + id: 'grid_invoices', + select: 'row', adjust: true, footer: true, resizeColumn: true, @@ -296,15 +318,16 @@ var form_invoice = { var multi_invoices = { - id: "multi_invoices", + id: 'multi_invoices', view: 'multiview', animate: true, cells:[ - {id: "invoices_home", rows:[ - {view: "toolbar", elements: toolbar_invoices}, + {id: 'invoices_home', rows:[ + {view: 'toolbar', elements: toolbar_invoices}, + {view: 'toolbar', elements: toolbar_invoices_util}, grid_invoices, ]}, - {id: "invoices_new", rows:[form_invoice, {}]} + {id: 'invoices_new', rows:[form_invoice, {}]} ] } diff --git a/source/xslt/cadena.xslt b/source/xslt/cadena.xslt new file mode 100644 index 0000000..2b81c70 --- /dev/null +++ b/source/xslt/cadena.xslt @@ -0,0 +1,345 @@ + + + + + + + + + + + + + + + + ||| + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/source/xslt/comercioexterior11.xslt b/source/xslt/comercioexterior11.xslt new file mode 100644 index 0000000..fd71841 --- /dev/null +++ b/source/xslt/comercioexterior11.xslt @@ -0,0 +1,181 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/source/xslt/leyendasFisc.xslt b/source/xslt/leyendasFisc.xslt new file mode 100644 index 0000000..e0587a2 --- /dev/null +++ b/source/xslt/leyendasFisc.xslt @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/source/xslt/nomina12.xslt b/source/xslt/nomina12.xslt new file mode 100644 index 0000000..2570170 --- /dev/null +++ b/source/xslt/nomina12.xslt @@ -0,0 +1,412 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/source/xslt/utilerias.xslt b/source/xslt/utilerias.xslt new file mode 100644 index 0000000..d5dd14e --- /dev/null +++ b/source/xslt/utilerias.xslt @@ -0,0 +1,22 @@ + + + + + + | + + + + + + + + | + + + + + + + +