Skip to content

Commit 6cc232e

Browse files
committed
Added Portal Sat Factura Electronica
1 parent 6294a48 commit 6cc232e

File tree

5 files changed

+238
-59
lines changed

5 files changed

+238
-59
lines changed
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
Portal SAT - Factura Electrónica
2+
================================================
3+
4+
Validación
5+
______________________
6+
7+
.. code-block:: python
8+
9+
from satcfdi import Signer
10+
from satcfdi.portal import SATFacturaElectronica
11+
12+
# Load Fiel
13+
signer = Signer.load(
14+
certificate=open('csd/xiqb891116qe4.cer', 'rb').read(),
15+
key=open('csd/xiqb891116qe4.key', 'rb').read(),
16+
password=open('csd/xiqb891116qe4.txt', 'r').read()
17+
)
18+
19+
sat_session = SATFacturaElectronica(signer)
20+
sat_session.login()
21+
22+
23+
# Validación RFC
24+
res = sat_session.rfc_valid(
25+
rfc='XIQB891116QE4'
26+
)
27+
print(res)
28+
29+
# Validación Razón Social
30+
res = sat_session.legal_name_valid(
31+
rfc='XIQB891116QE4',
32+
legal_name='KIJ, S.A DE C.V.'
33+
)
34+
print(res)
35+
36+
# LCO Detalles
37+
res = sat_session.lco_details(rfc="XIQB891116QE4")
38+
print(res)
39+
40+

readme.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,15 @@ ____________________
7474
* Listado 69B
7575
* Exportar Comprobantes a Excel
7676
* Descarga de Constancia de Situación Fiscal
77+
* Portal SAT - Factura Electrónica
78+
79+
* Validación de RFC, Razón Social
80+
* LCO - Lista de Contribuyentes Obligados
7781
* DIOT - Declaración Informativa de Operaciones con Terceros
7882
* Certifica - Solicitud de Certificados, Renovación de Fiel
7983
* PLD - Prevención de Lavado de Dinero
8084

85+
8186
Installation
8287
____________________
8388

satcfdi/portal/__init__.py

Lines changed: 173 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,80 @@
1+
import json
12
import pickle
3+
from time import time
4+
25
import requests
36
import urllib3
47

5-
from .utils import get_post_form, generate_token
6-
from .. import Signer, __version__
8+
from .utils import get_post_form, generate_token, request_ref_headers, request_verification_token, random_ajax_id
9+
from .. import Signer, ResponseError
710

811
CONSTANCIA_URL = 'https://rfcampc.siat.sat.gob.mx/PTSC/IdcSiat/IdcGeneraConstancia.jsf'
9-
USER_AGENT = __version__.__user_agent__
12+
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36'
1013

1114
DEFAULT_HEADERS = {
12-
'accept': 'text/html,application/xhtml+xml,application/xml',
13-
'pragma': 'no-cache',
14-
'cache-control': 'no-cache',
15-
'user-agent': USER_AGENT
15+
'Accept': 'text/html,application/xhtml+xml,application/xml',
16+
'Pragma': 'no-cache',
17+
'Cache-Control': 'no-cache',
18+
'User-Agent': USER_AGENT
1619
}
1720

1821

19-
def debug_response(res):
20-
print('-- RESPONSE DEBUG --')
21-
print(res.status_code)
22-
for k, v in res.request.headers.items():
23-
print(k, v)
24-
print(res.request.body)
25-
print('results')
26-
print(res.text)
27-
print('-- RESPONSE DEBUG END --')
28-
29-
30-
class SATSession:
22+
class PortalManager(requests.Session):
3123
def __init__(self, signer: Signer):
24+
super().__init__()
3225
urllib3.util.ssl_.DEFAULT_CIPHERS += ':HIGH:!DH'
3326
self.signer = signer
34-
self.session = requests.session()
3527

36-
def login(self):
37-
LOGIN_URL = 'https://loginda.siat.sat.gob.mx/nidp/app/login?id=fiel'
38-
LOGIN_URL_ORIGIN = 'https://loginda.siat.sat.gob.mx'
28+
def save_session(self, target):
29+
pickle.dump(self.cookies, target)
3930

40-
res = self.session.get(
41-
url=LOGIN_URL,
42-
headers=DEFAULT_HEADERS
43-
)
44-
assert res.status_code == 200
31+
def load_session(self, source):
32+
self.cookies.update(pickle.load(source))
4533

