From b12525e475e101d8f0d91b0405fac9a646aba683 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Thu, 29 Apr 2021 22:39:55 -0500 Subject: [PATCH] Generar XML --- source/api/util/cfdi_cert.py | 6 + source/api/util/cfdi_xml.py | 237 +++++++++++ source/api/util/util.py | 49 ++- source/api/util/xslt/cadena.xslt | 345 ++++++++++++++++ source/api/util/xslt/comercioexterior11.xslt | 181 ++++++++ source/api/util/xslt/divisas.xslt | 13 + source/api/util/xslt/donat11.xslt | 13 + source/api/util/xslt/iedu.xslt | 26 ++ source/api/util/xslt/implocal.xslt | 39 ++ source/api/util/xslt/ine11.xslt | 51 +++ source/api/util/xslt/leyendasFisc.xslt | 28 ++ source/api/util/xslt/nomina12.xslt | 412 +++++++++++++++++++ source/api/util/xslt/pagos10.xslt | 165 ++++++++ source/api/util/xslt/utilerias.xslt | 22 + source/api/views.py | 31 +- source/tests/tests.py | 94 +++++ 16 files changed, 1703 insertions(+), 9 deletions(-) create mode 100644 source/api/util/cfdi_xml.py create mode 100644 source/api/util/xslt/cadena.xslt create mode 100644 source/api/util/xslt/comercioexterior11.xslt create mode 100644 source/api/util/xslt/divisas.xslt create mode 100644 source/api/util/xslt/donat11.xslt create mode 100644 source/api/util/xslt/iedu.xslt create mode 100644 source/api/util/xslt/implocal.xslt create mode 100644 source/api/util/xslt/ine11.xslt create mode 100644 source/api/util/xslt/leyendasFisc.xslt create mode 100644 source/api/util/xslt/nomina12.xslt create mode 100644 source/api/util/xslt/pagos10.xslt create mode 100644 source/api/util/xslt/utilerias.xslt diff --git a/source/api/util/cfdi_cert.py b/source/api/util/cfdi_cert.py index c456013..079c078 100644 --- a/source/api/util/cfdi_cert.py +++ b/source/api/util/cfdi_cert.py @@ -34,6 +34,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'' @@ -78,6 +79,7 @@ class SATCertificate(object): self._is_fiel = obj.extensions.get_extension_for_oid( ExtensionOID.KEY_USAGE).value.key_agreement + self._cer = cer 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 @@ -181,6 +183,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/api/util/cfdi_xml.py b/source/api/util/cfdi_xml.py new file mode 100644 index 0000000..470c896 --- /dev/null +++ b/source/api/util/cfdi_xml.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 + +from datetime import datetime +import lxml.etree as ET + + +class CFDI(object): + _version = '3.3' + _prefix = 'cfdi' + _xmlns = 'http://www.sat.gob.mx/cfd/3' + _schema = f'{_xmlns} http://www.sat.gob.mx/sitio_internet/cfd/3/cfdv33.xsd' + _pagos = 'http://www.sat.gob.mx/Pagos' + NS = {'cfdi': 'http://www.sat.gob.mx/cfd/3'} + PAGOS = { + 'version': '1.0', + 'prefix': _pagos, + 'ns': {'pago10': _pagos}, + 'schema': f' {_pagos} http://www.sat.gob.mx/sitio_internet/cfd/Pagos/Pagos10.xsd', + } + _nomina = 'http://www.sat.gob.mx/nomina12' + NOMINA = { + 'version': '1.2', + 'prefix': _nomina, + 'ns': {'nomina12': _nomina}, + 'schema': f' {_nomina} http://www.sat.gob.mx/sitio_internet/cfd/nomina/nomina12.xsd', + } + + def __init__(self): + self.error = '' + self._root = None + + @property + def xml(self): + cfdi = ET.tostring(self._root, + pretty_print=True, xml_declaration=True, encoding='utf-8') + return cfdi.decode() + + def make_xml(self, data): + self._validate_data(data) + self._comprobante(data['comprobante']) + self._relacionados(data.get('relacionados', {})) + self._emisor(data['emisor']) + self._receptor(data['receptor']) + self._conceptos(data['conceptos']) + self._impuestos(data.get('impuestos', {})) + self._complementos(data.get('complementos', {})) + return + + def stamp(self, cert, path_xslt): + xslt = open(path_xslt, 'rb') + transfor = ET.XSLT(ET.parse(xslt)) + print(cert) + return + + def _validate_data(self, data): + self._node_complement = False + self._exists_pagos = False + self._exists_nomina = False + if not 'complementos' in data: + return + + complements = data['complementos'] + self._exists_pagos = 'pagos' in complements + self._exists_nomina = 'nomina' in complements + + if self._exists_pagos: + self._node_complement = True + self._schema += self.PAGOS['schema'] + if self._exists_nomina: + self._node_complement = True + self._schema += self.NOMINA['schema'] + return + + def _comprobante(self, attr): + NSMAP = { + self._prefix: self._xmlns, + 'xsi': 'http://www.w3.org/2001/XMLSchema-instance', + } + if self._exists_pagos: + NSMAP.update(self.PAGOS['ns']) + if self._exists_nomina: + NSMAP.update(self.NOMINA['ns']) + + attr_qname = ET.QName( + 'http://www.w3.org/2001/XMLSchema-instance', 'schemaLocation') + schema = {attr_qname: self._schema} + + if not 'Version' in attr: + attr['Version'] = self._version + if not 'Fecha' in attr: + attr['Fecha'] = datetime.now().isoformat()[:19] + + node_name = f'{{{self._xmlns}}}Comprobante' + self._root = ET.Element(node_name, schema, **attr, nsmap=NSMAP) + return + + def _relacionados(self, attr): + return + + def _emisor(self, attr): + node_name = f'{{{self._xmlns}}}Emisor' + emisor = ET.SubElement(self._root, node_name, attr) + return + + def _receptor(self, attr): + node_name = f'{{{self._xmlns}}}Receptor' + emisor = ET.SubElement(self._root, node_name, attr) + return + + def _conceptos(self, data): + node_name = f'{{{self._xmlns}}}Conceptos' + conceptos = ET.SubElement(self._root, node_name) + for row in data: + complemento = row.pop('complemento', {}) + taxes = row.pop('impuestos', {}) + + node_name = f'{{{self._xmlns}}}Concepto' + concepto = ET.SubElement(conceptos, node_name, row) + + if not taxes: + continue + + if taxes['traslados'] or taxes['retenciones']: + node_name = f'{{{self._xmlns}}}Impuestos' + impuestos = ET.SubElement(concepto, node_name) + if 'traslados' in taxes and taxes['traslados']: + node_name = f'{{{self._xmlns}}}Traslados' + traslados = ET.SubElement(impuestos, node_name) + node_name = f'{{{self._xmlns}}}Traslado' + for traslado in taxes['traslados']: + ET.SubElement(traslados, node_name, traslado) + if 'retenciones' in taxes and taxes['retenciones']: + node_name = f'{{{self._xmlns}}}Retenciones' + retenciones = ET.SubElement(impuestos, node_name) + node_name = f'{{{self._xmlns}}}Retencion' + for retencion in taxes['retenciones']: + ET.SubElement(retenciones, node_name, retencion) + return + + def _impuestos(self, data): + if not data: + return + + node_name = f'{{{self._xmlns}}}Impuestos' + retenciones = data.pop('retenciones', ()) + traslados = data.pop('traslados', ()) + taxes = ET.SubElement(self._root, node_name, data) + + if retenciones: + node_name = f'{{{self._xmlns}}}Retenciones' + subnode = ET.SubElement(taxes, node_name) + node_name = f'{{{self._xmlns}}}Retencion' + for row in retenciones: + ET.SubElement(subnode, node_name, row) + + if traslados: + node_name = f'{{{self._xmlns}}}Traslados' + subnode = ET.SubElement(taxes, node_name) + node_name = f'{{{self._xmlns}}}Traslado' + for row in traslados: + ET.SubElement(subnode, node_name, row) + + return + + def _complementos(self, data): + if not self._node_complement: + return + + node_name = f'{{{self._xmlns}}}Complemento' + complemento = ET.SubElement(self._root, node_name) + + if self._exists_pagos: + self._pagos(complemento, data['pagos']) + + if self._exists_nomina: + self._nomina(complemento, data['nomina']) + return + + def _pagos(self, complemento, data): + node_name = f"{{{self.PAGOS['prefix']}}}Pagos" + attr = {'Version': self.PAGOS['version']} + node_pagos = ET.SubElement(complemento, node_name, attr) + for pago in data: + documentos = pago.pop('documentos') + node_name = f"{{{self.PAGOS['prefix']}}}Pago" + node_pago = ET.SubElement(node_pagos, node_name, pago) + node_name = f"{{{self.PAGOS['prefix']}}}DoctoRelacionado" + for doc in documentos: + ET.SubElement(node_pago, node_name, doc) + return + + def _nomina(self, complemento, data): + emisor = data.pop('emisor') + receptor = data.pop('receptor') + percepciones = data.pop('percepciones', {}) + deducciones = data.pop('deducciones', {}) + otros = data.pop('otros', ()) + + if not 'Version' in data: + data['Version'] = self.NOMINA['version'] + + node_name = f"{{{self.NOMINA['prefix']}}}Nomina" + node_nomina = ET.SubElement(complemento, node_name, data) + node_name = f"{{{self.NOMINA['prefix']}}}Emisor" + ET.SubElement(node_nomina, node_name, emisor) + node_name = f"{{{self.NOMINA['prefix']}}}Receptor" + ET.SubElement(node_nomina, node_name, receptor) + + if percepciones: + attr = percepciones + percepciones = attr.pop('percepciones') + node_name = f"{{{self.NOMINA['prefix']}}}Percepciones" + node = ET.SubElement(node_nomina, node_name, attr) + node_name = f"{{{self.NOMINA['prefix']}}}Percepcion" + for percepcion in percepciones: + ET.SubElement(node, node_name, percepcion) + + if deducciones: + attr = deducciones + deducciones = attr.pop('deducciones') + node_name = f"{{{self.NOMINA['prefix']}}}Deducciones" + node = ET.SubElement(node_nomina, node_name, attr) + node_name = f"{{{self.NOMINA['prefix']}}}Deduccion" + for deduccion in deducciones: + ET.SubElement(node, node_name, deduccion) + + if otros: + node_name = f"{{{self.NOMINA['prefix']}}}OtrosPagos" + node = ET.SubElement(node_nomina, node_name) + node_name = f"{{{self.NOMINA['prefix']}}}OtroPago" + for otro in otros: + subsidio = otro.pop('subsidio', {}) + sub_node = ET.SubElement(node, node_name, otro) + if subsidio: + sub_name = f"{{{self.NOMINA['prefix']}}}SubsidioAlEmpleo" + ET.SubElement(sub_node, sub_name, subsidio) + return diff --git a/source/api/util/util.py b/source/api/util/util.py index 2de94b5..90bc51d 100644 --- a/source/api/util/util.py +++ b/source/api/util/util.py @@ -1,8 +1,19 @@ #!/usr/bin/env python3 +import json +from datetime import datetime +from pathlib import Path from secrets import token_hex +from django.core.exceptions import ObjectDoesNotExist +from django.utils.timezone import now from ..conf import API_TOKEN from .cfdi_cert import SATCertificate +from .cfdi_xml import CFDI + +from ..models import Clients + + +CURRENT_DIR = Path(__file__).resolve().parent #.parent def validate_token(token): @@ -46,9 +57,45 @@ def validate_client(post, files): 'rfc': rfc, 'token': token_hex(32), 'key': cert.key_enc, - 'cer': cert.cer_pem, + 'cer': cert.cer, 'serial_number': cert.serial_number, 'date_from': cert.not_before, 'date_to': cert.not_after, } return True, data + + +def validate_cfdi(post): + data = json.loads(post) + rfc = data['emisor']['Rfc'] + + try: + obj = Clients.objects.get(rfc=rfc) + except ObjectDoesNotExist: + msg = 'Emisor no existe' + return False, msg + + if obj.date_to < now(): + msg = 'Certificado vencido' + return False, msg + + if rfc != obj.rfc: + msg = 'Documento no corresponde al RFC del emisor' + return False, msg + + data = {'cfdi': data, 'emisor': obj} + + return True, data + + +def send_stamp(data): + msg = '' + emisor = data['emisor'] + path_xslt = CURRENT_DIR / 'xslt/cadena.xslt' + cert = SATCertificate(emisor.cer, emisor.key) + + cfdi = CFDI() + cfdi.make_xml(data['cfdi']) + cfdi.stamp(cert, path_xslt) + + return cfdi.xml, msg diff --git a/source/api/util/xslt/cadena.xslt b/source/api/util/xslt/cadena.xslt new file mode 100644 index 0000000..4c6e2d5 --- /dev/null +++ b/source/api/util/xslt/cadena.xslt @@ -0,0 +1,345 @@ + + + + + + + + + + + + + + + + + + + + + + ||| + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/source/api/util/xslt/comercioexterior11.xslt b/source/api/util/xslt/comercioexterior11.xslt new file mode 100644 index 0000000..fd71841 --- /dev/null +++ b/source/api/util/xslt/comercioexterior11.xslt @@ -0,0 +1,181 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/source/api/util/xslt/divisas.xslt b/source/api/util/xslt/divisas.xslt new file mode 100644 index 0000000..dc8988e --- /dev/null +++ b/source/api/util/xslt/divisas.xslt @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/source/api/util/xslt/donat11.xslt b/source/api/util/xslt/donat11.xslt new file mode 100644 index 0000000..24d4363 --- /dev/null +++ b/source/api/util/xslt/donat11.xslt @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/source/api/util/xslt/iedu.xslt b/source/api/util/xslt/iedu.xslt new file mode 100644 index 0000000..eb285cb --- /dev/null +++ b/source/api/util/xslt/iedu.xslt @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/source/api/util/xslt/implocal.xslt b/source/api/util/xslt/implocal.xslt new file mode 100644 index 0000000..80b8d23 --- /dev/null +++ b/source/api/util/xslt/implocal.xslt @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/source/api/util/xslt/ine11.xslt b/source/api/util/xslt/ine11.xslt new file mode 100644 index 0000000..05c1e56 --- /dev/null +++ b/source/api/util/xslt/ine11.xslt @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/source/api/util/xslt/leyendasFisc.xslt b/source/api/util/xslt/leyendasFisc.xslt new file mode 100644 index 0000000..e0587a2 --- /dev/null +++ b/source/api/util/xslt/leyendasFisc.xslt @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/source/api/util/xslt/nomina12.xslt b/source/api/util/xslt/nomina12.xslt new file mode 100644 index 0000000..2570170 --- /dev/null +++ b/source/api/util/xslt/nomina12.xslt @@ -0,0 +1,412 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/source/api/util/xslt/pagos10.xslt b/source/api/util/xslt/pagos10.xslt new file mode 100644 index 0000000..98b41f2 --- /dev/null +++ b/source/api/util/xslt/pagos10.xslt @@ -0,0 +1,165 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/source/api/util/xslt/utilerias.xslt b/source/api/util/xslt/utilerias.xslt new file mode 100644 index 0000000..d5dd14e --- /dev/null +++ b/source/api/util/xslt/utilerias.xslt @@ -0,0 +1,22 @@ + + + + + + | + + + + + + + + | + + + + + + + + diff --git a/source/api/views.py b/source/api/views.py index 5e6ee37..419316c 100644 --- a/source/api/views.py +++ b/source/api/views.py @@ -1,6 +1,7 @@ -from django.http import HttpResponse, HttpResponseNotFound +from django.http import HttpResponse from django.http import JsonResponse from django.views import View +from django.core.exceptions import ObjectDoesNotExist from .util import util from .models import Clients @@ -11,10 +12,8 @@ def _validate_token(request): return False token = request.headers['Token'] - if not util.validate_token(token): - return False - - return True + result = util.validate_token(token) + return result class ViewClients(View): @@ -58,12 +57,28 @@ class ViewClients(View): return HttpResponse(status=401) rfc = request.GET['rfc'] - obj = Clients.objects.get(rfc=rfc) + try: + obj = Clients.objects.get(rfc=rfc) + except ObjectDoesNotExist: + msg = 'Cliente no existe' + return HttpResponse(msg, status=202) + obj.delete() return HttpResponse() class ViewCfdi(View): - def get(self, request): - return HttpResponse('ok') + def post(self, request): + if not _validate_token(request): + return HttpResponse(status=401) + + result, data = util.validate_cfdi(request.body) + if not result: + return HttpResponse(data, status=202) + + xml, msg = util.send_stamp(data) + if msg: + return HttpResponse(msg, status=202) + + return HttpResponse(xml, status=201) diff --git a/source/tests/tests.py b/source/tests/tests.py index 91645cf..96e0b6e 100644 --- a/source/tests/tests.py +++ b/source/tests/tests.py @@ -1,16 +1,78 @@ #!/usr/bin/env python3 import unittest +import warnings import httpx URL_API = 'http://127.0.0.1:8000/api/{}' PATH_CERT = 'certificados/comercio.{}' +CFDI_MINIMO = { + "comprobante": { + "TipoCambio": "1", + "Moneda": "MXN", + "TipoDeComprobante": "I", + "LugarExpedicion": "06850", + "SubTotal": "1000.00", + "Total": "1160.00", + "FormaPago": "03", + "MetodoPago": "PUE", + }, + "emisor": { + "Rfc": "EKU9003173C9", + "RegimenFiscal": "601", + }, + "receptor": { + "Rfc": "BASM740115RW0", + "UsoCFDI": "G01" + }, + "conceptos": [ + { + "ClaveProdServ": "60121001", + "Cantidad": "1.0", + "ClaveUnidad": "KGM", + "Descripcion": "Asesoría en desarrollo", + "ValorUnitario": "1000.00", + "Importe": "1000.00", + "impuestos": { + "traslados": [ + { + "Base": "1000.00", + "Impuesto": "002", + "TipoFactor": "Tasa", + "TasaOCuota": "0.160000", + "Importe": "160.00" + } + ] + } + } + ], + "impuestos": { + "TotalImpuestosTrasladados": "160.00", + "traslados": [ + { + "Impuesto": "002", + "TipoFactor": "Tasa", + "TasaOCuota": "0.160000", + "Importe": "160.00" + } + ] + } +} + + +def ignore_warnings(test_func): + def do_test(self, *args, **kwargs): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + test_func(self, *args, **kwargs) + return do_test class TestClients(unittest.TestCase): def setUp(self): + print(f'In method: {self._testMethodName}') self.url = URL_API.format('clients/') def test_unauthorized_without_token(self): @@ -23,6 +85,7 @@ class TestClients(unittest.TestCase): result = httpx.post(self.url, headers={'Token': '123'}) self.assertEqual(expected, result.status_code) + @ignore_warnings def test_01_add_client(self): expected = 201 headers = {'Token': '12345'} @@ -51,6 +114,37 @@ class TestClients(unittest.TestCase): result = httpx.delete(self.url, headers=headers, params=params) self.assertEqual(expected, result.status_code) + def test_04_delete_client_not_exists(self): + expected = 202 + headers = {'Token': '12345'} + params = {'rfc': 'EKU900317321'} + result = httpx.delete(self.url, headers=headers, params=params) + self.assertEqual(expected, result.status_code) + + +class TestCfdi(unittest.TestCase): + + def setUp(self): + print(f'In method: {self._testMethodName}') + self.url = URL_API.format('cfdi/') + + # ~ def test_stamp_cfdi_emisor_not_exists(self): + # ~ expected = 202 + # ~ msg = 'Emisor no existe' + # ~ headers = {'Token': '12345'} + # ~ cfdi = CFDI_MINIMO.copy() + # ~ cfdi['emisor']['Rfc'] = 'No_exists' + # ~ result = httpx.post(self.url, headers=headers, json=cfdi) + # ~ self.assertEqual(result.text, msg) + # ~ self.assertEqual(expected, result.status_code) + + def test_stamp_cfdi(self): + expected = 201 + headers = {'Token': '12345'} + result = httpx.post(self.url, headers=headers, json=CFDI_MINIMO) + print(result.text) + self.assertEqual(expected, result.status_code) + if __name__ == '__main__': unittest.main()