Files
CRM_AGENTES_CARGA/backend/api/v1/modules/fin/stamping/service.py
Jair Cedillo 6e208876f7 feat(fin): timbrado de CFDI 4.0 de ingreso con Comercio Digital
Cierra el ciclo de la factura: construcción del comprobante, sellado con el CSD de la
empresa emisora y transmisión al PAC.

- cfdi_builder: XML 4.0 de ingreso en el orden de atributos del XSD, del que depende la
  cadena original y con ella el sello. Todo el dinero con Decimal.
- sealer: cadena original vía el XSLT oficial del SAT y firma con la llave del CSD.
- pac_comercio_digital: cliente de timbrarV5. Conserva el código y el saldo de folios que
  el legado leía en una variable que descartaba (CFDI.cs:19324-19336).
- csd_service y core/crypto: CSD por empresa, con la contraseña cifrada en la base. Antes
  el certificado había que dejarlo a mano en el almacenamiento y su contraseña era una
  variable de entorno global, lo que no funciona con varias empresas emisoras.
- Cada intento —también los rechazados— guarda el XML que se transmitió y el que contestó
  el PAC: sin ese par no hay forma de reconstruir un rechazo cuando termina la petición.

La declaración XML se escribe a mano con comillas dobles. lxml la emite con comillas
simples, que es XML válido, pero Comercio Digital compara la cadena literal version="1.0"
y responde 642 "la versión del XML no es 1.0".

El modo (pruebas o producción) sale de invoices.stamping_mode y no se puede pasar por la
API: es lo único que separa un timbre de prueba de un CFDI con validez fiscal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 09:07:05 -05:00

534 lines
20 KiB
Python

