Spaces:
Paused
Paused
| 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('<', '<').replace('>', '>') | |
| 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 | |