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 = ( '' '' '
' f'{unique_id}' f'{gen_time}' f'{exp_time}' '
' f'{service}' '
' ) cms_b64 = _generate_cms(tra_xml) url = WSAA_PROD_URL if production else WSAA_HOMO_URL soap_body = ( '' '' '' '' f'{cms_b64}' '' '' '' ) 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('') + 7 token_end = body_text.find('') sign_start = body_text.find('') + 6 sign_end = body_text.find('') 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