Files
CRM_AGENTES_CARGA/backend/api/v1/modules/fin/stamping/models.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

94 lines
4.3 KiB
Python

"""Timbrado de CFDI — ``fin.invoice_stamps``.
Una fila por **intento** de timbrado, incluidos los fallidos: sin ellos no hay forma de
reconstruir por qué una factura no se timbró, y el error del PAC llega en un header HTTP que
se pierde en cuanto termina la petición.
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
_ALIVE = text("deleted_at IS NULL")
# Modos de timbrado. El host del PAC se deriva de aquí y de ningún otro lado.
MODE_TEST = "pruebas"
MODE_PROD = "produccion"
STAMPING_MODES = (MODE_TEST, MODE_PROD)
# RFC del proveedor de certificación según el entorno (CFDI.cs:16665 del sistema legado).
# Sirve para verificar que el timbre recibido viene del entorno que se pidió.
PAC_RFC_BY_MODE = {
MODE_TEST: "SPR190613I52",
MODE_PROD: "SCD110105654",
}
# Estados del intento.
STATUS_PENDING = "pendiente"
STATUS_STAMPED = "timbrado"
STATUS_ERROR = "error"
class InvoiceStamp(Base, TenantScopedMixin, TimestampMixin):
"""Intento de timbrado de una factura ante el PAC."""
__tablename__ = "invoice_stamps"
__table_args__ = (
# Un UUID no puede repetirse: el SAT lo emite una sola vez. El índice es parcial
# sobre uuid IS NOT NULL porque los intentos fallidos no traen UUID y serían todos
# "iguales" entre sí bajo un único convencional.
Index(
"uq_fin_invoice_stamps_uuid",
"uuid",
unique=True,
postgresql_where=text("uuid IS NOT NULL AND deleted_at IS NULL"),
sqlite_where=text("uuid IS NOT NULL AND deleted_at IS NULL"),
),
{"schema": "fin"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
invoice_id: Mapped[int] = mapped_column(
Integer, ForeignKey("fin.invoices.id"), nullable=False, index=True
)
# Copiado de invoices.stamping_mode al transmitir y congelado aquí: es el registro de
# contra qué entorno se timbró de verdad, aunque la factura cambie después.
mode: Mapped[str] = mapped_column(String(12), nullable=False)
status: Mapped[str] = mapped_column(
String(12), nullable=False, server_default=text("'pendiente'"), index=True
)
# ----- Datos del Timbre Fiscal Digital (sólo si el PAC timbró) -----
uuid: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
stamped_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # FechaTimbrado
pac_rfc: Mapped[str | None] = mapped_column(String(13), nullable=True) # RfcProvCertif
sat_cert_number: Mapped[str | None] = mapped_column(
String(20), nullable=True
) # NoCertificadoSAT
sat_seal: Mapped[str | None] = mapped_column(Text, nullable=True) # SelloSAT
cfd_seal: Mapped[str | None] = mapped_column(Text, nullable=True) # SelloCFD
# ----- Respuesta del PAC -----
# El legado leía estos dos headers en una variable local que descartaba, así que su código
# de respuesta y su saldo de folios se perdían siempre (CFDI.cs:19324-19336). Aquí se
# persisten: sin ellos no se sabe cuántos folios quedan ni qué contestó el PAC.
pac_code: Mapped[int | None] = mapped_column(Integer, nullable=True) # header codigo
pac_balance: Mapped[int | None] = mapped_column(Integer, nullable=True) # header saldo
error_message: Mapped[str | None] = mapped_column(Text, nullable=True) # header errmsg
# XML timbrado en MinIO. Sólo lo tienen los intentos exitosos: es el comprobante que se
# descarga, y su clave lleva el UUID.
xml_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
# ----- Rastro del intento en MinIO -----
# El par enviado/recibido de CADA intento, incluidos los rechazados. Es lo único que
# permite reconstruir por qué el PAC rechazó un comprobante: el XML sellado se construye
# en memoria y se pierde al terminar la petición, y el cuerpo de la respuesta también.
request_xml_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
response_xml_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)