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>
94 lines
4.3 KiB
Python
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)
|