Files
CRM_AGENTES_CARGA/backend/api/v1/modules/fin/stamping/service.py
Jair Cedillo 1f8fdf2866 feat(fin): el IVA se calcula por partida, no con un % global
El impuesto vivía en dos planos que podían divergir: el dinero salía de invoices.tax_rate
aplicado al subtotal completo, y el CFDI sumaba los impuestos de cada partida. Una partida
que no causa IVA se lo cobraba igual, y con una retención capturada la factura pedía 1160
mientras el comprobante declaraba 1060 — cobranza persiguiendo un adeudo inexistente.

Ahora fin.invoice_item_taxes es la fuente del impuesto y _recompute la lee:
total = subtotal + trasladado - retenido, la misma composición del comprobante. Se agrega
withheld_amount, porque sin guardarlo el total no cuadraba con subtotal + tax_amount y nada
en la fila lo explicaba.

NINGUNA factura existente cambia de total. El cálculo se versiona con taxes_per_item: las
nuevas nacen en true, las 9 que ya existían quedaron en false con la fórmula que las emitió.
Backfillear habría exigido poner tax_object_id='02' en partidas que nadie clasificó —
inventar una afirmación fiscal — y _recompute corre desde create_payment, así que un pago
meses después le habría bajado el total, dejado saldo negativo, marcado 'pagada' y pisado su
paid_at. El rollback es un UPDATE.

Conceptos que no causan IVA: fin.concepts gana impuesto, tasa y tipo de factor por defecto,
que la partida hereda como ya heredaba las claves fiscales. Exento (ObjetoImp 02 +
TipoFactor Exento) y tasa 0% son distintos y ahora los dos son expresables; el 0% era
incapturable, el rate==0 borraba el traslado y el timbrado fallaba pidiendo el desglose.

Redondeo: manda el comprobante. subtotal = Σ round(qty × precio) por renglón, no round(Σ),
y tax_amount es la suma de los importes ya materializados, todo ROUND_HALF_UP con el mismo
`cents` que usa el builder. El PAC valida que SubTotal sea la suma de los Importe.

Trampas que el cambio cerró:
- _build_data construía TaxLine sin factor: un exento se habría timbrado como gravado al 0%,
  un CFDI incorrecto que el PAC acepta.
- CfdiData.transferred no excluía Exento mientras _add_totals sí: una fila exenta con importe
  dejaba el XML inconsistente consigo mismo.
- El guard de captura manual era heurístico (retención o impuesto != IVA), así que un IVA al
  8% capturado volvía al 16% por cambiarle la cantidad a la partida. Ahora is_manual es un
  hecho registrado.
- delete_item dejaba los impuestos vivos: cobro fantasma de una partida que ya no existe.
- set_item_tax y delete_item_tax no recalculaban la factura.
- El PDF imprimía "IVA (16%)" y no mostraba retenciones. Ahora desglosa por
  (impuesto, factor, tasa) con el mismo criterio del comprobante, y los exentos se listan con
  su base y sin importe: es lo que explica por qué el total no es subtotal × 1.16.

stamp_invoice verifica que invoice.total sea el del comprobante antes de sellar, y falla en
vez de corregir: el timbrado es donde el dinero se vuelve irreversible y recalcular ahí
cambiaría montos sin que nadie lo vea.

Cuota queda fuera con 422 explícito: su importe es cuota × cantidad, no base × tasa.

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

