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>
686 lines
27 KiB
Python
686 lines
27 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"
|
||
|
||
# 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)
|