"""Orquestación del timbrado: factura → XML → sello → PAC → persistencia."""
from __future__ import annotations
import logging
from datetime import datetime
from decimal import Decimal
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from core.config import settings
from core.s3_keys import (
STAMP_XML_KINDS,
invoice_stamp_attempt_xml_key,
invoice_stamp_xml_key,
)
from ..catalogs.models import (
CfdiUse,
PaymentForm,
PaymentMethod,
ProductService,
Tax,
TaxObject,
TaxRegime,
UnitOfMeasure,
)
from ..invoices.models import Invoice, InvoiceItem, InvoiceItemTax
from ..issuer.models import IssuerSettings
from . import cfdi_builder as builder
from . import pac_comercio_digital as pac
from . import sealer
from .models import (
PAC_RFC_BY_MODE,
STAMPING_MODES,
STATUS_ERROR,
STATUS_STAMPED,
InvoiceStamp,
)
logger = logging.getLogger(__name__)
CFDI_NS = "http://www.sat.gob.mx/cfd/4"
TFD_NS = "http://www.sat.gob.mx/TimbreFiscalDigital"
# --------------------------------------------------------------------------------------
# Lectura
# --------------------------------------------------------------------------------------
def get_stamp(db: Session, invoice_id: int, tenant_id: int, company_id: int) -> InvoiceStamp | None:
"""Timbre vigente de la factura, si lo hay. Sólo cuenta el exitoso."""
return (
db.query(InvoiceStamp)
.filter(
InvoiceStamp.invoice_id == invoice_id,
InvoiceStamp.tenant_id == tenant_id,
InvoiceStamp.company_id == company_id,
InvoiceStamp.status == STATUS_STAMPED,
InvoiceStamp.deleted_at.is_(None),
)
.order_by(InvoiceStamp.id.desc())
.first()
)
def list_attempts(
db: Session, invoice_id: int, tenant_id: int, company_id: int
) -> list[InvoiceStamp]:
"""Todos los intentos de la factura, del más reciente al más antiguo.
A diferencia de ``get_stamp``, incluye los rechazados: son los que hay que consultar
cuando el PAC devuelve un error y hace falta ver qué se le mandó.
"""
return (
db.query(InvoiceStamp)
.filter(
InvoiceStamp.invoice_id == invoice_id,
InvoiceStamp.tenant_id == tenant_id,
InvoiceStamp.company_id == company_id,
InvoiceStamp.deleted_at.is_(None),
)
.order_by(InvoiceStamp.id.desc())
.all()
)
def get_attempt_xml_url(
db: Session, invoice_id: int, attempt_id: int, kind: str, tenant_id: int, company_id: int
) -> str:
"""URL firmada del XML enviado o recibido en un intento concreto."""
from core.storage_s3 import presigned_get_url # noqa: PLC0415
if kind not in STAMP_XML_KINDS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Tipo de XML inválido: {kind!r}. Sólo se admiten {list(STAMP_XML_KINDS)}.",
)
attempt = (
db.query(InvoiceStamp)
.filter(
InvoiceStamp.id == attempt_id,
# invoice_id va en el filtro, no sólo en la ruta: sin él, el id de un intento de
# otra factura de la misma empresa devolvería su XML.
InvoiceStamp.invoice_id == invoice_id,
InvoiceStamp.tenant_id == tenant_id,
InvoiceStamp.company_id == company_id,
InvoiceStamp.deleted_at.is_(None),
)
.first()
)
if not attempt:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Intento no encontrado")
key = getattr(attempt, f"{kind}_xml_file_key")
if not key:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"El intento no tiene guardado el XML de {kind}",
)
return presigned_get_url(key)
def _get_invoice(db: Session, invoice_id: int, tenant_id: int, company_id: int) -> Invoice:
obj = (
db.query(Invoice)
.filter(
Invoice.id == invoice_id,
Invoice.tenant_id == tenant_id,
Invoice.company_id == company_id,
Invoice.deleted_at.is_(None),
)
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Factura no encontrada")
return obj
def _code(db: Session, model, pk: int | None) -> str:
"""Clave del SAT de un catálogo, o cadena vacía si no está capturado.
Devolver "" en vez de lanzar es deliberado: la validación del builder acumula TODOS los
faltantes y los reporta juntos, en vez de obligar a descubrirlos de uno en uno.
"""
if not pk:
return ""
row = db.query(model).filter(model.id == pk).first()
return row.code if row else ""
# --------------------------------------------------------------------------------------
# Armado de los datos fiscales
# --------------------------------------------------------------------------------------
def _build_data(db: Session, invoice: Invoice, tenant_id: int, company_id: int) -> builder.CfdiData:
"""Reúne emisor, receptor y partidas resolviendo las claves contra los catálogos."""
from ...crm.accounts.models import Account
from ...crm.addresses.models import Address
issuer = (
db.query(IssuerSettings)
.filter(
IssuerSettings.tenant_id == tenant_id,
IssuerSettings.company_id == company_id,
IssuerSettings.deleted_at.is_(None),
)
.first()
)
if not issuer:
raise builder.CfdiBuildError(
["no hay datos fiscales del emisor configurados para la empresa (fin.issuer_settings)"]
)
account = None
if invoice.account_id:
account = db.query(Account).filter(Account.id == invoice.account_id).first()
if not account:
raise builder.CfdiBuildError(["la factura no tiene cliente asignado"])
# CP fiscal del receptor: vive en la dirección de tipo 'fiscal' de la cuenta.
receiver_zip = ""
direccion = (
db.query(Address)
.filter(
Address.account_id == account.id,
Address.address_type == "fiscal",
Address.deleted_at.is_(None),
)
.first()
)
if direccion and direccion.postal_code:
receiver_zip = (direccion.postal_code or "").strip()[:5]
items = (
db.query(InvoiceItem)
.filter(InvoiceItem.invoice_id == invoice.id, InvoiceItem.deleted_at.is_(None))
.order_by(InvoiceItem.id)
.all()
)
concepts: list[builder.ConceptLine] = []
for it in items:
taxes: list[builder.TaxLine] = []
for t in (
db.query(InvoiceItemTax)
.filter(InvoiceItemTax.invoice_item_id == it.id, InvoiceItemTax.deleted_at.is_(None))
.order_by(InvoiceItemTax.id)
.all()
):
taxes.append(
builder.TaxLine(
code=_code(db, Tax, t.tax_id),
rate=Decimal(str(t.rate or 0)),
amount=Decimal(str(t.amount or 0)),
is_withholding=bool(t.is_withholding),
)
)
concepts.append(
builder.ConceptLine(
product_service_code=_code(db, ProductService, it.product_service_id),
unit_code=_code(db, UnitOfMeasure, it.unit_of_measure_id),
description=(it.description or it.concept or "").strip(),
quantity=Decimal(str(it.quantity or 0)),
unit_price=Decimal(str(it.unit_amount or 0)),
tax_object=_code(db, TaxObject, it.tax_object_id),
taxes=taxes,
)
)
# Fecha del comprobante: la de emisión si existe, y si no, ahora. Sin desplazamiento
# horario, que en CFDI 4.0 no se pone (ver cfdi_builder).
if invoice.issue_date:
fecha = datetime.combine(invoice.issue_date, datetime.now().time())
else:
fecha = datetime.now()
return builder.CfdiData(
folio=invoice.reference or str(invoice.id),
serie=None,
date=fecha.strftime("%Y-%m-%dT%H:%M:%S"),
payment_form=_code(db, PaymentForm, invoice.payment_form_id),
payment_method=_code(db, PaymentMethod, invoice.payment_method_id),
currency=(invoice.currency or "MXN").upper(),
exchange_rate=None,
expedition_zip=(invoice.expedition_zip_code or issuer.zip_code or "").strip()[:5],
payment_conditions=None,
issuer_rfc=(issuer.rfc or "").strip().upper(),
issuer_name=(issuer.legal_name or "").strip(),
issuer_tax_regime=_code(db, TaxRegime, issuer.tax_regime_id),
receiver_rfc=(account.rfc or "").strip().upper(),
receiver_name=(account.name or "").strip(),
receiver_zip=receiver_zip,
receiver_tax_regime=_code(db, TaxRegime, account.tax_regime_id),
receiver_cfdi_use=_code(db, CfdiUse, account.cfdi_use_id),
concepts=concepts,
)
def _load_csd(db: Session, tenant_id: int, company_id: int) -> tuple[bytes, bytes, str]:
"""Bytes del ``.cer``, del ``.key`` y la contraseña descifrada del CSD de la empresa.
Las rutas salen de ``fin.issuer_settings``, no de una convención fija: cada empresa carga
su propio certificado desde la configuración fiscal.
Import diferido de ``storage_s3``: importar arriba abre conexión y rompe los tests, que
corren sin MinIO. Es el mismo patrón que usa ``invoices.service``.
"""
from core.crypto import SecretDecryptionError, SecretsNotConfigured, decrypt_secret # noqa: PLC0415
issuer = (
db.query(IssuerSettings)
.filter(
IssuerSettings.tenant_id == tenant_id,
IssuerSettings.company_id == company_id,
IssuerSettings.deleted_at.is_(None),
)
.first()
)
if not issuer or not issuer.csd_cer_file_key or not issuer.csd_key_file_key:
raise builder.CfdiBuildError(
[
"la empresa no tiene CSD cargado: súbelo en Configuración de Facturación "
"(certificado .cer, llave .key y su contraseña)"
]
)
# Contraseña por empresa; la global de entorno queda sólo como respaldo del esquema previo.
if issuer.csd_password_enc:
try:
password = decrypt_secret(issuer.csd_password_enc)
except (SecretDecryptionError, SecretsNotConfigured) as exc:
raise builder.CfdiBuildError([str(exc)]) from exc
elif settings.CSD_PASSWORD:
password = settings.CSD_PASSWORD
else:
raise builder.CfdiBuildError(
["la empresa no tiene guardada la contraseña de su CSD: vuelve a cargarlo"]
)
from core.storage_s3 import get_object_bytes # noqa: PLC0415
try:
cer = get_object_bytes(issuer.csd_cer_file_key)
key = get_object_bytes(issuer.csd_key_file_key)
except Exception as exc: # noqa: BLE001 — cualquier fallo aquí es "no hay CSD utilizable"
raise builder.CfdiBuildError(
[f"no pude leer los archivos del CSD desde el almacenamiento: {exc}"]
) from exc
return cer, key, password
# --------------------------------------------------------------------------------------
# Timbrado
# --------------------------------------------------------------------------------------
def stamp_invoice(
db: Session,
invoice_id: int,
tenant_id: int,
company_id: int,
user_id: str | None = None,
) -> InvoiceStamp:
"""Genera, sella y transmite el CFDI de la factura.
Es idempotente: si la factura ya tiene un timbre exitoso lo devuelve tal cual, **sin**
llamar al PAC. Retimbrar cuesta un folio y genera un comprobante duplicado ante el SAT,
que después hay que cancelar.
"""
invoice = _get_invoice(db, invoice_id, tenant_id, company_id)
existente = get_stamp(db, invoice_id, tenant_id, company_id)
if existente:
return existente
if invoice.status == "cancelada":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="La factura está cancelada"
)
mode = (invoice.stamping_mode or "").strip()
if mode not in STAMPING_MODES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Modo de timbrado inválido en la factura: {mode!r}",
)
if not settings.PAC_USER or not settings.PAC_PASSWORD:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="No están configuradas las credenciales del PAC (PAC_USER / PAC_PASSWORD).",
)
# ----- Datos, CSD, XML y sello -----
try:
data = _build_data(db, invoice, tenant_id, company_id)
cer_bytes, key_bytes, csd_password = _load_csd(db, tenant_id, company_id)
cert_number, cert_b64 = sealer.read_certificate(cer_bytes)
xml = builder.build_xml(data, cert_number=cert_number, cert_b64=cert_b64)
cadena = sealer.build_original_string(xml)
private_key = sealer.load_private_key(key_bytes, csd_password)
sello = sealer.sign(cadena, private_key)
xml_sellado = builder.apply_seal(xml, sello)
except builder.CfdiBuildError as exc:
# 422 con la lista completa: son datos que falta capturar, no un fallo del sistema.
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"message": "Faltan datos fiscales para timbrar", "missing": exc.missing},
) from exc
except sealer.SealingError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)
) from exc
# ----- Transmisión -----
resultado = pac.stamp(
xml_sellado,
mode=mode,
user=settings.PAC_USER,
password=settings.PAC_PASSWORD,
host_test=settings.PAC_HOST_TEST,
host_prod=settings.PAC_HOST_PROD,
email=settings.PAC_NOTIFICATION_EMAIL,
timeout=settings.PAC_TIMEOUT_SECONDS,
)
stamp = InvoiceStamp(
tenant_id=tenant_id,
company_id=company_id,
invoice_id=invoice.id,
mode=mode,
status=STATUS_ERROR,
pac_code=resultado.code,
pac_balance=resultado.balance,
error_message=resultado.error_message or None,
created_by=user_id,
)
# El id se necesita para nombrar los XML del intento, y sólo existe después del flush.
db.add(stamp)
db.flush()
_store_attempt_xml(stamp, xml_sellado, resultado.xml)
if not resultado.ok:
db.commit()
db.refresh(stamp)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail={
"message": "El PAC rechazó el comprobante",
"pac_code": resultado.code,
"pac_error": resultado.error_message,
"stamp_id": stamp.id,
},
)
# ----- Verificación del timbre recibido -----
try:
tfd = _read_tfd(resultado.xml)
except ValueError as exc:
stamp.error_message = str(exc)
db.commit()
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"El PAC devolvió un XML que no pude interpretar: {exc}",
) from exc
esperado = PAC_RFC_BY_MODE[mode]
if tfd["pac_rfc"] != esperado:
# Red de seguridad final: se pidió un entorno y contestó otro. Nunca se da por bueno.
stamp.error_message = (
f"El timbre viene del PAC {tfd['pac_rfc']!r} y para el modo {mode!r} se esperaba "
f"{esperado!r}: se timbró contra un entorno distinto del solicitado."
)
db.commit()
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=stamp.error_message)
# ----- Persistencia -----
stamp.status = STATUS_STAMPED
stamp.uuid = tfd["uuid"]
stamp.stamped_at = tfd["stamped_at"]
stamp.pac_rfc = tfd["pac_rfc"]
stamp.sat_cert_number = tfd["sat_cert_number"]
stamp.sat_seal = tfd["sat_seal"]
stamp.cfd_seal = tfd["cfd_seal"]
stamp.error_message = None
key = invoice_stamp_xml_key(tenant_id, company_id, invoice.id, tfd["uuid"])
try:
from core.storage_s3 import put_object_bytes # noqa: PLC0415
put_object_bytes(key, resultado.xml.encode("utf-8"), content_type="application/xml")
stamp.xml_file_key = key
except Exception as exc: # noqa: BLE001
# El comprobante YA está timbrado ante el SAT: perder el archivo no puede invalidar el
# timbre ni provocar un retimbrado. Se guarda el registro sin la clave y se anota.
stamp.error_message = f"Timbrado correcto, pero no se pudo guardar el XML: {exc}"
db.commit()
db.refresh(stamp)
return stamp
def _store_attempt_xml(stamp: InvoiceStamp, sent: bytes, received: str) -> None:
"""Guarda el par enviado/recibido del intento y anota sus claves en ``stamp``.
Nunca propaga una excepción. Este rastro es para diagnóstico: si el almacenamiento está
caído no puede tumbar un timbrado que el SAT ya dio por bueno, ni convertir el rechazo del
PAC —que es lo que hay que contarle a quien factura— en un error de almacenamiento. Lo que
no se pudo subir queda con la clave en NULL y en el log.
"""
from core.storage_s3 import put_object_bytes # noqa: PLC0415
# El de respuesta puede venir vacío: un fallo de red corta antes de que el PAC conteste.
partes = [("request", sent), ("response", received.encode("utf-8") if received else b"")]
for kind, cuerpo in partes:
if not cuerpo:
continue
key = invoice_stamp_attempt_xml_key(
stamp.tenant_id, stamp.company_id, stamp.invoice_id, stamp.id, kind
)
try:
put_object_bytes(key, cuerpo, content_type="application/xml")
except Exception: # noqa: BLE001
logger.exception("No se pudo guardar el XML de %s del intento %s", kind, stamp.id)
continue
setattr(stamp, f"{kind}_xml_file_key", key)
def _read_tfd(xml_text: str) -> dict:
"""Extrae el Timbre Fiscal Digital del XML que devolvió el PAC."""
from lxml import etree # noqa: PLC0415
try:
root = etree.fromstring(xml_text.encode("utf-8") if isinstance(xml_text, str) else xml_text)
except etree.XMLSyntaxError as exc:
raise ValueError(f"XML mal formado: {exc}") from exc
nodo = root.find(f".//{{{TFD_NS}}}TimbreFiscalDigital")
if nodo is None:
raise ValueError("no trae el nodo TimbreFiscalDigital")
uuid = (nodo.get("UUID") or "").strip()
if not uuid:
raise ValueError("el TimbreFiscalDigital no trae UUID")
crudo = (nodo.get("FechaTimbrado") or "").strip()
try:
stamped_at = datetime.fromisoformat(crudo) if crudo else None
except ValueError:
# Fecha ilegible: no invalida el timbre, que ya existe ante el SAT. Se deja en NULL.
stamped_at = None
return {
"uuid": uuid,
"stamped_at": stamped_at,
"pac_rfc": (nodo.get("RfcProvCertif") or "").strip(),
"sat_cert_number": (nodo.get("NoCertificadoSAT") or "").strip() or None,
"sat_seal": (nodo.get("SelloSAT") or "").strip() or None,
"cfd_seal": (nodo.get("SelloCFD") or "").strip() or None,
}
def get_stamp_xml_url(db: Session, invoice_id: int, tenant_id: int, company_id: int) -> str:
"""URL firmada del XML timbrado. Las presignadas caducan, así que se genera al vuelo."""
from core.storage_s3 import presigned_get_url # noqa: PLC0415
stamp = get_stamp(db, invoice_id, tenant_id, company_id)
if not stamp or not stamp.xml_file_key:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="La factura no tiene XML timbrado almacenado",
)
return presigned_get_url(stamp.xml_file_key)