crowdata / app /utils /afip_wsaa.py
YOSOYYONOSOYOTRO's picture
Upload folder using huggingface_hub (part 2)
4223796 verified
Raw
History Blame Contribute Delete
6.18 kB
import os
import json
import base64
import logging
from datetime import datetime, timedelta, timezone
import requests
from cryptography import x509
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.serialization import pkcs7
logger = logging.getLogger(__name__)
# Load .env BEFORE reading os.environ so pydantic paths are available
try:
from dotenv import load_dotenv
_backend_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
load_dotenv(os.path.join(_backend_dir, ".env"))
except Exception:
pass
# Constants for AFIP WSAA — use environment variables, not hardcoded paths
_base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
CERT_PATH = os.environ.get('AFIP_CERT_PATH', os.path.join(_base_dir, 'ws_sr_padron_a5_15dc92ced814cc26.crt'))
KEY_PATH = os.environ.get('AFIP_KEY_PATH', os.path.join(_base_dir, 'infocrow.key'))
CACHE_FILE = os.environ.get('AFIP_CACHE_FILE', os.path.join(_base_dir, 'wsaa_cache.json'))
WSAA_PROD_URL = 'https://wsaa.afip.gov.ar/ws/services/LoginCms'
WSAA_HOMO_URL = 'https://wsaahomo.afip.gov.ar/ws/services/LoginCms'
def _generate_cms(tra_xml: str) -> str:
"""Generates the CMS (PKCS#7) signed data using pure cryptography (no rust_pkcs7 patch)."""
with open(CERT_PATH, 'rb') as f:
cert = x509.load_pem_x509_certificate(f.read())
with open(KEY_PATH, 'rb') as f:
key = serialization.load_pem_private_key(f.read(), password=None)
# Use PKCS7SignatureBuilder with SHA1 digest (required by AFIP)
builder = pkcs7.PKCS7SignatureBuilder()
builder = builder.add_signer(cert, key, hashes.SHA1())
builder = builder.add_data(tra_xml.encode('utf-8'))
# Generate detached signature in DER format
cms_der = builder.sign(
encoding=serialization.Encoding.DER,
options=[pkcs7.PKCS7Options.Binary]
)
return base64.b64encode(cms_der).decode('utf-8')
def _request_new_token(service: str, production: bool = True) -> tuple[str, str, str]:
"""Requests a new token from WSAA and returns (token, sign, expiration_iso)."""
now = datetime.now(timezone.utc)
gen_time = (now - timedelta(minutes=5)).strftime('%Y-%m-%dT%H:%M:%S-00:00')
exp_time = (now + timedelta(hours=12)).strftime('%Y-%m-%dT%H:%M:%S-00:00')
unique_id = str(int(now.timestamp()))
tra_xml = (
'<?xml version="1.0" encoding="UTF-8"?>'
'<loginTicketRequest version="1.0">'
'<header>'
f'<uniqueId>{unique_id}</uniqueId>'
f'<generationTime>{gen_time}</generationTime>'
f'<expirationTime>{exp_time}</expirationTime>'
'</header>'
f'<service>{service}</service>'
'</loginTicketRequest>'
)
cms_b64 = _generate_cms(tra_xml)
url = WSAA_PROD_URL if production else WSAA_HOMO_URL
soap_body = (
'<?xml version="1.0" encoding="UTF-8"?>'
'<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/">'
'<SOAP-ENV:Body>'
'<loginCms xmlns="http://wsaa.view.sua.dvadac.desig.afip.gov">'
f'<in0>{cms_b64}</in0>'
'</loginCms>'
'</SOAP-ENV:Body>'
'</SOAP-ENV:Envelope>'
)
headers = {'Content-Type': 'text/xml; charset=utf-8', 'SOAPAction': ''}
logger.info(f"Solicitando nuevo ticket WSAA para {service} en {'PROD' if production else 'HOMO'}")
resp = requests.post(url, data=soap_body.encode('utf-8'), headers=headers, timeout=30)
if resp.status_code != 200:
logger.error(f"Error WSAA: {resp.text}")
raise ValueError(f"AFIP WSAA Error (Status {resp.status_code}): {resp.text[:500]}")
body_text = resp.text.replace('&lt;', '<').replace('&gt;', '>')
token_start = body_text.find('<token>') + 7
token_end = body_text.find('</token>')
sign_start = body_text.find('<sign>') + 6
sign_end = body_text.find('</sign>')
if token_start <= 6 or token_end <= 0:
raise ValueError(f"No se encontró el token en la respuesta de AFIP. Respuesta: {body_text}")
token = body_text[token_start:token_end]
sign = body_text[sign_start:sign_end]
return token, sign, exp_time
def get_afip_credentials(service: str = "ws_sr_padron_a13", production: bool = True) -> tuple[str, str]:
"""
Returns a valid (Token, Sign) pair for the given AFIP service.
Uses a local JSON cache to avoid requesting a new ticket unnecessarily.
"""
cache = {}
if os.path.exists(CACHE_FILE):
try:
with open(CACHE_FILE, 'r') as f:
cache = json.load(f)
except Exception as e:
logger.warning(f"Error reading WSAA cache: {e}")
# Check if we have a valid cached token for this specific service
service_key = f"{service}_{'prod' if production else 'homo'}"
if service_key in cache:
cached_data = cache[service_key]
exp_time_str = cached_data.get("expirationTime")
if exp_time_str:
try:
# Format: 2026-05-26T12:36:00-03:00 (WSAA actually returns -00:00 but we saved our own requested exp_time)
exp_dt = datetime.strptime(exp_time_str, '%Y-%m-%dT%H:%M:%S-00:00')
exp_dt = exp_dt.replace(tzinfo=timezone.utc)
# If it's valid for at least 15 more minutes, use it
if datetime.now(timezone.utc) + timedelta(minutes=15) < exp_dt:
logger.debug(f"Usando ticket cacheado para {service}")
return cached_data["token"], cached_data["sign"]
except Exception as e:
logger.warning(f"Error parsing cached expiration time: {e}")
# Need new token
token, sign, exp_time = _request_new_token(service, production)
# Save to cache
cache[service_key] = {
"token": token,
"sign": sign,
"expirationTime": exp_time
}
try:
with open(CACHE_FILE, 'w') as f:
json.dump(cache, f)
except Exception as e:
logger.warning(f"Error writing WSAA cache: {e}")
return token, sign