686 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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"
# Tipo con el que los XML del timbrado entran al expediente de EFC. Se reusa el de la factura en
# vez de inventar uno: la lista de tipos está duplicada a mano en este repo y en EFC
# (``TIPOS_DOCUMENTO_CRM``), y una clave que solo exista de este lado se rechaza allá. Los tres
# archivos del CFDI —PDF, XML enviado y XML recibido— se distinguen por su nombre de archivo.
_EFC_TIPO_CFDI = "factura_venta"
# --------------------------------------------------------------------------------------
# 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),
# Sin pasar el factor, un exento se timbraría como gravado al 0%: un CFDI
# incorrecto que el PAC acepta y que queda así ante el SAT.
factor=t.factor or "Tasa",
)
)
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(),
# En MXN queda en None y el comprobante no lleva TipoCambio; con otra moneda es
# obligatorio y su ausencia la reporta la validación del builder junto al resto.
exchange_rate=(
Decimal(str(invoice.exchange_rate)) if invoice.exchange_rate is not None else 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 _verifica_cuadre_con_la_factura(invoice: Invoice, data: builder.CfdiData) -> None:
"""Comprueba que la factura y el comprobante digan el mismo total antes de sellar.
Con el impuesto por partida la igualdad es exacta por construcción: mismo importe de línea,
mismos importes de impuesto y misma composición (subtotal + trasladado retenido). Una
diferencia aquí significa que los totales guardados quedaron desincronizados por un camino que
el service no controla —una fila insertada por fuera, una migración a medias—.
Se **falla y no se corrige**: el timbrado es el punto donde el dinero se vuelve irreversible,
y recalcular en silencio cambiaría montos dentro de la operación de timbrado, que es
exactamente lo que no debe pasar sin que nadie lo vea. Sale como 422 junto al resto de los
faltantes, por el ``except`` que ya envuelve la construcción.
Las facturas anteriores al cálculo por partida no se verifican: su total viene de la fórmula
del porcentaje global y no tiene por qué coincidir con el desglose del comprobante.
"""
if not invoice.taxes_per_item:
return
guardado = Decimal(str(invoice.total or 0)).quantize(Decimal("0.01"))
del_comprobante = data.total.quantize(Decimal("0.01"))
if guardado != del_comprobante:
raise builder.CfdiBuildError(
[
f"los totales de la factura no cuadran con el comprobante: la factura dice "
f"{guardado} y el CFDI {del_comprobante}. Vuelve a guardar una partida para "
f"recalcular antes de timbrar."
]
)
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)
_verifica_cuadre_con_la_factura(invoice, data)
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}"
# Las filas del outbox van en ESTA transacción, junto con el timbre: así no puede quedar un
# CFDI timbrado sin su intención de entrega al expediente, ni al revés.
pendientes = _encolar_xml_al_expediente(db, invoice, stamp)
db.commit()
db.refresh(stamp)
# Después del commit: el worker necesita encontrar las filas ya existentes.
_despachar_xml_al_expediente(pendientes, tenant_id, company_id)
return stamp
def _encolar_xml_al_expediente(db: Session, invoice: Invoice, stamp: InvoiceStamp) -> list[int]:
"""Encola hacia el expediente de EFC el XML transmitido al PAC y el que contestó.
Devuelve los ids de las filas del outbox, para despacharlas después del commit.
Se entrega el par del intento que SÍ obtuvo timbre; los rechazados quedan en el CRM y se
consultan por ``/stamp/attempts/{id}/xml-url``. Un expediente fiscal con los comprobantes que
el PAC rechazó no aporta respaldo, solo ruido.
``delete_local=False`` a diferencia de los documentos que sube el usuario: el XML timbrado es
el comprobante fiscal y el CRM lo sirve por ``/stamp/xml-url``. El corte directo que borra la
copia local aplica a un documento cuya única razón de existir es vivir en el expediente; aquí
dejaría esos endpoints apuntando a un objeto inexistente.
Best-effort de punta a punta: si EFC está apagado o el encolado falla, el timbre ya es válido
ante el SAT y no puede caerse por esto. Por eso NUNCA propaga: ``enqueue_file_best_effort`` ya
se protege con un SAVEPOINT, pero todo lo que rodea a la llamada —resolver el expediente,
armar los nombres— también tiene que ser incapaz de tumbar un CFDI ya timbrado.
"""
try:
return _encolar_xml_al_expediente_inner(db, invoice, stamp)
except Exception: # noqa: BLE001
logger.exception(
"timbrado: falló el encolado de los XML del intento %s hacia EFC; el timbre no se toca",
stamp.id,
)
return []
def _encolar_xml_al_expediente_inner(
db: Session, invoice: Invoice, stamp: InvoiceStamp
) -> list[int]:
"""Cuerpo de ``_encolar_xml_al_expediente``; ver ahí el contrato y el porqué."""
from ...crm.expediente_gateway import service as gateway # noqa: PLC0415
from ...crm.expediente_gateway.doc_types import is_valid_doc_type # noqa: PLC0415
from ...crm.expediente_gateway.models import ( # noqa: PLC0415
FILE_KIND_CFDI_REQUEST,
FILE_KIND_CFDI_RESPONSE,
SOURCE_FIN_INVOICE_STAMPS,
)
# El expediente nace con la oportunidad y se hereda vía ``case_id``. Una factura suelta —
# capturada sin pasar por el ciclo comercial— no tiene a dónde entregar, y eso no es un error.
if not invoice.case_id:
logger.info(
"timbrado: la factura %s no tiene expediente (case_id nulo); no se entregan los XML a EFC",
invoice.id,
)
return []
# El tipo viaja al catálogo GLOBAL de EFC, compartido por todas las organizaciones. Se valida
# contra el set cerrado para no crear ahí un tipo basura que nadie limpia después.
if not is_valid_doc_type(_EFC_TIPO_CFDI):
logger.error(
"timbrado: %r no está en el catálogo de tipos que EFC acepta; no se entregan los XML",
_EFC_TIPO_CFDI,
)
return []
partes = (
(FILE_KIND_CFDI_REQUEST, stamp.request_xml_file_key, "envio", "CFDIREQ"),
(FILE_KIND_CFDI_RESPONSE, stamp.response_xml_file_key, "respuesta", "CFDIRES"),
)
filas: list[int] = []
for kind, s3_key, sufijo, prefijo_ref in partes:
if not s3_key:
# El almacenamiento falló al guardar el intento: no hay objeto que entregar.
logger.warning(
"timbrado: el intento %s no tiene XML de %s guardado; no se entrega a EFC",
stamp.id, sufijo,
)
continue
row = gateway.enqueue_file_best_effort(
db,
kind=kind,
s3_key=s3_key,
file_name=f"CFDI-{stamp.uuid}-{sufijo}.xml",
content_type="application/xml",
efc_tipo=_EFC_TIPO_CFDI,
source_table=SOURCE_FIN_INVOICE_STAMPS,
source_id=stamp.id,
crm_document_ref=f"{prefijo_ref}-{stamp.company_id}-{stamp.id}",
expediente_ref=invoice.case_id,
tenant_id=stamp.tenant_id,
company_id=stamp.company_id,
delete_local=False,
)
if row is not None:
filas.append(row.id)
return filas
def _despachar_xml_al_expediente(outbox_ids: list[int], tenant_id: int, company_id: int) -> None:
"""Despacha las filas ya commiteadas. Lo que no se despache lo recoge el sweep del beat."""
from ...crm.expediente_gateway import service as gateway # noqa: PLC0415
for outbox_id in outbox_ids:
gateway.dispatch_file_delivery(outbox_id, tenant_id, company_id)
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)