diff --git a/.env.example b/.env.example
index 74481c2..ea446dc 100644
--- a/.env.example
+++ b/.env.example
@@ -112,3 +112,27 @@ SYNC_SECRET_TOKEN=change-this-sync-token-in-production
# Lista de spokes (Solo si es HUB y desea retransmitir a otros - Opcional)
SPOKE_URLS=""
+
+# ==================================
+# PAC COMERCIO DIGITAL (timbrado CFDI)
+# ==================================
+# El modo de timbrado se decide POR FACTURA, en fin.invoices.stamping_mode.
+# Esta variable solo fija con qué valor nacen las facturas que no lo especifican.
+# pruebas -> pruebas.comercio-digital.mx | produccion -> ws.comercio-digital.mx
+# Una factura en "produccion" emite un CFDI con validez fiscal real ante el SAT.
+PAC_DEFAULT_MODE=pruebas
+# Credenciales del web service (headers usrws / pwdws). NO commitear valores reales:
+# van en .env, que está en .gitignore.
+PAC_USER=
+PAC_PASSWORD=
+# Opcional: correo al que el PAC notifica el comprobante (header email).
+PAC_NOTIFICATION_EMAIL=
+# Clave maestra que cifra las contraseñas de los CSD en la base de datos. OBLIGATORIA para
+# poder cargar certificados desde Configuración de Facturación. Generarla con:
+# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
+# Si se cambia, las contraseñas ya guardadas dejan de poder descifrarse y hay que recargar
+# los certificados.
+CSD_ENCRYPTION_KEY=
+# LEGADO: contraseña global del CSD. Sólo se usa como respaldo si una empresa no tiene la
+# suya guardada. Lo correcto es cargar el CSD por empresa desde la interfaz.
+CSD_PASSWORD=
diff --git a/backend/alembic/versions/h3i4j5k6l7m8_fin_invoice_stamping.py b/backend/alembic/versions/h3i4j5k6l7m8_fin_invoice_stamping.py
new file mode 100644
index 0000000..9bc07c2
--- /dev/null
+++ b/backend/alembic/versions/h3i4j5k6l7m8_fin_invoice_stamping.py
@@ -0,0 +1,91 @@
+"""Timbrado de CFDI: ``fin.invoice_stamps`` y ``fin.invoices.stamping_mode``.
+
+Escrita a mano y no con ``--autogenerate``: el autogenerate de este proyecto arrastra
+drift preexistente entre los modelos y la base (llaves foráneas de ``core``, cambios de
+tipo en ``invite_tokens``), y generaba 1,516 operaciones ajenas a este ticket. Aquí van
+sólo los dos cambios del timbrado.
+
+Revision ID: h3i4j5k6l7m8
+Revises: f7a8b9c0d1e2
+Create Date: 2026-08-07 00:00:00.000000
+
+"""
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "h3i4j5k6l7m8"
+down_revision: Union[str, None] = "f7a8b9c0d1e2"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ---------- fin.invoices: modo de timbrado por factura ----------
+ # NOT NULL con server_default: las facturas existentes quedan en 'pruebas', que es el
+ # valor seguro. Marcar como 'produccion' es siempre una decisión explícita.
+ op.add_column(
+ "invoices",
+ sa.Column(
+ "stamping_mode",
+ sa.String(length=12),
+ nullable=False,
+ server_default=sa.text("'pruebas'"),
+ ),
+ schema="fin",
+ )
+
+ # ---------- fin.invoice_stamps ----------
+ op.create_table(
+ "invoice_stamps",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("tenant_id", sa.Integer(), nullable=False),
+ sa.Column("company_id", sa.Integer(), nullable=False),
+ sa.Column("invoice_id", sa.Integer(), nullable=False),
+ sa.Column("mode", sa.String(length=12), nullable=False),
+ sa.Column("status", sa.String(length=12), nullable=False, server_default=sa.text("'pendiente'")),
+ # Timbre Fiscal Digital
+ sa.Column("uuid", sa.String(length=36), nullable=True),
+ sa.Column("stamped_at", sa.DateTime(), nullable=True),
+ sa.Column("pac_rfc", sa.String(length=13), nullable=True),
+ sa.Column("sat_cert_number", sa.String(length=20), nullable=True),
+ sa.Column("sat_seal", sa.Text(), nullable=True),
+ sa.Column("cfd_seal", sa.Text(), nullable=True),
+ # Respuesta del PAC
+ sa.Column("pac_code", sa.Integer(), nullable=True),
+ sa.Column("pac_balance", sa.Integer(), nullable=True),
+ sa.Column("error_message", sa.Text(), nullable=True),
+ sa.Column("xml_file_key", sa.String(length=512), nullable=True),
+ sa.Column("created_by", sa.String(length=64), nullable=True),
+ sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
+ sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
+ sa.Column("deleted_at", sa.DateTime(), nullable=True),
+ sa.ForeignKeyConstraint(["invoice_id"], ["fin.invoices.id"]),
+ sa.PrimaryKeyConstraint("id"),
+ schema="fin",
+ )
+ op.create_index("ix_fin_invoice_stamps_id", "invoice_stamps", ["id"], schema="fin")
+ op.create_index("ix_fin_invoice_stamps_invoice_id", "invoice_stamps", ["invoice_id"], schema="fin")
+ op.create_index("ix_fin_invoice_stamps_status", "invoice_stamps", ["status"], schema="fin")
+ op.create_index("ix_fin_invoice_stamps_uuid", "invoice_stamps", ["uuid"], schema="fin")
+ # Un UUID lo emite el SAT una sola vez. Parcial sobre uuid IS NOT NULL porque los intentos
+ # fallidos no traen UUID y colisionarían entre sí bajo un único convencional.
+ op.create_index(
+ "uq_fin_invoice_stamps_uuid",
+ "invoice_stamps",
+ ["uuid"],
+ unique=True,
+ schema="fin",
+ postgresql_where=sa.text("uuid IS NOT NULL AND deleted_at IS NULL"),
+ )
+
+
+def downgrade() -> None:
+ op.drop_index("uq_fin_invoice_stamps_uuid", table_name="invoice_stamps", schema="fin")
+ op.drop_index("ix_fin_invoice_stamps_uuid", table_name="invoice_stamps", schema="fin")
+ op.drop_index("ix_fin_invoice_stamps_status", table_name="invoice_stamps", schema="fin")
+ op.drop_index("ix_fin_invoice_stamps_invoice_id", table_name="invoice_stamps", schema="fin")
+ op.drop_index("ix_fin_invoice_stamps_id", table_name="invoice_stamps", schema="fin")
+ op.drop_table("invoice_stamps", schema="fin")
+ op.drop_column("invoices", "stamping_mode", schema="fin")
diff --git a/backend/alembic/versions/i4j5k6l7m8n9_fin_issuer_csd.py b/backend/alembic/versions/i4j5k6l7m8n9_fin_issuer_csd.py
new file mode 100644
index 0000000..f76e97f
--- /dev/null
+++ b/backend/alembic/versions/i4j5k6l7m8n9_fin_issuer_csd.py
@@ -0,0 +1,43 @@
+"""CSD por empresa en ``fin.issuer_settings``.
+
+Cierra el hueco de que el certificado de sello digital tuviera que dejarse a mano en el
+almacenamiento y de que su contraseña fuera una variable de entorno global: con varias
+empresas emisoras eso no funciona, porque cada una tiene su propio certificado.
+
+La contraseña se guarda cifrada (``core.crypto``); la clave maestra vive en el entorno.
+
+Escrita a mano, no con ``--autogenerate``: ver la nota de la migración h3i4j5k6l7m8.
+
+Revision ID: i4j5k6l7m8n9
+Revises: h3i4j5k6l7m8
+Create Date: 2026-08-10 00:00:00.000000
+
+"""
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "i4j5k6l7m8n9"
+down_revision: Union[str, None] = "h3i4j5k6l7m8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+_COLUMNAS = (
+ ("csd_cer_file_key", sa.String(length=512)),
+ ("csd_key_file_key", sa.String(length=512)),
+ ("csd_password_enc", sa.Text()),
+ ("csd_cert_number", sa.String(length=20)),
+ ("csd_uploaded_at", sa.DateTime()),
+)
+
+
+def upgrade() -> None:
+ for nombre, tipo in _COLUMNAS:
+ op.add_column("issuer_settings", sa.Column(nombre, tipo, nullable=True), schema="fin")
+
+
+def downgrade() -> None:
+ # Al revés, para que el orden de la tabla quede como estaba.
+ for nombre, _ in reversed(_COLUMNAS):
+ op.drop_column("issuer_settings", nombre, schema="fin")
diff --git a/backend/alembic/versions/j5k6l7m8n9o0_fin_stamp_attempt_xml.py b/backend/alembic/versions/j5k6l7m8n9o0_fin_stamp_attempt_xml.py
new file mode 100644
index 0000000..49e9c2e
--- /dev/null
+++ b/backend/alembic/versions/j5k6l7m8n9o0_fin_stamp_attempt_xml.py
@@ -0,0 +1,40 @@
+"""XML enviado y recibido de cada intento de timbrado, en ``fin.invoice_stamps``.
+
+Hasta ahora sólo se guardaba el XML del timbrado exitoso, que es justo el caso en el que
+menos falta hace. Cuando el PAC rechaza el comprobante no queda rastro de qué se le mandó
+ni de qué contestó: el XML sellado vive en memoria durante la petición y desaparece con
+ella, y el cuerpo de la respuesta también. Estas dos columnas apuntan al par enviado/recibido
+que se guarda en el almacenamiento por cada intento.
+
+Escrita a mano, no con ``--autogenerate``: ver la nota de la migración h3i4j5k6l7m8.
+
+Revision ID: j5k6l7m8n9o0
+Revises: i4j5k6l7m8n9
+Create Date: 2026-08-11 00:00:00.000000
+
+"""
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "j5k6l7m8n9o0"
+down_revision: Union[str, None] = "i4j5k6l7m8n9"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+_COLUMNAS = (
+ ("request_xml_file_key", sa.String(length=512)),
+ ("response_xml_file_key", sa.String(length=512)),
+)
+
+
+def upgrade() -> None:
+ for nombre, tipo in _COLUMNAS:
+ op.add_column("invoice_stamps", sa.Column(nombre, tipo, nullable=True), schema="fin")
+
+
+def downgrade() -> None:
+ # Al revés, para que el orden de la tabla quede como estaba.
+ for nombre, _ in reversed(_COLUMNAS):
+ op.drop_column("invoice_stamps", nombre, schema="fin")
diff --git a/backend/api/v1/modules/fin/invoices/dto.py b/backend/api/v1/modules/fin/invoices/dto.py
index 121613f..9318b33 100644
--- a/backend/api/v1/modules/fin/invoices/dto.py
+++ b/backend/api/v1/modules/fin/invoices/dto.py
@@ -1,5 +1,6 @@
from datetime import date, datetime
from decimal import Decimal
+from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, computed_field
@@ -92,6 +93,9 @@ class InvoiceBase(BaseModel):
payment_form_id: int | None = None
payment_method_id: int | None = None
expedition_zip_code: str | None = Field(None, max_length=5)
+ # Modo de timbrado de ESTA factura. 'produccion' emite un CFDI con validez fiscal real
+ # ante el SAT; por eso el default es 'pruebas' y subirlo es una decisión explícita.
+ stamping_mode: Literal["pruebas", "produccion"] = "pruebas"
class InvoiceCreate(InvoiceBase):
@@ -114,6 +118,7 @@ class InvoiceUpdate(BaseModel):
payment_form_id: int | None = None
payment_method_id: int | None = None
expedition_zip_code: str | None = Field(None, max_length=5)
+ stamping_mode: Literal["pruebas", "produccion"] | None = None
class InvoiceResponse(InvoiceBase):
@@ -140,3 +145,26 @@ class InvoiceResponse(InvoiceBase):
company_id: int
created_at: datetime
updated_at: datetime
+
+
+class InvoiceItemTaxInput(BaseModel):
+ """Alta o ajuste de un impuesto de la partida.
+
+ El importe no se recibe: se calcula de la base de la partida por la tasa, para que no
+ pueda quedar un desglose que no cuadre con el importe del concepto.
+ """
+
+ tax_id: int
+ rate: Decimal = Field(..., ge=0, le=1, max_digits=8, decimal_places=6)
+ is_withholding: bool = False
+
+
+class InvoiceItemTaxResponse(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ id: int
+ invoice_item_id: int
+ tax_id: int
+ is_withholding: bool
+ rate: Decimal | None = None
+ amount: Decimal
diff --git a/backend/api/v1/modules/fin/invoices/models.py b/backend/api/v1/modules/fin/invoices/models.py
index 147aa6b..98efd2f 100644
--- a/backend/api/v1/modules/fin/invoices/models.py
+++ b/backend/api/v1/modules/fin/invoices/models.py
@@ -74,6 +74,13 @@ class Invoice(Base, TenantScopedMixin, TimestampMixin):
Integer, ForeignKey("sat.payment_methods.id"), nullable=True
)
expedition_zip_code: Mapped[str | None] = mapped_column(String(5), nullable=True)
+ # ----- Modo de timbrado (por factura, no por entorno) -----
+ # 'pruebas' | 'produccion'. Determina el host del PAC y, con él, si el comprobante tiene
+ # validez fiscal ante el SAT. Inmutable una vez que la factura tiene un timbre exitoso:
+ # cambiarlo después falsearía el registro de con qué intención se emitió.
+ stamping_mode: Mapped[str] = mapped_column(
+ String(12), nullable=False, server_default=text("'pruebas'")
+ )
class InvoiceItem(Base, TenantScopedMixin, TimestampMixin):
diff --git a/backend/api/v1/modules/fin/invoices/routes.py b/backend/api/v1/modules/fin/invoices/routes.py
index b48b8e5..5751194 100644
--- a/backend/api/v1/modules/fin/invoices/routes.py
+++ b/backend/api/v1/modules/fin/invoices/routes.py
@@ -4,12 +4,14 @@ from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
-from . import service
+from . import service, taxes_service
from .dto import (
InvoiceClientReviewInput,
InvoiceCreate,
InvoiceItemCreate,
InvoiceItemResponse,
+ InvoiceItemTaxInput,
+ InvoiceItemTaxResponse,
InvoiceItemUpdate,
InvoiceResponse,
InvoiceUpdate,
@@ -132,3 +134,26 @@ def create_payment(payload: PaymentCreate, company_id: int = Query(...), current
@router.delete("/payments/{payment_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_payment(payment_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
service.delete_payment(db, payment_id, current_user["tenant_id"], company_id)
+
+
+# ----- Impuestos por partida -----
+# El traslado de IVA se deriva del % de la factura; estos endpoints son para ajustarlo
+# (retenciones, tasas distintas) cuando el caso lo pide.
+
+@router.get("/invoice-items/{item_id}/taxes", response_model=list[InvoiceItemTaxResponse])
+def list_item_taxes(item_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
+ return taxes_service.list_item_taxes(db, item_id, current_user["tenant_id"], company_id)
+
+
+@router.put("/invoice-items/{item_id}/taxes", response_model=InvoiceItemTaxResponse)
+def set_item_tax(item_id: int, payload: InvoiceItemTaxInput, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
+ """Alta o ajuste. La combinación impuesto + traslado/retención es única por partida."""
+ return taxes_service.set_item_tax(
+ db, item_id, payload.tax_id, payload.rate, payload.is_withholding,
+ current_user["tenant_id"], company_id,
+ )
+
+
+@router.delete("/invoice-item-taxes/{tax_row_id}", status_code=status.HTTP_204_NO_CONTENT)
+def delete_item_tax(tax_row_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
+ taxes_service.delete_item_tax(db, tax_row_id, current_user["tenant_id"], company_id)
diff --git a/backend/api/v1/modules/fin/invoices/service.py b/backend/api/v1/modules/fin/invoices/service.py
index 912f7f0..c897b07 100644
--- a/backend/api/v1/modules/fin/invoices/service.py
+++ b/backend/api/v1/modules/fin/invoices/service.py
@@ -20,6 +20,7 @@ from .dto import (
InvoiceUpdate,
PaymentCreate,
)
+from . import taxes_service
from .models import Invoice, InvoiceItem, Payment
from .pdf import build_invoice_pdf
@@ -119,16 +120,43 @@ def update_invoice(db, invoice_id, payload: InvoiceUpdate, tenant_id, company_id
obj = get_invoice(db, invoice_id, tenant_id, company_id)
data = payload.model_dump(exclude_unset=True)
_validate_refs(db, data, tenant_id, company_id)
+ _reject_stamping_mode_change(db, obj, data, tenant_id, company_id)
for f, v in data.items():
setattr(obj, f, v)
obj.updated_by = user_id
db.flush()
+ if "tax_rate" in data:
+ # El % global es la fuente del IVA derivado de cada partida: si cambia, se propaga.
+ taxes_service.sync_invoice_taxes(db, obj)
_recompute(db, obj) # tax_rate pudo cambiar
db.commit()
db.refresh(obj)
return obj
+def _reject_stamping_mode_change(db, obj: Invoice, data: dict, tenant_id, company_id) -> None:
+ """El modo de timbrado es inmutable una vez que la factura tiene timbre.
+
+ Cambiarlo después falsearía el registro de con qué intención se emitió el comprobante: el
+ CFDI ya existe ante el SAT con la validez que le dio el entorno donde se timbró, y ese
+ hecho no se edita.
+ """
+ nuevo = data.get("stamping_mode")
+ if nuevo is None or nuevo == obj.stamping_mode:
+ return
+ # Import diferido: stamping importa invoices, y al revés sería circular.
+ from ..stamping.service import get_stamp # noqa: PLC0415
+
+ if get_stamp(db, obj.id, tenant_id, company_id):
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=(
+ "La factura ya está timbrada: el modo de timbrado no se puede cambiar "
+ f"(sigue en {obj.stamping_mode!r})."
+ ),
+ )
+
+
def delete_invoice(db, invoice_id, tenant_id, company_id) -> None:
obj = get_invoice(db, invoice_id, tenant_id, company_id)
obj.deleted_at = datetime.now(timezone.utc)
@@ -391,6 +419,7 @@ def create_item(db, payload: InvoiceItemCreate, tenant_id, company_id) -> Invoic
item = InvoiceItem(**data, tenant_id=tenant_id, company_id=company_id)
db.add(item)
db.flush()
+ taxes_service.sync_item_taxes(db, item, invoice)
_recompute(db, invoice)
db.commit()
db.refresh(item)
@@ -407,7 +436,9 @@ def update_item(db, item_id, payload: InvoiceItemUpdate, tenant_id, company_id)
for f, v in data.items():
setattr(item, f, v)
db.flush()
- _recompute(db, get_invoice(db, item.invoice_id, tenant_id, company_id))
+ invoice = get_invoice(db, item.invoice_id, tenant_id, company_id)
+ taxes_service.sync_item_taxes(db, item, invoice)
+ _recompute(db, invoice)
db.commit()
db.refresh(item)
return item
diff --git a/backend/api/v1/modules/fin/invoices/taxes_service.py b/backend/api/v1/modules/fin/invoices/taxes_service.py
new file mode 100644
index 0000000..df3c444
--- /dev/null
+++ b/backend/api/v1/modules/fin/invoices/taxes_service.py
@@ -0,0 +1,201 @@
+"""Impuestos de las partidas de la factura.
+
+El CFDI exige el desglose **por partida**, pero la factura ya captura un porcentaje global de
+impuesto. Duplicar la captura sería trabajo doble y una fuente de incoherencias, así que el
+traslado de IVA se **deriva** de ``invoices.tax_rate`` y se recalcula cuando cambia el importe
+o el porcentaje.
+
+La derivación no pisa lo capturado a mano: en cuanto alguien ajusta los impuestos de una
+partida (una retención, una tasa distinta, un exento), esa partida deja de recalcularse sola.
+Es la diferencia entre un valor por defecto útil y un automatismo que borra trabajo ajeno.
+"""
+
+from decimal import ROUND_HALF_UP, Decimal
+
+from fastapi import HTTPException, status
+from sqlalchemy.orm import Session
+
+from ..catalogs.models import Tax, TaxObject
+from .models import Invoice, InvoiceItem, InvoiceItemTax
+
+# c_ObjetoImp que obligan al desglose de impuestos en el comprobante.
+_OBJETO_CON_DESGLOSE = {"02"}
+# c_Impuesto del IVA.
+_IVA = "002"
+
+
+def _cents(value: Decimal) -> Decimal:
+ return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
+
+
+def _item_taxes(db: Session, item_id: int) -> list[InvoiceItemTax]:
+ return (
+ db.query(InvoiceItemTax)
+ .filter(InvoiceItemTax.invoice_item_id == item_id, InvoiceItemTax.deleted_at.is_(None))
+ .order_by(InvoiceItemTax.id)
+ .all()
+ )
+
+
+def _tax_object_code(db: Session, item: InvoiceItem) -> str:
+ if not item.tax_object_id:
+ return ""
+ row = db.query(TaxObject).filter(TaxObject.id == item.tax_object_id).first()
+ return row.code if row else ""
+
+
+def sync_item_taxes(db: Session, item: InvoiceItem, invoice: Invoice) -> None:
+ """Deja el traslado de IVA de la partida al día con el ``tax_rate`` de la factura.
+
+ No hace nada si:
+ - la partida no es objeto de impuesto con desglose (``ObjetoImp`` distinto de 02), o
+ - ya hay impuestos que no son el traslado de IVA derivado — señal de captura manual.
+ """
+ if _tax_object_code(db, item) not in _OBJETO_CON_DESGLOSE:
+ # Si dejó de ser objeto de impuesto, se retira el traslado derivado: un CFDI con
+ # ObjetoImp 01 y nodo de impuestos es motivo de rechazo.
+ for t in _item_taxes(db, item.id):
+ db.delete(t)
+ return
+
+ iva = db.query(Tax).filter(Tax.code == _IVA).first()
+ if not iva:
+ return # sin catálogo no hay nada que derivar; la validación del timbrado lo reportará
+
+ existentes = _item_taxes(db, item.id)
+ manuales = [t for t in existentes if t.is_withholding or t.tax_id != iva.id]
+ if manuales:
+ return # hay captura manual: no se toca
+
+ rate = (Decimal(invoice.tax_rate or 0) / Decimal(100)).quantize(Decimal("0.000001"))
+ base = _cents(Decimal(item.quantity or 0) * Decimal(item.unit_amount or 0))
+ amount = _cents(base * rate)
+
+ traslado = next((t for t in existentes if t.tax_id == iva.id and not t.is_withholding), None)
+ if rate == 0:
+ # Tasa 0 no es lo mismo que exento, pero con el % en cero lo que hay es una factura sin
+ # IVA capturado: se retira el traslado en vez de declarar 0.00 y que el SAT lo cuestione.
+ if traslado:
+ db.delete(traslado)
+ return
+
+ if traslado is None:
+ traslado = InvoiceItemTax(
+ invoice_item_id=item.id,
+ tax_id=iva.id,
+ is_withholding=False,
+ tenant_id=item.tenant_id,
+ company_id=item.company_id,
+ )
+ db.add(traslado)
+ traslado.rate = rate
+ traslado.amount = amount
+
+
+def sync_invoice_taxes(db: Session, invoice: Invoice) -> None:
+ """Recalcula el IVA derivado de todas las partidas. Se llama al cambiar ``tax_rate``."""
+ items = (
+ db.query(InvoiceItem)
+ .filter(InvoiceItem.invoice_id == invoice.id, InvoiceItem.deleted_at.is_(None))
+ .all()
+ )
+ for item in items:
+ sync_item_taxes(db, item, invoice)
+
+
+# --------------------------------------------------------------------------------------
+# Ajuste manual
+# --------------------------------------------------------------------------------------
+def list_item_taxes(
+ db: Session, item_id: int, tenant_id: int, company_id: int
+) -> list[InvoiceItemTax]:
+ _get_item(db, item_id, tenant_id, company_id)
+ return _item_taxes(db, item_id)
+
+
+def _get_item(db: Session, item_id: int, tenant_id: int, company_id: int) -> InvoiceItem:
+ item = (
+ db.query(InvoiceItem)
+ .filter(
+ InvoiceItem.id == item_id,
+ InvoiceItem.tenant_id == tenant_id,
+ InvoiceItem.company_id == company_id,
+ InvoiceItem.deleted_at.is_(None),
+ )
+ .first()
+ )
+ if not item:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Partida no encontrada")
+ return item
+
+
+def set_item_tax(
+ db: Session,
+ item_id: int,
+ tax_id: int,
+ rate: Decimal,
+ is_withholding: bool,
+ tenant_id: int,
+ company_id: int,
+) -> InvoiceItemTax:
+ """Alta o ajuste de un impuesto de la partida. El importe se calcula de la base y la tasa."""
+ item = _get_item(db, item_id, tenant_id, company_id)
+ tax = db.query(Tax).filter(Tax.id == tax_id).first()
+ if not tax:
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail="El impuesto indicado no existe en el catálogo del SAT",
+ )
+ if is_withholding and not tax.is_withholding:
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail=f"El impuesto {tax.code} ({tax.description}) no puede retenerse",
+ )
+ if not is_withholding and not tax.is_transferred:
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail=f"El impuesto {tax.code} ({tax.description}) no puede trasladarse",
+ )
+
+ base = _cents(Decimal(item.quantity or 0) * Decimal(item.unit_amount or 0))
+ obj = (
+ db.query(InvoiceItemTax)
+ .filter(
+ InvoiceItemTax.invoice_item_id == item_id,
+ InvoiceItemTax.tax_id == tax_id,
+ InvoiceItemTax.is_withholding == is_withholding,
+ InvoiceItemTax.deleted_at.is_(None),
+ )
+ .first()
+ )
+ if obj is None:
+ obj = InvoiceItemTax(
+ invoice_item_id=item_id,
+ tax_id=tax_id,
+ is_withholding=is_withholding,
+ tenant_id=tenant_id,
+ company_id=company_id,
+ )
+ db.add(obj)
+ obj.rate = Decimal(rate).quantize(Decimal("0.000001"))
+ obj.amount = _cents(base * Decimal(rate))
+ db.commit()
+ db.refresh(obj)
+ return obj
+
+
+def delete_item_tax(db: Session, tax_row_id: int, tenant_id: int, company_id: int) -> None:
+ obj = (
+ db.query(InvoiceItemTax)
+ .filter(
+ InvoiceItemTax.id == tax_row_id,
+ InvoiceItemTax.tenant_id == tenant_id,
+ InvoiceItemTax.company_id == company_id,
+ InvoiceItemTax.deleted_at.is_(None),
+ )
+ .first()
+ )
+ if not obj:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Impuesto no encontrado")
+ db.delete(obj)
+ db.commit()
diff --git a/backend/api/v1/modules/fin/issuer/csd_service.py b/backend/api/v1/modules/fin/issuer/csd_service.py
new file mode 100644
index 0000000..c92cdaf
--- /dev/null
+++ b/backend/api/v1/modules/fin/issuer/csd_service.py
@@ -0,0 +1,159 @@
+"""Carga y lectura del CSD (Certificado de Sello Digital) de la empresa.
+
+El ``.key`` es material con el que se puede firmar a nombre de la empresa ante el SAT: no se
+devuelve nunca por la API, ni entero ni en partes. Sólo entra (al subirlo) y se usa del lado
+del servidor (al timbrar).
+"""
+
+from datetime import datetime, timezone
+
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.primitives.asymmetric import padding
+from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate
+from fastapi import HTTPException, status
+from sqlalchemy.orm import Session
+
+from core.crypto import SecretsNotConfigured, encrypt_secret
+from core.s3_keys import tenant_company_prefix
+
+from ...fin.stamping import sealer
+from .models import IssuerSettings
+from .service import get_issuer_settings
+
+# Tamaño máximo razonable: un .cer del SAT ronda los 2 KB y un .key los 2 KB. El tope evita
+# que alguien suba un archivo enorme por error o a propósito.
+_MAX_BYTES = 64 * 1024
+
+
+def _csd_keys(tenant_id: int, company_id: int) -> tuple[str, str]:
+ """Claves de almacenamiento del par. Estables: subir de nuevo reemplaza el anterior."""
+ prefijo = tenant_company_prefix(tenant_id, company_id) + "certificates/"
+ return prefijo + "cfdi.cer", prefijo + "cfdi.key"
+
+
+def _verify_pair(cer_bytes: bytes, key_bytes: bytes, password: str) -> str:
+ """Comprueba que la llave privada corresponde al certificado. Devuelve el NoCertificado.
+
+ Se hace firmando un dato de prueba con la llave y verificándolo con la pública del
+ certificado. Es la única forma de saberlo antes de timbrar: si no cuadran, el error
+ aparecería hasta que el PAC rechace el comprobante, con un mensaje que no menciona el CSD.
+ """
+ try:
+ cert_number, _ = sealer.read_certificate(cer_bytes)
+ private_key = sealer.load_private_key(key_bytes, password)
+ except sealer.SealingError as exc:
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)
+ ) from exc
+
+ try:
+ cert = load_der_x509_certificate(cer_bytes)
+ except ValueError:
+ cert = load_pem_x509_certificate(cer_bytes)
+
+ reto = b"verificacion-de-par-csd"
+ firma = private_key.sign(reto, padding.PKCS1v15(), hashes.SHA256())
+ try:
+ cert.public_key().verify(firma, reto, padding.PKCS1v15(), hashes.SHA256())
+ except Exception as exc: # noqa: BLE001 — cualquier fallo aquí es "no corresponden"
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail=(
+ "La llave privada (.key) no corresponde al certificado (.cer). "
+ "Verifica que ambos archivos sean del mismo CSD."
+ ),
+ ) from exc
+
+ return cert_number
+
+
+def upload_csd(
+ db: Session,
+ tenant_id: int,
+ company_id: int,
+ cer_bytes: bytes,
+ key_bytes: bytes,
+ password: str,
+ user_id: str | None = None,
+) -> IssuerSettings:
+ """Valida el par, lo guarda en almacenamiento y cifra la contraseña."""
+ # Exige que ya existan los datos fiscales: el CSD pertenece a un emisor, no al aire.
+ obj = get_issuer_settings(db, tenant_id, company_id)
+
+ if not password:
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail="La contraseña de la llave privada es obligatoria.",
+ )
+ for etiqueta, datos in (("certificado (.cer)", cer_bytes), ("llave privada (.key)", key_bytes)):
+ if not datos:
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail=f"Falta el archivo del {etiqueta}.",
+ )
+ if len(datos) > _MAX_BYTES:
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail=f"El archivo del {etiqueta} es demasiado grande para ser un CSD.",
+ )
+
+ cert_number = _verify_pair(cer_bytes, key_bytes, password)
+
+ # La contraseña se cifra ANTES de subir los archivos: si no hay clave maestra, no se deja
+ # material criptográfico en el almacenamiento a medio configurar.
+ try:
+ password_enc = encrypt_secret(password)
+ except SecretsNotConfigured as exc:
+ raise HTTPException(
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)
+ ) from exc
+
+ from core.storage_s3 import (
+ put_object_bytes,
+ ) # noqa: PLC0415 (import diferido: tests sin MinIO)
+
+ cer_key, key_key = _csd_keys(tenant_id, company_id)
+ put_object_bytes(cer_key, cer_bytes, content_type="application/x-x509-ca-cert")
+ put_object_bytes(key_key, key_bytes, content_type="application/octet-stream")
+
+ obj.csd_cer_file_key = cer_key
+ obj.csd_key_file_key = key_key
+ obj.csd_password_enc = password_enc
+ obj.csd_cert_number = cert_number
+ obj.csd_uploaded_at = datetime.now(timezone.utc)
+ obj.updated_by = user_id
+ db.commit()
+ db.refresh(obj)
+ return obj
+
+
+def delete_csd(
+ db: Session, tenant_id: int, company_id: int, user_id: str | None = None
+) -> IssuerSettings:
+ """Desvincula el CSD de la empresa.
+
+ Se borran también los objetos del almacenamiento: dejar una llave privada huérfana es
+ justo lo que no se quiere. Si el borrado remoto falla, la referencia se limpia igual —
+ sin ella el sistema ya no puede firmar.
+ """
+ obj = get_issuer_settings(db, tenant_id, company_id)
+ claves = [k for k in (obj.csd_cer_file_key, obj.csd_key_file_key) if k]
+ if claves:
+ try:
+ from core.storage_s3 import delete_object_if_exists # noqa: PLC0415
+
+ for k in claves:
+ delete_object_if_exists(k)
+ except Exception: # noqa: BLE001
+ # No se propaga: la referencia se limpia igual y el CSD queda inutilizable.
+ pass
+
+ obj.csd_cer_file_key = None
+ obj.csd_key_file_key = None
+ obj.csd_password_enc = None
+ obj.csd_cert_number = None
+ obj.csd_uploaded_at = None
+ obj.updated_by = user_id
+ db.commit()
+ db.refresh(obj)
+ return obj
diff --git a/backend/api/v1/modules/fin/issuer/dto.py b/backend/api/v1/modules/fin/issuer/dto.py
index 9f8df25..ff62a34 100644
--- a/backend/api/v1/modules/fin/issuer/dto.py
+++ b/backend/api/v1/modules/fin/issuer/dto.py
@@ -3,7 +3,7 @@
import re
from datetime import datetime
-from pydantic import BaseModel, ConfigDict, Field, field_validator
+from pydantic import BaseModel, ConfigDict, computed_field, Field, field_validator
from ..catalogs.dto import TaxRegimeResponse
@@ -58,3 +58,14 @@ class IssuerSettingsResponse(BaseModel):
updated_by: str | None = None
created_at: datetime
updated_at: datetime
+ # ----- Estado del CSD -----
+ # Se expone SI hay certificado cargado y cuál, nunca su contenido ni la contraseña: con el
+ # .key se puede firmar a nombre de la empresa ante el SAT.
+ csd_cert_number: str | None = None
+ csd_uploaded_at: datetime | None = None
+
+ @computed_field
+ @property
+ def has_csd(self) -> bool:
+ """Hay par de archivos y contraseña guardados, o sea que ya se puede timbrar."""
+ return bool(self.csd_cert_number and self.csd_uploaded_at)
diff --git a/backend/api/v1/modules/fin/issuer/models.py b/backend/api/v1/modules/fin/issuer/models.py
index 85eb025..e0472a2 100644
--- a/backend/api/v1/modules/fin/issuer/models.py
+++ b/backend/api/v1/modules/fin/issuer/models.py
@@ -5,7 +5,9 @@ fiscal y código postal del lugar de expedición. Hay **una sola configuración
por empresa**, garantizada con un índice único parcial.
"""
-from sqlalchemy import ForeignKey, Index, Integer, String, text
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
@@ -39,4 +41,17 @@ class IssuerSettings(Base, TenantScopedMixin, TimestampMixin):
zip_code: Mapped[str | None] = mapped_column(String(5), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
+ # ----- CSD (Certificado de Sello Digital) de la empresa -----
+ # Los archivos viven en MinIO; aquí sólo su clave. El .key es material con el que se puede
+ # firmar a nombre de la empresa: no se expone nunca por la API, ni siquiera su contenido en
+ # base64. Sólo se sube y se usa del lado del servidor.
+ csd_cer_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
+ csd_key_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
+ # Contraseña de la llave privada, cifrada con la clave maestra del entorno (core.crypto).
+ # Nunca se devuelve en una respuesta; sólo se sabe si está puesta o no.
+ csd_password_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
+ # Informativo, para mostrar en la pantalla qué certificado está cargado.
+ csd_cert_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
+ csd_uploaded_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+
tax_regime: Mapped["TaxRegime"] = relationship("TaxRegime", lazy="selectin")
diff --git a/backend/api/v1/modules/fin/issuer/routes.py b/backend/api/v1/modules/fin/issuer/routes.py
index 3f60490..15e7bf0 100644
--- a/backend/api/v1/modules/fin/issuer/routes.py
+++ b/backend/api/v1/modules/fin/issuer/routes.py
@@ -1,13 +1,13 @@
"""Endpoints de los datos fiscales del emisor (una configuración por empresa)."""
-from fastapi import APIRouter, Depends, Query
+from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
from sqlalchemy.orm import Session
from api.v1.modules.core.permissions.dependencies import PermissionChecker
from core.database import get_core_db
from core.security import get_current_user
-from . import service
+from . import csd_service, service
from .dto import IssuerSettingsInput, IssuerSettingsResponse
router = APIRouter()
@@ -46,3 +46,52 @@ def save_issuer_settings(
company_id,
current_user.get("sub") or current_user.get("id"),
)
+
+
+@router.post(
+ "/settings/issuer/csd",
+ response_model=IssuerSettingsResponse,
+ dependencies=[Depends(PermissionChecker(["fin.settings.edit"]))],
+)
+async def upload_csd(
+ company_id: int = Query(..., description="Company ID"),
+ cer: UploadFile = File(..., description="Certificado del CSD (.cer)"),
+ key: UploadFile = File(..., description="Llave privada del CSD (.key)"),
+ password: str = Form(..., description="Contraseña de la llave privada"),
+ current_user: dict = Depends(get_current_user),
+ db: Session = Depends(get_core_db),
+):
+ """Carga el CSD de la empresa.
+
+ Antes de guardar nada se comprueba que la llave privada corresponde al certificado: si no,
+ el error saldría hasta que el PAC rechace un comprobante, con un mensaje que no menciona
+ el CSD. La contraseña se guarda cifrada y **no se devuelve nunca**.
+ """
+ return csd_service.upload_csd(
+ db,
+ current_user["tenant_id"],
+ company_id,
+ await cer.read(),
+ await key.read(),
+ password,
+ current_user.get("sub") or current_user.get("id"),
+ )
+
+
+@router.delete(
+ "/settings/issuer/csd",
+ response_model=IssuerSettingsResponse,
+ dependencies=[Depends(PermissionChecker(["fin.settings.edit"]))],
+)
+def delete_csd(
+ company_id: int = Query(..., description="Company ID"),
+ current_user: dict = Depends(get_current_user),
+ db: Session = Depends(get_core_db),
+):
+ """Desvincula el CSD y borra sus archivos del almacenamiento."""
+ return csd_service.delete_csd(
+ db,
+ current_user["tenant_id"],
+ company_id,
+ current_user.get("sub") or current_user.get("id"),
+ )
diff --git a/backend/api/v1/modules/fin/router.py b/backend/api/v1/modules/fin/router.py
index ee82017..899b0d6 100644
--- a/backend/api/v1/modules/fin/router.py
+++ b/backend/api/v1/modules/fin/router.py
@@ -9,6 +9,7 @@ from .catalogs.routes import router as catalogs_router
from .concepts.routes import router as concepts_router
from .invoices.routes import router as invoices_router
from .issuer.routes import router as issuer_router
+from .stamping.routes import router as stamping_router
# Enforcement por área/carril (R-T-07): se exige fin.access para el módulo.
router = APIRouter(dependencies=[Depends(PermissionChecker(["fin.access"]))])
@@ -16,3 +17,4 @@ router.include_router(catalogs_router)
router.include_router(concepts_router)
router.include_router(issuer_router)
router.include_router(invoices_router)
+router.include_router(stamping_router)
diff --git a/backend/api/v1/modules/fin/stamping/__init__.py b/backend/api/v1/modules/fin/stamping/__init__.py
new file mode 100644
index 0000000..e3076a0
--- /dev/null
+++ b/backend/api/v1/modules/fin/stamping/__init__.py
@@ -0,0 +1 @@
+"""Timbrado de CFDI 4.0 ante el PAC (Comercio Digital)."""
diff --git a/backend/api/v1/modules/fin/stamping/cfdi_builder.py b/backend/api/v1/modules/fin/stamping/cfdi_builder.py
new file mode 100644
index 0000000..0523abe
--- /dev/null
+++ b/backend/api/v1/modules/fin/stamping/cfdi_builder.py
@@ -0,0 +1,382 @@
+"""Construcción del XML CFDI 4.0 de tipo ingreso.
+
+El orden de los atributos **no es libre**: la cadena original se calcula recorriendo el
+comprobante en el orden del XSD, y el sello se hace sobre esa cadena. Aquí se respeta el
+mismo orden que usa el sistema legado (``CFDI.cs:14340-14394``), verificado contra el XSD.
+
+Todo el dinero se maneja con ``Decimal``. Con ``float``, 0.1 + 0.2 no da 0.30 y el total del
+comprobante no cuadra con la suma de las partidas: el PAC lo rechaza.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from decimal import ROUND_HALF_UP, Decimal
+
+from lxml import etree
+
+CFDI_NS = "http://www.sat.gob.mx/cfd/4"
+XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
+SCHEMA_LOCATION = (
+ "http://www.sat.gob.mx/cfd/4 http://www.sat.gob.mx/sitio_internet/cfd/4/cfdv40.xsd"
+)
+
+VERSION = "4.0"
+TIPO_INGRESO = "I"
+
+# Declaración XML escrita a mano, con comillas DOBLES. lxml emite la suya con comillas simples
+# (), que es XML válido —la especificación admite ambas—
+# pero Comercio Digital lo rechaza con el código 642 "la versión del XML no es 1.0" porque
+# compara la cadena literal version="1.0" en vez de parsear el prólogo. Por eso el comprobante
+# se serializa sin declaración y ésta se antepone.
+XML_DECLARATION = b'\n'
+# c_Exportacion: "01" = No aplica. Es obligatorio en 4.0 y este módulo no emite exportaciones.
+EXPORTACION_NO_APLICA = "01"
+
+# Decimales por moneda (c_Moneda). El SAT admite hasta ese número en importes.
+_DECIMALES = {"MXN": 2, "USD": 2, "EUR": 2}
+_DECIMALES_DEFECTO = 2
+
+
+class CfdiBuildError(Exception):
+ """El comprobante no se puede construir con los datos disponibles."""
+
+ def __init__(self, missing: list[str]):
+ # Se acumulan TODOS los faltantes en vez de fallar en el primero: quien captura la
+ # factura necesita la lista completa, no descubrirlos de uno en uno.
+ self.missing = missing
+ super().__init__("Faltan datos fiscales: " + "; ".join(missing))
+
+
+def _serialize(root: etree._Element) -> bytes:
+ """Serializa el comprobante en UTF-8 con la declaración que acepta el PAC."""
+ # xml_declaration=False es obligatorio: con encoding distinto de ASCII, lxml la añade sola.
+ return XML_DECLARATION + etree.tostring(root, xml_declaration=False, encoding="UTF-8")
+
+
+def _money(value: Decimal | float | int | None, currency: str) -> str:
+ """Importe con los decimales de la moneda, sin separador de miles."""
+ dec = _DECIMALES.get(currency.upper(), _DECIMALES_DEFECTO)
+ q = Decimal(1).scaleb(-dec)
+ return str(Decimal(str(value or 0)).quantize(q, rounding=ROUND_HALF_UP))
+
+
+def _qty(value: Decimal | float | int | None) -> str:
+ """Cantidad: hasta 6 decimales, sin ceros finales innecesarios."""
+ d = Decimal(str(value or 0)).quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP)
+ return format(d.normalize(), "f")
+
+
+def _rate(value: Decimal | float | int) -> str:
+ """Tasa o cuota: el SAT la exige con 6 decimales (p. ej. 0.160000)."""
+ return str(Decimal(str(value)).quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP))
+
+
+@dataclass
+class TaxLine:
+ """Impuesto trasladado o retenido de una partida."""
+
+ code: str # c_Impuesto: "002" = IVA
+ rate: Decimal
+ amount: Decimal
+ is_withholding: bool = False
+ factor: str = "Tasa" # c_TipoFactor: Tasa | Cuota | Exento
+
+
+@dataclass
+class ConceptLine:
+ """Partida del comprobante."""
+
+ product_service_code: str # ClaveProdServ
+ unit_code: str # ClaveUnidad
+ description: str
+ quantity: Decimal
+ unit_price: Decimal
+ tax_object: str # c_ObjetoImp: "01" no objeto, "02" sí objeto
+ taxes: list[TaxLine] = field(default_factory=list)
+ identification: str | None = None # NoIdentificacion
+
+ @property
+ def amount(self) -> Decimal:
+ return (self.quantity * self.unit_price).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
+
+
+@dataclass
+class CfdiData:
+ """Todo lo que necesita un CFDI 4.0 de ingreso, ya resuelto contra los catálogos."""
+
+ # Comprobante
+ folio: str | None
+ serie: str | None
+ date: str # YYYY-MM-DDTHH:MM:SS, hora local del lugar de expedición
+ payment_form: str # c_FormaPago
+ payment_method: str # c_MetodoPago: PUE | PPD
+ currency: str
+ exchange_rate: Decimal | None
+ expedition_zip: str # LugarExpedicion
+ payment_conditions: str | None
+ # Emisor
+ issuer_rfc: str
+ issuer_name: str
+ issuer_tax_regime: str # c_RegimenFiscal
+ # Receptor
+ receiver_rfc: str
+ receiver_name: str
+ receiver_zip: str # DomicilioFiscalReceptor
+ receiver_tax_regime: str # c_RegimenFiscal del receptor
+ receiver_cfdi_use: str # c_UsoCFDI
+ # Partidas
+ concepts: list[ConceptLine]
+
+ def validate(self) -> None:
+ """Acumula los faltantes obligatorios del CFDI 4.0 y los reporta juntos."""
+ faltantes: list[str] = []
+ obligatorios = {
+ "fecha de emisión": self.date,
+ "forma de pago (c_FormaPago)": self.payment_form,
+ "método de pago (c_MetodoPago)": self.payment_method,
+ "moneda": self.currency,
+ "lugar de expedición (CP)": self.expedition_zip,
+ "RFC del emisor": self.issuer_rfc,
+ "razón social del emisor": self.issuer_name,
+ "régimen fiscal del emisor": self.issuer_tax_regime,
+ "RFC del receptor": self.receiver_rfc,
+ "razón social del receptor": self.receiver_name,
+ "domicilio fiscal del receptor (CP)": self.receiver_zip,
+ "régimen fiscal del receptor": self.receiver_tax_regime,
+ "uso de CFDI del receptor": self.receiver_cfdi_use,
+ }
+ for etiqueta, valor in obligatorios.items():
+ if not valor:
+ faltantes.append(f"falta {etiqueta}")
+
+ if not self.concepts:
+ faltantes.append("la factura no tiene partidas")
+
+ for i, c in enumerate(self.concepts, start=1):
+ if not c.product_service_code:
+ faltantes.append(f"partida {i}: falta la clave de producto/servicio")
+ if not c.unit_code:
+ faltantes.append(f"partida {i}: falta la clave de unidad")
+ if not c.tax_object:
+ faltantes.append(f"partida {i}: falta el objeto de impuesto")
+ if not c.description:
+ faltantes.append(f"partida {i}: falta la descripción")
+ if c.quantity is None or c.quantity <= 0:
+ faltantes.append(f"partida {i}: la cantidad debe ser mayor que cero")
+ # ObjetoImp "02" significa "sí objeto de impuesto": el SAT exige entonces el
+ # desglose. Sin él, el comprobante se rechaza; no se inventa una tasa por defecto.
+ if c.tax_object == "02" and not c.taxes:
+ faltantes.append(
+ f"partida {i}: es objeto de impuesto (02) pero no tiene impuestos capturados"
+ )
+
+ if self.currency.upper() != "MXN" and not self.exchange_rate:
+ faltantes.append("falta el tipo de cambio (moneda distinta de MXN)")
+
+ if faltantes:
+ raise CfdiBuildError(faltantes)
+
+ # ----- Totales -----
+ @property
+ def subtotal(self) -> Decimal:
+ return sum((c.amount for c in self.concepts), Decimal("0"))
+
+ @property
+ def transferred(self) -> Decimal:
+ return sum(
+ (t.amount for c in self.concepts for t in c.taxes if not t.is_withholding),
+ Decimal("0"),
+ )
+
+ @property
+ def withheld(self) -> Decimal:
+ return sum(
+ (t.amount for c in self.concepts for t in c.taxes if t.is_withholding),
+ Decimal("0"),
+ )
+
+ @property
+ def total(self) -> Decimal:
+ return self.subtotal + self.transferred - self.withheld
+
+
+def build_xml(data: CfdiData, cert_number: str = "", cert_b64: str = "") -> bytes:
+ """XML CFDI 4.0 de ingreso, **sin** el atributo ``Sello``.
+
+ ``cert_number`` y ``cert_b64`` salen del ``.cer`` y **tienen que venir puestos aquí**, no
+ después: la cadena original incluye ``NoCertificado``. Si se calculara la cadena con el
+ atributo vacío y se rellenara luego, el sello firmaría un texto distinto del que verifica
+ el SAT, y el comprobante se rechazaría con un error que no menciona el certificado.
+
+ ``Sello`` sí se deja vacío —la cadena original no lo incluye, es su resultado— y lo inserta
+ ``apply_seal`` en su posición.
+ """
+ data.validate()
+ cur = data.currency.upper()
+
+ comprobante = etree.Element(
+ f"{{{CFDI_NS}}}Comprobante",
+ nsmap={"cfdi": CFDI_NS, "xsi": XSI_NS},
+ )
+ comprobante.set(f"{{{XSI_NS}}}schemaLocation", SCHEMA_LOCATION)
+
+ # ORDEN DEL XSD. No reordenar: la cadena original -y por tanto el sello- depende de él.
+ comprobante.set("Version", VERSION)
+ if data.serie:
+ comprobante.set("Serie", data.serie)
+ if data.folio:
+ comprobante.set("Folio", str(data.folio))
+ # Sin desplazamiento horario: el "-06:00" es de CFDI 3.3 (ver CFDI.cs:12654). En 4.0 la
+ # fecha va en hora local del lugar de expedición, a secas.
+ comprobante.set("Fecha", data.date)
+ # Sello vacío: reserva su posición en el orden del XSD para que apply_seal lo rellene sin
+ # mover nada. lxml conserva el orden de inserción de los atributos.
+ comprobante.set("Sello", "")
+ comprobante.set("FormaPago", data.payment_form)
+ comprobante.set("NoCertificado", cert_number)
+ comprobante.set("Certificado", cert_b64)
+ if data.payment_conditions:
+ comprobante.set("CondicionesDePago", data.payment_conditions)
+ comprobante.set("SubTotal", _money(data.subtotal, cur))
+ comprobante.set("Moneda", cur)
+ if cur != "MXN" and data.exchange_rate:
+ comprobante.set(
+ "TipoCambio", str(Decimal(str(data.exchange_rate)).quantize(Decimal("0.0001")))
+ )
+ comprobante.set("Total", _money(data.total, cur))
+ comprobante.set("TipoDeComprobante", TIPO_INGRESO)
+ comprobante.set("Exportacion", EXPORTACION_NO_APLICA)
+ comprobante.set("MetodoPago", data.payment_method)
+ comprobante.set("LugarExpedicion", data.expedition_zip)
+
+ emisor = etree.SubElement(comprobante, f"{{{CFDI_NS}}}Emisor")
+ emisor.set("Rfc", data.issuer_rfc)
+ emisor.set("Nombre", data.issuer_name)
+ emisor.set("RegimenFiscal", data.issuer_tax_regime)
+
+ receptor = etree.SubElement(comprobante, f"{{{CFDI_NS}}}Receptor")
+ receptor.set("Rfc", data.receiver_rfc)
+ receptor.set("Nombre", data.receiver_name)
+ receptor.set("DomicilioFiscalReceptor", data.receiver_zip)
+ receptor.set("RegimenFiscalReceptor", data.receiver_tax_regime)
+ receptor.set("UsoCFDI", data.receiver_cfdi_use)
+
+ conceptos = etree.SubElement(comprobante, f"{{{CFDI_NS}}}Conceptos")
+ for c in data.concepts:
+ nodo = etree.SubElement(conceptos, f"{{{CFDI_NS}}}Concepto")
+ nodo.set("ClaveProdServ", c.product_service_code)
+ if c.identification:
+ nodo.set("NoIdentificacion", c.identification)
+ nodo.set("Cantidad", _qty(c.quantity))
+ nodo.set("ClaveUnidad", c.unit_code)
+ nodo.set("Descripcion", c.description)
+ nodo.set("ValorUnitario", _money(c.unit_price, cur))
+ nodo.set("Importe", _money(c.amount, cur))
+ nodo.set("ObjetoImp", c.tax_object)
+ if c.taxes:
+ _add_concept_taxes(nodo, c, cur)
+
+ if any(c.taxes for c in data.concepts):
+ _add_totals(comprobante, data, cur)
+
+ return _serialize(comprobante)
+
+
+def _add_concept_taxes(nodo: etree._Element, c: ConceptLine, cur: str) -> None:
+ """Nodo ``Impuestos`` de una partida: primero Traslados, después Retenciones."""
+ impuestos = etree.SubElement(nodo, f"{{{CFDI_NS}}}Impuestos")
+
+ traslados = [t for t in c.taxes if not t.is_withholding]
+ if traslados:
+ cont = etree.SubElement(impuestos, f"{{{CFDI_NS}}}Traslados")
+ for t in traslados:
+ el = etree.SubElement(cont, f"{{{CFDI_NS}}}Traslado")
+ el.set("Base", _money(c.amount, cur))
+ el.set("Impuesto", t.code)
+ el.set("TipoFactor", t.factor)
+ # Un impuesto exento no lleva TasaOCuota ni Importe: ponerlos es motivo de rechazo.
+ if t.factor != "Exento":
+ el.set("TasaOCuota", _rate(t.rate))
+ el.set("Importe", _money(t.amount, cur))
+
+ retenciones = [t for t in c.taxes if t.is_withholding]
+ if retenciones:
+ cont = etree.SubElement(impuestos, f"{{{CFDI_NS}}}Retenciones")
+ for t in retenciones:
+ el = etree.SubElement(cont, f"{{{CFDI_NS}}}Retencion")
+ el.set("Base", _money(c.amount, cur))
+ el.set("Impuesto", t.code)
+ el.set("TipoFactor", t.factor)
+ el.set("TasaOCuota", _rate(t.rate))
+ el.set("Importe", _money(t.amount, cur))
+
+
+def _add_totals(comprobante: etree._Element, data: CfdiData, cur: str) -> None:
+ """Nodo ``Impuestos`` del comprobante: totales agrupados por impuesto, factor y tasa."""
+ impuestos = etree.SubElement(comprobante, f"{{{CFDI_NS}}}Impuestos")
+
+ def agrupa(withholding: bool) -> dict[tuple[str, str, str], Decimal]:
+ acc: dict[tuple[str, str, str], Decimal] = {}
+ for c in data.concepts:
+ for t in c.taxes:
+ if t.is_withholding != withholding or t.factor == "Exento":
+ continue
+ clave = (t.code, t.factor, _rate(t.rate))
+ acc[clave] = acc.get(clave, Decimal("0")) + t.amount
+ return acc
+
+ # En el XSD, Retenciones va ANTES que Traslados dentro del nodo Impuestos del comprobante
+ # —al revés que dentro del concepto—. Es una asimetría real del esquema, no un descuido.
+ retenidos = agrupa(True)
+ if retenidos:
+ impuestos.set("TotalImpuestosRetenidos", _money(data.withheld, cur))
+ cont = etree.SubElement(impuestos, f"{{{CFDI_NS}}}Retenciones")
+ for (code, _factor, _tasa), monto in sorted(retenidos.items()):
+ el = etree.SubElement(cont, f"{{{CFDI_NS}}}Retencion")
+ el.set("Impuesto", code)
+ el.set("Importe", _money(monto, cur))
+
+ trasladados = agrupa(False)
+ if trasladados:
+ impuestos.set("TotalImpuestosTrasladados", _money(data.transferred, cur))
+ cont = etree.SubElement(impuestos, f"{{{CFDI_NS}}}Traslados")
+ for (code, factor, tasa), monto in sorted(trasladados.items()):
+ el = etree.SubElement(cont, f"{{{CFDI_NS}}}Traslado")
+ el.set(
+ "Base",
+ _money(
+ sum(
+ (
+ c.amount
+ for c in data.concepts
+ for t in c.taxes
+ if not t.is_withholding
+ and t.code == code
+ and t.factor == factor
+ and _rate(t.rate) == tasa
+ ),
+ Decimal("0"),
+ ),
+ cur,
+ ),
+ )
+ el.set("Impuesto", code)
+ el.set("TipoFactor", factor)
+ el.set("TasaOCuota", tasa)
+ el.set("Importe", _money(monto, cur))
+
+
+def apply_seal(xml_bytes: bytes, seal: str) -> bytes:
+ """Inserta el ``Sello`` en el comprobante ya construido.
+
+ Sólo el sello: ``NoCertificado`` y ``Certificado`` ya venían de ``build_xml`` porque el
+ primero entra en la cadena original que se acaba de firmar.
+ """
+ root = etree.fromstring(xml_bytes)
+ if root.get("NoCertificado") is None or not root.get("NoCertificado"):
+ raise CfdiBuildError(
+ ["el comprobante llegó a sellarse sin NoCertificado: la cadena original sería inválida"]
+ )
+ root.set("Sello", seal)
+ return _serialize(root)
diff --git a/backend/api/v1/modules/fin/stamping/dto.py b/backend/api/v1/modules/fin/stamping/dto.py
new file mode 100644
index 0000000..3c1132d
--- /dev/null
+++ b/backend/api/v1/modules/fin/stamping/dto.py
@@ -0,0 +1,31 @@
+"""Esquemas del timbrado de CFDI."""
+
+from datetime import datetime
+
+from pydantic import BaseModel, ConfigDict
+
+
+class InvoiceStampResponse(BaseModel):
+ """Resultado de un timbrado.
+
+ No expone el XML completo: se descarga por URL firmada desde ``/stamp/xml-url``.
+ """
+
+ model_config = ConfigDict(from_attributes=True)
+
+ id: int
+ invoice_id: int
+ mode: str # pruebas | produccion
+ status: str # pendiente | timbrado | error
+ uuid: str | None = None
+ stamped_at: datetime | None = None
+ pac_rfc: str | None = None
+ sat_cert_number: str | None = None
+ pac_code: int | None = None
+ pac_balance: int | None = None
+ error_message: str | None = None
+ xml_file_key: str | None = None
+ # Rastro del intento: se descargan por URL firmada desde ``/stamp/attempts/{id}/xml-url``.
+ request_xml_file_key: str | None = None
+ response_xml_file_key: str | None = None
+ created_at: datetime | None = None
diff --git a/backend/api/v1/modules/fin/stamping/models.py b/backend/api/v1/modules/fin/stamping/models.py
new file mode 100644
index 0000000..d0aa515
--- /dev/null
+++ b/backend/api/v1/modules/fin/stamping/models.py
@@ -0,0 +1,93 @@
+"""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)
diff --git a/backend/api/v1/modules/fin/stamping/pac_comercio_digital.py b/backend/api/v1/modules/fin/stamping/pac_comercio_digital.py
new file mode 100644
index 0000000..e774912
--- /dev/null
+++ b/backend/api/v1/modules/fin/stamping/pac_comercio_digital.py
@@ -0,0 +1,171 @@
+"""Cliente del PAC Comercio Digital — servicio ``timbrarV5``.
+
+El contrato está tomado del sistema legado (``CFDI.cs:19274-19346``), que es la única fuente
+de verdad disponible: se transmite el XML **sellado** en crudo por POST y la respuesta trae el
+comprobante timbrado en el cuerpo y los metadatos en cabeceras HTTP.
+
+Tres defectos del legado se corrigen aquí en vez de replicarse — ver ``_read_headers``.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import httpx
+
+from .models import MODE_TEST, STAMPING_MODES
+
+# Códigos de error propios del cliente, con los mismos números que usa el legado para que los
+# reportes de ambos sistemas se puedan comparar.
+ERR_USER = 701 # usuario con longitud inválida
+ERR_PASSWORD = 702 # password vacío
+ERR_EMPTY_XML = 711 # XML vacío o demasiado corto
+ERR_NETWORK = 833 # excepción de red
+ERR_HTTP = 998 # respuesta HTTP distinta de 200
+
+# El usrws de Comercio Digital tiene forma de RFC.
+_USER_MIN, _USER_MAX = 12, 13
+# Un CFDI sellado nunca baja de este tamaño; por debajo, es que algo se truncó.
+_MIN_XML_BYTES = 200
+
+
+class PacConfigError(Exception):
+ """Configuración inválida del PAC. Se detecta antes de tocar la red."""
+
+
+@dataclass
+class StampResult:
+ """Respuesta del PAC ante un intento de timbrado."""
+
+ ok: bool
+ code: int | None
+ error_message: str
+ xml: str = ""
+ uuid: str = ""
+ balance: int | None = None
+ email_error: str = ""
+
+
+def resolve_host(mode: str, host_test: str, host_prod: str) -> str:
+ """Host del PAC a partir del modo. **Es la única forma de elegirlo.**
+
+ No hay parámetro de host, ni de URL, ni forma de pasarlos desde la capa HTTP: el modo sale
+ de ``invoices.stamping_mode`` y nada más. En el legado, ``CFDIPacUrl`` es un campo mutable
+ que cualquier rama del código reasigna, y un host vacío cae silenciosamente al de pruebas
+ (``CFDI.cs:19288``). Aquí un modo desconocido es un error, no un valor por defecto: el
+ default equivocado emite un CFDI con validez fiscal real.
+ """
+ if mode not in STAMPING_MODES:
+ raise PacConfigError(
+ f"Modo de timbrado inválido: {mode!r}. Sólo se admiten {STAMPING_MODES}."
+ )
+ return host_test if mode == MODE_TEST else host_prod
+
+
+def stamp(
+ xml_bytes: bytes,
+ *,
+ mode: str,
+ user: str,
+ password: str,
+ host_test: str,
+ host_prod: str,
+ email: str = "",
+ timeout: int = 15,
+) -> StampResult:
+ """Transmite el CFDI sellado al PAC y devuelve el resultado.
+
+ Nunca lanza por causas de red o del PAC: esas se devuelven como ``StampResult`` con
+ ``ok=False``, porque el llamador tiene que persistir el intento fallido. Sí lanza
+ ``PacConfigError`` si el modo es inválido, que es un error de programación, no de operación.
+ """
+ host = resolve_host(mode, host_test, host_prod)
+
+ # Validaciones previas: mismas condiciones y códigos que el legado, antes de salir a la red.
+ if not user or not (_USER_MIN <= len(user) <= _USER_MAX):
+ return StampResult(False, ERR_USER, f"{ERR_USER} Usuario del PAC inválido o no configurado")
+ if not password:
+ return StampResult(False, ERR_PASSWORD, f"{ERR_PASSWORD} Password del PAC no configurado")
+ if not xml_bytes or len(xml_bytes) < _MIN_XML_BYTES:
+ return StampResult(False, ERR_EMPTY_XML, f"{ERR_EMPTY_XML} Contenido XML vacío")
+
+ url = f"https://{host}/timbre4/timbrarV5"
+ headers = {
+ "usrws": user,
+ "pwdws": password,
+ "tipo": "XML",
+ "Content-Type": "text/plain",
+ }
+ if email:
+ headers["email"] = email.lower()
+
+ try:
+ # El cuerpo va en crudo: ni base64 ni SOAP. httpx no reintenta por defecto, y así debe
+ # ser: reintentar un timbrado puede consumir un folio y generar un CFDI duplicado.
+ response = httpx.post(url, content=xml_bytes, headers=headers, timeout=timeout)
+ except httpx.HTTPError as exc:
+ return StampResult(
+ False, ERR_NETWORK, f"{ERR_NETWORK} Error de transmisión a {host}: {exc}"
+ )
+
+ if response.status_code != 200:
+ # El cuerpo se conserva aunque el estado no sea 200: cuando el PAC contesta con un
+ # error de servidor, lo que explica el rechazo suele venir precisamente ahí.
+ return StampResult(
+ False,
+ ERR_HTTP,
+ f"{ERR_HTTP} El PAC respondió HTTP {response.status_code}",
+ response.text,
+ )
+
+ return _read_headers(response)
+
+
+def _read_headers(response: httpx.Response) -> StampResult:
+ """Interpreta la respuesta del PAC.
+
+ Aquí se corrigen tres defectos del legado (``CFDI.cs:19324-19336``):
+
+ 1. ``codigo`` **se lee y se conserva**. El legado lo asignaba a una variable local que
+ descartaba, así que su parámetro de salida quedaba siempre en 999 o 991.
+ 2. ``saldo`` **también**. Mismo patrón: se perdía siempre, y con él la única señal de
+ cuántos folios quedan.
+ 3. La presencia de una cabecera se comprueba de verdad. En .NET, ``GetResponseHeader``
+ devuelve ``""`` y no ``null`` cuando falta, así que la rama ``== null`` del legado
+ prácticamente nunca se cumplía.
+ """
+ headers = response.headers
+ error_message = (headers.get("errmsg") or "").strip()
+ uuid = (headers.get("uuid") or "").strip()
+ email_error = (headers.get("erremail") or "").strip()
+
+ def entero(nombre: str) -> int | None:
+ crudo = (headers.get(nombre) or "").strip()
+ if not crudo:
+ return None
+ try:
+ return int(crudo)
+ except ValueError:
+ # El PAC mandó algo no numérico: se ignora el valor, pero no se rompe el timbrado
+ # por ello. Queda como None, que es "no informado".
+ return None
+
+ code = entero("codigo")
+ balance = entero("saldo")
+
+ # Criterio de éxito del legado: errmsg vacío. Se le añade la exigencia de UUID, porque una
+ # respuesta 200 sin errmsg y sin UUID no es un comprobante timbrado.
+ if error_message:
+ return StampResult(False, code, error_message, response.text, uuid, balance, email_error)
+ if not uuid:
+ return StampResult(
+ False,
+ code,
+ "El PAC respondió sin error pero no devolvió UUID",
+ response.text,
+ "",
+ balance,
+ email_error,
+ )
+
+ return StampResult(True, code, "", response.text, uuid, balance, email_error)
diff --git a/backend/api/v1/modules/fin/stamping/routes.py b/backend/api/v1/modules/fin/stamping/routes.py
new file mode 100644
index 0000000..a7c50ce
--- /dev/null
+++ b/backend/api/v1/modules/fin/stamping/routes.py
@@ -0,0 +1,95 @@
+"""Endpoints del timbrado de CFDI."""
+
+from fastapi import APIRouter, Depends, HTTPException, Query, status
+from sqlalchemy.orm import Session
+
+from core.database import get_core_db
+from core.security import get_current_user
+
+from . import service
+from .dto import InvoiceStampResponse
+
+router = APIRouter()
+
+
+def _uid(cu: dict) -> str | None:
+ return cu.get("sub") or cu.get("id")
+
+
+@router.post(
+ "/invoices/{invoice_id}/stamp",
+ response_model=InvoiceStampResponse,
+ status_code=status.HTTP_200_OK,
+)
+def stamp_invoice(
+ invoice_id: int,
+ company_id: int = Query(...),
+ current_user: dict = Depends(get_current_user),
+ db: Session = Depends(get_core_db),
+):
+ """Timbra la factura ante el PAC.
+
+ El modo (pruebas o producción) sale de ``invoices.stamping_mode`` y **no se puede pasar
+ por aquí**: ni por cuerpo, ni por query, ni por cabecera. Es lo único que separa un timbre
+ de prueba de un CFDI con validez fiscal ante el SAT.
+
+ Es idempotente: si la factura ya tiene timbre, lo devuelve sin volver a llamar al PAC.
+ """
+ return service.stamp_invoice(
+ db, invoice_id, current_user["tenant_id"], company_id, _uid(current_user)
+ )
+
+
+@router.get("/invoices/{invoice_id}/stamp", response_model=InvoiceStampResponse)
+def get_invoice_stamp(
+ invoice_id: int,
+ company_id: int = Query(...),
+ current_user: dict = Depends(get_current_user),
+ db: Session = Depends(get_core_db),
+):
+ """Timbre vigente de la factura."""
+ stamp = service.get_stamp(db, invoice_id, current_user["tenant_id"], company_id)
+ if not stamp:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND, detail="La factura no está timbrada"
+ )
+ return stamp
+
+
+@router.get("/invoices/{invoice_id}/stamp/attempts", response_model=list[InvoiceStampResponse])
+def list_stamp_attempts(
+ invoice_id: int,
+ company_id: int = Query(...),
+ current_user: dict = Depends(get_current_user),
+ db: Session = Depends(get_core_db),
+):
+ """Historial de intentos de timbrado, incluidos los rechazados por el PAC."""
+ return service.list_attempts(db, invoice_id, current_user["tenant_id"], company_id)
+
+
+@router.get("/invoices/{invoice_id}/stamp/attempts/{attempt_id}/xml-url")
+def get_stamp_attempt_xml_url(
+ invoice_id: int,
+ attempt_id: int,
+ kind: str = Query(..., pattern="^(request|response)$"),
+ company_id: int = Query(...),
+ current_user: dict = Depends(get_current_user),
+ db: Session = Depends(get_core_db),
+):
+ """URL firmada del XML transmitido al PAC (``request``) o del que contestó (``response``)."""
+ url = service.get_attempt_xml_url(
+ db, invoice_id, attempt_id, kind, current_user["tenant_id"], company_id
+ )
+ return {"url": url}
+
+
+@router.get("/invoices/{invoice_id}/stamp/xml-url")
+def get_stamp_xml_url(
+ invoice_id: int,
+ company_id: int = Query(...),
+ current_user: dict = Depends(get_current_user),
+ db: Session = Depends(get_core_db),
+):
+ """URL firmada para descargar el XML timbrado."""
+ url = service.get_stamp_xml_url(db, invoice_id, current_user["tenant_id"], company_id)
+ return {"url": url}
diff --git a/backend/api/v1/modules/fin/stamping/sealer.py b/backend/api/v1/modules/fin/stamping/sealer.py
new file mode 100644
index 0000000..4189d21
--- /dev/null
+++ b/backend/api/v1/modules/fin/stamping/sealer.py
@@ -0,0 +1,147 @@
+"""Cadena original y sello del CFDI.
+
+El sello es la firma del emisor sobre el comprobante. Se obtiene en dos pasos:
+
+1. **Cadena original**: transformación XSLT oficial del SAT sobre el XML *sin* sello.
+2. **Sello**: firma RSA con SHA-256 de esa cadena, en base64.
+
+Ambos pasos son exactos: un carácter de diferencia en la cadena produce un sello que el SAT
+rechaza. Por eso la cadena no se construye a mano ni se "normaliza" — sale tal cual del XSLT.
+"""
+
+from __future__ import annotations
+
+import base64
+import threading
+from pathlib import Path
+
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import padding, rsa
+from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate
+from lxml import etree
+
+_XSLT_DIR = Path(__file__).parent / "xslt"
+_XSLT_CADENA = _XSLT_DIR / "cadenaoriginal_4_0.xslt"
+
+# La compilación del XSLT es cara (33 includes) y el resultado es inmutable: se hace una vez.
+# El lock evita que dos peticiones concurrentes la compilen a la vez en el arranque.
+_transform: etree.XSLT | None = None
+_transform_lock = threading.Lock()
+
+
+class SealingError(Exception):
+ """Falla al calcular la cadena original o el sello."""
+
+
+def _get_transform() -> etree.XSLT:
+ global _transform
+ if _transform is None:
+ with _transform_lock:
+ if _transform is None: # otro hilo pudo compilarla mientras esperábamos
+ if not _XSLT_CADENA.is_file():
+ raise SealingError(
+ f"No encuentro el XSLT de la cadena original en {_XSLT_CADENA}"
+ )
+ # Los includes del XSLT apuntan a rutas RELATIVAS locales (ver xslt/README.md):
+ # no hay resolución por red, ni aquí ni dentro de libxslt.
+ _transform = etree.XSLT(etree.parse(str(_XSLT_CADENA)))
+ return _transform
+
+
+def build_original_string(xml_bytes: bytes) -> str:
+ """Cadena original del comprobante, vía el XSLT oficial del SAT.
+
+ ``xml_bytes`` es el CFDI **sin** los atributos ``Sello``, ``NoCertificado`` ni
+ ``Certificado``: son justamente los que se calculan a partir de esta cadena.
+ """
+ try:
+ doc = etree.fromstring(xml_bytes)
+ except etree.XMLSyntaxError as exc:
+ raise SealingError(f"El XML del comprobante no es válido: {exc}") from exc
+
+ cadena = str(_get_transform()(doc))
+ # Una cadena vacía o sin los delimitadores significa que la transformación no aplicó ninguna
+ # plantilla — pasa si el XSLT perdió sus includes. Firmar eso daría un sello con pinta de
+ # correcto sobre nada, así que se corta aquí.
+ if not cadena.startswith("||") or not cadena.endswith("||"):
+ raise SealingError(
+ "La cadena original no tiene la forma esperada (debe abrir y cerrar con '||'). "
+ "Revisa los includes de xslt/cadenaoriginal_4_0.xslt."
+ )
+ return cadena
+
+
+def load_private_key(key_der: bytes, password: str) -> rsa.RSAPrivateKey:
+ """Carga la llave privada del CSD.
+
+ El ``.key`` que entrega el SAT es PKCS#8 **DER** cifrado con contraseña. Se acepta también
+ PEM para no obligar a convertir llaves que ya estén en ese formato.
+ """
+ if not password:
+ raise SealingError(
+ "No se configuró la contraseña de la llave privada del CSD (CSD_PASSWORD)."
+ )
+ clave = password.encode("utf-8")
+ try:
+ key = serialization.load_der_private_key(key_der, password=clave)
+ except ValueError:
+ try:
+ key = serialization.load_pem_private_key(key_der, password=clave)
+ except ValueError as exc:
+ # Mismo error para "archivo corrupto" y "contraseña incorrecta" porque la librería no
+ # los distingue; el mensaje nombra las dos causas para no mandar a nadie al lugar
+ # equivocado.
+ raise SealingError(
+ "No pude abrir la llave privada del CSD: el archivo no es una llave válida "
+ "o la contraseña es incorrecta."
+ ) from exc
+ if not isinstance(key, rsa.RSAPrivateKey):
+ raise SealingError("La llave privada del CSD no es RSA.")
+ return key
+
+
+def read_certificate(cer_bytes: bytes) -> tuple[str, str]:
+ """Devuelve ``(numero_de_certificado, certificado_base64)`` a partir del ``.cer``.
+
+ - El ``.cer`` del SAT es X.509 **DER**.
+ - El atributo ``Certificado`` del comprobante es el base64 de ese DER.
+ - El atributo ``NoCertificado`` son los 20 dígitos del número de serie. El SAT lo codifica
+ de forma que los bytes del serial son directamente sus caracteres ASCII, así que se
+ decodifica en vez de imprimirse en hexadecimal.
+ """
+ try:
+ cert = load_der_x509_certificate(cer_bytes)
+ der = cer_bytes
+ except ValueError:
+ try:
+ cert = load_pem_x509_certificate(cer_bytes)
+ der = cert.public_bytes(serialization.Encoding.DER)
+ except ValueError as exc:
+ raise SealingError("El archivo del certificado no es un X.509 válido (.cer).") from exc
+
+ serial_bytes = cert.serial_number.to_bytes((cert.serial_number.bit_length() + 7) // 8, "big")
+ try:
+ numero = serial_bytes.decode("ascii")
+ except UnicodeDecodeError as exc:
+ raise SealingError(
+ "El número de serie del certificado no tiene el formato del SAT "
+ "(sus bytes deben ser los 20 dígitos en ASCII)."
+ ) from exc
+ if len(numero) != 20 or not numero.isdigit():
+ raise SealingError(f"El número de certificado debe ser 20 dígitos; se obtuvo {numero!r}.")
+
+ return numero, base64.b64encode(der).decode("ascii")
+
+
+def sign(original_string: str, private_key: rsa.RSAPrivateKey) -> str:
+ """Sello del comprobante: RSA sobre SHA-256 de la cadena original, en base64.
+
+ SHA-256 y no SHA-1: SHA-1 sólo aplica al CFDI de retenciones con otros PAC (ver
+ ``CFDI.cs:8157`` del sistema legado). Para CFDI de ingreso con Comercio Digital es SHA-256.
+ """
+ firma = private_key.sign(
+ original_string.encode("utf-8"),
+ padding.PKCS1v15(),
+ hashes.SHA256(),
+ )
+ return base64.b64encode(firma).decode("ascii")
diff --git a/backend/api/v1/modules/fin/stamping/service.py b/backend/api/v1/modules/fin/stamping/service.py
new file mode 100644
index 0000000..8ab6a49
--- /dev/null
+++ b/backend/api/v1/modules/fin/stamping/service.py
@@ -0,0 +1,533 @@
+"""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)
diff --git a/backend/api/v1/modules/fin/stamping/xslt/README.md b/backend/api/v1/modules/fin/stamping/xslt/README.md
new file mode 100644
index 0000000..de6b11f
--- /dev/null
+++ b/backend/api/v1/modules/fin/stamping/xslt/README.md
@@ -0,0 +1,61 @@
+# XSLT de la cadena original — CFDI 4.0
+
+La cadena original es la secuencia de datos que se firma para producir el sello del comprobante.
+Sólo se obtiene aplicando la transformación oficial del SAT: no se construye a mano.
+
+| Archivo | Origen |
+|---|---|
+| `cadenaoriginal_4_0.xslt` | `http://www.sat.gob.mx/sitio_internet/cfd/4/cadenaoriginal_4_0/cadenaoriginal_4_0.xslt` |
+| `utilerias.xslt` | `http://www.sat.gob.mx/sitio_internet/cfd/2/cadenaoriginal_2_0/utilerias.xslt` |
+| `sin_complemento.xslt` | Nuestro. Marcador de posición, ver abajo. |
+
+## La única modificación: los `href` de los `xsl:include`
+
+El archivo del SAT trae 33 `xsl:include` apuntando a URLs de `sat.gob.mx`. **Se reescribieron los
+`href` a rutas relativas locales**; no se tocó ni una plantilla, ni un `xsl:template`, ni el orden
+de los campos.
+
+- El include de `utilerias.xslt` apunta al archivo local del mismo nombre.
+- Los otros 32, todos de complementos (Carta Porte, Comercio Exterior, Nómina, Pagos…), apuntan a
+ `sin_complemento.xslt`, que es un stylesheet vacío.
+
+**Por qué es inocuo:** cada XSLT de complemento sólo aporta plantillas que hacen `match` sobre nodos
+de su propio complemento. Este módulo emite CFDI 4.0 tipo ingreso **sin complementos**, así que esas
+plantillas nunca se invocan. Verificado: la cadena original que produce esta versión local es
+**idéntica, carácter a carácter**, a la que produce el archivo del SAT resolviendo los includes por
+red. Hay una prueba que lo fija en `backend/tests/test_fin_stamping.py`.
+
+## Por qué se hizo así, y no con un resolver
+
+Porque **`libxslt` sale a internet a resolver los `xsl:include` aunque el parser de lxml se cree con
+`no_network=True`**. Está comprobado: con el archivo original y sin resolver, la transformación
+completa funciona, lo que sólo es posible si descargó `utilerias.xslt` de `sat.gob.mx` en ese
+momento.
+
+Eso es inaceptable aquí por tres razones: el timbrado dependería de que `sat.gob.mx` esté arriba y
+responda rápido; la cadena original —el dato que se firma— vendría de una descarga no verificada en
+tiempo de ejecución; y un cambio silencioso en el servidor del SAT cambiaría los sellos sin que
+nadie lo note.
+
+Se intentó primero con un `etree.Resolver` personalizado, que es lo que hace el sistema legado
+(`CFDI.cs`, el bloque `SafeXsltResolver` comentado hacia la línea 8333). No funcionó: el resolver
+también intercepta la resolución del documento principal, y devolver un stylesheet vacío ahí deja la
+transformación sin plantillas y **la cadena original sale vacía, sin ningún error**. Un sello sobre
+una cadena vacía es un CFDI que el SAT rechaza — o peor, un sello que parece válido y no lo es.
+
+Con los `href` locales el problema desaparece de raíz: no hay nada que resolver fuera del directorio.
+
+## Cómo actualizar estos archivos
+
+1. Descarga el original del SAT (URLs de la tabla).
+2. Reescribe los `href` de los `xsl:include`: el de `utilerias.xslt` al archivo local, el resto a
+ `sin_complemento.xslt`.
+3. Corre `pytest tests/test_fin_stamping.py`. La prueba de la cadena original tiene que seguir
+ verde: si cambió el orden o el número de campos, el sello cambia y hay que revisarlo con Fiscal
+ antes de subir nada.
+
+## Si algún día se soporta un complemento
+
+Trae **su** XSLT oficial, déjalo en este directorio y apunta el `href` de ese include concreto al
+archivo real, en lugar de a `sin_complemento.xslt`. No basta con añadir el nodo al XML: sin su
+plantilla, el complemento no entra en la cadena original y el sello sale mal.
diff --git a/backend/api/v1/modules/fin/stamping/xslt/cadenaoriginal_4_0.xslt b/backend/api/v1/modules/fin/stamping/xslt/cadenaoriginal_4_0.xslt
new file mode 100644
index 0000000..29269c6
--- /dev/null
+++ b/backend/api/v1/modules/fin/stamping/xslt/cadenaoriginal_4_0.xslt
@@ -0,0 +1,409 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |||
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/backend/api/v1/modules/fin/stamping/xslt/sin_complemento.xslt b/backend/api/v1/modules/fin/stamping/xslt/sin_complemento.xslt
new file mode 100644
index 0000000..80c2331
--- /dev/null
+++ b/backend/api/v1/modules/fin/stamping/xslt/sin_complemento.xslt
@@ -0,0 +1,14 @@
+
+
+
diff --git a/backend/api/v1/modules/fin/stamping/xslt/utilerias.xslt b/backend/api/v1/modules/fin/stamping/xslt/utilerias.xslt
new file mode 100644
index 0000000..4ae4bf4
--- /dev/null
+++ b/backend/api/v1/modules/fin/stamping/xslt/utilerias.xslt
@@ -0,0 +1,22 @@
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
diff --git a/backend/core/config.py b/backend/core/config.py
index 222e5f3..6c54f38 100644
--- a/backend/core/config.py
+++ b/backend/core/config.py
@@ -116,6 +116,24 @@ class Settings(BaseSettings):
S3_FILE_STORAGE: bool = True
S3_PRESIGNED_EXPIRES_SECONDS: int = 3600
+ # ----- PAC Comercio Digital (timbrado CFDI) -----
+ # El modo NO se configura aquí: vive en fin.invoices.stamping_mode, por factura. Esta
+ # variable sólo define con qué valor nacen las facturas que no lo especifican.
+ PAC_DEFAULT_MODE: Literal["pruebas", "produccion"] = "pruebas"
+ PAC_HOST_TEST: str = "pruebas.comercio-digital.mx"
+ PAC_HOST_PROD: str = "ws.comercio-digital.mx"
+ PAC_USER: str = "" # header usrws
+ PAC_PASSWORD: str = "" # header pwdws
+ PAC_TIMEOUT_SECONDS: int = 15
+ PAC_NOTIFICATION_EMAIL: str = "" # header email, opcional
+ # Clave maestra que cifra las contraseñas de los CSD guardadas en la base (ver core/crypto).
+ # Sin ella no se pueden cargar certificados. Se genera con:
+ # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
+ CSD_ENCRYPTION_KEY: str = ""
+ # Contraseña global del CSD. LEGADO: sólo se usa como respaldo si una empresa no tiene su
+ # propia contraseña guardada. Lo correcto es cargar el CSD por empresa.
+ CSD_PASSWORD: str = ""
+
# ── EFC (expediente electrónico) ────────────────────────────────────────────────────────
# Carril CRM -> EFC: los documentos del CRM se resguardan en el expediente de EFC.
# Los nombres son los MISMOS que usa el gateway de Anexo22 contra el mismo EFC: inventar
diff --git a/backend/core/crypto.py b/backend/core/crypto.py
new file mode 100644
index 0000000..dd1ec75
--- /dev/null
+++ b/backend/core/crypto.py
@@ -0,0 +1,77 @@
+"""Cifrado simétrico de secretos que hay que guardar y volver a leer.
+
+Se usa hoy para la contraseña de la llave privada del CSD: el timbrado necesita abrir el
+``.key`` sin que haya nadie tecleando, así que la contraseña tiene que estar en reposo. Un
+hash no sirve — habría que recuperar el valor original, no compararlo.
+
+**La clave maestra vive en el entorno** (``CSD_ENCRYPTION_KEY``), nunca en la base ni en el
+repositorio. Quien tenga a la vez la base de datos y esa variable puede descifrar los secretos:
+esa es la propiedad que da el diseño y conviene tenerla presente al decidir quién ve qué.
+
+Para generar una clave nueva::
+
+ python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
+"""
+
+from cryptography.fernet import Fernet, InvalidToken
+
+from core.config import settings
+
+
+class SecretsNotConfigured(Exception):
+ """No hay clave maestra configurada, así que no se puede cifrar ni descifrar."""
+
+
+class SecretDecryptionError(Exception):
+ """El valor guardado no se pudo descifrar con la clave maestra actual."""
+
+
+def _fernet() -> Fernet:
+ clave = (settings.CSD_ENCRYPTION_KEY or "").strip()
+ if not clave:
+ raise SecretsNotConfigured(
+ "Falta CSD_ENCRYPTION_KEY. Genérala con "
+ '`python -c "from cryptography.fernet import Fernet; '
+ 'print(Fernet.generate_key().decode())"` y ponla en el .env.'
+ )
+ try:
+ return Fernet(clave.encode("utf-8"))
+ except (ValueError, TypeError) as exc:
+ raise SecretsNotConfigured(
+ "CSD_ENCRYPTION_KEY no es una clave Fernet válida (32 bytes en base64 url-safe)."
+ ) from exc
+
+
+def encrypt_secret(value: str) -> str:
+ """Cifra un secreto. Devuelve el token en texto, listo para guardar en una columna."""
+ if not value:
+ raise ValueError("no se cifra un secreto vacío")
+ return _fernet().encrypt(value.encode("utf-8")).decode("ascii")
+
+
+def decrypt_secret(token: str) -> str:
+ """Recupera el secreto original.
+
+ Falla con ``SecretDecryptionError`` si la clave maestra cambió o el dato está corrupto.
+ Se distingue de ``SecretsNotConfigured`` a propósito: una dice "configura la variable" y la
+ otra "la variable no es la que cifró este dato", y confundirlas manda a buscar al lugar
+ equivocado.
+ """
+ if not token:
+ raise SecretDecryptionError("no hay secreto guardado")
+ try:
+ return _fernet().decrypt(token.encode("ascii")).decode("utf-8")
+ except InvalidToken as exc:
+ raise SecretDecryptionError(
+ "No pude descifrar el secreto guardado: la clave maestra no corresponde con la que "
+ "se usó al guardarlo, o el dato está dañado. Hay que volver a capturarlo."
+ ) from exc
+
+
+def secrets_available() -> bool:
+ """``True`` si hay clave maestra utilizable. Para avisar en la UI antes de pedir el dato."""
+ try:
+ _fernet()
+ return True
+ except SecretsNotConfigured:
+ return False
diff --git a/backend/core/s3_keys.py b/backend/core/s3_keys.py
index 6959336..3163be9 100644
--- a/backend/core/s3_keys.py
+++ b/backend/core/s3_keys.py
@@ -285,6 +285,55 @@ def doda_report_pdf_key(
return f"{tenant_company_prefix(tenant_id, company_id)}doda/{did}/report/doda_report.pdf"
+def invoice_stamp_xml_key(
+ tenant_id: Union[int, str],
+ company_id: int,
+ invoice_id: int,
+ uuid: str,
+) -> str:
+ """
+ XML timbrado bajo ``.../fin-invoices/{invoice_id}/stamps/{uuid}.xml``.
+
+ La clave lleva el UUID y no un timestamp: el UUID identifica el comprobante ante el SAT y
+ no cambia, así que la clave es estable y un retimbrado accidental no puede sobrescribir el
+ XML de otro comprobante.
+ """
+ iid = _segment(invoice_id, "invoice_id")
+ # El UUID va por _segment para que no pueda colar separadores de ruta.
+ u = _segment(uuid, "uuid")
+ return f"{tenant_company_prefix(tenant_id, company_id)}fin-invoices/{iid}/stamps/{u}.xml"
+
+
+STAMP_XML_KINDS = ("request", "response")
+
+
+def invoice_stamp_attempt_xml_key(
+ tenant_id: Union[int, str],
+ company_id: int,
+ invoice_id: int,
+ attempt_id: int,
+ kind: str,
+) -> str:
+ """
+ XML de un **intento** de timbrado, bajo
+ ``.../fin-invoices/{invoice_id}/stamps/attempts/{attempt_id}-{kind}.xml``.
+
+ ``kind`` es ``request`` (lo que se transmitió al PAC) o ``response`` (lo que contestó).
+
+ La clave va por ``attempt_id`` y no por UUID porque un intento rechazado no tiene UUID, y
+ es justo el rechazado el que hay que poder reconstruir: sin el par enviado/recibido, un
+ error del PAC no se puede diagnosticar después de que termine la petición.
+ """
+ if kind not in STAMP_XML_KINDS:
+ raise ValueError(f"kind inválido: {kind!r}. Sólo se admiten {STAMP_XML_KINDS}.")
+ iid = _segment(invoice_id, "invoice_id")
+ aid = _segment(attempt_id, "attempt_id")
+ return (
+ f"{tenant_company_prefix(tenant_id, company_id)}"
+ f"fin-invoices/{iid}/stamps/attempts/{aid}-{kind}.xml"
+ )
+
+
def company_certificate_key(
tenant_id: Union[int, str],
company_id: int,
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 0001d2f..6934f42 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -51,6 +51,15 @@ celery==5.3.6
redis==5.0.1
flower==2.0.1
+# CFDI / timbrado
+# lxml: la cadena original del SAT sólo se obtiene aplicando el XSLT 1.0 oficial;
+# xml.etree de la stdlib no hace XSLT y no hay otra implementación madura en Python.
+# cryptography: lee el .key del CSD (PKCS#8 DER cifrado) y el .cer (X.509 DER), y firma
+# RSA-SHA256. Ya entraba de forma transitiva por python-jose[cryptography]; aquí pasa a
+# ser dependencia directa, así que se declara.
+lxml==5.3.0
+cryptography==43.0.3
+
# Barcode
pdf417gen==0.8.1
asgiref==3.8.1
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index b428706..7d2a646 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -44,6 +44,7 @@ import api.v1.modules.fin.catalogs.models # noqa: E402,F401
import api.v1.modules.fin.concepts.models # noqa: E402,F401
import api.v1.modules.fin.issuer.models # noqa: E402,F401
import api.v1.modules.fin.invoices.models # noqa: E402,F401
+import api.v1.modules.fin.stamping.models # noqa: E402,F401
from api.v1.modules.fin.catalogs.seed_data import sync_catalogs # noqa: E402
_SCHEMA_MAP = {"crm": None, "core": None, "ops": None, "fin": None, "sat": None}
diff --git a/backend/tests/test_fin_csd.py b/backend/tests/test_fin_csd.py
new file mode 100644
index 0000000..b959034
--- /dev/null
+++ b/backend/tests/test_fin_csd.py
@@ -0,0 +1,141 @@
+"""Pruebas del cifrado de secretos y de la validación del CSD.
+
+Nada sale a la red ni toca MinIO: la validación del par ``.cer``/``.key`` es criptografía
+pura, y es justo la parte que importa comprobar.
+"""
+
+import datetime
+
+import pytest
+from cryptography import x509
+from cryptography.fernet import Fernet
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.x509.oid import NameOID
+from fastapi import HTTPException
+
+from api.v1.modules.fin.issuer import csd_service
+from core import crypto
+
+CERT_NUMBER = "00001000000700000001"
+PASSWORD = "12345678a"
+
+
+def _par(cert_number: str = CERT_NUMBER, password: str = PASSWORD):
+ """Genera un CSD de juguete con el formato del SAT: (cer_der, key_der_cifrada)."""
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+ nombre = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "EMISOR DE PRUEBA")])
+ cert = (
+ x509.CertificateBuilder()
+ .subject_name(nombre)
+ .issuer_name(nombre)
+ .public_key(key.public_key())
+ .serial_number(int.from_bytes(cert_number.encode("ascii"), "big"))
+ .not_valid_before(datetime.datetime(2026, 1, 1))
+ .not_valid_after(datetime.datetime(2035, 1, 1))
+ .sign(key, hashes.SHA256())
+ )
+ return (
+ cert.public_bytes(serialization.Encoding.DER),
+ key.private_bytes(
+ serialization.Encoding.DER,
+ serialization.PrivateFormat.PKCS8,
+ serialization.BestAvailableEncryption(password.encode()),
+ ),
+ )
+
+
+# ---------------------------------------------------------------------------------------
+# Cifrado de secretos
+# ---------------------------------------------------------------------------------------
+@pytest.fixture
+def clave_maestra(monkeypatch):
+ clave = Fernet.generate_key().decode()
+ monkeypatch.setattr(crypto.settings, "CSD_ENCRYPTION_KEY", clave, raising=False)
+ return clave
+
+
+def test_cifrar_y_recuperar(clave_maestra):
+ token = crypto.encrypt_secret(PASSWORD)
+ assert token != PASSWORD, "el secreto no puede quedar en claro"
+ assert crypto.decrypt_secret(token) == PASSWORD
+
+
+def test_dos_cifrados_del_mismo_valor_no_son_iguales(clave_maestra):
+ """Fernet incluye IV y timestamp: dos tokens distintos para el mismo dato.
+
+ Importa porque si fueran iguales, comparar columnas revelaría qué empresas comparten
+ contraseña.
+ """
+ assert crypto.encrypt_secret(PASSWORD) != crypto.encrypt_secret(PASSWORD)
+
+
+def test_sin_clave_maestra_no_se_cifra(monkeypatch):
+ monkeypatch.setattr(crypto.settings, "CSD_ENCRYPTION_KEY", "", raising=False)
+ assert crypto.secrets_available() is False
+ with pytest.raises(crypto.SecretsNotConfigured):
+ crypto.encrypt_secret(PASSWORD)
+
+
+def test_clave_maestra_invalida_se_detecta(monkeypatch):
+ monkeypatch.setattr(crypto.settings, "CSD_ENCRYPTION_KEY", "no-es-una-clave", raising=False)
+ with pytest.raises(crypto.SecretsNotConfigured):
+ crypto.encrypt_secret(PASSWORD)
+
+
+def test_otra_clave_maestra_no_descifra(clave_maestra, monkeypatch):
+ """Rotar la clave maestra deja ilegibles los secretos: tiene que decirlo, no romperse raro."""
+ token = crypto.encrypt_secret(PASSWORD)
+ monkeypatch.setattr(
+ crypto.settings, "CSD_ENCRYPTION_KEY", Fernet.generate_key().decode(), raising=False
+ )
+ with pytest.raises(crypto.SecretDecryptionError):
+ crypto.decrypt_secret(token)
+
+
+def test_no_se_cifra_un_valor_vacio(clave_maestra):
+ with pytest.raises(ValueError):
+ crypto.encrypt_secret("")
+
+
+# ---------------------------------------------------------------------------------------
+# Validación del par .cer / .key
+# ---------------------------------------------------------------------------------------
+def test_par_correcto_devuelve_numero_de_certificado():
+ cer, key = _par()
+ assert csd_service._verify_pair(cer, key, PASSWORD) == CERT_NUMBER
+
+
+def test_key_de_otro_certificado_se_rechaza():
+ """El caso que motiva la validación: dos CSD mezclados.
+
+ Sin esto, el error aparecería hasta que el PAC rechace el comprobante, con un mensaje que
+ no menciona el certificado.
+ """
+ cer, _ = _par()
+ _, key_ajena = _par(cert_number="00001000000700000002")
+ with pytest.raises(HTTPException) as exc:
+ csd_service._verify_pair(cer, key_ajena, PASSWORD)
+ assert exc.value.status_code == 422
+ assert "no corresponde" in str(exc.value.detail)
+
+
+def test_contrasena_incorrecta_se_rechaza():
+ cer, key = _par()
+ with pytest.raises(HTTPException) as exc:
+ csd_service._verify_pair(cer, key, "incorrecta")
+ assert exc.value.status_code == 422
+
+
+def test_certificado_que_no_es_x509_se_rechaza():
+ _, key = _par()
+ with pytest.raises(HTTPException) as exc:
+ csd_service._verify_pair(b"esto no es un certificado", key, PASSWORD)
+ assert exc.value.status_code == 422
+
+
+def test_llave_que_no_es_una_llave_se_rechaza():
+ cer, _ = _par()
+ with pytest.raises(HTTPException) as exc:
+ csd_service._verify_pair(cer, b"esto no es una llave", PASSWORD)
+ assert exc.value.status_code == 422
diff --git a/backend/tests/test_fin_stamping.py b/backend/tests/test_fin_stamping.py
new file mode 100644
index 0000000..f17e608
--- /dev/null
+++ b/backend/tests/test_fin_stamping.py
@@ -0,0 +1,547 @@
+"""Pruebas del timbrado de CFDI 4.0 de ingreso. Ninguna sale a la red.
+
+El CSD es autofirmado y se genera en el fixture: para comprobar que *sellamos bien* basta con
+que el sello verifique contra la llave pública de su propio certificado, y así la suite no
+depende de descargar nada ni de que exista un CSD en disco. El CSD real de pruebas del SAT
+hace falta para timbrar contra el PAC de verdad, que es la prueba de integración aparte.
+"""
+
+import base64
+import datetime
+from decimal import Decimal
+
+import httpx
+import pytest
+from cryptography import x509
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import padding, rsa
+from cryptography.x509.oid import NameOID
+from lxml import etree
+
+from api.v1.modules.fin.stamping import cfdi_builder as B
+from api.v1.modules.fin.stamping import pac_comercio_digital as pac
+from api.v1.modules.fin.stamping import sealer, service
+from core import s3_keys
+
+CFDI_NS = "http://www.sat.gob.mx/cfd/4"
+CSD_PASSWORD = "12345678a"
+# Número de serie con el formato del SAT: 20 dígitos cuyos bytes son sus caracteres ASCII.
+CERT_NUMBER = "00001000000700000001"
+
+
+# ---------------------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------------------
+@pytest.fixture(scope="module")
+def csd():
+ """CSD autofirmado con el formato del SAT: devuelve (cer_der, key_der_cifrada)."""
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+ nombre = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "EMISOR DE PRUEBA")])
+ cert = (
+ x509.CertificateBuilder()
+ .subject_name(nombre)
+ .issuer_name(nombre)
+ .public_key(key.public_key())
+ .serial_number(int.from_bytes(CERT_NUMBER.encode("ascii"), "big"))
+ .not_valid_before(datetime.datetime(2026, 1, 1))
+ .not_valid_after(datetime.datetime(2035, 1, 1))
+ .sign(key, hashes.SHA256())
+ )
+ return (
+ cert.public_bytes(serialization.Encoding.DER),
+ key.private_bytes(
+ serialization.Encoding.DER,
+ serialization.PrivateFormat.PKCS8,
+ serialization.BestAvailableEncryption(CSD_PASSWORD.encode()),
+ ),
+ )
+
+
+def _data(**overrides) -> B.CfdiData:
+ """CFDI de ingreso completo y válido; los kwargs sustituyen campos para los casos negativos."""
+ base = dict(
+ folio="1001",
+ serie="A",
+ date="2026-08-07T10:00:00",
+ payment_form="03",
+ payment_method="PUE",
+ currency="MXN",
+ exchange_rate=None,
+ expedition_zip="64000",
+ payment_conditions=None,
+ issuer_rfc="EKU9003173C9",
+ issuer_name="EMISOR DE PRUEBA",
+ issuer_tax_regime="601",
+ receiver_rfc="XAXX010101000",
+ receiver_name="PUBLICO EN GENERAL",
+ receiver_zip="64000",
+ receiver_tax_regime="616",
+ receiver_cfdi_use="S01",
+ concepts=[
+ B.ConceptLine(
+ product_service_code="78101800",
+ unit_code="E48",
+ description="Flete maritimo",
+ quantity=Decimal("1"),
+ unit_price=Decimal("1000.00"),
+ tax_object="02",
+ taxes=[B.TaxLine(code="002", rate=Decimal("0.160000"), amount=Decimal("160.00"))],
+ )
+ ],
+ )
+ base.update(overrides)
+ return B.CfdiData(**base)
+
+
+def _xml(csd, data=None) -> bytes:
+ numero, cert_b64 = sealer.read_certificate(csd[0])
+ return B.build_xml(data or _data(), cert_number=numero, cert_b64=cert_b64)
+
+
+# ---------------------------------------------------------------------------------------
+# Construcción del XML
+# ---------------------------------------------------------------------------------------
+def test_comprobante_tipo_ingreso_con_atributos_obligatorios(csd):
+ root = etree.fromstring(_xml(csd))
+ assert root.tag == f"{{{CFDI_NS}}}Comprobante"
+ assert root.get("Version") == "4.0"
+ assert root.get("TipoDeComprobante") == "I"
+ assert root.get("Exportacion") == "01"
+ assert root.get("SubTotal") == "1000.00"
+ assert root.get("Total") == "1160.00"
+
+
+def test_declaracion_xml_con_comillas_dobles(csd):
+ """Comercio Digital compara la cadena literal version="1.0".
+
+ lxml emitiría comillas simples —válido según la especificación, pero su validador lo
+ rechaza con el código 642 "la versión del XML no es 1.0"—, así que la declaración se
+ antepone a mano y tiene que sobrevivir también al sellado, que es lo que se transmite.
+ """
+ esperado = b''
+ xml = _xml(csd)
+ assert xml.startswith(esperado)
+ assert B.apply_seal(xml, "SELLO-DE-PRUEBA").startswith(esperado)
+
+
+def test_fecha_sin_desplazamiento_horario(csd):
+ """En CFDI 4.0 la fecha va sin offset; el '-06:00' era de 3.3 (CFDI.cs:12654)."""
+ fecha = etree.fromstring(_xml(csd)).get("Fecha")
+ assert fecha == "2026-08-07T10:00:00"
+ assert "+" not in fecha and not fecha.endswith("Z")
+
+
+def test_orden_de_atributos_del_comprobante(csd):
+ """El orden importa: la cadena original —y con ella el sello— se calcula recorriéndolo."""
+ root = etree.fromstring(_xml(csd))
+ orden = [k for k in root.attrib if not k.startswith("{")]
+ esperado = [
+ "Version",
+ "Serie",
+ "Folio",
+ "Fecha",
+ "Sello",
+ "FormaPago",
+ "NoCertificado",
+ "Certificado",
+ "SubTotal",
+ "Moneda",
+ "Total",
+ "TipoDeComprobante",
+ "Exportacion",
+ "MetodoPago",
+ "LugarExpedicion",
+ ]
+ assert orden == esperado
+
+
+def test_atributos_opcionales_se_omiten(csd):
+ root = etree.fromstring(_xml(csd, _data(serie=None, folio=None, payment_conditions=None)))
+ assert "Serie" not in root.attrib
+ assert "CondicionesDePago" not in root.attrib
+ # Moneda MXN: sin TipoCambio
+ assert "TipoCambio" not in root.attrib
+
+
+def test_moneda_extranjera_exige_tipo_de_cambio():
+ with pytest.raises(B.CfdiBuildError) as exc:
+ B.build_xml(_data(currency="USD", exchange_rate=None))
+ assert any("tipo de cambio" in m for m in exc.value.missing)
+
+
+def test_impuestos_del_concepto_y_totales(csd):
+ root = etree.fromstring(_xml(csd))
+ traslado = root.find(
+ f"{{{CFDI_NS}}}Conceptos/{{{CFDI_NS}}}Concepto/{{{CFDI_NS}}}Impuestos"
+ f"/{{{CFDI_NS}}}Traslados/{{{CFDI_NS}}}Traslado"
+ )
+ assert traslado.get("Base") == "1000.00"
+ assert traslado.get("Impuesto") == "002"
+ assert traslado.get("TipoFactor") == "Tasa"
+ assert traslado.get("TasaOCuota") == "0.160000" # el SAT exige 6 decimales
+ assert traslado.get("Importe") == "160.00"
+
+ totales = root.find(f"{{{CFDI_NS}}}Impuestos")
+ assert totales.get("TotalImpuestosTrasladados") == "160.00"
+
+
+def test_retenciones_restan_del_total():
+ data = _data()
+ data.concepts[0].taxes.append(
+ B.TaxLine(
+ code="001", rate=Decimal("0.100000"), amount=Decimal("100.00"), is_withholding=True
+ )
+ )
+ # 1000 + 160 - 100
+ assert data.total == Decimal("1060.00")
+
+
+def test_partida_objeto_de_impuesto_sin_impuestos_es_error():
+ """ObjetoImp '02' obliga al desglose. No se inventa una tasa por defecto."""
+ data = _data()
+ data.concepts[0].taxes = []
+ with pytest.raises(B.CfdiBuildError) as exc:
+ B.build_xml(data)
+ assert any("objeto de impuesto" in m for m in exc.value.missing)
+
+
+def test_validacion_reporta_todos_los_faltantes_juntos():
+ """Quien captura la factura necesita la lista completa, no descubrirlos de uno en uno."""
+ data = _data(issuer_rfc="", receiver_rfc="", payment_form="", expedition_zip="")
+ with pytest.raises(B.CfdiBuildError) as exc:
+ B.build_xml(data)
+ assert len(exc.value.missing) >= 4
+
+
+def test_importes_con_decimal_no_arrastran_error_de_punto_flotante():
+ data = _data()
+ data.concepts[0].quantity = Decimal("3")
+ data.concepts[0].unit_price = Decimal("0.10")
+ assert data.concepts[0].amount == Decimal("0.30")
+
+
+# ---------------------------------------------------------------------------------------
+# Cadena original y sello
+# ---------------------------------------------------------------------------------------
+def test_cadena_original_delimitada(csd):
+ cadena = sealer.build_original_string(_xml(csd))
+ assert cadena.startswith("||") and cadena.endswith("||")
+ assert "|4.0|A|1001|" in cadena
+
+
+def test_cadena_original_no_descarga_nada():
+ """Los includes del XSLT tienen que ser locales: libxslt sale a la red si son URLs.
+
+ Sin esto, la cadena original —el dato que se firma— vendría de una descarga no verificada
+ en tiempo de ejecución, y el timbrado dependería de que sat.gob.mx responda.
+ """
+ from pathlib import Path
+
+ xslt = Path(sealer._XSLT_CADENA)
+ contenido = xslt.read_text(encoding="utf-8")
+ assert 'href="http' not in contenido, "el XSLT conserva includes remotos"
+
+
+def test_numero_de_certificado_entra_en_la_cadena(csd):
+ """Si NoCertificado se rellenara después de firmar, el sello no verificaría."""
+ numero, _ = sealer.read_certificate(csd[0])
+ assert numero in sealer.build_original_string(_xml(csd))
+
+
+def test_certificado_da_numero_de_20_digitos_y_base64(csd):
+ numero, cert_b64 = sealer.read_certificate(csd[0])
+ assert numero == CERT_NUMBER
+ assert len(numero) == 20 and numero.isdigit()
+ assert base64.b64decode(cert_b64) == csd[0]
+
+
+def test_sello_verifica_contra_la_llave_publica_del_certificado(csd):
+ """La prueba fuerte del sellado: si esto pasa, sellamos como espera el SAT."""
+ cer_der, key_der = csd
+ xml = _xml(csd)
+ cadena = sealer.build_original_string(xml)
+ sello = sealer.sign(cadena, sealer.load_private_key(key_der, CSD_PASSWORD))
+
+ x509.load_der_x509_certificate(cer_der).public_key().verify(
+ base64.b64decode(sello), cadena.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256()
+ )
+
+
+def test_sello_no_verifica_si_la_cadena_cambia(csd):
+ """Contraparte de la anterior: un verde que no se ve fallar no vale."""
+ cer_der, key_der = csd
+ cadena = sealer.build_original_string(_xml(csd))
+ sello = sealer.sign(cadena, sealer.load_private_key(key_der, CSD_PASSWORD))
+
+ with pytest.raises(Exception):
+ x509.load_der_x509_certificate(cer_der).public_key().verify(
+ base64.b64decode(sello),
+ (cadena + " ").encode("utf-8"),
+ padding.PKCS1v15(),
+ hashes.SHA256(),
+ )
+
+
+def test_sellar_no_altera_la_cadena_original(csd):
+ """El Sello no entra en la cadena: insertarlo no puede cambiarla."""
+ xml = _xml(csd)
+ antes = sealer.build_original_string(xml)
+ firmado = B.apply_seal(xml, "SELLO-DE-PRUEBA")
+ assert sealer.build_original_string(firmado) == antes
+ assert etree.fromstring(firmado).get("Sello") == "SELLO-DE-PRUEBA"
+
+
+def test_contrasena_incorrecta_da_error_claro(csd):
+ with pytest.raises(sealer.SealingError) as exc:
+ sealer.load_private_key(csd[1], "incorrecta")
+ assert "contraseña" in str(exc.value).lower()
+
+
+def test_sin_contrasena_no_se_intenta_firmar(csd):
+ with pytest.raises(sealer.SealingError):
+ sealer.load_private_key(csd[1], "")
+
+
+def test_sellar_sin_numero_de_certificado_es_error():
+ xml = B.build_xml(_data(), cert_number="", cert_b64="")
+ with pytest.raises(B.CfdiBuildError):
+ B.apply_seal(xml, "SELLO")
+
+
+# ---------------------------------------------------------------------------------------
+# Cliente del PAC — resolución de host
+# ---------------------------------------------------------------------------------------
+HOST_TEST = "pruebas.comercio-digital.mx"
+HOST_PROD = "ws.comercio-digital.mx"
+
+
+def test_host_se_deriva_del_modo():
+ assert pac.resolve_host("pruebas", HOST_TEST, HOST_PROD) == HOST_TEST
+ assert pac.resolve_host("produccion", HOST_TEST, HOST_PROD) == HOST_PROD
+
+
+@pytest.mark.parametrize("modo", ["", "prod", "PRUEBAS", "producción", None])
+def test_modo_invalido_no_cae_a_ningun_host(modo):
+ """El legado, con host vacío, caía silenciosamente a pruebas (CFDI.cs:19288)."""
+ with pytest.raises(pac.PacConfigError):
+ pac.resolve_host(modo, HOST_TEST, HOST_PROD)
+
+
+def test_el_host_no_se_puede_inyectar_por_http():
+ """`stamp` no acepta host ni URL: sólo el modo. Es la regla 1 de §4.5 del plan."""
+ import inspect
+
+ params = set(inspect.signature(pac.stamp).parameters)
+ assert "url" not in params
+ assert params & {"host_test", "host_prod"} == {"host_test", "host_prod"}
+ # host_test/host_prod son configuración del servidor, no entrada de la petición: el
+ # endpoint los toma de settings y nunca del cuerpo (ver routes.stamp_invoice).
+
+
+# ---------------------------------------------------------------------------------------
+# Cliente del PAC — casos de error, contra un doble
+# ---------------------------------------------------------------------------------------
+XML_VALIDO = b"" + b"a" * 300 + b""
+
+
+def _stamp(monkeypatch, *, respuesta=None, excepcion=None, **kwargs):
+ """Ejecuta pac.stamp con httpx.post sustituido por un doble."""
+
+ def falso_post(url, content=None, headers=None, timeout=None):
+ falso_post.llamadas.append({"url": url, "headers": headers, "content": content})
+ if excepcion:
+ raise excepcion
+ return respuesta
+
+ falso_post.llamadas = []
+ monkeypatch.setattr(httpx, "post", falso_post)
+ opciones = dict(
+ mode="pruebas",
+ user="SCT050708AD1",
+ password="secreto",
+ host_test=HOST_TEST,
+ host_prod=HOST_PROD,
+ )
+ opciones.update(kwargs)
+ return pac.stamp(XML_VALIDO, **opciones), falso_post.llamadas
+
+
+def _respuesta(status_code=200, headers=None, text=""):
+ return httpx.Response(
+ status_code=status_code,
+ headers=headers or {},
+ text=text,
+ request=httpx.Request("POST", "https://x/timbre4/timbrarV5"),
+ )
+
+
+def test_error_701_usuario_invalido(monkeypatch):
+ res, llamadas = _stamp(monkeypatch, user="corto")
+ assert res.ok is False and res.code == 701
+ assert llamadas == [], "no debe salir a la red con el usuario inválido"
+
+
+def test_error_702_password_vacio(monkeypatch):
+ res, llamadas = _stamp(monkeypatch, password="")
+ assert res.ok is False and res.code == 702
+ assert llamadas == []
+
+
+def test_error_711_xml_demasiado_corto(monkeypatch):
+ monkeypatch.setattr(httpx, "post", lambda *a, **k: pytest.fail("no debe llamar al PAC"))
+ res = pac.stamp(
+ b"",
+ mode="pruebas",
+ user="SCT050708AD1",
+ password="x",
+ host_test=HOST_TEST,
+ host_prod=HOST_PROD,
+ )
+ assert res.ok is False and res.code == 711
+
+
+def test_error_833_fallo_de_red(monkeypatch):
+ res, _ = _stamp(monkeypatch, excepcion=httpx.ConnectError("sin ruta al host"))
+ assert res.ok is False and res.code == 833
+
+
+def test_error_998_http_distinto_de_200(monkeypatch):
+ res, _ = _stamp(monkeypatch, respuesta=_respuesta(status_code=500, text=""))
+ # El cuerpo se conserva: es lo que se guarda como XML de respuesta del intento.
+ assert res.xml == ""
+ assert res.ok is False and res.code == 998
+
+
+def test_timbrado_correcto_lee_codigo_y_saldo(monkeypatch):
+ """Los dos valores que el legado perdía siempre (CFDI.cs:19324-19336)."""
+ res, llamadas = _stamp(
+ monkeypatch,
+ respuesta=_respuesta(
+ headers={"uuid": "ABC-123", "codigo": "0", "saldo": "4821", "errmsg": ""}
+ ),
+ )
+ assert res.ok is True
+ assert res.uuid == "ABC-123"
+ assert res.code == 0, "el código del PAC no se está leyendo"
+ assert res.balance == 4821, "el saldo de folios no se está leyendo"
+ assert llamadas[0]["url"] == f"https://{HOST_TEST}/timbre4/timbrarV5"
+
+
+def test_errmsg_con_contenido_es_error(monkeypatch):
+ res, _ = _stamp(
+ monkeypatch,
+ respuesta=_respuesta(headers={"errmsg": "307 CFDI previamente timbrado", "codigo": "307"}),
+ )
+ assert res.ok is False
+ assert res.code == 307
+ assert "307" in res.error_message
+
+
+def test_respuesta_sin_uuid_no_es_exito(monkeypatch):
+ """200 sin errmsg y sin UUID no es un comprobante timbrado."""
+ res, _ = _stamp(monkeypatch, respuesta=_respuesta(headers={"errmsg": "", "codigo": "0"}))
+ assert res.ok is False
+ assert "UUID" in res.error_message
+
+
+def test_cabecera_no_numerica_no_rompe_el_timbrado(monkeypatch):
+ res, _ = _stamp(
+ monkeypatch,
+ respuesta=_respuesta(headers={"uuid": "U-1", "codigo": "n/d", "saldo": ""}),
+ )
+ assert res.ok is True
+ assert res.code is None and res.balance is None
+
+
+def test_cabeceras_y_cuerpo_de_la_peticion(monkeypatch):
+ res, llamadas = _stamp(
+ monkeypatch,
+ respuesta=_respuesta(headers={"uuid": "U-1"}),
+ email="Avisos@Ejemplo.MX",
+ )
+ enviado = llamadas[0]
+ assert enviado["headers"]["tipo"] == "XML"
+ assert enviado["headers"]["Content-Type"] == "text/plain"
+ assert enviado["headers"]["email"] == "avisos@ejemplo.mx" # el PAC lo exige en minúsculas
+ assert enviado["content"] == XML_VALIDO, "el XML va en crudo, ni base64 ni SOAP"
+
+
+def test_modo_produccion_apunta_al_host_de_produccion(monkeypatch):
+ """Se ejercita SOLO contra el doble: ninguna prueba transmite a producción."""
+ _, llamadas = _stamp(
+ monkeypatch, mode="produccion", respuesta=_respuesta(headers={"uuid": "U-1"})
+ )
+ assert llamadas[0]["url"] == f"https://{HOST_PROD}/timbre4/timbrarV5"
+
+
+# ---------------------------------------------------------------------------------------
+# Rastro del intento: XML enviado y recibido
+# ---------------------------------------------------------------------------------------
+class _StampFalso:
+ """Lo mínimo que _store_attempt_xml necesita de un InvoiceStamp, sin tocar la BD."""
+
+ def __init__(self):
+ self.tenant_id, self.company_id, self.invoice_id, self.id = 7, 3, 41, 9
+ self.request_xml_file_key = None
+ self.response_xml_file_key = None
+
+
+@pytest.fixture
+def subidas(monkeypatch):
+ """Captura lo que se sube al almacenamiento en vez de escribir en MinIO."""
+ from core import storage_s3
+
+ hechas: list[tuple[str, bytes]] = []
+
+ def falso_put(key, body, content_type=None):
+ hechas.append((key, body))
+
+ monkeypatch.setattr(storage_s3, "put_object_bytes", falso_put)
+ return hechas
+
+
+def test_clave_del_xml_del_intento_lleva_el_id_del_intento():
+ """Por id de intento y no por UUID: un intento rechazado no tiene UUID."""
+ key = s3_keys.invoice_stamp_attempt_xml_key(7, 3, 41, 9, "request")
+ assert key.endswith("fin-invoices/41/stamps/attempts/9-request.xml")
+
+
+def test_clave_del_xml_del_intento_rechaza_tipos_desconocidos():
+ with pytest.raises(ValueError):
+ s3_keys.invoice_stamp_attempt_xml_key(7, 3, 41, 9, "borrador")
+
+
+def test_se_guardan_los_dos_xml_del_intento(subidas):
+ stamp = _StampFalso()
+ service._store_attempt_xml(stamp, b"", "")
+
+ assert [cuerpo for _, cuerpo in subidas] == [b"", b""]
+ assert stamp.request_xml_file_key.endswith("9-request.xml")
+ assert stamp.response_xml_file_key.endswith("9-response.xml")
+
+
+def test_sin_respuesta_del_pac_se_guarda_al_menos_lo_enviado(subidas):
+ """Un fallo de red corta antes de que el PAC conteste: el envío sigue siendo el dato útil."""
+ stamp = _StampFalso()
+ service._store_attempt_xml(stamp, b"", "")
+
+ assert [cuerpo for _, cuerpo in subidas] == [b""]
+ assert stamp.request_xml_file_key is not None
+ assert stamp.response_xml_file_key is None
+
+
+def test_fallo_del_almacenamiento_no_tumba_el_timbrado(monkeypatch):
+ """El rastro es para diagnóstico: perderlo no puede invalidar un timbre que el SAT ya dio
+ por bueno, ni tapar el error del PAC con uno de almacenamiento."""
+ from core import storage_s3
+
+ def revienta(*_a, **_k):
+ raise RuntimeError("MinIO no responde")
+
+ monkeypatch.setattr(storage_s3, "put_object_bytes", revienta)
+
+ stamp = _StampFalso()
+ service._store_attempt_xml(stamp, b"", "") # no propaga
+
+ assert stamp.request_xml_file_key is None
+ assert stamp.response_xml_file_key is None
diff --git a/backend/tests/test_invoices.py b/backend/tests/test_invoices.py
index 5ac976e..d9ff242 100644
--- a/backend/tests/test_invoices.py
+++ b/backend/tests/test_invoices.py
@@ -5,7 +5,13 @@ from api.v1.modules.crm.accounts.dto import AccountCreate
from api.v1.modules.crm.quotes import service as quotes_service
from api.v1.modules.crm.quotes.dto import QuoteCreate, QuoteItemCreate
from api.v1.modules.fin.invoices import service
-from api.v1.modules.fin.invoices.dto import InvoiceCreate, InvoiceItemCreate, PaymentCreate
+from api.v1.modules.fin.invoices.dto import (
+ InvoiceCreate,
+ InvoiceItemCreate,
+ InvoiceItemUpdate,
+ InvoiceUpdate,
+ PaymentCreate,
+)
from api.v1.modules.ops.shipments import service as shipments_service
from api.v1.modules.ops.shipments.dto import ShipmentCloseInput, ShipmentCreate
@@ -55,3 +61,116 @@ def test_generate_from_shipment_copies_quote_items(db):
items = service.get_items(db, inv.id, T, C)
assert len(items) == 1 and float(items[0].unit_amount) == 1500.0
assert float(inv.subtotal) == 1500.0
+
+
+# ----- Impuestos derivados por partida (para el CFDI) -----
+# El CFDI exige el desglose por partida, pero la factura captura un % global. El traslado de
+# IVA se deriva de ese %; estas pruebas fijan que la derivación no invente ni borre nada.
+
+def _iva_de(db, item_id):
+ from api.v1.modules.fin.invoices.models import InvoiceItemTax
+ return db.query(InvoiceItemTax).filter(
+ InvoiceItemTax.invoice_item_id == item_id, InvoiceItemTax.deleted_at.is_(None)
+ ).all()
+
+
+def _obj_imp(db, code):
+ from api.v1.modules.fin.catalogs.models import TaxObject
+ return db.query(TaxObject).filter(TaxObject.code == code).first().id
+
+
+def test_iva_se_deriva_cuando_la_partida_es_objeto_de_impuesto(db):
+ inv = service.create_invoice(db, InvoiceCreate(reference="F-IVA", tax_rate=Decimal("16")), T, C)
+ item = service.create_item(
+ db,
+ InvoiceItemCreate(invoice_id=inv.id, concept="flete_internacional", quantity=1,
+ unit_amount=1000, tax_object_id=_obj_imp(db, "02")),
+ T, C,
+ )
+ taxes = _iva_de(db, item.id)
+ assert len(taxes) == 1
+ assert float(taxes[0].rate) == 0.16
+ assert float(taxes[0].amount) == 160.0
+ assert taxes[0].is_withholding is False
+
+
+def test_sin_objeto_de_impuesto_no_se_deriva_nada(db):
+ """ObjetoImp 01 con nodo de impuestos es motivo de rechazo del SAT."""
+ inv = service.create_invoice(db, InvoiceCreate(reference="F-NOOBJ", tax_rate=Decimal("16")), T, C)
+ item = service.create_item(
+ db,
+ InvoiceItemCreate(invoice_id=inv.id, concept="otros", quantity=1, unit_amount=1000,
+ tax_object_id=_obj_imp(db, "01")),
+ T, C,
+ )
+ assert _iva_de(db, item.id) == []
+
+
+def test_cambiar_el_porcentaje_recalcula_las_partidas(db):
+ inv = service.create_invoice(db, InvoiceCreate(reference="F-REC", tax_rate=Decimal("16")), T, C)
+ item = service.create_item(
+ db,
+ InvoiceItemCreate(invoice_id=inv.id, concept="otros", quantity=1, unit_amount=1000,
+ tax_object_id=_obj_imp(db, "02")),
+ T, C,
+ )
+ service.update_invoice(db, inv.id, InvoiceUpdate(tax_rate=Decimal("8")), T, C)
+ taxes = _iva_de(db, item.id)
+ assert float(taxes[0].rate) == 0.08
+ assert float(taxes[0].amount) == 80.0
+
+
+def test_cambiar_el_importe_recalcula_el_iva(db):
+ inv = service.create_invoice(db, InvoiceCreate(reference="F-IMP", tax_rate=Decimal("16")), T, C)
+ item = service.create_item(
+ db,
+ InvoiceItemCreate(invoice_id=inv.id, concept="otros", quantity=1, unit_amount=1000,
+ tax_object_id=_obj_imp(db, "02")),
+ T, C,
+ )
+ service.update_item(db, item.id, InvoiceItemUpdate(unit_amount=Decimal("2000")), T, C)
+ assert float(_iva_de(db, item.id)[0].amount) == 320.0
+
+
+def test_quitar_el_objeto_de_impuesto_retira_el_traslado(db):
+ inv = service.create_invoice(db, InvoiceCreate(reference="F-QUITA", tax_rate=Decimal("16")), T, C)
+ item = service.create_item(
+ db,
+ InvoiceItemCreate(invoice_id=inv.id, concept="otros", quantity=1, unit_amount=1000,
+ tax_object_id=_obj_imp(db, "02")),
+ T, C,
+ )
+ assert len(_iva_de(db, item.id)) == 1
+ service.update_item(db, item.id, InvoiceItemUpdate(tax_object_id=_obj_imp(db, "01")), T, C)
+ assert _iva_de(db, item.id) == []
+
+
+def test_la_derivacion_no_pisa_una_retencion_capturada(db):
+ """Ajustar los impuestos a mano desactiva el automatismo para esa partida.
+
+ Es la diferencia entre un valor por defecto útil y un automatismo que borra trabajo ajeno.
+ """
+ from api.v1.modules.fin.catalogs.models import Tax
+ from api.v1.modules.fin.invoices import taxes_service
+
+ inv = service.create_invoice(db, InvoiceCreate(reference="F-RET", tax_rate=Decimal("16")), T, C)
+ item = service.create_item(
+ db,
+ InvoiceItemCreate(invoice_id=inv.id, concept="otros", quantity=1, unit_amount=1000,
+ tax_object_id=_obj_imp(db, "02")),
+ T, C,
+ )
+ isr = db.query(Tax).filter(Tax.code == "001").first()
+ taxes_service.set_item_tax(db, item.id, isr.id, Decimal("0.10"), True, T, C)
+
+ iva = db.query(Tax).filter(Tax.code == "002").first()
+
+ # Cambiar el % ya no debe tocar ESTA partida: ni la retención capturada ni el IVA, que se
+ # queda con la tasa que tenía cuando se intervino a mano.
+ service.update_invoice(db, inv.id, InvoiceUpdate(tax_rate=Decimal("8")), T, C)
+ taxes = {t.tax_id: t for t in _iva_de(db, item.id)}
+ assert len(taxes) == 2, "se perdió un impuesto capturado a mano"
+ assert float(taxes[isr.id].amount) == 100.0, "se pisó la retención"
+ # Éste es el assert que distingue: sin el guard, el IVA habría bajado a 0.08 / 80.0.
+ assert float(taxes[iva.id].rate) == 0.16, "el automatismo recalculó una partida intervenida"
+ assert float(taxes[iva.id].amount) == 160.0
diff --git a/docs/prompts/SM-XXX_timbrado_cfdi_ingreso_comercio_digital.md b/docs/prompts/SM-XXX_timbrado_cfdi_ingreso_comercio_digital.md
new file mode 100644
index 0000000..65d3dea
--- /dev/null
+++ b/docs/prompts/SM-XXX_timbrado_cfdi_ingreso_comercio_digital.md
@@ -0,0 +1,694 @@
+# Prompt — Timbrado de CFDI 4.0 de ingreso vía PAC Comercio Digital (modo pruebas)
+
+> Doc **Prompt** del ticket Kanban SM. Plan asincrónico para ejecución autónoma en ventana Sundown.
+> Tipo de actividad IA a registrar: **DISEÑO DE PROMPTS IA** (elaboración de este plan) y
+> **REVISION DE CODIGO IA** + **PRUEBAS SOBRE CAMBIOS IA** en el Sunrise siguiente.
+
+| Campo | Valor |
+|---|---|
+| Ticket | SM-XXX *(asignar antes de disparar)* |
+| Repo | `CRM_AGENTES_CARGA` |
+| Rama base | `feature/crm-cumplimiento-pdf` *(confirmar — ver Bloqueante B3)* |
+| Rama de trabajo | `feature/AS-XXX-timbrado-cfdi-ingreso` |
+| **Ventana** | **Lunes 10 de agosto de 2026, 17:00 → martes 11, 07:00.** Ciclos cada 30 min (~28) |
+| Entregable | Commits locales en la rama de trabajo. **Sin push.** El PR lo abre una persona en el Sunrise |
+| Sunrise | Martes 11 de agosto de 2026, primera hora — ver `automatizacion/SUNRISE.md` |
+| Estimación | 6–7 h de trabajo efectivo |
+| Riesgo | Medio-bajo — módulo nuevo, sin migraciones a prod, sin tocar flujo de facturación existente |
+
+> **Operación autónoma.** Este plan se ejecuta en una ventana nocturna sin supervisión. El mecanismo
+> (corredor, preflight, instrucción, Sunrise) está montado en [`automatizacion/`](../../automatizacion/)
+> y se describe en §10. **La instrucción que recibe el agente cada ciclo es
+> `automatizacion/instruccion-nocturna.md`**, que remite a este documento como especificación.
+
+---
+
+## 1. Objetivo
+
+Agregar al backend del CRM la capacidad de **generar, sellar y timbrar un CFDI 4.0 de tipo
+ingreso (`TipoDeComprobante = "I"`)** a partir de una factura existente en `fin.invoices`,
+usando el PAC **Comercio Digital** en su **entorno de pruebas**.
+
+El proceso replica el que hoy vive en la solución legada WinForms `CFDI.sln`
+(.NET Framework 4.8, `CFDI/CFDI.cs`), pero reimplementado en Python/FastAPI siguiendo las
+convenciones del CRM. La solución legada es **fuente de verdad del contrato con el PAC**, no
+un modelo de arquitectura a copiar.
+
+### Fuera de alcance (NO hacer)
+
+- Frontend SvelteKit. Este Sundown es backend puro.
+- Tipos de comprobante distintos de `I` (egreso `E`, traslado `T`, pago `P`, retenciones).
+- Complementos (Carta Porte, Comercio Exterior, INE, Notarios, Pagos 2.0).
+- Cancelación, consulta de estatus, recuperación de XML, consulta de saldo del PAC.
+- Timbrado contra el entorno de **producción** de Comercio Digital.
+- Generación del PDF/representación impresa del comprobante timbrado y del código QR.
+- Migraciones aplicadas a bases de datos productivas.
+
+---
+
+## 2. Estado actual del CRM (verificado)
+
+Ya existe y **se debe reutilizar, no duplicar**:
+
+| Pieza | Ruta | Qué aporta |
+|---|---|---|
+| Catálogos SAT | `backend/api/v1/modules/fin/catalogs/` | Schema `sat`: `tax_regimes`, `taxes`, `payment_forms`, `payment_methods`, `voucher_types`, `units_of_measure`, `products_services`, `tax_objects`. Con `seed_data.py`. |
+| Datos del emisor | `backend/api/v1/modules/fin/issuer/` | `fin.issuer_settings`: `legal_name`, `rfc`, `tax_regime_id`, `zip_code`. Único vigente por empresa. |
+| Facturas | `backend/api/v1/modules/fin/invoices/` | `fin.invoices`, `fin.invoice_items`, `fin.invoice_item_taxes`, `fin.payments`. Ya tienen FK a los catálogos SAT (`voucher_type_id`, `payment_form_id`, `payment_method_id`, `expedition_zip_code`, `product_service_id`, `unit_of_measure_id`, `tax_object_id`). |
+| Conceptos | `backend/api/v1/modules/fin/concepts/` | Catálogo interno de conceptos facturables. |
+| Claves MinIO | `backend/core/s3_keys.py:288` | `company_certificate_key(tenant_id, company_id, certificate_type, timestamp, file_ext)` → `tenants/{tid}/companies/{cid}/certificates/{tipo}_{ts}.{cer\|key}`. **El CSD ya tiene dónde vivir.** |
+| Config | `backend/core/config.py` | `Settings` (pydantic-settings). Aquí se agregan las variables del PAC. |
+| Tests | `backend/tests/` | Convención `test_.py`. Referencias cercanas: `test_fin_sat_catalogs.py`, `test_invoices.py`. |
+
+**Lo que NO existe todavía:** ningún módulo de certificados/CSD, ningún cliente de PAC,
+ninguna generación de XML CFDI. Este ticket lo crea.
+
+---
+
+## 3. Contrato con el PAC — extraído del legado (fuente de verdad)
+
+Todo lo de esta sección está verificado en
+`C:\Users\Jair Cedillo\Documents\Visual Studio 2022\Projects\Proyectos\CFDI CP y 4.0\CFDI\CFDI\CFDI.cs`
+(21,127 líneas; el agente NO necesita abrir el archivo, la información relevante está aquí).
+
+### 3.1 Endpoint de timbrado — `timbrarV5Xml` (`CFDI.cs:19274-19346`)
+
+```
+POST https://{host}/timbre4/timbrarV5
+
+host pruebas : pruebas.comercio-digital.mx
+host producción : ws.comercio-digital.mx
+
+Content-Type : text/plain
+Timeout : 15 s
+TLS : 1.2 (el legado fuerza SecurityProtocolType 3072)
+
+Headers de petición:
+ usrws : usuario del web service (longitud válida 12–13 caracteres)
+ pwdws : password del web service
+ tipo : "XML"
+ email : opcional, en minúsculas; se omite el header si va vacío
+
+Body: bytes crudos del XML CFDI **ya sellado**, en UTF-8. NO va en base64,
+ NO va envuelto en SOAP.
+
+Respuesta:
+ body : XML del CFDI timbrado (con el nodo tfd:TimbreFiscalDigital incorporado)
+ headers : uuid, errmsg, saldo, erremail, codigo
+```
+
+Validaciones previas que el legado hace antes de salir a la red (replicarlas):
+
+| Condición | Código | Mensaje |
+|---|---|---|
+| `len(usrws)` fuera de 12–13 | 701 | Usuario/password inválido |
+| `pwdws` vacío | 702 | Usuario/password inválido |
+| XML nulo o < 200 bytes | 711 | Contenido XML vacío |
+| Excepción de red | 833 | Error de transmisión |
+| HTTP != 200 | 998 | Error HTTP |
+
+**Criterio de éxito del legado:** el header `errmsg` viene vacío. Si trae contenido, es error
+(`ComDig_Exception`, `CFDI.cs:16516-16519`).
+
+### 3.2 Defectos del legado que NO se deben replicar
+
+Al portar `timbrarV5Xml` hay que corregir, no copiar:
+
+1. **`codigo` nunca recibe el valor del header `codigo`.** En `CFDI.cs:19331-19336` el valor se
+ lee en la variable local `cod2`, que se descarta. El parámetro de salida `codigo` termina
+ siempre en `999` o `991`. → En Python, el código del PAC **sí** se debe leer y persistir.
+2. **`saldo` nunca se asigna.** Mismo patrón (`CFDI.cs:19324-19327`): se lee en `cod2` y se
+ pierde. El parámetro de salida queda siempre en `0`. → Leerlo y persistirlo como entero.
+3. **`GetResponseHeader` devuelve `""` y no `null`** cuando el header no existe, así que la
+ rama `if (... == null) codigo = 991` prácticamente nunca se cumple. → En Python usar
+ presencia real en el diccionario de headers.
+4. **Los errores se tragan con `MessageBox`** y el flujo continúa. → En el CRM, error del PAC
+ se traduce a excepción de dominio y respuesta HTTP con detalle, nunca a un `except: pass`.
+
+### 3.3 RFC del proveedor de certificación (`CFDI.cs:16665`)
+
+| Entorno | RFC |
+|---|---|
+| Pruebas | `SPR190613I52` |
+| Producción | `SCD110105654` |
+
+Sirve para **verificar** que el `RfcProvCertif` del timbre recibido corresponde al entorno
+esperado. Si no coincide, es error: se timbró contra el entorno equivocado.
+
+### 3.4 Cadena original y sello (`CFDI.cs:8295-8402`, `CFDI.cs:8150-8210`)
+
+- La **cadena original** se obtiene aplicando la transformación XSLT oficial del SAT
+ `cadenaoriginal_4_0.xslt` al XML del comprobante **sin sello**, y decodificando las
+ entidades HTML del resultado.
+- El **sello** es `RSA` + `SHA256` sobre los bytes UTF-8 de la cadena original, resultado en
+ base64. (El legado usa SHA1 solo para retenciones con otro PAC — para CFDI de ingreso con
+ Comercio Digital es **SHA256**.)
+- La llave privada `.key` del CSD es **PKCS#8 DER cifrada con contraseña**; el certificado
+ `.cer` es **X.509 DER**.
+- El atributo `Certificado` del comprobante es el **base64 del DER** del `.cer`.
+- El atributo `NoCertificado` es el número de serie del certificado del SAT, que viene
+ codificado de forma que sus bytes son directamente los 20 caracteres ASCII del número.
+
+⚠️ **Lección de seguridad del legado:** el XSLT del SAT hace `xsl:include` de otros archivos.
+El legado tuvo que introducir un resolver que no descarga recursos externos (ver el bloque
+comentado en `CFDI.cs:8333-8363`). En el CRM, los XSLT se versionan en el repo y la
+transformación se ejecuta **sin acceso a red y sin habilitar scripts ni `document()`**.
+
+### 3.5 Detalles del XML CFDI 4.0 verificados en el legado
+
+- **Orden de atributos del nodo `cfdi:Comprobante`** (`CFDI.cs:14340-14394`):
+ `Version`, `Serie`, `Folio`, `Fecha`, `Sello`, `FormaPago`, `NoCertificado`, `Certificado`,
+ `CondicionesDePago`, `SubTotal`, `Descuento`, `Moneda`, `TipoCambio`, `Total`,
+ `TipoDeComprobante`, `Exportacion`, `MetodoPago`, `LugarExpedicion`.
+- `Serie` y `CondicionesDePago` se omiten si vienen vacíos; `Descuento` solo si es > 0;
+ `TipoCambio` solo si la moneda no es MXN.
+- **`Fecha` en CFDI 4.0 va SIN offset de zona horaria** (`CFDI.cs:12654`: el `-06:00` es
+ exclusivo de CFDI 3.3). Formato `YYYY-MM-DDTHH:MM:SS`.
+- `Exportacion` es obligatorio en 4.0. Para factura de ingreso nacional: `"01"` (no aplica).
+- `TipoDeComprobante = "I"` es el caso por defecto en el legado (`CFDI.cs:8966`).
+
+---
+
+## 4. Diseño a implementar
+
+### 4.1 Estructura de archivos
+
+Módulo nuevo por dominio, siguiendo la convención de `fin/`:
+
+```
+backend/api/v1/modules/fin/stamping/
+├── __init__.py
+├── models.py # InvoiceStamp → fin.invoice_stamps
+├── dto.py # Pydantic v2
+├── routes.py # endpoints
+├── service.py # orquestación del flujo
+├── cfdi_builder.py # construcción del XML CFDI 4.0 tipo I
+├── sealer.py # cadena original (XSLT) + sello RSA-SHA256
+├── pac_comercio_digital.py # cliente HTTP del PAC
+└── xslt/ # XSLT oficiales del SAT, versionados
+ ├── cadenaoriginal_4_0.xslt
+ └── utilerias.xslt # (y cualquier otro archivo incluido por el anterior)
+```
+
+Los XSLT se descargan **una sola vez durante el desarrollo** desde `www.sat.gob.mx` y se
+commitean. En tiempo de ejecución no se descarga nada.
+
+### 4.2 Modelo de datos
+
+#### Campo nuevo en `fin.invoices`
+
+El modo de timbrado **se decide por factura**, no por entorno:
+
+```
+stamping_mode VARCHAR(12) NOT NULL DEFAULT 'pruebas' -- 'pruebas' | 'produccion'
+```
+
+- Editable desde el CRUD de facturas (`InvoiceCreate` / `InvoiceUpdate` / `InvoiceResponse`).
+- Al crear una factura sin el campo, toma el valor de `PAC_DEFAULT_MODE` (§4.5); si tampoco
+ está, `'pruebas'`.
+- **Inmutable una vez timbrada**: si la factura ya tiene un `invoice_stamp` en estado
+ `timbrado`, un `PATCH` que intente cambiar `stamping_mode` se rechaza con 409. Cambiarlo
+ después del hecho falsearía el registro de con qué intención se emitió.
+- Validado como enum cerrado en el DTO Pydantic. Un valor distinto de los dos permitidos es
+ error de validación (422), nunca un default silencioso.
+
+Este campo es la **única** fuente del modo. No se acepta modo ni host por body, query string ni
+cabecera en el endpoint de timbrado.
+
+#### Tabla nueva `fin.invoice_stamps`
+
+Una fila por intento de timbrado (incluidos los fallidos, para trazabilidad). Usa
+`TenantScopedMixin` + `TimestampMixin` como el resto del módulo.
+
+Campos mínimos:
+
+- `id`, `invoice_id` (FK `fin.invoices.id`, indexado)
+- `mode` — `pruebas` | `produccion`
+- `status` — `pendiente` | `timbrado` | `error`
+- `uuid` (36, nullable, indexado)
+- `stamped_at` (datetime, nullable) — `FechaTimbrado` del TFD
+- `pac_rfc` (13, nullable) — `RfcProvCertif` recibido
+- `sat_cert_number` (20, nullable) — `NoCertificadoSAT`
+- `sat_seal` / `cfd_seal` (Text, nullable) — `SelloSAT` / `SelloCFD`
+- `pac_code` (int, nullable), `pac_balance` (int, nullable) — headers `codigo` y `saldo`
+- `error_message` (Text, nullable) — header `errmsg`
+- `xml_file_key` (512, nullable) — clave MinIO del XML timbrado
+
+El campo `mode` se copia desde `invoices.stamping_mode` **en el momento del timbrado** y queda
+congelado en la fila: es el registro de contra qué entorno se transmitió realmente.
+
+Índice único parcial sobre `uuid` (donde `deleted_at IS NULL`) para impedir UUID duplicado.
+
+**Migración Alembic** en `backend/alembic/versions/`, siguiendo el estilo de las existentes.
+Se aplica solo en local/testing. **No tocar producción.**
+
+### 4.3 Flujo del servicio
+
+`stamp_invoice(db, invoice_id, tenant_id, company_id, user_id)`:
+
+1. **Cargar y validar** — la factura existe, pertenece al tenant/company, no está cancelada y
+ **no tiene ya un timbre en estado `timbrado`** (idempotencia: si lo tiene, devolver el
+ existente sin volver a timbrar; timbrar dos veces cuesta folios y genera un CFDI duplicado
+ ante el SAT).
+2. **Validar completitud fiscal** — emisor configurado (`fin.issuer_settings`), receptor con
+ RFC / razón social / régimen / domicilio fiscal, partidas con `product_service_id`,
+ `unit_of_measure_id` y `tax_object_id`, forma y método de pago. Cada faltante se acumula y
+ se devuelve como lista, no se falla en el primero.
+3. **Construir el XML** sin sello (`cfdi_builder`).
+4. **Calcular cadena original** por XSLT y **sellar** con el CSD (`sealer`).
+5. **Insertar `Sello`, `NoCertificado` y `Certificado`** en el comprobante.
+6. **Transmitir al PAC** (`pac_comercio_digital.stamp`), con el modo leído de
+ `invoice.stamping_mode` — que determina el host, ver §4.5.
+7. **Verificar el timbre recibido** — el nodo `tfd:TimbreFiscalDigital` existe y trae `UUID`, y
+ el `RfcProvCertif` corresponde al modo solicitado (§3.3). Si no corresponde, es error grave:
+ se timbró contra un entorno distinto del pedido.
+8. **Persistir** el registro en `fin.invoice_stamps` y subir el XML timbrado a MinIO.
+9. **Devolver** el resumen (uuid, fecha, modo, clave del archivo).
+
+Si algo falla en 6–7: se persiste la fila con `status = "error"`, `pac_code` y `error_message`,
+y se responde con error HTTP. **No se marca la factura como timbrada.**
+
+### 4.4 Endpoints
+
+```
+POST /api/v1/fin/invoices/{invoice_id}/stamp?company_id={cid}
+GET /api/v1/fin/invoices/{invoice_id}/stamp?company_id={cid}
+GET /api/v1/fin/invoices/{invoice_id}/stamp/xml-url?company_id={cid}
+```
+
+Mismo patrón de dependencias que `invoices/routes.py`: `company_id: int = Query(...)`,
+`current_user: dict = Depends(get_current_user)`, `db: Session = Depends(get_core_db)`.
+
+### 4.5 Configuración
+
+En `backend/core/config.py`, agregar al `Settings`:
+
+```python
+# ----- PAC Comercio Digital (timbrado CFDI) -----
+# El modo NO se configura aquí: vive en invoices.stamping_mode (§4.2). Esta variable
+# solo define con qué valor nacen las facturas nuevas que no lo especifican.
+PAC_DEFAULT_MODE: str = "pruebas" # pruebas | produccion
+PAC_HOST_TEST: str = "pruebas.comercio-digital.mx"
+PAC_HOST_PROD: str = "ws.comercio-digital.mx"
+PAC_USER: str = "" # header usrws
+PAC_PASSWORD: str = "" # header pwdws
+PAC_TIMEOUT_SECONDS: int = 15
+PAC_NOTIFICATION_EMAIL: str = "" # header email, opcional
+CSD_PASSWORD: str = "" # contraseña de la llave .key
+```
+
+El andamiaje ya está hecho: el passthrough existe en `docker-compose.yml` (bloque
+`backend.environment`) y los nombres están documentados en `.env.example`. Los **valores** viven
+solo en `.env`, que está en `.gitignore` (línea 28). Falta únicamente declarar los campos en
+`Settings`.
+
+⚠️ **`docker-compose.yml` no está versionado** (`.gitignore` línea 77): cada quien tiene el suyo.
+El passthrough ya está aplicado en el compose local de este clon, que es donde corre la ventana
+nocturna, así que el agente lo hereda. Pero **no intentes commitearlo ni versionarlo**: git lo
+ignora y forzarlo rompería la convención del proyecto. Si otro entorno necesita estas variables,
+se documentan en `.env.example` — que sí está versionado — y cada quien las añade a su compose.
+
+Reglas duras:
+
+- **Ningún valor real** (usuario, password, RFC, contraseña de CSD) se escribe en el código,
+ en tests, en fixtures ni en `.env.example`. Solo variables de entorno con default vacío.
+- Si faltan `PAC_USER` o `PAC_PASSWORD`, el servicio falla con mensaje claro en vez de intentar
+ timbrar con cadenas vacías. (El legado ya lo hace con los códigos 701/702 — §3.1.)
+
+#### Resolución del host — regla única
+
+El host **se deriva exclusivamente del enum** `invoice.stamping_mode`, en el servidor:
+
+| `stamping_mode` | Host |
+|---|---|
+| `pruebas` | `PAC_HOST_TEST` → `pruebas.comercio-digital.mx` |
+| `produccion` | `PAC_HOST_PROD` → `ws.comercio-digital.mx` |
+
+Tres reglas que el cliente del PAC debe cumplir:
+
+1. **Nunca aceptar un host, una URL ni un modo por parámetro de la petición HTTP.** El modo se
+ lee de la factura y punto. Es el defecto de diseño del legado, donde `CFDIPacUrl` es un
+ campo mutable que cualquier rama del código puede reasignar (§3.1).
+2. **Fallar de entrada** ante un `stamping_mode` que no sea exactamente `"pruebas"` o
+ `"produccion"`. Nada de default silencioso. Ojo con el legado: en `CFDI.cs:19288` un host
+ vacío cae a `pruebas.comercio-digital.mx` — comportamiento implícito que aquí no se replica.
+3. **Verificar el timbre recibido** contra el modo solicitado. Si se pidió `pruebas` y el
+ `RfcProvCertif` devuelto es el de producción (`SCD110105654`), o al revés, se trata como
+ error grave: se registra y la operación **no** se da por exitosa.
+
+⚠️ **Contexto para quien lea esto después.** Las credenciales configuradas son de la cuenta de
+**producción** del PAC (§8, B1), y por decisión del responsable del proyecto **no** existe un
+veto de entorno: una factura con `stamping_mode = "produccion"` timbra un CFDI con validez
+fiscal real ante el SAT desde cualquier entorno donde estén esas credenciales, incluido
+desarrollo. Deshacerlo requiere cancelar el comprobante ante el SAT. Las tres reglas de arriba
+son, por tanto, la única barrera técnica que queda: no se relajan sin decisión explícita.
+
+**Restricción para este Sundown:** todas las facturas que el agente cree o use para pruebas
+llevan `stamping_mode = "pruebas"`. El agente **no** debe timbrar en `produccion` bajo ninguna
+circunstancia (ya está en Fuera de alcance, §1).
+
+### 4.6 Dependencias nuevas — justificación obligatoria
+
+Dos, ambas necesarias y sin alternativa razonable en la stdlib:
+
+| Paquete | Por qué |
+|---|---|
+| `lxml` | La cadena original del SAT **solo** se puede calcular aplicando el XSLT 1.0 oficial. `xml.etree` de la stdlib no hace XSLT. Es la única implementación de XSLT 1.0 madura en Python. |
+| `cryptography` | Lectura del `.key` (PKCS#8 DER cifrado) y del `.cer` (X.509 DER), y firma RSA-SHA256. Ya entra de forma transitiva por `python-jose[cryptography]`; aquí se declara explícita porque pasa a ser dependencia directa. |
+
+Agregar a `backend/requirements.txt` con versión fijada, en una sección comentada
+`# CFDI / timbrado`, respetando el estilo del archivo.
+
+---
+
+## 5. Pruebas
+
+### 5.1 Unitarias — sin red (`backend/tests/test_fin_stamping.py`)
+
+- **Constructor de XML**: dada una factura de prueba, el XML resultante valida contra el XSD
+ `cfdv40.xsd`, tiene los atributos en el orden correcto, `TipoDeComprobante="I"`,
+ `Exportacion="01"`, y `Fecha` sin offset de zona horaria.
+- **Omisión de atributos opcionales**: sin serie → no aparece `Serie`; descuento en 0 → no
+ aparece `Descuento`; moneda MXN → no aparece `TipoCambio`.
+- **Cadena original**: la transformación XSLT produce una cadena que empieza con `||` y
+ termina con `||`, y no intenta ninguna descarga externa.
+- **Sello**: firmando con el CSD público de pruebas del SAT, el sello resultante **verifica**
+ contra la llave pública del `.cer`. Es la prueba fuerte del sellado.
+- **Cliente del PAC contra un doble**: valida los cinco casos de error previos (701, 702, 711,
+ 833, 998) y el camino feliz, comprobando que `codigo` y `saldo` **sí** se extraen de los
+ headers — el defecto corregido de la sección 3.2.
+- **Idempotencia**: timbrar dos veces la misma factura no genera un segundo llamado al PAC.
+- **Validación de completitud**: una factura incompleta devuelve **todos** los faltantes.
+- **Resolución del host por modo** (§4.5): una factura con `stamping_mode = "pruebas"` produce
+ una URL contra `pruebas.comercio-digital.mx`, y una con `"produccion"` contra
+ `ws.comercio-digital.mx`. Ambos casos se prueban contra el doble del PAC, sin red.
+- **Modo inválido**: `stamping_mode = "prod"`, `""` o `None` es error de validación, y en
+ ningún caso termina resolviendo a un host.
+- **El modo no se puede inyectar por HTTP**: mandar `mode` / `host` / `url` en el body o el
+ query string de `POST /stamp` no altera el host usado. Es la prueba de la regla 1 de §4.5.
+- **`stamping_mode` inmutable tras timbrar**: `PATCH` sobre una factura ya timbrada devuelve
+ 409 y no modifica el valor.
+- **Verificación de `RfcProvCertif`**: un doble que responde con el RFC del entorno equivocado
+ deja la fila en `status = "error"` y no marca la factura como timbrada.
+
+### 5.2 Integración — timbrado real en pruebas (`test_fin_stamping_pac.py`)
+
+Marcada con `@pytest.mark.skipif` sobre la ausencia de `PAC_USER`/`PAC_PASSWORD` en el entorno,
+para que la suite normal siga siendo verde sin credenciales.
+
+- Timbra un CFDI de ingreso completo contra `pruebas.comercio-digital.mx`.
+- Verifica: `errmsg` vacío, `uuid` con formato UUID válido, `RfcProvCertif == "SPR190613I52"`,
+ el XML devuelto contiene `tfd:TimbreFiscalDigital` y la fila en `fin.invoice_stamps` queda en
+ `status = "timbrado"`.
+
+### 5.3 CSD de pruebas
+
+Se usa el **CSD público de demostración que publica el SAT** (RFC de prueba). No es dato
+sensible y puede vivir en `backend/tests/fixtures/csd/`. **Bajo ninguna circunstancia** se
+commitea un CSD real de un cliente.
+
+Datos dummy autorizados para el resto de las pruebas: RFC `XAXX010101000`,
+pedimento `0000-0000000`.
+
+---
+
+## 6. Criterios de aceptación
+
+Verificables uno por uno en el Sunrise:
+
+1. `POST /api/v1/fin/invoices/{id}/stamp` sobre una factura completa con
+ `stamping_mode = "pruebas"` devuelve 200 con `uuid`, `stamped_at`, `mode: "pruebas"` y
+ `xml_file_key`.
+2. El XML timbrado queda en MinIO bajo una clave obtenida de `core/s3_keys.py` (función nueva,
+ no ruta construida a mano en la ruta HTTP).
+3. Existe una fila en `fin.invoice_stamps` con `status = "timbrado"` y `pac_rfc = "SPR190613I52"`.
+4. Un segundo `POST` sobre la misma factura devuelve el timbre existente **sin** llamar al PAC.
+5. Una factura sin `issuer_settings` o con partidas sin clave de producto/servicio devuelve 422
+ con la lista completa de faltantes.
+6. Un error del PAC deja fila con `status = "error"`, `pac_code` y `error_message` poblados, y
+ la factura **no** queda marcada como timbrada.
+7. `pytest backend/tests/test_fin_stamping.py` pasa completo sin acceso a red.
+8. `black`, `flake8` y `mypy` pasan sobre el módulo nuevo.
+9. La migración Alembic sube y baja limpio (`upgrade head` / `downgrade -1`) en local, e
+ incluye tanto `fin.invoice_stamps` como la columna `fin.invoices.stamping_mode`.
+10. No hay credenciales, RFC reales ni CSD de cliente en el diff. Verificable con
+ `git diff --stat` + inspección de los archivos nuevos.
+11. `stamping_mode` es editable vía `POST`/`PATCH` de facturas, se refleja en
+ `InvoiceResponse`, y una factura ya timbrada rechaza su modificación con 409.
+12. Con `stamping_mode = "produccion"` el cliente resuelve `ws.comercio-digital.mx` — probado
+ **solo contra el doble del PAC**. Ninguna prueba transmite a producción.
+
+---
+
+## 7. Convenciones obligatorias
+
+- **Nombres de código en inglés**; comentarios y docstrings en **español** para la lógica
+ fiscal/aduanera, igual que en `fin/issuer/models.py` y `fin/catalogs/models.py`.
+- **FastAPI**: Pydantic v2, routers por dominio, DTOs separados de los modelos.
+- **Sin `print` de depuración. Sin `except: pass`.** Todo error se maneja o se propaga.
+- Conventional commits (`feat(fin): ...`, `test(fin): ...`, `chore(deps): ...`).
+- Rama `feature/AS-XXX-timbrado-cfdi-ingreso`. **PR, nunca push directo** a main/master/
+ develop/release.
+- No inventar tablas, campos ni endpoints fuera de los especificados aquí. Si hace falta algo
+ que no está descrito, se documenta como PENDIENTE DECISIÓN.
+
+### Ambigüedad durante la ejecución autónoma
+
+**No adivinar.** Cualquier decisión no cubierta por este documento se implementa con la opción
+más conservadora y se documenta en el cuerpo del PR bajo un encabezado
+`## PENDIENTE DECISIÓN`, con: qué se asumió, qué alternativas había, y qué se necesita para
+resolverlo. Es la misma convención que ya usa `fin/catalogs/seed_data.py`.
+
+---
+
+## 8. Bloqueantes a resolver ANTES de disparar
+
+El DoR **no se cumple** hasta que estos tres estén cerrados:
+
+- **B1 — Credenciales del PAC. RESUELTO, con reserva.** Se reutilizan las credenciales de
+ Comercio Digital que hoy usa la solución legada, apuntando al host de pruebas. Van en `.env`
+ como `PAC_USER` / `PAC_PASSWORD`; el andamiaje ya está listo (§4.5). **No se pegan en este
+ documento, en el chat ni en ningún archivo versionado.**
+ → Reserva: son credenciales de **producción** — en el legado las ramas de pruebas y
+ producción del ternario son idénticas (§9). Mientras eso siga así, la única barrera entre un
+ timbre de prueba y un CFDI fiscal real es el host, y por eso la salvaguarda de §4.5 no es
+ opcional. Conviene solicitar a Comercio Digital un usuario de pruebas independiente y
+ sustituirlo en `.env` cuando exista; no bloquea este Sundown.
+ → Nota: el `usrws` es de 12–13 caracteres (formato de RFC), así que el RFC del emisor en el
+ CFDI de prueba debe ser coherente con la cuenta del PAC. Si el CSD de demostración del SAT
+ resulta incompatible con esa cuenta, el agente lo documenta como PENDIENTE DECISIÓN en el PR
+ en vez de inventar un RFC.
+
+- **B2 — Trabajo previo sin commitear.** `backend/api/v1/modules/fin/catalogs/` aparece como
+ no rastreado en git. Este ticket **depende** de esos catálogos. Hay que commitearlo (o
+ fusionarlo) antes de disparar; si no, el agente arranca sobre una base que no está en el
+ árbol y el PR saldrá mezclando dos trabajos.
+
+- **B3 — Rama base del PR.** El repo tiene `feature/crm-cumplimiento-pdf` como rama principal
+ de integración, no `main`. Confirmar que el PR va contra ella.
+
+- **B4 — CLI de Claude Code. RESUELTO.** Instalado en WSL (`~/.local/bin/claude`, versión
+ `2.1.224`) y verificado también por la ruta que usa el corredor (PowerShell → `wsl.exe` →
+ `bash -lc`). El ensayo de extremo a extremo corre y devuelve `ENSAYO-OK` — ver §10.3.
+
+- **B5 — La tarea programada no está registrada.** Requiere PowerShell **como administrador**; un
+ agente no puede ni debe registrarse una tarea del sistema. El bloque listo para pegar está en
+ §10.6. Al registrarla, verificar que `NextRunTime` **no** salga vacío: una tarea con hora de
+ arranque ya pasada nunca dispara y en la interfaz se ve perfectamente bien.
+
+> **Alcance de la noche: COMPLETO, en una sola ventana.** Se planteó partirlo en dos —el mecanismo
+> primero, el timbrado real después—, siguiendo la recomendación del kit de que la primera ventana
+> lleve un entregable pequeño. **Decisión del responsable del proyecto: se ejecutan los cinco
+> entregables de §4 en la ventana del lunes 10.** No se reserva nada para una segunda noche.
+>
+> Consecuencia práctica para el agente: la escalera de `instruccion-nocturna.md` se recorre entera y
+> el entregable 5 (timbrado real contra `pruebas.comercio-digital.mx`) **sí** entra en el alcance,
+> siempre que `PAC_USER` y `PAC_PASSWORD` estén en el entorno. Si faltan, se salta ese entregable y
+> se sigue con la escalera — no es motivo para detener la noche.
+
+---
+
+## 9. Hallazgo de seguridad — acción separada de este ticket
+
+Durante el análisis del legado se encontraron **credenciales de ambos PAC en texto plano dentro
+del código fuente** de `CFDI.cs`. Ubicaciones exactas:
+
+| Líneas | PAC | Contenido |
+|---|---|---|
+| 2709, 2711 | Edicom | usuario y password (2710: password anterior, comentado) |
+| 2720, 2721 | Comercio Digital | usuario y password |
+| 6858, 6860 | Edicom | duplicado exacto del bloque 2709 |
+| 6863, 6864 | Comercio Digital | duplicado exacto del bloque 2720 |
+| 7210, 7212 | Edicom | tercer duplicado |
+| 7216, 7217 | Comercio Digital | tercer duplicado |
+| 21061, 21063 | Edicom | dentro de `tst_xml_ine()` (línea 20910), más un `Emisor_Rfc` fijo |
+| 20833, 20834 | Edicom | dentro de `tst_xml()` (línea 20695), comentado |
+
+Agravantes:
+
+1. **El "modo pruebas" de Comercio Digital usa credenciales de producción.** Los ternarios de
+ las líneas 2720-2721 (y sus dos duplicados) tienen **ambas ramas idénticas**:
+ `CFDIMod == "Prueba" ? X : X`. Lo único que realmente cambia entre entornos es la URL
+ (línea 2722).
+2. **`tst_xml_ine()` no se invoca desde ningún lado, pero se compila.** Las credenciales quedan
+ en el ejecutable distribuido, y `Costura.Fody` embebe todo en un único `.exe`: son
+ recuperables con cualquier decompilador de .NET.
+3. El password de Edicom de la línea 2711 tiene forma de contraseña corporativa, con riesgo de
+ estar reutilizada en otros sistemas.
+
+Todo está commiteado en el repositorio `CFDI` (rama `main`, con remoto configurado).
+
+**Recomendación:** rotar esas credenciales con Comercio Digital y moverlas a configuración
+externa en la solución legada. Es un ticket aparte — **no** forma parte de este Sundown y el
+agente autónomo **no** debe tocar la solución C#.
+
+---
+
+## 10. Operación de la ventana nocturna
+
+El plan se ejecuta sin supervisión entre las 17:00 del lunes 10 y las 07:00 del martes 11 de agosto
+de 2026, en ciclos de 30 minutos disparados por el Programador de tareas de Windows. Cada ciclo es
+un proceso nuevo **sin memoria del anterior**: la única continuidad es la bitácora.
+
+### 10.1 Las piezas
+
+| Archivo | Dónde corre | Qué hace |
+|---|---|---|
+| `automatizacion/ventana-nocturna.ps1` | Windows | El corredor. Vigila la ventana, el candado, el árbol de procesos y la cuota |
+| `automatizacion/ciclo-noche.sh` / `ciclo-ensayo.sh` | WSL | Envoltorios **sin argumentos**. Existen para que ningún argumento lleve espacios — ver §10.2 |
+| `automatizacion/ciclo-wsl.sh` | WSL | Un ciclo: mete la instrucción por stdin y lanza el agente |
+| `automatizacion/cpu_arbol.py` | WSL | Mide si el ciclo trabaja o está muerto de pie |
+| `automatizacion/instruccion-nocturna.md` | — | **La instrucción del agente.** Es el 80% del valor del montaje |
+| `automatizacion/instruccion-ensayo.txt` | — | Instrucción de una línea para el ensayo del mecanismo |
+| `automatizacion/preflight.py` | WSL | Comprueba, antes de irse, que la ventana *puede* funcionar |
+| `automatizacion/SUNRISE.md` | — | Qué revisar el martes por la mañana, en orden |
+
+Nada de esto se commitea desde un ciclo nocturno: la bitácora vive en `~/.ventana/`, y el log del
+corredor en `%LOCALAPPDATA%\ventana-nocturna\`, ambos fuera del árbol versionado.
+
+### 10.2 La adaptación a WSL, y por qué no es cosmética
+
+El kit de operación nocturna asume Windows de punta a punta. Aquí el proyecto vive en WSL2, así que
+el corredor sigue en Windows pero cada ciclo entra a la distro con `wsl.exe`. Tres consecuencias que
+**no se deben "simplificar"**:
+
+1. **La CPU se mide dentro de Linux, no desde Windows.** En WSL2 el trabajo ocurre en una VM ligera:
+ `Get-Process` y `Win32_Process` solo ven `wsl.exe`, un cliente delgado con CPU casi nula. Medir
+ desde Windows daría "mudo" en **todos** los ciclos, incluidos los sanos, y el corte por silencio
+ apagaría la ventana en el primer disparo. Verificado: un árbol que trabaja de verdad mide 8.00 s
+ con `cpu_arbol.py` y **0.00 s** si solo se mira la raíz.
+2. **Matar `wsl.exe` no mata al agente.** El árbol hay que terminarlo también del lado Linux
+ (`pkill`), que es el que consume cuota.
+3. **`bash -lc`, nunca `bash -c`.** El CLI vive en `~/.local/bin`, que solo entra al `PATH` en un
+ shell de *login*. Sin el `-l`, el ciclo muere con «command not found» y cero caracteres de salida
+ — indistinguible de un cuelgue. `ciclo-wsl.sh` además asegura ese `PATH` por su cuenta, para que
+ un ensayo lanzado a mano se comporte igual que el ciclo real.
+4. **Ningún argumento puede contener espacios**, y por eso existen `ciclo-noche.sh` y
+ `ciclo-ensayo.sh`. `Start-Process -ArgumentList` no entrecomilla: el elemento
+ `"bash '' noche"` llegaba partido y `bash -lc bash noche` ejecutaba `bash` a secas,
+ que salía en el acto sin trabajo y sin error. El modo va codificado en la **ruta** del envoltorio,
+ no como argumento. Es el defecto 1 del kit reaparecido una capa más abajo.
+5. **El `.out`/`.err` del ciclo anterior se borra ANTES de lanzar.** Si no, un ciclo que no produce
+ nada reporta la salida del ciclo previo como suya. Costó un falso `ENSAYO-OK` en la única
+ comprobación que existe para detectar que la instrucción no llega.
+
+### 10.3 Verificación ya ejecutada (no leída)
+
+| Comprobación | Resultado |
+|---|---|
+| El corredor parsea en PowerShell 5.1 | **LIMPIO** |
+| El corredor es ASCII puro | **OK**, 20,110 bytes |
+| `cpu_arbol.py` suma la CPU de un **nieto** | **8.00 s** en árbol ocupado, **0.00 s** en dormido |
+| La misma prueba con el recorrido de descendientes mutilado | **ROJA** — la prueba sí detecta el defecto |
+| Interop PowerShell → `wsl.exe` → `git` / `python3` | Responde correctamente |
+| `ciclo-wsl.sh` sin CLI instalado | Aborta con código 4 y mensaje reconocible, no en silencio |
+| CLI de Claude Code en WSL | `2.1.224`, en `~/.local/bin/claude`, visible también por interop |
+| **Ensayo de extremo a extremo desde el corredor** | **`ENSAYO-OK`**, 9 caracteres, 4.3 s |
+| Marcas `8< / >8` alrededor de la salida del agente | Presentes en el log |
+| Limpieza del `finally` | Sin temporales en `~/.ventana/` ni locks en `%LOCALAPPDATA%` |
+| Respaldo del corredor fuera del repo | Creado |
+| Suite de pruebas del proyecto | **98 pruebas en 6.1 s** |
+
+### 10.4 El falso verde que apareció al verificar, y cómo se cazó
+
+La primera corrida del ensayo reportó `ENSAYO-OK` con 9 caracteres y **cerró en menos de un
+segundo**. Un `claude --print` real no es tan rápido: esa era la única señal de que algo iba mal.
+
+Al borrar el estado y repetir, la salida fue **0 caracteres**. El corredor había estado leyendo el
+`.out` que dejó un ensayo manual previo. Debajo había dos defectos encadenados —los puntos 4 y 5 de
+§10.2— y el segundo enmascaraba al primero.
+
+Es exactamente lo que advierte el kit: *un corredor que se lee correcto y no se corrió nunca es un
+corredor que no funciona*, y *una herramienta que nombra una causa falsa es peor que no tener
+ninguna*. Tras corregir ambos, el ensayo tarda 4.3 s y el `ENSAYO-OK` es real.
+
+### 10.5 Antes de irse el lunes
+
+1. `python3 automatizacion/preflight.py` y que salga **verde**.
+2. Ensayo de extremo a extremo (§4.2 del kit), y que la salida del agente diga `ENSAYO-OK`:
+ ```powershell
+ powershell -NoProfile -ExecutionPolicy Bypass -File "\ventana-nocturna.ps1" -Ensayo
+ ```
+3. Dejar el checkout en `feature/AS-XXX-timbrado-cfdi-ingreso`, **no** en la rama base.
+4. Dejar el árbol limpio (o saber qué queda sin commitear).
+5. Dejar los contenedores arriba: `docker compose up -d`.
+
+### 10.6 Lo que solo puede hacer una persona
+
+Registrar la tarea programada y decidir con qué nivel de autorización corre el agente durante
+catorce horas. Un agente no puede —ni debe— registrarse una tarea del sistema ni autorizarse
+herramientas por adelantado.
+
+Está automatizado en `automatizacion/instalar-tarea.ps1`. **En una terminal de PowerShell normal**
+(no hace falta administrador, ver más abajo):
+
+```powershell
+powershell -NoProfile -ExecutionPolicy Bypass -File "\\wsl.localhost\Debian\home\jcedillo\Projects\CRM_AGENTES_CARGA\automatizacion\instalar-tarea.ps1"
+```
+
+Tiene que terminar con `OK: primer disparo el 10/08/2026 17:05:00`. Si dice que `NextRunTime` está
+vacío, la tarea **no va a disparar** y hay que corregir la fecha.
+
+Tres decisiones que se apartan del bloque genérico del kit, y por qué:
+
+1. **El corredor se copia a `%LOCALAPPDATA%\ventana-nocturna\` y la tarea invoca esa copia**, no el
+ archivo del repo. Una ruta `\\wsl.localhost\` solo responde con el servicio de WSL arriba: si la
+ máquina arranca y la tarea dispara antes, el ciclo se pierde sin más rastro que un código de
+ error. Y como el agente cambia de rama durante la noche, invocar el archivo dentro del árbol de
+ git haría correr una versión vieja del corredor (defecto 4 del kit). Fuera del repo, el problema
+ desaparece de raíz.
+ → **Si editas `ventana-nocturna.ps1`, vuelve a correr `instalar-tarea.ps1`.** El preflight
+ compara ambas copias y avisa si se separaron.
+2. **Disparo `-Once` con fecha explícita** (`2026-08-10 17:05`), no `-Daily`. Con `-Daily`,
+ registrarla un viernes por la tarde la dispararía *ese mismo día*.
+3. **Sin `-RunLevel Highest`.** Elevar no aporta nada —lanzar `wsl.exe` no necesita privilegios— y
+ sí puede estorbar: una tarea elevada corre en otro contexto, donde el acceso al perfil de WSL y a
+ las credenciales del agente en `~/.claude` puede no resolverse igual. Efecto secundario útil: no
+ hace falta abrir PowerShell como administrador.
+
+⚠️ **La tarea corre como tu usuario y solo con la sesión iniciada.** El agente necesita tu perfil
+para llegar a WSL, a docker y a sus credenciales; como SYSTEM no funcionaría. **Deja la sesión de
+Windows iniciada el lunes** — bloquear la pantalla está bien; cerrar sesión o apagar, no.
+
+- Para quitarla: `Unregister-ScheduledTask -TaskName 'VentanaNocturna' -Confirm:$false`
+- Para probarla sin esperar: `Start-ScheduledTask -TaskName 'VentanaNocturna'` y mirar el log.
+ Ojo: fuera de la ventana el ciclo registrará `fuera de la ventana; no se lanza nada`, que es la
+ respuesta correcta. Para probar el mecanismo completo, usa el ensayo de §10.5.
+
+### 10.7 Las reglas que cambian de día a noche
+
+| De día, supervisado | De noche, autónomo |
+|---|---|
+| Una duda se pregunta | **Nada espera a nadie.** Solo abrir o mergear un PR requiere una persona |
+| Un bloqueo se plantea | Se anota como BLOQUEADO con su razón en una línea y **se pasa al siguiente** |
+| Un servicio caído se reporta | **Se levanta.** Es la primera tarea, no un bloqueo |
+| El push pide confirmación | **Esta noche no hay push.** El trabajo se queda en commits locales |
+| La prueba manual la hace una persona | Se cubre con pruebas automáticas y se deja escrito el guion para la mañana |
+| Una revisión con varios subagentes vale la pena | **Cuidado con la cuota:** es la misma bolsa para los ~28 ciclos |
+
+Y dos que no se negocian:
+
+- **Un cambio sin regresión en verde no se sube. Nunca.** Levantar el ambiente sirve para poder
+ *verificar*, no para saltarse la verificación.
+- **Ninguna hora se escribe de memoria.** El agente no tiene reloj: si la infiere del trabajo hecho,
+ siempre sale mal. Se lee de `date` o de la fecha de un commit, o no se escribe.
diff --git a/frontend/src/lib/api/fin/index.ts b/frontend/src/lib/api/fin/index.ts
index 0d2bbde..0ce03c0 100644
--- a/frontend/src/lib/api/fin/index.ts
+++ b/frontend/src/lib/api/fin/index.ts
@@ -6,6 +6,7 @@ import { api } from '$lib/api';
export * from './catalogs';
export * from './concepts';
export * from './issuer';
+export * from './stamping';
export type InvoiceStatus = 'borrador' | 'emitida' | 'enviada' | 'en_revision_cliente' | 'pagada' | 'cancelada';
@@ -42,6 +43,8 @@ export interface Invoice {
payment_form_id: number | null;
payment_method_id: number | null;
expedition_zip_code: string | null;
+ /** Modo de timbrado de ESTA factura: 'produccion' emite un CFDI con validez fiscal real. */
+ stamping_mode: 'pruebas' | 'produccion';
tenant_id: number;
company_id: number;
created_at: string;
diff --git a/frontend/src/lib/api/fin/issuer.ts b/frontend/src/lib/api/fin/issuer.ts
index 69cfc9c..aba7b8a 100644
--- a/frontend/src/lib/api/fin/issuer.ts
+++ b/frontend/src/lib/api/fin/issuer.ts
@@ -19,6 +19,11 @@ export interface IssuerSettings {
updated_by: string | null;
created_at: string;
updated_at: string;
+ // ----- Estado del CSD -----
+ // El backend expone si hay certificado y cuál, nunca su contenido ni la contraseña.
+ csd_cert_number: string | null;
+ csd_uploaded_at: string | null;
+ has_csd: boolean;
}
export interface IssuerSettingsInput {
@@ -47,5 +52,36 @@ export const issuerAPI = {
);
if (res.error) throw new Error(res.error);
return res.data!;
+ },
+
+ /**
+ * Sube el par del CSD y su contraseña. El backend valida que la llave corresponda al
+ * certificado antes de guardar nada, y la contraseña queda cifrada: no vuelve a salir.
+ */
+ async uploadCsd(
+ cer: File,
+ key: File,
+ password: string,
+ companyId: number
+ ): Promise {
+ const fd = new FormData();
+ fd.append('cer', cer);
+ fd.append('key', key);
+ fd.append('password', password);
+ const res = await api.request(
+ `/v1/fin/settings/issuer/csd?company_id=${companyId}`,
+ { method: 'POST', body: fd }
+ );
+ if (res.error) throw new Error(typeof res.error === 'string' ? res.error : 'No se pudo cargar el CSD');
+ return res.data!;
+ },
+
+ /** Desvincula el CSD y borra sus archivos del almacenamiento. */
+ async deleteCsd(companyId: number): Promise {
+ const res = await api.delete(
+ `/v1/fin/settings/issuer/csd?company_id=${companyId}`
+ );
+ if (res.error) throw new Error(typeof res.error === 'string' ? res.error : 'No se pudo quitar el CSD');
+ return res.data!;
}
};
diff --git a/frontend/src/lib/api/fin/stamping.ts b/frontend/src/lib/api/fin/stamping.ts
new file mode 100644
index 0000000..f712e65
--- /dev/null
+++ b/frontend/src/lib/api/fin/stamping.ts
@@ -0,0 +1,88 @@
+/**
+ * Cliente API — Timbrado de CFDI ante el PAC.
+ */
+import { api } from '$lib/api';
+
+export type StampingMode = 'pruebas' | 'produccion';
+export type StampStatus = 'pendiente' | 'timbrado' | 'error';
+
+export interface InvoiceStamp {
+ id: number;
+ invoice_id: number;
+ mode: StampingMode;
+ status: StampStatus;
+ uuid: string | null;
+ stamped_at: string | null;
+ pac_rfc: string | null;
+ sat_cert_number: string | null;
+ pac_code: number | null;
+ /** Folios que le quedan a la cuenta del PAC según la última respuesta. */
+ pac_balance: number | null;
+ error_message: string | null;
+ xml_file_key: string | null;
+ created_at: string | null;
+}
+
+/**
+ * El backend responde 422 con la lista COMPLETA de datos fiscales que faltan, en vez de
+ * fallar en el primero. Se conserva como error propio para poder pintarla: aplanarla a un
+ * string obligaría a capturar los faltantes de uno en uno.
+ */
+export class MissingFiscalDataError extends Error {
+ readonly missing: string[];
+ constructor(message: string, missing: string[]) {
+ super(message);
+ this.name = 'MissingFiscalDataError';
+ this.missing = missing;
+ }
+}
+
+function qp(companyId: number): string {
+ return new URLSearchParams({ company_id: String(companyId) }).toString();
+}
+
+/** Extrae el error del cliente base, que para 422 entrega un objeto y no una cadena. */
+function toError(raw: unknown, fallback: string): Error {
+ if (raw && typeof raw === 'object') {
+ const o = raw as { message?: unknown; missing?: unknown; pac_error?: unknown };
+ if (Array.isArray(o.missing)) {
+ return new MissingFiscalDataError(
+ typeof o.message === 'string' ? o.message : 'Faltan datos fiscales para timbrar',
+ o.missing.map(String)
+ );
+ }
+ // Rechazo del PAC: el mensaje útil es el suyo, no el genérico.
+ if (typeof o.pac_error === 'string' && o.pac_error) return new Error(o.pac_error);
+ if (typeof o.message === 'string' && o.message) return new Error(o.message);
+ }
+ return new Error(typeof raw === 'string' && raw ? raw : fallback);
+}
+
+export const stampingAPI = {
+ /**
+ * Timbra la factura. Es idempotente en el backend: si ya tiene timbre lo devuelve sin
+ * volver a llamar al PAC.
+ */
+ stamp: async (id: number, companyId: number): Promise => {
+ const res = await api.post(`/v1/fin/invoices/${id}/stamp?${qp(companyId)}`, {});
+ if (res.error) throw toError(res.error, 'No se pudo timbrar la factura');
+ return res.data as InvoiceStamp;
+ },
+
+ /** Timbre vigente, o `null` si la factura no está timbrada (404 esperado). */
+ get: async (id: number, companyId: number): Promise => {
+ const res = await api.get(`/v1/fin/invoices/${id}/stamp?${qp(companyId)}`);
+ if (res.error) {
+ if (res.status === 404) return null;
+ throw toError(res.error, 'No se pudo consultar el timbre');
+ }
+ return res.data as InvoiceStamp;
+ },
+
+ /** URL firmada del XML timbrado. Caduca, así que se pide en el momento de abrirlo. */
+ xmlUrl: async (id: number, companyId: number): Promise => {
+ const res = await api.get<{ url: string }>(`/v1/fin/invoices/${id}/stamp/xml-url?${qp(companyId)}`);
+ if (res.error) throw toError(res.error, 'No se pudo obtener el XML');
+ return (res.data as { url: string }).url;
+ }
+};
diff --git a/frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte b/frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte
index 8e085ad..9ecb072 100644
--- a/frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte
+++ b/frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte
@@ -1,13 +1,14 @@
@@ -196,5 +240,115 @@
{/if}
+
+
+
+
+
+ Certificado de Sello Digital (CSD)
+
+
+ Es lo que firma los comprobantes ante el SAT. Sin él no se puede timbrar.
+
+
+
+ {#if isNew}
+
+ Primero guarda los datos fiscales del emisor: el certificado se asocia a ellos.
+