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 1d20c52..0d6ce1f 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): @@ -139,3 +144,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 cf6fc41..1daec38 100644 --- a/backend/api/v1/modules/fin/invoices/models.py +++ b/backend/api/v1/modules/fin/invoices/models.py @@ -73,6 +73,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 3d10574..21e5387 100644 --- a/backend/api/v1/modules/fin/invoices/service.py +++ b/backend/api/v1/modules/fin/invoices/service.py @@ -18,6 +18,7 @@ from .dto import ( InvoiceUpdate, PaymentCreate, ) +from . import taxes_service from .models import Invoice, InvoiceItem, Payment from .pdf import build_invoice_pdf @@ -108,16 +109,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) @@ -378,6 +406,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) @@ -394,7 +423,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 ff4be27..a36a8ad 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -103,6 +103,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 = "" + model_config = SettingsConfigDict( env_file=[".env", "../.env"], case_sensitive=True, 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 7881476..3419051 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -41,6 +41,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. +

+ {:else} + {#if issuer?.has_csd} +
+

+ Certificado cargado +

+
+
+
No. de certificado:
+
{issuer.csd_cert_number}
+
+
+
Cargado el:
+
{issuer.csd_uploaded_at}
+
+
+ {#if canEdit} + + {/if} +
+ {/if} + + {#if canEdit} +
+ + + + + + +

+ + + Usa el CSD, no la FIEL: son certificados distintos y la FIEL no + sirve para timbrar. Con la llave privada se puede firmar a nombre de la empresa + ante el SAT, así que trátala como una credencial. + +

+ +
+ +
+
+ {:else if !issuer?.has_csd} +

+ Esta empresa no tiene certificado cargado. Se requiere el permiso de edición para + subirlo. +

+ {/if} + {/if} +
+
{/if}