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')