"""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)