46-
action, data = get_post_form(res)
47-
res = self.session.post(
34+
def form_request(self, action, referer_url, data):
35+
res = self.post(
4836
url=action,
49-
headers=DEFAULT_HEADERS | {
50-
'origin': LOGIN_URL_ORIGIN,
51-
'referer': LOGIN_URL,
52-
},
37+
headers=DEFAULT_HEADERS | request_ref_headers(referer_url),
5338
data=data
5439
)
5540
assert res.status_code == 200
41+
return res
5642

57-
action, data = get_post_form(res, id='certform')
58-
res = self.session.post(
59-
url=action,
60-
headers=DEFAULT_HEADERS | {
61-
'origin': LOGIN_URL_ORIGIN,
62-
'referer': LOGIN_URL,
63-
},
64-
data=data | {
43+
def fiel_login(self, login_response):
44+
action, data = get_post_form(login_response, id='certform')
45+
return self.form_request(
46+
action,
47+
login_response.request.url,
48+
data | {
6549
'token': generate_token(self.signer, code=data['guid']),
6650
'fert': self.signer.certificate.get_notAfter()[2:].decode(),
6751
}
6852
)
53+
54+
55+
class SATPortal(PortalManager):
56+
def login(self):
57+
LOGIN_URL = 'https://loginda.siat.sat.gob.mx/nidp/app/login?id=fiel'
58+
59+
res = self.get(
60+
url=LOGIN_URL,
61+
headers=DEFAULT_HEADERS
62+
)
6963
assert res.status_code == 200
7064

71-
return res
65+
action, data = get_post_form(res)
66+
return self.fiel_login(
67+
login_response=self.form_request(action, res.request.url, data)
68+
)
7269

7370
def home_page(self):
74-
return self.session.get(
71+
return self.get(
7572
url='https://loginda.siat.sat.gob.mx/nidp/app?sid=0',
7673
headers=DEFAULT_HEADERS
7774
)
7875

7976
def logout(self):
80-
return self.session.get(
77+
return self.get(
8178
url='https://loginda.siat.sat.gob.mx/nidp/app/logout',
8279
headers=DEFAULT_HEADERS | {
8380
'referer': 'https://loginda.siat.sat.gob.mx/nidp/app?sid=0'
@@ -86,27 +83,146 @@ def logout(self):
8683
)
8784

8885
def declaraciones_provisionales_login(self):
89-
res = self.session.get(
90-
url='https://ptscdecprov.clouda.sat.gob.mx/',
86+
res = self.get(
87+
url='https://ptscdecprov.clouda.sat.gob.mx',
9188
headers=DEFAULT_HEADERS,
9289
allow_redirects=True
9390
)
9491
assert res.status_code == 200
9592

9693
action, data = get_post_form(res)
97-
res = self.session.post(
98-
url=action,
94+
res = self.form_request(action, res.request.url, data)
95+
return res
96+
97+
98+
class SATFacturaElectronica(PortalManager):
99+
BASE_URL = 'https://portal.facturaelectronica.sat.gob.mx'
100+
REQUEST_CONTEXT = 'appId=cid-v1:20ff76f4-0bca-495f-b7fd-09ca520e39f7'
101+
102+
def __init__(self, signer: Signer):
103+
super().__init__(signer)
104+
self._ajax_id = random_ajax_id()
105+
self._request_verification_token = None
106+
107+
def login(self):
108+
res = self.get(
109+
url=self.BASE_URL,
110+
headers=DEFAULT_HEADERS
111+
)
112+
assert res.status_code == 200
113+
114+
try:
115+
action, data = get_post_form(res)
116+
except IndexError as ex:
117+
raise ValueError("Login form not found, please try again") from ex
118+
119+
if action.startswith('https://cfdiau.sat.gob.mx/'):
120+
assert 'nidp/wsfed/ep?id=SATUPCFDiCon' in action
121+
122+
res = self.fiel_login(
123+
login_response=self.form_request(
124+
action.replace('nidp/wsfed/ep?id=SATUPCFDiCon', 'nidp/app/login?id=SATx509Custom'),
125+
res.request.url,
126+
data
127+
)
128+
)
129+
130+
action, data = get_post_form(res)
131+
res = self.form_request(action, res.request.url, data)
132+
133+
action, data = get_post_form(res)
134+
135+
res = self.form_request(action, res.request.url, data)
136+
137+
self._request_verification_token = request_verification_token(res)
138+
self._ajax_id = random_ajax_id()
139+
return res
140+
141+
def _reload_verification_token(self):
142+
res = self.get(
143+
url=f'{self.BASE_URL}/Factura/GeneraFactura',
144+
headers=DEFAULT_HEADERS,
145+
allow_redirects=False
146+
)
147+
if res.status_code == 200:
148+
self._request_verification_token = request_verification_token(res)
149+
else:
150+
raise ValueError('Please Login Again')
151+
152+
def reactivate_session(self):
153+
res = self.post(
154+
url=f'{self.BASE_URL}/Home/ReActiveSession',
99155
headers=DEFAULT_HEADERS | {
100-
'content-type': 'application/x-www-form-urlencoded',
101-
'origin': 'https://loginda.siat.sat.gob.mx',
102-
'referer': 'https://loginda.siat.sat.gob.mx/',
156+
'Origin': f'{self.BASE_URL}',
157+
'Request-Context': self.REQUEST_CONTEXT,
158+
'Request-Id': f'|{self._ajax_id}.{random_ajax_id()}'
103159
},
104-
data=data
160+
allow_redirects=False
105161
)
106162
return res
107163

108-
def save_session(self, target):
109-
pickle.dump(self.session.cookies, target)
164+
def _request(self, method, path, data=None, params=None):
165+
if self._request_verification_token is None:
166+
self._reload_verification_token()
110167

111-
def load_session(self, source):
112-
self.session.cookies.update(pickle.load(source))
168+
if method.upper() == 'POST':
169+
headers = {
170+
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
171+
}
172+
else:
173+
headers = {}
174+
175+
res = self.request(
176+
method=method,
177+
url=f'{self.BASE_URL}/{path}',
178+
headers=DEFAULT_HEADERS | headers | {
179+
'Origin': self.BASE_URL,
180+
'Authority': self.BASE_URL,
181+
'Request-Context': self.REQUEST_CONTEXT,
182+
'__RequestVerificationToken': self._request_verification_token,
183+
'Request-Id': f'|{self._ajax_id}.{random_ajax_id()}' # |pR4Px.o0yAS
184+
},
185+
data=data,
186+
params=params,
187+
allow_redirects=False
188+
)
189+
if res.status_code == 200:
190+
return res.json()
191+
else:
192+
raise ResponseError(res)
193+
194+
def legal_name_valid(self, rfc, legal_name):
195+
res = self._request(
196+
method='POST',
197+
path='Clientes/ValidaRazonSocialRFC',
198+
data={
199+
'rfcValidar': rfc.upper(),
200+
'razonSocial': legal_name.upper(),
201+
})
202+
if not res['exitoso']:
203+
raise ResponseError(res)
204+
return res['resultado']
205+
206+
def rfc_valid(self, rfc):
207+
res = self._request(
208+
method='POST',
209+
path='Clientes/ExisteLrfc',
210+
data={
211+
'rfcValidar': rfc.upper()
212+
}
213+
)
214+
if not res['exitoso']:
215+
raise ResponseError(res)
216+
return res['resultado']
217+
218+
def lco_details(self, rfc, apply_border_region=True):
219+
res = self._request(
220+
method='GET',
221+
path='Clientes/ValidaLco',
222+
params={
223+
'rfcValidar': rfc.upper(),
224+
'aplicaRegionFronteriza': apply_border_region,
225+
"_": int(time() * 1000)
226+
}
227+
)
228+
return json.loads(res)

satcfdi/portal/utils.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import base64
2+
import random
3+
import string
24
from urllib.parse import urlparse, urlunparse
35

46
from bs4 import BeautifulSoup
@@ -67,3 +69,19 @@ def get_post_form(res: Response, id=None):
6769
assert form.attrs['method'].upper() == "POST"
6870
return action_url(form.attrs.get('action'), res.url), data
6971

72+
73+
def request_ref_headers(url):
74+
parts = urlparse(url)
75+
return {
76+
'origin': urlunparse((parts.scheme, parts.netloc, '', '', '', '')),
77+
'referer': url
78+
}
79+
80+
81+
def request_verification_token(res: Response):
82+
html = BeautifulSoup(res.text, 'html.parser')
83+
return html.find(name='input', attrs={'name': '__RequestVerificationToken'}).attrs['value']
84+
85+
86+
def random_ajax_id():
87+
return ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(5))

tests/test_portal.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import os
22

33
from satcfdi.portal.utils import generate_token, verify_token, action_url
4-
from satcfdi.portal import SATSession
4+
from satcfdi.portal import SATPortal
55
from tests.utils import get_signer
66

77
current_dir = os.path.dirname(__file__)
@@ -39,7 +39,7 @@ def test_action_url():
3939

4040
def test_start_session():
4141
signer = get_signer('cacx7605101p8')
42-
res = SATSession(signer)
42+
res = SATPortal(signer)
4343

4444
try:
4545
with open(session_file, 'rb') as f:

0 commit comments

Comments
 (0)