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>
534 lines
20 KiB
Python
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)
|